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:
106
lib/providers/navigation_provider.dart
Normal file
106
lib/providers/navigation_provider.dart
Normal file
@@ -0,0 +1,106 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class NavigationProvider extends ChangeNotifier {
|
||||
int _currentIndex = 0;
|
||||
final List<int> _navigationHistory = [0];
|
||||
String _currentRoute = '/';
|
||||
String _currentTitle = '홈';
|
||||
|
||||
int get currentIndex => _currentIndex;
|
||||
List<int> get navigationHistory => List.unmodifiable(_navigationHistory);
|
||||
String get currentRoute => _currentRoute;
|
||||
String get currentTitle => _currentTitle;
|
||||
|
||||
static const Map<String, int> routeToIndex = {
|
||||
'/': 0,
|
||||
'/add-subscription': -1,
|
||||
'/sms-scanner': 3,
|
||||
'/analysis': 1,
|
||||
'/settings': 4,
|
||||
'/subscription-detail': -1,
|
||||
};
|
||||
|
||||
static const Map<int, String> indexToRoute = {
|
||||
0: '/',
|
||||
1: '/analysis',
|
||||
3: '/sms-scanner',
|
||||
4: '/settings',
|
||||
};
|
||||
|
||||
static const Map<int, String> indexToTitle = {
|
||||
0: '홈',
|
||||
1: '분석',
|
||||
3: 'SMS 스캔',
|
||||
4: '설정',
|
||||
};
|
||||
|
||||
void updateCurrentIndex(int index, {bool addToHistory = true}) {
|
||||
if (_currentIndex == index) return;
|
||||
|
||||
_currentIndex = index;
|
||||
_currentRoute = indexToRoute[index] ?? '/';
|
||||
_currentTitle = indexToTitle[index] ?? '홈';
|
||||
|
||||
if (addToHistory && index >= 0) {
|
||||
_navigationHistory.add(index);
|
||||
if (_navigationHistory.length > 10) {
|
||||
_navigationHistory.removeAt(0);
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void updateByRoute(String route) {
|
||||
final index = routeToIndex[route] ?? 0;
|
||||
_currentRoute = route;
|
||||
|
||||
if (index >= 0) {
|
||||
_currentIndex = index;
|
||||
_currentTitle = indexToTitle[index] ?? '홈';
|
||||
} else {
|
||||
switch (route) {
|
||||
case '/add-subscription':
|
||||
_currentTitle = '구독 추가';
|
||||
break;
|
||||
case '/subscription-detail':
|
||||
_currentTitle = '구독 상세';
|
||||
break;
|
||||
default:
|
||||
_currentTitle = '홈';
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
bool canPop() {
|
||||
return _navigationHistory.length > 1;
|
||||
}
|
||||
|
||||
void pop() {
|
||||
if (_navigationHistory.length > 1) {
|
||||
_navigationHistory.removeLast();
|
||||
final previousIndex = _navigationHistory.last;
|
||||
updateCurrentIndex(previousIndex, addToHistory: false);
|
||||
}
|
||||
}
|
||||
|
||||
void reset() {
|
||||
_currentIndex = 0;
|
||||
_currentRoute = '/';
|
||||
_currentTitle = '홈';
|
||||
_navigationHistory.clear();
|
||||
_navigationHistory.add(0);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearHistoryAndGoHome() {
|
||||
_currentIndex = 0;
|
||||
_currentRoute = '/';
|
||||
_currentTitle = '홈';
|
||||
_navigationHistory.clear();
|
||||
_navigationHistory.add(0);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
@@ -243,4 +243,83 @@ class SubscriptionProvider extends ChangeNotifier {
|
||||
await refreshSubscriptions();
|
||||
}
|
||||
}
|
||||
|
||||
/// 총 월간 지출을 계산합니다.
|
||||
Future<double> calculateTotalExpense() async {
|
||||
// 이미 존재하는 totalMonthlyExpense getter를 사용
|
||||
return totalMonthlyExpense;
|
||||
}
|
||||
|
||||
/// 최근 6개월의 월별 지출 데이터를 반환합니다.
|
||||
Future<List<Map<String, dynamic>>> getMonthlyExpenseData() async {
|
||||
final now = DateTime.now();
|
||||
final List<Map<String, dynamic>> monthlyData = [];
|
||||
|
||||
// 최근 6개월 데이터 생성
|
||||
for (int i = 5; i >= 0; i--) {
|
||||
final month = DateTime(now.year, now.month - i, 1);
|
||||
double monthTotal = 0.0;
|
||||
|
||||
// 해당 월에 활성화된 구독 계산
|
||||
for (final subscription in _subscriptions) {
|
||||
// 구독이 해당 월에 활성화되어 있었는지 확인
|
||||
final subscriptionStartDate = subscription.nextBillingDate.subtract(
|
||||
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
|
||||
);
|
||||
|
||||
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||
subscription.nextBillingDate.isAfter(month)) {
|
||||
// 해당 월의 비용 계산 (이벤트 가격 고려)
|
||||
if (subscription.isEventActive &&
|
||||
subscription.eventStartDate != null &&
|
||||
subscription.eventEndDate != null &&
|
||||
month.isAfter(subscription.eventStartDate!) &&
|
||||
month.isBefore(subscription.eventEndDate!)) {
|
||||
monthTotal += subscription.eventPrice ?? subscription.monthlyCost;
|
||||
} else {
|
||||
monthTotal += subscription.monthlyCost;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
monthlyData.add({
|
||||
'month': month,
|
||||
'totalExpense': monthTotal,
|
||||
'monthName': _getMonthLabel(month),
|
||||
});
|
||||
}
|
||||
|
||||
return monthlyData;
|
||||
}
|
||||
|
||||
/// 이벤트로 인한 총 절약액을 계산합니다.
|
||||
double calculateTotalSavings() {
|
||||
// 이미 존재하는 totalEventSavings getter를 사용
|
||||
return totalEventSavings;
|
||||
}
|
||||
|
||||
/// 결제 주기를 일 단위로 변환합니다.
|
||||
int _getBillingCycleDays(String billingCycle) {
|
||||
switch (billingCycle) {
|
||||
case 'monthly':
|
||||
return 30;
|
||||
case 'yearly':
|
||||
return 365;
|
||||
case 'weekly':
|
||||
return 7;
|
||||
case 'quarterly':
|
||||
return 90;
|
||||
default:
|
||||
return 30;
|
||||
}
|
||||
}
|
||||
|
||||
/// 월 라벨을 생성합니다.
|
||||
String _getMonthLabel(DateTime month) {
|
||||
final months = [
|
||||
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||
];
|
||||
return months[month.month - 1];
|
||||
}
|
||||
}
|
||||
|
||||
186
lib/providers/theme_provider.dart
Normal file
186
lib/providers/theme_provider.dart
Normal file
@@ -0,0 +1,186 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../theme/adaptive_theme.dart';
|
||||
|
||||
/// 테마 관리 Provider
|
||||
class ThemeProvider extends ChangeNotifier {
|
||||
static const String _themeBoxName = 'theme_settings';
|
||||
static const String _themeKey = 'theme_settings';
|
||||
|
||||
late Box<Map> _themeBox;
|
||||
ThemeSettings _themeSettings = const ThemeSettings();
|
||||
|
||||
ThemeSettings get themeSettings => _themeSettings;
|
||||
|
||||
AppThemeMode get themeMode => _themeSettings.mode;
|
||||
bool get useSystemColors => _themeSettings.useSystemColors;
|
||||
bool get largeText => _themeSettings.largeText;
|
||||
bool get reduceMotion => _themeSettings.reduceMotion;
|
||||
bool get highContrast => _themeSettings.highContrast;
|
||||
|
||||
/// Provider 초기화
|
||||
Future<void> initialize() async {
|
||||
_themeBox = await Hive.openBox<Map>(_themeBoxName);
|
||||
await _loadThemeSettings();
|
||||
}
|
||||
|
||||
/// 저장된 테마 설정 로드
|
||||
Future<void> _loadThemeSettings() async {
|
||||
final savedSettings = _themeBox.get(_themeKey);
|
||||
if (savedSettings != null) {
|
||||
_themeSettings = ThemeSettings.fromJson(
|
||||
Map<String, dynamic>.from(savedSettings),
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 테마 설정 저장
|
||||
Future<void> _saveThemeSettings() async {
|
||||
await _themeBox.put(_themeKey, _themeSettings.toJson());
|
||||
}
|
||||
|
||||
/// 테마 모드 변경
|
||||
Future<void> setThemeMode(AppThemeMode mode) async {
|
||||
_themeSettings = _themeSettings.copyWith(mode: mode);
|
||||
await _saveThemeSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 시스템 색상 사용 설정
|
||||
Future<void> setUseSystemColors(bool value) async {
|
||||
_themeSettings = _themeSettings.copyWith(useSystemColors: value);
|
||||
await _saveThemeSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 큰 텍스트 설정
|
||||
Future<void> setLargeText(bool value) async {
|
||||
_themeSettings = _themeSettings.copyWith(largeText: value);
|
||||
await _saveThemeSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 모션 감소 설정
|
||||
Future<void> setReduceMotion(bool value) async {
|
||||
_themeSettings = _themeSettings.copyWith(reduceMotion: value);
|
||||
await _saveThemeSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 고대비 설정
|
||||
Future<void> setHighContrast(bool value) async {
|
||||
_themeSettings = _themeSettings.copyWith(highContrast: value);
|
||||
await _saveThemeSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 현재 설정에 따른 테마 가져오기
|
||||
ThemeData getTheme(BuildContext context) {
|
||||
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
||||
|
||||
ThemeData baseTheme;
|
||||
|
||||
switch (_themeSettings.mode) {
|
||||
case AppThemeMode.light:
|
||||
baseTheme = AdaptiveTheme.lightTheme;
|
||||
break;
|
||||
case AppThemeMode.dark:
|
||||
baseTheme = AdaptiveTheme.darkTheme;
|
||||
break;
|
||||
case AppThemeMode.oled:
|
||||
baseTheme = AdaptiveTheme.oledTheme;
|
||||
break;
|
||||
case AppThemeMode.system:
|
||||
baseTheme = platformBrightness == Brightness.dark
|
||||
? AdaptiveTheme.darkTheme
|
||||
: AdaptiveTheme.lightTheme;
|
||||
break;
|
||||
}
|
||||
|
||||
// 접근성 설정 적용
|
||||
return AdaptiveTheme.getAccessibleTheme(
|
||||
baseTheme,
|
||||
largeText: _themeSettings.largeText,
|
||||
reduceMotion: _themeSettings.reduceMotion,
|
||||
highContrast: _themeSettings.highContrast,
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 테마가 다크 모드인지 확인
|
||||
bool isDarkMode(BuildContext context) {
|
||||
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
||||
|
||||
switch (_themeSettings.mode) {
|
||||
case AppThemeMode.light:
|
||||
return false;
|
||||
case AppThemeMode.dark:
|
||||
case AppThemeMode.oled:
|
||||
return true;
|
||||
case AppThemeMode.system:
|
||||
return platformBrightness == Brightness.dark;
|
||||
}
|
||||
}
|
||||
|
||||
/// 테마 토글 (라이트/다크)
|
||||
Future<void> toggleTheme() async {
|
||||
if (_themeSettings.mode == AppThemeMode.light) {
|
||||
await setThemeMode(AppThemeMode.dark);
|
||||
} else {
|
||||
await setThemeMode(AppThemeMode.light);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 테마 전환 애니메이션 위젯
|
||||
class AnimatedThemeBuilder extends StatelessWidget {
|
||||
final Widget Function(BuildContext, ThemeData) builder;
|
||||
final Duration duration;
|
||||
|
||||
const AnimatedThemeBuilder({
|
||||
super.key,
|
||||
required this.builder,
|
||||
this.duration = const Duration(milliseconds: 300),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final themeProvider = context.watch<ThemeProvider>();
|
||||
final theme = themeProvider.getTheme(context);
|
||||
|
||||
return AnimatedTheme(
|
||||
data: theme,
|
||||
duration: duration,
|
||||
child: Builder(
|
||||
builder: (context) => builder(context, theme),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 테마별 색상 위젯
|
||||
class ThemedColor extends StatelessWidget {
|
||||
final Color lightColor;
|
||||
final Color darkColor;
|
||||
final Widget child;
|
||||
|
||||
const ThemedColor({
|
||||
super.key,
|
||||
required this.lightColor,
|
||||
required this.darkColor,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDark = context.read<ThemeProvider>().isDarkMode(context);
|
||||
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
primaryColor: isDark ? darkColor : lightColor,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user