Files
submanager/lib/screens/settings_screen.dart

915 lines
49 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:provider/provider.dart';
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/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});
// 알림 시점 라디오 버튼 생성 헬퍼 메서드
Widget _buildReminderDayRadio(BuildContext context,
NotificationProvider provider, int value, String label) {
final isSelected = provider.reminderDays == value;
return Expanded(
child: InkWell(
onTap: () => provider.setReminderDays(value),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context)
.colorScheme
.onSurfaceVariant
.withValues(alpha: 0.5),
width: isSelected ? 2 : 1,
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
isSelected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurfaceVariant,
size: 24,
),
const SizedBox(height: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.onSurface,
),
),
],
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
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'),
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),
],
),
],
);
},
),
),
),
// 언어 설정
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 Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
Icons.language,
color: Theme.of(context)
.colorScheme
.onSurfaceVariant,
),
title: Text(
loc.language,
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurface,
),
),
),
DropdownButtonFormField<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,
),
),
),
items: [
DropdownMenuItem(
value: 'ko', child: Text(loc.korean)),
DropdownMenuItem(
value: 'en', child: Text(loc.english)),
DropdownMenuItem(
value: 'ja', child: Text(loc.japanese)),
DropdownMenuItem(
value: 'zh', child: Text(loc.chinese)),
],
onChanged: (val) {
if (val != null) localeProvider.setLocale(val);
},
),
],
);
},
),
),
),
// 앱 잠금 설정 UI 숨김
// Card(
// margin: const EdgeInsets.all(16),
// child: Consumer<AppLockProvider>(
// builder: (context, provider, child) {
// return SwitchListTile(
// title: const Text('앱 잠금'),
// subtitle: const Text('생체 인증으로 앱 잠금'),
// value: provider.isEnabled,
// onChanged: (value) async {
// if (value) {
// final isAuthenticated = await provider.authenticate();
// if (isAuthenticated) {
// provider.enable();
// }
// } else {
// provider.disable();
// }
// },
// );
// },
// ),
// ),
// 알림 설정
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: Consumer<NotificationProvider>(
builder: (context, provider, child) {
return Column(
children: [
// 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(
'정확 알람 권한(알람 및 리마인더)',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: Text(
'정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
),
trailing: ElevatedButton(
onPressed: () async {
final ok = await NotificationService
.requestExactAlarmsPermission();
// 사용자가 설정 화면에서 허용 후 돌아오면 true가 될 수 있음
final recheck =
await NotificationService
.canScheduleExactAlarms();
if (context.mounted) {
if (ok || recheck) {
AppSnackBar.showSuccess(
context: context,
message: '권한이 허용되었습니다.',
);
} else {
AppSnackBar.showInfo(
context: context,
message:
'설정에서 "알람 및 리마인더"를 허용해 주세요.',
);
}
(context as Element).markNeedsBuild();
}
},
child: const Text('허용 요청'),
),
);
},
),
FutureBuilder<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)
.notificationPermissionDenied,
);
}
if (context.mounted) {
(context as Element)
.markNeedsBuild();
}
},
child: Text(
AppLocalizations.of(
context)
.requestPermission),
),
);
},
),
const Divider(),
// 결제 예정 알림 기본 스위치
SwitchListTile(
title: Text(
AppLocalizations.of(context)
.paymentNotification,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: Text(
AppLocalizations.of(context)
.paymentNotificationDesc,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
),
value: provider.isPaymentEnabled,
onChanged: (value) {
provider.setPaymentEnabled(value);
},
),
// 알림 세부 설정 (알림 활성화된 경우에만 표시)
AnimatedSize(
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
child: provider.isPaymentEnabled
? Padding(
padding: const EdgeInsets.only(
left: 16.0, right: 16.0, bottom: 8.0),
child: Card(
elevation: 0,
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius:
BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.3),
),
),
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
// 알림 시점 선택 (1일전, 2일전, 3일전)
Text(
AppLocalizations.of(context)
.notificationTiming,
style: const TextStyle(
fontWeight:
FontWeight.bold)),
const SizedBox(height: 8),
Padding(
padding:
const EdgeInsets.symmetric(
vertical: 8.0),
child: Row(
mainAxisAlignment:
MainAxisAlignment
.spaceEvenly,
children: [
_buildReminderDayRadio(
context,
provider,
1,
AppLocalizations.of(
context)
.oneDayBefore),
_buildReminderDayRadio(
context,
provider,
2,
AppLocalizations.of(
context)
.twoDaysBefore),
_buildReminderDayRadio(
context,
provider,
3,
AppLocalizations.of(
context)
.threeDaysBefore),
],
),
),
const SizedBox(height: 16),
// 알림 시간 선택
Text(
AppLocalizations.of(context)
.notificationTime,
style: const TextStyle(
fontWeight:
FontWeight.bold)),
const SizedBox(height: 12),
InkWell(
onTap: () async {
final TimeOfDay? picked =
await showTimePicker(
context: context,
initialTime: TimeOfDay(
hour: provider
.reminderHour,
minute: provider
.reminderMinute),
);
if (picked != null) {
provider.setReminderTime(
picked.hour,
picked.minute);
}
},
child: Container(
padding: const EdgeInsets
.symmetric(
vertical: 12,
horizontal: 16),
decoration: BoxDecoration(
border: Border.all(
color: Theme.of(context)
.colorScheme
.outline
.withValues(
alpha: 0.5),
),
borderRadius:
BorderRadius.circular(
8),
),
child: Row(
children: [
Expanded(
child: Row(
children: [
Icon(
Icons.access_time,
color: Theme.of(
context)
.colorScheme
.primary,
size: 22,
),
const SizedBox(
width: 12),
Text(
'${provider.reminderHour.toString().padLeft(2, '0')}:${provider.reminderMinute.toString().padLeft(2, '0')}',
style: TextStyle(
fontSize: 16,
fontWeight:
FontWeight
.bold,
color: Theme.of(
context)
.colorScheme
.onSurface,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context)
.colorScheme
.outline,
),
],
),
),
),
// 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화)
if (provider.reminderDays >= 2)
Padding(
padding:
const EdgeInsets.only(
top: 16.0),
child: Container(
padding: const EdgeInsets
.symmetric(
vertical: 4,
horizontal: 4),
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(
alpha: 0.3),
borderRadius:
BorderRadius.circular(
8),
),
child: SwitchListTile(
contentPadding:
const EdgeInsets
.symmetric(
horizontal: 12),
title: Text(
AppLocalizations.of(
context)
.dailyReminder),
subtitle: Text(
provider.isDailyReminderEnabled
? AppLocalizations
.of(context)
.dailyReminderEnabled
: AppLocalizations
.of(context)
.dailyReminderDisabledWithDays(
provider
.reminderDays),
style: TextStyle(
color: Theme.of(
context)
.colorScheme
.onSurfaceVariant),
),
value: provider
.isDailyReminderEnabled,
onChanged: (value) {
provider
.setDailyReminderEnabled(
value);
},
),
),
),
],
),
),
),
)
: const SizedBox.shrink(),
),
// 디버그 전용: 결제 알림 테스트 버튼 (숨김)
// 미사용 서비스 알림 기능 비활성화
// const Divider(),
// SwitchListTile(
// title: const Text('미사용 서비스 알림'),
// subtitle: const Text('2개월 이상 미사용 시 알림'),
// value: provider.isUnusedServiceNotificationEnabled,
// onChanged: (value) {
// provider.setUnusedServiceNotificationEnabled(value);
// },
// ),
],
);
},
),
),
),
// SMS 권한 설정
if (!kIsWeb && Platform.isAndroid)
Card(
margin:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
child: FutureBuilder<permission.PermissionStatus>(
future: permission.Permission.sms.status,
builder: (context, snapshot) {
final isLoading =
snapshot.connectionState == ConnectionState.waiting;
final status = snapshot.data;
final hasPermission = status?.isGranted ?? false;
final isPermanentlyDenied =
status?.isPermanentlyDenied ?? false;
return ListTile(
contentPadding: const EdgeInsets.all(8),
leading: Icon(
Icons.sms,
color:
Theme.of(context).colorScheme.onSurfaceVariant,
),
title: Text(
AppLocalizations.of(context).smsPermissionLabel,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
),
subtitle: !hasPermission
? Text(
isPermanentlyDenied
? AppLocalizations.of(context)
.permanentlyDeniedMessage
: AppLocalizations.of(context)
.smsPermissionRequired,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurfaceVariant),
)
: null,
trailing: isLoading
? const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(strokeWidth: 2),
)
: hasPermission
? Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8.0),
child: Icon(Icons.check_circle,
color: Theme.of(context)
.colorScheme
.success),
)
: isPermanentlyDenied
? TextButton(
onPressed: () async {
await permission.openAppSettings();
},
child: Text(
AppLocalizations.of(context)
.openSettings),
)
: ElevatedButton(
onPressed: () async {
final granted = await SMSService
.requestSMSPermission();
if (!granted) {
final newStatus = await permission
.Permission.sms.status;
if (newStatus
.isPermanentlyDenied) {
await permission
.openAppSettings();
}
}
if (context.mounted) {
(context as Element)
.markNeedsBuild();
}
},
child: Text(
AppLocalizations.of(context)
.requestPermission),
),
);
},
),
),
// 앱 정보
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
),
child: ListTile(
contentPadding: const EdgeInsets.all(8),
title: Text(
AppLocalizations.of(context).appInfo,
style: TextStyle(
color: Theme.of(context).colorScheme.onSurface),
),
subtitle: Text(
'${AppLocalizations.of(context).version} 1.0.0',
style: TextStyle(
color:
Theme.of(context).colorScheme.onSurfaceVariant),
),
leading: Icon(Icons.info,
color: Theme.of(context).colorScheme.onSurfaceVariant),
onTap: () async {
// 항상 앱 내 About 다이얼로그를 우선 표시
showAboutDialog(
context: context,
applicationName: AppLocalizations.of(context).appTitle,
applicationVersion: '1.0.0',
applicationIcon: const FlutterLogo(size: 50),
children: [
Text(AppLocalizations.of(context).appDescription),
const SizedBox(height: 8),
Text(
'${AppLocalizations.of(context).developer}: Julian Sul'),
const SizedBox(height: 12),
Builder(builder: (ctx) {
return TextButton.icon(
icon: const Icon(Icons.open_in_new),
label: Text(AppLocalizations.of(ctx).openStore),
onPressed: () async {
try {
if (Platform.isAndroid) {
// 우선 Play 스토어 앱 시도
const pkg =
'com.naturebridgeai.digitalrentmanager';
final marketUri =
Uri.parse('market://details?id=$pkg');
final webUri = Uri.parse(
'https://play.google.com/store/apps/details?id=$pkg');
final ok = await launchUrl(marketUri,
mode: LaunchMode.externalApplication);
if (!ok) {
await launchUrl(webUri,
mode: LaunchMode.externalApplication);
}
} else if (Platform.isIOS) {
final uri = Uri.parse(
'https://apps.apple.com/app/id123456789');
await launchUrl(uri,
mode: LaunchMode.externalApplication);
}
} catch (e) {
if (ctx.mounted) {
AppSnackBar.showError(
context: ctx,
message: AppLocalizations.of(ctx)
.cannotOpenStore,
);
}
}
},
);
}),
],
);
},
),
),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
),
),
),
],
);
}
}