From 3d86316a2b8dba69571bf768a52e7b727e0203a8 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Mon, 15 Sep 2025 15:21:44 +0900 Subject: [PATCH] feat(android): add exact alarms permission request entry in Settings\n\n- UI: Settings card shows request when exact alarms not allowed\n- Service: wrap canScheduleExactAlarms/requestExactAlarmsPermission via FLN plugin\n- Keeps changes minimal; no new deps\n\nValidation: scripts/check.sh passed --- lib/screens/settings_screen.dart | 1355 ++++++++++++++---------- lib/services/notification_service.dart | 28 + 2 files changed, 835 insertions(+), 548 deletions(-) diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0d4ceb6..5032c1f 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -5,14 +5,18 @@ import '../providers/notification_provider.dart'; import 'dart:io'; import '../services/notification_service.dart'; import 'package:url_launcher/url_launcher.dart'; -import '../widgets/glassmorphism_card.dart'; -import '../theme/app_colors.dart'; +// import '../widgets/glassmorphism_card.dart'; +// import '../theme/app_colors.dart'; import '../widgets/native_ad_widget.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; import '../l10n/app_localizations.dart'; import '../providers/locale_provider.dart'; import 'package:permission_handler/permission_handler.dart' as permission; import '../services/sms_service.dart'; +import '../providers/theme_provider.dart'; +import '../theme/adaptive_theme.dart'; +import '../widgets/common/layout/page_container.dart'; +import '../theme/color_scheme_ext.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -29,13 +33,16 @@ class SettingsScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected - ? AppColors.primaryColor.withValues(alpha: 0.2) + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2) : Colors.transparent, borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected - ? AppColors.primaryColor - : AppColors.textSecondary.withValues(alpha: 0.5), + ? Theme.of(context).colorScheme.primary + : Theme.of(context) + .colorScheme + .onSurfaceVariant + .withValues(alpha: 0.5), width: isSelected ? 2 : 1, ), ), @@ -48,8 +55,8 @@ class SettingsScreen extends StatelessWidget { ? Icons.radio_button_checked : Icons.radio_button_unchecked, color: isSelected - ? AppColors.primaryColor - : AppColors.textSecondary, + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurfaceVariant, size: 24, ), const SizedBox(height: 6), @@ -59,8 +66,8 @@ class SettingsScreen extends StatelessWidget { fontSize: 14, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, color: isSelected - ? AppColors.primaryColor - : AppColors.textPrimary, + ? Theme.of(context).colorScheme.primary + : Theme.of(context).colorScheme.onSurface, ), ), ], @@ -75,578 +82,830 @@ class SettingsScreen extends StatelessWidget { return Column( children: [ Expanded( - child: ListView( + child: PageContainer( padding: EdgeInsets.zero, - children: [ - // toolbar 높이 추가 - SizedBox( - height: kToolbarHeight + MediaQuery.of(context).padding.top, - ), - // 광고 위젯 추가 - const NativeAdWidget(key: ValueKey('settings_ad')), - const SizedBox(height: 16), - // 언어 설정 - GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: Consumer( - builder: (context, localeProvider, child) { - final loc = AppLocalizations.of(context); - return ListTile( - title: Text( - loc.language, - style: const TextStyle(color: AppColors.textPrimary), - ), - leading: const Icon( - Icons.language, - color: AppColors.textSecondary, - ), - trailing: Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: - AppColors.textSecondary.withValues(alpha: 0.5), - ), - ), - child: DropdownButton( - value: localeProvider.locale.languageCode, - underline: const SizedBox(), - borderRadius: BorderRadius.circular(12), - dropdownColor: const Color(0xFF2A2A2A), // 어두운 배경색 설정 - icon: const Icon( - Icons.arrow_drop_down, - color: AppColors.textPrimary, - ), - iconEnabledColor: AppColors.textPrimary, - selectedItemBuilder: (BuildContext context) { - return [ - Text(loc.korean, - style: const TextStyle( - color: AppColors.textPrimary)), - Text(loc.english, - style: const TextStyle( - color: AppColors.textPrimary)), - Text(loc.japanese, - style: const TextStyle( - color: AppColors.textPrimary)), - Text(loc.chinese, - style: const TextStyle( - color: AppColors.textPrimary)), - ]; - }, - items: [ - DropdownMenuItem( - value: 'ko', - child: Text( - loc.korean, - style: const TextStyle(color: Colors.white), + child: ListView( + padding: const EdgeInsets.symmetric(horizontal: 16), + children: [ + // toolbar 높이 추가 + SizedBox( + height: kToolbarHeight + MediaQuery.of(context).padding.top, + ), + // 광고 위젯 추가 + const NativeAdWidget( + key: ValueKey('settings_ad'), + useOuterPadding: true, + ), + const SizedBox(height: 16), + + // 테마 모드 설정 + Card( + margin: + const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Consumer( + builder: (context, themeProvider, child) { + final mode = themeProvider.themeMode; + final cs = Theme.of(context).colorScheme; + final loc = AppLocalizations.of(context); + Widget chip(AppThemeMode value, String label) { + final selected = mode == value; + return ChoiceChip( + label: Text(label), + selected: selected, + onSelected: (_) => + themeProvider.setThemeMode(value), + labelStyle: TextStyle( + color: selected ? cs.onPrimary : cs.onSurface, + fontWeight: FontWeight.w600, + ), + selectedColor: cs.primary, + backgroundColor: cs.surface, + side: BorderSide( + color: selected + ? cs.primary + : cs.outline.withValues(alpha: 0.6), + ), + ); + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon(Icons.color_lens, + color: cs.onSurfaceVariant), + title: Text( + '테마', + style: TextStyle(color: cs.onSurface), ), ), - DropdownMenuItem( - value: 'en', - child: Text( - loc.english, - style: const TextStyle(color: Colors.white), - ), - ), - DropdownMenuItem( - value: 'ja', - child: Text( - loc.japanese, - style: const TextStyle(color: Colors.white), - ), - ), - DropdownMenuItem( - value: 'zh', - child: Text( - loc.chinese, - style: const TextStyle(color: Colors.white), - ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + chip(AppThemeMode.system, loc.systemTheme), + chip(AppThemeMode.light, loc.lightTheme), + chip(AppThemeMode.dark, loc.darkTheme), + chip(AppThemeMode.oled, loc.oledTheme), + ], ), ], - onChanged: (String? value) { - if (value != null) { - localeProvider.setLocale(value); - } - }, - ), - ), - ); - }, + ); + }, + ), + ), ), - ), - // 앱 잠금 설정 UI 숨김 - // Card( - // margin: const EdgeInsets.all(16), - // child: Consumer( - // builder: (context, provider, child) { - // return SwitchListTile( - // title: const Text('앱 잠금'), - // subtitle: const Text('생체 인증으로 앱 잠금'), - // value: provider.isEnabled, - // onChanged: (value) async { - // if (value) { - // final isAuthenticated = await provider.authenticate(); - // if (isAuthenticated) { - // provider.enable(); - // } - // } else { - // provider.disable(); - // } - // }, - // ); - // }, - // ), - // ), - - // 알림 설정 - GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: Consumer( - builder: (context, provider, child) { - return Column( - children: [ - ListTile( - title: Text( - AppLocalizations.of(context).notificationPermission, - style: - const TextStyle(color: AppColors.textPrimary), - ), - subtitle: Text( - AppLocalizations.of(context) - .notificationPermissionDesc, - style: - const TextStyle(color: AppColors.textSecondary), - ), - trailing: ElevatedButton( - onPressed: () async { - final granted = - await NotificationService.requestPermission(); - if (granted) { - await provider.setEnabled(true); - } else { - if (!context.mounted) return; - AppSnackBar.showError( - context: context, - message: AppLocalizations.of(context) - .notificationPermissionDenied, - ); - } - }, - child: Text( - AppLocalizations.of(context).requestPermission), - ), - ), - const Divider(), - // 결제 예정 알림 기본 스위치 - SwitchListTile( - title: Text( - AppLocalizations.of(context).paymentNotification, - style: - const TextStyle(color: AppColors.textPrimary), - ), - subtitle: Text( - AppLocalizations.of(context) - .paymentNotificationDesc, - style: - const TextStyle(color: AppColors.textSecondary), - ), - value: provider.isPaymentEnabled, - onChanged: (value) { - provider.setPaymentEnabled(value); - }, - ), - - // 알림 세부 설정 (알림 활성화된 경우에만 표시) - AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - child: provider.isPaymentEnabled - ? Padding( - padding: const EdgeInsets.only( - left: 16.0, right: 16.0, bottom: 8.0), - child: Card( - elevation: 0, + // 언어 설정 + Card( + margin: + const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12), + child: Consumer( + builder: (context, localeProvider, child) { + final loc = AppLocalizations.of(context); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ListTile( + contentPadding: EdgeInsets.zero, + leading: Icon( + Icons.language, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant, + ), + title: Text( + loc.language, + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurface, + ), + ), + ), + DropdownButtonFormField( + initialValue: localeProvider.locale.languageCode, + isExpanded: true, + decoration: InputDecoration( + filled: true, + fillColor: + Theme.of(context).colorScheme.surface, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 14), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( color: Theme.of(context) .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.3), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + .outline + .withValues(alpha: 0.6), + width: 1, + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: + Theme.of(context).colorScheme.primary, + width: 2, + ), + ), + ), + items: [ + DropdownMenuItem( + value: 'ko', child: Text(loc.korean)), + DropdownMenuItem( + value: 'en', child: Text(loc.english)), + DropdownMenuItem( + value: 'ja', child: Text(loc.japanese)), + DropdownMenuItem( + value: 'zh', child: Text(loc.chinese)), + ], + onChanged: (val) { + if (val != null) localeProvider.setLocale(val); + }, + ), + ], + ); + }, + ), + ), + ), + // 앱 잠금 설정 UI 숨김 + // Card( + // margin: const EdgeInsets.all(16), + // child: Consumer( + // builder: (context, provider, child) { + // return SwitchListTile( + // title: const Text('앱 잠금'), + // subtitle: const Text('생체 인증으로 앱 잠금'), + // value: provider.isEnabled, + // onChanged: (value) async { + // if (value) { + // final isAuthenticated = await provider.authenticate(); + // if (isAuthenticated) { + // provider.enable(); + // } + // } else { + // provider.disable(); + // } + // }, + // ); + // }, + // ), + // ), + + // 알림 설정 + Card( + margin: + const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: Padding( + padding: const EdgeInsets.all(8), + child: Consumer( + builder: (context, provider, child) { + return Column( + children: [ + // Android 12+ 정확 알람 권한 (알람 및 리마인더) + if (!kIsWeb && Platform.isAndroid) + FutureBuilder( + future: NotificationService + .canScheduleExactAlarms(), + builder: (context, snap) { + final can = snap.data ?? true; + if (can) return const SizedBox.shrink(); + return ListTile( + leading: Icon(Icons.alarm, + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + title: Text( + '정확 알람 권한(알람 및 리마인더)', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface), ), - child: Padding( - padding: const EdgeInsets.all(12.0), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - // 알림 시점 선택 (1일전, 2일전, 3일전) - Text( - AppLocalizations.of(context) - .notificationTiming, - style: const TextStyle( - fontWeight: FontWeight.bold)), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric( - vertical: 8.0), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceEvenly, - children: [ - _buildReminderDayRadio( - context, - provider, - 1, - AppLocalizations.of(context) - .oneDayBefore), - _buildReminderDayRadio( - context, - provider, - 2, - AppLocalizations.of(context) - .twoDaysBefore), - _buildReminderDayRadio( - context, - provider, - 3, - AppLocalizations.of(context) - .threeDaysBefore), - ], - ), - ), + subtitle: Text( + '정확한 시각에 알림을 보장하려면 권한이 필요합니다.', + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + trailing: ElevatedButton( + onPressed: () async { + final ok = await NotificationService + .requestExactAlarmsPermission(); + // 사용자가 설정 화면에서 허용 후 돌아오면 true가 될 수 있음 + final recheck = + await NotificationService + .canScheduleExactAlarms(); + if (context.mounted) { + if (ok || recheck) { + AppSnackBar.showSuccess( + context: context, + message: '권한이 허용되었습니다.', + ); + } else { + AppSnackBar.showInfo( + context: context, + message: + '설정에서 "알람 및 리마인더"를 허용해 주세요.', + ); + } + (context as Element).markNeedsBuild(); + } + }, + child: const Text('허용 요청'), + ), + ); + }, + ), + FutureBuilder( + future: permission.Permission.notification.status, + builder: (context, snapshot) { + final isLoading = snapshot.connectionState == + ConnectionState.waiting; + final status = snapshot.data; + final hasPermission = + status?.isGranted ?? false; + final isPermanentlyDenied = + status?.isPermanentlyDenied ?? false; - const SizedBox(height: 16), - - // 알림 시간 선택 - Text( - AppLocalizations.of(context) - .notificationTime, - style: const TextStyle( - fontWeight: FontWeight.bold)), - const SizedBox(height: 12), - InkWell( - onTap: () async { - final TimeOfDay? picked = - await showTimePicker( - context: context, - initialTime: TimeOfDay( - hour: provider.reminderHour, - minute: provider - .reminderMinute), - ); - if (picked != null) { - provider.setReminderTime( - picked.hour, picked.minute); - } - }, - child: Container( + return ListTile( + title: Text( + AppLocalizations.of(context) + .notificationPermission, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface), + ), + subtitle: !hasPermission + ? Text( + isPermanentlyDenied + ? AppLocalizations.of(context) + .permanentlyDeniedMessage + : AppLocalizations.of(context) + .notificationPermissionDesc, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ) + : null, + trailing: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator( + strokeWidth: 2), + ) + : hasPermission + ? Padding( padding: const EdgeInsets.symmetric( + horizontal: 8.0), + child: Icon( + Icons.check_circle, + color: Theme.of(context) + .colorScheme + .success, + ), + ) + : isPermanentlyDenied + ? TextButton( + onPressed: () async { + await permission + .openAppSettings(); + }, + child: Text( + AppLocalizations.of( + context) + .openSettings), + ) + : ElevatedButton( + onPressed: () async { + final granted = + await NotificationService + .requestPermission(); + if (granted) { + await provider + .setEnabled(true); + } else { + if (!context.mounted) { + return; + } + AppSnackBar.showError( + context: context, + message: AppLocalizations + .of(context) + .notificationPermissionDenied, + ); + } + if (context.mounted) { + (context as Element) + .markNeedsBuild(); + } + }, + child: Text( + AppLocalizations.of( + context) + .requestPermission), + ), + ); + }, + ), + const Divider(), + // 결제 예정 알림 기본 스위치 + SwitchListTile( + title: Text( + AppLocalizations.of(context) + .paymentNotification, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurface), + ), + subtitle: Text( + AppLocalizations.of(context) + .paymentNotificationDesc, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ), + value: provider.isPaymentEnabled, + onChanged: (value) { + provider.setPaymentEnabled(value); + }, + ), + + // 알림 세부 설정 (알림 활성화된 경우에만 표시) + AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: provider.isPaymentEnabled + ? Padding( + padding: const EdgeInsets.only( + left: 16.0, right: 16.0, bottom: 8.0), + child: Card( + elevation: 0, + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues(alpha: 0.3), + shape: RoundedRectangleBorder( + borderRadius: + BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.3), + ), + ), + child: Padding( + padding: const EdgeInsets.all(12.0), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + // 알림 시점 선택 (1일전, 2일전, 3일전) + Text( + AppLocalizations.of(context) + .notificationTiming, + style: const TextStyle( + fontWeight: + FontWeight.bold)), + const SizedBox(height: 8), + Padding( + padding: + const EdgeInsets.symmetric( + vertical: 8.0), + child: Row( + mainAxisAlignment: + MainAxisAlignment + .spaceEvenly, + children: [ + _buildReminderDayRadio( + context, + provider, + 1, + AppLocalizations.of( + context) + .oneDayBefore), + _buildReminderDayRadio( + context, + provider, + 2, + AppLocalizations.of( + context) + .twoDaysBefore), + _buildReminderDayRadio( + context, + provider, + 3, + AppLocalizations.of( + context) + .threeDaysBefore), + ], + ), + ), + + const SizedBox(height: 16), + + // 알림 시간 선택 + Text( + AppLocalizations.of(context) + .notificationTime, + style: const TextStyle( + fontWeight: + FontWeight.bold)), + const SizedBox(height: 12), + InkWell( + onTap: () async { + final TimeOfDay? picked = + await showTimePicker( + context: context, + initialTime: TimeOfDay( + hour: provider + .reminderHour, + minute: provider + .reminderMinute), + ); + if (picked != null) { + provider.setReminderTime( + picked.hour, + picked.minute); + } + }, + child: Container( + padding: const EdgeInsets + .symmetric( vertical: 12, horizontal: 16), - decoration: BoxDecoration( - border: Border.all( - color: Theme.of(context) - .colorScheme - .outline - .withValues(alpha: 0.5), - ), - borderRadius: - BorderRadius.circular(8), - ), - child: Row( - children: [ - Expanded( - child: Row( - children: [ - Icon( - Icons.access_time, - color: - Theme.of(context) + decoration: BoxDecoration( + border: Border.all( + color: Theme.of(context) + .colorScheme + .outline + .withValues( + alpha: 0.5), + ), + borderRadius: + BorderRadius.circular( + 8), + ), + child: Row( + children: [ + Expanded( + child: Row( + children: [ + Icon( + Icons.access_time, + color: Theme.of( + context) .colorScheme .primary, - size: 22, + size: 22, + ), + const SizedBox( + width: 12), + Text( + '${provider.reminderHour.toString().padLeft(2, '0')}:${provider.reminderMinute.toString().padLeft(2, '0')}', + style: TextStyle( + fontSize: 16, + fontWeight: + FontWeight + .bold, + color: Theme.of( + context) + .colorScheme + .onSurface, + ), + ), + ], ), - const SizedBox( - width: 12), - Text( - '${provider.reminderHour.toString().padLeft(2, '0')}:${provider.reminderMinute.toString().padLeft(2, '0')}', - style: TextStyle( - fontSize: 16, - fontWeight: - FontWeight.bold, + ), + Icon( + Icons.arrow_forward_ios, + size: 16, + color: Theme.of(context) + .colorScheme + .outline, + ), + ], + ), + ), + ), + + // 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화) + if (provider.reminderDays >= 2) + Padding( + padding: + const EdgeInsets.only( + top: 16.0), + child: Container( + padding: const EdgeInsets + .symmetric( + vertical: 4, + horizontal: 4), + decoration: BoxDecoration( + color: Theme.of(context) + .colorScheme + .surfaceContainerHighest + .withValues( + alpha: 0.3), + borderRadius: + BorderRadius.circular( + 8), + ), + child: SwitchListTile( + contentPadding: + const EdgeInsets + .symmetric( + horizontal: 12), + title: Text( + AppLocalizations.of( + context) + .dailyReminder), + subtitle: Text( + provider.isDailyReminderEnabled + ? AppLocalizations + .of(context) + .dailyReminderEnabled + : AppLocalizations + .of(context) + .dailyReminderDisabledWithDays( + provider + .reminderDays), + style: TextStyle( color: Theme.of( context) .colorScheme - .onSurface, - ), - ), - ], + .onSurfaceVariant), + ), + value: provider + .isDailyReminderEnabled, + onChanged: (value) { + provider + .setDailyReminderEnabled( + value); + }, ), ), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Theme.of(context) - .colorScheme - .outline, - ), - ], - ), - ), + ), + ], ), - - // 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화) - if (provider.reminderDays >= 2) - Padding( - padding: const EdgeInsets.only( - top: 16.0), - child: Container( - padding: - const EdgeInsets.symmetric( - vertical: 4, - horizontal: 4), - decoration: BoxDecoration( - color: Theme.of(context) - .colorScheme - .surfaceContainerHighest - .withValues(alpha: 0.3), - borderRadius: - BorderRadius.circular(8), - ), - child: SwitchListTile( - contentPadding: - const EdgeInsets - .symmetric( - horizontal: 12), - title: Text( - AppLocalizations.of( - context) - .dailyReminder), - subtitle: Text( - provider.isDailyReminderEnabled - ? AppLocalizations.of( - context) - .dailyReminderEnabled - : AppLocalizations.of( - context) - .dailyReminderDisabledWithDays( - provider - .reminderDays), - style: const TextStyle( - color: AppColors - .textLight), - ), - value: provider - .isDailyReminderEnabled, - activeColor: Theme.of(context) - .colorScheme - .primary, - onChanged: (value) { - provider - .setDailyReminderEnabled( - value); - }, - ), - ), - ), - ], + ), ), - ), - ), - ) - : const SizedBox.shrink(), - ), - // 미사용 서비스 알림 기능 비활성화 - // const Divider(), - // SwitchListTile( - // title: const Text('미사용 서비스 알림'), - // subtitle: const Text('2개월 이상 미사용 시 알림'), - // value: provider.isUnusedServiceNotificationEnabled, - // onChanged: (value) { - // provider.setUnusedServiceNotificationEnabled(value); - // }, - // ), - ], - ); - }, + ) + : const SizedBox.shrink(), + ), + + // 디버그 전용: 결제 알림 테스트 버튼 (숨김) + // 미사용 서비스 알림 기능 비활성화 + // const Divider(), + // SwitchListTile( + // title: const Text('미사용 서비스 알림'), + // subtitle: const Text('2개월 이상 미사용 시 알림'), + // value: provider.isUnusedServiceNotificationEnabled, + // onChanged: (value) { + // provider.setUnusedServiceNotificationEnabled(value); + // }, + // ), + ], + ); + }, + ), + ), ), - ), - // SMS 권한 설정 - if (!kIsWeb && Platform.isAndroid) - GlassmorphismCard( - margin: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: FutureBuilder( - future: permission.Permission.sms.status, - builder: (context, snapshot) { - final isLoading = - snapshot.connectionState == ConnectionState.waiting; - final status = snapshot.data; - final hasPermission = status?.isGranted ?? false; - final isPermanentlyDenied = - status?.isPermanentlyDenied ?? false; + // SMS 권한 설정 + if (!kIsWeb && Platform.isAndroid) + Card( + margin: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + child: FutureBuilder( + future: permission.Permission.sms.status, + builder: (context, snapshot) { + final isLoading = + snapshot.connectionState == ConnectionState.waiting; + final status = snapshot.data; + final hasPermission = status?.isGranted ?? false; + final isPermanentlyDenied = + status?.isPermanentlyDenied ?? false; - return ListTile( - leading: const Icon( - Icons.sms, - color: AppColors.textSecondary, - ), - title: Text( - AppLocalizations.of(context).smsPermissionLabel, - style: const TextStyle(color: AppColors.textPrimary), - ), - subtitle: !hasPermission - ? Text( - isPermanentlyDenied - ? AppLocalizations.of(context) - .permanentlyDeniedMessage - : AppLocalizations.of(context) - .smsPermissionRequired, - style: const TextStyle( - color: AppColors.textSecondary), - ) - : null, - trailing: isLoading - ? const SizedBox( - width: 20, - height: 20, - child: - CircularProgressIndicator(strokeWidth: 2), - ) - : hasPermission - ? const Padding( - padding: - EdgeInsets.symmetric(horizontal: 8.0), - child: Icon(Icons.check_circle, - color: Colors.green), - ) - : isPermanentlyDenied - ? TextButton( - onPressed: () async { - await permission.openAppSettings(); - }, - child: Text(AppLocalizations.of(context) - .openSettings), - ) - : ElevatedButton( - onPressed: () async { - final granted = await SMSService - .requestSMSPermission(); - if (!granted) { - final newStatus = await permission - .Permission.sms.status; - if (newStatus.isPermanentlyDenied) { - await permission - .openAppSettings(); + return ListTile( + contentPadding: const EdgeInsets.all(8), + leading: Icon( + Icons.sms, + color: + Theme.of(context).colorScheme.onSurfaceVariant, + ), + title: Text( + AppLocalizations.of(context).smsPermissionLabel, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface), + ), + subtitle: !hasPermission + ? Text( + isPermanentlyDenied + ? AppLocalizations.of(context) + .permanentlyDeniedMessage + : AppLocalizations.of(context) + .smsPermissionRequired, + style: TextStyle( + color: Theme.of(context) + .colorScheme + .onSurfaceVariant), + ) + : null, + trailing: isLoading + ? const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + : hasPermission + ? Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8.0), + child: Icon(Icons.check_circle, + color: Theme.of(context) + .colorScheme + .success), + ) + : isPermanentlyDenied + ? TextButton( + onPressed: () async { + await permission.openAppSettings(); + }, + child: Text( + AppLocalizations.of(context) + .openSettings), + ) + : ElevatedButton( + onPressed: () async { + final granted = await SMSService + .requestSMSPermission(); + if (!granted) { + final newStatus = await permission + .Permission.sms.status; + if (newStatus + .isPermanentlyDenied) { + await permission + .openAppSettings(); + } } - } - if (context.mounted) { - (context as Element) - .markNeedsBuild(); - } - }, - child: Text(AppLocalizations.of(context) - .requestPermission), - ), + if (context.mounted) { + (context as Element) + .markNeedsBuild(); + } + }, + child: Text( + AppLocalizations.of(context) + .requestPermission), + ), + ); + }, + ), + ), + + // 앱 정보 + Card( + margin: + const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + elevation: 1, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide( + color: Theme.of(context) + .colorScheme + .outline + .withValues(alpha: 0.5), + ), + ), + child: ListTile( + contentPadding: const EdgeInsets.all(8), + title: Text( + AppLocalizations.of(context).appInfo, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurface), + ), + subtitle: Text( + '${AppLocalizations.of(context).version} 1.0.0', + style: TextStyle( + color: + Theme.of(context).colorScheme.onSurfaceVariant), + ), + leading: Icon(Icons.info, + color: Theme.of(context).colorScheme.onSurfaceVariant), + onTap: () async { + // 항상 앱 내 About 다이얼로그를 우선 표시 + showAboutDialog( + context: context, + applicationName: AppLocalizations.of(context).appTitle, + applicationVersion: '1.0.0', + applicationIcon: const FlutterLogo(size: 50), + children: [ + Text(AppLocalizations.of(context).appDescription), + const SizedBox(height: 8), + Text( + '${AppLocalizations.of(context).developer}: Julian Sul'), + const SizedBox(height: 12), + Builder(builder: (ctx) { + return TextButton.icon( + icon: const Icon(Icons.open_in_new), + label: Text(AppLocalizations.of(ctx).openStore), + onPressed: () async { + try { + if (Platform.isAndroid) { + // 우선 Play 스토어 앱 시도 + const pkg = + 'com.naturebridgeai.digitalrentmanager'; + final marketUri = + Uri.parse('market://details?id=$pkg'); + final webUri = Uri.parse( + 'https://play.google.com/store/apps/details?id=$pkg'); + final ok = await launchUrl(marketUri, + mode: LaunchMode.externalApplication); + if (!ok) { + await launchUrl(webUri, + mode: LaunchMode.externalApplication); + } + } else if (Platform.isIOS) { + final uri = Uri.parse( + 'https://apps.apple.com/app/id123456789'); + await launchUrl(uri, + mode: LaunchMode.externalApplication); + } + } catch (e) { + if (ctx.mounted) { + AppSnackBar.showError( + context: ctx, + message: AppLocalizations.of(ctx) + .cannotOpenStore, + ); + } + } + }, + ); + }), + ], ); }, ), ), - - // 앱 정보 - GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: ListTile( - title: Text( - AppLocalizations.of(context).appInfo, - style: const TextStyle(color: AppColors.textPrimary), - ), - subtitle: Text( - '${AppLocalizations.of(context).version} 1.0.0', - style: const TextStyle(color: AppColors.textSecondary), - ), - leading: const Icon( - Icons.info, - color: AppColors.textSecondary, - ), - onTap: () async { - // 웹 환경에서는 기본 다이얼로그 표시 - if (kIsWeb) { - showAboutDialog( - context: context, - applicationName: AppLocalizations.of(context).appTitle, - applicationVersion: '1.0.0', - applicationIcon: const FlutterLogo(size: 50), - children: [ - Text(AppLocalizations.of(context).appDescription), - const SizedBox(height: 8), - Text( - '${AppLocalizations.of(context).developer}: Julian Sul'), - ], - ); - return; - } - - // 앱 스토어 링크 - String storeUrl = ''; - - // 플랫폼에 따라 스토어 링크 설정 - if (Platform.isAndroid) { - // Android - Google Play 스토어 링크 - storeUrl = - 'https://play.google.com/store/apps/details?id=com.submanager.app'; - } else if (Platform.isIOS) { - // iOS - App Store 링크 - storeUrl = - 'https://apps.apple.com/app/submanager/id123456789'; - } - - if (storeUrl.isNotEmpty) { - try { - final Uri url = Uri.parse(storeUrl); - await launchUrl(url, - mode: LaunchMode.externalApplication); - } catch (e) { - if (context.mounted) { - AppSnackBar.showError( - context: context, - message: - AppLocalizations.of(context).cannotOpenStore, - ); - } - } - } else { - // 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시 - showAboutDialog( - context: context, - applicationName: AppLocalizations.of(context).appTitle, - applicationVersion: '1.0.0', - applicationIcon: const FlutterLogo(size: 50), - children: [ - Text(AppLocalizations.of(context).appDescription), - const SizedBox(height: 8), - Text( - '${AppLocalizations.of(context).developer}: Julian Sul'), - ], - ); - } - }, + // FloatingNavigationBar를 위한 충분한 하단 여백 + SizedBox( + height: 120 + MediaQuery.of(context).padding.bottom, ), - ), - // FloatingNavigationBar를 위한 충분한 하단 여백 - SizedBox( - height: 120 + MediaQuery.of(context).padding.bottom, - ), - ], + ], + ), ), ), ], diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index ee7d291..c171b70 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -248,6 +248,34 @@ class NotificationService { return true; // 기본값 } + // Android: 정확 알람 권한 가능 여부 확인 (S+) + static Future canScheduleExactAlarms() async { + if (_isWeb) return false; + if (Platform.isAndroid) { + final android = _notifications.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + if (android != null) { + final can = await android.canScheduleExactNotifications(); + return can ?? true; // 하위 버전은 true 간주 + } + } + return true; + } + + // Android: 정확 알람 권한 요청 (Android 12+에서 설정 화면으로 이동) + static Future requestExactAlarmsPermission() async { + if (_isWeb) return false; + if (Platform.isAndroid) { + final android = _notifications.resolvePlatformSpecificImplementation< + AndroidFlutterLocalNotificationsPlugin>(); + if (android != null) { + final granted = await android.requestExactAlarmsPermission(); + return granted ?? false; + } + } + return false; + } + // 알림 스케줄 설정 static Future scheduleNotification({ required int id,