Files
submanager/lib/screens/main_screen.dart
JiWoong Sul 8619e96739 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>
2025-07-09 14:29:53 +09:00

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