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:
JiWoong Sul
2025-07-09 14:29:53 +09:00
commit 8619e96739
177 changed files with 23085 additions and 0 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
class AppLockScreen extends StatelessWidget {
const AppLockScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.lock_outline,
size: 80,
color: Colors.grey,
),
const SizedBox(height: 24),
const Text(
'앱이 잠겨 있습니다',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
const Text(
'생체 인증으로 잠금을 해제하세요',
style: TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: () async {
final appLock = context.read<AppLockProvider>();
final success = await appLock.authenticate();
if (!success) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('인증에 실패했습니다. 다시 시도해주세요.'),
),
);
}
},
icon: const Icon(Icons.fingerprint),
label: const Text('생체 인증으로 잠금 해제'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,
vertical: 12,
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,179 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/category_provider.dart';
import '../models/category_model.dart';
class CategoryManagementScreen extends StatefulWidget {
const CategoryManagementScreen({super.key});
@override
State<CategoryManagementScreen> createState() =>
_CategoryManagementScreenState();
}
class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
final _formKey = GlobalKey<FormState>();
final _nameController = TextEditingController();
String _selectedColor = '#1976D2';
String _selectedIcon = 'subscriptions';
@override
void dispose() {
_nameController.dispose();
super.dispose();
}
Future<void> _addCategory() async {
if (_formKey.currentState!.validate()) {
await Provider.of<CategoryProvider>(context, listen: false).addCategory(
name: _nameController.text,
color: _selectedColor,
icon: _selectedIcon,
);
_nameController.clear();
setState(() {
_selectedColor = '#1976D2';
_selectedIcon = 'subscriptions';
});
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('카테고리 관리'),
backgroundColor: const Color(0xFF1976D2),
),
body: Consumer<CategoryProvider>(
builder: (context, provider, child) {
return Column(
children: [
// 카테고리 추가 폼
Card(
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Form(
key: _formKey,
child: Column(
children: [
TextFormField(
controller: _nameController,
decoration: const InputDecoration(
labelText: '카테고리 이름',
),
validator: (value) {
if (value == null || value.isEmpty) {
return '카테고리 이름을 입력하세요';
}
return null;
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedColor,
decoration: const InputDecoration(
labelText: '색상 선택',
),
items: const [
DropdownMenuItem(
value: '#1976D2', child: Text('파란색')),
DropdownMenuItem(
value: '#4CAF50', child: Text('초록색')),
DropdownMenuItem(
value: '#FF9800', child: Text('주황색')),
DropdownMenuItem(
value: '#F44336', child: Text('빨간색')),
DropdownMenuItem(
value: '#9C27B0', child: Text('보라색')),
],
onChanged: (value) {
setState(() {
_selectedColor = value!;
});
},
),
const SizedBox(height: 16),
DropdownButtonFormField<String>(
value: _selectedIcon,
decoration: const InputDecoration(
labelText: '아이콘 선택',
),
items: const [
DropdownMenuItem(
value: 'subscriptions', child: Text('구독')),
DropdownMenuItem(value: 'movie', child: Text('영화')),
DropdownMenuItem(
value: 'music_note', child: Text('음악')),
DropdownMenuItem(
value: 'fitness_center', child: Text('운동')),
DropdownMenuItem(
value: 'shopping_cart', child: Text('쇼핑')),
],
onChanged: (value) {
setState(() {
_selectedIcon = value!;
});
},
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _addCategory,
child: const Text('카테고리 추가'),
),
],
),
),
),
),
// 카테고리 목록
Expanded(
child: ListView.builder(
itemCount: provider.categories.length,
itemBuilder: (context, index) {
final category = provider.categories[index];
return ListTile(
leading: Icon(
IconData(
_getIconCode(category.icon),
fontFamily: 'MaterialIcons',
),
color: Color(
int.parse(category.color.replaceAll('#', '0xFF'))),
),
title: Text(category.name),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () async {
await provider.deleteCategory(category.id);
},
),
);
},
),
),
],
);
},
),
);
}
int _getIconCode(String iconName) {
switch (iconName) {
case 'subscriptions':
return 0xe8c1;
case 'movie':
return 0xe8c2;
case 'music_note':
return 0xe405;
case 'fitness_center':
return 0xeb43;
case 'shopping_cart':
return 0xe8cc;
default:
return 0xe8c1;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,442 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math;
import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart';
import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart';
import '../services/subscription_url_matcher.dart';
import '../models/subscription_model.dart';
import 'add_subscription_screen.dart';
import 'analysis_screen.dart';
import 'app_lock_screen.dart';
import 'settings_screen.dart';
import '../widgets/subscription_card.dart';
import '../widgets/skeleton_loading.dart';
import 'sms_scan_screen.dart';
import '../providers/category_provider.dart';
import '../utils/subscription_category_helper.dart';
import '../utils/animation_controller_helper.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/native_ad_widget.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@override
State<MainScreen> createState() => _MainScreenState();
}
class _MainScreenState extends State<MainScreen>
with WidgetsBindingObserver, TickerProviderStateMixin {
late AnimationController _fadeController;
late AnimationController _scaleController;
late AnimationController _rotateController;
late AnimationController _slideController;
late AnimationController _pulseController;
late AnimationController _waveController;
late ScrollController _scrollController;
double _scrollOffset = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_checkAppLock();
// 애니메이션 컨트롤러 초기화
_fadeController = AnimationController(vsync: this);
_scaleController = AnimationController(vsync: this);
_rotateController = AnimationController(vsync: this);
_slideController = AnimationController(vsync: this);
_pulseController = AnimationController(vsync: this);
_waveController = AnimationController(vsync: this);
// 헬퍼 클래스를 사용해 애니메이션 컨트롤러 초기화
AnimationControllerHelper.initControllers(
vsync: this,
fadeController: _fadeController,
scaleController: _scaleController,
rotateController: _rotateController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
);
_scrollController = ScrollController()
..addListener(() {
setState(() {
_scrollOffset = _scrollController.offset;
});
});
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
// 헬퍼 클래스를 사용해 애니메이션 컨트롤러 해제
AnimationControllerHelper.disposeControllers(
fadeController: _fadeController,
scaleController: _scaleController,
rotateController: _rotateController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
);
_scrollController.dispose();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.paused) {
// 앱이 백그라운드로 갈 때
final appLockProvider = context.read<AppLockProvider>();
if (appLockProvider.isBiometricEnabled) {
appLockProvider.lock();
}
} else if (state == AppLifecycleState.resumed) {
// 앱이 포그라운드로 돌아올 때
_checkAppLock();
_resetAnimations();
}
}
void _resetAnimations() {
AnimationControllerHelper.resetAnimations(
fadeController: _fadeController,
scaleController: _scaleController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
);
}
Future<void> _checkAppLock() async {
final appLockProvider = context.read<AppLockProvider>();
if (appLockProvider.isLocked) {
await Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AppLockScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
),
);
}
}
void _navigateToSmsScan(BuildContext context) async {
final added = await Navigator.push<bool>(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SmsScanScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
if (added == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
);
}
_resetAnimations();
}
void _navigateToAnalysis(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AnalysisScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
}
void _navigateToAddSubscription(BuildContext context) {
HapticFeedback.mediumImpact();
Navigator.of(context)
.push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AddSubscriptionScreen(),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.8, end: 1.0).animate(animation),
child: child,
),
);
},
),
)
.then((_) => _resetAnimations());
}
void _navigateToSettings(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SettingsScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
}
@override
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100));
return Scaffold(
backgroundColor: AppColors.backgroundColor,
extendBodyBehindAppBar: true,
appBar: _buildAppBar(appBarOpacity),
body: _buildBody(context, context.watch<SubscriptionProvider>()),
floatingActionButton: _buildFloatingActionButton(context),
);
}
PreferredSize _buildAppBar(double appBarOpacity) {
return PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
decoration: BoxDecoration(
color: AppColors.surfaceColor.withOpacity(appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.06 * appBarOpacity),
spreadRadius: 0,
blurRadius: 12,
offset: const Offset(0, 4),
)
]
: null,
),
child: SafeArea(
child: AppBar(
title: FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _fadeController, curve: Curves.easeInOut)),
child: const Text(
'SubManager',
style: TextStyle(
fontFamily: 'Montserrat',
fontSize: 26,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
),
),
),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.chartPie,
size: 20, color: Color(0xFF64748B)),
tooltip: '분석',
onPressed: () => _navigateToAnalysis(context),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.sms,
size: 20, color: Color(0xFF64748B)),
tooltip: 'SMS 스캔',
onPressed: () => _navigateToSmsScan(context),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.gear,
size: 20, color: Color(0xFF64748B)),
tooltip: '설정',
onPressed: () => _navigateToSettings(context),
),
],
),
),
),
);
}
Widget _buildFloatingActionButton(BuildContext context) {
return AnimatedBuilder(
animation: _scaleController,
builder: (context, child) {
return Transform.scale(
scale: Tween<double>(begin: 0.95, end: 1.0)
.animate(CurvedAnimation(
parent: _scaleController, curve: Curves.easeOutBack))
.value,
child: FloatingActionButton.extended(
onPressed: () => _navigateToAddSubscription(context),
icon: const Icon(Icons.add_rounded),
label: const Text(
'구독 추가',
style: TextStyle(
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
elevation: 4,
),
);
},
);
}
Widget _buildBody(BuildContext context, SubscriptionProvider provider) {
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
),
);
}
if (provider.subscriptions.isEmpty) {
return EmptyStateWidget(
fadeController: _fadeController,
rotateController: _rotateController,
slideController: _slideController,
onAddPressed: () => _navigateToAddSubscription(context),
);
}
// 카테고리별 구독 구분
final categoryProvider =
Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions =
SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions,
categoryProvider,
);
return RefreshIndicator(
onRefresh: () async {
await provider.refreshSubscriptions();
_resetAnimations();
},
color: const Color(0xFF3B82F6),
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.top + 60),
),
SliverToBoxAdapter(
child: NativeAdWidget(key: UniqueKey()),
),
SliverToBoxAdapter(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: MainScreenSummaryCard(
provider: provider,
fadeController: _fadeController,
pulseController: _pulseController,
waveController: _waveController,
slideController: _slideController,
onTap: () => _navigateToAnalysis(context),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SlideTransition(
position: Tween<Offset>(
begin: const Offset(-0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: Text(
'나의 구독 서비스',
style: Theme.of(context).textTheme.titleLarge,
),
),
SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: Row(
children: [
Text(
'${provider.subscriptions.length}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryColor,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: AppColors.primaryColor,
),
],
),
),
],
),
),
),
SubscriptionListWidget(
categorizedSubscriptions: categorizedSubscriptions,
fadeController: _fadeController,
),
SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
),
);
}
}

View 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'),
],
);
}
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,820 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../providers/subscription_provider.dart';
import 'package:provider/provider.dart';
import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@override
State<SmsScanScreen> createState() => _SmsScanScreenState();
}
class _SmsScanScreenState extends State<SmsScanScreen> {
bool _isLoading = false;
String? _errorMessage;
final SmsScanner _smsScanner = SmsScanner();
// 스캔한 구독 목록
List<Subscription> _scannedSubscriptions = [];
// 현재 표시 중인 구독 인덱스
int _currentIndex = 0;
// 웹사이트 URL 컨트롤러
final TextEditingController _websiteUrlController = TextEditingController();
@override
void dispose() {
_websiteUrlController.dispose();
super.dispose();
}
// SMS 스캔 실행
Future<void> _scanSms() async {
setState(() {
_isLoading = true;
_errorMessage = null;
_scannedSubscriptions = [];
_currentIndex = 0;
});
try {
// SMS 스캔 실행
print('SMS 스캔 시작');
final scannedSubscriptionModels =
await _smsScanner.scanForSubscriptions();
print('스캔된 구독: ${scannedSubscriptionModels.length}');
if (scannedSubscriptionModels.isNotEmpty) {
print(
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
}
if (!mounted) return;
if (scannedSubscriptionModels.isEmpty) {
print('스캔된 구독이 없음');
setState(() {
_errorMessage = '구독 정보를 찾을 수 없습니다.';
_isLoading = false;
});
return;
}
// SubscriptionModel을 Subscription으로 변환
final scannedSubscriptions =
_convertModelsToSubscriptions(scannedSubscriptionModels);
// 2회 이상 반복 결제된 구독만 필터링
final repeatSubscriptions =
scannedSubscriptions.where((sub) => sub.repeatCount >= 2).toList();
print('반복 결제된 구독: ${repeatSubscriptions.length}');
if (repeatSubscriptions.isNotEmpty) {
print(
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
}
if (repeatSubscriptions.isEmpty) {
print('반복 결제된 구독이 없음');
setState(() {
_errorMessage = '반복 결제된 구독 정보를 찾을 수 없습니다.';
_isLoading = false;
});
return;
}
// 구독 목록 가져오기
final provider =
Provider.of<SubscriptionProvider>(context, listen: false);
final existingSubscriptions = provider.subscriptions;
print('기존 구독: ${existingSubscriptions.length}');
// 중복 구독 필터링
final filteredSubscriptions =
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty &&
filteredSubscriptions[0] != null) {
print(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
setState(() {
_scannedSubscriptions = filteredSubscriptions;
_isLoading = false;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
});
} catch (e) {
print('SMS 스캔 중 오류 발생: $e');
if (mounted) {
setState(() {
_errorMessage = 'SMS 스캔 중 오류가 발생했습니다: $e';
_isLoading = false;
});
}
}
}
// SubscriptionModel 리스트를 Subscription 리스트로 변환
List<Subscription> _convertModelsToSubscriptions(
List<SubscriptionModel> models) {
final result = <Subscription>[];
for (var model in models) {
try {
// 모델의 필드가 null인 경우 기본값 사용
result.add(Subscription(
id: model.id,
serviceName: model.serviceName,
monthlyCost: model.monthlyCost,
billingCycle: model.billingCycle,
nextBillingDate: model.nextBillingDate,
category: model.categoryId, // categoryId를 category로 올바르게 매핑
repeatCount: model.repeatCount > 0
? model.repeatCount
: 1, // 반복 횟수가 0 이하인 경우 기본값 1 사용
lastPaymentDate: model.lastPaymentDate,
websiteUrl: model.websiteUrl,
currency: model.currency, // 통화 단위 정보 추가
));
print(
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
} catch (e) {
print('모델 변환 중 오류 발생: $e');
}
}
return result;
}
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
List<Subscription> _filterDuplicates(
List<Subscription> scanned, List<SubscriptionModel> existing) {
print(
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}');
// 중복되지 않은 구독만 필터링
final nonDuplicates = scanned.where((scannedSub) {
if (scannedSub == null) {
print('_filterDuplicates: null 구독 객체 발견');
return false;
}
// 서비스명과 금액이 동일한 기존 구독 찾기
final hasDuplicate = existing.any((existingSub) =>
existingSub.serviceName.toLowerCase() ==
scannedSub.serviceName.toLowerCase() &&
existingSub.monthlyCost == scannedSub.monthlyCost);
if (hasDuplicate) {
print('_filterDuplicates: 중복 발견 - ${scannedSub.serviceName}');
}
// 중복이 없으면 true 반환
return !hasDuplicate;
}).toList();
print('_filterDuplicates: 중복 제거 후 ${nonDuplicates.length}');
// 각 구독에 웹사이트 URL 자동 매칭 시도
final result = <Subscription>[];
for (int i = 0; i < nonDuplicates.length; i++) {
final subscription = nonDuplicates[i];
if (subscription == null) {
print('_filterDuplicates: null 구독 객체 무시');
continue;
}
String? websiteUrl = subscription.websiteUrl;
if (websiteUrl == null || websiteUrl.isEmpty) {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'_filterDuplicates: URL 자동 매칭 시도 - ${subscription.serviceName}, 결과: ${websiteUrl ?? "매칭 실패"}');
}
try {
// 유효성 검사
if (subscription.serviceName.isEmpty) {
print('_filterDuplicates: 서비스명이 비어 있습니다. 건너뜁니다.');
continue;
}
if (subscription.monthlyCost <= 0) {
print('_filterDuplicates: 월 비용이 0 이하입니다. 건너뜁니다.');
continue;
}
// Subscription 객체에 URL 설정 (새 객체 생성)
result.add(Subscription(
id: subscription.id,
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
category: subscription.category,
notes: subscription.notes,
repeatCount:
subscription.repeatCount > 0 ? subscription.repeatCount : 1,
lastPaymentDate: subscription.lastPaymentDate,
websiteUrl: websiteUrl,
currency: subscription.currency, // 통화 단위 정보 추가
));
print(
'_filterDuplicates: URL 설정 - ${subscription.serviceName}, URL: ${websiteUrl ?? "없음"}, 카테고리: ${subscription.category ?? "없음"}, 통화: ${subscription.currency}');
} catch (e) {
print('_filterDuplicates: 구독 객체 생성 중 오류 발생: $e');
}
}
print('_filterDuplicates: URL 설정 완료, 최종 ${result.length}개 구독');
return result;
}
// 현재 구독 추가
Future<void> _addCurrentSubscription() async {
if (_scannedSubscriptions.isEmpty ||
_currentIndex >= _scannedSubscriptions.length) {
print(
'오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})');
return;
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)');
_moveToNextSubscription();
return;
}
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 날짜가 과거면 다음 결제일을 조정
final now = DateTime.now();
DateTime nextBillingDate = subscription.nextBillingDate;
if (nextBillingDate.isBefore(now)) {
// 주기에 따라 다음 결제일 조정
if (subscription.billingCycle == '월간') {
// 현재 달의 결제일
int day = nextBillingDate.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, now.month, day);
// 현재 날짜보다 이전이라면 다음 달로 설정
if (adjustedDate.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjustedDate = DateTime(now.year, now.month + 1, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '연간') {
// 현재 년도의 결제일
int day = nextBillingDate.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day);
// 현재 날짜보다 이전이라면 다음 해로 설정
if (adjustedDate.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay =
DateTime(now.year + 1, nextBillingDate.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day);
}
nextBillingDate = adjustedDate;
} else if (subscription.billingCycle == '주간') {
// 현재 날짜에서 가장 가까운 다음 주 같은 요일
final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7;
nextBillingDate =
now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext));
}
}
// 웹사이트 URL이 비어있으면 자동 매칭 시도
String? websiteUrl = _websiteUrlController.text.trim();
if (websiteUrl.isEmpty && subscription.websiteUrl != null) {
websiteUrl = subscription.websiteUrl;
print('구독 추가: 기존 URL 사용 - ${websiteUrl ?? "없음"}');
} else if (websiteUrl.isEmpty) {
try {
websiteUrl =
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
print(
'구독 추가: URL 자동 매칭 - ${subscription.serviceName} -> ${websiteUrl ?? "매칭 실패"}');
} catch (e) {
print('구독 추가: URL 자동 매칭 실패 - $e');
websiteUrl = null;
}
} else {
print('구독 추가: 사용자 입력 URL 사용 - $websiteUrl');
}
try {
print(
'구독 추가 시도 - 서비스명: ${subscription.serviceName}, 비용: ${subscription.monthlyCost}, 반복 횟수: ${subscription.repeatCount}');
// 반복 횟수가 0 이하인 경우 기본값 1 사용
final int safeRepeatCount =
subscription.repeatCount > 0 ? subscription.repeatCount : 1;
await provider.addSubscription(
serviceName: subscription.serviceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: nextBillingDate,
websiteUrl: websiteUrl,
isAutoDetected: true,
repeatCount: safeRepeatCount,
lastPaymentDate: subscription.lastPaymentDate,
categoryId: subscription.category,
currency: subscription.currency, // 통화 단위 정보 추가
);
print('구독 추가 성공');
// 성공 메시지 표시
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${subscription.serviceName} 구독이 추가되었습니다.'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
),
);
}
// 다음 구독으로 이동
_moveToNextSubscription();
} catch (e) {
print('구독 추가 중 오류 발생: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('구독 추가 중 오류가 발생했습니다: $e'),
backgroundColor: Colors.red,
duration: const Duration(seconds: 2),
),
);
// 오류가 있어도 다음 구독으로 이동
_moveToNextSubscription();
}
}
}
// 현재 구독 건너뛰기
void _skipCurrentSubscription() {
_moveToNextSubscription();
}
// 다음 구독으로 이동
void _moveToNextSubscription() {
setState(() {
_currentIndex++;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
// 모든 구독을 처리했으면 화면 종료
if (_currentIndex >= _scannedSubscriptions.length) {
Navigator.of(context).pop(true);
}
});
}
// 날짜 상태 텍스트 가져오기
String _getNextBillingText(DateTime date) {
final now = DateTime.now();
if (date.isBefore(now)) {
// 주기에 따라 다음 결제일 예측
if (_currentIndex >= _scannedSubscriptions.length ||
_scannedSubscriptions[_currentIndex] == null) {
return '다음 결제일 확인 필요';
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription.billingCycle == '월간') {
// 이번 달 또는 다음 달 같은 날짜
int day = date.day;
// 현재 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, now.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, now.month, day);
if (adjusted.isBefore(now)) {
// 다음 달의 마지막 날을 초과하는 경우 조정
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
if (day > nextMonthLastDay) {
day = nextMonthLastDay;
}
adjusted = DateTime(now.year, now.month + 1, day);
}
final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
} else if (subscription.billingCycle == '연간') {
// 올해 또는 내년 같은 날짜
int day = date.day;
// 해당 월의 마지막 날을 초과하는 경우 조정
final lastDay = DateTime(now.year, date.month + 1, 0).day;
if (day > lastDay) {
day = lastDay;
}
DateTime adjusted = DateTime(now.year, date.month, day);
if (adjusted.isBefore(now)) {
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
final nextYearLastDay = DateTime(now.year + 1, date.month + 1, 0).day;
if (day > nextYearLastDay) {
day = nextYearLastDay;
}
adjusted = DateTime(now.year + 1, date.month, day);
}
final daysUntil = adjusted.difference(now).inDays;
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
} else {
return '다음 결제일 확인 필요 (과거 날짜)';
}
} else {
// 미래 날짜인 경우
final daysUntil = date.difference(now).inDays;
return '다음 결제일: ${_formatDate(date)} ($daysUntil일 후)';
}
}
// 날짜 포맷 함수
String _formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
// 결제 반복 횟수 텍스트
String _getRepeatCountText(int count) {
return '$count회 결제 감지됨';
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('SMS 스캔'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState())),
);
}
// 로딩 상태 UI
Widget _buildLoadingState() {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('SMS 메시지를 스캔 중입니다...'),
SizedBox(height: 8),
Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)),
],
),
);
}
// 초기 상태 UI
Widget _buildInitialState() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red),
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
const Text(
'2회 이상 결제된 구독 서비스 찾기',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
),
),
const SizedBox(height: 32),
ElevatedButton.icon(
onPressed: _scanSms,
icon: const Icon(Icons.search),
label: const Text('스캔 시작하기'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
),
),
],
),
);
}
// 구독 표시 상태 UI
Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) {
return const Center(
child: Text('모든 구독 처리 완료'),
);
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류: 구독 정보를 불러올 수 없습니다.'),
SizedBox(height: 16),
ElevatedButton(
onPressed: _moveToNextSubscription,
child: Text('건너뛰기'),
),
],
),
);
}
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
_websiteUrlController.text = subscription.websiteUrl!;
}
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: Colors.grey.withOpacity(0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 8),
Text(
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 24),
// 구독 정보 카드
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'다음 구독을 찾았습니다',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
// 서비스명
const Text(
'서비스명',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
subscription.serviceName,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 16),
// 금액 및 반복 횟수
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'월 비용',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
subscription.currency == 'USD'
? NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(subscription.monthlyCost)
: NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(subscription.monthlyCost),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'반복 횟수',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
_getRepeatCountText(subscription.repeatCount),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
),
],
),
),
],
),
const SizedBox(height: 16),
// 결제 주기
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제 주기',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
subscription.billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'결제일',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
Text(
_getNextBillingText(subscription.nextBillingDate),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
),
),
],
),
const SizedBox(height: 24),
// 웹사이트 URL 입력 필드 추가/수정
Padding(
padding: const EdgeInsets.all(8.0),
child: TextField(
controller: _websiteUrlController,
decoration: const InputDecoration(
labelText: '웹사이트 URL (자동 추출됨)',
hintText: '웹사이트 URL을 수정하거나 비워두세요',
prefixIcon: Icon(Icons.language),
border: OutlineInputBorder(),
),
),
),
const SizedBox(height: 32),
// 작업 버튼
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: _skipCurrentSubscription,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('건너뛰기'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: _addCurrentSubscription,
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
child: const Text('추가하기'),
),
),
],
),
],
),
),
),
],
);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_scannedSubscriptions.isNotEmpty &&
_currentIndex < _scannedSubscriptions.length &&
_scannedSubscriptions[_currentIndex] != null) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
_websiteUrlController.text = currentSub.websiteUrl!;
}
}
}
}

View File

@@ -0,0 +1,371 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../theme/app_colors.dart';
import 'app_lock_screen.dart';
import 'main_screen.dart';
class SplashScreen extends StatefulWidget {
const SplashScreen({super.key});
@override
State<SplashScreen> createState() => _SplashScreenState();
}
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late AnimationController _animationController;
late Animation<double> _fadeAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _slideAnimation;
late Animation<double> _rotateAnimation;
// 파티클 애니메이션을 위한 변수
final List<Map<String, dynamic>> _particles = [];
@override
void initState() {
super.initState();
// 애니메이션 컨트롤러 초기화
_animationController = AnimationController(
duration: const Duration(milliseconds: 2500),
vsync: this,
);
_fadeAnimation = CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeInOut),
);
_scaleAnimation = Tween<double>(
begin: 0.6,
end: 1.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: Curves.elasticOut,
));
_slideAnimation = Tween<double>(
begin: 50.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.3, 0.8, curve: Curves.easeOutCubic),
));
_rotateAnimation = Tween<double>(
begin: 0.0,
end: 0.1,
).animate(CurvedAnimation(
parent: _animationController,
curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack),
));
// 랜덤 파티클 생성
_generateParticles();
_animationController.forward();
// 애니메이션 완료 후 메인화면으로 이동
Timer(const Duration(seconds: 2), () {
navigateToNextScreen();
});
}
void _generateParticles() {
final random = DateTime.now().millisecondsSinceEpoch;
for (int i = 0; i < 20; i++) {
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
int colorIndex = (random + i) % AppColors.blueGradient.length;
_particles.add({
'size': size,
'x': x,
'y': y,
'opacity': opacity,
'duration': duration,
'delay': delay,
'color': AppColors.blueGradient[colorIndex],
});
}
}
void navigateToNextScreen() {
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const MainScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
transitionDuration: const Duration(milliseconds: 500),
),
);
}
@override
void dispose() {
_animationController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.of(context).size;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: AppColors.blueGradient,
),
),
child: Stack(
children: [
// 배경 파티클
..._particles.map((particle) {
return AnimatedPositioned(
duration: Duration(milliseconds: particle['duration'].toInt()),
curve: Curves.easeInOut,
left: particle['x'] - 50 + (size.width * 0.1),
top: particle['y'] - 50 + (size.height * 0.1),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
duration:
Duration(milliseconds: particle['duration'].toInt()),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: child,
);
},
child: Container(
width: particle['size'],
height: particle['size'],
decoration: BoxDecoration(
color: particle['color'],
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: particle['color'].withOpacity(0.3),
blurRadius: 10,
spreadRadius: 1,
),
],
),
),
),
);
}).toList(),
// 상단 원형 그라데이션
Positioned(
top: -size.height * 0.2,
right: -size.width * 0.2,
child: Container(
width: size.width * 0.8,
height: size.width * 0.8,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withOpacity(0.1),
Colors.white.withOpacity(0.0),
],
stops: const [0.2, 1.0],
),
),
),
),
// 하단 원형 그라데이션
Positioned(
bottom: -size.height * 0.1,
left: -size.width * 0.3,
child: Container(
width: size.width * 0.9,
height: size.width * 0.9,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withOpacity(0.07),
Colors.white.withOpacity(0.0),
],
stops: const [0.4, 1.0],
),
),
),
),
// 메인 콘텐츠
Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 로고 애니메이션
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotateAnimation.value,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: 0,
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) =>
LinearGradient(
colors: AppColors.blueGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: Icon(
Icons.subscriptions_outlined,
size: 64,
color: Theme.of(context)
.primaryColor,
),
);
}),
),
),
),
);
},
),
const SizedBox(height: 40),
// 앱 이름 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: child,
),
);
},
child: const Text(
'SubManager',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 16),
// 부제목 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value * 1.2),
child: child,
),
);
},
child: const Text(
'구독 서비스 관리를 더 쉽게',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 60),
// 로딩 인디케이터
FadeTransition(
opacity: _fadeAnimation,
child: Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(50),
),
child: const CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 3,
),
),
),
],
),
),
),
// 카피라이트 텍스트
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: FadeTransition(
opacity: _fadeAnimation,
child: const Text(
'© 2023 CClabs. All rights reserved.',
style: TextStyle(
fontSize: 12,
color: Colors.white60,
letterSpacing: 0.5,
),
),
),
),
],
),
],
),
),
);
}
}