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:
@@ -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 {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user