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:
2022
lib/screens/add_subscription_screen.dart
Normal file
2022
lib/screens/add_subscription_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
1143
lib/screens/analysis_screen.dart
Normal file
1143
lib/screens/analysis_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
63
lib/screens/app_lock_screen.dart
Normal file
63
lib/screens/app_lock_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
lib/screens/category_management_screen.dart
Normal file
179
lib/screens/category_management_screen.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
2232
lib/screens/detail_screen.dart
Normal file
2232
lib/screens/detail_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
442
lib/screens/main_screen.dart
Normal file
442
lib/screens/main_screen.dart
Normal 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
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'),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
820
lib/screens/sms_scan_screen.dart
Normal file
820
lib/screens/sms_scan_screen.dart
Normal 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!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
371
lib/screens/splash_screen.dart
Normal file
371
lib/screens/splash_screen.dart
Normal 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user