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

This commit is contained in:
JiWoong Sul
2025-09-15 15:21:44 +09:00
parent 55e3f67279
commit 3d86316a2b
2 changed files with 835 additions and 548 deletions

View File

@@ -5,14 +5,18 @@ import '../providers/notification_provider.dart';
import 'dart:io'; import 'dart:io';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'package:url_launcher/url_launcher.dart'; import 'package:url_launcher/url_launcher.dart';
import '../widgets/glassmorphism_card.dart'; // import '../widgets/glassmorphism_card.dart';
import '../theme/app_colors.dart'; // import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart'; import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart'; import '../providers/locale_provider.dart';
import 'package:permission_handler/permission_handler.dart' as permission; import 'package:permission_handler/permission_handler.dart' as permission;
import '../services/sms_service.dart'; 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 { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -29,13 +33,16 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected color: isSelected
? AppColors.primaryColor.withValues(alpha: 0.2) ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)
: Colors.transparent, : Colors.transparent,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
border: Border.all( border: Border.all(
color: isSelected color: isSelected
? AppColors.primaryColor ? Theme.of(context).colorScheme.primary
: AppColors.textSecondary.withValues(alpha: 0.5), : Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.5),
width: isSelected ? 2 : 1, width: isSelected ? 2 : 1,
), ),
), ),
@@ -48,8 +55,8 @@ class SettingsScreen extends StatelessWidget {
? Icons.radio_button_checked ? Icons.radio_button_checked
: Icons.radio_button_unchecked, : Icons.radio_button_unchecked,
color: isSelected color: isSelected
? AppColors.primaryColor ? Theme.of(context).colorScheme.primary
: AppColors.textSecondary, : Theme.of(context).colorScheme.onSurfaceVariant,
size: 24, size: 24,
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -59,8 +66,8 @@ class SettingsScreen extends StatelessWidget {
fontSize: 14, fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected color: isSelected
? AppColors.primaryColor ? Theme.of(context).colorScheme.primary
: AppColors.textPrimary, : Theme.of(context).colorScheme.onSurface,
), ),
), ),
], ],
@@ -75,109 +82,183 @@ class SettingsScreen extends StatelessWidget {
return Column( return Column(
children: [ children: [
Expanded( Expanded(
child: ListView( child: PageContainer(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [ children: [
// toolbar 높이 추가 // toolbar 높이 추가
SizedBox( SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top, height: kToolbarHeight + MediaQuery.of(context).padding.top,
), ),
// 광고 위젯 추가 // 광고 위젯 추가
const NativeAdWidget(key: ValueKey('settings_ad')), const NativeAdWidget(
key: ValueKey('settings_ad'),
useOuterPadding: true,
),
const SizedBox(height: 16), 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<ThemeProvider>(
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),
),
),
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),
],
),
],
);
},
),
),
),
// 언어 설정 // 언어 설정
GlassmorphismCard( Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin:
padding: const EdgeInsets.all(8), 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<LocaleProvider>( child: Consumer<LocaleProvider>(
builder: (context, localeProvider, child) { builder: (context, localeProvider, child) {
final loc = AppLocalizations.of(context); final loc = AppLocalizations.of(context);
return ListTile( return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.language,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
title: Text( title: Text(
loc.language, loc.language,
style: const TextStyle(color: AppColors.textPrimary), style: TextStyle(
),
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: color:
AppColors.textSecondary.withValues(alpha: 0.5), Theme.of(context).colorScheme.onSurface,
),
),
),
DropdownButtonFormField<String>(
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
.outline
.withValues(alpha: 0.6),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide(
color:
Theme.of(context).colorScheme.primary,
width: 2,
), ),
), ),
child: DropdownButton<String>(
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: [ items: [
DropdownMenuItem( DropdownMenuItem(
value: 'ko', value: 'ko', child: Text(loc.korean)),
child: Text(
loc.korean,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem( DropdownMenuItem(
value: 'en', value: 'en', child: Text(loc.english)),
child: Text(
loc.english,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem( DropdownMenuItem(
value: 'ja', value: 'ja', child: Text(loc.japanese)),
child: Text(
loc.japanese,
style: const TextStyle(color: Colors.white),
),
),
DropdownMenuItem( DropdownMenuItem(
value: 'zh', value: 'zh', child: Text(loc.chinese)),
child: Text( ],
loc.chinese, onChanged: (val) {
style: const TextStyle(color: Colors.white), if (val != null) localeProvider.setLocale(val);
), },
), ),
], ],
onChanged: (String? value) {
if (value != null) {
localeProvider.setLocale(value);
}
},
),
),
); );
}, },
), ),
), ),
),
// 앱 잠금 설정 UI 숨김 // 앱 잠금 설정 UI 숨김
// Card( // Card(
// margin: const EdgeInsets.all(16), // margin: const EdgeInsets.all(16),
@@ -203,57 +284,194 @@ class SettingsScreen extends StatelessWidget {
// ), // ),
// 알림 설정 // 알림 설정
GlassmorphismCard( Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), 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), padding: const EdgeInsets.all(8),
child: Consumer<NotificationProvider>( child: Consumer<NotificationProvider>(
builder: (context, provider, child) { builder: (context, provider, child) {
return Column( return Column(
children: [ children: [
ListTile( // Android 12+ 정확 알람 권한 (알람 및 리마인더)
if (!kIsWeb && Platform.isAndroid)
FutureBuilder<bool>(
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( title: Text(
AppLocalizations.of(context).notificationPermission, '정확 알람 권한(알람 및 리마인더)',
style: style: TextStyle(
const TextStyle(color: AppColors.textPrimary), color: Theme.of(context)
.colorScheme
.onSurface),
), ),
subtitle: Text( subtitle: Text(
AppLocalizations.of(context) '정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
.notificationPermissionDesc, style: TextStyle(
style: color: Theme.of(context)
const TextStyle(color: AppColors.textSecondary), .colorScheme
.onSurfaceVariant),
), ),
trailing: ElevatedButton( trailing: ElevatedButton(
onPressed: () async { onPressed: () async {
final granted = final ok = await NotificationService
await NotificationService.requestPermission(); .requestExactAlarmsPermission();
if (granted) { // 사용자가 설정 화면에서 허용 후 돌아오면 true가 될 수 있음
await provider.setEnabled(true); final recheck =
await NotificationService
.canScheduleExactAlarms();
if (context.mounted) {
if (ok || recheck) {
AppSnackBar.showSuccess(
context: context,
message: '권한이 허용되었습니다.',
);
} else { } else {
if (!context.mounted) return; AppSnackBar.showInfo(
context: context,
message:
'설정에서 "알람 및 리마인더"를 허용해 주세요.',
);
}
(context as Element).markNeedsBuild();
}
},
child: const Text('허용 요청'),
),
);
},
),
FutureBuilder<permission.PermissionStatus>(
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;
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( AppSnackBar.showError(
context: context, context: context,
message: AppLocalizations.of(context) message: AppLocalizations
.of(context)
.notificationPermissionDenied, .notificationPermissionDenied,
); );
} }
if (context.mounted) {
(context as Element)
.markNeedsBuild();
}
}, },
child: Text( child: Text(
AppLocalizations.of(context).requestPermission), AppLocalizations.of(
context)
.requestPermission),
), ),
);
},
), ),
const Divider(), const Divider(),
// 결제 예정 알림 기본 스위치 // 결제 예정 알림 기본 스위치
SwitchListTile( SwitchListTile(
title: Text( title: Text(
AppLocalizations.of(context).paymentNotification, AppLocalizations.of(context)
style: .paymentNotification,
const TextStyle(color: AppColors.textPrimary), style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
), ),
subtitle: Text( subtitle: Text(
AppLocalizations.of(context) AppLocalizations.of(context)
.paymentNotificationDesc, .paymentNotificationDesc,
style: style: TextStyle(
const TextStyle(color: AppColors.textSecondary), color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
), ),
value: provider.isPaymentEnabled, value: provider.isPaymentEnabled,
onChanged: (value) { onChanged: (value) {
@@ -276,7 +494,14 @@ class SettingsScreen extends StatelessWidget {
.surfaceContainerHighest .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(alpha: 0.3),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius:
BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12.0), padding: const EdgeInsets.all(12.0),
@@ -289,32 +514,38 @@ class SettingsScreen extends StatelessWidget {
AppLocalizations.of(context) AppLocalizations.of(context)
.notificationTiming, .notificationTiming,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold)), fontWeight:
FontWeight.bold)),
const SizedBox(height: 8), const SizedBox(height: 8),
Padding( Padding(
padding: const EdgeInsets.symmetric( padding:
const EdgeInsets.symmetric(
vertical: 8.0), vertical: 8.0),
child: Row( child: Row(
mainAxisAlignment: mainAxisAlignment:
MainAxisAlignment.spaceEvenly, MainAxisAlignment
.spaceEvenly,
children: [ children: [
_buildReminderDayRadio( _buildReminderDayRadio(
context, context,
provider, provider,
1, 1,
AppLocalizations.of(context) AppLocalizations.of(
context)
.oneDayBefore), .oneDayBefore),
_buildReminderDayRadio( _buildReminderDayRadio(
context, context,
provider, provider,
2, 2,
AppLocalizations.of(context) AppLocalizations.of(
context)
.twoDaysBefore), .twoDaysBefore),
_buildReminderDayRadio( _buildReminderDayRadio(
context, context,
provider, provider,
3, 3,
AppLocalizations.of(context) AppLocalizations.of(
context)
.threeDaysBefore), .threeDaysBefore),
], ],
), ),
@@ -327,7 +558,8 @@ class SettingsScreen extends StatelessWidget {
AppLocalizations.of(context) AppLocalizations.of(context)
.notificationTime, .notificationTime,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.bold)), fontWeight:
FontWeight.bold)),
const SizedBox(height: 12), const SizedBox(height: 12),
InkWell( InkWell(
onTap: () async { onTap: () async {
@@ -335,18 +567,20 @@ class SettingsScreen extends StatelessWidget {
await showTimePicker( await showTimePicker(
context: context, context: context,
initialTime: TimeOfDay( initialTime: TimeOfDay(
hour: provider.reminderHour, hour: provider
.reminderHour,
minute: provider minute: provider
.reminderMinute), .reminderMinute),
); );
if (picked != null) { if (picked != null) {
provider.setReminderTime( provider.setReminderTime(
picked.hour, picked.minute); picked.hour,
picked.minute);
} }
}, },
child: Container( child: Container(
padding: padding: const EdgeInsets
const EdgeInsets.symmetric( .symmetric(
vertical: 12, vertical: 12,
horizontal: 16), horizontal: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -354,10 +588,12 @@ class SettingsScreen extends StatelessWidget {
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.outline .outline
.withValues(alpha: 0.5), .withValues(
alpha: 0.5),
), ),
borderRadius: borderRadius:
BorderRadius.circular(8), BorderRadius.circular(
8),
), ),
child: Row( child: Row(
children: [ children: [
@@ -366,8 +602,8 @@ class SettingsScreen extends StatelessWidget {
children: [ children: [
Icon( Icon(
Icons.access_time, Icons.access_time,
color: color: Theme.of(
Theme.of(context) context)
.colorScheme .colorScheme
.primary, .primary,
size: 22, size: 22,
@@ -379,7 +615,8 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: fontWeight:
FontWeight.bold, FontWeight
.bold,
color: Theme.of( color: Theme.of(
context) context)
.colorScheme .colorScheme
@@ -404,20 +641,23 @@ class SettingsScreen extends StatelessWidget {
// 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화) // 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화)
if (provider.reminderDays >= 2) if (provider.reminderDays >= 2)
Padding( Padding(
padding: const EdgeInsets.only( padding:
const EdgeInsets.only(
top: 16.0), top: 16.0),
child: Container( child: Container(
padding: padding: const EdgeInsets
const EdgeInsets.symmetric( .symmetric(
vertical: 4, vertical: 4,
horizontal: 4), horizontal: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Theme.of(context) color: Theme.of(context)
.colorScheme .colorScheme
.surfaceContainerHighest .surfaceContainerHighest
.withValues(alpha: 0.3), .withValues(
alpha: 0.3),
borderRadius: borderRadius:
BorderRadius.circular(8), BorderRadius.circular(
8),
), ),
child: SwitchListTile( child: SwitchListTile(
contentPadding: contentPadding:
@@ -430,23 +670,22 @@ class SettingsScreen extends StatelessWidget {
.dailyReminder), .dailyReminder),
subtitle: Text( subtitle: Text(
provider.isDailyReminderEnabled provider.isDailyReminderEnabled
? AppLocalizations.of( ? AppLocalizations
context) .of(context)
.dailyReminderEnabled .dailyReminderEnabled
: AppLocalizations.of( : AppLocalizations
context) .of(context)
.dailyReminderDisabledWithDays( .dailyReminderDisabledWithDays(
provider provider
.reminderDays), .reminderDays),
style: const TextStyle( style: TextStyle(
color: AppColors color: Theme.of(
.textLight), context)
.colorScheme
.onSurfaceVariant),
), ),
value: provider value: provider
.isDailyReminderEnabled, .isDailyReminderEnabled,
activeColor: Theme.of(context)
.colorScheme
.primary,
onChanged: (value) { onChanged: (value) {
provider provider
.setDailyReminderEnabled( .setDailyReminderEnabled(
@@ -462,6 +701,8 @@ class SettingsScreen extends StatelessWidget {
) )
: const SizedBox.shrink(), : const SizedBox.shrink(),
), ),
// 디버그 전용: 결제 알림 테스트 버튼 (숨김)
// 미사용 서비스 알림 기능 비활성화 // 미사용 서비스 알림 기능 비활성화
// const Divider(), // const Divider(),
// SwitchListTile( // SwitchListTile(
@@ -477,13 +718,16 @@ class SettingsScreen extends StatelessWidget {
}, },
), ),
), ),
),
// SMS 권한 설정 // SMS 권한 설정
if (!kIsWeb && Platform.isAndroid) if (!kIsWeb && Platform.isAndroid)
GlassmorphismCard( Card(
margin: margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8), const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8), elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: FutureBuilder<permission.PermissionStatus>( child: FutureBuilder<permission.PermissionStatus>(
future: permission.Permission.sms.status, future: permission.Permission.sms.status,
builder: (context, snapshot) { builder: (context, snapshot) {
@@ -495,13 +739,16 @@ class SettingsScreen extends StatelessWidget {
status?.isPermanentlyDenied ?? false; status?.isPermanentlyDenied ?? false;
return ListTile( return ListTile(
leading: const Icon( contentPadding: const EdgeInsets.all(8),
leading: Icon(
Icons.sms, Icons.sms,
color: AppColors.textSecondary, color:
Theme.of(context).colorScheme.onSurfaceVariant,
), ),
title: Text( title: Text(
AppLocalizations.of(context).smsPermissionLabel, AppLocalizations.of(context).smsPermissionLabel,
style: const TextStyle(color: AppColors.textPrimary), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
), ),
subtitle: !hasPermission subtitle: !hasPermission
? Text( ? Text(
@@ -510,8 +757,10 @@ class SettingsScreen extends StatelessWidget {
.permanentlyDeniedMessage .permanentlyDeniedMessage
: AppLocalizations.of(context) : AppLocalizations.of(context)
.smsPermissionRequired, .smsPermissionRequired,
style: const TextStyle( style: TextStyle(
color: AppColors.textSecondary), color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
) )
: null, : null,
trailing: isLoading trailing: isLoading
@@ -522,18 +771,21 @@ class SettingsScreen extends StatelessWidget {
CircularProgressIndicator(strokeWidth: 2), CircularProgressIndicator(strokeWidth: 2),
) )
: hasPermission : hasPermission
? const Padding( ? Padding(
padding: padding: const EdgeInsets.symmetric(
EdgeInsets.symmetric(horizontal: 8.0), horizontal: 8.0),
child: Icon(Icons.check_circle, child: Icon(Icons.check_circle,
color: Colors.green), color: Theme.of(context)
.colorScheme
.success),
) )
: isPermanentlyDenied : isPermanentlyDenied
? TextButton( ? TextButton(
onPressed: () async { onPressed: () async {
await permission.openAppSettings(); await permission.openAppSettings();
}, },
child: Text(AppLocalizations.of(context) child: Text(
AppLocalizations.of(context)
.openSettings), .openSettings),
) )
: ElevatedButton( : ElevatedButton(
@@ -543,7 +795,8 @@ class SettingsScreen extends StatelessWidget {
if (!granted) { if (!granted) {
final newStatus = await permission final newStatus = await permission
.Permission.sms.status; .Permission.sms.status;
if (newStatus.isPermanentlyDenied) { if (newStatus
.isPermanentlyDenied) {
await permission await permission
.openAppSettings(); .openAppSettings();
} }
@@ -553,7 +806,8 @@ class SettingsScreen extends StatelessWidget {
.markNeedsBuild(); .markNeedsBuild();
} }
}, },
child: Text(AppLocalizations.of(context) child: Text(
AppLocalizations.of(context)
.requestPermission), .requestPermission),
), ),
); );
@@ -562,25 +816,36 @@ class SettingsScreen extends StatelessWidget {
), ),
// 앱 정보 // 앱 정보
GlassmorphismCard( Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin:
padding: const EdgeInsets.all(8), 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( child: ListTile(
contentPadding: const EdgeInsets.all(8),
title: Text( title: Text(
AppLocalizations.of(context).appInfo, AppLocalizations.of(context).appInfo,
style: const TextStyle(color: AppColors.textPrimary), style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
), ),
subtitle: Text( subtitle: Text(
'${AppLocalizations.of(context).version} 1.0.0', '${AppLocalizations.of(context).version} 1.0.0',
style: const TextStyle(color: AppColors.textSecondary), style: TextStyle(
), color:
leading: const Icon( Theme.of(context).colorScheme.onSurfaceVariant),
Icons.info,
color: AppColors.textSecondary,
), ),
leading: Icon(Icons.info,
color: Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () async { onTap: () async {
// 웹 환경에서는 기본 다이얼로그 표시 // 항상 앱 내 About 다이얼로그를 우선 표시
if (kIsWeb) {
showAboutDialog( showAboutDialog(
context: context, context: context,
applicationName: AppLocalizations.of(context).appTitle, applicationName: AppLocalizations.of(context).appTitle,
@@ -591,54 +856,47 @@ class SettingsScreen extends StatelessWidget {
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'${AppLocalizations.of(context).developer}: Julian Sul'), '${AppLocalizations.of(context).developer}: Julian Sul'),
], const SizedBox(height: 12),
); Builder(builder: (ctx) {
return; return TextButton.icon(
} icon: const Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(ctx).openStore),
// 앱 스토어 링크 onPressed: () async {
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 { try {
final Uri url = Uri.parse(storeUrl); if (Platform.isAndroid) {
await launchUrl(url, // 우선 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); 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) { } catch (e) {
if (context.mounted) { if (ctx.mounted) {
AppSnackBar.showError( AppSnackBar.showError(
context: context, context: ctx,
message: message: AppLocalizations.of(ctx)
AppLocalizations.of(context).cannotOpenStore, .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'),
], ],
); );
}
}, },
), ),
), ),
@@ -649,6 +907,7 @@ class SettingsScreen extends StatelessWidget {
], ],
), ),
), ),
),
], ],
); );
} }

View File

@@ -248,6 +248,34 @@ class NotificationService {
return true; // 기본값 return true; // 기본값
} }
// Android: 정확 알람 권한 가능 여부 확인 (S+)
static Future<bool> 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<bool> 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<void> scheduleNotification({ static Future<void> scheduleNotification({
required int id, required int id,