From c1c06f65d7b181ab379b6ef912a0fb884b1d3f11 Mon Sep 17 00:00:00 2001 From: wsw0922 Date: Fri, 26 Jun 2026 01:30:12 +0900 Subject: [PATCH 1/4] feat: add alarm settings entry to notice list --- assets/icons/alarm_setting.svg | 3 +++ lib/core/routing/router.dart | 8 +++++-- .../home/notice_list_page_screen.dart | 24 +++++++++++++++++-- 3 files changed, 31 insertions(+), 4 deletions(-) create mode 100644 assets/icons/alarm_setting.svg diff --git a/assets/icons/alarm_setting.svg b/assets/icons/alarm_setting.svg new file mode 100644 index 00000000..faf616f6 --- /dev/null +++ b/assets/icons/alarm_setting.svg @@ -0,0 +1,3 @@ + + + diff --git a/lib/core/routing/router.dart b/lib/core/routing/router.dart index 743d78df..41b470c4 100644 --- a/lib/core/routing/router.dart +++ b/lib/core/routing/router.dart @@ -581,7 +581,11 @@ final router = GoRouter( pageBuilder: (context, state) { return MaterialPage( key: state.pageKey, - child: const NoticeListPageScreen(), + child: NoticeListPageScreen( + onTapAlarmSetting: () { + context.push(RoutePaths.notification); + }, + ), ); }, ), @@ -793,7 +797,7 @@ final router = GoRouter( context.push(RoutePaths.mypage + RoutePaths.mypageBlock); }, onTapNoticeKeyword: () { - context.push(RoutePaths.mypage + RoutePaths.noticeKeyword); + context.push(RoutePaths.notification); }, onTapSocialLoginConnect: () { context.push(RoutePaths.mypage + RoutePaths.socialLoginConnect); diff --git a/lib/presentation/home/notice_list_page_screen.dart b/lib/presentation/home/notice_list_page_screen.dart index 9dfb16d3..ed47e316 100644 --- a/lib/presentation/home/notice_list_page_screen.dart +++ b/lib/presentation/home/notice_list_page_screen.dart @@ -12,11 +12,17 @@ import 'package:dongsoop/ui/color_styles.dart'; import 'package:dongsoop/ui/text_styles.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_svg/svg.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class NoticeListPageScreen extends HookConsumerWidget { - const NoticeListPageScreen({super.key}); + final VoidCallback onTapAlarmSetting; + + const NoticeListPageScreen({ + super.key, + required this.onTapAlarmSetting, + }); NoticeTab selectedTab(int index) { return NoticeTab.values[index]; @@ -67,7 +73,21 @@ class NoticeListPageScreen extends HookConsumerWidget { return SafeArea( child: Scaffold( backgroundColor: ColorStyles.white, - appBar: const DetailHeader(title: '공지'), + appBar: DetailHeader( + title: '공지', + trailing: IconButton( + onPressed: onTapAlarmSetting, + icon: SvgPicture.asset( + 'assets/icons/alarm_setting.svg', + width: 24, + height: 24, + colorFilter: const ColorFilter.mode( + ColorStyles.black, + BlendMode.srcIn, + ), + ), + ), + ), body: GestureDetector( behavior: HitTestBehavior.translucent, onTap: () { From 4907b9f0e605334547d5ec32a3fa9ab2bb3682c4 Mon Sep 17 00:00:00 2001 From: wsw0922 Date: Fri, 26 Jun 2026 03:28:30 +0900 Subject: [PATCH 2/4] refactor: move notification setting out of setting page --- lib/core/routing/router.dart | 42 +- .../app/marketing_push_guard.dart | 4 +- lib/presentation/my_page/my_page_screen.dart | 65 ++-- .../my_page/widgets/logged_in_user_card.dart | 8 +- .../widgets/logged_out_prompt_card.dart | 18 +- .../notice/keyword/notice_keyword_screen.dart | 39 +- .../notification/notification_screen.dart | 367 ++++++++++++++++++ .../notification_setting_provider.dart | 0 ...otification_setting_use_case_provider.dart | 4 +- .../notification_setting_state.dart | 2 +- .../notification_setting_view_model.dart | 6 +- .../notification_setting_view_model.g.dart | 0 .../view_model/notification_types.dart | 0 .../widget/notification_section.dart | 0 .../widget/notification_toggle_row.dart | 0 .../notification/notification_screen.dart | 341 ---------------- lib/presentation/setting/setting_screen.dart | 69 ++-- 17 files changed, 512 insertions(+), 453 deletions(-) create mode 100644 lib/presentation/notification/notification_screen.dart rename lib/presentation/{setting => }/notification/providers/notification_setting_provider.dart (100%) rename lib/presentation/{setting => }/notification/providers/notification_setting_use_case_provider.dart (93%) rename lib/presentation/{setting => }/notification/view_model/notification_setting_state.dart (92%) rename lib/presentation/{setting => }/notification/view_model/notification_setting_view_model.dart (98%) rename lib/presentation/{setting => }/notification/view_model/notification_setting_view_model.g.dart (100%) rename lib/presentation/{setting => }/notification/view_model/notification_types.dart (100%) rename lib/presentation/{setting => }/notification/widget/notification_section.dart (100%) rename lib/presentation/{setting => }/notification/widget/notification_toggle_row.dart (100%) delete mode 100644 lib/presentation/setting/notification/notification_screen.dart diff --git a/lib/core/routing/router.dart b/lib/core/routing/router.dart index 41b470c4..7a9edaf8 100644 --- a/lib/core/routing/router.dart +++ b/lib/core/routing/router.dart @@ -43,7 +43,7 @@ import 'package:dongsoop/presentation/my_page/my_page_screen.dart'; import 'package:dongsoop/presentation/report/report_screen.dart'; import 'package:dongsoop/presentation/my_page/feedback/feedback_more_screen.dart'; import 'package:dongsoop/presentation/setting/device_management/device_management_screen.dart'; -import 'package:dongsoop/presentation/setting/notification/notification_screen.dart'; +import 'package:dongsoop/presentation/notification/notification_screen.dart'; import 'package:dongsoop/presentation/setting/setting_screen.dart'; import 'package:dongsoop/presentation/sign_in/password_reset_screen.dart'; import 'package:dongsoop/presentation/sign_in/sign_in_screen.dart'; @@ -263,23 +263,21 @@ final router = GoRouter( ), ), GoRoute( - path: RoutePaths.adminReportSanction, - builder: (context, state) { - final extra = state.extra as Map?; - final reportId = extra?['reportId'] as int? ?? 0; - final targetMemberId = extra?['targetMemberId'] as int? ?? 0; + path: RoutePaths.adminReportSanction, + builder: (context, state) { + final extra = state.extra as Map?; + final reportId = extra?['reportId'] as int? ?? 0; + final targetMemberId = extra?['targetMemberId'] as int? ?? 0; - return ReportAdminSanctionScreen( - reportId: reportId, - targetMemberId: targetMemberId, - ); - }), + return ReportAdminSanctionScreen( + reportId: reportId, + targetMemberId: targetMemberId, + ); + } + ), GoRoute( path: RoutePaths.setting, builder: (context, state) => SettingScreen( - onTapNotification: () { - context.push(RoutePaths.notification); - }, onTapDevice: () { context.push(RoutePaths.deviceManagement); }, @@ -288,7 +286,15 @@ final router = GoRouter( ), GoRoute( path: RoutePaths.notification, - builder: (context, state) => NotificationScreen(), + builder: (context, state) => NotificationScreen( + onTapNoticeKeyword: () => context.replace(RoutePaths.noticeKeyword), + ), + ), + GoRoute( + path: RoutePaths.noticeKeyword, + builder: (context, state) => NoticeKeywordScreen( + onTapNotification: () => context.replace(RoutePaths.notification), + ), ), GoRoute( path: RoutePaths.deviceManagement, @@ -709,10 +715,6 @@ final router = GoRouter( onTapPasswordReset: () => context.push(RoutePaths.passwordReset), ), ), - GoRoute( - path: RoutePaths.noticeKeyword, - builder: (context, state) => const NoticeKeywordScreen(), - ), GoRoute( path: RoutePaths.socialLoginConnect, builder: (context, state) => SocialLoginConnectScreen(), @@ -796,7 +798,7 @@ final router = GoRouter( onTapBlockedUser: () { context.push(RoutePaths.mypage + RoutePaths.mypageBlock); }, - onTapNoticeKeyword: () { + onTapNotification: () { context.push(RoutePaths.notification); }, onTapSocialLoginConnect: () { diff --git a/lib/presentation/app/marketing_push_guard.dart b/lib/presentation/app/marketing_push_guard.dart index a52cd391..0e8b7828 100644 --- a/lib/presentation/app/marketing_push_guard.dart +++ b/lib/presentation/app/marketing_push_guard.dart @@ -1,7 +1,7 @@ import 'package:dongsoop/core/storage/preferences_service.dart'; import 'package:dongsoop/domain/notification/enum/notification_target.dart'; -import 'package:dongsoop/presentation/setting/notification/view_model/notification_setting_view_model.dart'; -import 'package:dongsoop/presentation/setting/notification/view_model/notification_types.dart'; +import 'package:dongsoop/presentation/notification/view_model/notification_setting_view_model.dart'; +import 'package:dongsoop/presentation/notification/view_model/notification_types.dart'; import 'package:dongsoop/providers/auth_providers.dart'; import 'package:dongsoop/providers/device_providers.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; diff --git a/lib/presentation/my_page/my_page_screen.dart b/lib/presentation/my_page/my_page_screen.dart index 5456ed83..b23dee5f 100644 --- a/lib/presentation/my_page/my_page_screen.dart +++ b/lib/presentation/my_page/my_page_screen.dart @@ -21,7 +21,7 @@ class MyPageScreen extends HookConsumerWidget { final VoidCallback onTapMarket; final void Function(bool isApply) onTapRecruit; final VoidCallback onTapBlockedUser; - final VoidCallback onTapNoticeKeyword; + final VoidCallback onTapNotification; final VoidCallback onTapSocialLoginConnect; const MyPageScreen({ @@ -37,7 +37,7 @@ class MyPageScreen extends HookConsumerWidget { required this.onTapMarket, required this.onTapRecruit, required this.onTapBlockedUser, - required this.onTapNoticeKeyword, + required this.onTapNotification, required this.onTapSocialLoginConnect, }); @@ -76,37 +76,38 @@ class MyPageScreen extends HookConsumerWidget { ) ), body: SafeArea( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), - child: myPageState.when( - data: (user) { - if (user == null) { - return LoggedOutPromptCard( - onTapLogin: onTapSignIn, - onTapUserFeedback: onTapUserFeedback, - ); - } else { - return LoggedInUserCard( - user: user, - onTapAdminReport: onTapAdminReport, - onTapMarket: onTapMarket, - onTapRecruit: onTapRecruit, - onTapCalendar: onTapCalendar, - onTapTimetable: onTapTimetable, - onTapBlockedUser: onTapBlockedUser, - onTapAdminBlindDate: onTapAdminBlindDate, - onTapAdminFeedback: onTapAdminFeedback, - onTapUserFeedback: onTapUserFeedback, - onTapNoticeKeyword: onTapNoticeKeyword, - onTapSocialLoginConnect: onTapSocialLoginConnect, - ); - } - }, - error: (e, _) => Center(child: Text('$e', style: TextStyles.normalTextRegular.copyWith(color: ColorStyles.black),)), - loading: () => Center(child: CircularProgressIndicator()), - ), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16), + child: myPageState.when( + data: (user) { + if (user == null) { + return LoggedOutPromptCard( + onTapLogin: onTapSignIn, + onTapNotification: onTapNotification, + onTapUserFeedback: onTapUserFeedback, + ); + } else { + return LoggedInUserCard( + user: user, + onTapAdminReport: onTapAdminReport, + onTapMarket: onTapMarket, + onTapRecruit: onTapRecruit, + onTapCalendar: onTapCalendar, + onTapTimetable: onTapTimetable, + onTapBlockedUser: onTapBlockedUser, + onTapAdminBlindDate: onTapAdminBlindDate, + onTapAdminFeedback: onTapAdminFeedback, + onTapUserFeedback: onTapUserFeedback, + onTapNotification: onTapNotification, + onTapSocialLoginConnect: onTapSocialLoginConnect, + ); + } + }, + error: (e, _) => Center(child: Text('$e', style: TextStyles.normalTextRegular.copyWith(color: ColorStyles.black),)), + loading: () => Center(child: CircularProgressIndicator()), ), + ), ), ), ); diff --git a/lib/presentation/my_page/widgets/logged_in_user_card.dart b/lib/presentation/my_page/widgets/logged_in_user_card.dart index 5f3dc1e4..6450bf8c 100644 --- a/lib/presentation/my_page/widgets/logged_in_user_card.dart +++ b/lib/presentation/my_page/widgets/logged_in_user_card.dart @@ -16,7 +16,7 @@ class LoggedInUserCard extends HookConsumerWidget { final VoidCallback onTapTimetable; final void Function(bool isApply) onTapRecruit; final VoidCallback onTapBlockedUser; - final VoidCallback onTapNoticeKeyword; + final VoidCallback onTapNotification; final VoidCallback onTapSocialLoginConnect; const LoggedInUserCard({ @@ -31,7 +31,7 @@ class LoggedInUserCard extends HookConsumerWidget { required this.onTapTimetable, required this.onTapRecruit, required this.onTapBlockedUser, - required this.onTapNoticeKeyword, + required this.onTapNotification, required this.onTapSocialLoginConnect, }); @@ -182,8 +182,8 @@ class LoggedInUserCard extends HookConsumerWidget { Column( children: [ MyActivityItem( - label: '공지 키워드 알림', - onTap: onTapNoticeKeyword, + label: '알림 설정', + onTap: onTapNotification, ), MyActivityItem( label: '소셜 계정 연동', diff --git a/lib/presentation/my_page/widgets/logged_out_prompt_card.dart b/lib/presentation/my_page/widgets/logged_out_prompt_card.dart index 1c381bbd..7a6af182 100644 --- a/lib/presentation/my_page/widgets/logged_out_prompt_card.dart +++ b/lib/presentation/my_page/widgets/logged_out_prompt_card.dart @@ -6,11 +6,13 @@ import 'package:dongsoop/ui/text_styles.dart'; class LoggedOutPromptCard extends StatelessWidget { final VoidCallback onTapLogin; + final VoidCallback onTapNotification; final VoidCallback onTapUserFeedback; const LoggedOutPromptCard({ super.key, required this.onTapLogin, + required this.onTapNotification, required this.onTapUserFeedback, }); @@ -69,7 +71,7 @@ class LoggedOutPromptCard extends StatelessWidget { ), ), - const SizedBox(height: 16), + const SizedBox(height: 24), Container( width: double.infinity, @@ -80,9 +82,17 @@ class LoggedOutPromptCard extends StatelessWidget { ), ), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: MyActivityItem( - label: '피드백 하러가기', - onTap: onTapUserFeedback, + child: Column( + children: [ + MyActivityItem( + label: '알림 설정', + onTap: onTapNotification, + ), + MyActivityItem( + label: '피드백 하러가기', + onTap: onTapUserFeedback, + ), + ], ), ), ], diff --git a/lib/presentation/notice/keyword/notice_keyword_screen.dart b/lib/presentation/notice/keyword/notice_keyword_screen.dart index 8b78539c..a19241e1 100644 --- a/lib/presentation/notice/keyword/notice_keyword_screen.dart +++ b/lib/presentation/notice/keyword/notice_keyword_screen.dart @@ -1,3 +1,4 @@ +import 'package:dongsoop/core/presentation/components/category_tab_bar.dart'; import 'package:dongsoop/core/presentation/components/custom_confirm_dialog.dart'; import 'package:dongsoop/core/presentation/components/detail_header.dart'; import 'package:dongsoop/core/presentation/components/login_required_dialog.dart'; @@ -12,7 +13,12 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class NoticeKeywordScreen extends HookConsumerWidget { - const NoticeKeywordScreen({super.key}); + final VoidCallback onTapNotification; + + const NoticeKeywordScreen({ + super.key, + required this.onTapNotification, + }); @override Widget build(BuildContext context, WidgetRef ref) { @@ -47,7 +53,7 @@ class NoticeKeywordScreen extends HookConsumerWidget { child: Scaffold( backgroundColor: ColorStyles.white, appBar: DetailHeader( - title: '공지 키워드 알림', + title: '알림 설정', bottom: TabBar( overlayColor: const WidgetStatePropertyAll(Colors.transparent), labelColor: ColorStyles.primary100, @@ -64,8 +70,10 @@ class NoticeKeywordScreen extends HookConsumerWidget { ), body: SafeArea( child: state.isLoading && state.keywords.isEmpty - ? const Center(child: CircularProgressIndicator(color: ColorStyles.primaryColor,)) - : GestureDetector( + ? const Center(child: CircularProgressIndicator(color: ColorStyles.primaryColor,)) + : Stack( + children: [ + GestureDetector( onTap: () => FocusScope.of(context).unfocus(), behavior: HitTestBehavior.opaque, child: TabBarView( @@ -90,7 +98,28 @@ class NoticeKeywordScreen extends HookConsumerWidget { ), ], ), - ), + ), + Positioned( + left: 0, + right: 0, + bottom: 24, + child: Center( + child: CategoryTabBar( + tabs: const ['전체', '키워드'], + selectedIndex: 1, + onSelected: (i) { + if (i == 1) return; + + Future.microtask(() async { + onTapNotification(); + }); + }, + isBoard: false, + ), + ), + ), + ], + ), ), ), ); diff --git a/lib/presentation/notification/notification_screen.dart b/lib/presentation/notification/notification_screen.dart new file mode 100644 index 00000000..b72d3a55 --- /dev/null +++ b/lib/presentation/notification/notification_screen.dart @@ -0,0 +1,367 @@ +import 'package:dongsoop/core/presentation/components/category_tab_bar.dart'; +import 'package:dongsoop/ui/text_styles.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:dongsoop/core/presentation/components/detail_header.dart'; +import 'package:dongsoop/presentation/notification/view_model/notification_setting_view_model.dart'; +import 'package:dongsoop/presentation/notification/view_model/notification_types.dart'; +import 'package:dongsoop/presentation/notification/widget/notification_section.dart'; +import 'package:dongsoop/presentation/notification/widget/notification_toggle_row.dart'; +import 'package:dongsoop/providers/auth_providers.dart'; +import 'package:dongsoop/providers/device_providers.dart'; +import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/domain/notification/enum/notification_target.dart'; + +class NotificationScreen extends ConsumerStatefulWidget { + final VoidCallback onTapNoticeKeyword; + + const NotificationScreen({ + super.key, + required this.onTapNoticeKeyword, + }); + + @override + ConsumerState createState() => _NotificationScreenState(); +} + +class _NotificationScreenState extends ConsumerState { + + void _showSnack(BuildContext context, String message) { + final messenger = ScaffoldMessenger.of(context); + messenger.hideCurrentSnackBar(); + messenger.showSnackBar( + SnackBar( + content: Text(message, style: TextStyles.normalTextRegular), + backgroundColor: ColorStyles.gray3, + ), + ); + } + + String _todayLabel() { + final now = DateTime.now(); + return '${now.month}월 ${now.day}일'; + } + + String _consentMessage({ + required String label, + required bool enabled, + }) { + return '${_todayLabel()} $label 알림 ' + '${enabled ? '동의했어요' : '동의를 거부했어요'}'; + } + + @override + void initState() { + super.initState(); + + WidgetsBinding.instance.addPostFrameCallback((_) async { + final user = ref.read(userSessionProvider); + final target = + user != null ? NotificationTarget.user : NotificationTarget.guest; + + final deviceToken = + await ref.read(getFcmTokenUseCaseProvider).execute(); + if (deviceToken == null || deviceToken.isEmpty) return; + + try { + await ref + .read(notificationSettingViewModelProvider.notifier) + .fetchSettings( + target: target, + deviceToken: deviceToken, + ); + } catch (_) {} + }); + } + + @override + Widget build(BuildContext context) { + final user = ref.watch(userSessionProvider); + final target = + user != null ? NotificationTarget.user : NotificationTarget.guest; + + final isAdmin = user != null && user.role.contains('ADMIN'); + + final state = ref.watch(notificationSettingViewModelProvider); + final vm = ref.read(notificationSettingViewModelProvider.notifier); + + if (state.error != null) { + return Scaffold( + backgroundColor: Colors.white, + appBar: const DetailHeader( + title: '알림 설정', + backgroundColor: Colors.white, + ), + body: Center( + child: Text( + state.error!, + style: TextStyles.normalTextRegular.copyWith(color: ColorStyles.black), + textAlign: TextAlign.center, + ), + ), + ); + } + + final getTokenUseCase = ref.read(getFcmTokenUseCaseProvider); + + Future _requireDeviceToken() async { + final deviceToken = await getTokenUseCase.execute(); + if (deviceToken == null || deviceToken.isEmpty) { + _showSnack(context, '오류가 발생했어요. 잠시 후 다시 시도해 주세요.'); + return null; + } + return deviceToken; + } + + Future onToggle({ + required String label, + required String type, + required bool nextValue, + }) async { + final deviceToken = await _requireDeviceToken(); + if (deviceToken == null) return; + + try { + await vm.setToggle( + target: target, + deviceToken: deviceToken, + notificationType: type, + nextValue: nextValue, + ); + _showSnack( + context, + _consentMessage(label: label, enabled: nextValue), + ); + } catch (e) { + _showSnack(context, e.toString()); + } + } + + Future onRecruitApplyToggle(bool nextValue) async { + final deviceToken = await _requireDeviceToken(); + if (deviceToken == null) return; + + try { + await vm.setRecruitApplyToggle( + target: target, + deviceToken: deviceToken, + nextValue: nextValue, + ); + _showSnack( + context, + _consentMessage(label: '지원 현황', enabled: nextValue), + ); + } catch (e) { + _showSnack(context, e.toString()); + } + } + + Future onRecruitResultToggle(bool nextValue) async { + final deviceToken = await _requireDeviceToken(); + if (deviceToken == null) return; + + try { + await vm.setRecruitResultToggle( + target: target, + deviceToken: deviceToken, + nextValue: nextValue, + ); + _showSnack( + context, + _consentMessage(label: '지원 결과', enabled: nextValue), + ); + } catch (e) { + _showSnack(context, e.toString()); + } + } + + return Scaffold( + backgroundColor: ColorStyles.white, + appBar: const DetailHeader( + title: '알림 설정', + backgroundColor: ColorStyles.white, + ), + body: SafeArea( + child: Stack( + children: [ + ListView( + children: [ + NotificationSection( + title: '공지사항', + subtitle: '교내 공지를 빠르게 확인할 수 있어요', + children: [ + NotificationToggleRow( + label: '공지', + value: state.isEnabled(NotificationTypes.notice), + loading: state.isLoading(NotificationTypes.notice), + onChanged: (v) => onToggle( + label: '공지', + type: NotificationTypes.notice, + nextValue: v, + ), + ), + ], + ), + const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), + + NotificationSection( + title: '오늘의 일정', + subtitle: '매일 아침 8시에 오늘 일정을 확인할 수 있어요', + children: [ + NotificationToggleRow( + label: '시간표', + value: state.isEnabled(NotificationTypes.timetable), + loading: state.isLoading(NotificationTypes.timetable), + onChanged: (v) => onToggle( + label: '시간표', + type: NotificationTypes.timetable, + nextValue: v, + ), + ), + NotificationToggleRow( + label: '일정', + value: state.isEnabled(NotificationTypes.calendar), + loading: state.isLoading(NotificationTypes.calendar), + onChanged: (v) => onToggle( + label: '일정', + type: NotificationTypes.calendar, + nextValue: v, + ), + ), + ], + ), + const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), + + if (user != null) + NotificationSection( + title: '모집 알림', + subtitle: '모집 지원·결과를 빠르게 알려드려요.', + children: [ + NotificationToggleRow( + label: '지원 현황', + value: state.recruitApplyEnabled, + loading: state.recruitApplyLoading, + onChanged: onRecruitApplyToggle, + ), + const SizedBox(height: 4), + NotificationToggleRow( + label: '지원 결과', + value: state.recruitResultEnabled, + loading: state.recruitResultLoading, + onChanged: onRecruitResultToggle, + ), + ], + ), + + const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), + + if (user != null) + NotificationSection( + title: '채팅 알림', + subtitle: '채팅을 빠르게 확인할 수 있어요', + children: [ + NotificationToggleRow( + label: '새로운 채팅', + value: state.isEnabled(NotificationTypes.chat), + loading: state.isLoading(NotificationTypes.chat), + onChanged: (v) => onToggle( + label: '새로운 채팅', + type: NotificationTypes.chat, + nextValue: v, + ), + ), + ], + ), + + const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), + + if (user != null) ...[ + NotificationSection( + title: '보안', + subtitle: '계정 보안을 위한 알림을 설정할 수 있어요', + children: [ + NotificationToggleRow( + label: '새로운 기기 로그인', + value: state.isEnabled(NotificationTypes.newDevice), + loading: state.isLoading(NotificationTypes.newDevice), + onChanged: (v) => onToggle( + label: '새로운 기기 로그인', + type: NotificationTypes.newDevice, + nextValue: v, + ), + ), + ], + ), + ], + const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), + + NotificationSection( + title: '기타', + subtitle: '과팅 오픈 소식을 확인할 수 있어요', + children: [ + NotificationToggleRow( + label: '광고성 푸시 알림', + value: state.isEnabled(NotificationTypes.marketing), + loading: state.isLoading(NotificationTypes.marketing), + onChanged: (v) => onToggle( + label: '광고성 푸시 알림', + type: NotificationTypes.marketing, + nextValue: v, + ), + ), + if (user != null) ...[ + const SizedBox(height: 4), + NotificationToggleRow( + label: '과팅 오픈', + value: state.isEnabled(NotificationTypes.blinddate), + loading: state.isLoading(NotificationTypes.blinddate), + onChanged: (v) => onToggle( + label: '과팅 오픈', + type: NotificationTypes.blinddate, + nextValue: v, + ), + ), + ], + if (isAdmin) ...[ + const SizedBox(height: 4), + NotificationToggleRow( + label: '피드백 도착', + value: state.isEnabled(NotificationTypes.feedback), + loading: state.isLoading(NotificationTypes.feedback), + onChanged: (v) => onToggle( + label: '피드백 도착', + type: NotificationTypes.feedback, + nextValue: v, + ), + ), + ], + ], + ), + ], + ), + if (user != null) + Positioned( + left: 0, + right: 0, + bottom: 24, + child: Center( + child: CategoryTabBar( + tabs: const ['전체', '키워드'], + selectedIndex: 0, + onSelected: (i) { + if (i == 0) return; + + Future.microtask(() async { + widget.onTapNoticeKeyword(); + }); + }, + isBoard: false, + ), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/presentation/setting/notification/providers/notification_setting_provider.dart b/lib/presentation/notification/providers/notification_setting_provider.dart similarity index 100% rename from lib/presentation/setting/notification/providers/notification_setting_provider.dart rename to lib/presentation/notification/providers/notification_setting_provider.dart diff --git a/lib/presentation/setting/notification/providers/notification_setting_use_case_provider.dart b/lib/presentation/notification/providers/notification_setting_use_case_provider.dart similarity index 93% rename from lib/presentation/setting/notification/providers/notification_setting_use_case_provider.dart rename to lib/presentation/notification/providers/notification_setting_use_case_provider.dart index eb4848cf..0698010d 100644 --- a/lib/presentation/setting/notification/providers/notification_setting_use_case_provider.dart +++ b/lib/presentation/notification/providers/notification_setting_use_case_provider.dart @@ -1,12 +1,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; - import 'package:dongsoop/domain/notification/use_case/notification_disable_use_case.dart'; import 'package:dongsoop/domain/notification/use_case/notification_enable_use_case.dart'; import 'package:dongsoop/domain/notification/use_case/notification_setting_use_case.dart'; import 'package:dongsoop/domain/notification/use_case/notification_setting_apply_use_case.dart'; import 'package:dongsoop/domain/notification/use_case/notification_setting_result_use_case.dart'; - -import 'package:dongsoop/presentation/setting/notification/providers/notification_setting_provider.dart'; +import 'package:dongsoop/presentation/notification/providers/notification_setting_provider.dart'; final notificationSettingUseCaseProvider = Provider((ref) { diff --git a/lib/presentation/setting/notification/view_model/notification_setting_state.dart b/lib/presentation/notification/view_model/notification_setting_state.dart similarity index 92% rename from lib/presentation/setting/notification/view_model/notification_setting_state.dart rename to lib/presentation/notification/view_model/notification_setting_state.dart index fd1d8613..b376dd10 100644 --- a/lib/presentation/setting/notification/view_model/notification_setting_state.dart +++ b/lib/presentation/notification/view_model/notification_setting_state.dart @@ -1,4 +1,4 @@ -import 'package:dongsoop/presentation/setting/notification/view_model/notification_types.dart'; +import 'package:dongsoop/presentation/notification/view_model/notification_types.dart'; class NotificationSettingState { final Map enabled; diff --git a/lib/presentation/setting/notification/view_model/notification_setting_view_model.dart b/lib/presentation/notification/view_model/notification_setting_view_model.dart similarity index 98% rename from lib/presentation/setting/notification/view_model/notification_setting_view_model.dart rename to lib/presentation/notification/view_model/notification_setting_view_model.dart index a50e0dd1..8895d7d1 100644 --- a/lib/presentation/setting/notification/view_model/notification_setting_view_model.dart +++ b/lib/presentation/notification/view_model/notification_setting_view_model.dart @@ -1,16 +1,12 @@ import 'package:flutter/foundation.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; - -import 'package:dongsoop/presentation/setting/notification/providers/notification_setting_provider.dart'; - +import 'package:dongsoop/presentation/notification/providers/notification_setting_provider.dart'; import 'package:dongsoop/domain/notification/entity/notification_enable_entity.dart'; import 'package:dongsoop/domain/notification/entity/notification_recruit_entity.dart'; import 'package:dongsoop/domain/notification/enum/notification_target.dart'; import 'package:dongsoop/domain/notification/repository/notification_setting_repository.dart'; - import 'notification_setting_state.dart'; import 'notification_types.dart'; - part 'notification_setting_view_model.g.dart'; @riverpod diff --git a/lib/presentation/setting/notification/view_model/notification_setting_view_model.g.dart b/lib/presentation/notification/view_model/notification_setting_view_model.g.dart similarity index 100% rename from lib/presentation/setting/notification/view_model/notification_setting_view_model.g.dart rename to lib/presentation/notification/view_model/notification_setting_view_model.g.dart diff --git a/lib/presentation/setting/notification/view_model/notification_types.dart b/lib/presentation/notification/view_model/notification_types.dart similarity index 100% rename from lib/presentation/setting/notification/view_model/notification_types.dart rename to lib/presentation/notification/view_model/notification_types.dart diff --git a/lib/presentation/setting/notification/widget/notification_section.dart b/lib/presentation/notification/widget/notification_section.dart similarity index 100% rename from lib/presentation/setting/notification/widget/notification_section.dart rename to lib/presentation/notification/widget/notification_section.dart diff --git a/lib/presentation/setting/notification/widget/notification_toggle_row.dart b/lib/presentation/notification/widget/notification_toggle_row.dart similarity index 100% rename from lib/presentation/setting/notification/widget/notification_toggle_row.dart rename to lib/presentation/notification/widget/notification_toggle_row.dart diff --git a/lib/presentation/setting/notification/notification_screen.dart b/lib/presentation/setting/notification/notification_screen.dart deleted file mode 100644 index 06c5d4cb..00000000 --- a/lib/presentation/setting/notification/notification_screen.dart +++ /dev/null @@ -1,341 +0,0 @@ -import 'package:dongsoop/ui/text_styles.dart'; -import 'package:flutter/material.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -import 'package:dongsoop/core/presentation/components/detail_header.dart'; -import 'package:dongsoop/presentation/setting/notification/view_model/notification_setting_view_model.dart'; -import 'package:dongsoop/presentation/setting/notification/view_model/notification_types.dart'; -import 'package:dongsoop/presentation/setting/notification/widget/notification_section.dart'; -import 'package:dongsoop/presentation/setting/notification/widget/notification_toggle_row.dart'; -import 'package:dongsoop/providers/auth_providers.dart'; -import 'package:dongsoop/providers/device_providers.dart'; -import 'package:dongsoop/ui/color_styles.dart'; - -import 'package:dongsoop/domain/notification/enum/notification_target.dart'; - -class NotificationScreen extends ConsumerStatefulWidget { - const NotificationScreen({super.key}); - - @override - ConsumerState createState() => - _NotificationScreenState(); -} - -class _NotificationScreenState - extends ConsumerState { - void _showSnack(BuildContext context, String message) { - final messenger = ScaffoldMessenger.of(context); - messenger.hideCurrentSnackBar(); - messenger.showSnackBar( - SnackBar( - content: Text(message, style: TextStyles.normalTextRegular), - backgroundColor: ColorStyles.gray3, - ), - ); - } - - String _todayLabel() { - final now = DateTime.now(); - return '${now.month}월 ${now.day}일'; - } - - String _consentMessage({ - required String label, - required bool enabled, - }) { - return '${_todayLabel()} $label 알림 ' - '${enabled ? '동의했어요' : '동의를 거부했어요'}'; - } - - @override - void initState() { - super.initState(); - - WidgetsBinding.instance.addPostFrameCallback((_) async { - final user = ref.read(userSessionProvider); - final target = - user != null ? NotificationTarget.user : NotificationTarget.guest; - - final deviceToken = - await ref.read(getFcmTokenUseCaseProvider).execute(); - if (deviceToken == null || deviceToken.isEmpty) return; - - try { - await ref - .read(notificationSettingViewModelProvider.notifier) - .fetchSettings( - target: target, - deviceToken: deviceToken, - ); - } catch (_) {} - }); - } - - @override - Widget build(BuildContext context) { - final user = ref.watch(userSessionProvider); - final target = - user != null ? NotificationTarget.user : NotificationTarget.guest; - - final isAdmin = user != null && user.role.contains('ADMIN'); - - final state = ref.watch(notificationSettingViewModelProvider); - final vm = ref.read(notificationSettingViewModelProvider.notifier); - - if (state.error != null) { - return Scaffold( - backgroundColor: Colors.white, - appBar: const DetailHeader( - title: '알림 설정', - backgroundColor: Colors.white, - ), - body: Center( - child: Text( - state.error!, - style: TextStyles.normalTextRegular.copyWith(color: ColorStyles.black), - textAlign: TextAlign.center, - ), - ), - ); - } - - final getTokenUseCase = ref.read(getFcmTokenUseCaseProvider); - - Future _requireDeviceToken() async { - final deviceToken = await getTokenUseCase.execute(); - if (deviceToken == null || deviceToken.isEmpty) { - _showSnack(context, '오류가 발생했어요. 잠시 후 다시 시도해 주세요.'); - return null; - } - return deviceToken; - } - - Future onToggle({ - required String label, - required String type, - required bool nextValue, - }) async { - final deviceToken = await _requireDeviceToken(); - if (deviceToken == null) return; - - try { - await vm.setToggle( - target: target, - deviceToken: deviceToken, - notificationType: type, - nextValue: nextValue, - ); - _showSnack( - context, - _consentMessage(label: label, enabled: nextValue), - ); - } catch (e) { - _showSnack(context, e.toString()); - } - } - - Future onRecruitApplyToggle(bool nextValue) async { - final deviceToken = await _requireDeviceToken(); - if (deviceToken == null) return; - - try { - await vm.setRecruitApplyToggle( - target: target, - deviceToken: deviceToken, - nextValue: nextValue, - ); - _showSnack( - context, - _consentMessage(label: '지원 현황', enabled: nextValue), - ); - } catch (e) { - _showSnack(context, e.toString()); - } - } - - Future onRecruitResultToggle(bool nextValue) async { - final deviceToken = await _requireDeviceToken(); - if (deviceToken == null) return; - - try { - await vm.setRecruitResultToggle( - target: target, - deviceToken: deviceToken, - nextValue: nextValue, - ); - _showSnack( - context, - _consentMessage(label: '지원 결과', enabled: nextValue), - ); - } catch (e) { - _showSnack(context, e.toString()); - } - } - - return Scaffold( - backgroundColor: ColorStyles.gray1, - appBar: const DetailHeader( - title: '알림 설정', - backgroundColor: ColorStyles.gray1, - ), - body: SafeArea( - child: ListView( - children: [ - NotificationSection( - title: '공지사항', - subtitle: '교내 공지를 빠르게 확인할 수 있어요', - children: [ - NotificationToggleRow( - label: '공지', - value: state.isEnabled(NotificationTypes.notice), - loading: state.isLoading(NotificationTypes.notice), - onChanged: (v) => onToggle( - label: '공지', - type: NotificationTypes.notice, - nextValue: v, - ), - ), - ], - ), - const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), - - NotificationSection( - title: '오늘의 일정', - subtitle: '매일 아침 8시에 오늘 일정을 확인할 수 있어요', - children: [ - NotificationToggleRow( - label: '시간표', - value: state.isEnabled(NotificationTypes.timetable), - loading: state.isLoading(NotificationTypes.timetable), - onChanged: (v) => onToggle( - label: '시간표', - type: NotificationTypes.timetable, - nextValue: v, - ), - ), - NotificationToggleRow( - label: '일정', - value: state.isEnabled(NotificationTypes.calendar), - loading: state.isLoading(NotificationTypes.calendar), - onChanged: (v) => onToggle( - label: '일정', - type: NotificationTypes.calendar, - nextValue: v, - ), - ), - ], - ), - const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), - - if (user != null) - NotificationSection( - title: '모집 알림', - subtitle: '모집 지원·결과를 빠르게 알려드려요.', - children: [ - NotificationToggleRow( - label: '지원 현황', - value: state.recruitApplyEnabled, - loading: state.recruitApplyLoading, - onChanged: onRecruitApplyToggle, - ), - const SizedBox(height: 4), - NotificationToggleRow( - label: '지원 결과', - value: state.recruitResultEnabled, - loading: state.recruitResultLoading, - onChanged: onRecruitResultToggle, - ), - ], - ), - - const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), - - if (user != null) - NotificationSection( - title: '채팅 알림', - subtitle: '채팅을 빠르게 확인할 수 있어요', - children: [ - NotificationToggleRow( - label: '새로운 채팅', - value: state.isEnabled(NotificationTypes.chat), - loading: state.isLoading(NotificationTypes.chat), - onChanged: (v) => onToggle( - label: '새로운 채팅', - type: NotificationTypes.chat, - nextValue: v, - ), - ), - ], - ), - - const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), - - NotificationSection( - title: '보안', - subtitle: '계정 보안을 위한 알림을 설정할 수 있어요', - children: [ - if (user != null) ...[ - NotificationToggleRow( - label: '새로운 기기 로그인', - value: state.isEnabled(NotificationTypes.newDevice), - loading: state.isLoading(NotificationTypes.newDevice), - onChanged: (v) => onToggle( - label: '새로운 기기 로그인', - type: NotificationTypes.newDevice, - nextValue: v, - ), - ), - ], - ], - ), - - const Divider(thickness: 4, height: 1, color: ColorStyles.gray1), - - NotificationSection( - title: '기타', - subtitle: '과팅 오픈 소식을 확인할 수 있어요', - children: [ - NotificationToggleRow( - label: '광고성 푸시 알림', - value: state.isEnabled(NotificationTypes.marketing), - loading: state.isLoading(NotificationTypes.marketing), - onChanged: (v) => onToggle( - label: '광고성 푸시 알림', - type: NotificationTypes.marketing, - nextValue: v, - ), - ), - if (user != null) ...[ - const SizedBox(height: 4), - NotificationToggleRow( - label: '과팅 오픈', - value: state.isEnabled(NotificationTypes.blinddate), - loading: state.isLoading(NotificationTypes.blinddate), - onChanged: (v) => onToggle( - label: '과팅 오픈', - type: NotificationTypes.blinddate, - nextValue: v, - ), - ), - ], - if (isAdmin) ...[ - const SizedBox(height: 4), - NotificationToggleRow( - label: '피드백 도착', - value: state.isEnabled(NotificationTypes.feedback), - loading: state.isLoading(NotificationTypes.feedback), - onChanged: (v) => onToggle( - label: '피드백 도착', - type: NotificationTypes.feedback, - nextValue: v, - ), - ), - ], - ], - ), - ], - ), - ), - ); - } -} diff --git a/lib/presentation/setting/setting_screen.dart b/lib/presentation/setting/setting_screen.dart index 5cca6f29..daff336b 100644 --- a/lib/presentation/setting/setting_screen.dart +++ b/lib/presentation/setting/setting_screen.dart @@ -10,13 +10,11 @@ import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; class SettingScreen extends HookConsumerWidget { - final VoidCallback onTapNotification; final VoidCallback onTapDevice; final VoidCallback onTapPasswordReset; const SettingScreen({ super.key, - required this.onTapNotification, required this.onTapDevice, required this.onTapPasswordReset, }); @@ -101,36 +99,33 @@ class SettingScreen extends HookConsumerWidget { ), ], ), + SizedBox(height: 24), - SizedBox(height: 40), - buildSettingsSection( - title: '앱 설정', - children: [ - buildSettingsItem( - label: '알림 설정', - onTap: onTapNotification - ), - if (user != null) - buildSettingsItem( - label: '채팅 캐시 삭제', - onTap: () async { - // 채팅 캐시 삭제 다이얼로그 - showDialog( - context: context, - builder: (_) => CustomConfirmDialog( - title: '채팅 캐시 삭제', - content: '채팅 내역을 삭제하시겠어요?', - onConfirm: () async { - await viewModel.localDataDelete(); - Navigator.of(context).pop(); // 다이얼로그 닫기 - }, - ), - ); - }, - ), - ], - ), - const SizedBox(height: 40), + if (user != null) ...[ + buildSettingsSection( + title: '앱 설정', + children: [ + buildSettingsItem( + label: '채팅 캐시 삭제', + onTap: () async { + // 채팅 캐시 삭제 다이얼로그 + showDialog( + context: context, + builder: (_) => CustomConfirmDialog( + title: '채팅 캐시 삭제', + content: '채팅 내역을 삭제하시겠어요?', + onConfirm: () async { + await viewModel.localDataDelete(); + Navigator.of(context).pop(); // 다이얼로그 닫기 + }, + ), + ); + }, + ), + ], + ), + const SizedBox(height: 24), + ], if (user != null) ...[ buildSettingsSection( @@ -146,7 +141,7 @@ class SettingScreen extends HookConsumerWidget { ), ], ), - const SizedBox(height: 40), + const SizedBox(height: 24), ], if (user != null) @@ -212,10 +207,12 @@ class SettingScreen extends HookConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(title, - style: TextStyles.largeTextBold.copyWith( - color: ColorStyles.black, - )), + Text( + title, + style: TextStyles.largeTextBold.copyWith( + color: ColorStyles.black, + ), + ), const SizedBox(height: 16), ...children, ], From 8837da4bcbca6dc595bd615c2e09c3e6f44a44ad Mon Sep 17 00:00:00 2001 From: wsw0922 Date: Fri, 26 Jun 2026 04:01:31 +0900 Subject: [PATCH 3/4] chore: update to v1.11.2+52 --- ios/Runner.xcodeproj/project.pbxproj | 6 +++--- lib/presentation/setting/setting_screen.dart | 2 +- macos/Flutter/ephemeral/Flutter-Generated.xcconfig | 4 ++-- macos/Flutter/ephemeral/flutter_export_environment.sh | 4 ++-- pubspec.yaml | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 55e82beb..858caa4e 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -512,7 +512,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.11.1; + MARKETING_VERSION = 1.11.2; PRODUCT_BUNDLE_IDENTIFIER = com.dongsoop.site.dongsoop; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -725,7 +725,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.11.1; + MARKETING_VERSION = 1.11.2; PRODUCT_BUNDLE_IDENTIFIER = com.dongsoop.site.dongsoop; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -765,7 +765,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.11.1; + MARKETING_VERSION = 1.11.2; PRODUCT_BUNDLE_IDENTIFIER = com.dongsoop.site.dongsoop; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/lib/presentation/setting/setting_screen.dart b/lib/presentation/setting/setting_screen.dart index daff336b..2bac2343 100644 --- a/lib/presentation/setting/setting_screen.dart +++ b/lib/presentation/setting/setting_screen.dart @@ -70,7 +70,7 @@ class SettingScreen extends HookConsumerWidget { title: '이용 안내', children: [ buildSettingsItem( - label: '버전 1.11.1', + label: '버전 1.11.2', onTap: () {}, ), buildSettingsItem( diff --git a/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/macos/Flutter/ephemeral/Flutter-Generated.xcconfig index 5b78bd36..b7d0b58c 100644 --- a/macos/Flutter/ephemeral/Flutter-Generated.xcconfig +++ b/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -3,8 +3,8 @@ FLUTTER_ROOT=/Users/seungwon-woo/development/flutter FLUTTER_APPLICATION_PATH=/Users/seungwon-woo/dongsoop COCOAPODS_PARALLEL_CODE_SIGN=true FLUTTER_BUILD_DIR=build -FLUTTER_BUILD_NAME=1.11.1 -FLUTTER_BUILD_NUMBER=51 +FLUTTER_BUILD_NAME=1.11.2 +FLUTTER_BUILD_NUMBER=52 DART_OBFUSCATION=false TRACK_WIDGET_CREATION=true TREE_SHAKE_ICONS=false diff --git a/macos/Flutter/ephemeral/flutter_export_environment.sh b/macos/Flutter/ephemeral/flutter_export_environment.sh index c9b59136..99bc4b14 100755 --- a/macos/Flutter/ephemeral/flutter_export_environment.sh +++ b/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -4,8 +4,8 @@ export "FLUTTER_ROOT=/Users/seungwon-woo/development/flutter" export "FLUTTER_APPLICATION_PATH=/Users/seungwon-woo/dongsoop" export "COCOAPODS_PARALLEL_CODE_SIGN=true" export "FLUTTER_BUILD_DIR=build" -export "FLUTTER_BUILD_NAME=1.11.1" -export "FLUTTER_BUILD_NUMBER=51" +export "FLUTTER_BUILD_NAME=1.11.2" +export "FLUTTER_BUILD_NUMBER=52" export "DART_OBFUSCATION=false" export "TRACK_WIDGET_CREATION=true" export "TREE_SHAKE_ICONS=false" diff --git a/pubspec.yaml b/pubspec.yaml index 2de59a3c..614af111 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.11.1+51 +version: 1.11.2+52 environment: sdk: ^3.6.0 From 3d187dd5bcf07374e18e689db539fc41b7bc927a Mon Sep 17 00:00:00 2001 From: wsw0922 Date: Mon, 6 Jul 2026 19:44:15 +0900 Subject: [PATCH 4/4] fix: use test ads for TestFlight builds --- ios/Runner/AppDelegate.swift | 22 ++++++++++++++++ lib/core/environment/app_distribution.dart | 19 ++++++++++++++ .../components/admob_native_ad.dart | 25 ++++++++++++++++--- lib/main.dart | 2 +- 4 files changed, 64 insertions(+), 4 deletions(-) create mode 100644 lib/core/environment/app_distribution.dart diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 88d3ac1d..40fc52ec 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -52,6 +52,20 @@ class MyAppCheckProviderFactory: NSObject, AppCheckProviderFactory { } if let controller = window?.rootViewController as? FlutterViewController { + let appDistributionChannel = FlutterMethodChannel( + name: "dongsoop/app_distribution", + binaryMessenger: controller.binaryMessenger + ) + + appDistributionChannel.setMethodCallHandler { call, result in + switch call.method { + case "isTestFlight": + result(Self.isRunningInTestFlight()) + default: + result(FlutterMethodNotImplemented) + } + } + pushChannel = FlutterMethodChannel( name: "app/push", binaryMessenger: controller.binaryMessenger @@ -178,4 +192,12 @@ class MyAppCheckProviderFactory: NSObject, AppCheckProviderFactory { } super.application(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: completionHandler) } + + private static func isRunningInTestFlight() -> Bool { + guard let receiptURL = Bundle.main.appStoreReceiptURL else { + return false + } + + return receiptURL.lastPathComponent == "sandboxReceipt" + } } \ No newline at end of file diff --git a/lib/core/environment/app_distribution.dart b/lib/core/environment/app_distribution.dart new file mode 100644 index 00000000..290ccde6 --- /dev/null +++ b/lib/core/environment/app_distribution.dart @@ -0,0 +1,19 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +class AppDistribution { + static const MethodChannel _channel = MethodChannel('dongsoop/app_distribution'); + + static Future isTestFlight() async { + if (!Platform.isIOS) return false; + + try { + final result = await _channel.invokeMethod('isTestFlight'); + return result ?? false; + } catch (e) { + print('[isTestFlight Error] Failed to detect TestFlight: $e'); + return false; + } + } +} \ No newline at end of file diff --git a/lib/core/presentation/components/admob_native_ad.dart b/lib/core/presentation/components/admob_native_ad.dart index 82941d39..26b306ae 100644 --- a/lib/core/presentation/components/admob_native_ad.dart +++ b/lib/core/presentation/components/admob_native_ad.dart @@ -5,6 +5,7 @@ import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:logger/logger.dart'; import 'package:dongsoop/ui/color_styles.dart'; +import 'package:dongsoop/core/environment/app_distribution.dart'; class AdmobNativeAd extends StatefulWidget { final TemplateType templateType; @@ -23,6 +24,8 @@ class AdmobNativeAd extends StatefulWidget { class _AdmobNativeAdState extends State { NativeAd? _nativeAd; bool _nativeAdIsLoaded = false; + bool _useTestAds = kDebugMode; + final Logger _logger = Logger(); static const String _androidTestAdUnitId = 'ca-app-pub-3940256099942544/2247696110'; @@ -30,18 +33,27 @@ class _AdmobNativeAdState extends State { String get _adUnitId { if (Platform.isAndroid) { - if (kDebugMode) return _androidTestAdUnitId; + if (_useTestAds) return _androidTestAdUnitId; return dotenv.maybeGet('ADMOB_ANDROID_NATIVE_ID') ?? _androidTestAdUnitId; } else if (Platform.isIOS) { - if (kDebugMode) return _iosTestAdUnitId; + if (_useTestAds) return _iosTestAdUnitId; return dotenv.maybeGet('ADMOB_IOS_NATIVE_ID') ?? _iosTestAdUnitId; } + return ''; } @override void initState() { super.initState(); + _initAndLoadAd(); + } + + Future _initAndLoadAd() async { + final isTestFlight = await AppDistribution.isTestFlight(); + _useTestAds = kDebugMode || isTestFlight; + _logger.d('AdMob use test ads: $_useTestAds'); + if (!mounted) return; _loadAd(); } @@ -69,6 +81,13 @@ class _AdmobNativeAdState extends State { onAdFailedToLoad: (ad, error) { _logger.e('$NativeAd failed to load: $error'); ad.dispose(); + + if (mounted) { + setState(() { + _nativeAd = null; + _nativeAdIsLoaded = false; + }); + } }, ), request: const AdRequest(), @@ -104,7 +123,7 @@ class _AdmobNativeAdState extends State { child: AdWidget(ad: _nativeAd!), ); } - // 광고 로딩 중에는 빈 공간 또는 스켈레톤 UI를 보여줄 수 있습니다. + return SizedBox(height: widget.height); } } diff --git a/lib/main.dart b/lib/main.dart index f34762a5..433b54c7 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -28,7 +28,7 @@ import 'domain/chat/model/chat_room_member.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); - MobileAds.instance.initialize(); + await MobileAds.instance.initialize(); await dotenv.load(); // .env 파일 로드 await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);