diff --git a/.github/workflows/flutter-ci.yaml b/.github/workflows/flutter-ci.yaml index b680a240..d799abde 100644 --- a/.github/workflows/flutter-ci.yaml +++ b/.github/workflows/flutter-ci.yaml @@ -38,11 +38,8 @@ jobs: - name: Install dependencies run: flutter pub get - - name: Generate code - run: dart run build_runner build --delete-conflicting-outputs - - name: Analyze project source - run: flutter analyze --no-fatal-warnings --no-fatal-infos + run: flutter analyze - name: Run tests run: flutter test \ No newline at end of file diff --git a/docs/naming-conventions.md b/docs/naming-conventions.md index 7c51fdb1..4b8e2c4e 100644 --- a/docs/naming-conventions.md +++ b/docs/naming-conventions.md @@ -26,10 +26,30 @@ ### 금지/비권장 이름 - `pages` 사용 금지 - 기존 `pages`는 점진적으로 `screens`로 이동한다. -- `viewmodels` 사용 금지 - - Riverpod `Notifier`, `AsyncNotifier`, `Provider`는 모두 `providers` 아래에 둔다. -- `states` 디렉터리 사용 금지 - - UI 상태 타입은 `models` 아래에 둔다. +- 신규 코드는 `providers` / `models`를 쓴다. + - 기존 `viewmodels` / `states` 디렉터리는 그대로 두고, 그 안의 코드를 수정할 때 + 한 파일씩 옮긴다. 이름만 바꾸는 일괄 리네임은 하지 않는다. + +## 상태를 어디에 둘까 + +디렉터리 이름보다 이 질문이 먼저다. Riverpod의 `Notifier`가 곧 ViewModel이므로 +"MVVM을 쓸까"가 아니라 **"이 상태가 위젯보다 오래 사는가"**로 판단한다. + +### 1. Provider(= ViewModel)에 둔다 +- 화면을 벗어나도 유지돼야 하거나, 둘 이상이 읽는 상태 +- 서버 호출·비동기 로딩·에러·폼 검증이 붙는 상태 +- 예: `authProvider`, `signupProvider`, `mapScreenProvider`, `settingsProvider` + +### 2. `setState`로 둔다 +- 위젯 하나만 읽고 쓰고, 위젯이 사라지면 같이 사라지는 상태 +- 바텀시트 안의 선택값, 토글 진행 중 플래그, 펼침/접힘 같은 것 +- provider로 올리면 `autoDispose.family` + 식별용 키가 따라붙는다. + 그건 `setState`를 어렵게 재구현한 것이다. +- 부모가 새 값을 내려줄 때 따라가야 하면 `didUpdateWidget`에서 명시적으로 반영한다. + +### 3. 만들지 않는다 +- 다른 provider의 메서드를 그대로 호출만 하는 ViewModel은 두지 않는다. +- 위젯에서 `ref.read(대상provider.notifier)`를 직접 부른다. ## 파일 규칙 @@ -103,6 +123,62 @@ feature/ - `provider`로 통일한다. `viewmodel`은 더 이상 추가하지 않는다. - `model`로 통일한다. `state` 전용 디렉터리는 더 이상 추가하지 않는다. - pass-through `usecase`는 더 이상 기본값이 아니다. +- 저장소/서비스도 마찬가지다. 인터페이스와 구현이 1:1이고 구현이 전달만 하면 + 중간 계층 없이 `datasource` 또는 유틸을 직접 쓴다. + 플랫폼 API를 감싸 테스트에서 갈아끼워야 하는 경우(`PermissionService`)만 예외다. + +## 정리 대상 (2026-08-19 기준) + +규칙에 맞지 않지만 아직 옮기지 않은 것들. 수정이 닿을 때 함께 정리한다. + +| 위치 | 내용 | +| --- | --- | +| `features/*/presentation/viewmodels/` | 디렉터리 11개. 안의 provider 이름은 이미 대부분 `xxxProvider`라 파일 위치만 남았다. | +| `features/auth/verification/presentation/states/` | `models/`로 이동 | +| `features/map/shared/presentation/widgets/` | 15개. 실제로 공용인 것만 남기고 나머지는 소유 하위 feature로 | +| `features/map/routes/` | 다른 feature와 맞춰 `presentation/routes/`로 | +| `features/home/domain/enums/student_role_enum.dart` | `home`의 유일한 파일인데 실사용처는 `member`/`outing`. `core/enums/`로 | +| `features/auth/email_verification/data/models/request/email_verification/` | 경로에 feature 이름이 두 번 들어간다 | + +## Git 네이밍 + +### 브랜치 +`<타입>/#<이슈번호>-<영문 설명>` + +| 타입 | 용도 | +| --- | --- | +| `feature` | 기능 추가 | +| `fix` | 버그 수정 | +| `refactor` | 동작 변경 없는 구조 정리 | +| `perf` | 성능 개선 | +| `release` | 배포 준비 (`release/v1.4.2` 처럼 이슈번호 없이) | + +- 타입은 브랜치가 하는 일에 맞춘다. 리팩터링에 `feature`를 붙이지 않는다. +- 설명은 영문 kebab-case. 예: `refactor/#108-dead-code-cleanup` + +### 커밋 +`:gitmoji: :: <한글 설명>` + +- gitmoji는 콜론 형식(`:sparkles:`)으로 쓴다. 유니코드 이모지(`✨`)를 섞지 않는다. +- 이슈 참조가 필요하면 설명 끝에 `(#126)`. + +| gitmoji | 용도 | +| --- | --- | +| `:sparkles:` | 기능 추가 | +| `:bug:` | 버그 수정 | +| `:recycle:` | 리팩터링 | +| `:zap:` | 성능 개선 | +| `:memo:` | 문서 | +| `:green_heart:` | CI | +| `:bookmark:` | 버전업 | + +### PR 제목 +`🔀 :: (#<이슈번호>) - <한글 설명>` + +- PR 제목의 이모지는 변경 성격과 무관하게 항상 `🔀`다. 커밋과 달리 유니코드 이모지를 쓴다. +- 배포 PR만 이슈번호 대신 버전을 쓴다. 예: `🔀 :: v1.4.4 - 핫플레이스 조회 기준 기간 1일로 변경` +- base는 `develop`. `main`으로 직접 열지 않는다. +- 본문은 `.github/PULL_REQUEST_TEMPLATE.md`를 채우고, 관련 이슈에 `Closes #N`을 남긴다. ## 예외 - 외부 라이브러리/코드 생성기 제약이 있는 경우 diff --git a/lib/core/data/services_impl/settings_service_impl.dart b/lib/core/data/services_impl/settings_service_impl.dart deleted file mode 100644 index 108bd436..00000000 --- a/lib/core/data/services_impl/settings_service_impl.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'package:goms/core/domain/services/settings_service.dart'; -import 'package:goms/core/utils/settings_storage.dart'; - -/// SettingsService의 구현체 -/// -/// SettingsStorage를 래핑하여 의존성 주입 및 테스트를 용이하게 합니다. -class SettingsServiceImpl implements SettingsService { - @override - Future getShowClock() => SettingsStorage.getShowClock(); - - @override - Future setShowClock(bool value) => SettingsStorage.setShowClock(value); - - @override - Future getOutingPushAlarm() => SettingsStorage.getOutingPushAlarm(); - - @override - Future setOutingPushAlarm(bool value) => - SettingsStorage.setOutingPushAlarm(value); - - @override - Future getCameraLaunch() => SettingsStorage.getCameraLaunch(); - - @override - Future setCameraLaunch(bool value) => - SettingsStorage.setCameraLaunch(value); - - @override - Future getThemeMode() => SettingsStorage.getThemeMode(); - - @override - Future setThemeMode(int value) => SettingsStorage.setThemeMode(value); -} diff --git a/lib/core/domain/services/settings_service.dart b/lib/core/domain/services/settings_service.dart deleted file mode 100644 index 581c586c..00000000 --- a/lib/core/domain/services/settings_service.dart +++ /dev/null @@ -1,28 +0,0 @@ -/// 앱 설정값 저장/조회를 담당하는 추상 서비스 -/// -/// SettingsStorage에 대한 의존성을 추상화하여, 테스트 및 의존성 주입을 용이하게 합니다. -abstract class SettingsService { - /// 화면 시계 표시 여부 조회 - Future getShowClock(); - - /// 화면 시계 표시 여부 저장 - Future setShowClock(bool value); - - /// 외출 푸시 알림 활성화 여부 조회 - Future getOutingPushAlarm(); - - /// 외출 푸시 알림 활성화 여부 저장 - Future setOutingPushAlarm(bool value); - - /// 카메라 실행 여부 조회 - Future getCameraLaunch(); - - /// 카메라 실행 여부 저장 - Future setCameraLaunch(bool value); - - /// 테마 모드 조회 (0=system, 1=light, 2=dark) - Future getThemeMode(); - - /// 테마 모드 저장 (0=system, 1=light, 2=dark) - Future setThemeMode(int value); -} diff --git a/lib/core/providers/service_providers.dart b/lib/core/providers/service_providers.dart index afc54af4..00872b64 100644 --- a/lib/core/providers/service_providers.dart +++ b/lib/core/providers/service_providers.dart @@ -1,9 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:goms/core/domain/services/permission_service.dart'; -import 'package:goms/core/domain/services/settings_service.dart'; import 'package:goms/core/domain/services/notification_service.dart'; import 'package:goms/core/data/services_impl/permission_service_impl.dart'; -import 'package:goms/core/data/services_impl/settings_service_impl.dart'; import 'package:goms/core/data/services_impl/notification_service_impl.dart'; import 'package:goms/features/notification/data/datasources/notification_api.dart'; import 'package:goms/core/network/dio_providers.dart'; @@ -13,11 +11,6 @@ final permissionServiceProvider = Provider((ref) { return PermissionServiceImpl(); }); -/// Settings Service 제공자 -final settingsServiceProvider = Provider((ref) { - return SettingsServiceImpl(); -}); - /// Notification Service 제공자 final notificationServiceProvider = Provider((ref) { final notificationApi = NotificationApi(ref.watch(dioProvider)); diff --git a/lib/core/widgets/dialogs/banned_outing_dialog.dart b/lib/core/widgets/dialogs/banned_outing_dialog.dart deleted file mode 100644 index b5046068..00000000 --- a/lib/core/widgets/dialogs/banned_outing_dialog.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:goms_design_system/goms_design_system.dart'; - -/// 외출금지 다이얼로그 -Future bannedOuting({ - required BuildContext context, - required String title, - required String content, - required String redContent, - required String content2, - String cancelText = '취소', - String confirmText = '외출 금지', - VoidCallback? onConfirm, - bool isDestructive = false, -}) { - return showCupertinoDialog( - context: context, - barrierDismissible: true, - builder: (context) => CupertinoAlertDialog( - title: Text(title), - content: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: TextStyle( - color: context.isLightMode ? Colors.black : Colors.white, - ), - children: [ - TextSpan(text: content), - TextSpan( - text: redContent, - style: const TextStyle(color: AppColors.negative), - ), - TextSpan(text: content2), - ], - ), - ), - actions: [ - CupertinoDialogAction( - onPressed: () => Navigator.of(context).pop(), - child: Text( - cancelText, - style: const TextStyle(color: CupertinoColors.systemBlue), - ), - ), - CupertinoDialogAction( - isDestructiveAction: isDestructive, - onPressed: () { - Navigator.of(context).pop(); - onConfirm?.call(); - }, - child: Text( - confirmText, - style: const TextStyle(color: AppColors.negative), - ), - ), - ], - ), - ); -} diff --git a/lib/core/widgets/dialogs/banned_outing_release_dialog.dart b/lib/core/widgets/dialogs/banned_outing_release_dialog.dart deleted file mode 100644 index 1d26ca3c..00000000 --- a/lib/core/widgets/dialogs/banned_outing_release_dialog.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:goms_design_system/goms_design_system.dart'; - -/// 외출금지 해제 -Future bannedOutingRelease({ - required BuildContext context, - required String title, - required String content, - required String redContent, - required String content2, - String cancelText = '취소', - String confirmText = '외출 해제', - VoidCallback? onConfirm, - bool isDestructive = false, -}) { - return showCupertinoDialog( - context: context, - barrierDismissible: true, - builder: (context) => CupertinoAlertDialog( - title: Text(title), - content: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: TextStyle( - color: context.isLightMode ? Colors.black : Colors.white, - ), - children: [ - TextSpan(text: content), - TextSpan( - text: redContent, - style: const TextStyle(color: AppColors.negative), - ), - TextSpan(text: content2), - ], - ), - ), - actions: [ - CupertinoDialogAction( - onPressed: () => Navigator.of(context).pop(), - child: Text( - cancelText, - style: const TextStyle(color: CupertinoColors.systemBlue), - ), - ), - CupertinoDialogAction( - isDestructiveAction: isDestructive, - onPressed: () { - Navigator.of(context).pop(); - onConfirm?.call(); - }, - child: Text( - confirmText, - style: const TextStyle(color: AppColors.negative), - ), - ), - ], - ), - ); -} diff --git a/lib/core/widgets/dialogs/forced_outing_release_dialog.dart b/lib/core/widgets/dialogs/forced_outing_release_dialog.dart deleted file mode 100644 index 3320f585..00000000 --- a/lib/core/widgets/dialogs/forced_outing_release_dialog.dart +++ /dev/null @@ -1,53 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:flutter/material.dart'; -import 'package:goms_design_system/goms_design_system.dart'; - -/// 강제외출 복귀 -Future forcedOutingRelease({ - required BuildContext context, - required String title, - required String content, - String cancelText = '취소', - String confirmText = '복귀', - VoidCallback? onConfirm, - bool isDestructive = false, -}) { - return showCupertinoDialog( - context: context, - barrierDismissible: true, - builder: (context) => CupertinoAlertDialog( - title: Text(title), - content: RichText( - textAlign: TextAlign.center, - text: TextSpan( - style: TextStyle( - color: context.isLightMode ? Colors.black : Colors.white, - ), - children: [ - TextSpan(text: content), - ], - ), - ), - actions: [ - CupertinoDialogAction( - onPressed: () => Navigator.of(context).pop(), - child: Text( - cancelText, - style: const TextStyle(color: CupertinoColors.systemBlue), - ), - ), - CupertinoDialogAction( - isDestructiveAction: isDestructive, - onPressed: () { - Navigator.of(context).pop(); - onConfirm?.call(); - }, - child: Text( - confirmText, - style: const TextStyle(color: AppColors.negative), - ), - ), - ], - ), - ); -} diff --git a/lib/core/widgets/dialogs/forced_return_dialog.dart b/lib/core/widgets/dialogs/forced_return_dialog.dart deleted file mode 100644 index fc119e8f..00000000 --- a/lib/core/widgets/dialogs/forced_return_dialog.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:goms_design_system/goms_design_system.dart'; - -/// 후기 삭제 다이얼로그 -Future forcedReturn({ - required BuildContext context, - required String title, - required String content, - String cancelText = '취소', - String confirmText = '복귀', - VoidCallback? onConfirm, - bool isDestructive = false, -}) { - return showCupertinoDialog( - context: context, - barrierDismissible: true, - builder: (context) => CupertinoAlertDialog( - title: Text(title), - content: Text(content), - actions: [ - CupertinoDialogAction( - onPressed: () => Navigator.of(context).pop(), - child: Text( - cancelText, - style: const TextStyle(color: CupertinoColors.systemBlue), - ), - ), - CupertinoDialogAction( - isDestructiveAction: isDestructive, - onPressed: () { - Navigator.of(context).pop(); - onConfirm?.call(); - }, - child: Text( - confirmText, - style: const TextStyle( - color: AppColors.negative, - ), - ), - ), - ], - ), - ); -} diff --git a/lib/core/widgets/dialogs/force_outing_dialog.dart b/lib/core/widgets/dialogs/outing_action_dialog.dart similarity index 54% rename from lib/core/widgets/dialogs/force_outing_dialog.dart rename to lib/core/widgets/dialogs/outing_action_dialog.dart index 9740f853..a050b828 100644 --- a/lib/core/widgets/dialogs/force_outing_dialog.dart +++ b/lib/core/widgets/dialogs/outing_action_dialog.dart @@ -2,35 +2,48 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:goms_design_system/goms_design_system.dart'; -/// 강제외출 -Future forcedOuting({ +/// 외출/외출금지 관리용 확인 다이얼로그. +/// +/// 강제외출·복귀·외출금지·외출금지 해제는 문구와 버튼 라벨만 다르고 배색이 같아 +/// 하나로 합쳤다. 취소는 파랑, 확인은 [AppColors.negative]. +/// +/// [redContent]를 주면 [content]와 [content2] 사이에 강조색으로 끼워 넣는다. +Future showOutingActionDialog({ required BuildContext context, required String title, required String content, + required String confirmText, + String? redContent, + String? content2, String cancelText = '취소', - String confirmText = '외출', VoidCallback? onConfirm, bool isDestructive = false, }) { return showCupertinoDialog( context: context, barrierDismissible: true, - builder: (context) => CupertinoAlertDialog( + builder: (dialogContext) => CupertinoAlertDialog( title: Text(title), content: RichText( textAlign: TextAlign.center, text: TextSpan( style: TextStyle( - color: context.isLightMode ? Colors.black : Colors.white, + color: dialogContext.isLightMode ? Colors.black : Colors.white, ), children: [ TextSpan(text: content), + if (redContent != null) + TextSpan( + text: redContent, + style: const TextStyle(color: AppColors.negative), + ), + if (content2 != null) TextSpan(text: content2), ], ), ), actions: [ CupertinoDialogAction( - onPressed: () => Navigator.of(context).pop(), + onPressed: () => Navigator.of(dialogContext).pop(), child: Text( cancelText, style: const TextStyle(color: CupertinoColors.systemBlue), @@ -39,7 +52,7 @@ Future forcedOuting({ CupertinoDialogAction( isDestructiveAction: isDestructive, onPressed: () { - Navigator.of(context).pop(); + Navigator.of(dialogContext).pop(); onConfirm?.call(); }, child: Text( diff --git a/lib/features/auth/login/presentation/viewmodels/login_viewmodel.dart b/lib/features/auth/login/presentation/viewmodels/login_viewmodel.dart index 4bca1215..7e976907 100644 --- a/lib/features/auth/login/presentation/viewmodels/login_viewmodel.dart +++ b/lib/features/auth/login/presentation/viewmodels/login_viewmodel.dart @@ -4,7 +4,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:goms/app/router/route_path.dart'; import 'package:goms/core/enums/role_enum.dart'; import 'package:goms/core/network/network_exception.dart'; -import 'package:goms/core/providers/service_providers.dart'; +import 'package:goms/core/utils/settings_storage.dart'; import 'package:goms/core/utils/camera_launch_destination_resolver.dart'; import 'package:goms/core/utils/token_storage.dart'; import 'package:goms/features/auth/session/data/providers/session_data_providers.dart'; @@ -128,9 +128,7 @@ class LoginNotifier extends Notifier { Future resolvePostLoginNavigation() async { try { final currentMember = await ref.read(currentMemberProvider.future); - final settingsService = ref.read(settingsServiceProvider); - - final isCameraLaunchEnabled = await settingsService.getCameraLaunch(); + final isCameraLaunchEnabled = await SettingsStorage.getCameraLaunch(); final cameraPermissionStatus = await Permission.camera.status; final cameraLaunchRoute = CameraLaunchDestinationResolver.resolve( diff --git a/lib/features/late/presentation/providers/late_rank_students_provider.dart b/lib/features/late/presentation/providers/late_rank_students_provider.dart index d9c44216..0ac6b850 100644 --- a/lib/features/late/presentation/providers/late_rank_students_provider.dart +++ b/lib/features/late/presentation/providers/late_rank_students_provider.dart @@ -7,7 +7,8 @@ import 'package:goms/features/late/presentation/models/late_rank_student_model.d final lateRankStudentsProvider = AsyncNotifierProvider>( - LateRankStudentsNotifier.new); + LateRankStudentsNotifier.new, +); class LateRankStudentsNotifier extends AsyncNotifier> { diff --git a/lib/features/outing/presentation/screens/admin_outing_state_screen.dart b/lib/features/outing/presentation/screens/admin_outing_state_screen.dart index b2e8ceba..afc3277d 100644 --- a/lib/features/outing/presentation/screens/admin_outing_state_screen.dart +++ b/lib/features/outing/presentation/screens/admin_outing_state_screen.dart @@ -17,10 +17,7 @@ import 'package:goms/core/widgets/bottom_sheets/filter_button.dart'; import 'package:goms/core/widgets/buttons/qr_button.dart'; import 'package:goms/core/widgets/buttons/toggle_button.dart'; import 'package:goms/core/widgets/chips/category_chip.dart'; -import 'package:goms/core/widgets/dialogs/banned_outing_dialog.dart'; -import 'package:goms/core/widgets/dialogs/banned_outing_release_dialog.dart'; -import 'package:goms/core/widgets/dialogs/force_outing_dialog.dart'; -import 'package:goms/core/widgets/dialogs/forced_outing_release_dialog.dart'; +import 'package:goms/core/widgets/dialogs/outing_action_dialog.dart'; import 'package:goms/core/widgets/scaffolds/base_scaffold.dart'; class AdminOutingStateScreen extends ConsumerStatefulWidget { @@ -307,24 +304,7 @@ class MemberFilterSheetSelection { } } -final _memberFilterSelectionProvider = NotifierProvider.autoDispose.family< - _MemberFilterSelectionNotifier, - MemberFilterSheetSelection, - (Object, MemberFilterSheetSelection)>(_MemberFilterSelectionNotifier.new); - -class _MemberFilterSelectionNotifier - extends Notifier { - _MemberFilterSelectionNotifier(this.args); - - final (Object, MemberFilterSheetSelection) args; - - @override - MemberFilterSheetSelection build() => args.$2; - - void setSelection(MemberFilterSheetSelection selection) => state = selection; -} - -class MemberFilterBottomSheet extends ConsumerStatefulWidget { +class MemberFilterBottomSheet extends StatefulWidget { const MemberFilterBottomSheet({ super.key, this.initialSelection = const MemberFilterSheetSelection(), @@ -337,26 +317,22 @@ class MemberFilterBottomSheet extends ConsumerStatefulWidget { final VoidCallback? onReset; @override - ConsumerState createState() => + State createState() => _MemberFilterBottomSheetState(); } -class _MemberFilterBottomSheetState - extends ConsumerState { - late final Object _providerIdentity; - - (Object, MemberFilterSheetSelection) get _providerKey => - (_providerIdentity, widget.initialSelection); +class _MemberFilterBottomSheetState extends State { + late MemberFilterSheetSelection _selection; @override void initState() { super.initState(); - _providerIdentity = Object(); + _selection = widget.initialSelection; } @override Widget build(BuildContext context) { - final selection = ref.watch(_memberFilterSelectionProvider(_providerKey)); + final selection = _selection; return CommonBottomSheet( title: '필터', @@ -585,21 +561,13 @@ class _MemberFilterBottomSheetState } void _handleReset() { - ref - .read(_memberFilterSelectionProvider(_providerKey).notifier) - .setSelection(const MemberFilterSheetSelection()); + setState(() => _selection = const MemberFilterSheetSelection()); widget.onReset?.call(); Navigator.pop(context); } void _updateSelection(MemberFilterSheetSelection selection) { - ref - .read(_memberFilterSelectionProvider(_providerKey).notifier) - .setSelection(selection); - _notifySelectionChanged(selection); - } - - void _notifySelectionChanged(MemberFilterSheetSelection selection) { + setState(() => _selection = selection); widget.onApply?.call(selection); } } @@ -609,50 +577,7 @@ class _MemberFilterBottomSheetState // AdminBottomSheet eliminated — inlined as UserRoleBottomSheet(maxHeightRatio: 1) // --------------------------------------------------------------------------- -class _AdminOutingStudentState { - const _AdminOutingStudentState({ - required this.role, - required this.status, - }); - - final StudentRole role; - final String status; - - _AdminOutingStudentState copyWith({ - StudentRole? role, - String? status, - }) { - return _AdminOutingStudentState( - role: role ?? this.role, - status: status ?? this.status, - ); - } -} - -final _adminOutingStudentRoleProvider = NotifierProvider.autoDispose.family< - _AdminOutingStudentRoleNotifier, - _AdminOutingStudentState, - (Object, StudentRole, String)>(_AdminOutingStudentRoleNotifier.new); - -class _AdminOutingStudentRoleNotifier - extends Notifier<_AdminOutingStudentState> { - _AdminOutingStudentRoleNotifier(this.args); - - final (Object, StudentRole, String) args; - - @override - _AdminOutingStudentState build() => - _AdminOutingStudentState(role: args.$2, status: args.$3); - - void update({ - StudentRole? role, - String? status, - }) { - state = state.copyWith(role: role, status: status); - } -} - -class AdminOutingStateContainer extends ConsumerStatefulWidget { +class AdminOutingStateContainer extends StatefulWidget { final int memberId; final String name; final int grade; @@ -673,28 +598,36 @@ class AdminOutingStateContainer extends ConsumerStatefulWidget { }); @override - ConsumerState createState() => + State createState() => _AdminOutingStateContainerState(); } -class _AdminOutingStateContainerState - extends ConsumerState { - late final Object _providerIdentity; - - (Object, StudentRole, String) get _providerKey => - (_providerIdentity, widget.studentRole, widget.status); +class _AdminOutingStateContainerState extends State { + late StudentRole _studentRole; + late String _status; @override void initState() { super.initState(); - _providerIdentity = Object(); + _studentRole = widget.studentRole; + _status = widget.status; + } + + // 목록이 갱신돼 상위에서 새 값이 내려오면 로컬 편집 상태를 버리고 따라간다. + @override + void didUpdateWidget(covariant AdminOutingStateContainer oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.studentRole != widget.studentRole) { + _studentRole = widget.studentRole; + } + if (oldWidget.status != widget.status) { + _status = widget.status; + } } @override Widget build(BuildContext context) { - final studentState = - ref.watch(_adminOutingStudentRoleProvider(_providerKey)); - final studentRole = studentState.role; + final studentRole = _studentRole; return Container( color: context.backgroundColor, @@ -771,26 +704,12 @@ class _AdminOutingStateContainerState builder: (context) => UserRoleBottomSheet( memberId: widget.memberId, studentRole: studentRole, - status: studentState.status, + status: _status, maxHeightRatio: 1, - onRoleChanged: (newRole) { - ref - .read( - _adminOutingStudentRoleProvider( - _providerKey, - ).notifier, - ) - .update(role: newRole); - }, - onStatusChanged: (newStatus) { - ref - .read( - _adminOutingStudentRoleProvider( - _providerKey, - ).notifier, - ) - .update(status: newStatus); - }, + onRoleChanged: (newRole) => + setState(() => _studentRole = newRole), + onStatusChanged: (newStatus) => + setState(() => _status = newStatus), ), ); }, @@ -855,32 +774,6 @@ class _UserRoleBottomSheetStateModel { } } -final _userRoleBottomSheetStateProvider = NotifierProvider.autoDispose.family< - _UserRoleBottomSheetStateNotifier, - _UserRoleBottomSheetStateModel, - (Object, StudentRole, String)>( - _UserRoleBottomSheetStateNotifier.new, -); - -class _UserRoleBottomSheetStateNotifier - extends Notifier<_UserRoleBottomSheetStateModel> { - _UserRoleBottomSheetStateNotifier(this.args); - - final (Object, StudentRole, String) args; - - @override - _UserRoleBottomSheetStateModel build() => - _UserRoleBottomSheetStateModel.fromRole(args.$2, args.$3); - - void update( - _UserRoleBottomSheetStateModel Function( - _UserRoleBottomSheetStateModel state, - ) transform, - ) { - state = transform(state); - } -} - class UserRoleBottomSheet extends ConsumerStatefulWidget { const UserRoleBottomSheet({ super.key, @@ -905,20 +798,20 @@ class UserRoleBottomSheet extends ConsumerStatefulWidget { } class _UserRoleBottomSheetState extends ConsumerState { - late final Object _providerIdentity; - - (Object, StudentRole, String) get _providerKey => - (_providerIdentity, widget.studentRole, widget.status); + late _UserRoleBottomSheetStateModel _uiState; @override void initState() { super.initState(); - _providerIdentity = Object(); + _uiState = _UserRoleBottomSheetStateModel.fromRole( + widget.studentRole, + widget.status, + ); } @override Widget build(BuildContext context) { - final uiState = ref.watch(_userRoleBottomSheetStateProvider(_providerKey)); + final uiState = _uiState; return CommonBottomSheet( title: '유저 권한 변경', @@ -940,17 +833,19 @@ class _UserRoleBottomSheetState extends ConsumerState { ? (_) {} : (_) { if (uiState.isOuting) { - forcedOutingRelease( + showOutingActionDialog( context: context, title: '강제외출 복귀', content: '\n 학생을 복귀 상태로 변경하시겠습니까?', + confirmText: '복귀', onConfirm: _releaseForcedOuting, ); } else { - forcedOuting( + showOutingActionDialog( context: context, title: '강제외출', content: '\n 이 학생을 외출 상태로 변경하시겠습니까?', + confirmText: '외출', onConfirm: _forceOut, ); } @@ -973,21 +868,23 @@ class _UserRoleBottomSheetState extends ConsumerState { ? (_) {} : (value) { if (uiState.isOutingBanned) { - bannedOutingRelease( + showOutingActionDialog( context: context, title: '외출금지', content: '\n이 학생을', redContent: ' 외출금지 해제 ', content2: '시키겠습니까?', + confirmText: '외출 해제', onConfirm: () => _updateOutingAllowed(true), ); } else { - bannedOuting( + showOutingActionDialog( context: context, title: '외출금지', content: '\n이 학생을', redContent: ' 외출금지 ', content2: '시키겠습니까?', + confirmText: '외출 금지', onConfirm: () => _updateOutingAllowed(false), ); } @@ -1102,9 +999,7 @@ class _UserRoleBottomSheetState extends ConsumerState { Future Function() action, { VoidCallback? onSuccess, }) async { - if (ref - .read(_userRoleBottomSheetStateProvider(_providerKey)) - .isSubmitting) { + if (_uiState.isSubmitting) { return; } @@ -1123,20 +1018,20 @@ class _UserRoleBottomSheetState extends ConsumerState { } void _showError(String message) { + if (!mounted) return; ScaffoldMessenger.maybeOf(context)?.showSnackBar( SnackBar(content: Text(message)), ); } + // await 이후 finally에서도 호출되므로 mounted 확인이 필요하다. void _updateUiState( _UserRoleBottomSheetStateModel Function( _UserRoleBottomSheetStateModel state, ) transform, ) { - final notifier = ref.read( - _userRoleBottomSheetStateProvider(_providerKey).notifier, - ); - notifier.update(transform); + if (!mounted) return; + setState(() => _uiState = transform(_uiState)); } } diff --git a/lib/features/outing/presentation/screens/outing_state_screen.dart b/lib/features/outing/presentation/screens/outing_state_screen.dart index 39719301..3d607bcc 100644 --- a/lib/features/outing/presentation/screens/outing_state_screen.dart +++ b/lib/features/outing/presentation/screens/outing_state_screen.dart @@ -8,7 +8,7 @@ import 'package:goms/core/widgets/scaffolds/base_scaffold.dart'; import 'package:goms/core/widgets/buttons/qr_button.dart'; import 'package:intl/intl.dart'; import 'package:goms/core/utils/student_info_formatter.dart'; -import 'package:goms/core/widgets/dialogs/forced_return_dialog.dart'; +import 'package:goms/core/widgets/dialogs/outing_action_dialog.dart'; import 'package:goms/features/outing/presentation/widgets/user_manage_button.dart'; import 'package:goms/features/outing/domain/entities/outing_student_entity.dart'; import 'package:goms/features/outing/presentation/providers/current_outing_students_provider.dart'; @@ -318,8 +318,9 @@ class SearchProfileList extends ConsumerWidget { padding: const EdgeInsets.only(right: 4), child: IconButton( onPressed: () { - forcedReturn( + showOutingActionDialog( context: context, + confirmText: '복귀', title: '외출 강제 복귀', content: '\n외출자를 강제로 복귀시키겠습니까?', onConfirm: () async { diff --git a/lib/features/profile/data/providers/profile_data_providers.dart b/lib/features/profile/data/providers/profile_data_providers.dart index 9d38991b..54d295e6 100644 --- a/lib/features/profile/data/providers/profile_data_providers.dart +++ b/lib/features/profile/data/providers/profile_data_providers.dart @@ -1,34 +1,24 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:goms/core/providers/service_providers.dart'; import 'package:goms/features/notification/data/providers/notification_data_providers.dart'; -import 'package:goms/features/profile/data/repositories/notification_repository_impl.dart'; -import 'package:goms/features/profile/domain/repositories/notification_repository.dart'; import 'package:goms/features/profile/domain/usecases/enable_push_notification_usecase.dart'; import 'package:goms/features/profile/domain/usecases/disable_push_notification_usecase.dart'; import 'package:goms/features/profile/domain/usecases/enable_camera_launch_usecase.dart'; /// Profile 관련 Data Providers -final notificationRepositoryProvider = Provider((ref) { - return NotificationRepositoryImpl( - remoteDataSource: ref.watch(notificationDataSourceProvider), - ); -}); - final enablePushNotificationUseCaseProvider = Provider((ref) { return EnablePushNotificationUseCase( permissionService: ref.watch(permissionServiceProvider), - settingsService: ref.watch(settingsServiceProvider), - notificationRepository: ref.watch(notificationRepositoryProvider), + notificationDataSource: ref.watch(notificationDataSourceProvider), ); }); final disablePushNotificationUseCaseProvider = Provider((ref) { return DisablePushNotificationUseCase( - settingsService: ref.watch(settingsServiceProvider), - notificationRepository: ref.watch(notificationRepositoryProvider), + notificationDataSource: ref.watch(notificationDataSourceProvider), ); }); @@ -36,6 +26,5 @@ final enableCameraLaunchUseCaseProvider = Provider((ref) { return EnableCameraLaunchUseCase( permissionService: ref.watch(permissionServiceProvider), - settingsService: ref.watch(settingsServiceProvider), ); }); diff --git a/lib/features/profile/data/repositories/notification_repository_impl.dart b/lib/features/profile/data/repositories/notification_repository_impl.dart deleted file mode 100644 index cb41df5c..00000000 --- a/lib/features/profile/data/repositories/notification_repository_impl.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:goms/features/notification/data/datasources/notification_remote_datasource.dart'; -import 'package:goms/features/profile/domain/repositories/notification_repository.dart'; - -class NotificationRepositoryImpl implements NotificationRepository { - const NotificationRepositoryImpl({ - required NotificationRemoteDataSource remoteDataSource, - }) : _remoteDataSource = remoteDataSource; - - final NotificationRemoteDataSource _remoteDataSource; - - @override - Future registerDeviceToken() { - return _remoteDataSource.registerDeviceToken(); - } - - @override - Future deleteDeviceToken() { - return _remoteDataSource.deleteDeviceToken(); - } -} diff --git a/lib/features/profile/domain/repositories/notification_repository.dart b/lib/features/profile/domain/repositories/notification_repository.dart deleted file mode 100644 index e0ff76aa..00000000 --- a/lib/features/profile/domain/repositories/notification_repository.dart +++ /dev/null @@ -1,5 +0,0 @@ -abstract class NotificationRepository { - Future registerDeviceToken(); - - Future deleteDeviceToken(); -} diff --git a/lib/features/profile/domain/usecases/disable_push_notification_usecase.dart b/lib/features/profile/domain/usecases/disable_push_notification_usecase.dart index d894ed5d..0656564e 100644 --- a/lib/features/profile/domain/usecases/disable_push_notification_usecase.dart +++ b/lib/features/profile/domain/usecases/disable_push_notification_usecase.dart @@ -1,5 +1,5 @@ -import 'package:goms/core/domain/services/settings_service.dart'; -import 'package:goms/features/profile/domain/repositories/notification_repository.dart'; +import 'package:goms/core/utils/settings_storage.dart'; +import 'package:goms/features/notification/data/datasources/notification_remote_datasource.dart'; /// 푸시 알림 비활성화 UseCase /// @@ -7,20 +7,17 @@ import 'package:goms/features/profile/domain/repositories/notification_repositor /// 1. 기기 토큰을 서버에서 삭제 /// 2. 설정값 저장 class DisablePushNotificationUseCase { - final SettingsService _settingsService; - final NotificationRepository _notificationRepository; + final NotificationRemoteDataSource _notificationDataSource; DisablePushNotificationUseCase({ - required SettingsService settingsService, - required NotificationRepository notificationRepository, - }) : _settingsService = settingsService, - _notificationRepository = notificationRepository; + required NotificationRemoteDataSource notificationDataSource, + }) : _notificationDataSource = notificationDataSource; /// 푸시 알림 비활성화 Future call() async { try { - await _notificationRepository.deleteDeviceToken(); - await _settingsService.setOutingPushAlarm(false); + await _notificationDataSource.deleteDeviceToken(); + await SettingsStorage.setOutingPushAlarm(false); return true; } catch (_) { return false; diff --git a/lib/features/profile/domain/usecases/enable_camera_launch_usecase.dart b/lib/features/profile/domain/usecases/enable_camera_launch_usecase.dart index 7d0e507a..7a0e9f8e 100644 --- a/lib/features/profile/domain/usecases/enable_camera_launch_usecase.dart +++ b/lib/features/profile/domain/usecases/enable_camera_launch_usecase.dart @@ -1,24 +1,21 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:goms/core/domain/services/permission_service.dart'; -import 'package:goms/core/domain/services/settings_service.dart'; +import 'package:goms/core/utils/settings_storage.dart'; /// 카메라 자동 실행 활성화 UseCase -/// +/// /// 카메라 권한을 요청하고, 설정값을 저장합니다. class EnableCameraLaunchUseCase { final PermissionService _permissionService; - final SettingsService _settingsService; EnableCameraLaunchUseCase({ required PermissionService permissionService, - required SettingsService settingsService, - }) : _permissionService = permissionService, - _settingsService = settingsService; + }) : _permissionService = permissionService; /// 카메라 자동 실행 활성화 - /// + /// /// 카메라 권한을 요청하고, 권한이 있으면 설정값을 저장합니다. - /// + /// /// 반환값: true (성공) 또는 false (실패) Future call() async { try { @@ -29,7 +26,7 @@ class EnableCameraLaunchUseCase { return false; } - await _settingsService.setCameraLaunch(true); + await SettingsStorage.setCameraLaunch(true); return true; } catch (_) { return false; diff --git a/lib/features/profile/domain/usecases/enable_push_notification_usecase.dart b/lib/features/profile/domain/usecases/enable_push_notification_usecase.dart index 67653fe9..f5a6a204 100644 --- a/lib/features/profile/domain/usecases/enable_push_notification_usecase.dart +++ b/lib/features/profile/domain/usecases/enable_push_notification_usecase.dart @@ -1,7 +1,7 @@ import 'package:permission_handler/permission_handler.dart'; import 'package:goms/core/domain/services/permission_service.dart'; -import 'package:goms/core/domain/services/settings_service.dart'; -import 'package:goms/features/profile/domain/repositories/notification_repository.dart'; +import 'package:goms/core/utils/settings_storage.dart'; +import 'package:goms/features/notification/data/datasources/notification_remote_datasource.dart'; /// 푸시 알림 활성화 UseCase /// @@ -11,16 +11,13 @@ import 'package:goms/features/profile/domain/repositories/notification_repositor /// 3. 설정값 저장 class EnablePushNotificationUseCase { final PermissionService _permissionService; - final SettingsService _settingsService; - final NotificationRepository _notificationRepository; + final NotificationRemoteDataSource _notificationDataSource; EnablePushNotificationUseCase({ required PermissionService permissionService, - required SettingsService settingsService, - required NotificationRepository notificationRepository, + required NotificationRemoteDataSource notificationDataSource, }) : _permissionService = permissionService, - _settingsService = settingsService, - _notificationRepository = notificationRepository; + _notificationDataSource = notificationDataSource; /// 푸시 알림 활성화 /// @@ -37,8 +34,8 @@ class EnablePushNotificationUseCase { } try { - await _notificationRepository.registerDeviceToken(); - await _settingsService.setOutingPushAlarm(true); + await _notificationDataSource.registerDeviceToken(); + await SettingsStorage.setOutingPushAlarm(true); return true; } catch (_) { return false;