Initial commit: SubManager Flutter App
주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
561
lib/screens/settings_screen.dart
Normal file
561
lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,561 @@
|
||||
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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user