Major UI/UX and architecture improvements

- Implemented new navigation system with NavigationProvider and route management
- Added adaptive theme system with ThemeProvider for better theme handling
- Introduced glassmorphism design elements (app bars, scaffolds, cards)
- Added advanced animations (spring animations, page transitions, staggered lists)
- Implemented performance optimizations (memory manager, lazy loading)
- Refactored Analysis screen into modular components
- Added floating navigation bar with haptic feedback
- Improved subscription cards with swipe actions
- Enhanced skeleton loading with better animations
- Added cached network image support
- Improved overall app architecture and code organization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-10 18:36:57 +09:00
parent 8619e96739
commit 4731288622
55 changed files with 8219 additions and 2149 deletions

View File

@@ -1,28 +1,20 @@
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 '../providers/navigation_provider.dart';
import '../theme/app_colors.dart';
import '../services/subscription_url_matcher.dart';
import '../models/subscription_model.dart';
import 'add_subscription_screen.dart';
import '../routes/app_routes.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';
import '../widgets/floating_navigation_bar.dart';
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/home_content.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@@ -40,7 +32,11 @@ class _MainScreenState extends State<MainScreen>
late AnimationController _pulseController;
late AnimationController _waveController;
late ScrollController _scrollController;
double _scrollOffset = 0;
late FloatingNavBarScrollController _navBarScrollController;
bool _isNavBarVisible = true;
// 화면 목록
late final List<Widget> _screens;
@override
void initState() {
@@ -67,12 +63,30 @@ class _MainScreenState extends State<MainScreen>
waveController: _waveController,
);
_scrollController = ScrollController()
..addListener(() {
setState(() {
_scrollOffset = _scrollController.offset;
});
});
_scrollController = ScrollController();
_navBarScrollController = FloatingNavBarScrollController(
scrollController: _scrollController,
onHide: () => setState(() => _isNavBarVisible = false),
onShow: () => setState(() => _isNavBarVisible = true),
);
// 화면 목록 초기화
_screens = [
HomeContent(
fadeController: _fadeController,
rotateController: _rotateController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
scrollController: _scrollController,
onAddPressed: () => _navigateToAddSubscription(context),
),
const AnalysisScreen(),
Container(), // 추가 버튼은 별도 처리
const SmsScanScreen(),
const SettingsScreen(),
];
}
@override
@@ -90,6 +104,7 @@ class _MainScreenState extends State<MainScreen>
);
_scrollController.dispose();
_navBarScrollController.dispose();
super.dispose();
}
@@ -136,307 +151,109 @@ class _MainScreenState extends State<MainScreen>
}
}
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,
Navigator.pushNamed(
context,
AppRoutes.addSubscription,
).then((result) {
_resetAnimations();
// 구독이 성공적으로 추가된 경우
if (result == true) {
// 상단에 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
);
},
const SizedBox(width: 12),
const Text(
'구독이 추가되었습니다',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
backgroundColor: const Color(0xFF10B981), // 초록색
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
dismissDirection: DismissDirection.horizontal,
),
)
.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,
);
},
),
);
void _handleNavigation(int index, BuildContext context) {
final navigationProvider = context.read<NavigationProvider>();
// 이미 같은 인덱스면 무시
if (navigationProvider.currentIndex == index) {
return;
}
// 추가 버튼은 별도 처리
if (index == 2) {
_navigateToAddSubscription(context);
return;
}
// 인덱스 업데이트
navigationProvider.updateCurrentIndex(index);
}
@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)),
),
);
final navigationProvider = context.watch<NavigationProvider>();
final hour = DateTime.now().hour;
List<Color> backgroundGradient;
// 시간대별 배경 그라디언트 설정
if (hour >= 6 && hour < 10) {
backgroundGradient = AppColors.morningGradient;
} else if (hour >= 10 && hour < 17) {
backgroundGradient = AppColors.dayGradient;
} else if (hour >= 17 && hour < 20) {
backgroundGradient = AppColors.eveningGradient;
} else {
backgroundGradient = AppColors.nightGradient;
}
if (provider.subscriptions.isEmpty) {
return EmptyStateWidget(
fadeController: _fadeController,
rotateController: _rotateController,
slideController: _slideController,
onAddPressed: () => _navigateToAddSubscription(context),
);
// 현재 인덱스가 유효한지 확인
int currentIndex = navigationProvider.currentIndex;
if (currentIndex == 2) {
currentIndex = 0; // 추가 버튼은 홈으로 표시
}
// 카테고리별 구독 구분
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),
),
],
return GlassmorphicScaffold(
body: IndexedStack(
index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex,
children: _screens,
),
backgroundGradient: backgroundGradient,
useFloatingNavBar: true,
floatingNavBarIndex: navigationProvider.currentIndex,
onFloatingNavBarTapped: (index) {
_handleNavigation(index, context);
},
enableParticles: false,
enableWaveAnimation: false,
);
}
}
}