diff --git a/lib/main.dart b/lib/main.dart index 164e17f..0bad487 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -45,8 +45,15 @@ Future main() async { try { // 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비 - // 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지) - await DefaultCacheManager().emptyCache(); + // 캐시 전체 삭제는 큰 I/O 부하를 유발할 수 있어 비활성화 + // 필요 시 환경 플래그로 제어하거나 주기적 백그라운드 정리로 전환하세요. + const bool clearCacheOnStartup = bool.fromEnvironment( + 'CLEAR_CACHE_ON_STARTUP', + defaultValue: false, + ); + if (clearCacheOnStartup) { + await DefaultCacheManager().emptyCache(); + } if (kDebugMode) { Log.d('이미지 캐시 관리 초기화 완료'); diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index f998ec9..ba4d9b1 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -93,32 +93,32 @@ class _CategoryManagementScreenState extends State { value: '#1976D2', child: Text( AppLocalizations.of(context).colorBlue, - style: - const TextStyle(color: AppColors.darkNavy))), + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( value: '#4CAF50', child: Text( AppLocalizations.of(context).colorGreen, - style: - const TextStyle(color: AppColors.darkNavy))), + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( value: '#FF9800', child: Text( AppLocalizations.of(context).colorOrange, - style: - const TextStyle(color: AppColors.darkNavy))), + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( value: '#F44336', child: Text( AppLocalizations.of(context).colorRed, - style: - const TextStyle(color: AppColors.darkNavy))), + style: const TextStyle( + color: AppColors.darkNavy))), DropdownMenuItem( value: '#9C27B0', child: Text( AppLocalizations.of(context).colorPurple, - style: - const TextStyle(color: AppColors.darkNavy))), + style: const TextStyle( + color: AppColors.darkNavy))), ], onChanged: (value) { setState(() { diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 4fff2ee..0d4ceb6 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -533,9 +533,8 @@ class SettingsScreen extends StatelessWidget { onPressed: () async { await permission.openAppSettings(); }, - child: Text( - AppLocalizations.of(context) - .openSettings), + child: Text(AppLocalizations.of(context) + .openSettings), ) : ElevatedButton( onPressed: () async { @@ -545,11 +544,13 @@ class SettingsScreen extends StatelessWidget { final newStatus = await permission .Permission.sms.status; if (newStatus.isPermanentlyDenied) { - await permission.openAppSettings(); + await permission + .openAppSettings(); } } if (context.mounted) { - (context as Element).markNeedsBuild(); + (context as Element) + .markNeedsBuild(); } }, child: Text(AppLocalizations.of(context) diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index 06282ac..613bf4e 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -7,6 +7,38 @@ import '../services/subscription_url_matcher.dart'; import '../utils/platform_helper.dart'; class SmsScanner { + // 반복 사용되는 리소스 상수화로 파싱 성능 최적화 + static const List _subscriptionKeywords = [ + '구독', + '결제', + '정기결제', + '자동결제', + '월정액', + 'subscription', + 'payment', + 'billing', + 'charge', + '넷플릭스', + 'Netflix', + '유튜브', + 'YouTube', + 'Spotify', + '멜론', + '웨이브', + 'Disney+', + '디즈니플러스', + 'Apple', + 'Microsoft', + 'GitHub', + 'Adobe', + 'Amazon' + ]; + static final List _amountPatterns = [ + RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 + RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 + RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD + RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 + ]; final SmsQuery _query = SmsQuery(); Future> scanForSubscriptions() async { @@ -106,35 +138,8 @@ class SmsScanner { final sender = message.address ?? ''; final date = message.date ?? DateTime.now(); - // 구독 서비스 키워드 매칭 - final subscriptionKeywords = [ - '구독', - '결제', - '정기결제', - '자동결제', - '월정액', - 'subscription', - 'payment', - 'billing', - 'charge', - '넷플릭스', - 'Netflix', - '유튜브', - 'YouTube', - 'Spotify', - '멜론', - '웨이브', - 'Disney+', - '디즈니플러스', - 'Apple', - 'Microsoft', - 'GitHub', - 'Adobe', - 'Amazon' - ]; - // 구독 관련 키워드가 있는지 확인 - bool isSubscription = subscriptionKeywords.any((keyword) => + bool isSubscription = _subscriptionKeywords.any((keyword) => body.toLowerCase().contains(keyword.toLowerCase()) || sender.toLowerCase().contains(keyword.toLowerCase())); @@ -208,15 +213,8 @@ class SmsScanner { // 금액 추출 로직 double? _extractAmount(String body) { - // 다양한 금액 패턴 매칭 - final patterns = [ - RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화 - RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러 - RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD - RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액 - ]; - - for (final pattern in patterns) { + // 다양한 금액 패턴 매칭(사전 컴파일) + for (final pattern in _amountPatterns) { final match = pattern.firstMatch(body); if (match != null) { String amountStr = match.group(1) ?? ''; diff --git a/lib/widgets/analysis/monthly_expense_chart_card.dart b/lib/widgets/analysis/monthly_expense_chart_card.dart index ec9bb21..71f751a 100644 --- a/lib/widgets/analysis/monthly_expense_chart_card.dart +++ b/lib/widgets/analysis/monthly_expense_chart_card.dart @@ -154,99 +154,101 @@ class MonthlyExpenseChartCard extends StatelessWidget { ), ), const SizedBox(height: 20), - // 바 차트 - AspectRatio( - aspectRatio: 1.6, - child: BarChart( - BarChartData( - alignment: BarChartAlignment.spaceAround, - maxY: _calculateChartMaxY( - monthlyData.fold( - 0, - (max, data) => math.max( - max, data['totalExpense'] as double)), - locale), - barGroups: _getMonthlyBarGroups(locale), - gridData: FlGridData( - show: true, - drawVerticalLine: false, - horizontalInterval: _calculateGridInterval( - _calculateChartMaxY( - monthlyData.fold( - 0, - (max, data) => math.max(max, - data['totalExpense'] as double)), - locale), - CurrencyUtil.getDefaultCurrency(locale)), - getDrawingHorizontalLine: (value) { - return FlLine( - color: - AppColors.navyGray.withValues(alpha: 0.1), - strokeWidth: 1, - ); - }, - ), - titlesData: FlTitlesData( - show: true, - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), + // 바 차트 (RepaintBoundary로 페인트 분리) + RepaintBoundary( + child: AspectRatio( + aspectRatio: 1.6, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: _calculateChartMaxY( + monthlyData.fold( + 0, + (max, data) => math.max( + max, data['totalExpense'] as double)), + locale), + barGroups: _getMonthlyBarGroups(locale), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: _calculateGridInterval( + _calculateChartMaxY( + monthlyData.fold( + 0, + (max, data) => math.max(max, + data['totalExpense'] as double)), + locale), + CurrencyUtil.getDefaultCurrency(locale)), + getDrawingHorizontalLine: (value) { + return FlLine( + color: + AppColors.navyGray.withValues(alpha: 0.1), + strokeWidth: 1, + ); + }, ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - return Padding( - padding: const EdgeInsets.only(top: 8), - child: ThemedText.caption( - text: monthlyData[value.toInt()] - ['monthName'], - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, + titlesData: FlTitlesData( + show: true, + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + getTitlesWidget: (value, meta) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: ThemedText.caption( + text: monthlyData[value.toInt()] + ['monthName'], + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), ), + ); + }, + ), + ), + leftTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + borderData: FlBorderData(show: false), + barTouchData: BarTouchData( + enabled: true, + touchTooltipData: BarTouchTooltipData( + tooltipBgColor: AppColors.darkNavy, + tooltipRoundedRadius: 8, + getTooltipItem: + (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + '${monthlyData[group.x]['monthName']}\n', + const TextStyle( + color: AppColors.pureWhite, + fontWeight: FontWeight.bold, ), + children: [ + TextSpan( + text: CurrencyUtil + .formatTotalAmountWithLocale( + monthlyData[group.x] + ['totalExpense'] as double, + locale), + style: const TextStyle( + color: Color(0xFFFBBF24), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], ); }, ), ), - leftTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - borderData: FlBorderData(show: false), - barTouchData: BarTouchData( - enabled: true, - touchTooltipData: BarTouchTooltipData( - tooltipBgColor: AppColors.darkNavy, - tooltipRoundedRadius: 8, - getTooltipItem: - (group, groupIndex, rod, rodIndex) { - return BarTooltipItem( - '${monthlyData[group.x]['monthName']}\n', - const TextStyle( - color: AppColors.pureWhite, - fontWeight: FontWeight.bold, - ), - children: [ - TextSpan( - text: CurrencyUtil - .formatTotalAmountWithLocale( - monthlyData[group.x] - ['totalExpense'] as double, - locale), - style: const TextStyle( - color: Color(0xFFFBBF24), - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ); - }, - ), ), ), ), diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart index 10bfd60..f0c2ae3 100644 --- a/lib/widgets/glassmorphic_scaffold.dart +++ b/lib/widgets/glassmorphic_scaffold.dart @@ -177,10 +177,13 @@ class _GlassmorphicScaffoldState extends State child: AnimatedBuilder( animation: _particleController, builder: (context, child) { + final media = MediaQuery.maybeOf(context); + final reduce = media?.disableAnimations ?? false; + final count = reduce ? 10 : 30; return CustomPaint( painter: ParticlePainter( animation: _particleController, - particleCount: 30, + particleCount: count, ), ); }, diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index ade668c..b569298 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -71,6 +71,7 @@ class SubscriptionListWidget extends StatelessWidget { physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, padding: const EdgeInsets.symmetric(horizontal: 16), + prototypeItem: const SizedBox(height: 156), itemCount: subscriptions.length, itemBuilder: (context, subIndex) { // 각 구독의 지연값 계산 (순차적으로 나타나도록) @@ -98,60 +99,63 @@ class SubscriptionListWidget extends StatelessWidget { child: StaggeredAnimationItem( index: subIndex, delay: const Duration(milliseconds: 50), - child: SwipeableSubscriptionCard( - subscription: subscriptions[subIndex], - onTap: () { - Log.d( - '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); - AppNavigator.toDetail( - context, subscriptions[subIndex]); - }, - onDelete: () async { - // 현재 로케일에 맞는 서비스명 가져오기 - final localeProvider = - Provider.of( - context, - listen: false, - ); - final locale = - localeProvider.locale.languageCode; - final displayName = await SubscriptionUrlMatcher - .getServiceDisplayName( - serviceName: - subscriptions[subIndex].serviceName, - locale: locale, - ); - - // 삭제 확인 다이얼로그 표시 - if (!context.mounted) return; - final shouldDelete = - await DeleteConfirmationDialog.show( - context: context, - serviceName: displayName, - ); - if (!context.mounted) return; - - if (shouldDelete) { - // 사용자가 확인한 경우에만 삭제 진행 - final provider = - Provider.of( + child: RepaintBoundary( + child: SwipeableSubscriptionCard( + subscription: subscriptions[subIndex], + onTap: () { + Log.d( + '[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); + AppNavigator.toDetail( + context, subscriptions[subIndex]); + }, + onDelete: () async { + // 현재 로케일에 맞는 서비스명 가져오기 + final localeProvider = + Provider.of( context, listen: false, ); - await provider.deleteSubscription( - subscriptions[subIndex].id, + final locale = + localeProvider.locale.languageCode; + final displayName = + await SubscriptionUrlMatcher + .getServiceDisplayName( + serviceName: + subscriptions[subIndex].serviceName, + locale: locale, ); - if (context.mounted) { - AppSnackBar.showError( - context: context, - message: AppLocalizations.of(context) - .subscriptionDeleted(displayName), - icon: Icons.delete_forever_rounded, + // 삭제 확인 다이얼로그 표시 + if (!context.mounted) return; + final shouldDelete = + await DeleteConfirmationDialog.show( + context: context, + serviceName: displayName, + ); + if (!context.mounted) return; + + if (shouldDelete) { + // 사용자가 확인한 경우에만 삭제 진행 + final provider = + Provider.of( + context, + listen: false, ); + await provider.deleteSubscription( + subscriptions[subIndex].id, + ); + + if (context.mounted) { + AppSnackBar.showError( + context: context, + message: AppLocalizations.of(context) + .subscriptionDeleted(displayName), + icon: Icons.delete_forever_rounded, + ); + } } - } - }, + }, + ), ), ), ),