Flutter

블로그 프로젝트 - 회원가입 폼 상태 관리

whs5758 2025. 8. 22. 19:24

 

CustomAuthTextFormField 코드 수정
import 'package:flutter/material.dart';
import 'package:flutter_blog/_core/constants/size.dart';

class CustomAuthTextFormField extends StatelessWidget {
  final String title; // 라벨 제목
  final String errorText; // 검증 실패시 표시될 에러 메세지
  final Function(String)? onChanged; // 사용자 입력값이 변경될 때 호출되는 콜백 함수
  final bool obscureText;

  CustomAuthTextFormField({
    required this.title,
    this.errorText = "",
    this.onChanged,
    this.obscureText = false,
  }); // 입력값 숨길지 여부 설정

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(title),
        const SizedBox(height: smallGap),
        TextFormField(
          onChanged: onChanged,
          obscureText: obscureText,
          decoration: InputDecoration(
            hintText: "Enter $title",
            errorText: errorText.isEmpty ? null : errorText,
            enabledBorder: OutlineInputBorder(
              // 3. 기본 TextFormField 디자인
              borderRadius: BorderRadius.circular(20),
            ),
            focusedBorder: OutlineInputBorder(
              // 4. 손가락 터치시 TextFormField 디자인
              borderRadius: BorderRadius.circular(20),
            ),
            errorBorder: OutlineInputBorder(
              // 5. 에러발생시 TextFormField 디자인
              borderRadius: BorderRadius.circular(20),
            ),
            focusedErrorBorder: OutlineInputBorder(
              // 6. 에러가 발생 후 손가락을 터치했을 때 TextFormField 디자인
              borderRadius: BorderRadius.circular(20),
            ),
          ),
        ),
      ],
    );
  }
}

 

_core/utils/validator_util.dart - 유효성 검사 로직 분리
import 'package:validators/validators.dart';

String validateUsername(String value) {
  if (value.isEmpty) {
    return "유저네임에 들어갈 수 없습니다.";
  } else if (!isAlphanumeric(value)) {
    return "유저네임에 한글이나 특수 문자가 들어갈 수 없습니다.";
  } else if (value.length > 12) {
    return "유저네임의 길이를 초과하였습니다.";
  } else if (value.length < 3) {
    return "유저네임의 최소 길이는 3자입니다.";
  } else {
    return "";
  }
}

String validatePassword(String value) {
  if (value.isEmpty) {
    return "패스워드 공백이 들어갈 수 없습니다.";
  } else if (value.length > 12) {
    return "패스워드의 길이를 초과하였습니다.";
  } else if (value.length < 4) {
    return "패스워드의 최소 길이는 4자입니다.";
  } else {
    return "";
  }
}

String validateEmail(String value) {
  if (value.isEmpty) {
    return "이메일은 공백이 들어갈 수 없습니다.";
  } else if (!isEmail(value)) {
    return "이메일 형식에 맞지 않습니다.";
  } else {
    return "";
  }
}

String validateTitle(String value) {
  if (value.isEmpty) {
    return "제목은 공백이 들어갈 수 없습니다.";
  } else if (value.length > 30) {
    return "제목의 길이를 초과하였습니다.";
  } else {
    return "";
  }
}

String validateContent(String value) {
  if (value.isEmpty) {
    return "내용은 공백이 들어갈 수 없습니다.";
  } else if (value.length > 500) {
    return "내용의 길이를 초과하였습니다.";
  } else {
    return "";
  }
}

 

회원 가입 폼 상태 관리 클래스들 설계 및 생성
// 복합 창고 설계할 예정

// 1.1 회원 가입 폼 모델 부터 설계 (어떤 데이터를 관리할지 설계)
import 'package:flutter_blog/_core/utils/validator_util.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logger/logger.dart';

/// 상태 결정
class JoinModel {
  final String username;
  final String email;
  final String password;

  // 각 필드의 검증 에러 메세지
  final String usernameError;
  final String emailError;
  final String passwordError;

  JoinModel({
    required this.username,
    required this.email,
    required this.password,
    required this.usernameError,
    required this.emailError,
    required this.passwordError,
  });

  // 불변 객체에서 일부만 변견한 새 객체 생성 편의 기능
  JoinModel copyWith({
    String? username, // null 이면 기존값 유지, 값이 주어지면 변경
    String? email,
    String? password,
    String? usernameError,
    String? emailError,
    String? passwordError,
  }) {
    return JoinModel(
      username: username ?? this.username,
      email: email ?? this.email,
      password: password ?? this.password,
      usernameError: usernameError ?? this.usernameError,
      emailError: emailError ?? this.emailError,
      passwordError: passwordError ?? this.passwordError,
    );
  }

  // 개발용 디버그 코드
  @override
  String toString() {
    return 'JoinModel{username: $username, email: $email, password: $password, usernameError: $usernameError, emailError: $emailError, passwordError: $passwordError}';
  }
}

/// 1.2 창고 매뉴얼 설계
class JoinFormNotifier extends Notifier<JoinModel> {
  // 초기 상태값을 명시해주어야 한다.
  @override
  JoinModel build() {
    return JoinModel(
      username: "",
      email: "",
      password: "",
      usernameError: "",
      emailError: "",
      passwordError: "",
    );
  }

  // 사용자명 입력시 : 즉시 검증 + 상태 업데이트 기능 구현
  void username(String username) {
    final String error = validateUsername(username);
    Logger().d(error);

    state = state.copyWith(
      username: username,
      usernameError: error,
    );
  }

  // 이메일 검증, 상태 업데이트
  void email(String email) {
    String emailError = validateEmail(email);

    if (emailError.trim().isEmpty) {
      Logger().d(email);
    } else {
      Logger().e(emailError);
    }
    state = state.copyWith(email: email, emailError: emailError);
  }

  // 비밀번호 입력시: 즉시 검증 + 상태 업데이트 기능 구현
  void password(String password) {
    String passwordError = validatePassword(password);
    if (passwordError.trim().isEmpty) {
      Logger().d(password);
    } else {
      Logger().d(passwordError);
    }
    state = state.copyWith(password: password, passwordError: passwordError);
  }

  // 최종 검증 - 회원 가입 버튼 누를 동작 처리
  // 최종 검증 - 회원가입 버튼 누를 시 동작 처리
  bool validate() {
    String usernameError = validateUsername(state.username);
    // usernameError = "", usernameError = "4글자이상이야"
    String emailError = validateEmail(state.email);
    String passwordError = validatePassword(state.password);
    if (usernameError.trim().isEmpty &&
        emailError.trim().isEmpty &&
        passwordError.trim().isEmpty) {
      Logger().d("이름 값: ${state.username} / 이메일 값: ${state.email}");
    } else {
      Logger().e(
          "이름 값 오류: $usernameError / 이메일 값 오류: $emailError / 비밀번호 값 오류: $passwordError");
    }
    return usernameError.isEmpty && emailError.isEmpty && passwordError.isEmpty;
  }
}

// 1-3 실제 창고 개설
final joinProvider =
    NotifierProvider<JoinFormNotifier, JoinModel>(() => JoinFormNotifier());

 

JoinForm (UI 와 상태 연결)
import 'package:flutter/material.dart';
import 'package:flutter_blog/data/models/repository/user_repository.dart';
import 'package:flutter_blog/providers/form/join_form_notifier.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../../../../_core/constants/size.dart';
import '../../../../widgets/custom_auth_text_form_field.dart';
import '../../../../widgets/custom_elavated_button.dart';
import '../../../../widgets/custom_text_button.dart';

//
class JoinForm extends ConsumerWidget {
  const JoinForm({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 상태 값 감시
    JoinModel joinModel = ref.watch(joinProvider);
    JoinFormNotifier formNotifier = ref.read(joinProvider.notifier);
    return Form(
      child: Column(
        children: [
          CustomAuthTextFormField(
            title: "Username",
            errorText: joinModel.usernameError,
            onChanged: (value) {
              // 입력값 변경시 상태 업데이트 + 실시간 검증
              print("value : ${value}");
              formNotifier.username(value);
            },
          ),
          const SizedBox(height: mediumGap),
          CustomAuthTextFormField(
            title: "Email",
            errorText: joinModel.emailError,
            onChanged: (value) {
              formNotifier.email(value);
            },
          ),
          const SizedBox(height: mediumGap),
          CustomAuthTextFormField(
            title: "Password",
            obscureText: true,
            errorText: joinModel.passwordError,
            onChanged: (value) {
              formNotifier.password(value);
            },
          ),
          const SizedBox(height: largeGap),
          CustomElevatedButton(
            text: "회원가입",
            click: () {
              // 최종 검증
              bool isValid = formNotifier.validate();
              if (isValid) {
                // TODO - 회원 가입 통신 요청
                /// 테스트 용
                // UserRepository().join(joinModel.username, joinModel.email, joinModel.password);
              } else {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text("유효성 검사 실패입니다")),
                );
              }
            },
          ),
          CustomTextButton(
            text: "로그인 페이지로 이동",
            click: () {
              Navigator.pushNamed(context, "/login");
            },
          ),
        ],
      ),
    );
  }
}