주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
562 lines
25 KiB
Dart
562 lines
25 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
|
import 'package:provider/provider.dart';
|
|
import '../providers/app_lock_provider.dart';
|
|
import '../providers/notification_provider.dart';
|
|
import '../providers/subscription_provider.dart';
|
|
import 'package:share_plus/share_plus.dart';
|
|
import 'package:path_provider/path_provider.dart';
|
|
import 'dart:io';
|
|
import 'package:path/path.dart' as path;
|
|
import '../services/notification_service.dart';
|
|
import '../screens/sms_scan_screen.dart';
|
|
import 'package:url_launcher/url_launcher.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.withOpacity(0.2)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(8),
|
|
border: Border.all(
|
|
color: isSelected
|
|
? Theme.of(context).colorScheme.primary
|
|
: Theme.of(context).colorScheme.outline.withOpacity(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.outline,
|
|
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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _backupData(BuildContext context) async {
|
|
try {
|
|
final provider = context.read<SubscriptionProvider>();
|
|
final subscriptions = provider.subscriptions;
|
|
|
|
// 임시 디렉토리에 백업 파일 생성
|
|
final tempDir = await getTemporaryDirectory();
|
|
final backupFile =
|
|
File(path.join(tempDir.path, 'submanager_backup.json'));
|
|
|
|
// 구독 데이터를 JSON 형식으로 저장
|
|
final jsonData = subscriptions
|
|
.map((sub) => {
|
|
'id': sub.id,
|
|
'serviceName': sub.serviceName,
|
|
'monthlyCost': sub.monthlyCost,
|
|
'billingCycle': sub.billingCycle,
|
|
'nextBillingDate': sub.nextBillingDate.toIso8601String(),
|
|
'isAutoDetected': sub.isAutoDetected,
|
|
'repeatCount': sub.repeatCount,
|
|
'lastPaymentDate': sub.lastPaymentDate?.toIso8601String(),
|
|
})
|
|
.toList();
|
|
|
|
await backupFile.writeAsString(jsonData.toString());
|
|
|
|
// 파일 공유
|
|
await Share.shareXFiles(
|
|
[XFile(backupFile.path)],
|
|
text: 'SubManager 백업 파일',
|
|
);
|
|
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('백업 파일이 생성되었습니다')),
|
|
);
|
|
}
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(content: Text('백업 중 오류가 발생했습니다: $e')),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
// SMS 스캔 화면으로 이동
|
|
void _navigateToSmsScan(BuildContext context) async {
|
|
final added = await Navigator.push<bool>(
|
|
context,
|
|
MaterialPageRoute(builder: (context) => const SmsScanScreen()),
|
|
);
|
|
|
|
if (added == true && context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('설정'),
|
|
),
|
|
body: ListView(
|
|
children: [
|
|
// 앱 잠금 설정 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(horizontal: 16),
|
|
child: Consumer<NotificationProvider>(
|
|
builder: (context, provider, child) {
|
|
return Column(
|
|
children: [
|
|
ListTile(
|
|
title: const Text('알림 권한'),
|
|
subtitle: const Text('알림을 받으려면 권한이 필요합니다'),
|
|
trailing: ElevatedButton(
|
|
onPressed: () async {
|
|
final granted =
|
|
await NotificationService.requestPermission();
|
|
if (granted) {
|
|
provider.setEnabled(true);
|
|
} else {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(
|
|
content: Text('알림 권한이 거부되었습니다'),
|
|
),
|
|
);
|
|
}
|
|
},
|
|
child: const Text('권한 요청'),
|
|
),
|
|
),
|
|
const Divider(),
|
|
// 결제 예정 알림 기본 스위치
|
|
SwitchListTile(
|
|
title: const Text('결제 예정 알림'),
|
|
subtitle: const Text('결제 예정일 알림 받기'),
|
|
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
|
|
.surfaceVariant
|
|
.withOpacity(0.3),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(12.0),
|
|
child: Column(
|
|
crossAxisAlignment:
|
|
CrossAxisAlignment.start,
|
|
children: [
|
|
// 알림 시점 선택 (1일전, 2일전, 3일전)
|
|
const Text('알림 시점',
|
|
style: 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, '1일 전'),
|
|
_buildReminderDayRadio(
|
|
context, provider, 2, '2일 전'),
|
|
_buildReminderDayRadio(
|
|
context, provider, 3, '3일 전'),
|
|
],
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 16),
|
|
|
|
// 알림 시간 선택
|
|
const Text('알림 시간',
|
|
style: 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
|
|
.withOpacity(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
|
|
.surfaceVariant
|
|
.withOpacity(0.3),
|
|
borderRadius:
|
|
BorderRadius.circular(8),
|
|
),
|
|
child: SwitchListTile(
|
|
contentPadding:
|
|
const EdgeInsets.symmetric(
|
|
horizontal: 12),
|
|
title: const Text('1일마다 반복 알림'),
|
|
subtitle: const Text(
|
|
'결제일까지 매일 알림을 받습니다'),
|
|
value: provider
|
|
.isDailyReminderEnabled,
|
|
activeColor: Theme.of(context)
|
|
.colorScheme
|
|
.primary,
|
|
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);
|
|
// },
|
|
// ),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
),
|
|
|
|
// 데이터 관리
|
|
Card(
|
|
margin: const EdgeInsets.all(16),
|
|
child: Column(
|
|
children: [
|
|
// 데이터 백업 기능 비활성화
|
|
// ListTile(
|
|
// title: const Text('데이터 백업'),
|
|
// subtitle: const Text('구독 데이터를 백업합니다'),
|
|
// leading: const Icon(Icons.backup),
|
|
// onTap: () => _backupData(context),
|
|
// ),
|
|
// const Divider(),
|
|
// SMS 스캔 - 시각적으로 강조된 UI
|
|
InkWell(
|
|
onTap: () => _navigateToSmsScan(context),
|
|
child: Container(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
colors: [
|
|
Theme.of(context).primaryColor.withOpacity(0.1),
|
|
Theme.of(context).primaryColor.withOpacity(0.2),
|
|
],
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
),
|
|
borderRadius: const BorderRadius.only(
|
|
bottomLeft: Radius.circular(12),
|
|
bottomRight: Radius.circular(12),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(
|
|
vertical: 16.0, horizontal: 8.0),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.all(12),
|
|
margin: const EdgeInsets.only(left: 8, right: 16),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context)
|
|
.primaryColor
|
|
.withOpacity(0.15),
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
Icons.sms_rounded,
|
|
color: Theme.of(context).primaryColor,
|
|
size: 28,
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Text(
|
|
'SMS 스캔으로 구독 자동 찾기',
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.bold,
|
|
fontSize: 16,
|
|
color: Theme.of(context).primaryColor,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 8,
|
|
vertical: 3,
|
|
),
|
|
decoration: BoxDecoration(
|
|
color: Theme.of(context).primaryColor,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: const Text(
|
|
'추천',
|
|
style: TextStyle(
|
|
color: Colors.white,
|
|
fontSize: 10,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 6),
|
|
const Text(
|
|
'2회 이상 반복 결제된 구독 서비스를 자동으로 찾아 추가합니다',
|
|
style: TextStyle(
|
|
color: Colors.black54,
|
|
fontSize: 13,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Icon(
|
|
Icons.arrow_forward_ios,
|
|
size: 16,
|
|
color: Theme.of(context).primaryColor,
|
|
),
|
|
const SizedBox(width: 16),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
|
|
// 앱 정보
|
|
Card(
|
|
margin: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: ListTile(
|
|
title: const Text('앱 정보'),
|
|
subtitle: const Text('버전 1.0.0'),
|
|
leading: const Icon(Icons.info),
|
|
onTap: () async {
|
|
// 웹 환경에서는 기본 다이얼로그 표시
|
|
if (kIsWeb) {
|
|
showAboutDialog(
|
|
context: context,
|
|
applicationName: 'SubManager',
|
|
applicationVersion: '1.0.0',
|
|
applicationIcon: const FlutterLogo(size: 50),
|
|
children: [
|
|
const Text('구독 관리 앱'),
|
|
const SizedBox(height: 8),
|
|
const Text('개발자: SubManager Team'),
|
|
],
|
|
);
|
|
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) {
|
|
try {
|
|
final Uri url = Uri.parse(storeUrl);
|
|
await launchUrl(url, mode: LaunchMode.externalApplication);
|
|
} catch (e) {
|
|
if (context.mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('스토어를 열 수 없습니다')),
|
|
);
|
|
}
|
|
}
|
|
} else {
|
|
// 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시
|
|
showAboutDialog(
|
|
context: context,
|
|
applicationName: 'SubManager',
|
|
applicationVersion: '1.0.0',
|
|
applicationIcon: const FlutterLogo(size: 50),
|
|
children: [
|
|
const Text('구독 관리 앱'),
|
|
const SizedBox(height: 8),
|
|
const Text('개발자: SubManager Team'),
|
|
],
|
|
);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|