주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
443 lines
14 KiB
Dart
443 lines
14 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|