diff --git a/.fvmrc b/.fvmrc new file mode 100644 index 0000000..c300356 --- /dev/null +++ b/.fvmrc @@ -0,0 +1,3 @@ +{ + "flutter": "stable" +} \ No newline at end of file diff --git a/.github/workflows/jusicool_cd.yml b/.github/workflows/jusicool_cd.yml index b86dcd2..b1008b8 100644 --- a/.github/workflows/jusicool_cd.yml +++ b/.github/workflows/jusicool_cd.yml @@ -37,6 +37,9 @@ jobs: - name: Install Dependencies run: flutter pub get + - name: Run Code Generation + run: flutter pub run build_runner build --delete-conflicting-outputs + - name: Analyze project source run: flutter analyze diff --git a/.github/workflows/jusicool_ci.yml b/.github/workflows/jusicool_ci.yml index 93fc108..d6b92f9 100644 --- a/.github/workflows/jusicool_ci.yml +++ b/.github/workflows/jusicool_ci.yml @@ -36,6 +36,9 @@ jobs: - name: Install Dependencies run: flutter pub get + - name: Run Code Generation + run: flutter pub run build_runner build --delete-conflicting-outputs + - name: Analyze project source run: flutter analyze diff --git a/.gitignore b/.gitignore index 8fd601c..59e0586 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,9 @@ pubspec.lock # If you don't generate documentation locally you can remove this line. doc/api/ +# .g.dart files are generated by the Dart build system. +*.g.dart +*.freezed.dart # dotenv environment variables file .env* @@ -39,7 +42,6 @@ doc/api/ ### Flutter ### # Flutter/Dart/Pub related **/doc/api/ -.fvm/flutter_sdk .pub-cache/ .pub/ coverage/ @@ -319,4 +321,7 @@ iOSInjectionProject/ /*.gcno **/xcshareddata/WorkspaceSettings.xcsettings -# End of https://www.toptal.com/developers/gitignore/api/dotenv,flutter,xcode,swift,intellij,dart,git \ No newline at end of file +# End of https://www.toptal.com/developers/gitignore/api/dotenv,flutter,xcode,swift,intellij,dart,git + +# FVM Version Cache +.fvm/ \ No newline at end of file diff --git a/.idea/Jusicool-iOS.iml b/.idea/Jusicool-iOS.iml index c55092e..d9e12e4 100644 --- a/.idea/Jusicool-iOS.iml +++ b/.idea/Jusicool-iOS.iml @@ -18,6 +18,795 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/analysis_options.yaml b/analysis_options.yaml new file mode 100644 index 0000000..1374fca --- /dev/null +++ b/analysis_options.yaml @@ -0,0 +1,6 @@ +analyzer: + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + errors: + invalid_annotation_target: ignore \ No newline at end of file diff --git a/lib/core/config/di/dependencies.dart b/lib/core/config/di/dependencies.dart index 5c80a11..fbc7ff4 100644 --- a/lib/core/config/di/dependencies.dart +++ b/lib/core/config/di/dependencies.dart @@ -3,16 +3,92 @@ import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; import 'package:get_it/get_it.dart'; import 'package:jusicool_ios/core/network/api/api_client.dart'; +import 'package:jusicool_ios/data/user/service/user_api.dart'; +import 'package:jusicool_ios/data/user/data_sources/user_data_source.dart'; +import 'package:jusicool_ios/domain/sign_in/repositories/sign_in_repository.dart'; +import 'package:jusicool_ios/domain/sign_in/usecase/sign_in_usecase.dart'; +import 'package:jusicool_ios/domain/sign_in/usecase/sign_in_usecase_impl.dart'; +import 'package:jusicool_ios/domain/sign_up/repositories/sign_up_repository.dart'; +import 'package:jusicool_ios/domain/sign_up/usecase/sign_up_usecase.dart'; +import 'package:jusicool_ios/domain/sign_up/usecase/sign_up_usecase_impl.dart'; +import '../../../data/user/data_sources/user_data_source_impl.dart'; +import '../../../data/user/repositories/user_repository_impl.dart'; +import '../../../data/user/service/neis_api.dart'; import '../../network/interceptor/cookie_interceptor.dart'; final di = GetIt.instance; -void setDio() { +void _setDio() { try { di.registerLazySingleton(() => dio()); di.registerSingletonAsync(() async => await cookieJar()); + di.registerLazySingleton(() => neis(), instanceName: 'neis'); log('✅ :: DIO DI 성공'); } catch (e) { log("⛔ :: DIO DI 실패 \n$e"); } } + +void _setApi() { + try { + di.registerLazySingleton(() => UserApi(di.get())); + di.registerLazySingleton( + () => NeisApi(di.get(instanceName: 'neis')), + ); + log('✅ :: API DI 성공'); + } catch (e) { + log("⛔ :: API DI 실패 \n$e"); + } +} + +void _setDataSources() { + try { + di.registerLazySingleton( + () => UserDataSourceImpl(di.get(), di.get()), + ); + log('✅ :: DataSources DI 성공'); + } catch (e) { + log("⛔ :: DataSources DI 실패 \n$e"); + } +} + +void _setRepository() { + try { + di.registerLazySingleton( + () => UserRepositoryImpl(di.get()), + ); + di.registerLazySingleton( + () => UserRepositoryImpl(di.get()), + ); + log('✅ :: Repository DI 성공'); + } catch (e) { + log("⛔ :: Repository DI 실패 \n$e"); + } +} + +void _setUseCase() { + try { + di.registerLazySingleton( + () => SignInUseCaseImpl(di.get()), + ); + di.registerLazySingleton( + () => SignUpUseCaseImpl(di.get()), + ); + log('✅ :: UseCase DI 성공'); + } catch (e) { + log("⛔ :: UseCase DI 실패 \n$e"); + } +} + +void setDependencies() { + try { + _setDio(); + _setApi(); + _setDataSources(); + _setRepository(); + _setUseCase(); + log('✅ :: DI 설정 완료'); + } catch (e) { + log("⛔ :: DI 실패 \n$e"); + } +} diff --git a/lib/router.dart b/lib/core/config/router/router.dart similarity index 80% rename from lib/router.dart rename to lib/core/config/router/router.dart index 283bd9d..f0c4ab8 100644 --- a/lib/router.dart +++ b/lib/core/config/router/router.dart @@ -10,8 +10,7 @@ import 'package:jusicool_ios/presentation/sign_up/screens/name_input_screen.dart import 'package:jusicool_ios/presentation/sign_up/screens/password_create_screen.dart'; import 'package:jusicool_ios/presentation/splash/screens/splash_screen.dart'; import 'package:jusicool_ios/presentation/community/screens/community_post_list_screen.dart'; - -import 'main.dart'; +import '../../../main.dart'; class RoutePaths { static const String splash = '/splash'; @@ -45,7 +44,7 @@ class AppRouter { ), GoRoute( path: RoutePaths.login, - builder: (context, state) => const LoginScreen(), + builder: (context, state) => LoginScreen(), ), GoRoute( path: RoutePaths.main, @@ -57,31 +56,15 @@ class AppRouter { ), GoRoute( path: RoutePaths.emailAuth, - builder: (context, state) { - final username = state.extra as String?; - return EmailAuthScreen(username: username ?? ''); - }, + builder: (context, state) => EmailAuthScreen(), ), GoRoute( path: RoutePaths.passwordCreate, - builder: (context, state) { - final extra = state.extra as Map?; - return PasswordCreateScreen( - username: extra?['username'] ?? '', - email: extra?['email'] ?? '', - ); - }, + builder: (context, state) => PasswordCreateScreen(), ), GoRoute( path: RoutePaths.findSchool, - builder: (context, state) { - final extra = state.extra as Map?; - return FindSchoolScreen( - username: extra?['username'] ?? '', - email: extra?['email'] ?? '', - password: extra?['password'] ?? '', - ); - }, + builder: (context, state) => FindSchoolScreen(), ), GoRoute( path: RoutePaths.mainCapital, diff --git a/lib/menu_bottom.dart b/lib/core/config/widget/menu_bottom.dart similarity index 100% rename from lib/menu_bottom.dart rename to lib/core/config/widget/menu_bottom.dart diff --git a/lib/core/network/api/api_client.dart b/lib/core/network/api/api_client.dart index 4256899..170a641 100644 --- a/lib/core/network/api/api_client.dart +++ b/lib/core/network/api/api_client.dart @@ -1,9 +1,11 @@ +import 'dart:developer'; import 'package:cookie_jar/cookie_jar.dart'; import 'package:dio/dio.dart'; import 'package:dio_cookie_manager/dio_cookie_manager.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:get_it/get_it.dart'; +import 'package:jusicool_ios/core/network/interceptor/dio_request_interceptor.dart'; import 'package:pretty_dio_logger/pretty_dio_logger.dart'; import '../interceptor/dio_error_interceptor.dart'; @@ -13,15 +15,19 @@ Dio dio() { bool _homeDebugMode = dotenv.env['HOME_DEBUG_MODE'] == 'true'; if (_baseUrlDev == null || _baseUrlProd == null) { - throw Exception('BASE_URL_DEV or BASE_URL_PROD is not set in .env file'); + log('⛔ :: BASE_URL_DEV or BASE_URL_PROD is not set in .env file'); } - String _baseUrl = + String? _baseUrl = (kReleaseMode || _homeDebugMode) ? _baseUrlProd : _baseUrlDev; Dio dio = Dio( BaseOptions( - baseUrl: _baseUrl, + baseUrl: _baseUrl ?? "", + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + }, connectTimeout: Duration(seconds: 30), receiveTimeout: Duration(seconds: 30), ), @@ -31,15 +37,33 @@ Dio dio() { dio.interceptors.add( PrettyDioLogger( requestHeader: true, - requestBody: true, responseHeader: true, responseBody: true, error: false, compact: true, - maxWidth: 90, enabled: kDebugMode, ), ); dio.interceptors.add(DioErrorInterceptor()); + dio.interceptors.add(DioRequestInterceptor()); + return dio; +} + +Dio neis() { + String? _neisApiKey = dotenv.env['NEIS_API_KEY']; + if (_neisApiKey == null) { + log('⛔ :: NEIS_API_KEY is not set in .env file'); + } + + Dio dio = Dio( + BaseOptions( + baseUrl: 'https://open.neis.go.kr/hub/schoolInfo', + connectTimeout: Duration(seconds: 30), + receiveTimeout: Duration(seconds: 30), + queryParameters: {'KEY': _neisApiKey ?? '', 'Type': 'json'}, + ), + ); + dio.interceptors.add(DioErrorInterceptor()); + dio.interceptors.add(PrettyDioLogger(responseBody: true)); return dio; } diff --git a/lib/core/network/interceptor/dio_error_interceptor.dart b/lib/core/network/interceptor/dio_error_interceptor.dart index 6dee083..aa20744 100644 --- a/lib/core/network/interceptor/dio_error_interceptor.dart +++ b/lib/core/network/interceptor/dio_error_interceptor.dart @@ -1,51 +1,49 @@ -import 'dart:developer'; - import 'package:dio/dio.dart'; class DioErrorInterceptor extends InterceptorsWrapper { @override void onError(DioException err, ErrorInterceptorHandler handler) { - log('⛔ DIO 에러 :: ${err.type}'); + print('⛔ DIO 에러 :: ${err.type}'); switch (err.type) { case DioExceptionType.connectionTimeout: - log('⏳ 연결 시간 초과'); + print('⏳ 연결 시간 초과'); break; case DioExceptionType.sendTimeout: - log('⏳ 전송 시간 초과'); + print('⏳ 전송 시간 초과'); break; case DioExceptionType.receiveTimeout: - log('⏳ 수신 시간 초과'); + print('⏳ 수신 시간 초과'); break; case DioExceptionType.badResponse: switch (err.response?.statusCode) { case 400: - log('🚫 잘못된 요청: ${err.response?.data}'); + print('🚫 잘못된 요청: ${err.response?.data}'); break; case 401: - log('🚫 인증 실패: ${err.response?.data}'); + print('🚫 인증 실패: ${err.response?.data}'); break; case 403: - log('🚫 권한 없음: ${err.response?.data}'); + print('🚫 권한 없음: ${err.response?.data}'); break; case 404: - log('🚫 리소스 없음: ${err.response?.data}'); + print('🚫 리소스 없음: ${err.response?.data}'); break; case 500: - log('🚫 서버 오류: ${err.response?.data}'); + print('🚫 서버 오류: ${err.response?.data}'); break; default: - log('🚫 기타 오류: ${err.response?.data}'); + print('🚫 기타 오류: ${err.response?.data}'); break; } case DioExceptionType.cancel: - log('❌ 요청 취소됨'); + print('❌ 요청 취소됨'); break; case DioExceptionType.connectionError: - log('🚫 인터넷 연결 오류: ${err.message}'); + print('🚫 인터넷 연결 오류: ${err.message}'); break; case DioExceptionType.unknown: default: - log('❓ 알 수 없는 에러: ${err.message}'); + print('❓ 알 수 없는 에러: ${err.message}'); } handler.next(err); } diff --git a/lib/core/network/interceptor/dio_request_interceptor.dart b/lib/core/network/interceptor/dio_request_interceptor.dart new file mode 100644 index 0000000..6683c5b --- /dev/null +++ b/lib/core/network/interceptor/dio_request_interceptor.dart @@ -0,0 +1,13 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; + +class DioRequestInterceptor extends InterceptorsWrapper { + @override + void onRequest(RequestOptions options, RequestInterceptorHandler handler) { + print("╔ Body"); + print("║ ${jsonEncode(options.data)}"); + print("╚${'═' * 90}╝"); + super.onRequest(options, handler); + } +} diff --git a/lib/data/user/data_sources/user_data_source.dart b/lib/data/user/data_sources/user_data_source.dart new file mode 100644 index 0000000..c90cd7c --- /dev/null +++ b/lib/data/user/data_sources/user_data_source.dart @@ -0,0 +1,20 @@ +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_search_school_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_send_email_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/response/sign_up_search_school_response_dto.dart'; +import '../dto/remote/request/sign_in_request_dto.dart'; +import '../dto/remote/request/sign_up_request_dto.dart'; + +abstract class UserDataSource { + Future signIn(SignInRequestDto body); + + Future signUp(SignUpRequestDto body); + + Future sendEmail(SignUpSendEmailRequestDto body); + + Future verifyEmail(SignUpVerifyEmailRequestDto body); + + Future searchSchools( + SignUpSearchSchoolRequestDto schoolName, + ); +} diff --git a/lib/data/user/data_sources/user_data_source_impl.dart b/lib/data/user/data_sources/user_data_source_impl.dart new file mode 100644 index 0000000..0113c13 --- /dev/null +++ b/lib/data/user/data_sources/user_data_source_impl.dart @@ -0,0 +1,43 @@ +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_search_school_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_send_email_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart'; +import 'package:jusicool_ios/data/user/service/neis_api.dart'; +import 'package:jusicool_ios/data/user/service/user_api.dart'; +import 'package:jusicool_ios/data/user/data_sources/user_data_source.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_in_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_request_dto.dart'; +import '../dto/remote/response/sign_up_search_school_response_dto.dart'; + +class UserDataSourceImpl extends UserDataSource { + final UserApi _userApi; + final NeisApi _neisClient; + + UserDataSourceImpl(this._userApi, this._neisClient); + + @override + Future signIn(SignInRequestDto body) async { + return await _userApi.signIn(body); + } + + @override + Future signUp(SignUpRequestDto body) async { + return await _userApi.signUp(body); + } + + @override + Future sendEmail(SignUpSendEmailRequestDto body) async { + return await _userApi.sendEmail(body); + } + + @override + Future verifyEmail(SignUpVerifyEmailRequestDto body) async { + return await _userApi.verifyEmail(body); + } + + @override + Future searchSchools( + SignUpSearchSchoolRequestDto school, + ) async { + return await _neisClient.fetchSchools(school.schoolName); + } +} diff --git a/lib/data/user/dto/local/request/.gitkeep b/lib/data/user/dto/local/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/data/user/dto/local/response/.gitkeep b/lib/data/user/dto/local/response/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/data/user/dto/remote/request/sign_in_request_dto.dart b/lib/data/user/dto/remote/request/sign_in_request_dto.dart new file mode 100644 index 0000000..64c2a05 --- /dev/null +++ b/lib/data/user/dto/remote/request/sign_in_request_dto.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_in_request_dto.g.dart'; + +part 'sign_in_request_dto.freezed.dart'; + +@freezed +abstract class SignInRequestDto with _$SignInRequestDto { + const factory SignInRequestDto({ + required String email, + required String password, + }) = _SignInRequestDto; + + factory SignInRequestDto.fromJson(Map json) => + _$SignInRequestDtoFromJson(json); +} diff --git a/lib/data/user/dto/remote/request/sign_up_request_dto.dart b/lib/data/user/dto/remote/request/sign_up_request_dto.dart new file mode 100644 index 0000000..523e840 --- /dev/null +++ b/lib/data/user/dto/remote/request/sign_up_request_dto.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_request_dto.g.dart'; + +part 'sign_up_request_dto.freezed.dart'; + +@freezed +abstract class SignUpRequestDto with _$SignUpRequestDto { + factory SignUpRequestDto({ + required String email, + required String password, + required String username, + required String school, + }) = _SignUpRequestDto; + + factory SignUpRequestDto.fromJson(Map json) => + _$SignUpRequestDtoFromJson(json); +} diff --git a/lib/data/user/dto/remote/request/sign_up_search_school_request_dto.dart b/lib/data/user/dto/remote/request/sign_up_search_school_request_dto.dart new file mode 100644 index 0000000..d4bc32e --- /dev/null +++ b/lib/data/user/dto/remote/request/sign_up_search_school_request_dto.dart @@ -0,0 +1,15 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_search_school_request_dto.freezed.dart'; + +part 'sign_up_search_school_request_dto.g.dart'; + +@freezed +abstract class SignUpSearchSchoolRequestDto + with _$SignUpSearchSchoolRequestDto { + factory SignUpSearchSchoolRequestDto({@Default("") String schoolName}) = + _SignUpSearchSchoolRequestDto; + + factory SignUpSearchSchoolRequestDto.fromJson(Map json) => + _$SignUpSearchSchoolRequestDtoFromJson(json); +} diff --git a/lib/data/user/dto/remote/request/sign_up_send_email_request_dto.dart b/lib/data/user/dto/remote/request/sign_up_send_email_request_dto.dart new file mode 100644 index 0000000..47d5811 --- /dev/null +++ b/lib/data/user/dto/remote/request/sign_up_send_email_request_dto.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_send_email_request_dto.freezed.dart'; + +part 'sign_up_send_email_request_dto.g.dart'; + +@freezed +abstract class SignUpSendEmailRequestDto with _$SignUpSendEmailRequestDto { + factory SignUpSendEmailRequestDto({required String email}) = + _SignUpSendEmailRequestDto; + + factory SignUpSendEmailRequestDto.fromJson(Map json) => + _$SignUpSendEmailRequestDtoFromJson(json); +} diff --git a/lib/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart b/lib/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart new file mode 100644 index 0000000..5352509 --- /dev/null +++ b/lib/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_verify_email_request_dto.freezed.dart'; + +part 'sign_up_verify_email_request_dto.g.dart'; + +@freezed +abstract class SignUpVerifyEmailRequestDto with _$SignUpVerifyEmailRequestDto { + factory SignUpVerifyEmailRequestDto({ + required String email, + required String code, + }) = _SignUpVerifyEmailRequestDto; + + factory SignUpVerifyEmailRequestDto.fromJson(Map json) => + _$SignUpVerifyEmailRequestDtoFromJson(json); +} diff --git a/lib/data/user/dto/remote/response/sign_up_search_school_response_dto.dart b/lib/data/user/dto/remote/response/sign_up_search_school_response_dto.dart new file mode 100644 index 0000000..bc4bf9b --- /dev/null +++ b/lib/data/user/dto/remote/response/sign_up_search_school_response_dto.dart @@ -0,0 +1,37 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_search_school_response_dto.freezed.dart'; + +part 'sign_up_search_school_response_dto.g.dart'; + +@freezed +abstract class SignUpSearchSchoolResponseDto + with _$SignUpSearchSchoolResponseDto { + const factory SignUpSearchSchoolResponseDto({ + @JsonKey(name: 'schoolInfo') required List schoolInfo, + }) = _SignUpSearchSchoolResponseDto; + + factory SignUpSearchSchoolResponseDto.fromJson( + Map json, + ) => _$SignUpSearchSchoolResponseDtoFromJson(json); +} + +@freezed +abstract class SchoolInfoDto with _$SchoolInfoDto { + const factory SchoolInfoDto({List? head, List? row}) = + _SchoolInfoDto; + + factory SchoolInfoDto.fromJson(Map json) => + _$SchoolInfoDtoFromJson(json); +} + +@freezed +abstract class SchoolRowDto with _$SchoolRowDto { + const factory SchoolRowDto({ + @JsonKey(name: 'SCHUL_NM') required String schoolName, + @JsonKey(name: 'ORG_RDNMA') required String schoolAddress, + }) = _SchoolRowDto; + + factory SchoolRowDto.fromJson(Map json) => + _$SchoolRowDtoFromJson(json); +} diff --git a/lib/data/user/mappers/local/request/.gitkeep b/lib/data/user/mappers/local/request/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/data/user/mappers/local/response/.gitkeep b/lib/data/user/mappers/local/response/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/data/user/mappers/remote/request/sign_in_request_mapper.dart b/lib/data/user/mappers/remote/request/sign_in_request_mapper.dart new file mode 100644 index 0000000..485e922 --- /dev/null +++ b/lib/data/user/mappers/remote/request/sign_in_request_mapper.dart @@ -0,0 +1,10 @@ +import 'package:jusicool_ios/data/user/dto/remote/request/sign_in_request_dto.dart'; +import 'package:jusicool_ios/domain/sign_in/entity/sign_in_entity.dart'; + +class SignInRequestMapper { + static SignInRequestDto toDto(SignInEntity entity) => + SignInRequestDto(email: entity.email, password: entity.password); + + static SignInEntity toEntity(SignInRequestDto dto) => + SignInEntity(email: dto.email, password: dto.password); +} diff --git a/lib/data/user/mappers/remote/request/sign_up_request_mapper.dart b/lib/data/user/mappers/remote/request/sign_up_request_mapper.dart new file mode 100644 index 0000000..1dec46a --- /dev/null +++ b/lib/data/user/mappers/remote/request/sign_up_request_mapper.dart @@ -0,0 +1,18 @@ +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_request_dto.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_entity.dart'; + +class SignUpRequestMapper { + static SignUpRequestDto toDto(SignUpEntity entity) => SignUpRequestDto( + email: entity.email, + password: entity.password, + username: entity.name, + school: entity.school, + ); + + static SignUpEntity toEntity(SignUpRequestDto dto) => SignUpEntity( + email: dto.email, + password: dto.password, + name: dto.username, + school: dto.school, + ); +} diff --git a/lib/data/user/mappers/remote/request/sign_up_search_school_request_mapper.dart b/lib/data/user/mappers/remote/request/sign_up_search_school_request_mapper.dart new file mode 100644 index 0000000..e165842 --- /dev/null +++ b/lib/data/user/mappers/remote/request/sign_up_search_school_request_mapper.dart @@ -0,0 +1,7 @@ +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_search_school_request_dto.dart'; + +class SignUpSearchSchoolRequestMapper { + static SignUpSearchSchoolRequestDto toDto(String entity) { + return SignUpSearchSchoolRequestDto(schoolName: entity); + } +} diff --git a/lib/data/user/mappers/remote/request/sign_up_send_email_request_mapper.dart b/lib/data/user/mappers/remote/request/sign_up_send_email_request_mapper.dart new file mode 100644 index 0000000..8cd512e --- /dev/null +++ b/lib/data/user/mappers/remote/request/sign_up_send_email_request_mapper.dart @@ -0,0 +1,8 @@ +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_email_entity.dart'; + +import '../../../dto/remote/request/sign_up_send_email_request_dto.dart'; + +class SignUpSendEmailRequestMapper { + static SignUpSendEmailRequestDto toDto(SignUpEmailEntity entity) => + SignUpSendEmailRequestDto(email: entity.email); +} diff --git a/lib/data/user/mappers/remote/request/sign_up_verify_email_request_mapper.dart b/lib/data/user/mappers/remote/request/sign_up_verify_email_request_mapper.dart new file mode 100644 index 0000000..8231896 --- /dev/null +++ b/lib/data/user/mappers/remote/request/sign_up_verify_email_request_mapper.dart @@ -0,0 +1,8 @@ +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart'; + +import '../../../../../domain/sign_up/entity/sign_up_email_entity.dart'; + +class SignUpVerifyEmailRequestMapper { + static SignUpVerifyEmailRequestDto toDto(SignUpEmailEntity entity) => + SignUpVerifyEmailRequestDto(email: entity.email, code: entity.verifyCode); +} diff --git a/lib/data/user/mappers/remote/response/sign_up_search_school_response_mapper.dart b/lib/data/user/mappers/remote/response/sign_up_search_school_response_mapper.dart new file mode 100644 index 0000000..ec6456c --- /dev/null +++ b/lib/data/user/mappers/remote/response/sign_up_search_school_response_mapper.dart @@ -0,0 +1,24 @@ +import 'package:jusicool_ios/data/user/dto/remote/response/sign_up_search_school_response_dto.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_search_school_entity.dart'; + +class SignUpSearchSchoolResponseMapper { + static SignUpSearchSchoolResponseEntity toEntity( + SignUpSearchSchoolResponseDto dto, + ) { + return SignUpSearchSchoolResponseEntity( + schoolInfo: + dto.schoolInfo.map((infoDto) { + return SchoolInfoEntity( + head: infoDto.head, + row: + infoDto.row?.map((rowDto) { + return SchoolRowEntity( + schoolName: rowDto.schoolName, + schoolAddress: rowDto.schoolAddress, + ); + }).toList(), + ); + }).toList(), + ); + } +} diff --git a/lib/data/user/repositories/user_repository_impl.dart b/lib/data/user/repositories/user_repository_impl.dart new file mode 100644 index 0000000..8368023 --- /dev/null +++ b/lib/data/user/repositories/user_repository_impl.dart @@ -0,0 +1,60 @@ +import 'package:jusicool_ios/data/user/data_sources/user_data_source.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_in_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_search_school_request_dto.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart'; +import 'package:jusicool_ios/data/user/mappers/remote/request/sign_in_request_mapper.dart'; +import 'package:jusicool_ios/data/user/mappers/remote/request/sign_up_request_mapper.dart'; +import 'package:jusicool_ios/data/user/mappers/remote/request/sign_up_send_email_request_mapper.dart'; +import 'package:jusicool_ios/data/user/mappers/remote/request/sign_up_verify_email_request_mapper.dart'; +import 'package:jusicool_ios/data/user/mappers/remote/response/sign_up_search_school_response_mapper.dart'; +import 'package:jusicool_ios/domain/sign_in/entity/sign_in_entity.dart'; +import 'package:jusicool_ios/domain/sign_in/repositories/sign_in_repository.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_email_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_search_school_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/repositories/sign_up_repository.dart'; +import '../dto/remote/request/sign_up_send_email_request_dto.dart'; +import '../mappers/remote/request/sign_up_search_school_request_mapper.dart'; + +class UserRepositoryImpl implements SignInRepository, SignUpRepository { + final UserDataSource _userDataSource; + + UserRepositoryImpl(this._userDataSource); + + @override + Future signIn(SignInEntity body) async { + final SignInRequestDto request = SignInRequestMapper.toDto(body); + return await _userDataSource.signIn(request); + } + + @override + Future signUp(SignUpEntity body) async { + final SignUpRequestDto request = SignUpRequestMapper.toDto(body); + return await _userDataSource.signUp(request); + } + + @override + Future sendEmail(SignUpEmailEntity body) async { + final SignUpSendEmailRequestDto request = + SignUpSendEmailRequestMapper.toDto(body); + return await _userDataSource.sendEmail(request); + } + + @override + Future verifyEmail(SignUpEmailEntity entity) async { + final SignUpVerifyEmailRequestDto request = + SignUpVerifyEmailRequestMapper.toDto(entity); + return await _userDataSource.verifyEmail(request); + } + + @override + Future searchSchool( + String schoolName, + ) async { + final SignUpSearchSchoolRequestDto request = + SignUpSearchSchoolRequestMapper.toDto(schoolName); + final response = await _userDataSource.searchSchools(request); + return SignUpSearchSchoolResponseMapper.toEntity(response); + } +} diff --git a/lib/data/user/service/neis_api.dart b/lib/data/user/service/neis_api.dart new file mode 100644 index 0000000..e034596 --- /dev/null +++ b/lib/data/user/service/neis_api.dart @@ -0,0 +1,15 @@ +import 'package:dio/dio.dart'; +import 'package:retrofit/retrofit.dart'; +import '../dto/remote/response/sign_up_search_school_response_dto.dart'; + +part 'neis_api.g.dart'; + +@RestApi() +abstract class NeisApi { + factory NeisApi(Dio dio, {String baseUrl}) = _NeisApi; + + @GET('/') + Future fetchSchools( + @Query('SCHUL_NM') String schoolName, + ); +} diff --git a/lib/data/user/service/user_api.dart b/lib/data/user/service/user_api.dart new file mode 100644 index 0000000..f2dece3 --- /dev/null +++ b/lib/data/user/service/user_api.dart @@ -0,0 +1,25 @@ +import 'package:dio/dio.dart'; +import 'package:jusicool_ios/data/user/dto/remote/request/sign_up_verify_email_request_dto.dart'; +import 'package:retrofit/retrofit.dart'; +import '../dto/remote/request/sign_in_request_dto.dart'; +import '../dto/remote/request/sign_up_request_dto.dart'; +import '../dto/remote/request/sign_up_send_email_request_dto.dart'; + +part 'user_api.g.dart'; + +@RestApi() +abstract class UserApi { + factory UserApi(Dio dio, {String baseUrl}) = _UserApi; + + @POST('/user/signin') + Future signIn(@Body() SignInRequestDto body); + + @POST('/user/signup') + Future signUp(@Body() SignUpRequestDto body); + + @POST('/user/email/send') + Future sendEmail(@Body() SignUpSendEmailRequestDto body); + + @POST('/user/email/verify') + Future verifyEmail(@Body() SignUpVerifyEmailRequestDto body); +} diff --git a/lib/data/models/my_assets.dart b/lib/domain/my_capital/entities/my_assets.dart similarity index 100% rename from lib/data/models/my_assets.dart rename to lib/domain/my_capital/entities/my_assets.dart diff --git a/lib/domain/my_capital/repositories/.gitkeep b/lib/domain/my_capital/repositories/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/domain/my_capital/usecase/.gitkeep b/lib/domain/my_capital/usecase/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/lib/domain/sign_in/entity/sign_in_entity.dart b/lib/domain/sign_in/entity/sign_in_entity.dart new file mode 100644 index 0000000..e544e9f --- /dev/null +++ b/lib/domain/sign_in/entity/sign_in_entity.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_in_entity.freezed.dart'; + +part 'sign_in_entity.g.dart'; + +@freezed +abstract class SignInEntity with _$SignInEntity { + factory SignInEntity({required String email, required String password}) = + _SignInEntity; + + factory SignInEntity.fromJson(Map json) => + _$SignInEntityFromJson(json); +} diff --git a/lib/domain/sign_in/repositories/sign_in_repository.dart b/lib/domain/sign_in/repositories/sign_in_repository.dart new file mode 100644 index 0000000..0c64680 --- /dev/null +++ b/lib/domain/sign_in/repositories/sign_in_repository.dart @@ -0,0 +1,5 @@ +import 'package:jusicool_ios/domain/sign_in/entity/sign_in_entity.dart'; + +abstract class SignInRepository { + Future signIn(SignInEntity entity); +} diff --git a/lib/domain/sign_in/usecase/sign_in_usecase.dart b/lib/domain/sign_in/usecase/sign_in_usecase.dart new file mode 100644 index 0000000..46fbbcd --- /dev/null +++ b/lib/domain/sign_in/usecase/sign_in_usecase.dart @@ -0,0 +1,5 @@ +import 'package:jusicool_ios/domain/sign_in/entity/sign_in_entity.dart'; + +abstract class SignInUseCase { + Future signIn(SignInEntity entity); +} diff --git a/lib/domain/sign_in/usecase/sign_in_usecase_impl.dart b/lib/domain/sign_in/usecase/sign_in_usecase_impl.dart new file mode 100644 index 0000000..7efb2aa --- /dev/null +++ b/lib/domain/sign_in/usecase/sign_in_usecase_impl.dart @@ -0,0 +1,14 @@ +import 'package:jusicool_ios/domain/sign_in/entity/sign_in_entity.dart'; +import 'package:jusicool_ios/domain/sign_in/usecase/sign_in_usecase.dart'; +import '../repositories/sign_in_repository.dart'; + +class SignInUseCaseImpl extends SignInUseCase { + final SignInRepository _signInRepository; + + SignInUseCaseImpl(this._signInRepository); + + @override + Future signIn(SignInEntity entity) async { + return await _signInRepository.signIn(entity); + } +} diff --git a/lib/domain/sign_up/entity/sign_up_email_entity.dart b/lib/domain/sign_up/entity/sign_up_email_entity.dart new file mode 100644 index 0000000..38ebbd9 --- /dev/null +++ b/lib/domain/sign_up/entity/sign_up_email_entity.dart @@ -0,0 +1,16 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_email_entity.freezed.dart'; + +part 'sign_up_email_entity.g.dart'; + +@freezed +abstract class SignUpEmailEntity with _$SignUpEmailEntity { + const factory SignUpEmailEntity({ + required String email, + required String verifyCode, + }) = _SignUpEmailEntity; + + factory SignUpEmailEntity.fromJson(Map json) => + _$SignUpEmailEntityFromJson(json); +} diff --git a/lib/domain/sign_up/entity/sign_up_entity.dart b/lib/domain/sign_up/entity/sign_up_entity.dart new file mode 100644 index 0000000..b65a9aa --- /dev/null +++ b/lib/domain/sign_up/entity/sign_up_entity.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_entity.freezed.dart'; + +part 'sign_up_entity.g.dart'; + +@freezed +abstract class SignUpEntity with _$SignUpEntity { + const factory SignUpEntity({ + required String email, + required String password, + required String name, + required String school, + }) = _SignUpEntity; + + factory SignUpEntity.fromJson(Map json) => + _$SignUpEntityFromJson(json); +} diff --git a/lib/domain/sign_up/entity/sign_up_search_school_entity.dart b/lib/domain/sign_up/entity/sign_up_search_school_entity.dart new file mode 100644 index 0000000..45ed788 --- /dev/null +++ b/lib/domain/sign_up/entity/sign_up_search_school_entity.dart @@ -0,0 +1,39 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_search_school_entity.freezed.dart'; + +part 'sign_up_search_school_entity.g.dart'; + +@freezed +abstract class SignUpSearchSchoolResponseEntity + with _$SignUpSearchSchoolResponseEntity { + const factory SignUpSearchSchoolResponseEntity({ + @JsonKey(name: 'schoolInfo') required List schoolInfo, + }) = _SignUpSearchSchoolResponseEntity; + + factory SignUpSearchSchoolResponseEntity.fromJson( + Map json, + ) => _$SignUpSearchSchoolResponseEntityFromJson(json); +} + +@freezed +abstract class SchoolInfoEntity with _$SchoolInfoEntity { + const factory SchoolInfoEntity({ + List? head, + List? row, + }) = _SchoolInfoEntity; + + factory SchoolInfoEntity.fromJson(Map json) => + _$SchoolInfoEntityFromJson(json); +} + +@freezed +abstract class SchoolRowEntity with _$SchoolRowEntity { + const factory SchoolRowEntity({ + @JsonKey(name: 'SCHUL_NM') required String schoolName, + @JsonKey(name: 'ORG_RDNMA') required String schoolAddress, + }) = _SchoolRowEntity; + + factory SchoolRowEntity.fromJson(Map json) => + _$SchoolRowEntityFromJson(json); +} diff --git a/lib/domain/sign_up/repositories/sign_up_repository.dart b/lib/domain/sign_up/repositories/sign_up_repository.dart new file mode 100644 index 0000000..a60c975 --- /dev/null +++ b/lib/domain/sign_up/repositories/sign_up_repository.dart @@ -0,0 +1,14 @@ +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_email_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_entity.dart'; + +import '../entity/sign_up_search_school_entity.dart'; + +abstract class SignUpRepository { + Future signUp(SignUpEntity entity); + + Future sendEmail(SignUpEmailEntity entity); + + Future verifyEmail(SignUpEmailEntity entity); + + Future searchSchool(String schoolName); +} diff --git a/lib/domain/sign_up/usecase/sign_up_usecase.dart b/lib/domain/sign_up/usecase/sign_up_usecase.dart new file mode 100644 index 0000000..475f9e8 --- /dev/null +++ b/lib/domain/sign_up/usecase/sign_up_usecase.dart @@ -0,0 +1,13 @@ +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_email_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_search_school_entity.dart'; + +abstract class SignUpUseCase { + Future signUp(SignUpEntity entity); + + Future verifyEmail(SignUpEmailEntity entity); + + Future sendEmail(SignUpEmailEntity entity); + + Future searchSchool(String schoolName); +} diff --git a/lib/domain/sign_up/usecase/sign_up_usecase_impl.dart b/lib/domain/sign_up/usecase/sign_up_usecase_impl.dart new file mode 100644 index 0000000..0eaa2aa --- /dev/null +++ b/lib/domain/sign_up/usecase/sign_up_usecase_impl.dart @@ -0,0 +1,32 @@ +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_email_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_search_school_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/usecase/sign_up_usecase.dart'; + +import '../repositories/sign_up_repository.dart'; + +class SignUpUseCaseImpl extends SignUpUseCase { + final SignUpRepository _signUpRepository; + + SignUpUseCaseImpl(this._signUpRepository); + + @override + Future signUp(SignUpEntity entity) { + return _signUpRepository.signUp(entity); + } + + @override + Future sendEmail(SignUpEmailEntity entity) { + return _signUpRepository.sendEmail(entity); + } + + @override + Future verifyEmail(SignUpEmailEntity entity) { + return _signUpRepository.verifyEmail(entity); + } + + @override + Future searchSchool(String schoolName) { + return _signUpRepository.searchSchool(schoolName); + } +} diff --git a/lib/main.dart b/lib/main.dart index f03c36f..dbe5db4 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,20 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:jusicool_design_system/src/core/theme/colors/color_palette.dart'; import 'package:jusicool_ios/core/config/di/dependencies.dart'; -import 'package:jusicool_ios/menu_bottom.dart'; -import 'package:jusicool_ios/router.dart'; - +import 'core/config/router/router.dart'; import 'core/config/theme/app_theme.dart'; +import 'core/config/widget/menu_bottom.dart'; void main() async { WidgetsFlutterBinding.ensureInitialized(); await dotenv.load(fileName: '.env'); - setDio(); + setDependencies(); await di.allReady(); SystemChrome.setSystemUIOverlayStyle( @@ -26,7 +26,7 @@ void main() async { ), ); - runApp(const MyApp()); + runApp(ProviderScope(child: const MyApp())); } class MyApp extends StatelessWidget { diff --git a/lib/presentation/my_capital/screens/my_assets_screen.dart b/lib/presentation/my_capital/screens/my_assets_screen.dart index 6ba4dc5..3fbfc57 100644 --- a/lib/presentation/my_capital/screens/my_assets_screen.dart +++ b/lib/presentation/my_capital/screens/my_assets_screen.dart @@ -6,7 +6,7 @@ import 'package:intl/intl.dart'; import 'package:jusicool_design_system/src/core/theme/texts/typography.dart'; import 'package:jusicool_design_system/src/core/theme/colors/color_palette.dart'; import 'package:jusicool_ios/presentation/my_capital/widgets/my_asset_tile.dart'; -import '../../../data/models/my_assets.dart'; +import '../../../domain/my_capital/entities/my_assets.dart'; class MyAssetsScreen extends StatefulWidget { const MyAssetsScreen({super.key}); diff --git a/lib/presentation/sign_in/controller/sign_in_controller.dart b/lib/presentation/sign_in/controller/sign_in_controller.dart new file mode 100644 index 0000000..d9223f1 --- /dev/null +++ b/lib/presentation/sign_in/controller/sign_in_controller.dart @@ -0,0 +1,112 @@ +import 'package:email_validator/email_validator.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:jusicool_ios/core/config/router/router.dart'; +import 'package:jusicool_ios/domain/sign_in/usecase/sign_in_usecase.dart'; +import '../../../core/config/di/dependencies.dart'; +import '../mapper/sign_in_mapper.dart'; +import '../state/sign_in_state.dart'; + +final signInControllerProvider = + StateNotifierProvider( + (ref) => UserSignInController(di.get()), + ); + +class UserSignInController extends StateNotifier { + final SignInUseCase _signInUseCase; + + UserSignInController(this._signInUseCase) : super(SignInState()) { + _emailController.addListener(() { + _setEmail(_emailController.text); + }); + _passwordController.addListener(() { + _setPassword(_passwordController.text); + }); + } + + bool _isValidPassword(String password) { + if (password.length < 8 || password.length > 13) return false; + + final hasLetter = RegExp(r'[A-Za-z]').hasMatch(password); + final hasNumber = RegExp(r'\d').hasMatch(password); + final hasSpecial = RegExp(r'[@$!%*?&]').hasMatch(password); + + int satisfiedConditions = + [hasLetter, hasNumber, hasSpecial].where((e) => e).length; + + return satisfiedConditions >= 2; + } + + final TextEditingController _emailController = TextEditingController(); + final TextEditingController _passwordController = TextEditingController(); + + TextEditingController get emailController => _emailController; + + TextEditingController get passwordController => _passwordController; + + void _setEmail(String email) { + state = state.copyWith(email: email); + if (email.isNotEmpty && EmailValidator.validate(email)) { + _clearError(); + } else { + _setEnableButton(false); + } + } + + void _setPassword(String password) { + state = state.copyWith(password: password); + if (password.isNotEmpty && _isValidPassword(password)) { + _clearError(); + } else { + _setEnableButton(false); + } + } + + void _setError(String message) { + state = state.copyWith(hasError: true, errorMessage: message); + } + + void _clearError() { + state = state.copyWith(hasError: false, errorMessage: ""); + _setEnableButton(true); + } + + void _setEnableButton(bool enable) { + state = state.copyWith(enableButton: enable); + } + + void signIn(BuildContext context) { + if (state.email.isEmpty || state.password.isEmpty) { + _setError("이메일과 비밀번호를 입력해주세요."); + return; + } + if (!EmailValidator.validate(state.email)) { + _setError("유효한 이메일을 입력해주세요."); + return; + } + if (!_isValidPassword(state.password)) { + _setError("비밀번호는 8~13자이며, 문자, 숫자, 특수문자 중 2가지 이상 포함해야 합니다."); + return; + } + + _clearError(); + + final request = SignInMapper.toEntity(state); + _signInUseCase + .signIn(request) + .then((_) { + context.pushReplacement(RoutePaths.main); + }) + .catchError((error) { + _setError("아이디와 비밀번호를 다시 한 번 확인해주세요"); + }); + } + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } +} diff --git a/lib/presentation/sign_in/mapper/sign_in_mapper.dart b/lib/presentation/sign_in/mapper/sign_in_mapper.dart new file mode 100644 index 0000000..69f868c --- /dev/null +++ b/lib/presentation/sign_in/mapper/sign_in_mapper.dart @@ -0,0 +1,7 @@ +import 'package:jusicool_ios/domain/sign_in/entity/sign_in_entity.dart'; +import 'package:jusicool_ios/presentation/sign_in/state/sign_in_state.dart'; + +class SignInMapper { + static SignInEntity toEntity(SignInState state) => + SignInEntity(email: state.email, password: state.password); +} diff --git a/lib/presentation/sign_in/screens/login_screen.dart b/lib/presentation/sign_in/screens/login_screen.dart index b10dc56..b114c1d 100644 --- a/lib/presentation/sign_in/screens/login_screen.dart +++ b/lib/presentation/sign_in/screens/login_screen.dart @@ -1,195 +1,20 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:go_router/go_router.dart'; import 'package:jusicool_design_system/jusicool_design_system.dart'; -import 'package:email_validator/email_validator.dart'; -import 'package:jusicool_ios/presentation/sign_in/widgets/input_field.dart'; -import 'package:jusicool_ios/router.dart'; +import 'package:jusicool_ios/presentation/sign_in/screens/widgets/input_field.dart'; import '../../sign_up/screens/name_input_screen.dart'; +import '../controller/sign_in_controller.dart'; -class LoginScreen extends StatefulWidget { - const LoginScreen({super.key}); +class LoginScreen extends ConsumerWidget { + LoginScreen({super.key}); @override - State createState() => _LoginScreenState(); -} - -class _LoginScreenState extends State { - final TextEditingController _emailController = TextEditingController(); - final TextEditingController _passwordController = TextEditingController(); - - bool showEmailError = false; - String emailErrorMessage = ''; - bool showPasswordError = false; - String passwordErrorMessage = ''; - bool showLoginError = false; - String loginErrorMessage = ''; - - static const double FIELD_HEIGHT = 56.0; - static const double FORM_WIDTH = 312.0; - - /// ==================================== - final List> database = [ - {'email': 'admin@admin.com', 'password': '12341234!'}, - {'email': 's24001@gsm.hs.kr', 'password': '12345678!'}, - ]; - - /// ==================================== - - // 공통 에러 처리 함수 - void setError({ - required bool emailError, - required String emailMsg, - required bool passwordError, - required String passwordMsg, - required bool loginError, - required String loginMsg, - }) { - setState(() { - showEmailError = emailError; - emailErrorMessage = emailMsg; - showPasswordError = passwordError; - passwordErrorMessage = passwordMsg; - showLoginError = loginError; - loginErrorMessage = loginMsg; - }); - } - - // 텍스트 필드 스타일을 관리하는 공통 함수 - InputDecoration getInputDecoration(String hint, bool hasError) { - return InputDecoration( - hintText: hint, - hintStyle: JusicoolTypography.bodySmall.copyWith( - color: hasError ? JusicoolColor.error : JusicoolColor.gray500, - ), - contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 18.h), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: BorderSide( - color: hasError ? JusicoolColor.error : JusicoolColor.gray300, - width: 1.w, - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: BorderSide( - color: hasError ? JusicoolColor.error : JusicoolColor.main, - width: 2.w, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: BorderSide(color: JusicoolColor.error, width: 1.w), - ), - focusedErrorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: BorderSide(color: JusicoolColor.error, width: 2.w), - ), - ); - } - - bool isValidPassword(String password) { - if (password.length < 8 || password.length > 13) return false; - - final hasLetter = RegExp(r'[A-Za-z]').hasMatch(password); - final hasNumber = RegExp(r'\d').hasMatch(password); - final hasSpecial = RegExp(r'[@$!%*?&]').hasMatch(password); - - int satisfiedConditions = - [hasLetter, hasNumber, hasSpecial].where((e) => e).length; - - return satisfiedConditions >= 2; - } - - void validateEmail(String email) { - if (email.isEmpty || EmailValidator.validate(email)) { - setError( - emailError: false, - emailMsg: '', - passwordError: showPasswordError, - passwordMsg: passwordErrorMessage, - loginError: showLoginError, - loginMsg: loginErrorMessage, - ); - } else { - setError( - emailError: true, - emailMsg: '유효한 이메일 주소를 입력해주세요.', - passwordError: showPasswordError, - passwordMsg: passwordErrorMessage, - loginError: showLoginError, - loginMsg: loginErrorMessage, - ); - } - } - - void validatePassword(String password) { - if (password.isEmpty || isValidPassword(password)) { - setError( - emailError: showEmailError, - emailMsg: emailErrorMessage, - passwordError: false, - passwordMsg: '', - loginError: showLoginError, - loginMsg: loginErrorMessage, - ); - } else { - setError( - emailError: showEmailError, - emailMsg: emailErrorMessage, - passwordError: true, - passwordMsg: '영문, 숫자, 특수문자 중 2개 이상 조합으로 8글자 이상.', - loginError: showLoginError, - loginMsg: loginErrorMessage, - ); - } - } - - void handleLogin() { - final email = _emailController.text; - final password = _passwordController.text; - - final isEmailValid = EmailValidator.validate(email); - final isPasswordValid = isValidPassword(password); - - if (!isEmailValid || !isPasswordValid) { - setError( - emailError: !isEmailValid, - emailMsg: !isEmailValid ? '유효한 이메일 주소를 입력해주세요.' : '', - passwordError: !isPasswordValid, - passwordMsg: - !isPasswordValid ? '영문, 숫자, 특수문자 중 2개 이상 조합으로 8글자 이상.' : '', - loginError: false, - loginMsg: '', - ); - return; - } - - final user = database.firstWhere( - (user) => user['email'] == email && user['password'] == password, - orElse: () => {}, - ); - - if (user.isNotEmpty) { - context.pushReplacement(RoutePaths.main); - } else { - setError( - emailError: false, - emailMsg: '', - passwordError: false, - passwordMsg: '', - loginError: true, - loginMsg: '아이디와 비밀번호를 다시 한 번 확인해주세요', - ); - } - } - - @override - Widget build(BuildContext context) { - final isFormFilled = - _emailController.text.isNotEmpty && _passwordController.text.isNotEmpty; - + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(signInControllerProvider); + final provider = ref.watch(signInControllerProvider.notifier); return Scaffold( + resizeToAvoidBottomInset: false, backgroundColor: JusicoolColor.white, body: Padding( padding: EdgeInsets.fromLTRB(24.w, 112.h, 24.w, 84.h), @@ -215,44 +40,18 @@ class _LoginScreenState extends State { children: [ InputField( label: '이메일', - controller: _emailController, + controller: provider.emailController, hint: '이메일을 입력해주세요', - hasError: showEmailError || showLoginError, - errorMessage: emailErrorMessage, - onChanged: validateEmail, + hasError: state.hasError, obscureText: false, - width: FORM_WIDTH.w, - height: FIELD_HEIGHT.h, - getInputDecoration: getInputDecoration, - showLoginError: showLoginError, - clearLoginError: () { - setState(() { - showLoginError = false; - loginErrorMessage = ''; - }); - }, ), InputField( label: '비밀번호', - controller: _passwordController, + controller: provider.passwordController, hint: '비밀번호를 입력해주세요', - hasError: showPasswordError || showLoginError, - errorMessage: - showPasswordError - ? passwordErrorMessage - : (showLoginError ? loginErrorMessage : ''), + errorMessage: state.errorMessage, + hasError: state.hasError, obscureText: true, - onChanged: validatePassword, - width: FORM_WIDTH.w, - height: FIELD_HEIGHT.h, - getInputDecoration: getInputDecoration, - showLoginError: showLoginError, - clearLoginError: () { - setState(() { - showLoginError = false; - loginErrorMessage = ''; - }); - }, ), ], ), @@ -262,15 +61,19 @@ class _LoginScreenState extends State { children: [ AppButtonMedium( text: '로그인', - onPressed: handleLogin, + onPressed: () => provider.signIn(context), backgroundColor: - isFormFilled ? JusicoolColor.main : JusicoolColor.gray300, + state.enableButton + ? JusicoolColor.main + : JusicoolColor.gray300, textColor: - isFormFilled + state.enableButton ? JusicoolColor.white : JusicoolColor.gray600, borderColor: - isFormFilled ? JusicoolColor.main : JusicoolColor.gray300, + state.enableButton + ? JusicoolColor.main + : JusicoolColor.gray300, ), Text( '아직 계정이 없으신가요?', diff --git a/lib/presentation/sign_in/screens/widgets/input_field.dart b/lib/presentation/sign_in/screens/widgets/input_field.dart new file mode 100644 index 0000000..17ee6da --- /dev/null +++ b/lib/presentation/sign_in/screens/widgets/input_field.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_screenutil/flutter_screenutil.dart'; +import 'package:jusicool_design_system/jusicool_design_system.dart'; + +class InputField extends StatelessWidget { + final String label; + final TextEditingController controller; + final String hint; + final String errorMessage; + final bool hasError; + final bool obscureText; + + const InputField({ + super.key, + required this.label, + required this.controller, + required this.hint, + this.errorMessage = "", + this.hasError = false, + this.obscureText = false, + }); + + @override + Widget build(BuildContext context) { + return Column( + spacing: 4.h, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: JusicoolTypography.bodySmall.copyWith( + fontSize: 16.sp, + color: hasError ? JusicoolColor.error : JusicoolColor.black, + ), + ), + SizedBox.shrink(), + TextFormField( + controller: controller, + obscureText: obscureText, + cursorColor: JusicoolColor.main, + cursorErrorColor: JusicoolColor.error, + decoration: getInputDecoration(hint, hasError), + ), + if (hasError && errorMessage.isNotEmpty) + Text( + errorMessage, + textAlign: TextAlign.right, + style: JusicoolTypography.bodySmall.copyWith( + color: JusicoolColor.error, + fontSize: 12.sp, + ), + ), + ], + ); + } + + InputDecoration getInputDecoration(String hint, bool hasError) { + return InputDecoration( + hintText: hint, + hintStyle: JusicoolTypography.bodySmall.copyWith( + color: hasError ? JusicoolColor.error : JusicoolColor.gray500, + ), + contentPadding: EdgeInsets.symmetric(horizontal: 16.w, vertical: 18.h), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide( + color: hasError ? JusicoolColor.error : JusicoolColor.gray300, + width: 1.w, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide( + color: hasError ? JusicoolColor.error : JusicoolColor.main, + width: 2.w, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide(color: JusicoolColor.error, width: 1.w), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide(color: JusicoolColor.error, width: 2.w), + ), + ); + } +} diff --git a/lib/presentation/sign_in/state/sign_in_state.dart b/lib/presentation/sign_in/state/sign_in_state.dart new file mode 100644 index 0000000..ffeface --- /dev/null +++ b/lib/presentation/sign_in/state/sign_in_state.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_in_state.freezed.dart'; + +@freezed +abstract class SignInState with _$SignInState { + factory SignInState({ + @Default("") String email, + @Default("") String password, + @Default(false) bool enableButton, + @Default(false) bool hasError, + @Default("") String errorMessage, + }) = _SignInState; +} diff --git a/lib/presentation/sign_in/widgets/input_field.dart b/lib/presentation/sign_in/widgets/input_field.dart deleted file mode 100644 index 681d99c..0000000 --- a/lib/presentation/sign_in/widgets/input_field.dart +++ /dev/null @@ -1,81 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_screenutil/flutter_screenutil.dart'; -import 'package:jusicool_design_system/jusicool_design_system.dart'; - -class InputField extends StatelessWidget { - final String label; - final TextEditingController controller; - final String hint; - final bool hasError; - final String errorMessage; - final bool obscureText; - final Function(String) onChanged; - final double width; - final double height; - final InputDecoration Function(String, bool) getInputDecoration; - final bool showLoginError; - final VoidCallback clearLoginError; - - const InputField({ - super.key, - required this.label, - required this.controller, - required this.hint, - required this.hasError, - required this.errorMessage, - this.obscureText = false, - required this.onChanged, - required this.width, - required this.height, - required this.getInputDecoration, - required this.showLoginError, - required this.clearLoginError, - }); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - label, - style: JusicoolTypography.bodySmall.copyWith( - fontSize: 16.sp, - color: hasError ? JusicoolColor.error : JusicoolColor.black, - ), - ), - SizedBox(height: 8.h), - SizedBox( - width: width, - height: height, - child: TextFormField( - controller: controller, - obscureText: obscureText, - onChanged: (value) { - onChanged(value); - if (showLoginError) { - clearLoginError(); - } - }, - decoration: getInputDecoration(hint, hasError || showLoginError), - ), - ), - if (hasError && errorMessage.isNotEmpty) - Padding( - padding: EdgeInsets.only(top: 4.h), - child: SizedBox( - width: width, - child: Text( - errorMessage, - textAlign: TextAlign.right, - style: JusicoolTypography.bodySmall.copyWith( - color: JusicoolColor.error, - fontSize: 12.sp, - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/presentation/sign_up/controller/sign_up_email_controller.dart b/lib/presentation/sign_up/controller/sign_up_email_controller.dart new file mode 100644 index 0000000..5a9e194 --- /dev/null +++ b/lib/presentation/sign_up/controller/sign_up_email_controller.dart @@ -0,0 +1,158 @@ +import 'dart:async'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:email_validator/email_validator.dart'; +import 'package:go_router/go_router.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_email_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/usecase/sign_up_usecase.dart'; +import 'package:jusicool_ios/presentation/sign_up/mapper/sign_up_email_mapper.dart'; +import 'package:jusicool_ios/presentation/sign_up/state/sign_up_email_state.dart'; + +import '../../../core/config/di/dependencies.dart'; +import '../../../core/config/router/router.dart'; + +final emailAuthControllerProvider = + StateNotifierProvider( + (ref) => EmailAuthController(di.get()), + ); + +class EmailAuthController extends StateNotifier { + final SignUpUseCase _signUpUseCase; + + EmailAuthController(this._signUpUseCase) : super(SignUpEmailState()) { + emailController.addListener(() => _onEmailChanged()); + codeController.addListener(() => _onCodeChanged()); + } + + final TextEditingController emailController = TextEditingController(); + final TextEditingController codeController = TextEditingController(); + + static const codeExpirationDuration = Duration(minutes: 5); + Timer? _codeExpirationTimer; + Timer? _countdownTimer; + + void disposeResources() { + _codeExpirationTimer?.cancel(); + _countdownTimer?.cancel(); + emailController.dispose(); + codeController.dispose(); + } + + void _onEmailChanged() { + final email = emailController.text.trim(); + state = state.copyWith( + email: email, + errorMessage: null, + enableButton: true, + isEmailValid: true, + ); + } + + void _onCodeChanged() { + final code = codeController.text.trim(); + state = state.copyWith( + verify: code, + isCodeMatched: true, + enableButton: true, + ); + } + + void sendEmail() { + final email = state.email; + if (email.isEmpty) { + state = state.copyWith( + isEmailValid: false, + enableButton: false, + errorMessage: "이메일을 입력해주세요.", + ); + return; + } + + if (EmailValidator.validate(email)) { + state = state.copyWith( + isEmailValid: true, + errorMessage: null, + isSendingCode: true, + ); + final SignUpEmailEntity request = SignUpEmailMapper.toEntity(state); + _signUpUseCase + .sendEmail(request) + .then((_) { + _startVerificationTimers(); + }) + .catchError((error) { + state = state.copyWith( + isEmailValid: false, + enableButton: false, + isSendingCode: false, + errorMessage: "이메일 전송에 실패했습니다. 잠시 후에 시도해주세요.", + ); + }); + } else { + state = state.copyWith( + isEmailValid: false, + enableButton: false, + isSendingCode: false, + errorMessage: "올바른 이메일 형식이 아닙니다.", + ); + } + } + + void sendVerificationCode(BuildContext context) { + final code = state.verify; + if (code.isEmpty || code.length != 6) { + state = state.copyWith( + isCodeMatched: false, + enableButton: false, + errorMessage: "인증번호를 입력해주세요.", + ); + return; + } + + state = state.copyWith( + isEmailValid: true, + errorMessage: null, + isSendingCode: true, + ); + final request = SignUpEmailMapper.toEntity(state); + + state = state.copyWith(isCodeMatched: true, isSendingCode: false); + _signUpUseCase + .verifyEmail(request) + .then((_) { + context.push(RoutePaths.passwordCreate); + state = state.copyWith(isCodeMatched: true, isSendingCode: false); + }) + .catchError((error) { + state = state.copyWith(isCodeMatched: false, isSendingCode: false); + }); + } + + void _startVerificationTimers() { + _codeExpirationTimer?.cancel(); + _countdownTimer?.cancel(); + + state = state.copyWith( + codeSent: true, + isSendingCode: false, + isCodeMatched: true, + timeRemaining: codeExpirationDuration, + ); + + codeController.clear(); + + _codeExpirationTimer = Timer(codeExpirationDuration, () { + _countdownTimer?.cancel(); + state = state.copyWith(codeSent: false); + }); + + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { + final seconds = state.timeRemaining.inSeconds; + if (seconds > 0) { + state = state.copyWith(timeRemaining: Duration(seconds: seconds - 1)); + } else { + timer.cancel(); + } + }); + } +} diff --git a/lib/presentation/sign_up/controller/sign_up_name_controller.dart b/lib/presentation/sign_up/controller/sign_up_name_controller.dart new file mode 100644 index 0000000..d17ec1f --- /dev/null +++ b/lib/presentation/sign_up/controller/sign_up_name_controller.dart @@ -0,0 +1,52 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../state/sign_up_name_state.dart'; + +final nameControllerProvider = + StateNotifierProvider( + (ref) => SignUpNameController(), + ); + +class SignUpNameController extends StateNotifier { + SignUpNameController() : super(SignUpNameState()) { + _controller.addListener(() => _setUsername(_controller.text)); + } + + final TextEditingController _controller = TextEditingController(); + + TextEditingController get controller => _controller; + + void _setUsername(String username) { + state = state.copyWith(username: username); + if (username.length >= 2) { + _setEnableButton(true); + } else { + _setEnableButton(false); + } + } + + void _setEnableButton(bool enable) { + state = state.copyWith(enableButton: enable); + } + + bool validateUsername() { + final username = _controller.text; + + if (username.isEmpty) { + state = state.copyWith(errorMessage: "이름을 입력해주세요.", enableButton: false); + return false; + } + + if (RegExp(r'^[가-힣]{2,}$').hasMatch(username)) { + state = state.copyWith(errorMessage: null, enableButton: true); + return true; + } else { + state = state.copyWith( + errorMessage: "올바른 이름 형식이 아닙니다.", + enableButton: false, + ); + return false; + } + } +} diff --git a/lib/presentation/sign_up/controller/sign_up_password_controller.dart b/lib/presentation/sign_up/controller/sign_up_password_controller.dart new file mode 100644 index 0000000..7346154 --- /dev/null +++ b/lib/presentation/sign_up/controller/sign_up_password_controller.dart @@ -0,0 +1,78 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:jusicool_ios/presentation/sign_up/state/sign_up_password_state.dart'; + +import '../../../core/config/router/router.dart'; + +final signupPasswordControllerProvider = + StateNotifierProvider( + (ref) => SignupPasswordController(), + ); + +class SignupPasswordController extends StateNotifier { + SignupPasswordController() : super(SignUpPasswordState()) { + _passwordController.addListener(_onPasswordChanged); + _confirmPasswordController.addListener(_onConfirmPasswordChanged); + } + + final TextEditingController _passwordController = TextEditingController(); + final TextEditingController _confirmPasswordController = + TextEditingController(); + + TextEditingController get passwordController => _passwordController; + + TextEditingController get confirmPasswordController => + _confirmPasswordController; + + void _onPasswordChanged() { + final password = _passwordController.text.trim(); + state = state.copyWith(password: password, isPasswordValid: true); + } + + void _onConfirmPasswordChanged() { + _updateFormState(); + checkPasswordsMatch(); + } + + void _updateFormState() { + state = state.copyWith( + isFormFilled: + _passwordController.text.isNotEmpty && + _confirmPasswordController.text.isNotEmpty, + ); + } + + void validatePassword(String password) { + state = state.copyWith(isPasswordValid: isValidPassword(password)); + } + + void checkPasswordsMatch() { + state = state.copyWith( + isPasswordMatched: + _passwordController.text == _confirmPasswordController.text, + ); + } + + bool isValidPassword(String password) { + if (password.length < 8 || password.length > 13) return false; + + final hasLetter = RegExp(r'[A-Za-z]').hasMatch(password); + final hasNumber = RegExp(r'\d').hasMatch(password); + final hasSpecial = RegExp(r'[@$!%*?&]').hasMatch(password); + + final count = [hasLetter, hasNumber, hasSpecial].where((e) => e).length; + return count >= 2; + } + + void onNextButtonPressed(BuildContext context) { + context.push(RoutePaths.findSchool); + } + + @override + void dispose() { + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } +} diff --git a/lib/presentation/sign_up/controller/sign_up_school_controller.dart b/lib/presentation/sign_up/controller/sign_up_school_controller.dart new file mode 100644 index 0000000..504436a --- /dev/null +++ b/lib/presentation/sign_up/controller/sign_up_school_controller.dart @@ -0,0 +1,92 @@ +import 'package:flutter/cupertino.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:jusicool_ios/core/config/router/router.dart'; +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_entity.dart'; +import 'package:jusicool_ios/domain/sign_up/usecase/sign_up_usecase.dart'; +import 'package:jusicool_ios/presentation/sign_up/mapper/sign_up_search_school_mapper.dart'; +import 'package:rxdart/rxdart.dart'; +import '../../../core/config/di/dependencies.dart'; +import '../state/sign_up_school_state.dart'; + +final signupSchoolControllerProvider = + StateNotifierProvider( + (ref) => SignupSchoolController(di.get()), + ); + +class SignupSchoolController extends StateNotifier { + SignUpUseCase _signUpUseCase; + final _schoolNameSubject = PublishSubject(); + + SignupSchoolController(this._signUpUseCase) : super(SignUpSchoolState()) { + _schoolNameController.addListener(() { + _schoolNameSubject.add(_schoolNameController.text); + }); + + _schoolNameSubject.debounceTime(const Duration(milliseconds: 500)).listen(( + _, + ) { + searchSchool(); + }); + } + + final TextEditingController _schoolNameController = TextEditingController(); + + TextEditingController get schoolNameController => _schoolNameController; + + void searchSchool() { + print(state.toString()); + _signUpUseCase + .searchSchool(_schoolNameController.text) + .then((result) { + if (result.schoolInfo.isNotEmpty) { + state = state.copyWith( + filteredSchools: SignUpSearchSchoolMapper.toState(result), + ); + print(state.toString()); + } else { + state = state.copyWith(filteredSchools: []); + } + }) + .catchError((error) { + state = state.copyWith(filteredSchools: []); + }); + } + + void selectSchool(SchoolInfoState school) { + state = state.copyWith(selectedSchool: school); + } + + void start({ + required BuildContext context, + required String email, + required String password, + required String name, + }) { + final String? schoolName = state.selectedSchool?.schoolName; + if (schoolName == null) { + return; + } else { + _signUpUseCase + .signUp( + SignUpEntity( + email: email, + password: password, + name: name, + school: schoolName, + ), + ) + .then((value) { + context.pushReplacement(RoutePaths.main); + }) + .catchError((error) {}); + } + } + + @override + void dispose() { + _schoolNameSubject.close(); + _schoolNameController.dispose(); + super.dispose(); + } +} diff --git a/lib/presentation/sign_up/mapper/sign_up_email_mapper.dart b/lib/presentation/sign_up/mapper/sign_up_email_mapper.dart new file mode 100644 index 0000000..cfe0a32 --- /dev/null +++ b/lib/presentation/sign_up/mapper/sign_up_email_mapper.dart @@ -0,0 +1,7 @@ +import 'package:jusicool_ios/domain/sign_up/entity/sign_up_email_entity.dart'; +import 'package:jusicool_ios/presentation/sign_up/state/sign_up_email_state.dart'; + +class SignUpEmailMapper { + static SignUpEmailEntity toEntity(SignUpEmailState state) => + SignUpEmailEntity(email: state.email, verifyCode: state.verify); +} diff --git a/lib/presentation/sign_up/mapper/sign_up_search_school_mapper.dart b/lib/presentation/sign_up/mapper/sign_up_search_school_mapper.dart new file mode 100644 index 0000000..c9c9012 --- /dev/null +++ b/lib/presentation/sign_up/mapper/sign_up_search_school_mapper.dart @@ -0,0 +1,18 @@ +import '../../../domain/sign_up/entity/sign_up_search_school_entity.dart'; +import '../state/sign_up_school_state.dart'; + +class SignUpSearchSchoolMapper { + static List toState( + SignUpSearchSchoolResponseEntity entity, + ) { + return entity.schoolInfo + .expand((info) => info.row ?? []) + .map( + (row) => SchoolInfoState( + schoolName: row.schoolName, + schoolAddress: row.schoolAddress, + ), + ) + .toList(); + } +} diff --git a/lib/presentation/sign_up/screens/email_auth_screen.dart b/lib/presentation/sign_up/screens/email_auth_screen.dart index 005de17..dc68eb7 100644 --- a/lib/presentation/sign_up/screens/email_auth_screen.dart +++ b/lib/presentation/sign_up/screens/email_auth_screen.dart @@ -1,187 +1,18 @@ -import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:jusicool_design_system/src/core/theme/colors/color_palette.dart'; import 'package:jusicool_design_system/src/core/theme/texts/typography.dart'; -import 'package:email_validator/email_validator.dart'; -import 'package:go_router/go_router.dart'; +import '../controller/sign_up_email_controller.dart'; -class AppStrings { - static const emailLabel = '이메일'; - static const emailHint = '이메일을 입력해주세요'; - static const emailInvalidFormat = '이메일 형식을 다시 확인해주세요'; - static const codeLabel = '인증번호'; - static const codeHint = '인증번호를 입력해주세요'; - static const codeInvalid = '인증번호가 일치하지 않습니다'; - static const resendCodeButton = '인증번호 재전송'; - static const nextButton = '다음'; - static const codeSentMessage = '인증번호가 전송되었습니다'; - static const codeExpiredMessage = '인증번호가 만료되었습니다'; - static const networkErrorMessage = '인증번호 전송에 실패했습니다'; - static const verifyEmailTitle = '이메일을 인증해주세요'; -} - -class EmailAuthScreen extends StatefulWidget { - final String username; - - const EmailAuthScreen({super.key, required this.username}); - - @override - State createState() => _EmailAuthScreenState(); -} - -class _EmailAuthScreenState extends State { - final TextEditingController emailController = TextEditingController(); - final TextEditingController codeController = TextEditingController(); - final FocusNode _codeFocusNode = FocusNode(); - Timer? _codeExpirationTimer; - Timer? _countdownTimer; - static const codeExpirationDuration = Duration(minutes: 5); - - bool isEmailValid = true; - bool codeSent = false; - bool isCodeMatched = true; - bool isSendingCode = false; - Duration timeRemaining = codeExpirationDuration; - - String get timerText => - '${(timeRemaining.inSeconds ~/ 60).toString().padLeft(1, '0')}:${(timeRemaining.inSeconds % 60).toString().padLeft(2, '0')}'; - - static final TextStyle LABEL_STYLE = JusicoolTypography.bodySmall.copyWith( - fontSize: 16.sp, - color: JusicoolColor.black, - ); - static final TextStyle ERROR_STYLE = JusicoolTypography.bodySmall.copyWith( - fontSize: 12.sp, - color: JusicoolColor.error, - ); - static final TextStyle HINT_STYLE = JusicoolTypography.bodySmall.copyWith( - fontSize: 16.sp, - color: JusicoolColor.gray300, - ); - static final TextStyle TIMER_STYLE = JusicoolTypography.bodySmall.copyWith( - fontSize: 14.sp, - color: JusicoolColor.black, - ); - static final TextStyle RESEND_STYLE = JusicoolTypography.bodySmall.copyWith( - fontSize: 14.sp, - color: JusicoolColor.main, - decoration: TextDecoration.underline, - ); - - @override - void initState() { - super.initState(); - emailController.addListener(onEmailChanged); - } - - @override - void dispose() { - _codeExpirationTimer?.cancel(); - _countdownTimer?.cancel(); - _codeFocusNode.dispose(); - emailController.dispose(); - codeController.dispose(); - super.dispose(); - } +class EmailAuthScreen extends ConsumerWidget { + const EmailAuthScreen({super.key}); - void onEmailChanged() { - final email = emailController.text.trim(); - setState(() { - isEmailValid = email.isEmpty || EmailValidator.validate(email); - }); - } - - void sendVerificationCode() { - if (!isEmailValid || isSendingCode) return; - - setState(() => isSendingCode = true); - try { - setState(() { - codeSent = true; - isCodeMatched = true; - codeController.clear(); - timeRemaining = codeExpirationDuration; - }); - - _codeExpirationTimer?.cancel(); - _countdownTimer?.cancel(); - _codeExpirationTimer = Timer(codeExpirationDuration, () { - setState(() { - codeSent = false; - _countdownTimer?.cancel(); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text(AppStrings.codeExpiredMessage)), - ); - }); - }); - - _countdownTimer = Timer.periodic(const Duration(seconds: 1), (timer) { - setState(() { - if (timeRemaining.inSeconds > 0) { - timeRemaining = timeRemaining - const Duration(seconds: 1); - } else { - timer.cancel(); - } - }); - }); - - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text(AppStrings.codeSentMessage))); - FocusScope.of(context).requestFocus(_codeFocusNode); - } finally { - setState(() => isSendingCode = false); - } - } - - void checkCodeMatch() { - if (isSendingCode) return; - - setState(() => isSendingCode = true); - try { - if (codeController.text == "1234") { - setState(() { - isCodeMatched = true; - _codeExpirationTimer?.cancel(); - _countdownTimer?.cancel(); - }); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text('인증번호가 확인되었습니다'))); - context.push( - '/password-create', - extra: { - 'username': widget.username, - 'email': emailController.text.trim(), - }, - ); - return; - } - - setState(() => isCodeMatched = false); - ScaffoldMessenger.of( - context, - ).showSnackBar(const SnackBar(content: Text(AppStrings.codeInvalid))); - } finally { - setState(() => isSendingCode = false); - } - } - - bool get isNextEnabled { - final canSendCode = - isEmailValid && emailController.text.isNotEmpty && !codeSent; - final canProceed = codeSent && codeController.text.length == 4; - return canSendCode || canProceed; - } - - void handleNextButton() { - if (!codeSent && isEmailValid) { - sendVerificationCode(); - } else if (codeSent && codeController.text.length == 4) { - checkCodeMatch(); - } + String formatTimer(Duration duration) { + final minutes = duration.inSeconds ~/ 60; + final seconds = duration.inSeconds % 60; + return '${minutes.toString().padLeft(1, '0')}:${seconds.toString().padLeft(2, '0')}'; } Widget buildTextField({ @@ -189,49 +20,48 @@ class _EmailAuthScreenState extends State { required String hintText, required bool isValid, TextInputType? keyboardType, - FocusNode? focusNode, int? maxLength, List? inputFormatters, }) { final borderSide = BorderSide( - color: - controller.text.isNotEmpty - ? (controller == emailController - ? (isEmailValid ? JusicoolColor.main : JusicoolColor.error) - : (isValid ? JusicoolColor.main : JusicoolColor.error)) - : JusicoolColor.gray300, - width: 1.w, + color: isValid ? JusicoolColor.main : JusicoolColor.error, + width: 2.w, ); - return Container( - width: 312.w, - height: 58.h, - child: TextField( - controller: controller, - keyboardType: keyboardType, - focusNode: focusNode, - maxLength: maxLength, - inputFormatters: inputFormatters, - decoration: InputDecoration( - hintText: hintText, - hintStyle: HINT_STYLE, - contentPadding: EdgeInsets.all(16.w), - filled: true, - fillColor: JusicoolColor.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: borderSide, - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: borderSide, + return TextField( + controller: controller, + keyboardType: keyboardType, + maxLength: maxLength, + inputFormatters: inputFormatters, + decoration: InputDecoration( + hintText: hintText, + hintStyle: JusicoolTypography.bodySmall.copyWith( + fontSize: 16.sp, + color: JusicoolColor.gray300, + ), + contentPadding: EdgeInsets.all(16.w), + filled: true, + fillColor: JusicoolColor.white, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide( + color: isValid ? JusicoolColor.gray200 : JusicoolColor.error, + width: 2.w, ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8.r), - borderSide: borderSide, + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: BorderSide( + color: isValid ? JusicoolColor.gray200 : JusicoolColor.error, + width: 2.w, ), - counterText: '', ), + + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8.r), + borderSide: borderSide, + ), + counterText: '', ), ); } @@ -277,7 +107,10 @@ class _EmailAuthScreenState extends State { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final controller = ref.read(emailAuthControllerProvider.notifier); + final state = ref.watch(emailAuthControllerProvider); + return Scaffold( appBar: AppBar( leading: Padding( @@ -292,103 +125,120 @@ class _EmailAuthScreenState extends State { child: Column( children: [ Expanded( - child: Column( - children: [ - SingleChildScrollView( - padding: EdgeInsets.fromLTRB(24.w, 16.h, 24.w, 56.h), - child: Column( - spacing: 40.h, + child: SingleChildScrollView( + padding: EdgeInsets.fromLTRB(24.w, 16.h, 24.w, 56.h), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: EdgeInsets.only(top: 8.h), + child: Text( + '이메일을 인증해주세요', + style: JusicoolTypography.subTitle, + ), + ), + SizedBox(height: 40.h), + Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 타이틀 - Padding( - padding: EdgeInsets.only(top: 8.h), - child: Text( - AppStrings.verifyEmailTitle, - style: JusicoolTypography.subTitle, + Text( + '이메일', + style: JusicoolTypography.bodySmall.copyWith( + fontSize: 16.sp, + color: JusicoolColor.black, ), ), - - // 이메일 입력 - Column( - spacing: 4.h, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(AppStrings.emailLabel, style: LABEL_STYLE), - buildTextField( - controller: emailController, - hintText: AppStrings.emailHint, - isValid: isEmailValid, - keyboardType: TextInputType.emailAddress, + buildTextField( + controller: controller.emailController, + hintText: '이메일을 입력해주세요', + isValid: state.isEmailValid, + keyboardType: TextInputType.emailAddress, + ), + if (controller.emailController.text.isNotEmpty && + !state.isEmailValid) + Text( + state.errorMessage ?? '올바른 이메일 형식이 아닙니다', + style: JusicoolTypography.bodySmall.copyWith( + fontSize: 12.sp, + color: JusicoolColor.error, ), - if (emailController.text.isNotEmpty && - !isEmailValid) + ), + ], + ), + SizedBox(height: 32.h), + if (state.codeSent) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ Text( - AppStrings.emailInvalidFormat, - style: ERROR_STYLE, + '인증번호', + style: JusicoolTypography.bodySmall.copyWith( + fontSize: 16.sp, + color: JusicoolColor.black, + ), ), - ], - ), - - // 인증번호 입력 (codeSent 상태에서만 표시) - if (codeSent) - Column( - spacing: 4.h, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - spacing: 8.w, - children: [ - Text( - AppStrings.codeLabel, - style: LABEL_STYLE, - ), - Text(timerText, style: TIMER_STYLE), - ], + SizedBox(width: 8.w), + Text( + formatTimer(state.timeRemaining), + style: JusicoolTypography.bodySmall.copyWith( + fontSize: 14.sp, + color: JusicoolColor.black, + ), ), - buildTextField( - controller: codeController, - hintText: AppStrings.codeHint, - isValid: isCodeMatched, - keyboardType: TextInputType.number, - focusNode: _codeFocusNode, - maxLength: 4, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - ], + ], + ), + buildTextField( + controller: controller.codeController, + hintText: '인증번호를 입력해주세요', + isValid: state.isCodeMatched, + keyboardType: TextInputType.number, + maxLength: 6, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + ], + ), + if (controller.codeController.text.isNotEmpty && + !state.isCodeMatched) + Text( + '인증번호가 일치하지 않습니다', + style: JusicoolTypography.bodySmall.copyWith( + fontSize: 12.sp, + color: JusicoolColor.error, ), - if (codeController.text.isNotEmpty && - !isCodeMatched) - Text( - AppStrings.codeInvalid, - style: ERROR_STYLE, - ), - Align( - alignment: Alignment.centerLeft, - child: TextButton( - onPressed: sendVerificationCode, - child: Text( - AppStrings.resendCodeButton, - style: RESEND_STYLE, - ), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: controller.sendEmail, + child: Text( + '인증번호 재전송', + style: JusicoolTypography.bodySmall.copyWith( + fontSize: 14.sp, + color: JusicoolColor.main, + decoration: TextDecoration.underline, ), ), - ], + ), ), - ], - ), - ), - ], + ], + ), + ], + ), ), ), - - // 하단 버튼 Padding( padding: EdgeInsets.fromLTRB(24.w, 0, 24.w, 24.h), child: buildButton( - label: AppStrings.nextButton, - onPressed: isNextEnabled ? handleNextButton : null, - isLoading: isSendingCode, + label: '다음', + onPressed: + state.enableButton + ? state.codeSent + ? () => controller.sendVerificationCode(context) + : controller.sendEmail + : null, + isLoading: state.isSendingCode, ), ), ], diff --git a/lib/presentation/sign_up/screens/find_school_screen.dart b/lib/presentation/sign_up/screens/find_school_screen.dart index 3efcbcc..bc433d0 100644 --- a/lib/presentation/sign_up/screens/find_school_screen.dart +++ b/lib/presentation/sign_up/screens/find_school_screen.dart @@ -1,88 +1,16 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:jusicool_design_system/jusicool_design_system.dart'; -import 'package:go_router/go_router.dart'; +import 'package:jusicool_ios/presentation/sign_up/controller/sign_up_email_controller.dart'; +import '../controller/sign_up_name_controller.dart'; +import '../controller/sign_up_password_controller.dart'; +import '../controller/sign_up_school_controller.dart'; +import '../state/sign_up_school_state.dart'; -class SchoolInfo { - final String name; - final String address; - - SchoolInfo({required this.name, required this.address}); - - Map toMap() => {"name": name, "address": address}; -} - -class FindSchoolScreen extends StatefulWidget { - final String username; - final String email; - final String password; - - const FindSchoolScreen({ - super.key, - required this.username, - required this.email, - required this.password, - }); - - @override - State createState() => _FindSchoolScreenState(); -} - -class _FindSchoolScreenState extends State { - final TextEditingController schoolNameController = TextEditingController(); - - bool isSearchButtonPressed = false; - List filteredSchools = []; - SchoolInfo? selectedSchool; - //========== - final List schools = [ - SchoolInfo(name: "대충중학교", address: "대충남도 대충시 대충면 대충로 1-2"), - SchoolInfo(name: "대충고등학교", address: "대충남도 대충시 대충면 대충로 3-4"), - SchoolInfo(name: "가나초등학교", address: "대충남도 대충시 가나동 가나로 5-6"), - SchoolInfo(name: "다라중학교", address: "대충남도 대충시 다라동 다라로 7-8"), - ]; - //========== - - @override - void initState() { - super.initState(); - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: JusicoolColor.white, - statusBarIconBrightness: Brightness.dark, - systemNavigationBarColor: JusicoolColor.white, - systemNavigationBarIconBrightness: Brightness.dark, - ), - ); - } - - @override - void dispose() { - schoolNameController.dispose(); - super.dispose(); - } - - void onSearch() { - final q = schoolNameController.text.trim(); - setState(() { - if (q.isEmpty) { - filteredSchools = []; - selectedSchool = null; - } else { - filteredSchools = - schools - .where((s) => s.name.toLowerCase().contains(q.toLowerCase())) - .toList(); - } - }); - } - - void onStart() { - if (selectedSchool != null) { - context.go('/main-capital'); - } - } +class FindSchoolScreen extends ConsumerWidget { + const FindSchoolScreen({super.key}); Widget _labelChip(String text) => Container( padding: EdgeInsets.symmetric(horizontal: 8.w, vertical: 2.h), @@ -99,13 +27,14 @@ class _FindSchoolScreenState extends State { ), ); - Widget _schoolCard(SchoolInfo school) { - final isSelected = selectedSchool?.name == school.name; + Widget _schoolCard( + SchoolInfoState school, + SchoolInfoState? selectedSchool, + VoidCallback onTap, + ) { + final isSelected = selectedSchool?.schoolName == school.schoolName; return GestureDetector( - onTap: - () => setState(() { - selectedSchool = isSelected ? null : school; - }), + onTap: () => onTap(), child: Container( margin: EdgeInsets.only(bottom: 12.h), padding: EdgeInsets.all(16.w), @@ -126,7 +55,7 @@ class _FindSchoolScreenState extends State { Padding( padding: EdgeInsets.only(left: 12.w), child: Text( - school.name, + school.schoolName, style: JusicoolTypography.bodySmall.copyWith( fontSize: 12.sp, color: JusicoolColor.black, @@ -143,7 +72,7 @@ class _FindSchoolScreenState extends State { Padding( padding: EdgeInsets.only(left: 12.w), child: Text( - school.address, + school.schoolAddress, style: JusicoolTypography.bodySmall.copyWith( fontSize: 12.sp, color: JusicoolColor.black, @@ -159,12 +88,15 @@ class _FindSchoolScreenState extends State { ); } - Widget _searchRow() => Row( + Widget _searchRow( + TextEditingController schoolNameController, + bool isSelect, + Function onTap, + ) => Row( children: [ Expanded( child: TextField( controller: schoolNameController, - onChanged: (_) => onSearch(), decoration: InputDecoration( hintText: '학교명을 입력해주세요', hintStyle: JusicoolTypography.bodySmall.copyWith( @@ -194,33 +126,39 @@ class _FindSchoolScreenState extends State { width: 54.w, height: 54.h, child: GestureDetector( - onTapDown: (_) => setState(() => isSearchButtonPressed = true), - onTapUp: (_) { - setState(() => isSearchButtonPressed = false); - onSearch(); - }, - onTapCancel: () => setState(() => isSearchButtonPressed = false), + onTap: () => onTap(), child: AnimatedContainer( duration: const Duration(milliseconds: 100), decoration: BoxDecoration( - color: - isSearchButtonPressed - ? JusicoolColor.gray100 - : JusicoolColor.white, + color: isSelect ? JusicoolColor.gray100 : JusicoolColor.white, borderRadius: BorderRadius.circular(8.r), ), - child: JusicoolIcon.search(), + child: Container( + padding: EdgeInsets.all(15), + decoration: BoxDecoration( + border: Border.all( + color: JusicoolColor.main.withValues(alpha:0.5), + width: 1.sp, + ), + borderRadius: BorderRadius.circular(8), + ), + child: JusicoolIcon.search( + height: 24.h, + width: 24.w, + color: JusicoolColor.main.withValues(alpha: 0.5), + ), + ), ), ), ), ], ); - Widget _startButton(bool enabled) => SizedBox( + Widget _startButton(bool enabled, Function onTap) => SizedBox( width: double.infinity, height: 54.h, child: ElevatedButton( - onPressed: enabled ? onStart : null, + onPressed: () => onTap(), style: ElevatedButton.styleFrom( backgroundColor: enabled ? JusicoolColor.main : JusicoolColor.gray300, foregroundColor: enabled ? JusicoolColor.white : JusicoolColor.gray600, @@ -239,8 +177,10 @@ class _FindSchoolScreenState extends State { ); @override - Widget build(BuildContext context) { - final isSchoolSelected = selectedSchool != null; + Widget build(BuildContext context, WidgetRef ref) { + final provider = ref.watch(signupSchoolControllerProvider.notifier); + final state = ref.watch(signupSchoolControllerProvider); + final isSchoolSelected = state.selectedSchool != null; return Scaffold( backgroundColor: JusicoolColor.white, @@ -289,7 +229,11 @@ class _FindSchoolScreenState extends State { color: JusicoolColor.black, ), ), - _searchRow(), + _searchRow( + provider.schoolNameController, + state.selectedSchool != null, + provider.searchSchool, + ), ], ), ], @@ -298,7 +242,7 @@ class _FindSchoolScreenState extends State { ), Expanded( child: - filteredSchools.isEmpty + state.filteredSchools.isEmpty ? Center( child: Text( '검색 결과가 없습니다.', @@ -310,12 +254,31 @@ class _FindSchoolScreenState extends State { ) : ListView.builder( padding: EdgeInsets.only(top: 2.h), - itemCount: filteredSchools.length, + itemCount: state.filteredSchools.length, itemBuilder: - (_, index) => _schoolCard(filteredSchools[index]), + (_, index) => _schoolCard( + state.filteredSchools[index], + state.selectedSchool, + () => provider.selectSchool( + state.filteredSchools[index], + ), + ), ), ), - Column(children: [_startButton(isSchoolSelected)]), + Column( + children: [ + _startButton( + isSchoolSelected, + () => provider.start( + context: context, + email: ref.watch(emailAuthControllerProvider).email, + password: + ref.watch(signupPasswordControllerProvider).password, + name: ref.watch(nameControllerProvider).username, + ), + ), + ], + ), ], ), ), diff --git a/lib/presentation/sign_up/screens/name_input_screen.dart b/lib/presentation/sign_up/screens/name_input_screen.dart index 7993a80..770d2a8 100644 --- a/lib/presentation/sign_up/screens/name_input_screen.dart +++ b/lib/presentation/sign_up/screens/name_input_screen.dart @@ -1,70 +1,17 @@ +import 'package:cookie_jar/cookie_jar.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:jusicool_design_system/jusicool_design_system.dart'; import 'package:go_router/go_router.dart'; +import 'package:jusicool_ios/presentation/sign_up/controller/sign_up_name_controller.dart'; -const double BUTTON_HEIGHT = 54; +import '../../../core/config/di/dependencies.dart'; +import '../../../core/config/router/router.dart'; -class NameInputScreen extends StatefulWidget { +class NameInputScreen extends ConsumerWidget { const NameInputScreen({super.key}); - @override - State createState() => _NameInputScreenState(); -} - -class _NameInputScreenState extends State { - final TextEditingController _controller = TextEditingController(); - String? _errorMessage; - - bool get _isButtonEnabled => _controller.text.trim().isNotEmpty; - - @override - void initState() { - super.initState(); - _controller.addListener(() { - _clearErrorOnTextChange(); - setState(() {}); - }); - } - - @override - void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _clearErrorOnTextChange() { - if (_errorMessage != null) { - setState(() { - _errorMessage = null; - }); - } - } - - void _handleNext() { - final name = _controller.text.trim(); - - if (name.isEmpty) { - setState(() { - _errorMessage = '필수 입력 항목입니다.'; - }); - return; - } - - if (!_isValidKoreanName(name)) { - setState(() { - _errorMessage = '한글 이름을 2자 이상 입력해주세요.'; - }); - return; - } - - context.push('/email-auth', extra: name); - } - - bool _isValidKoreanName(String name) { - return RegExp(r'^[가-힣]{2,}$').hasMatch(name); - } - Widget buildButton({ required String label, required VoidCallback? onPressed, @@ -77,11 +24,11 @@ class _NameInputScreenState extends State { label: label, child: Container( width: double.infinity, - height: BUTTON_HEIGHT.h, decoration: BoxDecoration(borderRadius: BorderRadius.circular(12.r)), child: ElevatedButton( onPressed: isLoading ? null : onPressed, style: ElevatedButton.styleFrom( + padding: EdgeInsets.symmetric(vertical: 16.h), backgroundColor: isEnabled ? JusicoolColor.main : JusicoolColor.gray300, foregroundColor: @@ -109,7 +56,11 @@ class _NameInputScreenState extends State { } @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final CookieJar cookieJar = di.get(); + cookieJar.deleteAll(); // 쿠키 삭제 + final provider = ref.watch(nameControllerProvider.notifier); + final state = ref.watch(nameControllerProvider); return Scaffold( backgroundColor: JusicoolColor.white, appBar: AppBar( @@ -134,27 +85,26 @@ class _NameInputScreenState extends State { children: [ Text('이름', style: JusicoolTypography.bodySmall), DefaultTextField( - controller: _controller, + controller: provider.controller, hintText: '실명을 적어주세요', - validator: (value) { - final name = value?.trim() ?? ''; - if (name.isEmpty) { - _errorMessage = '이름을 입력해주세요'; - } else if (!RegExp(r'^[가-힣]{2,}$').hasMatch(name)) { - _errorMessage = '2자 이상 한글로 입력해주세요'; - } else { - _errorMessage = null; - } - return _errorMessage; + errorText: state.errorMessage, + validator: (String) { + return null; }, - errorText: _errorMessage, ), ], ), const Spacer(), buildButton( label: '다음', - onPressed: _isButtonEnabled ? _handleNext : null, + onPressed: + state.enableButton + ? () { + if (provider.validateUsername()) { + context.push(RoutePaths.emailAuth); + } + } + : null, ), ], ), diff --git a/lib/presentation/sign_up/screens/password_create_screen.dart b/lib/presentation/sign_up/screens/password_create_screen.dart index 15b2a13..8334191 100644 --- a/lib/presentation/sign_up/screens/password_create_screen.dart +++ b/lib/presentation/sign_up/screens/password_create_screen.dart @@ -1,31 +1,11 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:jusicool_design_system/jusicool_design_system.dart'; -import 'package:go_router/go_router.dart'; +import '../controller/sign_up_password_controller.dart'; -class PasswordCreateScreen extends StatefulWidget { - final String username; - final String email; - - const PasswordCreateScreen({ - super.key, - required this.username, - required this.email, - }); - - @override - State createState() => _PasswordCreateScreenState(); -} - -class _PasswordCreateScreenState extends State { - final TextEditingController _passwordController = TextEditingController(); - final TextEditingController _confirmPasswordController = - TextEditingController(); - - bool _isFormFilled = false; - bool _isPasswordValid = true; - bool _isPasswordMatched = true; +class PasswordCreateScreen extends ConsumerWidget { + const PasswordCreateScreen({super.key}); static final TextStyle _titleStyle = JusicoolTypography.bodyMedium.copyWith( fontSize: 18.sp, @@ -37,87 +17,11 @@ class _PasswordCreateScreenState extends State { ); @override - void initState() { - super.initState(); - - _passwordController.addListener(_onPasswordChanged); - _confirmPasswordController.addListener(_onConfirmPasswordChanged); - - SystemChrome.setSystemUIOverlayStyle( - const SystemUiOverlayStyle( - statusBarColor: JusicoolColor.white, - statusBarIconBrightness: Brightness.dark, - systemNavigationBarColor: JusicoolColor.white, - systemNavigationBarIconBrightness: Brightness.dark, - ), - ); - } - - @override - void dispose() { - _passwordController.dispose(); - _confirmPasswordController.dispose(); - super.dispose(); - } - - void _onPasswordChanged() { - _validatePassword(_passwordController.text); - _updateFormState(); - _checkPasswordsMatch(); - } - - void _onConfirmPasswordChanged() { - _updateFormState(); - _checkPasswordsMatch(); - } - - void _updateFormState() { - setState(() { - _isFormFilled = - _passwordController.text.isNotEmpty && - _confirmPasswordController.text.isNotEmpty; - }); - } - - void _validatePassword(String password) { - setState(() { - _isPasswordValid = _isValidPassword(password); - }); - } - - void _checkPasswordsMatch() { - setState(() { - _isPasswordMatched = - _passwordController.text == _confirmPasswordController.text; - }); - } - - bool _isValidPassword(String password) { - if (password.length < 8 || password.length > 13) return false; - - final hasLetter = RegExp(r'[A-Za-z]').hasMatch(password); - final hasNumber = RegExp(r'\d').hasMatch(password); - final hasSpecial = RegExp(r'[@$!%*?&]').hasMatch(password); - - final count = [hasLetter, hasNumber, hasSpecial].where((e) => e).length; - return count >= 2; - } - - void _onNextButtonPressed() { - context.push( - '/find-school', - extra: { - 'username': widget.username, - 'email': widget.email, - 'password': _passwordController.text, - }, - ); - } - - @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final state = ref.watch(signupPasswordControllerProvider); + final provider = ref.watch(signupPasswordControllerProvider.notifier); final isButtonEnabled = - _isFormFilled && _isPasswordValid && _isPasswordMatched; + state.isFormFilled && state.isPasswordValid && state.isPasswordMatched; return Scaffold( appBar: AppBar( @@ -145,29 +49,20 @@ class _PasswordCreateScreenState extends State { '비밀번호', style: _labelStyle.copyWith( color: - _isPasswordValid + state.isPasswordValid ? JusicoolColor.black : JusicoolColor.error, ), ), DefaultTextField( - controller: _passwordController, + controller: provider.passwordController, hintText: '비밀번호를 입력해주세요', obscureText: true, errorText: - _isPasswordValid - ? null - : '영문, 숫자, 특수문자 중 2개 이상 조합으로 8~13자', - validator: (value) { - final pwd = value ?? ''; - if (pwd.isEmpty) { - return '비밀번호를 입력해주세요'; - } - if (!_isValidPassword(pwd)) { - return '영문, 숫자, 특수문자 중 2개 이상 조합으로 8~13자'; - } - return null; - }, + !state.isPasswordValid + ? '영문, 숫자, 특수문자 중 2개 이상 조합으로 8~13자' + : null, + validator: (String) => null, ), ], ), @@ -176,29 +71,21 @@ class _PasswordCreateScreenState extends State { spacing: 4.h, children: [ Text( - '비밀번호 재 입력', + '비밀번호 재입력', style: _labelStyle.copyWith( color: - _isPasswordMatched + state.isPasswordMatched ? JusicoolColor.black : JusicoolColor.error, ), ), DefaultTextField( - controller: _confirmPasswordController, + controller: provider.confirmPasswordController, hintText: '비밀번호를 다시 입력해주세요', obscureText: true, - errorText: _isPasswordMatched ? null : '비밀번호가 일치하지 않아요', - validator: (value) { - final confirmPwd = value ?? ''; - if (confirmPwd.isEmpty) { - return '비밀번호를 다시 입력해주세요'; - } - if (confirmPwd != _passwordController.text) { - return '비밀번호가 일치하지 않아요'; - } - return null; - }, + errorText: + !state.isPasswordMatched ? '비밀번호가 일치하지 않아요' : null, + validator: (String) => null, ), ], ), @@ -207,7 +94,22 @@ class _PasswordCreateScreenState extends State { width: double.infinity, height: 54.h, child: ElevatedButton( - onPressed: isButtonEnabled ? _onNextButtonPressed : null, + onPressed: () { + final password = provider.passwordController.text.trim(); + + provider.validatePassword(password); + provider.checkPasswordsMatch(); + + final newState = ref.read(signupPasswordControllerProvider); + final isValid = + newState.isFormFilled && + newState.isPasswordValid && + newState.isPasswordMatched; + + if (isValid) { + provider.onNextButtonPressed(context); + } + }, style: ElevatedButton.styleFrom( backgroundColor: isButtonEnabled diff --git a/lib/presentation/sign_up/state/sign_up_email_state.dart b/lib/presentation/sign_up/state/sign_up_email_state.dart new file mode 100644 index 0000000..f9546e6 --- /dev/null +++ b/lib/presentation/sign_up/state/sign_up_email_state.dart @@ -0,0 +1,20 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_email_state.freezed.dart'; + +@freezed +abstract class SignUpEmailState with _$SignUpEmailState { + factory SignUpEmailState({ + @Default("") String email, + @Default("") String verify, + + @Default(null) String? errorMessage, + @Default(false) bool enableButton, + + @Default(true) bool isEmailValid, + @Default(false) bool codeSent, + @Default(true) bool isCodeMatched, + @Default(false) bool isSendingCode, + @Default(Duration(minutes: 10)) Duration timeRemaining, + }) = _SignUpEmailState; +} diff --git a/lib/presentation/sign_up/state/sign_up_name_state.dart b/lib/presentation/sign_up/state/sign_up_name_state.dart new file mode 100644 index 0000000..d6fc5e4 --- /dev/null +++ b/lib/presentation/sign_up/state/sign_up_name_state.dart @@ -0,0 +1,12 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_name_state.freezed.dart'; + +@freezed +abstract class SignUpNameState with _$SignUpNameState { + factory SignUpNameState({ + @Default("") String username, + @Default(null) String? errorMessage, + @Default(false) bool enableButton, + }) = _SignUpNameState; +} diff --git a/lib/presentation/sign_up/state/sign_up_password_state.dart b/lib/presentation/sign_up/state/sign_up_password_state.dart new file mode 100644 index 0000000..593e1c2 --- /dev/null +++ b/lib/presentation/sign_up/state/sign_up_password_state.dart @@ -0,0 +1,13 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_password_state.freezed.dart'; + +@freezed +abstract class SignUpPasswordState with _$SignUpPasswordState { + const factory SignUpPasswordState({ + @Default('') String password, + @Default(true) bool isPasswordValid, + @Default(true) bool isPasswordMatched, + @Default(true) bool isFormFilled, + }) = _SignUpPasswordState; +} diff --git a/lib/presentation/sign_up/state/sign_up_school_state.dart b/lib/presentation/sign_up/state/sign_up_school_state.dart new file mode 100644 index 0000000..2013518 --- /dev/null +++ b/lib/presentation/sign_up/state/sign_up_school_state.dart @@ -0,0 +1,19 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'sign_up_school_state.freezed.dart'; + +@freezed +abstract class SignUpSchoolState with _$SignUpSchoolState { + const factory SignUpSchoolState({ + @Default(null) SchoolInfoState? selectedSchool, + @Default([]) List filteredSchools, + }) = _SignUpSchoolState; +} + +@freezed +abstract class SchoolInfoState with _$SchoolInfoState { + const factory SchoolInfoState({ + @Default('') String schoolName, + @Default('') String schoolAddress, + }) = _SchoolInfoState; +} diff --git a/pubspec.yaml b/pubspec.yaml index 86ec9f7..6fe4a71 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -23,7 +23,7 @@ dependencies: url_launcher: ^6.2.1 - fl_chart: ^0.64.0 + fl_chart: ^1.0.0 intl: ^0.20.2 @@ -33,8 +33,6 @@ dependencies: dio: ^5.8.0+1 - retrofit: ^4.4.2 - get_it: ^8.0.3 json_annotation: ^4.9.0 @@ -47,7 +45,25 @@ dependencies: pretty_dio_logger: ^1.4.0 - go_router: ^15.2.0 + go_router: ^16.0.0 + + retrofit: ^4.5.0 + + freezed_annotation: ^3.0.0 + + riverpod: ^2.6.1 + + flutter_riverpod: ^2.6.1 + + rxdart: ^0.28.0 + + +dev_dependencies: + retrofit_generator: ^9.2.0 + json_serializable: ^6.9.5 + build_runner: ^2.5.4 + freezed: ^3.0.6 + flutter_native_splash: color: "#FFFFFF"