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