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

@@ -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();
}
}

View File

@@ -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];
}
}

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