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:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user