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 '../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,109 +82,183 @@ class SettingsScreen extends StatelessWidget {
return Column(
children: [
Expanded(
child: ListView(
child: PageContainer(
padding: EdgeInsets.zero,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
// toolbar 높이 추가
SizedBox(
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),
// 테마 모드 설정
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(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
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<LocaleProvider>(
builder: (context, localeProvider, child) {
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(
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(
style: TextStyle(
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: [
DropdownMenuItem(
value: 'ko',
child: Text(
loc.korean,
style: const TextStyle(color: Colors.white),
),
),
value: 'ko', child: Text(loc.korean)),
DropdownMenuItem(
value: 'en',
child: Text(
loc.english,
style: const TextStyle(color: Colors.white),
),
),
value: 'en', child: Text(loc.english)),
DropdownMenuItem(
value: 'ja',
child: Text(
loc.japanese,
style: const TextStyle(color: Colors.white),
),
),
value: 'ja', child: Text(loc.japanese)),
DropdownMenuItem(
value: 'zh',
child: Text(
loc.chinese,
style: const TextStyle(color: Colors.white),
),
value: 'zh', child: Text(loc.chinese)),
],
onChanged: (val) {
if (val != null) localeProvider.setLocale(val);
},
),
],
onChanged: (String? value) {
if (value != null) {
localeProvider.setLocale(value);
}
},
),
),
);
},
),
),
),
// 앱 잠금 설정 UI 숨김
// Card(
// margin: const EdgeInsets.all(16),
@@ -203,57 +284,194 @@ class SettingsScreen extends StatelessWidget {
// ),
// 알림 설정
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
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<NotificationProvider>(
builder: (context, provider, child) {
return Column(
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(
AppLocalizations.of(context).notificationPermission,
style:
const TextStyle(color: AppColors.textPrimary),
'정확 알람 권한(알람 및 리마인더)',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: Text(
AppLocalizations.of(context)
.notificationPermissionDesc,
style:
const TextStyle(color: AppColors.textSecondary),
'정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
),
trailing: ElevatedButton(
onPressed: () async {
final granted =
await NotificationService.requestPermission();
if (granted) {
await provider.setEnabled(true);
final ok = await NotificationService
.requestExactAlarmsPermission();
// 사용자가 설정 화면에서 허용 후 돌아오면 true가 될 수 있음
final recheck =
await NotificationService
.canScheduleExactAlarms();
if (context.mounted) {
if (ok || recheck) {
AppSnackBar.showSuccess(
context: context,
message: '권한이 허용되었습니다.',
);
} 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(
context: context,
message: AppLocalizations.of(context)
message: AppLocalizations
.of(context)
.notificationPermissionDenied,
);
}
if (context.mounted) {
(context as Element)
.markNeedsBuild();
}
},
child: Text(
AppLocalizations.of(context).requestPermission),
AppLocalizations.of(
context)
.requestPermission),
),
);
},
),
const Divider(),
// 결제 예정 알림 기본 스위치
SwitchListTile(
title: Text(
AppLocalizations.of(context).paymentNotification,
style:
const TextStyle(color: AppColors.textPrimary),
AppLocalizations.of(context)
.paymentNotification,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: Text(
AppLocalizations.of(context)
.paymentNotificationDesc,
style:
const TextStyle(color: AppColors.textSecondary),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
),
value: provider.isPaymentEnabled,
onChanged: (value) {
@@ -276,7 +494,14 @@ class SettingsScreen extends StatelessWidget {
.surfaceContainerHighest
.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
borderRadius:
BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
@@ -289,32 +514,38 @@ class SettingsScreen extends StatelessWidget {
AppLocalizations.of(context)
.notificationTiming,
style: const TextStyle(
fontWeight: FontWeight.bold)),
fontWeight:
FontWeight.bold)),
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.symmetric(
padding:
const EdgeInsets.symmetric(
vertical: 8.0),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceEvenly,
MainAxisAlignment
.spaceEvenly,
children: [
_buildReminderDayRadio(
context,
provider,
1,
AppLocalizations.of(context)
AppLocalizations.of(
context)
.oneDayBefore),
_buildReminderDayRadio(
context,
provider,
2,
AppLocalizations.of(context)
AppLocalizations.of(
context)
.twoDaysBefore),
_buildReminderDayRadio(
context,
provider,
3,
AppLocalizations.of(context)
AppLocalizations.of(
context)
.threeDaysBefore),
],
),
@@ -327,7 +558,8 @@ class SettingsScreen extends StatelessWidget {
AppLocalizations.of(context)
.notificationTime,
style: const TextStyle(
fontWeight: FontWeight.bold)),
fontWeight:
FontWeight.bold)),
const SizedBox(height: 12),
InkWell(
onTap: () async {
@@ -335,18 +567,20 @@ class SettingsScreen extends StatelessWidget {
await showTimePicker(
context: context,
initialTime: TimeOfDay(
hour: provider.reminderHour,
hour: provider
.reminderHour,
minute: provider
.reminderMinute),
);
if (picked != null) {
provider.setReminderTime(
picked.hour, picked.minute);
picked.hour,
picked.minute);
}
},
child: Container(
padding:
const EdgeInsets.symmetric(
padding: const EdgeInsets
.symmetric(
vertical: 12,
horizontal: 16),
decoration: BoxDecoration(
@@ -354,10 +588,12 @@ class SettingsScreen extends StatelessWidget {
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
.withValues(
alpha: 0.5),
),
borderRadius:
BorderRadius.circular(8),
BorderRadius.circular(
8),
),
child: Row(
children: [
@@ -366,8 +602,8 @@ class SettingsScreen extends StatelessWidget {
children: [
Icon(
Icons.access_time,
color:
Theme.of(context)
color: Theme.of(
context)
.colorScheme
.primary,
size: 22,
@@ -379,7 +615,8 @@ class SettingsScreen extends StatelessWidget {
style: TextStyle(
fontSize: 16,
fontWeight:
FontWeight.bold,
FontWeight
.bold,
color: Theme.of(
context)
.colorScheme
@@ -404,20 +641,23 @@ class SettingsScreen extends StatelessWidget {
// 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화)
if (provider.reminderDays >= 2)
Padding(
padding: const EdgeInsets.only(
padding:
const EdgeInsets.only(
top: 16.0),
child: Container(
padding:
const EdgeInsets.symmetric(
padding: const EdgeInsets
.symmetric(
vertical: 4,
horizontal: 4),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.3),
.withValues(
alpha: 0.3),
borderRadius:
BorderRadius.circular(8),
BorderRadius.circular(
8),
),
child: SwitchListTile(
contentPadding:
@@ -430,23 +670,22 @@ class SettingsScreen extends StatelessWidget {
.dailyReminder),
subtitle: Text(
provider.isDailyReminderEnabled
? AppLocalizations.of(
context)
? AppLocalizations
.of(context)
.dailyReminderEnabled
: AppLocalizations.of(
context)
: AppLocalizations
.of(context)
.dailyReminderDisabledWithDays(
provider
.reminderDays),
style: const TextStyle(
color: AppColors
.textLight),
style: TextStyle(
color: Theme.of(
context)
.colorScheme
.onSurfaceVariant),
),
value: provider
.isDailyReminderEnabled,
activeColor: Theme.of(context)
.colorScheme
.primary,
onChanged: (value) {
provider
.setDailyReminderEnabled(
@@ -462,6 +701,8 @@ class SettingsScreen extends StatelessWidget {
)
: const SizedBox.shrink(),
),
// 디버그 전용: 결제 알림 테스트 버튼 (숨김)
// 미사용 서비스 알림 기능 비활성화
// const Divider(),
// SwitchListTile(
@@ -477,13 +718,16 @@ class SettingsScreen extends StatelessWidget {
},
),
),
),
// SMS 권한 설정
if (!kIsWeb && Platform.isAndroid)
GlassmorphismCard(
Card(
margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: FutureBuilder<permission.PermissionStatus>(
future: permission.Permission.sms.status,
builder: (context, snapshot) {
@@ -495,13 +739,16 @@ class SettingsScreen extends StatelessWidget {
status?.isPermanentlyDenied ?? false;
return ListTile(
leading: const Icon(
contentPadding: const EdgeInsets.all(8),
leading: Icon(
Icons.sms,
color: AppColors.textSecondary,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(
AppLocalizations.of(context).smsPermissionLabel,
style: const TextStyle(color: AppColors.textPrimary),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
),
subtitle: !hasPermission
? Text(
@@ -510,8 +757,10 @@ class SettingsScreen extends StatelessWidget {
.permanentlyDeniedMessage
: AppLocalizations.of(context)
.smsPermissionRequired,
style: const TextStyle(
color: AppColors.textSecondary),
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
)
: null,
trailing: isLoading
@@ -522,18 +771,21 @@ class SettingsScreen extends StatelessWidget {
CircularProgressIndicator(strokeWidth: 2),
)
: hasPermission
? const Padding(
padding:
EdgeInsets.symmetric(horizontal: 8.0),
? Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0),
child: Icon(Icons.check_circle,
color: Colors.green),
color: Theme.of(context)
.colorScheme
.success),
)
: isPermanentlyDenied
? TextButton(
onPressed: () async {
await permission.openAppSettings();
},
child: Text(AppLocalizations.of(context)
child: Text(
AppLocalizations.of(context)
.openSettings),
)
: ElevatedButton(
@@ -543,7 +795,8 @@ class SettingsScreen extends StatelessWidget {
if (!granted) {
final newStatus = await permission
.Permission.sms.status;
if (newStatus.isPermanentlyDenied) {
if (newStatus
.isPermanentlyDenied) {
await permission
.openAppSettings();
}
@@ -553,7 +806,8 @@ class SettingsScreen extends StatelessWidget {
.markNeedsBuild();
}
},
child: Text(AppLocalizations.of(context)
child: Text(
AppLocalizations.of(context)
.requestPermission),
),
);
@@ -562,25 +816,36 @@ class SettingsScreen extends StatelessWidget {
),
// 앱 정보
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
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: const TextStyle(color: AppColors.textPrimary),
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
),
subtitle: Text(
'${AppLocalizations.of(context).version} 1.0.0',
style: const TextStyle(color: AppColors.textSecondary),
),
leading: const Icon(
Icons.info,
color: AppColors.textSecondary,
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurfaceVariant),
),
leading: Icon(Icons.info,
color: Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () async {
// 웹 환경에서는 기본 다이얼로그 표시
if (kIsWeb) {
// 항상 앱 내 About 다이얼로그를 우선 표시
showAboutDialog(
context: context,
applicationName: AppLocalizations.of(context).appTitle,
@@ -591,54 +856,47 @@ class SettingsScreen extends StatelessWidget {
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) {
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 {
final Uri url = Uri.parse(storeUrl);
await launchUrl(url,
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 (context.mounted) {
if (ctx.mounted) {
AppSnackBar.showError(
context: context,
message:
AppLocalizations.of(context).cannotOpenStore,
context: ctx,
message: AppLocalizations.of(ctx)
.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; // 기본값
}
// 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({
required int id,