From 47312886225f8ca85ec5744841d7eeaca5161b5f Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Thu, 10 Jul 2025 18:36:57 +0900 Subject: [PATCH] Major UI/UX and architecture improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- doc/color.md | 58 + ios/Podfile.lock | 177 +++ ios/Runner.xcodeproj/project.pbxproj | 130 ++ .../contents.xcworkspacedata | 3 + lib/main.dart | 64 +- lib/models/subscription_model.dart | 3 + lib/navigation/app_navigation_observer.dart | 79 ++ lib/providers/navigation_provider.dart | 106 ++ lib/providers/subscription_provider.dart | 79 ++ lib/providers/theme_provider.dart | 186 +++ lib/routes/app_routes.dart | 106 ++ lib/screens/add_subscription_screen.dart | 57 +- lib/screens/analysis_screen.dart | 1214 ++--------------- lib/screens/detail_screen.dart | 66 +- lib/screens/main_screen.dart | 433 ++---- lib/screens/settings_screen.dart | 239 ++-- lib/screens/sms_scan_screen.dart | 246 ++-- lib/screens/splash_screen.dart | 541 ++++---- lib/services/currency_util.dart | 25 + lib/theme/adaptive_theme.dart | 379 +++++ lib/theme/app_colors.dart | 45 + lib/theme/app_theme.dart | 6 +- lib/utils/haptic_feedback_helper.dart | 74 + lib/utils/memory_manager.dart | 287 ++++ lib/utils/performance_optimizer.dart | 204 +++ lib/widgets/analysis/analysis_badge.dart | 83 ++ .../analysis/analysis_screen_spacer.dart | 19 + lib/widgets/analysis/event_analysis_card.dart | 272 ++++ .../analysis/monthly_expense_chart_card.dart | 214 +++ .../analysis/subscription_pie_chart_card.dart | 294 ++++ .../analysis/total_expense_summary_card.dart | 228 ++++ lib/widgets/animated_page_transitions.dart | 311 +++++ lib/widgets/animated_wave_background.dart | 8 +- lib/widgets/app_navigator.dart | 200 +++ lib/widgets/cached_network_image_widget.dart | 315 +++++ lib/widgets/empty_state_widget.dart | 48 +- lib/widgets/expandable_fab.dart | 268 ++++ lib/widgets/floating_navigation_bar.dart | 310 +++++ lib/widgets/glassmorphic_app_bar.dart | 304 +++++ lib/widgets/glassmorphic_scaffold.dart | 314 +++++ lib/widgets/glassmorphism_card.dart | 210 +++ lib/widgets/home_content.dart | 154 +++ lib/widgets/lazy_loading_list.dart | 416 ++++++ lib/widgets/main_summary_card.dart | 72 +- lib/widgets/native_ad_widget.dart | 15 +- lib/widgets/skeleton_loading.dart | 134 +- lib/widgets/spring_animation_widget.dart | 350 +++++ lib/widgets/staggered_list_animation.dart | 302 ++++ lib/widgets/subscription_card.dart | 69 +- lib/widgets/subscription_list_widget.dart | 32 +- lib/widgets/swipeable_subscription_card.dart | 227 +++ lib/widgets/themed_text.dart | 216 +++ macos/Podfile.lock | 75 + macos/Runner.xcodeproj/project.pbxproj | 98 +- .../contents.xcworkspacedata | 3 + 55 files changed, 8219 insertions(+), 2149 deletions(-) create mode 100644 doc/color.md create mode 100644 ios/Podfile.lock create mode 100644 lib/navigation/app_navigation_observer.dart create mode 100644 lib/providers/navigation_provider.dart create mode 100644 lib/providers/theme_provider.dart create mode 100644 lib/routes/app_routes.dart create mode 100644 lib/theme/adaptive_theme.dart create mode 100644 lib/utils/haptic_feedback_helper.dart create mode 100644 lib/utils/memory_manager.dart create mode 100644 lib/utils/performance_optimizer.dart create mode 100644 lib/widgets/analysis/analysis_badge.dart create mode 100644 lib/widgets/analysis/analysis_screen_spacer.dart create mode 100644 lib/widgets/analysis/event_analysis_card.dart create mode 100644 lib/widgets/analysis/monthly_expense_chart_card.dart create mode 100644 lib/widgets/analysis/subscription_pie_chart_card.dart create mode 100644 lib/widgets/analysis/total_expense_summary_card.dart create mode 100644 lib/widgets/animated_page_transitions.dart create mode 100644 lib/widgets/app_navigator.dart create mode 100644 lib/widgets/cached_network_image_widget.dart create mode 100644 lib/widgets/expandable_fab.dart create mode 100644 lib/widgets/floating_navigation_bar.dart create mode 100644 lib/widgets/glassmorphic_app_bar.dart create mode 100644 lib/widgets/glassmorphic_scaffold.dart create mode 100644 lib/widgets/glassmorphism_card.dart create mode 100644 lib/widgets/home_content.dart create mode 100644 lib/widgets/lazy_loading_list.dart create mode 100644 lib/widgets/spring_animation_widget.dart create mode 100644 lib/widgets/staggered_list_animation.dart create mode 100644 lib/widgets/swipeable_subscription_card.dart create mode 100644 lib/widgets/themed_text.dart create mode 100644 macos/Podfile.lock diff --git a/doc/color.md b/doc/color.md new file mode 100644 index 0000000..b358945 --- /dev/null +++ b/doc/color.md @@ -0,0 +1,58 @@ +## ๊ตฌ๋…๊ด€๋ฆฌ ์•ฑ ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜ ์ƒ‰์ƒ ๊ฐ€์ด๋“œ +**์‹ ๋ขฐ์„ฑ, ํŽธ์•ˆํ•จ, ํŠธ๋ Œ๋“œํ•จ**์„ ๋ชจ๋‘ ์žก๋Š” ์ปฌ๋Ÿฌ ์กฐํ•ฉ ์ถ”์ฒœ + +### 1. ์ปฌ๋Ÿฌ ์„ ์ • ์›์น™ + +- **์‹ ๋ขฐ์„ฑ:** ๋ธ”๋ฃจ ๊ณ„์—ด, ๊ทธ๋ ˆ์ด, ํ™”์ดํŠธ ๋“ฑ ์•ˆ์ •์ ์ด๊ณ  ์ „๋ฌธ์ ์ธ ๋А๋‚Œ์˜ ์ƒ‰์ƒ +- **ํŽธ์•ˆํ•จ:** ์ €์ฑ„๋„ ํŒŒ์Šคํ…”, ์—ฐํ•œ ๋ธ”๋ฃจยท๋ฏผํŠธ, ๋”ฐ๋œปํ•œ ๋ฒ ์ด์ง€ ๋“ฑ ๋ˆˆ์— ๋ถ€๋‹ด ์—†๋Š” ์ƒ‰์ƒ +- **ํŠธ๋ Œ๋“œํ•จ:** ๊ทธ๋ผ๋””์–ธํŠธ, ๋ฐ˜ํˆฌ๋ช… ๋ ˆ์ด์–ด, ์•ฝ๊ฐ„์˜ ๋„ค์˜จ ํฌ์ธํŠธ ๋“ฑ ํ˜„๋Œ€์  ๊ฐ๊ฐ + +### 2. ์ถ”์ฒœ ์ปฌ๋Ÿฌ ํŒ”๋ ˆํŠธ + +| ์šฉ๋„ | ์ถ”์ฒœ ์ƒ‰์ƒ ์˜ˆ์‹œ (Hex) | ์„ค๋ช… | +|--------------|-------------------------------|---------------------------------------| +| ๋ฉ”์ธ | #2563eb, #60a5fa, #e0e7ef | ์‹ ๋ขฐ๊ฐ ์ฃผ๋Š” ๋ธ”๋ฃจ ๊ณ„์—ด ๊ทธ๋ผ๋””์–ธํŠธ | +| ์„œ๋ธŒ | #f9fafb, #f1f5f9, #f3f4f6 | ๋ฐ์€ ํ™”์ดํŠธยท๊ทธ๋ ˆ์ด, ํŽธ์•ˆํ•œ ๋ฐฐ๊ฒฝ | +| ํฌ์ธํŠธ | #38bdf8, #7dd3fc, #f472b6 | ํŠธ๋ Œ๋””ํ•œ ๋ฏผํŠธ, ์—ฐํ•‘ํฌ, ๋ฐ์€ ๋ธ”๋ฃจ | +| ํ…Œ๋‘๋ฆฌ/๋ธ”๋Ÿฌ | rgba(255,255,255,0.3) | ๊ธ€๋ž˜์Šค ํšจ๊ณผ์šฉ ๋ฐ˜ํˆฌ๋ช… ํ™”์ดํŠธ | +| ๊ทธ๋ฆผ์ž | rgba(0,0,0,0.08) | ๋ถ€๋“œ๋Ÿฌ์šด ๊นŠ์ด๊ฐ ๋ถ€์—ฌ | + +### 3. ์‹ค์ „ ์ ์šฉ ์˜ˆ์‹œ + +- **๋ฐฐ๊ฒฝ:** + ์—ฐํ•œ ๋ธ”๋ฃจ(#e0e7ef) ๋˜๋Š” ๋ฐ์€ ๊ทธ๋ ˆ์ด(#f9fafb) +- **๊ธ€๋ž˜์Šค ์นด๋“œ:** + ๋ฐ˜ํˆฌ๋ช… ํ™”์ดํŠธ(์˜ˆ: rgba(255,255,255,0.2)), ๋ธ”๋ฃจ ๊ทธ๋ผ๋””์–ธํŠธ ํ…Œ๋‘๋ฆฌ +- **ํฌ์ธํŠธ ๋ฒ„ํŠผ:** + ๋ฐ์€ ๋ฏผํŠธ(#38bdf8) ๋˜๋Š” ์—ฐํ•‘ํฌ(#f472b6) +- **์•„์ด์ฝ˜/ํ…์ŠคํŠธ:** + ์ง„ํ•œ ๋ธ”๋ฃจ(#2563eb), ๋‹คํฌ ๊ทธ๋ ˆ์ด(#334155) +- **๊ทธ๋ผ๋””์–ธํŠธ ์˜ˆ์‹œ:** + LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFF2563eb), Color(0xFF60a5fa), Color(0xFFe0e7ef)], + ) + +### 4. ์ฐธ๊ณ  ํŒ + +- ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜์€ **ํˆฌ๋ช…๋„ยท๋ธ”๋Ÿฌ**์™€ ํ•จ๊ป˜ **๋ฐ๊ณ  ๊นจ๋—ํ•œ ์ƒ‰์ƒ**์„ ์กฐํ•ฉํ•˜๋ฉด ์‹ ๋ขฐ๊ฐ๊ณผ ํŠธ๋ Œ๋””ํ•จ์„ ๋™์‹œ์— ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ํฌ์ธํŠธ ์ปฌ๋Ÿฌ๋ฅผ ๋„ˆ๋ฌด ๊ฐ•ํ•˜๊ฒŒ ์“ฐ๊ธฐ๋ณด๋‹ค๋Š”, ์ „์ฒด์ ์œผ๋กœ **๋ฐ๊ณ  ๋ถ€๋“œ๋Ÿฌ์šด ํ†ค**์— ์•ฝ๊ฐ„์˜ ์ปฌ๋Ÿฌ๋งŒ ๋”ํ•˜๋Š” ๊ฒƒ์ด ํŽธ์•ˆํ•จ์„ ๊ทน๋Œ€ํ™”ํ•ฉ๋‹ˆ๋‹ค. +- ์‹ค์ œ ์ธ๊ธฐ ์•ฑ(Reflect, T.RICKS, Coffee ๋“ฑ)๋„ ๋ธ”๋ฃจยทํ™”์ดํŠธยท๋ฏผํŠธ ๊ณ„์—ด์„ ์ฃผ๋กœ ํ™œ์šฉํ•ฉ๋‹ˆ๋‹ค. + +### 5. ์ปฌ๋Ÿฌ ํŒ”๋ ˆํŠธ ์˜ˆ์‹œ + +| ์ด๋ฆ„ | Hex ์ฝ”๋“œ | ์šฉ๋„/๋А๋‚Œ | +|-------------|------------|-------------------| +| Deep Blue | #2563eb | ์‹ ๋ขฐ, ๋ฉ”์ธ | +| Sky Blue | #60a5fa | ํŠธ๋ Œ๋“œ, ๊ทธ๋ผ๋””์–ธํŠธ| +| Soft Mint | #38bdf8 | ํฌ์ธํŠธ, ์ƒ์พŒํ•จ | +| Light Gray | #f1f5f9 | ๋ฐฐ๊ฒฝ, ํŽธ์•ˆํ•จ | +| White Glass | #ffffff(ํˆฌ๋ช…๋„) | ๊ธ€๋ž˜์Šค ํšจ๊ณผ | +| Pink Accent | #f472b6 | ํฌ์ธํŠธ, ํŠธ๋ Œ๋”” | + +### 6. ๋งˆ๋ฌด๋ฆฌ + +- **๋ธ”๋ฃจ+ํ™”์ดํŠธ+๋ฏผํŠธ** ์กฐํ•ฉ์€ ์‹ ๋ขฐ์„ฑ, ํŽธ์•ˆํ•จ, ํŠธ๋ Œ๋“œํ•จ์„ ๋ชจ๋‘ ๋งŒ์กฑ์‹œํ‚ต๋‹ˆ๋‹ค. +- ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜ ํšจ๊ณผ์™€ ํ•จ๊ป˜๋ผ๋ฉด, ์œ„ ํŒ”๋ ˆํŠธ๋กœ ์„ธ๋ จ๋˜๊ณ  ํ˜„๋Œ€์ ์ธ ๊ตฌ๋…๊ด€๋ฆฌ ์•ฑ UI๋ฅผ ์™„์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ์‹ค์ œ ์ ์šฉ ์‹œ, ๋ฐ์€ ๋ฐฐ๊ฒฝ๊ณผ ๋ถ€๋“œ๋Ÿฌ์šด ๊ทธ๋ผ๋””์–ธํŠธ, ํฌ์ธํŠธ ์ปฌ๋Ÿฌ๋ฅผ ์ ์ ˆํžˆ ์กฐํ•ฉํ•ด๋ณด์„ธ์š”. \ No newline at end of file diff --git a/ios/Podfile.lock b/ios/Podfile.lock new file mode 100644 index 0000000..07eef28 --- /dev/null +++ b/ios/Podfile.lock @@ -0,0 +1,177 @@ +PODS: + - Flutter (1.0.0) + - flutter_local_notifications (0.0.1): + - Flutter + - flutter_native_splash (2.4.3): + - Flutter + - flutter_secure_storage (6.0.0): + - Flutter + - flutter_sms (1.1.0): + - Flutter + - Google-Mobile-Ads-SDK (10.11.0): + - GoogleAppMeasurement (< 11.0, >= 7.0) + - GoogleUserMessagingPlatform (>= 1.1) + - google_mobile_ads (1.0.0): + - Flutter + - Google-Mobile-Ads-SDK (~> 10.11.0) + - webview_flutter_wkwebview + - GoogleAppMeasurement (10.29.0): + - GoogleAppMeasurement/AdIdSupport (= 10.29.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/AdIdSupport (10.29.0): + - GoogleAppMeasurement/WithoutAdIdSupport (= 10.29.0) + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleAppMeasurement/WithoutAdIdSupport (10.29.0): + - GoogleUtilities/AppDelegateSwizzler (~> 7.11) + - GoogleUtilities/MethodSwizzler (~> 7.11) + - GoogleUtilities/Network (~> 7.11) + - "GoogleUtilities/NSData+zlib (~> 7.11)" + - nanopb (< 2.30911.0, >= 2.30908.0) + - GoogleUserMessagingPlatform (3.0.0) + - GoogleUtilities/AppDelegateSwizzler (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Logger + - GoogleUtilities/Network + - GoogleUtilities/Privacy + - GoogleUtilities/Environment (7.13.3): + - GoogleUtilities/Privacy + - PromisesObjC (< 3.0, >= 1.2) + - GoogleUtilities/Logger (7.13.3): + - GoogleUtilities/Environment + - GoogleUtilities/Privacy + - GoogleUtilities/MethodSwizzler (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - GoogleUtilities/Network (7.13.3): + - GoogleUtilities/Logger + - "GoogleUtilities/NSData+zlib" + - GoogleUtilities/Privacy + - GoogleUtilities/Reachability + - "GoogleUtilities/NSData+zlib (7.13.3)": + - GoogleUtilities/Privacy + - GoogleUtilities/Privacy (7.13.3) + - GoogleUtilities/Reachability (7.13.3): + - GoogleUtilities/Logger + - GoogleUtilities/Privacy + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS + - nanopb (2.30910.0): + - nanopb/decode (= 2.30910.0) + - nanopb/encode (= 2.30910.0) + - nanopb/decode (2.30910.0) + - nanopb/encode (2.30910.0) + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - permission_handler_apple (9.3.0): + - Flutter + - PromisesObjC (2.4.0) + - share_plus (0.0.1): + - Flutter + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - telephony (0.0.1): + - Flutter + - url_launcher_ios (0.0.1): + - Flutter + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - Flutter (from `Flutter`) + - flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`) + - flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`) + - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`) + - flutter_sms (from `.symlinks/plugins/flutter_sms/ios`) + - google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`) + - local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`) + - path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`) + - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) + - share_plus (from `.symlinks/plugins/share_plus/ios`) + - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) + - telephony (from `.symlinks/plugins/telephony/ios`) + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +SPEC REPOS: + trunk: + - Google-Mobile-Ads-SDK + - GoogleAppMeasurement + - GoogleUserMessagingPlatform + - GoogleUtilities + - nanopb + - PromisesObjC + +EXTERNAL SOURCES: + Flutter: + :path: Flutter + flutter_local_notifications: + :path: ".symlinks/plugins/flutter_local_notifications/ios" + flutter_native_splash: + :path: ".symlinks/plugins/flutter_native_splash/ios" + flutter_secure_storage: + :path: ".symlinks/plugins/flutter_secure_storage/ios" + flutter_sms: + :path: ".symlinks/plugins/flutter_sms/ios" + google_mobile_ads: + :path: ".symlinks/plugins/google_mobile_ads/ios" + local_auth_darwin: + :path: ".symlinks/plugins/local_auth_darwin/darwin" + path_provider_foundation: + :path: ".symlinks/plugins/path_provider_foundation/darwin" + permission_handler_apple: + :path: ".symlinks/plugins/permission_handler_apple/ios" + share_plus: + :path: ".symlinks/plugins/share_plus/ios" + shared_preferences_foundation: + :path: ".symlinks/plugins/shared_preferences_foundation/darwin" + sqflite_darwin: + :path: ".symlinks/plugins/sqflite_darwin/darwin" + telephony: + :path: ".symlinks/plugins/telephony/ios" + url_launcher_ios: + :path: ".symlinks/plugins/url_launcher_ios/ios" + webview_flutter_wkwebview: + :path: ".symlinks/plugins/webview_flutter_wkwebview/darwin" + +SPEC CHECKSUMS: + Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7 + flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743 + flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29 + flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12 + flutter_sms: 91ce41530f55c85d6524d82307a5d555844c086a + Google-Mobile-Ads-SDK: 58b4fda3f9758fc1ed210aa5cf7777b5eb55d47e + google_mobile_ads: 511febb4768edc860ee455a9e201ff52de385908 + GoogleAppMeasurement: f9de05ee17401e3355f68e8fc8b5064d429f5918 + GoogleUserMessagingPlatform: f8d0cdad3ca835406755d0a69aa634f00e76d576 + GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15 + local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3 + nanopb: 438bc412db1928dac798aa6fd75726007be04262 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2 + PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47 + share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + telephony: c41768fae9fb5495781b05a72004106ca33ec777 + url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe + webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 + +PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5 + +COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index e160bae..ae413df 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,8 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */; }; + 73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */; }; 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; }; 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; @@ -40,6 +42,8 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + 13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; @@ -55,19 +59,43 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + C8B5AAC4245FB9238AB6F925 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + DDC48A61CC3887158D51699F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + DEDD176B80E79E5674C841B0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ + 27474C77F8EBFE4B5468329B /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EB1CF9000F007C117D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 104722E6173DA3E706B6AF13 /* Frameworks */ = { + isa = PBXGroup; + children = ( + C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */, + 13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 331C8082294A63A400263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -94,6 +122,8 @@ 97C146F01CF9000F007C117D /* Runner */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, + F0813F149E71664270D649A1 /* Pods */, + 104722E6173DA3E706B6AF13 /* Frameworks */, ); sourceTree = ""; }; @@ -121,6 +151,20 @@ path = Runner; sourceTree = ""; }; + F0813F149E71664270D649A1 /* Pods */ = { + isa = PBXGroup; + children = ( + C8B5AAC4245FB9238AB6F925 /* Pods-Runner.debug.xcconfig */, + DDC48A61CC3887158D51699F /* Pods-Runner.release.xcconfig */, + DEDD176B80E79E5674C841B0 /* Pods-Runner.profile.xcconfig */, + A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */, + 0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */, + EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -128,8 +172,10 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, + 27474C77F8EBFE4B5468329B /* Frameworks */, ); buildRules = ( ); @@ -145,12 +191,15 @@ isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, 9705A1C41CF9048500538489 /* Embed Frameworks */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, + 45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */, + 05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -222,6 +271,45 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ + 05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; + 0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -238,6 +326,23 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; + 45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -253,6 +358,28 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build"; }; + EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -378,6 +505,7 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -395,6 +523,7 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; @@ -410,6 +539,7 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; diff --git a/ios/Runner.xcworkspace/contents.xcworkspacedata b/ios/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/ios/Runner.xcworkspace/contents.xcworkspacedata +++ b/ios/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + + diff --git a/lib/main.dart b/lib/main.dart index 7ac7963..b2ea7b0 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,27 +1,28 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:hive_flutter/hive_flutter.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode; import 'package:flutter_localizations/flutter_localizations.dart'; import 'models/subscription_model.dart'; import 'models/category_model.dart'; import 'providers/subscription_provider.dart'; import 'providers/app_lock_provider.dart'; import 'providers/notification_provider.dart'; -import 'screens/main_screen.dart'; -import 'screens/app_lock_screen.dart'; +import 'providers/navigation_provider.dart'; import 'services/notification_service.dart'; import 'providers/category_provider.dart'; import 'providers/locale_provider.dart'; +import 'providers/theme_provider.dart'; import 'l10n/app_localizations.dart'; -import 'theme/app_theme.dart'; -import 'screens/splash_screen.dart'; -import 'package:cached_network_image/cached_network_image.dart'; +import 'theme/adaptive_theme.dart'; +import 'routes/app_routes.dart'; +import 'navigation/app_navigation_observer.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'dart:io' show Platform; - -final GlobalKey navigatorKey = GlobalKey(); +import 'utils/memory_manager.dart'; +import 'utils/performance_optimizer.dart'; +import 'navigator_key.dart'; Future main() async { WidgetsFlutterBinding.ensureInitialized(); @@ -31,17 +32,25 @@ Future main() async { await MobileAds.instance.initialize(); } + // ์„ฑ๋Šฅ ์ตœ์ ํ™” ์„ค์ • + MemoryManager.optimizeImageCache(); + MemoryManager().startAutoCleanup(); + // ์•ฑ ์‹œ์ž‘ ์‹œ ์ด๋ฏธ์ง€ ์บ์‹œ ๊ด€๋ฆฌ try { // ๋ฉ”๋ชจ๋ฆฌ ์ด๋ฏธ์ง€ ์บ์‹œ๋Š” ์œ ์ง€ํ•˜์ง€๋งŒ ํ•„์š”ํ•œ ๊ฒฝ์šฐ ์‚ญ์ œํ•  ์ˆ˜ ์žˆ๋„๋ก ์ค€๋น„ - final cache = PaintingBinding.instance.imageCache; // ์˜ค๋ž˜๋œ ๋””์Šคํฌ ์บ์‹œ ํŒŒ์ผ๋งŒ ์ง€์šฐ๊ธฐ (์ƒˆ๋กœ์šด ๊ฒƒ์€ ์œ ์ง€) await DefaultCacheManager().emptyCache(); - print('์ด๋ฏธ์ง€ ์บ์‹œ ๊ด€๋ฆฌ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + if (kDebugMode) { + print('์ด๋ฏธ์ง€ ์บ์‹œ ๊ด€๋ฆฌ ์ดˆ๊ธฐํ™” ์™„๋ฃŒ'); + PerformanceOptimizer.checkConstOptimization(); + } } catch (e) { - print('์บ์‹œ ์ดˆ๊ธฐํ™” ์˜ค๋ฅ˜: $e'); + if (kDebugMode) { + print('์บ์‹œ ์ดˆ๊ธฐํ™” ์˜ค๋ฅ˜: $e'); + } } // Hive ์ดˆ๊ธฐํ™” @@ -58,11 +67,14 @@ Future main() async { final categoryProvider = CategoryProvider(); final localeProvider = LocaleProvider(); final notificationProvider = NotificationProvider(); + final themeProvider = ThemeProvider(); + final navigationProvider = NavigationProvider(); await subscriptionProvider.init(); await categoryProvider.init(); await localeProvider.init(); await notificationProvider.init(); + await themeProvider.initialize(); // NotificationProvider์— SubscriptionProvider ์—ฐ๊ฒฐ (์•Œ๋ฆผ ์žฌ์˜ˆ์•ฝ์šฉ) // SRP ์›์น™์— ๋”ฐ๋ผ ๋‹ค๋ฅธ Provider ๊ฐ์ฒด๋ฅผ ๋ช…์‹œ์ ์œผ๋กœ ์ฃผ์ž… @@ -89,6 +101,8 @@ Future main() async { ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)), ChangeNotifierProvider(create: (_) => notificationProvider), ChangeNotifierProvider(create: (_) => localeProvider), + ChangeNotifierProvider(create: (_) => themeProvider), + ChangeNotifierProvider(create: (_) => navigationProvider), ], child: const SubManagerApp(), ), @@ -100,12 +114,15 @@ class SubManagerApp extends StatelessWidget { @override Widget build(BuildContext context) { - return Consumer( - builder: (context, localeProvider, child) { + return Consumer2( + builder: (context, localeProvider, themeProvider, child) { + // ์‹œ์Šคํ…œ UI ์˜ค๋ฒ„๋ ˆ์ด ์Šคํƒ€์ผ ์ ์šฉ + AdaptiveTheme.applySystemUIOverlay(context); + return MaterialApp( title: 'SubManager', debugShowCheckedModeBanner: false, - theme: AppTheme.lightTheme, + theme: themeProvider.getTheme(context), locale: localeProvider.locale, localizationsDelegates: const [ AppLocalizationsDelegate(), @@ -118,7 +135,24 @@ class SubManagerApp extends StatelessWidget { Locale('ko'), ], navigatorKey: navigatorKey, - home: const SplashScreen(), + navigatorObservers: [AppNavigationObserver()], + initialRoute: AppRoutes.splash, + routes: AppRoutes.getRoutes(), + onGenerateRoute: AppRoutes.generateRoute, + builder: (context, child) { + // ์„ฑ๋Šฅ ์ตœ์ ํ™” ๋ฐ ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ + if (kDebugMode) { + PerformanceOptimizer().startFrameMonitoring(); + } + + return MediaQuery( + data: MediaQuery.of(context).copyWith( + textScaler: TextScaler.linear(themeProvider.largeText ? 1.2 : 1.0), + disableAnimations: themeProvider.reduceMotion, + ), + child: child!, + ); + }, ); }, ); diff --git a/lib/models/subscription_model.dart b/lib/models/subscription_model.dart index 4e32654..5beca29 100644 --- a/lib/models/subscription_model.dart +++ b/lib/models/subscription_model.dart @@ -95,6 +95,9 @@ class SubscriptionModel extends HiveObject { } return 0; } + + // ์›๋ž˜ ๊ฐ€๊ฒฉ (์ด๋ฒคํŠธ์™€ ๊ด€๊ณ„์—†์ด ํ•ญ์ƒ ์ •์ƒ ๊ฐ€๊ฒฉ) + double get originalPrice => monthlyCost; } // Hive TypeAdapter ์ƒ์„ฑ์„ ์œ„ํ•œ ๋ช…๋ น์–ด diff --git a/lib/navigation/app_navigation_observer.dart b/lib/navigation/app_navigation_observer.dart new file mode 100644 index 0000000..3727f99 --- /dev/null +++ b/lib/navigation/app_navigation_observer.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/navigation_provider.dart'; + +class AppNavigationObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + _updateNavigationState(route); + debugPrint('Navigation: Push ${route.settings.name}'); + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + if (previousRoute != null) { + _updateNavigationState(previousRoute); + } else { + // ์ด์ „ ๋ผ์šฐํŠธ๊ฐ€ ์—†์œผ๋ฉด Provider์˜ ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ์‚ฌ์šฉ + _handlePopWithProvider(); + } + debugPrint('Navigation: Pop ${route.settings.name}'); + } + + @override + void didRemove(Route route, Route? previousRoute) { + super.didRemove(route, previousRoute); + if (previousRoute != null) { + _updateNavigationState(previousRoute); + } + debugPrint('Navigation: Remove ${route.settings.name}'); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + if (newRoute != null) { + _updateNavigationState(newRoute); + } + debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); + } + + void _updateNavigationState(Route route) { + if (navigator?.context == null) return; + + final routeName = route.settings.name; + if (routeName == null) return; + + // build ์™„๋ฃŒ ํ›„ ์—…๋ฐ์ดํŠธํ•˜๋„๋ก ๋ณ€๊ฒฝ + WidgetsBinding.instance.addPostFrameCallback((_) { + if (navigator?.context == null) return; + + try { + final context = navigator!.context; + final navigationProvider = Provider.of(context, listen: false); + navigationProvider.updateByRoute(routeName); + } catch (e) { + debugPrint('Failed to update navigation state: $e'); + } + }); + } + + void _handlePopWithProvider() { + if (navigator?.context == null) return; + + // build ์™„๋ฃŒ ํ›„ ์—…๋ฐ์ดํŠธํ•˜๋„๋ก ๋ณ€๊ฒฝ + WidgetsBinding.instance.addPostFrameCallback((_) { + if (navigator?.context == null) return; + + try { + final context = navigator!.context; + final navigationProvider = Provider.of(context, listen: false); + navigationProvider.pop(); + } catch (e) { + debugPrint('Failed to handle pop with provider: $e'); + } + }); + } +} \ No newline at end of file diff --git a/lib/providers/navigation_provider.dart b/lib/providers/navigation_provider.dart new file mode 100644 index 0000000..14c2dfb --- /dev/null +++ b/lib/providers/navigation_provider.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +class NavigationProvider extends ChangeNotifier { + int _currentIndex = 0; + final List _navigationHistory = [0]; + String _currentRoute = '/'; + String _currentTitle = 'ํ™ˆ'; + + int get currentIndex => _currentIndex; + List get navigationHistory => List.unmodifiable(_navigationHistory); + String get currentRoute => _currentRoute; + String get currentTitle => _currentTitle; + + static const Map routeToIndex = { + '/': 0, + '/add-subscription': -1, + '/sms-scanner': 3, + '/analysis': 1, + '/settings': 4, + '/subscription-detail': -1, + }; + + static const Map indexToRoute = { + 0: '/', + 1: '/analysis', + 3: '/sms-scanner', + 4: '/settings', + }; + + static const Map 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(); + } +} \ No newline at end of file diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 508b332..82364ab 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -243,4 +243,83 @@ class SubscriptionProvider extends ChangeNotifier { await refreshSubscriptions(); } } + + /// ์ด ์›”๊ฐ„ ์ง€์ถœ์„ ๊ณ„์‚ฐํ•ฉ๋‹ˆ๋‹ค. + Future calculateTotalExpense() async { + // ์ด๋ฏธ ์กด์žฌํ•˜๋Š” totalMonthlyExpense getter๋ฅผ ์‚ฌ์šฉ + return totalMonthlyExpense; + } + + /// ์ตœ๊ทผ 6๊ฐœ์›”์˜ ์›”๋ณ„ ์ง€์ถœ ๋ฐ์ดํ„ฐ๋ฅผ ๋ฐ˜ํ™˜ํ•ฉ๋‹ˆ๋‹ค. + Future>> getMonthlyExpenseData() async { + final now = DateTime.now(); + final List> 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]; + } } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart new file mode 100644 index 0000000..412fecb --- /dev/null +++ b/lib/providers/theme_provider.dart @@ -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 _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 initialize() async { + _themeBox = await Hive.openBox(_themeBoxName); + await _loadThemeSettings(); + } + + /// ์ €์žฅ๋œ ํ…Œ๋งˆ ์„ค์ • ๋กœ๋“œ + Future _loadThemeSettings() async { + final savedSettings = _themeBox.get(_themeKey); + if (savedSettings != null) { + _themeSettings = ThemeSettings.fromJson( + Map.from(savedSettings), + ); + notifyListeners(); + } + } + + /// ํ…Œ๋งˆ ์„ค์ • ์ €์žฅ + Future _saveThemeSettings() async { + await _themeBox.put(_themeKey, _themeSettings.toJson()); + } + + /// ํ…Œ๋งˆ ๋ชจ๋“œ ๋ณ€๊ฒฝ + Future setThemeMode(AppThemeMode mode) async { + _themeSettings = _themeSettings.copyWith(mode: mode); + await _saveThemeSettings(); + notifyListeners(); + } + + /// ์‹œ์Šคํ…œ ์ƒ‰์ƒ ์‚ฌ์šฉ ์„ค์ • + Future setUseSystemColors(bool value) async { + _themeSettings = _themeSettings.copyWith(useSystemColors: value); + await _saveThemeSettings(); + notifyListeners(); + } + + /// ํฐ ํ…์ŠคํŠธ ์„ค์ • + Future setLargeText(bool value) async { + _themeSettings = _themeSettings.copyWith(largeText: value); + await _saveThemeSettings(); + notifyListeners(); + } + + /// ๋ชจ์…˜ ๊ฐ์†Œ ์„ค์ • + Future setReduceMotion(bool value) async { + _themeSettings = _themeSettings.copyWith(reduceMotion: value); + await _saveThemeSettings(); + notifyListeners(); + } + + /// ๊ณ ๋Œ€๋น„ ์„ค์ • + Future 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 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(); + 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().isDarkMode(context); + + return Theme( + data: Theme.of(context).copyWith( + primaryColor: isDark ? darkColor : lightColor, + ), + child: child, + ); + } +} \ No newline at end of file diff --git a/lib/routes/app_routes.dart b/lib/routes/app_routes.dart new file mode 100644 index 0000000..8346ea9 --- /dev/null +++ b/lib/routes/app_routes.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; +import 'package:submanager/screens/main_screen.dart'; +import 'package:submanager/screens/add_subscription_screen.dart'; +import 'package:submanager/screens/detail_screen.dart'; +import 'package:submanager/screens/sms_scan_screen.dart'; +import 'package:submanager/screens/analysis_screen.dart'; +import 'package:submanager/screens/settings_screen.dart'; +import 'package:submanager/screens/splash_screen.dart'; +import 'package:submanager/models/subscription_model.dart'; + +class AppRoutes { + static const String splash = '/splash'; + static const String main = '/'; + static const String addSubscription = '/add-subscription'; + static const String subscriptionDetail = '/subscription-detail'; + static const String smsScanner = '/sms-scanner'; + static const String analysis = '/analysis'; + static const String settings = '/settings'; + + static Map getRoutes() { + return { + splash: (context) => const SplashScreen(), + main: (context) => const MainScreen(), + addSubscription: (context) => const AddSubscriptionScreen(), + smsScanner: (context) => const SmsScanScreen(), + analysis: (context) => const AnalysisScreen(), + settings: (context) => const SettingsScreen(), + }; + } + + static Route generateRoute(RouteSettings routeSettings) { + switch (routeSettings.name) { + case splash: + return _buildRoute(const SplashScreen(), routeSettings); + + case main: + return _buildRoute(const MainScreen(), routeSettings); + + case addSubscription: + return _buildRoute(const AddSubscriptionScreen(), routeSettings); + + case subscriptionDetail: + final subscription = routeSettings.arguments as SubscriptionModel?; + if (subscription != null) { + return _buildRoute(DetailScreen(subscription: subscription), routeSettings); + } + return _errorRoute(); + + case smsScanner: + return _buildRoute(const SmsScanScreen(), routeSettings); + + case analysis: + return _buildRoute(const AnalysisScreen(), routeSettings); + + case settings: + return _buildRoute(const SettingsScreen(), routeSettings); + + default: + return _errorRoute(); + } + } + + static Route _buildRoute(Widget page, RouteSettings settings) { + return MaterialPageRoute( + builder: (_) => page, + settings: settings, + ); + } + + static Route _errorRoute() { + return MaterialPageRoute( + builder: (_) => const Scaffold( + body: Center( + child: Text('ํŽ˜์ด์ง€๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค'), + ), + ), + ); + } + + static void navigateTo(BuildContext context, String routeName, {Object? arguments}) { + Navigator.pushNamed(context, routeName, arguments: arguments); + } + + static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) { + Navigator.pushReplacementNamed(context, routeName, arguments: arguments); + } + + static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) { + Navigator.pushNamedAndRemoveUntil( + context, + routeName, + (route) => false, + arguments: arguments, + ); + } + + static void pop(BuildContext context, {dynamic result}) { + if (Navigator.canPop(context)) { + Navigator.pop(context, result); + } + } + + static bool canPop(BuildContext context) { + return Navigator.canPop(context); + } +} \ No newline at end of file diff --git a/lib/screens/add_subscription_screen.dart b/lib/screens/add_subscription_screen.dart index 82eb4fa..894f96e 100644 --- a/lib/screens/add_subscription_screen.dart +++ b/lib/screens/add_subscription_screen.dart @@ -7,7 +7,6 @@ import 'dart:math' as math; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import '../providers/subscription_provider.dart'; import '../providers/category_provider.dart'; -import '../models/category_model.dart'; import '../services/sms_service.dart'; import '../services/subscription_url_matcher.dart'; import '../services/exchange_rate_service.dart'; @@ -495,7 +494,7 @@ class _AddSubscriptionScreenState extends State ); if (mounted) { - Navigator.pop(context); + Navigator.pop(context, true); // ์„ฑ๊ณต ์—ฌ๋ถ€ ๋ฐ˜ํ™˜ } } catch (e) { setState(() { @@ -536,11 +535,11 @@ class _AddSubscriptionScreenState extends State preferredSize: const Size.fromHeight(60), child: Container( decoration: BoxDecoration( - color: Colors.white.withOpacity(appBarOpacity), + color: Colors.white.withValues(alpha: appBarOpacity), boxShadow: appBarOpacity > 0.6 ? [ BoxShadow( - color: Colors.black.withOpacity(0.1 * appBarOpacity), + color: Colors.black.withValues(alpha: 0.1 * appBarOpacity), spreadRadius: 1, blurRadius: 8, offset: const Offset(0, 4), @@ -561,7 +560,7 @@ class _AddSubscriptionScreenState extends State shadows: appBarOpacity > 0.6 ? [ Shadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), offset: const Offset(0, 1), blurRadius: 2, ) @@ -626,7 +625,7 @@ class _AddSubscriptionScreenState extends State ), boxShadow: [ BoxShadow( - color: _gradientColors[0].withOpacity(0.3), + color: _gradientColors[0].withValues(alpha: 0.3), blurRadius: 20, spreadRadius: 0, offset: const Offset(0, 8), @@ -638,7 +637,7 @@ class _AddSubscriptionScreenState extends State Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.2), + color: Colors.white.withValues(alpha: 0.2), borderRadius: BorderRadius.circular(16), ), child: const Icon( @@ -741,7 +740,7 @@ class _AddSubscriptionScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 0 - ? const Color(0xFF3B82F6).withOpacity(0.1) + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -786,7 +785,7 @@ class _AddSubscriptionScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -821,7 +820,7 @@ class _AddSubscriptionScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 1 - ? const Color(0xFF3B82F6).withOpacity(0.1) + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -922,7 +921,7 @@ class _AddSubscriptionScreenState extends State BorderRadius.circular(12), borderSide: BorderSide( color: - Colors.grey.withOpacity(0.2), + Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -979,7 +978,7 @@ class _AddSubscriptionScreenState extends State border: Border.all( color: _currentEditingField == 1 ? const Color(0xFF3B82F6) - : Colors.grey.withOpacity( + : Colors.grey.withValues(alpha: 0.4), // ํฌ์ปค์Šค ์—†์„ ๋•Œ ๋” ์ง„ํ•œ ํšŒ์ƒ‰ width: _currentEditingField == 1 ? 2 @@ -997,7 +996,7 @@ class _AddSubscriptionScreenState extends State border: Border( right: BorderSide( color: Colors.grey - .withOpacity(0.2), + .withValues(alpha: 0.2), width: 1, ), ), @@ -1248,7 +1247,7 @@ class _AddSubscriptionScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 2 - ? const Color(0xFF3B82F6).withOpacity(0.1) + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1285,7 +1284,7 @@ class _AddSubscriptionScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1336,7 +1335,7 @@ class _AddSubscriptionScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 3 - ? const Color(0xFF3B82F6).withOpacity(0.1) + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1397,7 +1396,7 @@ class _AddSubscriptionScreenState extends State border: Border.all( color: _nextBillingDate == null ? Colors.red - : Colors.grey.withOpacity(0.2), + : Colors.grey.withValues(alpha: 0.2), ), borderRadius: BorderRadius.circular(12), color: Colors.white, @@ -1437,7 +1436,7 @@ class _AddSubscriptionScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 4 - ? const Color(0xFF3B82F6).withOpacity(0.1) + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1476,7 +1475,7 @@ class _AddSubscriptionScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1504,7 +1503,7 @@ class _AddSubscriptionScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 5 - ? const Color(0xFF3B82F6).withOpacity(0.1) + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1538,7 +1537,7 @@ class _AddSubscriptionScreenState extends State decoration: BoxDecoration( border: Border.all( color: - Colors.grey.withOpacity(0.2), + Colors.grey.withValues(alpha: 0.2), ), borderRadius: BorderRadius.circular(12), @@ -1598,7 +1597,7 @@ class _AddSubscriptionScreenState extends State borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1667,7 +1666,7 @@ class _AddSubscriptionScreenState extends State border: Border.all( color: _isEventActive ? const Color(0xFF3B82F6) - : Colors.grey.withOpacity(0.2), + : Colors.grey.withValues(alpha: 0.2), width: _isEventActive ? 2 : 1, ), ), @@ -1761,7 +1760,7 @@ class _AddSubscriptionScreenState extends State child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withOpacity(0.3)), + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -1825,7 +1824,7 @@ class _AddSubscriptionScreenState extends State child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withOpacity(0.3)), + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -1889,7 +1888,7 @@ class _AddSubscriptionScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1967,15 +1966,15 @@ class _AddSubscriptionScreenState extends State style: ElevatedButton.styleFrom( backgroundColor: const Color(0xFF3B82F6), foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey.withOpacity(0.3), + disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3), disabledForegroundColor: - Colors.white.withOpacity(0.5), + Colors.white.withValues(alpha: 0.5), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), ), padding: const EdgeInsets.symmetric(vertical: 16), elevation: _isSaveHovered ? 8 : 4, - shadowColor: const Color(0xFF3B82F6).withOpacity(0.5), + shadowColor: const Color(0xFF3B82F6).withValues(alpha: 0.5), ), child: _isLoading ? const SizedBox( diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index 1542abb..aeaee33 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import 'package:fl_chart/fl_chart.dart'; -import 'dart:math' as math; import '../providers/subscription_provider.dart'; -import '../models/subscription_model.dart'; -import '../temp/test_sms_data.dart'; -import '../services/currency_util.dart'; -import '../services/exchange_rate_service.dart'; +import '../widgets/glassmorphic_app_bar.dart'; import '../widgets/native_ad_widget.dart'; +import '../widgets/analysis/analysis_screen_spacer.dart'; +import '../widgets/analysis/subscription_pie_chart_card.dart'; +import '../widgets/analysis/total_expense_summary_card.dart'; +import '../widgets/analysis/monthly_expense_chart_card.dart'; +import '../widgets/analysis/event_analysis_card.dart'; class AnalysisScreen extends StatefulWidget { const AnalysisScreen({super.key}); @@ -17,76 +17,24 @@ class AnalysisScreen extends StatefulWidget { } class _AnalysisScreenState extends State - with SingleTickerProviderStateMixin { + with TickerProviderStateMixin { late AnimationController _animationController; late ScrollController _scrollController; - double _scrollOffset = 0; + + double _totalExpense = 0; + List> _monthlyData = []; int _touchedIndex = -1; - - // ์ตœ๊ทผ 6๊ฐœ์›” ๋ฐ์ดํ„ฐ - late List> _monthlyData; - - // ์ด ์ง€์ถœ์•ก (์›ํ™” ํ™˜์‚ฐ) - double _totalExpense = 0.0; - - // ๋กœ๋”ฉ ์ƒํƒœ bool _isLoading = true; @override void initState() { super.initState(); - _animationController = AnimationController( + duration: const Duration(milliseconds: 1500), vsync: this, - duration: const Duration(milliseconds: 600), // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์†๋„ ์กฐ์ • - )..forward(); - - _scrollController = ScrollController() - ..addListener(() { - setState(() { - _scrollOffset = _scrollController.offset; - }); - }); - - // ์›”๊ฐ„ ์ง€์ถœ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” - _monthlyData = TestSmsData.getMonthlyExpenseData(); - } - - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _calculateTotalExpense(); - } - - // ์ด ์ง€์ถœ ๊ธˆ์•ก ๊ณ„์‚ฐ (USD๋Š” ์›ํ™”๋กœ ํ™˜์‚ฐ) - Future _calculateTotalExpense() async { - setState(() => _isLoading = true); - - try { - final provider = - Provider.of(context, listen: false); - final subscriptions = provider.subscriptions; - - if (subscriptions.isEmpty) { - setState(() { - _totalExpense = 0.0; - _isLoading = false; - }); - return; - } - - // ๋ชจ๋“  ๊ตฌ๋…์˜ ์›” ๋น„์šฉ์„ ์›ํ™”๋กœ ํ™˜์‚ฐํ•˜์—ฌ ๊ณ„์‚ฐ - final total = - await CurrencyUtil.calculateTotalMonthlyExpense(subscriptions); - - setState(() { - _totalExpense = total; - _isLoading = false; - }); - } catch (e) { - debugPrint('์ด ์ง€์ถœ ๊ณ„์‚ฐ ์˜ค๋ฅ˜: $e'); - setState(() => _isLoading = false); - } + ); + _scrollController = ScrollController(); + _loadData(); } @override @@ -96,1048 +44,114 @@ class _AnalysisScreenState extends State super.dispose(); } - // ์ด ์ง€์ถœ ๊ธˆ์•ก ๋ฐ” ์ฐจํŠธ ๋ฐ์ดํ„ฐ - List _getBarGroups(List subscriptions) { - return [ - BarChartGroupData( - x: 0, - barRods: [ - BarChartRodData( - toY: _totalExpense, - gradient: const LinearGradient( - colors: [Color(0xFF3B82F6), Color(0xFF60A5FA)], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), - width: 20, - borderRadius: BorderRadius.circular(4), - backDrawRodData: BackgroundBarChartRodData( - show: true, - toY: _totalExpense + (_totalExpense * 0.2), - color: Colors.grey.withOpacity(0.1), - ), - ), - ], - ), - ]; - } - - // ํŒŒ์ด ์ฐจํŠธ ์„น์…˜ ๋ฐ์ดํ„ฐ - List _getPieSections( - List subscriptions) { - if (subscriptions.isEmpty) return []; - - final colors = [ - const Color(0xFF3B82F6), - const Color(0xFF10B981), - const Color(0xFFF59E0B), - const Color(0xFFEF4444), - const Color(0xFF8B5CF6), - const Color(0xFF0EA5E9), - const Color(0xFFEC4899), - ]; - - // ๊ฐœ๋ณ„ ๊ตฌ๋…์˜ ๋น„์œจ ๊ณ„์‚ฐ์„ ์œ„ํ•œ ๊ฐ’๋“ค - List sectionValues = []; - - // ๊ฐ ๊ตฌ๋…์˜ ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก ๋˜๋Š” ์›ํ™” ๊ธˆ์•ก์„ ๊ณ„์‚ฐ - for (var subscription in subscriptions) { - double value = subscription.monthlyCost; - if (subscription.currency == 'USD') { - // USD์˜ ๊ฒฝ์šฐ ๋งˆ์ง€๋ง‰์œผ๋กœ ์กฐํšŒ๋œ ํ™˜์œจ๋กœ ๋Œ€๋žต์ ์ธ ๊ณ„์‚ฐ - // (์ •ํ™•ํ•œ ๊ณ„์‚ฐ์€ ๋น„๋™๊ธฐ๋กœ ์ด๋ฃจ์–ด์ง€๋ฏ€๋กœ UI ํ‘œ์‹œ์šฉ์œผ๋กœ๋งŒ ์‚ฌ์šฉ) - const rate = 1350.0; // ๊ธฐ๋ณธ ํ™˜์œจ (์‹ค์ œ ๊ฐ’์€ API๋กœ ๋ณ„๋„๋กœ ๊ฐ€์ ธ์˜ด) - value = value * rate; - } - sectionValues.add(value); - } - - // ์ดํ•ฉ ๊ณ„์‚ฐ - double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); - - // ์„น์…˜ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ - return List.generate(subscriptions.length, (i) { - final subscription = subscriptions[i]; - final percentage = (sectionValues[i] / sectionsTotal) * 100; - final index = i % colors.length; - final isTouched = _touchedIndex == i; - final fontSize = isTouched ? 16.0 : 12.0; - final radius = isTouched ? 105.0 : 100.0; - - return PieChartSectionData( - value: sectionValues[i], - title: '${percentage.toStringAsFixed(1)}%', - titleStyle: TextStyle( - fontSize: fontSize, - fontWeight: FontWeight.bold, - color: Colors.white, - shadows: const [ - Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) - ], - ), - color: colors[index], - radius: radius, - titlePositionPercentageOffset: 0.6, - badgeWidget: isTouched - ? _Badge( - size: 40, - borderColor: colors[index], - subscription: subscription, - ) - : null, - badgePositionPercentageOffset: .98, - ); + Future _loadData() async { + setState(() { + _isLoading = true; }); + + final provider = Provider.of(context, listen: false); + + // ์ด ์ง€์ถœ ๊ณ„์‚ฐ + _totalExpense = await provider.calculateTotalExpense(); + + // ์›”๋ณ„ ๋ฐ์ดํ„ฐ ๊ณ„์‚ฐ + _monthlyData = await provider.getMonthlyExpenseData(); + + setState(() { + _isLoading = false; + }); + + // ๋ฐ์ดํ„ฐ ๋กœ๋“œ ์™„๋ฃŒ ํ›„ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹œ์ž‘ + _animationController.forward(); } - // ์›”๊ฐ„ ์ง€์ถœ ์ฐจํŠธ ๋ฐ์ดํ„ฐ - List _getMonthlyBarGroups() { - final List barGroups = []; - final maxAmount = _monthlyData.fold( - 0, (max, data) => math.max(max, data['totalExpense'] as double)); - - for (int i = 0; i < _monthlyData.length; i++) { - final data = _monthlyData[i]; - barGroups.add( - BarChartGroupData( - x: i, - barRods: [ - BarChartRodData( - toY: data['totalExpense'], - gradient: LinearGradient( - colors: [ - const Color(0xFF3B82F6).withOpacity(0.7), - const Color(0xFF60A5FA), - ], - begin: Alignment.bottomCenter, - end: Alignment.topCenter, - ), - width: 18, - borderRadius: BorderRadius.circular(4), - backDrawRodData: BackgroundBarChartRodData( - show: true, - toY: maxAmount + (maxAmount * 0.1), - color: Colors.grey.withOpacity(0.1), - ), - ), - ], - ), - ); - } - - return barGroups; + Widget _buildAnimatedAd() { + return FadeTransition( + opacity: CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.5, curve: Curves.easeOut), + )), + child: const NativeAdWidget(key: ValueKey('analysis_ad')), + ), + ); } @override Widget build(BuildContext context) { - final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100)); + return Consumer( + builder: (context, provider, child) { + final subscriptions = provider.subscriptions; - return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withOpacity(appBarOpacity), - boxShadow: appBarOpacity > 0.6 - ? [ - BoxShadow( - color: Colors.black.withOpacity(0.1 * appBarOpacity), - spreadRadius: 1, - blurRadius: 8, - offset: const Offset(0, 4), - ) - ] - : null, - ), - child: SafeArea( - child: AppBar( - title: Text( - '์ง€์ถœ ๋ถ„์„', - style: TextStyle( - fontFamily: 'Montserrat', - fontSize: 24, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - color: const Color(0xFF1E293B), - shadows: appBarOpacity > 0.6 - ? [ - Shadow( - color: Colors.black.withOpacity(0.2), - offset: const Offset(0, 1), - blurRadius: 2, - ) - ] - : null, - ), - ), - elevation: 0, - backgroundColor: Colors.transparent, - ), - ), - ), - ), - body: Consumer( - builder: (context, provider, child) { - final subscriptions = provider.subscriptions; - - if (_isLoading) { - return const Center( - child: CircularProgressIndicator(), - ); - } - - return SingleChildScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: MediaQuery.of(context).padding.top + 60), - - // ๋„ค์ดํ‹ฐ๋ธŒ ๊ด‘๊ณ  ์œ„์ ฏ ์ถ”๊ฐ€ - FadeTransition( - opacity: CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.5, curve: Curves.easeOut), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.5, curve: Curves.easeOut), - )), - child: const NativeAdWidget(key: ValueKey('analysis_ad')), - ), - ), - - const SizedBox(height: 24), - - // 1. ๊ตฌ๋… ๋น„์œจ ํŒŒ์ด ์ฐจํŠธ (์ฒ˜์Œ์œผ๋กœ ์œ„์น˜ ๋ณ€๊ฒฝ) - FadeTransition( - opacity: CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.7, curve: Curves.easeOut), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.7, curve: Curves.easeOut), - )), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '๊ตฌ๋… ์„œ๋น„์Šค ๋น„์œจ', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF1E293B), - ), - ), - FutureBuilder( - future: CurrencyUtil.getExchangeRateInfo(), - builder: (context, snapshot) { - if (snapshot.hasData && - snapshot.data!.isNotEmpty) { - return Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFE5F2FF), - borderRadius: - BorderRadius.circular(4), - border: Border.all( - color: const Color(0xFFBFDBFE), - width: 1, - ), - ), - child: Text( - snapshot.data!, - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - const SizedBox(height: 8), - Text( - '์›” ์ง€์ถœ ๊ธฐ์ค€', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 16), - Center( - child: subscriptions.isEmpty - ? const SizedBox( - height: 250, - child: Center( - child: Text( - '๊ตฌ๋…์ค‘์ธ ์„œ๋น„์Šค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค', - style: TextStyle( - fontSize: 16, - color: Colors.grey, - ), - ), - ), - ) - : SizedBox( - height: 250, - child: PieChart( - PieChartData( - borderData: FlBorderData(show: false), - sectionsSpace: 2, - centerSpaceRadius: 60, - sections: - _getPieSections(subscriptions), - pieTouchData: PieTouchData( - touchCallback: (FlTouchEvent event, - pieTouchResponse) { - setState(() { - if (!event - .isInterestedForInteractions || - pieTouchResponse == null || - pieTouchResponse - .touchedSection == - null) { - _touchedIndex = -1; - return; - } - _touchedIndex = pieTouchResponse - .touchedSection! - .touchedSectionIndex; - }); - }, - ), - ), - ), - ), - ), - const SizedBox(height: 16), - // ์„œ๋น„์Šค ๋ชฉ๋ก (๋น„๋™๊ธฐ ์ฒ˜๋ฆฌ๋กœ ์ˆ˜์ •) - Column( - children: subscriptions.isEmpty - ? [] - : List.generate( - subscriptions.length, - (index) { - final subscription = - subscriptions[index]; - final color = [ - const Color(0xFF3B82F6), - const Color(0xFF10B981), - const Color(0xFFF59E0B), - const Color(0xFFEF4444), - const Color(0xFF8B5CF6), - const Color(0xFF0EA5E9), - const Color(0xFFEC4899), - ][index % 7]; - return Padding( - padding: const EdgeInsets.only( - bottom: 4.0), - child: Row( - children: [ - Container( - width: 12, - height: 12, - decoration: BoxDecoration( - color: color, - shape: BoxShape.circle, - ), - ), - const SizedBox(width: 8), - Expanded( - child: Text( - subscription.serviceName, - style: const TextStyle( - fontSize: 14, - ), - overflow: - TextOverflow.ellipsis, - ), - ), - FutureBuilder( - future: CurrencyUtil - .formatSubscriptionAmount( - subscription), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data!, - style: const TextStyle( - fontSize: 14, - fontWeight: - FontWeight.bold, - ), - ); - } - return const SizedBox( - width: 20, - height: 20, - child: - CircularProgressIndicator( - strokeWidth: 2, - ), - ); - }, - ), - ], - ), - ); - }, - ), - ), - ], - ), - ), - ), - ), - ), - - const SizedBox(height: 24), - - // 2. ์ด ์ง€์ถœ ์š”์•ฝ ์นด๋“œ - FadeTransition( - opacity: CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 0.8, curve: Curves.easeOut), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 0.8, curve: Curves.easeOut), - )), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ํ—ค๋” ํ…์ŠคํŠธ - const Text( - '์ด ์ง€์ถœ ํ˜„ํ™ฉ', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(height: 8), - Text( - '์ด๋ฒˆ ๋‹ฌ', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 24), - // ์ด ์ง€์ถœ ๊ธˆ์•ก (๊ฐ•์กฐ ํ‘œ์‹œ) - Center( - child: Column( - children: [ - Text( - CurrencyUtil.formatTotalAmount( - _totalExpense), - style: const TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Color(0xFF3B82F6), - letterSpacing: -1, - ), - ), - const SizedBox(height: 4), - const Text( - '์›” ๊ตฌ๋… ์ง€์ถœ', - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), - ), - ], - ), - ), - const SizedBox(height: 24), - // ์„œ๋น„์Šค ๊ฑด์ˆ˜ ๋ฐ ํ‰๊ท  ์š”๊ธˆ - Row( - children: [ - Expanded( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Text( - '์ด ์„œ๋น„์Šค', - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - '${subscriptions.length}๊ฐœ', - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - const Text( - 'ํ‰๊ท  ์š”๊ธˆ', - style: TextStyle( - fontSize: 14, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - subscriptions.isEmpty - ? 'โ‚ฉ0' - : CurrencyUtil.formatTotalAmount( - _totalExpense / - subscriptions.length), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ), - ), - ], - ), - - // ๋ฐ” ์ฐจํŠธ ์ถ”๊ฐ€ - if (subscriptions.isNotEmpty) ...[ - const SizedBox(height: 24), - SizedBox( - height: 150, - child: BarChart( - BarChartData( - barGroups: _getBarGroups(subscriptions), - gridData: const FlGridData(show: false), - borderData: FlBorderData(show: false), - titlesData: FlTitlesData( - show: true, - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - return const Text( - '์ด ์ง€์ถœ', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Color(0xFF64748B), - ), - ); - }, - ), - ), - leftTitles: const AxisTitles( - sideTitles: - SideTitles(showTitles: false), - ), - rightTitles: const AxisTitles( - sideTitles: - SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: - SideTitles(showTitles: false), - ), - ), - ), - ), - ), - ], - ], - ), - ), - ), - ), - ), - - const SizedBox(height: 24), - - // 3. ์›”๊ฐ„ ์ง€์ถœ ์ฐจํŠธ - FadeTransition( - opacity: CurvedAnimation( - parent: _animationController, - curve: const Interval(0.4, 1.0, curve: Curves.easeOut), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.4, 1.0, curve: Curves.easeOut), - )), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์›”๊ฐ„ ์ง€์ถœ ์ถ”์ด', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(height: 8), - Text( - '์ตœ๊ทผ 6๊ฐœ์›”', - style: TextStyle( - fontSize: 14, - color: Colors.grey.shade600, - ), - ), - const SizedBox(height: 24), - SizedBox( - height: 250, - child: BarChart( - BarChartData( - gridData: FlGridData( - show: false, - ), - borderData: FlBorderData( - show: false, - ), - titlesData: FlTitlesData( - show: true, - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: false, - ), - ), - topTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: false, - ), - ), - rightTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: false, - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - getTitlesWidget: (value, meta) { - if (value.toInt() >= - _monthlyData.length) { - return const SizedBox.shrink(); - } - return Padding( - padding: - const EdgeInsets.only(top: 8.0), - child: Text( - _monthlyData[value.toInt()] - ['monthName'], - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.bold, - color: Color(0xFF64748B), - ), - ), - ); - }, - ), - ), - ), - barGroups: _getMonthlyBarGroups(), - barTouchData: BarTouchData( - touchTooltipData: BarTouchTooltipData( - tooltipBgColor: Colors.blueGrey.shade800, - tooltipRoundedRadius: 8, - getTooltipItem: - (group, groupIndex, rod, rodIndex) { - return BarTooltipItem( - '${_monthlyData[group.x]['monthName']}\n', - const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - ), - children: [ - TextSpan( - text: CurrencyUtil - .formatTotalAmount( - _monthlyData[group.x] - ['totalExpense'] - as double), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.w500, - ), - ), - ], - ); - }, - ), - ), - ), - ), - ), - ], - ), - ), - ), - ), - ), - - const SizedBox(height: 24), - - // 4. ์ด๋ฒคํŠธ ๋ถ„์„ - if (provider.activeEventSubscriptions.isNotEmpty) ...[ - FadeTransition( - opacity: CurvedAnimation( - parent: _animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeOut), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeOut), - )), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const Text( - '์ด๋ฒคํŠธ ํ• ์ธ ํ˜„ํ™ฉ', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - color: Color(0xFF1E293B), - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFFFF6B6B), - const Color(0xFFFF8787), - ], - ), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon( - Icons.local_offer_rounded, - size: 14, - color: Colors.white, - ), - const SizedBox(width: 4), - Text( - '${provider.activeEventSubscriptions.length}๊ฐœ ์ง„ํ–‰์ค‘', - style: const TextStyle( - fontSize: 12, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - - // ์ด ์ ˆ์•ฝ์•ก ํ‘œ์‹œ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - const Color(0xFFFF6B6B).withOpacity(0.1), - const Color(0xFFFF8787).withOpacity(0.1), - ], - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFFFF6B6B).withOpacity(0.3), - width: 1, - ), - ), - child: Column( - children: [ - const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.savings_rounded, - size: 24, - color: Color(0xFFFF6B6B), - ), - SizedBox(width: 8), - Text( - '์›”๊ฐ„ ์ด ์ ˆ์•ฝ์•ก', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - ], - ), - const SizedBox(height: 8), - FutureBuilder( - future: CurrencyUtil.calculateTotalEventSavings( - provider.subscriptions), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - CurrencyUtil.formatTotalAmount( - snapshot.data!), - style: const TextStyle( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Color(0xFFFF6B6B), - ), - ); - } - return const CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Color(0xFFFF6B6B)), - ); - }, - ), - ], - ), - ), - - const SizedBox(height: 16), - - // ์ด๋ฒคํŠธ ์ค‘์ธ ๊ตฌ๋… ๋ชฉ๋ก - const Text( - '์ด๋ฒคํŠธ ์ง„ํ–‰ ์ค‘์ธ ๊ตฌ๋…', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(height: 12), - - ...provider.activeEventSubscriptions.map((subscription) { - final daysRemaining = subscription.eventEndDate != null - ? subscription.eventEndDate!.difference(DateTime.now()).inDays - : 0; - - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: const Color(0xFFE5E7EB), - width: 1, - ), - ), - child: Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - subscription.serviceName, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(height: 4), - FutureBuilder( - future: CurrencyUtil.formatEventSavings( - subscription), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - '${snapshot.data} ์ ˆ์•ฝ', - style: const TextStyle( - fontSize: 12, - color: Color(0xFFFF6B6B), - fontWeight: FontWeight.w500, - ), - ); - } - return const SizedBox(); - }, - ), - ], - ), - ), - if (daysRemaining > 0) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFFEF3C7), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - '$daysRemaining์ผ ๋‚จ์Œ', - style: const TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: Color(0xFFF59E0B), - ), - ), - ), - ], - ), - ); - }).toList(), - ], - ), - ), - ), - ), - ), - ], - - const SizedBox(height: 32), - ], - ), + if (_isLoading) { + return const Center( + child: CircularProgressIndicator(), ); - }, - ), + } + + return CustomScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + slivers: [ + const GlassmorphicSliverAppBar( + title: '๋ถ„์„', + pinned: true, + expandedHeight: kToolbarHeight, + ), + + // ๋„ค์ดํ‹ฐ๋ธŒ ๊ด‘๊ณ  ์œ„์ ฏ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(16), + child: _buildAnimatedAd(), + ), + ), + + const AnalysisScreenSpacer(), + + // 1. ๊ตฌ๋… ๋น„์œจ ํŒŒ์ด ์ฐจํŠธ + SubscriptionPieChartCard( + subscriptions: subscriptions, + animationController: _animationController, + touchedIndex: _touchedIndex, + onPieTouch: (index) => setState(() => _touchedIndex = index), + ), + + const AnalysisScreenSpacer(), + + // 2. ์ด ์ง€์ถœ ์š”์•ฝ ์นด๋“œ + TotalExpenseSummaryCard( + subscriptions: subscriptions, + totalExpense: _totalExpense, + animationController: _animationController, + ), + + const AnalysisScreenSpacer(), + + // 3. ์›”๋ณ„ ์ง€์ถœ ์ฐจํŠธ + MonthlyExpenseChartCard( + monthlyData: _monthlyData, + animationController: _animationController, + ), + + const AnalysisScreenSpacer(), + + // 4. ์ด๋ฒคํŠธ ๋ถ„์„ + EventAnalysisCard( + animationController: _animationController, + ), + + const AnalysisScreenSpacer(height: 32), + ], + ); + }, ); } -} - -class _Badge extends StatelessWidget { - final double size; - final Color borderColor; - final SubscriptionModel subscription; - - const _Badge({ - required this.size, - required this.borderColor, - required this.subscription, - }); - - @override - Widget build(BuildContext context) { - return AnimatedContainer( - duration: PieChart.defaultDuration, - width: size, - height: size, - decoration: BoxDecoration( - color: Colors.white, - shape: BoxShape.circle, - border: Border.all( - color: borderColor, - width: 2, - ), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.5), - blurRadius: 10, - spreadRadius: 2, - ), - ], - ), - padding: const EdgeInsets.all(1), - child: Center( - child: Text( - subscription.serviceName - .substring(0, math.min(1, subscription.serviceName.length)), - style: TextStyle( - color: borderColor, - fontSize: 16, - fontWeight: FontWeight.bold, - ), - ), - ), - ); - } -} +} \ No newline at end of file diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 3a80e97..767993b 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -385,7 +385,7 @@ class _DetailScreenState extends State return LinearGradient( colors: [ baseColor, - baseColor.withOpacity(0.7), + baseColor.withValues(alpha: 0.7), ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -628,11 +628,11 @@ class _DetailScreenState extends State preferredSize: const Size.fromHeight(60), child: Container( decoration: BoxDecoration( - color: Colors.white.withOpacity(appBarOpacity), + color: Colors.white.withValues(alpha: appBarOpacity), boxShadow: appBarOpacity > 0.6 ? [ BoxShadow( - color: Colors.black.withOpacity(0.1 * appBarOpacity), + color: Colors.black.withValues(alpha: 0.1 * appBarOpacity), spreadRadius: 1, blurRadius: 8, offset: const Offset(0, 4), @@ -653,7 +653,7 @@ class _DetailScreenState extends State shadows: appBarOpacity > 0.6 ? [ Shadow( - color: Colors.black.withOpacity(0.2), + color: Colors.black.withValues(alpha: 0.2), offset: const Offset(0, 1), blurRadius: 2, ) @@ -746,7 +746,7 @@ class _DetailScreenState extends State tag: 'subscription_${widget.subscription.id}', child: Card( elevation: 8, - shadowColor: baseColor.withOpacity(0.4), + shadowColor: baseColor.withValues(alpha: 0.4), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(24), ), @@ -760,7 +760,7 @@ class _DetailScreenState extends State begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [ - baseColor.withOpacity(0.8), + baseColor.withValues(alpha: 0.8), baseColor, ], ), @@ -787,7 +787,7 @@ class _DetailScreenState extends State boxShadow: [ BoxShadow( color: Colors.black - .withOpacity(0.1), + .withValues(alpha: 0.1), blurRadius: 10, spreadRadius: 0, ), @@ -834,7 +834,7 @@ class _DetailScreenState extends State fontSize: 16, fontWeight: FontWeight.w500, color: - Colors.white.withOpacity(0.8), + Colors.white.withValues(alpha: 0.8), ), ), ], @@ -846,7 +846,7 @@ class _DetailScreenState extends State Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), + color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(16), ), child: Row( @@ -863,7 +863,7 @@ class _DetailScreenState extends State fontSize: 14, fontWeight: FontWeight.w500, color: - Colors.white.withOpacity(0.8), + Colors.white.withValues(alpha: 0.8), ), ), const SizedBox(height: 4), @@ -889,7 +889,7 @@ class _DetailScreenState extends State fontSize: 14, fontWeight: FontWeight.w500, color: - Colors.white.withOpacity(0.8), + Colors.white.withValues(alpha: 0.8), ), ), const SizedBox(height: 4), @@ -924,10 +924,10 @@ class _DetailScreenState extends State ), decoration: BoxDecoration( color: const Color(0xFFDC2626) - .withOpacity(0.2), + .withValues(alpha: 0.2), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.white.withOpacity(0.3), + color: Colors.white.withValues(alpha: 0.3), width: 1, ), ), @@ -1015,7 +1015,7 @@ class _DetailScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 0 - ? baseColor.withOpacity(0.1) + ? baseColor.withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1053,7 +1053,7 @@ class _DetailScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1080,7 +1080,7 @@ class _DetailScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 1 - ? baseColor.withOpacity(0.1) + ? baseColor.withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1181,7 +1181,7 @@ class _DetailScreenState extends State BorderRadius.circular(12), borderSide: BorderSide( color: - Colors.grey.withOpacity(0.2), + Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1238,7 +1238,7 @@ class _DetailScreenState extends State border: Border.all( color: _currentEditingField == 1 ? baseColor - : Colors.grey.withOpacity( + : Colors.grey.withValues(alpha: 0.4), // ํฌ์ปค์Šค ์—†์„ ๋•Œ ๋” ์ง„ํ•œ ํšŒ์ƒ‰ width: _currentEditingField == 1 ? 2 @@ -1256,7 +1256,7 @@ class _DetailScreenState extends State border: Border( right: BorderSide( color: Colors.grey - .withOpacity(0.2), + .withValues(alpha: 0.2), width: 1, ), ), @@ -1508,7 +1508,7 @@ class _DetailScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 2 - ? baseColor.withOpacity(0.1) + ? baseColor.withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1545,7 +1545,7 @@ class _DetailScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1584,7 +1584,7 @@ class _DetailScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 3 - ? baseColor.withOpacity(0.1) + ? baseColor.withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1642,7 +1642,7 @@ class _DetailScreenState extends State padding: const EdgeInsets.all(16), decoration: BoxDecoration( border: Border.all( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), borderRadius: BorderRadius.circular(12), color: Colors.white, @@ -1678,7 +1678,7 @@ class _DetailScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 4 - ? baseColor.withOpacity(0.1) + ? baseColor.withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1716,7 +1716,7 @@ class _DetailScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1748,7 +1748,7 @@ class _DetailScreenState extends State decoration: BoxDecoration( borderRadius: BorderRadius.circular(16), color: _currentEditingField == 5 - ? baseColor.withOpacity(0.1) + ? baseColor.withValues(alpha: 0.1) : Colors.transparent, ), padding: const EdgeInsets.all(8), @@ -1776,7 +1776,7 @@ class _DetailScreenState extends State ), decoration: BoxDecoration( border: Border.all( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), borderRadius: BorderRadius.circular(12), @@ -1827,7 +1827,7 @@ class _DetailScreenState extends State borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -1900,7 +1900,7 @@ class _DetailScreenState extends State border: Border.all( color: _isEventActive ? baseColor - : Colors.grey.withOpacity(0.2), + : Colors.grey.withValues(alpha: 0.2), width: _isEventActive ? 2 : 1, ), ), @@ -1990,7 +1990,7 @@ class _DetailScreenState extends State child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withOpacity(0.3)), + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -2054,7 +2054,7 @@ class _DetailScreenState extends State child: Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withOpacity(0.3)), + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -2118,7 +2118,7 @@ class _DetailScreenState extends State border: OutlineInputBorder( borderRadius: BorderRadius.circular(12), borderSide: BorderSide( - color: Colors.grey.withOpacity(0.2), + color: Colors.grey.withValues(alpha: 0.2), ), ), focusedBorder: OutlineInputBorder( @@ -2196,7 +2196,7 @@ class _DetailScreenState extends State ), padding: const EdgeInsets.symmetric(vertical: 16), elevation: _isSaveHovered ? 8 : 4, - shadowColor: baseColor.withOpacity(0.5), + shadowColor: baseColor.withValues(alpha: 0.5), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index eb8fad5..5da87eb 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -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 late AnimationController _pulseController; late AnimationController _waveController; late ScrollController _scrollController; - double _scrollOffset = 0; + late FloatingNavBarScrollController _navBarScrollController; + bool _isNavBarVisible = true; + + // ํ™”๋ฉด ๋ชฉ๋ก + late final List _screens; @override void initState() { @@ -67,12 +63,30 @@ class _MainScreenState extends State 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 ); _scrollController.dispose(); + _navBarScrollController.dispose(); super.dispose(); } @@ -136,307 +151,109 @@ class _MainScreenState extends State } } - void _navigateToSmsScan(BuildContext context) async { - final added = await Navigator.push( - context, - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - const SmsScanScreen(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return SlideTransition( - position: Tween( - 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( - 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(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( - begin: const Offset(1, 0), - end: Offset.zero, - ).animate(animation), - child: child, - ); - }, - ), - ); + void _handleNavigation(int index, BuildContext context) { + final navigationProvider = context.read(); + + // ์ด๋ฏธ ๊ฐ™์€ ์ธ๋ฑ์Šค๋ฉด ๋ฌด์‹œ + 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()), - 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(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(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(0xFF3B82F6)), - ), - ); + final navigationProvider = context.watch(); + final hour = DateTime.now().hour; + List 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(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( - 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( - 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( - 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, ); } -} +} \ No newline at end of file diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index c24d21d..bb26510 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -4,6 +4,7 @@ import 'package:provider/provider.dart'; import '../providers/app_lock_provider.dart'; import '../providers/notification_provider.dart'; import '../providers/subscription_provider.dart'; +import '../providers/navigation_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:path_provider/path_provider.dart'; import 'dart:io'; @@ -11,6 +12,13 @@ import 'package:path/path.dart' as path; import '../services/notification_service.dart'; import '../screens/sms_scan_screen.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../providers/theme_provider.dart'; +import '../theme/adaptive_theme.dart'; +import '../widgets/glassmorphic_scaffold.dart'; +import '../widgets/glassmorphic_app_bar.dart'; +import '../widgets/glassmorphism_card.dart'; +import '../widgets/app_navigator.dart'; +import '../theme/app_colors.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -27,13 +35,13 @@ class SettingsScreen extends StatelessWidget { padding: const EdgeInsets.symmetric(vertical: 10), decoration: BoxDecoration( color: isSelected - ? Theme.of(context).colorScheme.primary.withOpacity(0.2) + ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2) : Colors.transparent, borderRadius: BorderRadius.circular(8), border: Border.all( color: isSelected ? Theme.of(context).colorScheme.primary - : Theme.of(context).colorScheme.outline.withOpacity(0.5), + : Theme.of(context).colorScheme.outline.withValues(alpha: 0.5), width: isSelected ? 2 : 1, ), ), @@ -130,12 +138,81 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('์„ค์ •'), - ), - body: ListView( + return ListView( + padding: const EdgeInsets.only(top: 20), children: [ + // ํ…Œ๋งˆ ์„ค์ • + GlassmorphismCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), + child: Consumer( + builder: (context, themeProvider, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.all(16), + child: Text( + 'ํ…Œ๋งˆ ์„ค์ •', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + // ํ…Œ๋งˆ ๋ชจ๋“œ ์„ ํƒ + ListTile( + title: const Text('ํ…Œ๋งˆ ๋ชจ๋“œ'), + subtitle: Text(_getThemeModeText(themeProvider.themeMode)), + leading: Icon( + _getThemeModeIcon(themeProvider.themeMode), + color: Theme.of(context).colorScheme.primary, + ), + trailing: DropdownButton( + value: themeProvider.themeMode, + underline: Container(), + onChanged: (mode) { + if (mode != null) { + themeProvider.setThemeMode(mode); + } + }, + items: AppThemeMode.values.map((mode) => + DropdownMenuItem( + value: mode, + child: Text(_getThemeModeText(mode)), + ), + ).toList(), + ), + ), + const Divider(height: 1), + + // ์ ‘๊ทผ์„ฑ ์„ค์ • + SwitchListTile( + title: const Text('ํฐ ํ…์ŠคํŠธ'), + subtitle: const Text('ํ…์ŠคํŠธ ํฌ๊ธฐ๋ฅผ ํฌ๊ฒŒ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค'), + secondary: const Icon(Icons.text_fields), + value: themeProvider.largeText, + onChanged: themeProvider.setLargeText, + ), + SwitchListTile( + title: const Text('๋ชจ์…˜ ๊ฐ์†Œ'), + subtitle: const Text('์• ๋‹ˆ๋ฉ”์ด์…˜ ํšจ๊ณผ๋ฅผ ์ค„์ž…๋‹ˆ๋‹ค'), + secondary: const Icon(Icons.slow_motion_video), + value: themeProvider.reduceMotion, + onChanged: themeProvider.setReduceMotion, + ), + SwitchListTile( + title: const Text('๊ณ ๋Œ€๋น„ ๋ชจ๋“œ'), + subtitle: const Text('๋” ์„ ๋ช…ํ•œ ์ƒ‰์ƒ์œผ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค'), + secondary: const Icon(Icons.contrast), + value: themeProvider.highContrast, + onChanged: themeProvider.setHighContrast, + ), + ], + ); + }, + ), + ), // ์•ฑ ์ž ๊ธˆ ์„ค์ • UI ์ˆจ๊น€ // Card( // margin: const EdgeInsets.all(16), @@ -161,8 +238,9 @@ class SettingsScreen extends StatelessWidget { // ), // ์•Œ๋ฆผ ์„ค์ • - Card( - margin: const EdgeInsets.symmetric(horizontal: 16), + GlassmorphismCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), child: Consumer( builder: (context, provider, child) { return Column( @@ -211,7 +289,7 @@ class SettingsScreen extends StatelessWidget { color: Theme.of(context) .colorScheme .surfaceVariant - .withOpacity(0.3), + .withValues(alpha: 0.3), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), @@ -273,7 +351,7 @@ class SettingsScreen extends StatelessWidget { color: Theme.of(context) .colorScheme .outline - .withOpacity(0.5), + .withValues(alpha: 0.5), ), borderRadius: BorderRadius.circular(8), @@ -329,7 +407,7 @@ class SettingsScreen extends StatelessWidget { color: Theme.of(context) .colorScheme .surfaceVariant - .withOpacity(0.3), + .withValues(alpha: 0.3), borderRadius: BorderRadius.circular(8), ), @@ -377,8 +455,9 @@ class SettingsScreen extends StatelessWidget { ), // ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ - Card( - margin: const EdgeInsets.all(16), + GlassmorphismCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), child: Column( children: [ // ๋ฐ์ดํ„ฐ ๋ฐฑ์—… ๊ธฐ๋Šฅ ๋น„ํ™œ์„ฑํ™” @@ -389,108 +468,14 @@ class SettingsScreen extends StatelessWidget { // onTap: () => _backupData(context), // ), // const Divider(), - // SMS ์Šค์บ” - ์‹œ๊ฐ์ ์œผ๋กœ ๊ฐ•์กฐ๋œ UI - InkWell( - onTap: () => _navigateToSmsScan(context), - child: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Theme.of(context).primaryColor.withOpacity(0.1), - Theme.of(context).primaryColor.withOpacity(0.2), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(12), - bottomRight: Radius.circular(12), - ), - ), - child: Padding( - padding: const EdgeInsets.symmetric( - vertical: 16.0, horizontal: 8.0), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(12), - margin: const EdgeInsets.only(left: 8, right: 16), - decoration: BoxDecoration( - color: Theme.of(context) - .primaryColor - .withOpacity(0.15), - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - Icons.sms_rounded, - color: Theme.of(context).primaryColor, - size: 28, - ), - ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'SMS ์Šค์บ”์œผ๋กœ ๊ตฌ๋… ์ž๋™ ์ฐพ๊ธฐ', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 16, - color: Theme.of(context).primaryColor, - ), - ), - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(12), - ), - child: const Text( - '์ถ”์ฒœ', - style: TextStyle( - color: Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ], - ), - const SizedBox(height: 6), - const Text( - '2ํšŒ ์ด์ƒ ๋ฐ˜๋ณต ๊ฒฐ์ œ๋œ ๊ตฌ๋… ์„œ๋น„์Šค๋ฅผ ์ž๋™์œผ๋กœ ์ฐพ์•„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค', - style: TextStyle( - color: Colors.black54, - fontSize: 13, - ), - ), - ], - ), - ), - Icon( - Icons.arrow_forward_ios, - size: 16, - color: Theme.of(context).primaryColor, - ), - const SizedBox(width: 16), - ], - ), - ), - ), - ), ], ), ), // ์•ฑ ์ •๋ณด - Card( - margin: const EdgeInsets.symmetric(horizontal: 16), + GlassmorphismCard( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(8), child: ListTile( title: const Text('์•ฑ ์ •๋ณด'), subtitle: const Text('๋ฒ„์ „ 1.0.0'), @@ -554,8 +539,36 @@ class SettingsScreen extends StatelessWidget { }, ), ), + SizedBox( + height: 20 + MediaQuery.of(context).padding.bottom, // ํ•˜๋‹จ ์—ฌ๋ฐฑ + ), ], - ), - ); + ); + } + + String _getThemeModeText(AppThemeMode mode) { + switch (mode) { + case AppThemeMode.light: + return '๋ผ์ดํŠธ'; + case AppThemeMode.dark: + return '๋‹คํฌ'; + case AppThemeMode.oled: + return 'OLED ๋ธ”๋ž™'; + case AppThemeMode.system: + return '์‹œ์Šคํ…œ ์„ค์ •'; + } + } + + IconData _getThemeModeIcon(AppThemeMode mode) { + switch (mode) { + case AppThemeMode.light: + return Icons.light_mode; + case AppThemeMode.dark: + return Icons.dark_mode; + case AppThemeMode.oled: + return Icons.phonelink_lock; + case AppThemeMode.system: + return Icons.settings_brightness; + } } } diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 271c9a6..025d725 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -1,11 +1,17 @@ import 'package:flutter/material.dart'; import '../services/sms_scanner.dart'; import '../providers/subscription_provider.dart'; +import '../providers/navigation_provider.dart'; import 'package:provider/provider.dart'; import '../models/subscription.dart'; import '../models/subscription_model.dart'; import '../services/subscription_url_matcher.dart'; import 'package:intl/intl.dart'; // NumberFormat์„ ์‚ฌ์šฉํ•˜๊ธฐ ์œ„ํ•œ import ์ถ”๊ฐ€ +import '../widgets/glassmorphic_scaffold.dart'; +import '../widgets/glassmorphic_app_bar.dart'; +import '../widgets/glassmorphism_card.dart'; +import '../widgets/themed_text.dart'; +import '../theme/app_colors.dart'; class SmsScanScreen extends StatefulWidget { const SmsScanScreen({super.key}); @@ -100,8 +106,7 @@ class _SmsScanScreenState extends State { _filterDuplicates(repeatSubscriptions, existingSubscriptions); print('์ค‘๋ณต ์ œ๊ฑฐ ํ›„ ๊ตฌ๋…: ${filteredSubscriptions.length}๊ฐœ'); - if (filteredSubscriptions.isNotEmpty && - filteredSubscriptions[0] != null) { + if (filteredSubscriptions.isNotEmpty) { print( '์ฒซ ๋ฒˆ์งธ ํ•„ํ„ฐ๋ง๋œ ๊ตฌ๋…: ${filteredSubscriptions[0].serviceName}, ๋ฐ˜๋ณต ํšŸ์ˆ˜: ${filteredSubscriptions[0].repeatCount}'); } @@ -163,10 +168,6 @@ class _SmsScanScreenState extends State { // ์ค‘๋ณต๋˜์ง€ ์•Š์€ ๊ตฌ๋…๋งŒ ํ•„ํ„ฐ๋ง final nonDuplicates = scanned.where((scannedSub) { - if (scannedSub == null) { - print('_filterDuplicates: null ๊ตฌ๋… ๊ฐ์ฒด ๋ฐœ๊ฒฌ'); - return false; - } // ์„œ๋น„์Šค๋ช…๊ณผ ๊ธˆ์•ก์ด ๋™์ผํ•œ ๊ธฐ์กด ๊ตฌ๋… ์ฐพ๊ธฐ final hasDuplicate = existing.any((existingSub) => @@ -189,10 +190,6 @@ class _SmsScanScreenState extends State { for (int i = 0; i < nonDuplicates.length; i++) { final subscription = nonDuplicates[i]; - if (subscription == null) { - print('_filterDuplicates: null ๊ตฌ๋… ๊ฐ์ฒด ๋ฌด์‹œ'); - continue; - } String? websiteUrl = subscription.websiteUrl; @@ -252,11 +249,6 @@ class _SmsScanScreenState extends State { } final subscription = _scannedSubscriptions[_currentIndex]; - if (subscription == null) { - print('์˜ค๋ฅ˜: ํ˜„์žฌ ์ธ๋ฑ์Šค์˜ ๊ตฌ๋…์ด null์ž…๋‹ˆ๋‹ค. (index: $_currentIndex)'); - _moveToNextSubscription(); - return; - } final provider = Provider.of(context, listen: false); @@ -365,9 +357,38 @@ class _SmsScanScreenState extends State { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text('${subscription.serviceName} ๊ตฌ๋…์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), - backgroundColor: Colors.green, - duration: const Duration(seconds: 2), + content: Row( + children: [ + const Icon( + Icons.check_circle, + color: Colors.white, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + '${subscription.serviceName} ๊ตฌ๋…์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', + style: const 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, ), ); } @@ -402,21 +423,36 @@ class _SmsScanScreenState extends State { _currentIndex++; _websiteUrlController.text = ''; // URL ์ž…๋ ฅ ํ•„๋“œ ์ดˆ๊ธฐํ™” - // ๋ชจ๋“  ๊ตฌ๋…์„ ์ฒ˜๋ฆฌํ–ˆ์œผ๋ฉด ํ™”๋ฉด ์ข…๋ฃŒ + // ๋ชจ๋“  ๊ตฌ๋…์„ ์ฒ˜๋ฆฌํ–ˆ์œผ๋ฉด ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ if (_currentIndex >= _scannedSubscriptions.length) { - Navigator.of(context).pop(true); + _navigateToHome(); } }); } + // ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ + void _navigateToHome() { + // NavigationProvider๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ํ™ˆ ํ™”๋ฉด์œผ๋กœ ์ด๋™ + final navigationProvider = Provider.of(context, listen: false); + navigationProvider.updateCurrentIndex(0); + + // ์™„๋ฃŒ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('๋ชจ๋“  ๊ตฌ๋…์ด ์ฒ˜๋ฆฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), + backgroundColor: Colors.green, + duration: Duration(seconds: 2), + ), + ); + } + // ๋‚ ์งœ ์ƒํƒœ ํ…์ŠคํŠธ ๊ฐ€์ ธ์˜ค๊ธฐ String _getNextBillingText(DateTime date) { final now = DateTime.now(); if (date.isBefore(now)) { // ์ฃผ๊ธฐ์— ๋”ฐ๋ผ ๋‹ค์Œ ๊ฒฐ์ œ์ผ ์˜ˆ์ธก - if (_currentIndex >= _scannedSubscriptions.length || - _scannedSubscriptions[_currentIndex] == null) { + if (_currentIndex >= _scannedSubscriptions.length) { return '๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ™•์ธ ํ•„์š”'; } @@ -485,17 +521,13 @@ class _SmsScanScreenState extends State { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('SMS ์Šค์บ”'), - ), - body: Padding( - padding: const EdgeInsets.all(16.0), - child: _isLoading - ? _buildLoadingState() - : (_scannedSubscriptions.isEmpty - ? _buildInitialState() - : _buildSubscriptionState())), + return Padding( + padding: const EdgeInsets.all(16.0), + child: _isLoading + ? _buildLoadingState() + : (_scannedSubscriptions.isEmpty + ? _buildInitialState() + : _buildSubscriptionState()), ); } @@ -507,9 +539,9 @@ class _SmsScanScreenState extends State { children: [ CircularProgressIndicator(), SizedBox(height: 16), - Text('SMS ๋ฉ”์‹œ์ง€๋ฅผ ์Šค์บ” ์ค‘์ž…๋‹ˆ๋‹ค...'), + ThemedText('SMS ๋ฉ”์‹œ์ง€๋ฅผ ์Šค์บ” ์ค‘์ž…๋‹ˆ๋‹ค...'), SizedBox(height: 8), - Text('๊ตฌ๋… ์„œ๋น„์Šค๋ฅผ ์ฐพ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค', style: TextStyle(color: Colors.grey)), + ThemedText('๊ตฌ๋… ์„œ๋น„์Šค๋ฅผ ์ฐพ๊ณ  ์žˆ์Šต๋‹ˆ๋‹ค', opacity: 0.7), ], ), ); @@ -524,24 +556,25 @@ class _SmsScanScreenState extends State { if (_errorMessage != null) Padding( padding: const EdgeInsets.all(16.0), - child: Text( + child: ThemedText( _errorMessage!, - style: const TextStyle(color: Colors.red), + color: Colors.red, textAlign: TextAlign.center, ), ), const SizedBox(height: 24), - const Text( + const ThemedText( '2ํšŒ ์ด์ƒ ๊ฒฐ์ œ๋œ ๊ตฌ๋… ์„œ๋น„์Šค ์ฐพ๊ธฐ', - style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + fontSize: 20, + fontWeight: FontWeight.bold, ), const SizedBox(height: 16), const Padding( padding: EdgeInsets.symmetric(horizontal: 32.0), - child: Text( + child: ThemedText( '๋ฌธ์ž ๋ฉ”์‹œ์ง€๋ฅผ ์Šค์บ”ํ•˜์—ฌ ๋ฐ˜๋ณต์ ์œผ๋กœ ๊ฒฐ์ œ๋œ ๊ตฌ๋… ์„œ๋น„์Šค๋ฅผ ์ž๋™์œผ๋กœ ์ฐพ์Šต๋‹ˆ๋‹ค. ์„œ๋น„์Šค๋ช…๊ณผ ๊ธˆ์•ก์„ ์ถ”์ถœํ•˜์—ฌ ์‰ฝ๊ฒŒ ๊ตฌ๋…์„ ์ถ”๊ฐ€ํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.', textAlign: TextAlign.center, - style: TextStyle(color: Colors.grey), + opacity: 0.7, ), ), const SizedBox(height: 32), @@ -562,26 +595,11 @@ class _SmsScanScreenState extends State { Widget _buildSubscriptionState() { if (_currentIndex >= _scannedSubscriptions.length) { return const Center( - child: Text('๋ชจ๋“  ๊ตฌ๋… ์ฒ˜๋ฆฌ ์™„๋ฃŒ'), + child: ThemedText('๋ชจ๋“  ๊ตฌ๋… ์ฒ˜๋ฆฌ ์™„๋ฃŒ'), ); } final subscription = _scannedSubscriptions[_currentIndex]; - if (subscription == null) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('์˜ค๋ฅ˜: ๊ตฌ๋… ์ •๋ณด๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'), - SizedBox(height: 16), - ElevatedButton( - onPressed: _moveToNextSubscription, - child: Text('๊ฑด๋„ˆ๋›ฐ๊ธฐ'), - ), - ], - ), - ); - } // ๊ตฌ๋… ๋ฆฌ์ŠคํŠธ ์นด๋“œ๋ฅผ ํ‘œ์‹œํ•  ๋•Œ URL ํ•„๋“œ ์ž๋™ ์„ค์ • if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) { @@ -594,54 +612,42 @@ class _SmsScanScreenState extends State { // ์ง„ํ–‰ ์ƒํƒœ ํ‘œ์‹œ LinearProgressIndicator( value: (_currentIndex + 1) / _scannedSubscriptions.length, - backgroundColor: Colors.grey.withOpacity(0.2), + backgroundColor: Colors.grey.withValues(alpha: 0.2), valueColor: AlwaysStoppedAnimation( Theme.of(context).colorScheme.primary), ), const SizedBox(height: 8), - Text( + ThemedText( '${_currentIndex + 1}/${_scannedSubscriptions.length}', - style: TextStyle( - color: Colors.grey.shade600, - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, + opacity: 0.7, ), const SizedBox(height: 24), // ๊ตฌ๋… ์ •๋ณด ์นด๋“œ - Card( - elevation: 4, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - child: Padding( - padding: const EdgeInsets.all(16.0), + GlassmorphismCard( + width: double.infinity, + padding: const EdgeInsets.all(16.0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + const ThemedText( '๋‹ค์Œ ๊ตฌ๋…์„ ์ฐพ์•˜์Šต๋‹ˆ๋‹ค', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + fontSize: 18, + fontWeight: FontWeight.bold, ), const SizedBox(height: 24), // ์„œ๋น„์Šค๋ช… - const Text( + const ThemedText( '์„œ๋น„์Šค๋ช…', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, + opacity: 0.7, ), const SizedBox(height: 4), - Text( + ThemedText( subscription.serviceName, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.bold, - ), + fontSize: 22, + fontWeight: FontWeight.bold, ), const SizedBox(height: 16), @@ -652,15 +658,13 @@ class _SmsScanScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + const ThemedText( '์›” ๋น„์šฉ', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, + opacity: 0.7, ), const SizedBox(height: 4), - Text( + ThemedText( subscription.currency == 'USD' ? NumberFormat.currency( locale: 'en_US', @@ -672,10 +676,8 @@ class _SmsScanScreenState extends State { symbol: 'โ‚ฉ', decimalDigits: 0, ).format(subscription.monthlyCost), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), + fontSize: 18, + fontWeight: FontWeight.bold, ), ], ), @@ -684,21 +686,17 @@ class _SmsScanScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + const ThemedText( '๋ฐ˜๋ณต ํšŸ์ˆ˜', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, + opacity: 0.7, ), const SizedBox(height: 4), - Text( + ThemedText( _getRepeatCountText(subscription.repeatCount), - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.secondary, - ), + fontSize: 16, + fontWeight: FontWeight.w500, + color: Theme.of(context).colorScheme.secondary, ), ], ), @@ -714,20 +712,16 @@ class _SmsScanScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + const ThemedText( '๊ฒฐ์ œ ์ฃผ๊ธฐ', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, + opacity: 0.7, ), const SizedBox(height: 4), - Text( + ThemedText( subscription.billingCycle, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - ), + fontSize: 16, + fontWeight: FontWeight.w500, ), ], ), @@ -736,20 +730,16 @@ class _SmsScanScreenState extends State { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( + const ThemedText( '๊ฒฐ์ œ์ผ', - style: TextStyle( - color: Colors.grey, - fontWeight: FontWeight.w500, - ), + fontWeight: FontWeight.w500, + opacity: 0.7, ), const SizedBox(height: 4), - Text( + ThemedText( _getNextBillingText(subscription.nextBillingDate), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), + fontSize: 14, + fontWeight: FontWeight.w500, ), ], ), @@ -800,7 +790,6 @@ class _SmsScanScreenState extends State { ], ), ), - ), ], ); } @@ -809,8 +798,7 @@ class _SmsScanScreenState extends State { void didChangeDependencies() { super.didChangeDependencies(); if (_scannedSubscriptions.isNotEmpty && - _currentIndex < _scannedSubscriptions.length && - _scannedSubscriptions[_currentIndex] != null) { + _currentIndex < _scannedSubscriptions.length) { final currentSub = _scannedSubscriptions[_currentIndex]; if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) { _websiteUrlController.text = currentSub.websiteUrl!; diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index 4f8d797..e2121c5 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,8 +1,12 @@ import 'dart:async'; +import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/app_lock_provider.dart'; +import '../providers/navigation_provider.dart'; import '../theme/app_colors.dart'; +import '../widgets/glassmorphism_card.dart'; +import '../routes/app_routes.dart'; import 'app_lock_screen.dart'; import 'main_screen.dart'; @@ -101,18 +105,10 @@ class _SplashScreenState extends State void navigateToNextScreen() { // ์•ฑ ์ž ๊ธˆ ๊ธฐ๋Šฅ ๋น„ํ™œ์„ฑํ™”: ํ•ญ์ƒ MainScreen์œผ๋กœ ์ด๋™ - Navigator.of(context).pushReplacement( - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - const MainScreen(), - transitionsBuilder: (context, animation, secondaryAnimation, child) { - return FadeTransition( - opacity: animation, - child: child, - ); - }, - transitionDuration: const Duration(milliseconds: 500), - ), + // ๋ชจ๋“  ์ด์ „ ๋ผ์šฐํŠธ๋ฅผ ์ œ๊ฑฐํ•˜๊ณ  ํ™ˆ์œผ๋กœ ์ด๋™ + Navigator.of(context).pushNamedAndRemoveUntil( + AppRoutes.main, + (route) => false, ); } @@ -127,244 +123,305 @@ class _SplashScreenState extends State final size = MediaQuery.of(context).size; return Scaffold( - body: Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: AppColors.blueGradient, + body: Stack( + children: [ + // ๋ฐฐ๊ฒฝ ๊ทธ๋ผ๋””์–ธํŠธ + Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.dayGradient[0], + AppColors.dayGradient[1], + ], + ), + ), ), - ), - child: Stack( - children: [ - // ๋ฐฐ๊ฒฝ ํŒŒํ‹ฐํด - ..._particles.map((particle) { - return AnimatedPositioned( - duration: Duration(milliseconds: particle['duration'].toInt()), - curve: Curves.easeInOut, - left: particle['x'] - 50 + (size.width * 0.1), - top: particle['y'] - 50 + (size.height * 0.1), - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: particle['opacity']), + // ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ์˜ค๋ฒ„๋ ˆ์ด + Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + ), + ), + Stack( + children: [ + // ๋ฐฐ๊ฒฝ ํŒŒํ‹ฐํด + ..._particles.map((particle) { + return AnimatedPositioned( duration: Duration(milliseconds: particle['duration'].toInt()), - builder: (context, value, child) { - return Opacity( - opacity: value, - child: child, - ); - }, - child: Container( - width: particle['size'], - height: particle['size'], - decoration: BoxDecoration( - color: particle['color'], - shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: particle['color'].withOpacity(0.3), - blurRadius: 10, - spreadRadius: 1, - ), - ], - ), - ), - ), - ); - }).toList(), - - // ์ƒ๋‹จ ์›ํ˜• ๊ทธ๋ผ๋ฐ์ด์…˜ - Positioned( - top: -size.height * 0.2, - right: -size.width * 0.2, - child: Container( - width: size.width * 0.8, - height: size.width * 0.8, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - Colors.white.withOpacity(0.1), - Colors.white.withOpacity(0.0), - ], - stops: const [0.2, 1.0], - ), - ), - ), - ), - - // ํ•˜๋‹จ ์›ํ˜• ๊ทธ๋ผ๋ฐ์ด์…˜ - Positioned( - bottom: -size.height * 0.1, - left: -size.width * 0.3, - child: Container( - width: size.width * 0.9, - height: size.width * 0.9, - decoration: BoxDecoration( - shape: BoxShape.circle, - gradient: RadialGradient( - colors: [ - Colors.white.withOpacity(0.07), - Colors.white.withOpacity(0.0), - ], - stops: const [0.4, 1.0], - ), - ), - ), - ), - - // ๋ฉ”์ธ ์ฝ˜ํ…์ธ  - Column( - children: [ - Expanded( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - // ๋กœ๊ณ  ์• ๋‹ˆ๋ฉ”์ด์…˜ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Transform.scale( - scale: _scaleAnimation.value, - child: Transform.rotate( - angle: _rotateAnimation.value, - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: 120, - height: 120, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(30), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.2), - spreadRadius: 0, - blurRadius: 20, - offset: const Offset(0, 10), - ), - ], - ), - child: Center( - child: AnimatedBuilder( - animation: _animationController, - builder: (context, _) { - return ShaderMask( - blendMode: BlendMode.srcIn, - shaderCallback: (bounds) => - LinearGradient( - colors: AppColors.blueGradient, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(bounds), - child: Icon( - Icons.subscriptions_outlined, - size: 64, - color: Theme.of(context) - .primaryColor, - ), - ); - }), - ), - ), - ), - ); - }, - ), - - const SizedBox(height: 40), - - // ์•ฑ ์ด๋ฆ„ ํ…์ŠคํŠธ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Opacity( - opacity: _fadeAnimation.value, - child: Transform.translate( - offset: Offset(0, _slideAnimation.value), - child: child, - ), - ); - }, - child: const Text( - 'SubManager', - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.bold, - color: Colors.white, - letterSpacing: 1.2, - ), + curve: Curves.easeInOut, + left: particle['x'] - 50 + (size.width * 0.1), + top: particle['y'] - 50 + (size.height * 0.1), + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: particle['opacity']), + duration: + Duration(milliseconds: particle['duration'].toInt()), + builder: (context, value, child) { + return Opacity( + opacity: value, + child: child, + ); + }, + child: Container( + width: particle['size'], + height: particle['size'], + decoration: BoxDecoration( + color: particle['color'], + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: particle['color'].withValues(alpha: 0.3), + blurRadius: 10, + spreadRadius: 1, ), - ), - - const SizedBox(height: 16), - - // ๋ถ€์ œ๋ชฉ ํ…์ŠคํŠธ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Opacity( - opacity: _fadeAnimation.value, - child: Transform.translate( - offset: Offset(0, _slideAnimation.value * 1.2), - child: child, - ), - ); - }, - child: const Text( - '๊ตฌ๋… ์„œ๋น„์Šค ๊ด€๋ฆฌ๋ฅผ ๋” ์‰ฝ๊ฒŒ', - style: TextStyle( - fontSize: 16, - color: Colors.white70, - letterSpacing: 0.5, - ), - ), - ), - - const SizedBox(height: 60), - - // ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ - FadeTransition( - opacity: _fadeAnimation, - child: Container( - width: 60, - height: 60, - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), - borderRadius: BorderRadius.circular(50), - ), - child: const CircularProgressIndicator( - valueColor: - AlwaysStoppedAnimation(Colors.white), - strokeWidth: 3, - ), - ), - ), - ], - ), - ), - ), - - // ์นดํ”ผ๋ผ์ดํŠธ ํ…์ŠคํŠธ - Padding( - padding: const EdgeInsets.only(bottom: 24.0), - child: FadeTransition( - opacity: _fadeAnimation, - child: const Text( - 'ยฉ 2023 CClabs. All rights reserved.', - style: TextStyle( - fontSize: 12, - color: Colors.white60, - letterSpacing: 0.5, + ], ), ), ), + ); + }).toList(), + + // ์ƒ๋‹จ ์›ํ˜• ๊ทธ๋ผ๋ฐ์ด์…˜ + Positioned( + top: -size.height * 0.2, + right: -size.width * 0.2, + child: Container( + width: size.width * 0.8, + height: size.width * 0.8, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.white.withValues(alpha: 0.1), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0.2, 1.0], + ), + ), ), - ], - ), - ], - ), + ), + + // ํ•˜๋‹จ ์›ํ˜• ๊ทธ๋ผ๋ฐ์ด์…˜ + Positioned( + bottom: -size.height * 0.1, + left: -size.width * 0.3, + child: Container( + width: size.width * 0.9, + height: size.width * 0.9, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: RadialGradient( + colors: [ + Colors.white.withValues(alpha: 0.07), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0.4, 1.0], + ), + ), + ), + ), + + // ๋ฉ”์ธ ์ฝ˜ํ…์ธ  + Column( + children: [ + Expanded( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + // ๋กœ๊ณ  ์• ๋‹ˆ๋ฉ”์ด์…˜ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Transform.rotate( + angle: _rotateAnimation.value, + child: AnimatedContainer( + duration: + const Duration(milliseconds: 200), + width: 120, + height: 120, + child: ClipRRect( + borderRadius: + BorderRadius.circular(30), + child: BackdropFilter( + filter: ImageFilter.blur( + sigmaX: 20, sigmaY: 20), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white + .withValues(alpha: 0.2), + Colors.white + .withValues(alpha: 0.1), + ], + ), + borderRadius: + BorderRadius.circular(30), + border: Border.all( + color: Colors.white + .withValues(alpha: 0.3), + width: 1.5, + ), + boxShadow: [ + BoxShadow( + color: Colors.black + .withValues(alpha: 0.1), + spreadRadius: 0, + blurRadius: 30, + offset: const Offset(0, 10), + ), + ], + ), + child: Center( + child: AnimatedBuilder( + animation: + _animationController, + builder: (context, _) { + return ShaderMask( + blendMode: + BlendMode.srcIn, + shaderCallback: + (bounds) => + LinearGradient( + colors: AppColors + .blueGradient, + begin: + Alignment.topLeft, + end: Alignment + .bottomRight, + ).createShader(bounds), + child: Icon( + Icons + .subscriptions_outlined, + size: 64, + color: + Theme.of(context) + .primaryColor, + ), + ); + }), + ), + ), + ), + )), + )); + }, + ), + + const SizedBox(height: 40), + + // ์•ฑ ์ด๋ฆ„ ํ…์ŠคํŠธ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: Transform.translate( + offset: Offset(0, _slideAnimation.value), + child: child, + ), + ); + }, + child: const Text( + 'SubManager', + style: TextStyle( + fontSize: 36, + fontWeight: FontWeight.bold, + color: Colors.white, + letterSpacing: 1.2, + ), + ), + ), + + const SizedBox(height: 16), + + // ๋ถ€์ œ๋ชฉ ํ…์ŠคํŠธ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Opacity( + opacity: _fadeAnimation.value, + child: Transform.translate( + offset: + Offset(0, _slideAnimation.value * 1.2), + child: child, + ), + ); + }, + child: const Text( + '๊ตฌ๋… ์„œ๋น„์Šค ๊ด€๋ฆฌ๋ฅผ ๋” ์‰ฝ๊ฒŒ', + style: TextStyle( + fontSize: 16, + color: Colors.white70, + letterSpacing: 0.5, + ), + ), + ), + + const SizedBox(height: 60), + + // ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ + FadeTransition( + opacity: _fadeAnimation, + child: ClipRRect( + borderRadius: BorderRadius.circular(50), + child: BackdropFilter( + filter: + ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: Container( + width: 60, + height: 60, + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(50), + border: Border.all( + color: + Colors.white.withValues(alpha: 0.2), + width: 1, + ), + ), + child: const CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation( + Colors.white), + strokeWidth: 3, + ), + ), + ), + ), + ), + ], + ), + ), + ), + + // ์นดํ”ผ๋ผ์ดํŠธ ํ…์ŠคํŠธ + Padding( + padding: const EdgeInsets.only(bottom: 24.0), + child: FadeTransition( + opacity: _fadeAnimation, + child: const Text( + 'ยฉ 2023 CClabs. All rights reserved.', + style: TextStyle( + fontSize: 12, + color: Colors.white60, + letterSpacing: 0.5, + ), + ), + ), + ), + ], + ), + ], + ), + ], ), ); } diff --git a/lib/services/currency_util.dart b/lib/services/currency_util.dart index 122897c..318aa57 100644 --- a/lib/services/currency_util.dart +++ b/lib/services/currency_util.dart @@ -131,4 +131,29 @@ class CurrencyUtil { ).format(savings); } } + + /// ๊ธˆ์•ก๊ณผ ํ†ตํ™”๋ฅผ ๋ฐ›์•„ ํฌ๋งทํŒ…ํ•˜์—ฌ ๋ฐ˜ํ™˜ + static Future formatAmount(double amount, String currency) async { + if (currency == 'USD') { + // USD ํ‘œ์‹œ + ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก + final usdFormatted = NumberFormat.currency( + locale: 'en_US', + symbol: '\$', + decimalDigits: 2, + ).format(amount); + + // ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก + final krwAmount = await _exchangeRateService + .getFormattedKrwAmount(amount); + + return '$usdFormatted $krwAmount'; + } else { + // ์›ํ™” ํ‘œ์‹œ + return NumberFormat.currency( + locale: 'ko_KR', + symbol: 'โ‚ฉ', + decimalDigits: 0, + ).format(amount); + } + } } diff --git a/lib/theme/adaptive_theme.dart b/lib/theme/adaptive_theme.dart new file mode 100644 index 0000000..cc93e95 --- /dev/null +++ b/lib/theme/adaptive_theme.dart @@ -0,0 +1,379 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'app_colors.dart'; +import 'app_theme.dart'; + +/// ์ ์‘ํ˜• ํ…Œ๋งˆ ๊ด€๋ฆฌ ํด๋ž˜์Šค +class AdaptiveTheme { + /// ๋ผ์ดํŠธ ํ…Œ๋งˆ + static ThemeData get lightTheme => AppTheme.lightTheme; + + /// ๋‹คํฌ ํ…Œ๋งˆ + static ThemeData get darkTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.dark, + colorScheme: ColorScheme.dark( + primary: AppColors.primaryColor, + onPrimary: Colors.white, + secondary: AppColors.secondaryColor, + tertiary: AppColors.infoColor, + error: AppColors.dangerColor, + background: const Color(0xFF121212), + surface: const Color(0xFF1E1E1E), + ), + + scaffoldBackgroundColor: const Color(0xFF121212), + + cardTheme: CardTheme( + color: const Color(0xFF1E1E1E), + elevation: 2, + shadowColor: Colors.black.withValues(alpha: 0.3), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5), + ), + clipBehavior: Clip.antiAlias, + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + ), + + appBarTheme: AppBarTheme( + backgroundColor: const Color(0xFF1E1E1E), + foregroundColor: Colors.white, + elevation: 0, + centerTitle: false, + titleTextStyle: const TextStyle( + color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + iconTheme: IconThemeData( + color: Colors.white.withValues(alpha: 0.9), + size: 24, + ), + ), + + textTheme: TextTheme( + headlineLarge: const TextStyle( + color: Colors.white, + fontSize: 32, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ), + headlineMedium: const TextStyle( + color: Colors.white, + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + height: 1.2, + ), + headlineSmall: const TextStyle( + color: Colors.white, + fontSize: 24, + fontWeight: FontWeight.w600, + letterSpacing: -0.25, + height: 1.3, + ), + titleLarge: const TextStyle( + color: Colors.white, + fontSize: 20, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + height: 1.4, + ), + titleMedium: const TextStyle( + color: Colors.white, + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.1, + height: 1.4, + ), + titleSmall: const TextStyle( + color: Colors.white, + fontSize: 16, + fontWeight: FontWeight.w600, + letterSpacing: 0, + height: 1.4, + ), + bodyLarge: TextStyle( + color: Colors.white.withValues(alpha: 0.9), + fontSize: 16, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.5, + ), + bodyMedium: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 14, + fontWeight: FontWeight.w400, + letterSpacing: 0.1, + height: 1.5, + ), + bodySmall: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 12, + fontWeight: FontWeight.w400, + letterSpacing: 0.2, + height: 1.5, + ), + ), + + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: const Color(0xFF2A2A2A), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: AppColors.dangerColor, width: 1), + ), + labelStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 14, + fontWeight: FontWeight.w500, + ), + hintStyle: TextStyle( + color: Colors.white.withValues(alpha: 0.5), + fontSize: 14, + fontWeight: FontWeight.w400, + ), + ), + + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: AppColors.primaryColor, + foregroundColor: Colors.white, + minimumSize: const Size(0, 48), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + elevation: 0, + ), + ), + + dividerTheme: DividerThemeData( + color: Colors.white.withValues(alpha: 0.1), + thickness: 1, + space: 16, + ), + ); + } + + /// OLED ์ตœ์ ํ™” ๋‹คํฌ ํ…Œ๋งˆ + static ThemeData get oledTheme { + return darkTheme.copyWith( + scaffoldBackgroundColor: Colors.black, + colorScheme: darkTheme.colorScheme.copyWith( + background: Colors.black, + surface: const Color(0xFF0A0A0A), + ), + cardTheme: darkTheme.cardTheme.copyWith( + color: const Color(0xFF0A0A0A), + ), + appBarTheme: darkTheme.appBarTheme.copyWith( + backgroundColor: Colors.black, + ), + inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith( + fillColor: const Color(0xFF0A0A0A), + ), + ); + } + + /// ๊ณ ๋Œ€๋น„ ํ…Œ๋งˆ + static ThemeData get highContrastTheme { + return ThemeData( + useMaterial3: true, + brightness: Brightness.light, + colorScheme: const ColorScheme.highContrastLight( + primary: Colors.black, + secondary: Colors.black87, + tertiary: Colors.black54, + error: Colors.red, + background: Colors.white, + surface: Colors.white, + ), + + textTheme: const TextTheme( + headlineLarge: TextStyle( + color: Colors.black, + fontSize: 32, + fontWeight: FontWeight.w900, + ), + headlineMedium: TextStyle( + color: Colors.black, + fontSize: 28, + fontWeight: FontWeight.w900, + ), + headlineSmall: TextStyle( + color: Colors.black, + fontSize: 24, + fontWeight: FontWeight.w800, + ), + bodyLarge: TextStyle( + color: Colors.black, + fontSize: 16, + fontWeight: FontWeight.w600, + ), + bodyMedium: TextStyle( + color: Colors.black87, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + + cardTheme: CardTheme( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + side: const BorderSide(color: Colors.black, width: 2), + ), + ), + + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.black, + foregroundColor: Colors.white, + side: const BorderSide(color: Colors.black, width: 2), + textStyle: const TextStyle( + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } + + /// ์‹œ์Šคํ…œ ํ…Œ๋งˆ์— ๋”ฐ๋ฅธ ์ƒํƒœ๋ฐ” ์Šคํƒ€์ผ ์ ์šฉ + static void applySystemUIOverlay(BuildContext context) { + final brightness = Theme.of(context).brightness; + final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black; + + SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle( + statusBarColor: Colors.transparent, + statusBarIconBrightness: brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + statusBarBrightness: brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + systemNavigationBarColor: isOled + ? Colors.black + : (brightness == Brightness.dark + ? const Color(0xFF121212) + : Colors.white), + systemNavigationBarIconBrightness: brightness == Brightness.dark + ? Brightness.light + : Brightness.dark, + )); + } + + /// ์ ‘๊ทผ์„ฑ ์„ค์ •์— ๋”ฐ๋ฅธ ํ…Œ๋งˆ ์กฐ์ • + static ThemeData getAccessibleTheme( + ThemeData baseTheme, { + required bool largeText, + required bool reduceMotion, + required bool highContrast, + }) { + if (highContrast) { + return highContrastTheme; + } + + ThemeData theme = baseTheme; + + if (largeText) { + theme = theme.copyWith( + textTheme: theme.textTheme.apply( + fontSizeFactor: 1.2, + ), + ); + } + + if (reduceMotion) { + theme = theme.copyWith( + pageTransitionsTheme: const PageTransitionsTheme( + builders: { + TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(), + TargetPlatform.iOS: FadeUpwardsPageTransitionsBuilder(), + }, + ), + ); + } + + return theme; + } +} + +/// ํ…Œ๋งˆ ๋ชจ๋“œ ์—ด๊ฑฐํ˜• +enum AppThemeMode { + light, + dark, + oled, + system, +} + +/// ํ…Œ๋งˆ ์„ค์ • ํด๋ž˜์Šค +class ThemeSettings { + final AppThemeMode mode; + final bool useSystemColors; + final bool largeText; + final bool reduceMotion; + final bool highContrast; + + const ThemeSettings({ + this.mode = AppThemeMode.system, + this.useSystemColors = false, + this.largeText = false, + this.reduceMotion = false, + this.highContrast = false, + }); + + ThemeSettings copyWith({ + AppThemeMode? mode, + bool? useSystemColors, + bool? largeText, + bool? reduceMotion, + bool? highContrast, + }) { + return ThemeSettings( + mode: mode ?? this.mode, + useSystemColors: useSystemColors ?? this.useSystemColors, + largeText: largeText ?? this.largeText, + reduceMotion: reduceMotion ?? this.reduceMotion, + highContrast: highContrast ?? this.highContrast, + ); + } + + Map toJson() => { + 'mode': mode.name, + 'useSystemColors': useSystemColors, + 'largeText': largeText, + 'reduceMotion': reduceMotion, + 'highContrast': highContrast, + }; + + factory ThemeSettings.fromJson(Map json) { + return ThemeSettings( + mode: AppThemeMode.values.firstWhere( + (mode) => mode.name == json['mode'], + orElse: () => AppThemeMode.system, + ), + useSystemColors: json['useSystemColors'] ?? false, + largeText: json['largeText'] ?? false, + reduceMotion: json['reduceMotion'] ?? false, + highContrast: json['highContrast'] ?? false, + ); + } +} \ No newline at end of file diff --git a/lib/theme/app_colors.dart b/lib/theme/app_colors.dart index 7076526..ee1f155 100644 --- a/lib/theme/app_colors.dart +++ b/lib/theme/app_colors.dart @@ -46,4 +46,49 @@ class AppColors { Color(0xFFF43F5E), Color(0xFFE11D48) ]; + + // Glassmorphism ํšจ๊ณผ๋ฅผ ์œ„ํ•œ ์ƒ‰์ƒ + static const glassSurface = Color(0x0FFFFFFF); // ๋งค์šฐ ์—ฐํ•œ ํฐ์ƒ‰ (6% opacity) + static const glassBackground = Color(0x1AFFFFFF); // ์—ฐํ•œ ํฐ์ƒ‰ (10% opacity) + static const glassCard = Color(0x33FFFFFF); // ๋ฐ˜ํˆฌ๋ช… ํฐ์ƒ‰ (20% opacity) + static const glassBorder = Color(0x4DFFFFFF); // ๋ฐ˜ํˆฌ๋ช… ํ…Œ๋‘๋ฆฌ (30% opacity) + static const glassOverlay = Color(0x0D000000); // ์—ฐํ•œ ๊ฒ€์ • ์˜ค๋ฒ„๋ ˆ์ด (5% opacity) + + // ๋‹คํฌ ๋ชจ๋“œ์šฉ Glassmorphism ์ƒ‰์ƒ + static const glassSurfaceDark = Color(0x0F000000); // ๋งค์šฐ ์—ฐํ•œ ๊ฒ€์ • (6% opacity) + static const glassBackgroundDark = Color(0x1A000000); // ์—ฐํ•œ ๊ฒ€์ • (10% opacity) + static const glassCardDark = Color(0x33000000); // ๋ฐ˜ํˆฌ๋ช… ๊ฒ€์ • (20% opacity) + static const glassBorderDark = Color(0x4D000000); // ๋ฐ˜ํˆฌ๋ช… ๊ฒ€์ • ํ…Œ๋‘๋ฆฌ (30% opacity) + + // ๋ฐฑ๋“œ๋กญ ๋ธ”๋Ÿฌ ํšจ๊ณผ๋ฅผ ์œ„ํ•œ ๊ทธ๋ผ๋””์–ธํŠธ + static const List glassGradient = [ + Color(0x1AFFFFFF), // 10% white + Color(0x0FFFFFFF), // 6% white + ]; + + static const List glassGradientDark = [ + Color(0x1A000000), // 10% black + Color(0x0F000000), // 6% black + ]; + + // ์‹œ๊ฐ„๋Œ€๋ณ„ ๋ฐฐ๊ฒฝ ๊ทธ๋ผ๋””์–ธํŠธ + static const List morningGradient = [ + Color(0xFFFED7AA), // ๋”ฐ๋œปํ•œ ์˜ค๋ Œ์ง€ + Color(0xFFFBBF24), // ๋ถ€๋“œ๋Ÿฌ์šด ๋…ธ๋ž‘ + ]; + + static const List dayGradient = [ + Color(0xFFDDEAFC), // ์—ฐํ•œ ํ•˜๋Š˜์ƒ‰ + Color(0xFFBFDBFE), // ๋ง‘์€ ํŒŒ๋ž‘ + ]; + + static const List eveningGradient = [ + Color(0xFFFCA5A5), // ๋ถ€๋“œ๋Ÿฌ์šด ํ•‘ํฌ + Color(0xFFC084FC), // ์—ฐํ•œ ๋ณด๋ผ + ]; + + static const List nightGradient = [ + Color(0xFF4338CA), // ๊นŠ์€ ์ธ๋””๊ณ  + Color(0xFF1E1B4B), // ๋‹คํฌ ๋„ค์ด๋น„ + ]; } diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index f6c710c..2753e98 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -21,7 +21,7 @@ class AppTheme { cardTheme: CardTheme( color: AppColors.cardColor, elevation: 1, - shadowColor: Colors.black.withOpacity(0.04), + shadowColor: Colors.black.withValues(alpha: 0.04), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), side: BorderSide(color: AppColors.borderColor, width: 0.5), @@ -265,7 +265,7 @@ class AppTheme { }), trackColor: MaterialStateProperty.resolveWith((states) { if (states.contains(MaterialState.selected)) { - return AppColors.primaryColor.withOpacity(0.5); + return AppColors.primaryColor.withValues(alpha: 0.5); } return AppColors.borderColor; }), @@ -300,7 +300,7 @@ class AppTheme { activeTrackColor: AppColors.primaryColor, inactiveTrackColor: AppColors.borderColor, thumbColor: AppColors.primaryColor, - overlayColor: AppColors.primaryColor.withOpacity(0.2), + overlayColor: AppColors.primaryColor.withValues(alpha: 0.2), trackHeight: 4, thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10), overlayShape: const RoundSliderOverlayShape(overlayRadius: 20), diff --git a/lib/utils/haptic_feedback_helper.dart b/lib/utils/haptic_feedback_helper.dart new file mode 100644 index 0000000..1062d73 --- /dev/null +++ b/lib/utils/haptic_feedback_helper.dart @@ -0,0 +1,74 @@ +import 'package:flutter/services.dart'; +import 'dart:io' show Platform; + +/// ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ์„ ๊ด€๋ฆฌํ•˜๋Š” ํ—ฌํผ ํด๋ž˜์Šค +class HapticFeedbackHelper { + static bool _isEnabled = true; + + /// ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ ํ™œ์„ฑํ™” ์—ฌ๋ถ€ ์„ค์ • + static void setEnabled(bool enabled) { + _isEnabled = enabled; + } + + /// ๊ฐ€๋ฒผ์šด ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ + static Future lightImpact() async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.lightImpact(); + } + + /// ์ค‘๊ฐ„ ๊ฐ•๋„ ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ + static Future mediumImpact() async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.mediumImpact(); + } + + /// ๊ฐ•ํ•œ ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ + static Future heavyImpact() async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.heavyImpact(); + } + + /// ์„ ํƒ ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ (iOS์˜ ๊ฒฝ์šฐ Taptic Engine) + static Future selectionClick() async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.selectionClick(); + } + + /// ์ง„๋™ ํŒจํ„ด (Android) + static Future vibrate({int duration = 50}) async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.vibrate(); + } + + /// ์„ฑ๊ณต ํ”ผ๋“œ๋ฐฑ ํŒจํ„ด + static Future success() async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.mediumImpact(); + await Future.delayed(const Duration(milliseconds: 100)); + await HapticFeedback.lightImpact(); + } + + /// ์—๋Ÿฌ ํ”ผ๋“œ๋ฐฑ ํŒจํ„ด + static Future error() async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.heavyImpact(); + await Future.delayed(const Duration(milliseconds: 100)); + await HapticFeedback.heavyImpact(); + } + + /// ๊ฒฝ๊ณ  ํ”ผ๋“œ๋ฐฑ ํŒจํ„ด + static Future warning() async { + if (!_isEnabled || !_isPlatformSupported()) return; + await HapticFeedback.mediumImpact(); + } + + /// ํ”Œ๋žซํผ์ด ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ์„ ์ง€์›ํ•˜๋Š”์ง€ ํ™•์ธ + static bool _isPlatformSupported() { + try { + return Platform.isIOS || Platform.isAndroid; + } catch (e) { + // ์›น์ด๋‚˜ ๋ฐ์Šคํฌํ†ฑ์—์„œ๋Š” Platform์„ ์‚ฌ์šฉํ•  ์ˆ˜ ์—†์Œ + return false; + } + } +} \ No newline at end of file diff --git a/lib/utils/memory_manager.dart b/lib/utils/memory_manager.dart new file mode 100644 index 0000000..7751f25 --- /dev/null +++ b/lib/utils/memory_manager.dart @@ -0,0 +1,287 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart'; +import 'dart:collection'; +import 'dart:async'; + +/// ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ๋ฅผ ์œ„ํ•œ ํ—ฌํผ ํด๋ž˜์Šค +class MemoryManager { + static final MemoryManager _instance = MemoryManager._internal(); + factory MemoryManager() => _instance; + MemoryManager._internal(); + + // ์บ์‹œ ๊ด€๋ฆฌ + final Map _cache = {}; + final int _maxCacheSize = 100; + final Duration _defaultTTL = const Duration(minutes: 5); + + // ์ด๋ฏธ์ง€ ์บ์‹œ ๊ด€๋ฆฌ + static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB + static const int maxImageCacheCount = 100; + + // ์œ„์ ฏ ์ฐธ์กฐ ์ถ”์  + final Map> _widgetReferences = {}; + + /// ์บ์‹œ์— ๋ฐ์ดํ„ฐ ์ €์žฅ + void cacheData({ + required String key, + required T data, + Duration? ttl, + }) { + _cleanupExpiredCache(); + + if (_cache.length >= _maxCacheSize) { + _evictOldestEntry(); + } + + _cache[key] = _CacheEntry( + data: data, + timestamp: DateTime.now(), + ttl: ttl ?? _defaultTTL, + ); + } + + /// ์บ์‹œ์—์„œ ๋ฐ์ดํ„ฐ ๊ฐ€์ ธ์˜ค๊ธฐ + T? getCachedData(String key) { + final entry = _cache[key]; + if (entry == null) return null; + + if (entry.isExpired) { + _cache.remove(key); + return null; + } + + entry.lastAccess = DateTime.now(); + return entry.data as T?; + } + + /// ์บ์‹œ ๋น„์šฐ๊ธฐ + void clearCache() { + _cache.clear(); + if (kDebugMode) { + print('๐Ÿงน ๋ฉ”๋ชจ๋ฆฌ ์บ์‹œ๊ฐ€ ๋น„์›Œ์กŒ์Šต๋‹ˆ๋‹ค.'); + } + } + + /// ํŠน์ • ํŒจํ„ด์˜ ์บ์‹œ ์ œ๊ฑฐ + void clearCacheByPattern(String pattern) { + final keysToRemove = _cache.keys + .where((key) => key.contains(pattern)) + .toList(); + + for (final key in keysToRemove) { + _cache.remove(key); + } + } + + /// ๋งŒ๋ฃŒ๋œ ์บ์‹œ ์ •๋ฆฌ + void _cleanupExpiredCache() { + final expiredKeys = _cache.entries + .where((entry) => entry.value.isExpired) + .map((entry) => entry.key) + .toList(); + + for (final key in expiredKeys) { + _cache.remove(key); + } + } + + /// ๊ฐ€์žฅ ์˜ค๋ž˜๋œ ์บ์‹œ ํ•ญ๋ชฉ ์ œ๊ฑฐ + void _evictOldestEntry() { + if (_cache.isEmpty) return; + + var oldestKey = _cache.keys.first; + var oldestTime = _cache[oldestKey]!.lastAccess; + + for (final entry in _cache.entries) { + if (entry.value.lastAccess.isBefore(oldestTime)) { + oldestKey = entry.key; + oldestTime = entry.value.lastAccess; + } + } + + _cache.remove(oldestKey); + } + + /// ์ด๋ฏธ์ง€ ์บ์‹œ ์ตœ์ ํ™” + static void optimizeImageCache() { + PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount; + PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize; + } + + /// ์ด๋ฏธ์ง€ ์บ์‹œ ์ƒํƒœ ํ™•์ธ + static ImageCacheStatus getImageCacheStatus() { + final cache = PaintingBinding.instance.imageCache; + return ImageCacheStatus( + currentSize: cache.currentSize, + maximumSize: cache.maximumSize, + currentSizeBytes: cache.currentSizeBytes, + maximumSizeBytes: cache.maximumSizeBytes, + ); + } + + /// ์ด๋ฏธ์ง€ ์บ์‹œ ๋น„์šฐ๊ธฐ + static void clearImageCache() { + PaintingBinding.instance.imageCache.clear(); + PaintingBinding.instance.imageCache.clearLiveImages(); + if (kDebugMode) { + print('๐Ÿ–ผ๏ธ ์ด๋ฏธ์ง€ ์บ์‹œ๊ฐ€ ๋น„์›Œ์กŒ์Šต๋‹ˆ๋‹ค.'); + } + } + + /// ์œ„์ ฏ ์ฐธ์กฐ ์ถ”์  + void trackWidget(String key, State widget) { + _widgetReferences[key] = WeakReference(widget); + } + + /// ์œ„์ ฏ ์ฐธ์กฐ ์ œ๊ฑฐ + void untrackWidget(String key) { + _widgetReferences.remove(key); + } + + /// ์‚ด์•„์žˆ๋Š” ์œ„์ ฏ ์ˆ˜ ํ™•์ธ + int getAliveWidgetCount() { + return _widgetReferences.values + .where((ref) => ref.target != null) + .length; + } + + /// ๋ฉ”๋ชจ๋ฆฌ ์••๋ฐ• ์‹œ ๋Œ€์‘ + void handleMemoryPressure() { + // ์บ์‹œ 50% ์ œ๊ฑฐ + final keysToRemove = _cache.keys.take(_cache.length ~/ 2).toList(); + for (final key in keysToRemove) { + _cache.remove(key); + } + + // ์ด๋ฏธ์ง€ ์บ์‹œ ์ถ•์†Œ + final imageCache = PaintingBinding.instance.imageCache; + imageCache.maximumSize = maxImageCacheCount ~/ 2; + imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2; + + if (kDebugMode) { + print('โš ๏ธ ๋ฉ”๋ชจ๋ฆฌ ์••๋ฐ• ๋Œ€์‘: ์บ์‹œ ํฌ๊ธฐ ๊ฐ์†Œ'); + } + } + + /// ์ž๋™ ๋ฉ”๋ชจ๋ฆฌ ์ •๋ฆฌ ์‹œ์ž‘ + Timer? _cleanupTimer; + + void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) { + _cleanupTimer?.cancel(); + _cleanupTimer = Timer.periodic(interval, (_) { + _cleanupExpiredCache(); + + // ์ฃฝ์€ ์œ„์ ฏ ์ฐธ์กฐ ์ œ๊ฑฐ + final deadKeys = _widgetReferences.entries + .where((entry) => entry.value.target == null) + .map((entry) => entry.key) + .toList(); + + for (final key in deadKeys) { + _widgetReferences.remove(key); + } + }); + } + + /// ์ž๋™ ๋ฉ”๋ชจ๋ฆฌ ์ •๋ฆฌ ์ค‘์ง€ + void stopAutoCleanup() { + _cleanupTimer?.cancel(); + _cleanupTimer = null; + } + + /// ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ๋ฆฌํฌํŠธ + Map getMemoryReport() { + return { + 'cacheSize': _cache.length, + 'maxCacheSize': _maxCacheSize, + 'aliveWidgets': getAliveWidgetCount(), + 'totalWidgetReferences': _widgetReferences.length, + 'imageCacheStatus': getImageCacheStatus().toJson(), + }; + } +} + +/// ์บ์‹œ ํ•ญ๋ชฉ ํด๋ž˜์Šค +class _CacheEntry { + final dynamic data; + final DateTime timestamp; + final Duration ttl; + DateTime lastAccess; + + _CacheEntry({ + required this.data, + required this.timestamp, + required this.ttl, + }) : lastAccess = timestamp; + + bool get isExpired => DateTime.now().difference(timestamp) > ttl; +} + +/// ์ด๋ฏธ์ง€ ์บ์‹œ ์ƒํƒœ ํด๋ž˜์Šค +class ImageCacheStatus { + final int currentSize; + final int maximumSize; + final int currentSizeBytes; + final int maximumSizeBytes; + + ImageCacheStatus({ + required this.currentSize, + required this.maximumSize, + required this.currentSizeBytes, + required this.maximumSizeBytes, + }); + + double get sizeUsagePercentage => (currentSize / maximumSize) * 100; + double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100; + + Map toJson() => { + 'currentSize': currentSize, + 'maximumSize': maximumSize, + 'currentSizeBytes': currentSizeBytes, + 'maximumSizeBytes': maximumSizeBytes, + 'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2), + 'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2), + }; +} + +/// ๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ์ ์ธ ๋ฆฌ์ŠคํŠธ ๋ทฐ +class MemoryEfficientListView extends StatefulWidget { + final List items; + final Widget Function(BuildContext, T) itemBuilder; + final int cacheExtent; + final ScrollPhysics? physics; + + const MemoryEfficientListView({ + super.key, + required this.items, + required this.itemBuilder, + this.cacheExtent = 250, + this.physics, + }); + + @override + State> createState() => + _MemoryEfficientListViewState(); +} + +class _MemoryEfficientListViewState + extends State> + with AutomaticKeepAliveClientMixin { + + @override + bool get wantKeepAlive => false; + + @override + Widget build(BuildContext context) { + super.build(context); + + return ListView.builder( + itemCount: widget.items.length, + cacheExtent: widget.cacheExtent.toDouble(), + physics: widget.physics ?? const BouncingScrollPhysics(), + itemBuilder: (context, index) { + return widget.itemBuilder(context, widget.items[index]); + }, + ); + } +} \ No newline at end of file diff --git a/lib/utils/performance_optimizer.dart b/lib/utils/performance_optimizer.dart new file mode 100644 index 0000000..3059806 --- /dev/null +++ b/lib/utils/performance_optimizer.dart @@ -0,0 +1,204 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/scheduler.dart'; +import 'dart:async'; +import 'dart:developer' as developer; + +/// ์„ฑ๋Šฅ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค +class PerformanceOptimizer { + static final PerformanceOptimizer _instance = PerformanceOptimizer._internal(); + factory PerformanceOptimizer() => _instance; + PerformanceOptimizer._internal(); + + // ํ”„๋ ˆ์ž„ ํƒ€์ด๋ฐ ์ •๋ณด + final List _frameTimings = []; + bool _isMonitoring = false; + + /// ํ”„๋ ˆ์ž„ ์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง ์‹œ์ž‘ + void startFrameMonitoring() { + if (_isMonitoring) return; + _isMonitoring = true; + + SchedulerBinding.instance.addTimingsCallback((timings) { + _frameTimings.addAll(timings); + // ์ตœ๊ทผ 100๊ฐœ ํ”„๋ ˆ์ž„๋งŒ ์œ ์ง€ + if (_frameTimings.length > 100) { + _frameTimings.removeRange(0, _frameTimings.length - 100); + } + }); + } + + /// ํ”„๋ ˆ์ž„ ์„ฑ๋Šฅ ๋ชจ๋‹ˆํ„ฐ๋ง ์ค‘์ง€ + void stopFrameMonitoring() { + if (!_isMonitoring) return; + _isMonitoring = false; + SchedulerBinding.instance.addTimingsCallback((_) {}); + } + + /// ํ‰๊ท  FPS ๊ณ„์‚ฐ + double getAverageFPS() { + if (_frameTimings.isEmpty) return 0.0; + + double totalDuration = 0; + for (final timing in _frameTimings) { + totalDuration += timing.totalSpan.inMicroseconds; + } + + final averageDuration = totalDuration / _frameTimings.length; + return 1000000 / averageDuration; // microseconds to FPS + } + + /// ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ๋ชจ๋‹ˆํ„ฐ๋ง + static Future getMemoryInfo() async { + // Flutter์—์„œ๋Š” ์ง์ ‘์ ์ธ ๋ฉ”๋ชจ๋ฆฌ ์‚ฌ์šฉ๋Ÿ‰ ์ธก์ •์ด ์ œํ•œ์ ์ด๋ฏ€๋กœ + // ์ด๋ฏธ์ง€ ์บ์‹œ ์‚ฌ์šฉ๋Ÿ‰์„ ๊ธฐ์ค€์œผ๋กœ ์ธก์ • + final imageCache = PaintingBinding.instance.imageCache; + return MemoryInfo( + currentUsage: imageCache.currentSizeBytes, + capacity: imageCache.maximumSizeBytes, + ); + } + + /// ์œ„์ ฏ ์žฌ๋นŒ๋“œ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ๋ฐ๋ฐ”์šด์„œ + static Timer? _debounceTimer; + static void debounce( + VoidCallback callback, { + Duration delay = const Duration(milliseconds: 300), + }) { + _debounceTimer?.cancel(); + _debounceTimer = Timer(delay, callback); + } + + /// ์Šค๋กœํ‹€๋ง - ์ง€์ •๋œ ์‹œ๊ฐ„ ๊ฐ„๊ฒฉ์œผ๋กœ๋งŒ ์‹คํ–‰ + static DateTime? _lastThrottleTime; + static void throttle( + VoidCallback callback, { + Duration interval = const Duration(milliseconds: 300), + }) { + final now = DateTime.now(); + if (_lastThrottleTime == null || + now.difference(_lastThrottleTime!) > interval) { + _lastThrottleTime = now; + callback(); + } + } + + /// ๋ฌด๊ฑฐ์šด ์—ฐ์‚ฐ์„ ๋ณ„๋„ Isolate์—์„œ ์‹คํ–‰ + static Future runInIsolate( + ComputeCallback callback, + dynamic parameter, + ) async { + return await compute(callback, parameter); + } + + /// ๋ ˆ์ด์ง€ ๋กœ๋”ฉ์„ ์œ„ํ•œ ํŽ˜์ด์ง€๋„ค์ด์…˜ ํ—ฌํผ + static List paginate({ + required List items, + required int page, + required int pageSize, + }) { + final startIndex = page * pageSize; + final endIndex = (startIndex + pageSize).clamp(0, items.length); + + if (startIndex >= items.length) return []; + return items.sublist(startIndex, endIndex); + } + + /// ์ด๋ฏธ์ง€ ์ตœ์ ํ™” - ๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ์ ์ธ ํฌ๊ธฐ๋กœ ์กฐ์ • + static double getOptimalImageSize(BuildContext context, { + required double originalSize, + double maxSize = 1000, + }) { + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final screenSize = MediaQuery.of(context).size; + final maxDimension = screenSize.width > screenSize.height + ? screenSize.width + : screenSize.height; + + final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize); + return optimalSize < originalSize ? optimalSize : originalSize; + } + + /// ์œ„์ ฏ ํ‚ค ์ตœ์ ํ™” + static Key generateOptimizedKey(String prefix, dynamic identifier) { + return ValueKey('${prefix}_$identifier'); + } + + /// ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ตœ์ ํ™” - ๋ณด์ด์ง€ ์•Š๋Š” ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ค‘์ง€ + static bool shouldAnimateWidget(BuildContext context) { + final mediaQuery = MediaQuery.of(context); + return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation; + } + + /// ์Šคํฌ๋กค ์„ฑ๋Šฅ ์ตœ์ ํ™” + static ScrollPhysics getOptimizedScrollPhysics() { + return const BouncingScrollPhysics( + parent: AlwaysScrollableScrollPhysics(), + ); + } + + /// ๋นŒ๋“œ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ const ์œ„์ ฏ ๊ถŒ์žฅ์‚ฌํ•ญ ์ฒดํฌ + static void checkConstOptimization() { + if (kDebugMode) { + print('๐Ÿ’ก ์„ฑ๋Šฅ ์ตœ์ ํ™” ํŒ:'); + print('1. ๊ฐ€๋Šฅํ•œ ๋ชจ๋“  ์œ„์ ฏ์— const ์‚ฌ์šฉ'); + print('2. StatelessWidget ๋Œ€์‹  const ์ƒ์„ฑ์ž ์‚ฌ์šฉ'); + print('3. ํฐ ๋ฆฌ์ŠคํŠธ๋Š” ListView.builder ์‚ฌ์šฉ'); + print('4. ์ด๋ฏธ์ง€๋Š” ์บ์‹ฑ๊ณผ ํ•จ๊ป˜ ์ ์ ˆํ•œ ํฌ๊ธฐ๋กœ ๋กœ๋“œ'); + print('5. ์• ๋‹ˆ๋ฉ”์ด์…˜์€ AnimatedBuilder ์‚ฌ์šฉ'); + } + } + + /// ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๊ฐ์ง€ ํ—ฌํผ + static final Map _widgetCounts = {}; + + static void trackWidget(String widgetName, bool isCreated) { + if (!kDebugMode) return; + + _widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) + + (isCreated ? 1 : -1); + + // ์œ„์ ฏ์ด ๋น„์ •์ƒ์ ์œผ๋กœ ๋งŽ์ด ์ƒ์„ฑ๋˜๋ฉด ๊ฒฝ๊ณ  + if ((_widgetCounts[widgetName] ?? 0) > 100) { + print('โš ๏ธ ๊ฒฝ๊ณ : $widgetName ์œ„์ ฏ์ด 100๊ฐœ ์ด์ƒ ์ƒ์„ฑ๋จ. ๋ฉ”๋ชจ๋ฆฌ ๋ˆ„์ˆ˜ ๊ฐ€๋Šฅ์„ฑ!'); + } + } +} + +/// ๋ฉ”๋ชจ๋ฆฌ ์ •๋ณด ํด๋ž˜์Šค +class MemoryInfo { + final int currentUsage; + final int capacity; + + MemoryInfo({ + required this.currentUsage, + required this.capacity, + }); + + double get usagePercentage => (currentUsage / capacity) * 100; + + String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB'; + String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB'; +} + +/// ์„ฑ๋Šฅ ์ธก์ • ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ +class PerformanceMeasure { + static Future measure({ + required String name, + required Future Function() operation, + }) async { + if (!kDebugMode) return await operation(); + + final stopwatch = Stopwatch()..start(); + try { + final result = await operation(); + stopwatch.stop(); + print('โœ… $name ์™„๋ฃŒ: ${stopwatch.elapsedMilliseconds}ms'); + return result; + } catch (e) { + stopwatch.stop(); + print('โŒ $name ์‹คํŒจ: ${stopwatch.elapsedMilliseconds}ms - $e'); + rethrow; + } + } +} \ No newline at end of file diff --git a/lib/widgets/analysis/analysis_badge.dart b/lib/widgets/analysis/analysis_badge.dart new file mode 100644 index 0000000..c59561c --- /dev/null +++ b/lib/widgets/analysis/analysis_badge.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../models/subscription_model.dart'; +import '../../services/currency_util.dart'; + +/// ํŒŒ์ด ์ฐจํŠธ์—์„œ ์„ ํƒ๋œ ์„น์…˜์— ํ‘œ์‹œ๋˜๋Š” ๋ฐฐ์ง€ ์œ„์ ฏ +class AnalysisBadge extends StatelessWidget { + final double size; + final Color borderColor; + final SubscriptionModel subscription; + + const AnalysisBadge({ + super.key, + required this.size, + required this.borderColor, + required this.subscription, + }); + + @override + Widget build(BuildContext context) { + return AnimatedContainer( + duration: PieChart.defaultDuration, + width: size, + height: size, + decoration: BoxDecoration( + color: Colors.white, + shape: BoxShape.circle, + border: Border.all( + color: borderColor, + width: 2, + ), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.5), + blurRadius: 10, + spreadRadius: 2, + ), + ], + ), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + subscription.serviceName.length > 5 + ? '${subscription.serviceName.substring(0, 5)}...' + : subscription.serviceName, + style: const TextStyle( + fontSize: 8, + fontWeight: FontWeight.bold, + color: Colors.black87, + ), + ), + const SizedBox(height: 2), + FutureBuilder( + future: CurrencyUtil.formatAmount( + subscription.monthlyCost, + subscription.currency, + ), + builder: (context, snapshot) { + if (snapshot.hasData) { + final amountText = snapshot.data!; + // ๊ธˆ์•ก์ด ๋„ˆ๋ฌด ๊ธธ๋ฉด ์ถ•์•ฝ + final displayText = amountText.length > 8 + ? amountText.replaceAll('์›', '').trim() + : amountText; + return Text( + displayText, + style: const TextStyle( + fontSize: 7, + color: Colors.black54, + ), + ); + } + return const SizedBox(); + }, + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/analysis/analysis_screen_spacer.dart b/lib/widgets/analysis/analysis_screen_spacer.dart new file mode 100644 index 0000000..f8db192 --- /dev/null +++ b/lib/widgets/analysis/analysis_screen_spacer.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +/// ๋ถ„์„ ํ™”๋ฉด์—์„œ ์‚ฌ์šฉํ•˜๋Š” ๊ฐ„๊ฒฉ ์œ„์ ฏ +/// SliverToBoxAdapter ์˜ค๋ฅ˜๋ฅผ ํ•ด๊ฒฐํ•˜๊ธฐ ์œ„ํ•ด ๋ณ„๋„ ์ปดํฌ๋„ŒํŠธ๋กœ ๋ถ„๋ฆฌ +class AnalysisScreenSpacer extends StatelessWidget { + final double height; + + const AnalysisScreenSpacer({ + super.key, + this.height = 24, + }); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: SizedBox(height: height), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/analysis/event_analysis_card.dart b/lib/widgets/analysis/event_analysis_card.dart new file mode 100644 index 0000000..233c3e8 --- /dev/null +++ b/lib/widgets/analysis/event_analysis_card.dart @@ -0,0 +1,272 @@ +import 'package:flutter/material.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'package:provider/provider.dart'; +import '../../providers/subscription_provider.dart'; +import '../../services/currency_util.dart'; +import '../glassmorphism_card.dart'; +import '../themed_text.dart'; + +/// ์ด๋ฒคํŠธ ํ• ์ธ ํ˜„ํ™ฉ์„ ๋ณด์—ฌ์ฃผ๋Š” ์นด๋“œ ์œ„์ ฏ +class EventAnalysisCard extends StatelessWidget { + final AnimationController animationController; + + const EventAnalysisCard({ + super.key, + required this.animationController, + }); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Consumer( + builder: (context, provider, child) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: provider.activeEventSubscriptions.isNotEmpty + ? FadeTransition( + opacity: CurvedAnimation( + parent: animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeOut), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeOut), + )), + child: GlassmorphismCard( + blur: 10, + opacity: 0.1, + borderRadius: 16, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ThemedText.headline( + text: '์ด๋ฒคํŠธ ํ• ์ธ ํ˜„ํ™ฉ', + style: const TextStyle( + fontSize: 18, + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [ + Color(0xFFFF6B6B), + Color(0xFFFE7E7E), + ], + ), + borderRadius: BorderRadius.circular(4), + ), + child: Row( + children: [ + const FaIcon( + FontAwesomeIcons.fire, + size: 12, + color: Colors.white, + ), + const SizedBox(width: 4), + Text( + '${provider.activeEventSubscriptions.length}๊ฐœ ์ง„ํ–‰์ค‘', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Colors.white, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + const Color(0xFFFF6B6B).withValues(alpha: 0.1), + const Color(0xFFFF8787).withValues(alpha: 0.1), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: const Color(0xFFFF6B6B).withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + const Icon( + Icons.savings, + color: Color(0xFFFF6B6B), + size: 32, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ThemedText( + '์›”๊ฐ„ ์ ˆ์•ฝ ๊ธˆ์•ก', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 4), + ThemedText( + CurrencyUtil.formatTotalAmount( + provider.calculateTotalSavings(), + ), + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + color: Color(0xFFFF6B6B), + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: 16), + const ThemedText( + '์ง„ํ–‰์ค‘์ธ ์ด๋ฒคํŠธ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 12), + ...provider.activeEventSubscriptions.map((sub) { + final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice); + final discountRate = + ((savings / sub.originalPrice) * 100).round(); + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.05), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: Colors.white.withValues(alpha: 0.1), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText( + sub.serviceName, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + Row( + children: [ + FutureBuilder( + future: CurrencyUtil + .formatAmount( + sub.originalPrice, + sub.currency), + builder: (context, snapshot) { + if (snapshot.hasData) { + return ThemedText( + snapshot.data!, + style: const TextStyle( + fontSize: 12, + decoration: TextDecoration + .lineThrough, + color: Colors.grey, + ), + ); + } + return const SizedBox(); + }, + ), + const SizedBox(width: 8), + const Icon( + Icons.arrow_forward, + size: 12, + color: Colors.grey, + ), + const SizedBox(width: 8), + FutureBuilder( + future: CurrencyUtil + .formatAmount( + sub.eventPrice ?? sub.originalPrice, + sub.currency), + builder: (context, snapshot) { + if (snapshot.hasData) { + return ThemedText( + snapshot.data!, + style: const TextStyle( + fontSize: 12, + fontWeight: + FontWeight.bold, + color: + Color(0xFF10B981), + ), + ); + } + return const SizedBox(); + }, + ), + ], + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFFF6B6B) + .withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(4), + ), + child: Text( + '$discountRate% ํ• ์ธ', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + color: Color(0xFFFF6B6B), + ), + ), + ), + ], + ), + ); + }).toList(), + ], + ), + ), + ), + ), + ) + : const SizedBox.shrink(), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/analysis/monthly_expense_chart_card.dart b/lib/widgets/analysis/monthly_expense_chart_card.dart new file mode 100644 index 0000000..24041af --- /dev/null +++ b/lib/widgets/analysis/monthly_expense_chart_card.dart @@ -0,0 +1,214 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import 'dart:math' as math; +import '../../services/currency_util.dart'; +import '../glassmorphism_card.dart'; +import '../themed_text.dart'; + +/// ์›”๋ณ„ ์ง€์ถœ ํ˜„ํ™ฉ์„ ์ฐจํŠธ๋กœ ๋ณด์—ฌ์ฃผ๋Š” ์นด๋“œ ์œ„์ ฏ +class MonthlyExpenseChartCard extends StatelessWidget { + final List> monthlyData; + final AnimationController animationController; + + const MonthlyExpenseChartCard({ + super.key, + required this.monthlyData, + required this.animationController, + }); + + // ์›”๊ฐ„ ์ง€์ถœ ์ฐจํŠธ ๋ฐ์ดํ„ฐ + List _getMonthlyBarGroups() { + final List barGroups = []; + final calculatedMax = monthlyData.fold( + 0, (max, data) => math.max(max, data['totalExpense'] as double)); + final maxAmount = calculatedMax > 0 ? calculatedMax : 100000.0; // ๊ธฐ๋ณธ๊ฐ’ 10๋งŒ์› + + for (int i = 0; i < monthlyData.length; i++) { + final data = monthlyData[i]; + barGroups.add( + BarChartGroupData( + x: i, + barRods: [ + BarChartRodData( + toY: data['totalExpense'], + gradient: LinearGradient( + colors: [ + const Color(0xFF3B82F6).withValues(alpha: 0.7), + const Color(0xFF60A5FA), + ], + begin: Alignment.bottomCenter, + end: Alignment.topCenter, + ), + width: 18, + borderRadius: BorderRadius.circular(4), + backDrawRodData: BackgroundBarChartRodData( + show: true, + toY: maxAmount + (maxAmount * 0.1), + color: Colors.grey.withValues(alpha: 0.1), + ), + ), + ], + ), + ); + } + + return barGroups; + } + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FadeTransition( + opacity: CurvedAnimation( + parent: animationController, + curve: const Interval(0.4, 0.9, curve: Curves.easeOut), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController, + curve: const Interval(0.4, 0.9, curve: Curves.easeOut), + )), + child: GlassmorphismCard( + blur: 10, + opacity: 0.1, + borderRadius: 16, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText.headline( + text: '์›”๋ณ„ ์ง€์ถœ ํ˜„ํ™ฉ', + style: const TextStyle( + fontSize: 18, + ), + ), + const SizedBox(height: 8), + ThemedText.subtitle( + text: '์ตœ๊ทผ 6๊ฐœ์›”๊ฐ„ ์ถ”์ด', + style: const TextStyle( + fontSize: 14, + ), + ), + const SizedBox(height: 20), + // ๋ฐ” ์ฐจํŠธ + AspectRatio( + aspectRatio: 1.6, + child: BarChart( + BarChartData( + alignment: BarChartAlignment.spaceAround, + maxY: math.max( + monthlyData.fold( + 0, + (max, data) => math.max( + max, data['totalExpense'] as double)) * + 1.2, + 100000.0 // ์ตœ์†Œ๊ฐ’ 10๋งŒ์› + ), + barGroups: _getMonthlyBarGroups(), + gridData: FlGridData( + show: true, + drawVerticalLine: false, + horizontalInterval: math.max( + monthlyData.fold( + 0, + (max, data) => math.max(max, + data['totalExpense'] as double)) / + 4, + 25000.0 // ์ตœ์†Œ ๊ฐ„๊ฒฉ 2.5๋งŒ์› + ), + getDrawingHorizontalLine: (value) { + return FlLine( + color: Colors.grey.withValues(alpha: 0.1), + strokeWidth: 1, + ); + }, + ), + 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: Colors.blueGrey.shade800, + tooltipRoundedRadius: 8, + getTooltipItem: + (group, groupIndex, rod, rodIndex) { + return BarTooltipItem( + '${monthlyData[group.x]['monthName']}\n', + const TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + ), + children: [ + TextSpan( + text: CurrencyUtil.formatTotalAmount( + monthlyData[group.x]['totalExpense'] + as double), + style: const TextStyle( + color: Colors.yellow, + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 16), + Center( + child: ThemedText.caption( + text: '์›” ๊ตฌ๋… ์ง€์ถœ', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/analysis/subscription_pie_chart_card.dart b/lib/widgets/analysis/subscription_pie_chart_card.dart new file mode 100644 index 0000000..a152ec0 --- /dev/null +++ b/lib/widgets/analysis/subscription_pie_chart_card.dart @@ -0,0 +1,294 @@ +import 'package:flutter/material.dart'; +import 'package:fl_chart/fl_chart.dart'; +import '../../models/subscription_model.dart'; +import '../../services/currency_util.dart'; +import '../glassmorphism_card.dart'; +import '../themed_text.dart'; +import 'analysis_badge.dart'; + +/// ๊ตฌ๋… ์„œ๋น„์Šค ๋น„์œจ์„ ํŒŒ์ด ์ฐจํŠธ๋กœ ๋ณด์—ฌ์ฃผ๋Š” ์นด๋“œ ์œ„์ ฏ +class SubscriptionPieChartCard extends StatelessWidget { + final List subscriptions; + final AnimationController animationController; + final int touchedIndex; + final Function(int) onPieTouch; + + const SubscriptionPieChartCard({ + super.key, + required this.subscriptions, + required this.animationController, + required this.touchedIndex, + required this.onPieTouch, + }); + + // ํŒŒ์ด ์ฐจํŠธ ์„น์…˜ ๋ฐ์ดํ„ฐ + List _getPieSections() { + if (subscriptions.isEmpty) return []; + + final colors = [ + const Color(0xFF3B82F6), + const Color(0xFF10B981), + const Color(0xFFF59E0B), + const Color(0xFFEF4444), + const Color(0xFF8B5CF6), + const Color(0xFF0EA5E9), + const Color(0xFFEC4899), + ]; + + // ๊ฐœ๋ณ„ ๊ตฌ๋…์˜ ๋น„์œจ ๊ณ„์‚ฐ์„ ์œ„ํ•œ ๊ฐ’๋“ค + List sectionValues = []; + + // ๊ฐ ๊ตฌ๋…์˜ ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก ๋˜๋Š” ์›ํ™” ๊ธˆ์•ก์„ ๊ณ„์‚ฐ + for (var subscription in subscriptions) { + double value = subscription.monthlyCost; + if (subscription.currency == 'USD') { + // USD์˜ ๊ฒฝ์šฐ ๋งˆ์ง€๋ง‰์œผ๋กœ ์กฐํšŒ๋œ ํ™˜์œจ๋กœ ๋Œ€๋žต์ ์ธ ๊ณ„์‚ฐ + // (์ •ํ™•ํ•œ ๊ณ„์‚ฐ์€ ๋น„๋™๊ธฐ๋กœ ์ด๋ฃจ์–ด์ง€๋ฏ€๋กœ UI ํ‘œ์‹œ์šฉ์œผ๋กœ๋งŒ ์‚ฌ์šฉ) + const rate = 1350.0; // ๊ธฐ๋ณธ ํ™˜์œจ (์‹ค์ œ ๊ฐ’์€ API๋กœ ๋ณ„๋„๋กœ ๊ฐ€์ ธ์˜ด) + value = value * rate; + } + sectionValues.add(value); + } + + // ์ดํ•ฉ ๊ณ„์‚ฐ + double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value); + + // ์„น์…˜ ๋ฐ์ดํ„ฐ ์ƒ์„ฑ + return List.generate(subscriptions.length, (i) { + final subscription = subscriptions[i]; + final percentage = (sectionValues[i] / sectionsTotal) * 100; + final index = i % colors.length; + final isTouched = touchedIndex == i; + final fontSize = isTouched ? 16.0 : 12.0; + final radius = isTouched ? 105.0 : 100.0; + + return PieChartSectionData( + value: sectionValues[i], + title: '${percentage.toStringAsFixed(1)}%', + titleStyle: TextStyle( + fontSize: fontSize, + fontWeight: FontWeight.bold, + color: Colors.white, + shadows: const [ + Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1)) + ], + ), + color: colors[index], + radius: radius, + titlePositionPercentageOffset: 0.6, + badgeWidget: isTouched + ? AnalysisBadge( + size: 40, + borderColor: colors[index], + subscription: subscription, + ) + : null, + badgePositionPercentageOffset: .98, + ); + }); + } + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FadeTransition( + opacity: CurvedAnimation( + parent: animationController, + curve: const Interval(0.0, 0.7, curve: Curves.easeOut), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController, + curve: const Interval(0.0, 0.7, curve: Curves.easeOut), + )), + child: GlassmorphismCard( + blur: 10, + opacity: 0.1, + borderRadius: 16, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ThemedText.headline( + text: '๊ตฌ๋… ์„œ๋น„์Šค ๋น„์œจ', + style: const TextStyle( + fontSize: 18, + ), + ), + FutureBuilder( + future: CurrencyUtil.getExchangeRateInfo(), + builder: (context, snapshot) { + if (snapshot.hasData && + snapshot.data!.isNotEmpty) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + decoration: BoxDecoration( + color: const Color(0xFFE5F2FF), + borderRadius: + BorderRadius.circular(4), + border: Border.all( + color: const Color(0xFFBFDBFE), + width: 1, + ), + ), + child: Text( + snapshot.data!, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + const SizedBox(height: 8), + ThemedText.subtitle( + text: '์›” ์ง€์ถœ ๊ธฐ์ค€', + style: const TextStyle( + fontSize: 14, + ), + ), + const SizedBox(height: 16), + Center( + child: subscriptions.isEmpty + ? const SizedBox( + height: 250, + child: Center( + child: ThemedText( + '๊ตฌ๋…์ค‘์ธ ์„œ๋น„์Šค๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค', + style: TextStyle( + fontSize: 16, + ), + ), + ), + ) + : SizedBox( + height: 250, + child: PieChart( + PieChartData( + borderData: FlBorderData(show: false), + sectionsSpace: 2, + centerSpaceRadius: 60, + sections: _getPieSections(), + pieTouchData: PieTouchData( + touchCallback: (FlTouchEvent event, + pieTouchResponse) { + if (!event + .isInterestedForInteractions || + pieTouchResponse == null || + pieTouchResponse + .touchedSection == + null) { + onPieTouch(-1); + return; + } + onPieTouch(pieTouchResponse + .touchedSection! + .touchedSectionIndex); + }, + ), + ), + ), + ), + ), + const SizedBox(height: 16), + // ์„œ๋น„์Šค ๋ชฉ๋ก + Column( + children: subscriptions.isEmpty + ? [] + : List.generate( + subscriptions.length, + (index) { + final subscription = + subscriptions[index]; + final color = [ + const Color(0xFF3B82F6), + const Color(0xFF10B981), + const Color(0xFFF59E0B), + const Color(0xFFEF4444), + const Color(0xFF8B5CF6), + const Color(0xFF0EA5E9), + const Color(0xFFEC4899), + ][index % 7]; + return Padding( + padding: const EdgeInsets.only( + bottom: 4.0), + child: Row( + children: [ + Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 8), + Expanded( + child: ThemedText( + subscription.serviceName, + style: const TextStyle( + fontSize: 14, + ), + overflow: + TextOverflow.ellipsis, + ), + ), + FutureBuilder( + future: CurrencyUtil + .formatSubscriptionAmount( + subscription), + builder: (context, snapshot) { + if (snapshot.hasData) { + return ThemedText( + snapshot.data!, + style: const TextStyle( + fontSize: 14, + fontWeight: + FontWeight.bold, + ), + ); + } + return const SizedBox( + width: 20, + height: 20, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ); + }, + ), + ], + ), + ); + }, + ), + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/analysis/total_expense_summary_card.dart b/lib/widgets/analysis/total_expense_summary_card.dart new file mode 100644 index 0000000..a6231f3 --- /dev/null +++ b/lib/widgets/analysis/total_expense_summary_card.dart @@ -0,0 +1,228 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../../models/subscription_model.dart'; +import '../../services/currency_util.dart'; +import '../../utils/haptic_feedback_helper.dart'; +import '../../theme/app_colors.dart'; +import '../glassmorphism_card.dart'; +import '../themed_text.dart'; + +/// ์ด ์ง€์ถœ ์š”์•ฝ์„ ๋ณด์—ฌ์ฃผ๋Š” ์นด๋“œ ์œ„์ ฏ +class TotalExpenseSummaryCard extends StatelessWidget { + final List subscriptions; + final double totalExpense; + final AnimationController animationController; + + const TotalExpenseSummaryCard({ + super.key, + required this.subscriptions, + required this.totalExpense, + required this.animationController, + }); + + @override + Widget build(BuildContext context) { + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: FadeTransition( + opacity: CurvedAnimation( + parent: animationController, + curve: const Interval(0.2, 0.8, curve: Curves.easeOut), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController, + curve: const Interval(0.2, 0.8, curve: Curves.easeOut), + )), + child: GlassmorphismCard( + blur: 10, + opacity: 0.1, + borderRadius: 16, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + ThemedText.headline( + text: '์ด ์ง€์ถœ ์š”์•ฝ', + style: const TextStyle( + fontSize: 18, + ), + ), + IconButton( + icon: const Icon(Icons.content_copy), + iconSize: 20, + padding: EdgeInsets.zero, + constraints: const BoxConstraints(), + onPressed: () async { + final totalExpenseText = CurrencyUtil.formatTotalAmount(totalExpense); + await Clipboard.setData( + ClipboardData(text: totalExpenseText)); + HapticFeedbackHelper.lightImpact(); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('์ด ์ง€์ถœ์•ก์ด ๋ณต์‚ฌ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: $totalExpenseText'), + duration: const Duration(seconds: 2), + behavior: SnackBarBehavior.floating, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3), + margin: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 8, + ), + ), + ); + }, + ), + ], + ), + const SizedBox(height: 8), + ThemedText.subtitle( + text: '์›” ๋‹จ์œ„ ์ด์•ก', + style: const TextStyle( + fontSize: 14, + ), + ), + const SizedBox(height: 16), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: '์ด ์ง€์ถœ', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 4), + ThemedText( + CurrencyUtil.formatTotalAmount(totalExpense), + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.bold, + letterSpacing: -0.5, + ), + ), + ], + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glassBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.2), + ), + ), + child: const FaIcon( + FontAwesomeIcons.listCheck, + size: 16, + color: Colors.blue, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: '์ด ์„œ๋น„์Šค', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + ThemedText( + '${subscriptions.length}๊ฐœ', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + const SizedBox(height: 12), + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AppColors.glassBackground.withValues(alpha: 0.3), + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.2), + ), + ), + child: const FaIcon( + FontAwesomeIcons.chartLine, + size: 16, + color: Colors.green, + ), + ), + const SizedBox(width: 12), + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + ThemedText.caption( + text: 'ํ‰๊ท  ์š”๊ธˆ', + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + ThemedText( + CurrencyUtil.formatTotalAmount( + subscriptions.isEmpty + ? 0 + : totalExpense / subscriptions.length), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ], + ), + ], + ), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/animated_page_transitions.dart b/lib/widgets/animated_page_transitions.dart new file mode 100644 index 0000000..d79deed --- /dev/null +++ b/lib/widgets/animated_page_transitions.dart @@ -0,0 +1,311 @@ +import 'package:flutter/material.dart'; +import 'dart:math' as math; + +/// ์Šฌ๋ผ์ด๋“œ + ํŽ˜์ด๋“œ ์ „ํ™˜ +class SlidePageRoute extends PageRouteBuilder { + final Widget page; + final AxisDirection direction; + + SlidePageRoute({ + required this.page, + this.direction = AxisDirection.right, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 300), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + Offset begin; + switch (direction) { + case AxisDirection.right: + begin = const Offset(-1.0, 0.0); + break; + case AxisDirection.left: + begin = const Offset(1.0, 0.0); + break; + case AxisDirection.up: + begin = const Offset(0.0, 1.0); + break; + case AxisDirection.down: + begin = const Offset(0.0, -1.0); + break; + } + + const end = Offset.zero; + const curve = Curves.easeOutCubic; + + var tween = Tween(begin: begin, end: end).chain( + CurveTween(curve: curve), + ); + var offsetAnimation = animation.drive(tween); + + var fadeTween = Tween(begin: 0.0, end: 1.0).chain( + CurveTween(curve: curve), + ); + var fadeAnimation = animation.drive(fadeTween); + + return SlideTransition( + position: offsetAnimation, + child: FadeTransition( + opacity: fadeAnimation, + child: child, + ), + ); + }, + ); +} + +/// ์Šค์ผ€์ผ + ํŽ˜์ด๋“œ ์ „ํ™˜ +class ScalePageRoute extends PageRouteBuilder { + final Widget page; + final Alignment alignment; + + ScalePageRoute({ + required this.page, + this.alignment = Alignment.center, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: const Duration(milliseconds: 400), + reverseTransitionDuration: const Duration(milliseconds: 400), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + const curve = Curves.elasticOut; + + var scaleTween = Tween(begin: 0.0, end: 1.0).chain( + CurveTween(curve: curve), + ); + var scaleAnimation = animation.drive(scaleTween); + + var fadeTween = Tween(begin: 0.0, end: 1.0).chain( + CurveTween(curve: Curves.easeIn), + ); + var fadeAnimation = animation.drive(fadeTween); + + return ScaleTransition( + scale: scaleAnimation, + alignment: alignment, + child: FadeTransition( + opacity: fadeAnimation, + child: child, + ), + ); + }, + ); +} + +/// ํšŒ์ „ + ์Šค์ผ€์ผ ์ „ํ™˜ +class RotatePageRoute extends PageRouteBuilder { + final Widget page; + + RotatePageRoute({required this.page}) + : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: const Duration(milliseconds: 500), + reverseTransitionDuration: const Duration(milliseconds: 500), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + const curve = Curves.easeInOut; + + var rotateTween = Tween(begin: -0.5, end: 0.0).chain( + CurveTween(curve: curve), + ); + var rotateAnimation = animation.drive(rotateTween); + + var scaleTween = Tween(begin: 0.0, end: 1.0).chain( + CurveTween(curve: curve), + ); + var scaleAnimation = animation.drive(scaleTween); + + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateZ(rotateAnimation.value) + ..scale(scaleAnimation.value), + child: child, + ); + }, + ); +} + +/// 3D ํ”Œ๋ฆฝ ์ „ํ™˜ +class FlipPageRoute extends PageRouteBuilder { + final Widget page; + final bool horizontal; + + FlipPageRoute({ + required this.page, + this.horizontal = true, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: const Duration(milliseconds: 800), + reverseTransitionDuration: const Duration(milliseconds: 800), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + final isAnimatingForward = animation.status == AnimationStatus.forward; + + final flipAnimation = Tween( + begin: 0.0, + end: isAnimatingForward ? -math.pi : math.pi, + ).animate(CurvedAnimation( + parent: animation, + curve: Curves.easeInOut, + )); + + return AnimatedBuilder( + animation: flipAnimation, + builder: (context, child) { + final isShowingFront = flipAnimation.value.abs() < math.pi / 2; + + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(horizontal ? flipAnimation.value : 0) + ..rotateX(horizontal ? 0 : flipAnimation.value), + child: isShowingFront + ? Container() + : Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..rotateY(horizontal ? math.pi : 0) + ..rotateX(horizontal ? 0 : math.pi), + child: child, + ), + ); + }, + child: child, + ); + }, + ); +} + +/// ์ปจํ…Œ์ด๋„ˆ ํŠธ๋žœ์Šคํผ (Material Design) +class ContainerTransformPageRoute extends PageRouteBuilder { + final Widget page; + final Widget startWidget; + final BorderRadius? borderRadius; + + ContainerTransformPageRoute({ + required this.page, + required this.startWidget, + this.borderRadius, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: const Duration(milliseconds: 500), + reverseTransitionDuration: const Duration(milliseconds: 500), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return Stack( + children: [ + // ๋ฐฐ๊ฒฝ ํŽ˜์ด๋“œ + FadeTransition( + opacity: animation, + child: Container( + color: Colors.black.withValues(alpha: 0.3), + ), + ), + // ์ปจํ…Œ์ด๋„ˆ ํ™•์žฅ ์• ๋‹ˆ๋ฉ”์ด์…˜ + AnimatedBuilder( + animation: animation, + builder: (context, _) { + final progress = animation.value; + final scale = 0.5 + (0.5 * progress); + final radius = borderRadius?.topLeft.x ?? 0; + final currentRadius = radius * (1 - progress); + + return Transform.scale( + scale: scale, + child: ClipRRect( + borderRadius: BorderRadius.circular(currentRadius), + child: progress < 0.5 ? startWidget : child, + ), + ); + }, + child: child, + ), + ], + ); + }, + ); +} + +/// ์ปค์Šคํ…€ Hero ์• ๋‹ˆ๋ฉ”์ด์…˜ +class CustomHeroPageRoute extends PageRouteBuilder { + final Widget page; + final String heroTag; + + CustomHeroPageRoute({ + required this.page, + required this.heroTag, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: const Duration(milliseconds: 500), + reverseTransitionDuration: const Duration(milliseconds: 500), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + return FadeTransition( + opacity: CurvedAnimation( + parent: animation, + curve: const Interval(0.5, 1.0), + ), + child: child, + ); + }, + ); +} + +/// ๊ณต์œ  ์ถ• ์ „ํ™˜ (Material Design) +class SharedAxisPageRoute extends PageRouteBuilder { + final Widget page; + final SharedAxisTransitionType transitionType; + + SharedAxisPageRoute({ + required this.page, + required this.transitionType, + }) : super( + pageBuilder: (context, animation, secondaryAnimation) => page, + transitionDuration: const Duration(milliseconds: 300), + reverseTransitionDuration: const Duration(milliseconds: 300), + transitionsBuilder: (context, animation, secondaryAnimation, child) { + late final Offset begin; + late final Offset end; + + switch (transitionType) { + case SharedAxisTransitionType.horizontal: + begin = const Offset(1.0, 0.0); + end = Offset.zero; + break; + case SharedAxisTransitionType.vertical: + begin = const Offset(0.0, 1.0); + end = Offset.zero; + break; + case SharedAxisTransitionType.scaled: + begin = Offset.zero; + end = Offset.zero; + break; + } + + final slideTween = Tween(begin: begin, end: end); + final fadeTween = Tween(begin: 0.0, end: 1.0); + final scaleTween = transitionType == SharedAxisTransitionType.scaled + ? Tween(begin: 0.8, end: 1.0) + : Tween(begin: 1.0, end: 1.0); + + final slideAnimation = animation.drive(slideTween); + final fadeAnimation = animation.drive(fadeTween); + final scaleAnimation = animation.drive(scaleTween); + + return SlideTransition( + position: slideAnimation, + child: FadeTransition( + opacity: fadeAnimation, + child: ScaleTransition( + scale: scaleAnimation, + child: child, + ), + ), + ); + }, + ); +} + +enum SharedAxisTransitionType { + horizontal, + vertical, + scaled, +} \ No newline at end of file diff --git a/lib/widgets/animated_wave_background.dart b/lib/widgets/animated_wave_background.dart index 6edcd77..9fb2d8f 100644 --- a/lib/widgets/animated_wave_background.dart +++ b/lib/widgets/animated_wave_background.dart @@ -38,7 +38,7 @@ class AnimatedWaveBackground extends StatelessWidget { width: 200, height: 200, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.1), + color: Colors.white.withValues(alpha: 0.1), borderRadius: BorderRadius.circular(100), ), ), @@ -64,7 +64,7 @@ class AnimatedWaveBackground extends StatelessWidget { width: 220, height: 220, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.05), + color: Colors.white.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(110), ), ), @@ -90,7 +90,7 @@ class AnimatedWaveBackground extends StatelessWidget { width: 120, height: 120, decoration: BoxDecoration( - color: Colors.white.withOpacity(0.08), + color: Colors.white.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(60), ), ), @@ -109,7 +109,7 @@ class AnimatedWaveBackground extends StatelessWidget { width: 30, height: 30, decoration: BoxDecoration( - color: Colors.white.withOpacity( + color: Colors.white.withValues(alpha: 0.1 + 0.1 * pulseController.value, ), borderRadius: BorderRadius.circular(15), diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart new file mode 100644 index 0000000..4e44de6 --- /dev/null +++ b/lib/widgets/app_navigator.dart @@ -0,0 +1,200 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:provider/provider.dart'; +import '../screens/main_screen.dart'; +import '../screens/analysis_screen.dart'; +import '../screens/add_subscription_screen.dart'; +import '../screens/detail_screen.dart'; +import '../screens/settings_screen.dart'; +import '../screens/sms_scan_screen.dart'; +import '../screens/category_management_screen.dart'; +import '../screens/app_lock_screen.dart'; +import '../models/subscription_model.dart'; +import '../providers/navigation_provider.dart'; +import '../routes/app_routes.dart'; +import 'animated_page_transitions.dart'; + +/// ์•ฑ ์ „์ฒด์˜ ๋„ค๋น„๊ฒŒ์ด์…˜์„ ๊ด€๋ฆฌํ•˜๋Š” ํด๋ž˜์Šค +class AppNavigator { + // NavigationProvider๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ƒํƒœ๋ฅผ ๊ด€๋ฆฌํ•˜๋ฏ€๋กœ ๋” ์ด์ƒ ์‹ฑ๊ธ€ํ†ค ํŒจํ„ด์ด ํ•„์š”ํ•˜์ง€ ์•Š์Œ + + /// ํ™ˆ์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toHome(BuildContext context) async { + HapticFeedback.lightImpact(); + final navigationProvider = context.read(); + navigationProvider.clearHistoryAndGoHome(); + + await Navigator.of(context).pushNamedAndRemoveUntil( + AppRoutes.main, + (route) => false, + ); + } + + /// ๋ถ„์„ ํ™”๋ฉด์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toAnalysis(BuildContext context) async { + HapticFeedback.lightImpact(); + final navigationProvider = context.read(); + navigationProvider.updateCurrentIndex(1); + + await Navigator.of(context).pushNamed(AppRoutes.analysis); + } + + /// ๊ตฌ๋… ์ถ”๊ฐ€ ํ™”๋ฉด์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toAddSubscription(BuildContext context) async { + HapticFeedback.mediumImpact(); + + await Navigator.of(context).pushNamed(AppRoutes.addSubscription); + } + + /// ๊ตฌ๋… ์ƒ์„ธ ํ™”๋ฉด์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toDetail(BuildContext context, SubscriptionModel subscription) async { + HapticFeedback.lightImpact(); + + await Navigator.of(context).pushNamed( + AppRoutes.subscriptionDetail, + arguments: subscription, + ); + } + + /// SMS ์Šค์บ” ํ™”๋ฉด์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toSmsScan(BuildContext context) async { + HapticFeedback.lightImpact(); + final navigationProvider = context.read(); + navigationProvider.updateCurrentIndex(3); + + await Navigator.of(context).pushNamed(AppRoutes.smsScanner); + } + + /// ์„ค์ • ํ™”๋ฉด์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toSettings(BuildContext context) async { + HapticFeedback.lightImpact(); + final navigationProvider = context.read(); + navigationProvider.updateCurrentIndex(4); + + await Navigator.of(context).pushNamed(AppRoutes.settings); + } + + /// ์นดํ…Œ๊ณ ๋ฆฌ ๊ด€๋ฆฌ ํ™”๋ฉด์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toCategoryManagement(BuildContext context) async { + HapticFeedback.lightImpact(); + + await Navigator.of(context).push( + SlidePageRoute( + page: const CategoryManagementScreen(), + direction: AxisDirection.up, + ), + ); + } + + /// ์•ฑ ์ž ๊ธˆ ํ™”๋ฉด์œผ๋กœ ๋„ค๋น„๊ฒŒ์ด์…˜ + static Future toAppLock(BuildContext context) async { + await Navigator.of(context).push( + MaterialPageRoute( + builder: (context) => const AppLockScreen(), + fullscreenDialog: true, + ), + ); + } + + /// ๋’ค๋กœ๊ฐ€๊ธฐ ์ฒ˜๋ฆฌ + static Future handleBackButton(BuildContext context) async { + final navigator = Navigator.of(context); + final navigationProvider = context.read(); + + // ๋„ค๋น„๊ฒŒ์ด์…˜ ์Šคํƒ์ด ์žˆ์œผ๋ฉด ํŒ + if (navigator.canPop()) { + HapticFeedback.lightImpact(); + + // NavigationProvider์˜ ํžˆ์Šคํ† ๋ฆฌ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ์ด์ „ ์ธ๋ฑ์Šค๋กœ ๋ณต์› + if (navigationProvider.canPop()) { + navigationProvider.pop(); + } + + navigator.pop(); + return false; + } + + // ์•ฑ ์ข…๋ฃŒ ํ™•์ธ + final shouldExit = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('์•ฑ ์ข…๋ฃŒ'), + content: const Text('SubManager๋ฅผ ์ข…๋ฃŒํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('์ทจ์†Œ'), + ), + TextButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('์ข…๋ฃŒ'), + ), + ], + ), + ); + + return shouldExit ?? false; + } + + /// ํ”Œ๋กœํŒ… ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ” ํƒญ ์ฒ˜๋ฆฌ + static void handleFloatingNavTap(BuildContext context, int index) { + final navigationProvider = context.read(); + final currentIndex = navigationProvider.currentIndex; + + // ๊ฐ™์€ ํƒญ์„ ๋‹ค์‹œ ํƒญํ•˜๋ฉด ์•„๋ฌด ๋™์ž‘ ์•ˆ ํ•จ + if (currentIndex == index) { + return; + } + + // ํ˜„์žฌ ํ™”๋ฉด์ด ๋ฉ”์ธ์ด ์•„๋‹ˆ๋ฉด ๋จผ์ € ๋ฉ”์ธ์œผ๋กœ ๋Œ์•„๊ฐ€๊ธฐ + if (Navigator.of(context).canPop()) { + Navigator.of(context).popUntil((route) => route.isFirst); + } + + // ์„ ํƒ๋œ ์ธ๋ฑ์Šค์— ๋”ฐ๋ผ ๋„ค๋น„๊ฒŒ์ด์…˜ + switch (index) { + case 0: // ํ™ˆ + navigationProvider.updateCurrentIndex(0); + break; + case 1: // ๋ถ„์„ + toAnalysis(context); + break; + case 2: // ์ถ”๊ฐ€ + toAddSubscription(context); + break; + case 3: // SMS + toSmsScan(context); + break; + case 4: // ์„ค์ • + toSettings(context); + break; + } + } +} + +/// ๋„ค๋น„๊ฒŒ์ด์…˜ ๊ด€์ฐฐ์ž (๋””๋ฒ„๊น…์šฉ) +class AppNavigationObserver extends NavigatorObserver { + @override + void didPush(Route route, Route? previousRoute) { + super.didPush(route, previousRoute); + debugPrint('Navigation: Push ${route.settings.name}'); + } + + @override + void didPop(Route route, Route? previousRoute) { + super.didPop(route, previousRoute); + debugPrint('Navigation: Pop ${route.settings.name}'); + } + + @override + void didRemove(Route route, Route? previousRoute) { + super.didRemove(route, previousRoute); + debugPrint('Navigation: Remove ${route.settings.name}'); + } + + @override + void didReplace({Route? newRoute, Route? oldRoute}) { + super.didReplace(newRoute: newRoute, oldRoute: oldRoute); + debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}'); + } +} \ No newline at end of file diff --git a/lib/widgets/cached_network_image_widget.dart b/lib/widgets/cached_network_image_widget.dart new file mode 100644 index 0000000..f778517 --- /dev/null +++ b/lib/widgets/cached_network_image_widget.dart @@ -0,0 +1,315 @@ +import 'package:flutter/material.dart'; +import 'package:cached_network_image/cached_network_image.dart'; +import '../theme/app_colors.dart'; +import 'skeleton_loading.dart'; + +/// ์ตœ์ ํ™”๋œ ์บ์‹œ ๋„คํŠธ์›Œํฌ ์ด๋ฏธ์ง€ ์œ„์ ฏ +class OptimizedCachedNetworkImage extends StatelessWidget { + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final BorderRadius? borderRadius; + final Duration fadeInDuration; + final Duration fadeOutDuration; + final Widget? placeholder; + final Widget? errorWidget; + final Map? httpHeaders; + final bool enableMemoryCache; + final bool enableDiskCache; + final int? maxWidth; + final int? maxHeight; + + const OptimizedCachedNetworkImage({ + super.key, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.borderRadius, + this.fadeInDuration = const Duration(milliseconds: 300), + this.fadeOutDuration = const Duration(milliseconds: 300), + this.placeholder, + this.errorWidget, + this.httpHeaders, + this.enableMemoryCache = true, + this.enableDiskCache = true, + this.maxWidth, + this.maxHeight, + }); + + @override + Widget build(BuildContext context) { + // ์„ฑ๋Šฅ ์ตœ์ ํ™”๋ฅผ ์œ„ํ•œ ์ด๋ฏธ์ง€ ํฌ๊ธฐ ๊ณ„์‚ฐ + final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final optimalWidth = maxWidth ?? + (width != null ? (width! * devicePixelRatio).round() : null); + final optimalHeight = maxHeight ?? + (height != null ? (height! * devicePixelRatio).round() : null); + + Widget image = CachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + fadeInDuration: fadeInDuration, + fadeOutDuration: fadeOutDuration, + httpHeaders: httpHeaders, + memCacheWidth: optimalWidth, + memCacheHeight: optimalHeight, + maxWidthDiskCache: optimalWidth, + maxHeightDiskCache: optimalHeight, + placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(), + errorWidget: (context, url, error) => + errorWidget ?? _buildDefaultErrorWidget(), + imageBuilder: (context, imageProvider) { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + borderRadius: borderRadius, + image: DecorationImage( + image: imageProvider, + fit: fit, + ), + ), + ); + }, + ); + + if (borderRadius != null) { + return ClipRRect( + borderRadius: borderRadius!, + child: image, + ); + } + + return image; + } + + Widget _buildDefaultPlaceholder() { + return SkeletonLoading( + width: width, + height: height, + borderRadius: borderRadius?.topLeft.x ?? 0, + ); + } + + Widget _buildDefaultErrorWidget() { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: AppColors.surfaceColorAlt, + borderRadius: borderRadius, + ), + child: const Icon( + Icons.broken_image_outlined, + color: AppColors.textMuted, + size: 24, + ), + ); + } +} + +/// ํ”„๋กœ๊ทธ๋ ˆ์‹œ๋ธŒ ์ด๋ฏธ์ง€ ๋กœ๋” (์ €ํ™”์งˆ โ†’ ๊ณ ํ™”์งˆ) +class ProgressiveNetworkImage extends StatelessWidget { + final String thumbnailUrl; + final String imageUrl; + final double? width; + final double? height; + final BoxFit fit; + final BorderRadius? borderRadius; + + const ProgressiveNetworkImage({ + super.key, + required this.thumbnailUrl, + required this.imageUrl, + this.width, + this.height, + this.fit = BoxFit.cover, + this.borderRadius, + }); + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.passthrough, + children: [ + // ์ธ๋„ค์ผ (์ €ํ™”์งˆ) + OptimizedCachedNetworkImage( + imageUrl: thumbnailUrl, + width: width, + height: height, + fit: fit, + borderRadius: borderRadius, + fadeInDuration: Duration.zero, + ), + // ์›๋ณธ ์ด๋ฏธ์ง€ (๊ณ ํ™”์งˆ) + OptimizedCachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + borderRadius: borderRadius, + ), + ], + ); + } +} + +/// ์ด๋ฏธ์ง€ ๊ฐค๋Ÿฌ๋ฆฌ ์œ„์ ฏ (๋ฉ”๋ชจ๋ฆฌ ํšจ์œจ์ ) +class OptimizedImageGallery extends StatefulWidget { + final List imageUrls; + final double itemHeight; + final double spacing; + final int crossAxisCount; + final void Function(int)? onImageTap; + + const OptimizedImageGallery({ + super.key, + required this.imageUrls, + this.itemHeight = 120, + this.spacing = 8, + this.crossAxisCount = 3, + this.onImageTap, + }); + + @override + State createState() => _OptimizedImageGalleryState(); +} + +class _OptimizedImageGalleryState extends State { + final ScrollController _scrollController = ScrollController(); + final Set _visibleIndices = {}; + + @override + void initState() { + super.initState(); + _scrollController.addListener(_onScroll); + // ์ดˆ๊ธฐ ๋ณด์ด๋Š” ์•„์ดํ…œ ๊ณ„์‚ฐ + WidgetsBinding.instance.addPostFrameCallback((_) { + _calculateVisibleIndices(); + }); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + _calculateVisibleIndices(); + } + + void _calculateVisibleIndices() { + if (!mounted) return; + + final viewportHeight = context.size?.height ?? 0; + final scrollOffset = _scrollController.offset; + final itemHeight = widget.itemHeight + widget.spacing; + final itemsPerRow = widget.crossAxisCount; + + final firstVisibleRow = (scrollOffset / itemHeight).floor(); + final lastVisibleRow = ((scrollOffset + viewportHeight) / itemHeight).ceil(); + + final newVisibleIndices = {}; + for (int row = firstVisibleRow; row <= lastVisibleRow; row++) { + for (int col = 0; col < itemsPerRow; col++) { + final index = row * itemsPerRow + col; + if (index < widget.imageUrls.length) { + newVisibleIndices.add(index); + } + } + } + + if (!setEquals(_visibleIndices, newVisibleIndices)) { + setState(() { + _visibleIndices.clear(); + _visibleIndices.addAll(newVisibleIndices); + }); + } + } + + @override + Widget build(BuildContext context) { + return GridView.builder( + controller: _scrollController, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + childAspectRatio: 1.0, + crossAxisSpacing: widget.spacing, + mainAxisSpacing: widget.spacing, + ), + itemCount: widget.imageUrls.length, + itemBuilder: (context, index) { + // ๋ณด์ด๋Š” ์˜์—ญ์˜ ์ด๋ฏธ์ง€๋งŒ ๋กœ๋“œ + if (_visibleIndices.contains(index) || + (index >= _visibleIndices.first - widget.crossAxisCount && + index <= _visibleIndices.last + widget.crossAxisCount)) { + return GestureDetector( + onTap: () => widget.onImageTap?.call(index), + child: OptimizedCachedNetworkImage( + imageUrl: widget.imageUrls[index], + fit: BoxFit.cover, + borderRadius: BorderRadius.circular(8), + ), + ); + } + + // ๋ณด์ด์ง€ ์•Š๋Š” ์˜์—ญ์€ ํ”Œ๋ ˆ์ด์Šคํ™€๋” + return Container( + decoration: BoxDecoration( + color: AppColors.surfaceColorAlt, + borderRadius: BorderRadius.circular(8), + ), + ); + }, + ); + } + + bool setEquals(Set a, Set b) { + if (a.length != b.length) return false; + for (final item in a) { + if (!b.contains(item)) return false; + } + return true; + } +} + +/// ํžˆ์–ด๋กœ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ ์šฉ๋œ ์ด๋ฏธ์ง€ +class HeroNetworkImage extends StatelessWidget { + final String imageUrl; + final String heroTag; + final double? width; + final double? height; + final BoxFit fit; + final VoidCallback? onTap; + + const HeroNetworkImage({ + super.key, + required this.imageUrl, + required this.heroTag, + this.width, + this.height, + this.fit = BoxFit.cover, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Hero( + tag: heroTag, + child: OptimizedCachedNetworkImage( + imageUrl: imageUrl, + width: width, + height: height, + fit: fit, + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/empty_state_widget.dart b/lib/widgets/empty_state_widget.dart index 3d4cea6..86bba91 100644 --- a/lib/widgets/empty_state_widget.dart +++ b/lib/widgets/empty_state_widget.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'dart:math' as math; +import 'glassmorphism_card.dart'; +import 'themed_text.dart'; /// ๊ตฌ๋…์ด ์—†์„ ๋•Œ ํ‘œ์‹œ๋˜๋Š” ๋นˆ ํ™”๋ฉด ์œ„์ ฏ /// @@ -31,21 +33,10 @@ class EmptyStateWidget extends StatelessWidget { end: Offset.zero, ).animate(CurvedAnimation( parent: slideController, curve: Curves.easeOutBack)), - child: Container( + child: GlassmorphismCard( + width: null, margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(32), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: Colors.grey.withOpacity(0.08), - spreadRadius: 0, - blurRadius: 16, - offset: const Offset(0, 8), - ), - ], - ), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -65,7 +56,7 @@ class EmptyStateWidget extends StatelessWidget { borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: const Color(0xFF3B82F6).withOpacity(0.3), + color: const Color(0xFF3B82F6).withValues(alpha: 0.3), spreadRadius: 0, blurRadius: 16, offset: const Offset(0, 8), @@ -82,29 +73,17 @@ class EmptyStateWidget extends StatelessWidget { }, ), const SizedBox(height: 32), - ShaderMask( - shaderCallback: (bounds) => const LinearGradient( - colors: [Color(0xFF3B82F6), Color(0xFF0EA5E9)], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(bounds), - child: const Text( - '๋“ฑ๋ก๋œ ๊ตฌ๋…์ด ์—†์Šต๋‹ˆ๋‹ค', - style: TextStyle( - fontSize: 22, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -0.5, - ), - ), + const ThemedText( + '๋“ฑ๋ก๋œ ๊ตฌ๋…์ด ์—†์Šต๋‹ˆ๋‹ค', + fontSize: 22, + fontWeight: FontWeight.w800, + letterSpacing: -0.5, ), const SizedBox(height: 8), - const Text( + const ThemedText( '์ƒˆ๋กœ์šด ๊ตฌ๋…์„ ์ถ”๊ฐ€ํ•ด๋ณด์„ธ์š”', - style: TextStyle( - fontSize: 16, - color: Color(0xFF64748B), - ), + fontSize: 16, + opacity: 0.7, ), const SizedBox(height: 32), MouseRegion( @@ -133,6 +112,7 @@ class EmptyStateWidget extends StatelessWidget { fontSize: 16, fontWeight: FontWeight.w600, letterSpacing: 0.5, + color: Colors.white, ), ), ), diff --git a/lib/widgets/expandable_fab.dart b/lib/widgets/expandable_fab.dart new file mode 100644 index 0000000..3bbd7bf --- /dev/null +++ b/lib/widgets/expandable_fab.dart @@ -0,0 +1,268 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:math' as math; +import '../theme/app_colors.dart'; +import '../utils/haptic_feedback_helper.dart'; +import 'glassmorphism_card.dart'; + +class ExpandableFab extends StatefulWidget { + final List actions; + final double distance; + + const ExpandableFab({ + super.key, + required this.actions, + this.distance = 100.0, + }); + + @override + State createState() => _ExpandableFabState(); +} + +class _ExpandableFabState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _expandAnimation; + late Animation _rotateAnimation; + bool _isExpanded = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + + _expandAnimation = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutBack, + reverseCurve: Curves.easeInBack, + ); + + _rotateAnimation = Tween( + begin: 0.0, + end: math.pi / 4, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() { + _isExpanded = !_isExpanded; + }); + + if (_isExpanded) { + HapticFeedbackHelper.mediumImpact(); + _controller.forward(); + } else { + HapticFeedbackHelper.lightImpact(); + _controller.reverse(); + } + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: Alignment.bottomRight, + children: [ + // ๋ฐฐ๊ฒฝ ์˜ค๋ฒ„๋ ˆ์ด (ํ™•์žฅ ์‹œ) + if (_isExpanded) + GestureDetector( + onTap: _toggle, + child: AnimatedBuilder( + animation: _expandAnimation, + builder: (context, child) { + return Container( + color: Colors.black.withValues(alpha: 0.3 * _expandAnimation.value), + ); + }, + ), + ), + + // ์•ก์…˜ ๋ฒ„ํŠผ๋“ค + ...widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + final angle = (index + 1) * (math.pi / 2 / widget.actions.length); + + return AnimatedBuilder( + animation: _expandAnimation, + builder: (context, child) { + final distance = widget.distance * _expandAnimation.value; + final x = distance * math.cos(angle); + final y = distance * math.sin(angle); + + return Transform.translate( + offset: Offset(-x, -y), + child: ScaleTransition( + scale: _expandAnimation, + child: FloatingActionButton.small( + heroTag: 'fab_action_$index', + onPressed: _isExpanded + ? () { + HapticFeedbackHelper.lightImpact(); + _toggle(); + action.onPressed(); + } + : null, + backgroundColor: action.color ?? AppColors.primaryColor, + child: Icon( + action.icon, + size: 20, + color: Colors.white, + ), + ), + ), + ); + }, + ); + }), + + // ๋ฉ”์ธ FAB + AnimatedBuilder( + animation: _rotateAnimation, + builder: (context, child) { + return Transform.rotate( + angle: _rotateAnimation.value, + child: FloatingActionButton( + onPressed: _toggle, + backgroundColor: AppColors.primaryColor, + child: Icon( + _isExpanded ? Icons.close : Icons.add, + size: 28, + color: Colors.white, + ), + ), + ); + }, + ), + + // ๋ผ๋ฒจ ํ‘œ์‹œ + if (_isExpanded) + ...widget.actions.asMap().entries.map((entry) { + final index = entry.key; + final action = entry.value; + final angle = (index + 1) * (math.pi / 2 / widget.actions.length); + + return AnimatedBuilder( + animation: _expandAnimation, + builder: (context, child) { + final distance = widget.distance * _expandAnimation.value; + final x = distance * math.cos(angle); + final y = distance * math.sin(angle); + + return Transform.translate( + offset: Offset(-x - 80, -y), + child: FadeTransition( + opacity: _expandAnimation, + child: GlassmorphismCard( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + borderRadius: 8, + blur: 10, + child: Text( + action.label, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + }, + ); + }), + ], + ); + } +} + +class FabAction { + final IconData icon; + final String label; + final VoidCallback onPressed; + final Color? color; + + const FabAction({ + required this.icon, + required this.label, + required this.onPressed, + this.color, + }); +} + +// ๋“œ๋ž˜๊ทธ ๊ฐ€๋Šฅํ•œ FAB +class DraggableFab extends StatefulWidget { + final Widget child; + final EdgeInsets? padding; + + const DraggableFab({ + super.key, + required this.child, + this.padding, + }); + + @override + State createState() => _DraggableFabState(); +} + +class _DraggableFabState extends State { + Offset _position = const Offset(20, 20); + bool _isDragging = false; + + @override + Widget build(BuildContext context) { + final screenSize = MediaQuery.of(context).size; + final padding = widget.padding ?? const EdgeInsets.all(20); + + return Stack( + children: [ + Positioned( + right: _position.dx, + bottom: _position.dy, + child: GestureDetector( + onPanStart: (_) { + setState(() => _isDragging = true); + HapticFeedbackHelper.lightImpact(); + }, + onPanUpdate: (details) { + setState(() { + _position = Offset( + (_position.dx - details.delta.dx).clamp( + padding.right, + screenSize.width - 100 - padding.left, + ), + (_position.dy - details.delta.dy).clamp( + padding.bottom, + screenSize.height - 200 - padding.top, + ), + ); + }); + }, + onPanEnd: (_) { + setState(() => _isDragging = false); + HapticFeedbackHelper.lightImpact(); + }, + child: AnimatedScale( + duration: const Duration(milliseconds: 150), + scale: _isDragging ? 0.9 : 1.0, + child: widget.child, + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart new file mode 100644 index 0000000..52ab9af --- /dev/null +++ b/lib/widgets/floating_navigation_bar.dart @@ -0,0 +1,310 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:ui'; +import '../theme/app_colors.dart'; +import 'glassmorphism_card.dart'; + +class FloatingNavigationBar extends StatefulWidget { + final int selectedIndex; + final Function(int) onItemTapped; + final bool isVisible; + + const FloatingNavigationBar({ + super.key, + required this.selectedIndex, + required this.onItemTapped, + this.isVisible = true, + }); + + @override + State createState() => _FloatingNavigationBarState(); +} + +class _FloatingNavigationBarState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + + if (widget.isVisible) { + _controller.forward(); + } + } + + @override + void didUpdateWidget(FloatingNavigationBar oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.isVisible != oldWidget.isVisible) { + if (widget.isVisible) { + _controller.forward(); + } else { + _controller.reverse(); + } + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Positioned( + bottom: 20, + left: 20, + right: 20, + child: Transform.translate( + offset: Offset(0, 100 * (1 - _animation.value)), + child: Opacity( + opacity: _animation.value, + child: GlassmorphismCard( + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), + borderRadius: 24, + blur: 10.0, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _NavigationItem( + icon: Icons.home_rounded, + label: 'ํ™ˆ', + isSelected: widget.selectedIndex == 0, + onTap: () => _onItemTapped(0), + ), + _NavigationItem( + icon: Icons.analytics_rounded, + label: '๋ถ„์„', + isSelected: widget.selectedIndex == 1, + onTap: () => _onItemTapped(1), + ), + _AddButton( + onTap: () => _onItemTapped(2), + ), + _NavigationItem( + icon: Icons.qr_code_scanner_rounded, + label: 'SMS', + isSelected: widget.selectedIndex == 3, + onTap: () => _onItemTapped(3), + ), + _NavigationItem( + icon: Icons.settings_rounded, + label: '์„ค์ •', + isSelected: widget.selectedIndex == 4, + onTap: () => _onItemTapped(4), + ), + ], + ), + ), + ), + ), + ); + }, + ); + } + + void _onItemTapped(int index) { + HapticFeedback.lightImpact(); + widget.onItemTapped(index); + } +} + +class _NavigationItem extends StatelessWidget { + final IconData icon; + final String label; + final bool isSelected; + final VoidCallback onTap; + + const _NavigationItem({ + required this.icon, + required this.label, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF14B8A6).withValues(alpha: 0.1) + : Colors.transparent, + borderRadius: BorderRadius.circular(12), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + child: Icon( + icon, + color: isSelected + ? const Color(0xFF14B8A6) + : (isDarkMode ? Colors.white70 : AppColors.textSecondary), + size: isSelected ? 26 : 24, + ), + ), + const SizedBox(height: 4), + AnimatedDefaultTextStyle( + duration: const Duration(milliseconds: 200), + style: TextStyle( + fontSize: 11, + fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500, + color: isSelected + ? const Color(0xFF14B8A6) + : (isDarkMode ? Colors.white70 : AppColors.textSecondary), + ), + child: Text(label), + ), + ], + ), + ), + ); + } +} + +class _AddButton extends StatefulWidget { + final VoidCallback onTap; + + const _AddButton({required this.onTap}); + + @override + State<_AddButton> createState() => _AddButtonState(); +} + +class _AddButtonState extends State<_AddButton> + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 150), + vsync: this, + ); + _scaleAnimation = Tween( + begin: 1.0, + end: 0.9, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: (_) => _controller.forward(), + onTapUp: (_) { + _controller.reverse(); + widget.onTap(); + }, + onTapCancel: () => _controller.reverse(), + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppColors.blueGradient, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: AppColors.primaryColor.withValues(alpha: 0.3), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: const Icon( + Icons.add_rounded, + color: Colors.white, + size: 28, + ), + ), + ); + }, + ), + ); + } +} + +// ์Šคํฌ๋กค ๊ฐ์ง€๋ฅผ ์œ„ํ•œ ์œ ํ‹ธ๋ฆฌํ‹ฐ ํด๋ž˜์Šค +class FloatingNavBarScrollController { + final ScrollController scrollController; + final VoidCallback onHide; + final VoidCallback onShow; + + double _lastScrollPosition = 0; + bool _isVisible = true; + + FloatingNavBarScrollController({ + required this.scrollController, + required this.onHide, + required this.onShow, + }) { + scrollController.addListener(_handleScroll); + } + + void _handleScroll() { + final currentScroll = scrollController.position.pixels; + + if (currentScroll > _lastScrollPosition && currentScroll > 50) { + // ์Šคํฌ๋กค ๋‹ค์šด + if (_isVisible) { + _isVisible = false; + onHide(); + } + } else if (currentScroll < _lastScrollPosition - 5) { + // ์Šคํฌ๋กค ์—… + if (!_isVisible) { + _isVisible = true; + onShow(); + } + } + + _lastScrollPosition = currentScroll; + } + + void dispose() { + scrollController.removeListener(_handleScroll); + } +} diff --git a/lib/widgets/glassmorphic_app_bar.dart b/lib/widgets/glassmorphic_app_bar.dart new file mode 100644 index 0000000..1ca9496 --- /dev/null +++ b/lib/widgets/glassmorphic_app_bar.dart @@ -0,0 +1,304 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:ui'; +import '../theme/app_colors.dart'; +import 'themed_text.dart'; + +/// ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ํšจ๊ณผ๊ฐ€ ์ ์šฉ๋œ ํ†ต์ผ๋œ ์•ฑ๋ฐ” +class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget { + final String title; + final List? actions; + final Widget? leading; + final bool automaticallyImplyLeading; + final double elevation; + final Color? backgroundColor; + final double blur; + final double opacity; + final PreferredSizeWidget? bottom; + final bool centerTitle; + final double? titleSpacing; + final VoidCallback? onBackPressed; + + const GlassmorphicAppBar({ + super.key, + required this.title, + this.actions, + this.leading, + this.automaticallyImplyLeading = true, + this.elevation = 0, + this.backgroundColor, + this.blur = 20, + this.opacity = 0.1, + this.bottom, + this.centerTitle = false, + this.titleSpacing, + this.onBackPressed, + }); + + @override + Size get preferredSize => Size.fromHeight( + kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final canPop = Navigator.of(context).canPop(); + + return ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + (backgroundColor ?? (isDarkMode + ? AppColors.glassBackgroundDark + : AppColors.glassBackground)).withValues(alpha: opacity), + (backgroundColor ?? (isDarkMode + ? AppColors.glassSurfaceDark + : AppColors.glassSurface)).withValues(alpha: opacity * 0.8), + ], + ), + border: Border( + bottom: BorderSide( + color: isDarkMode + ? AppColors.glassBorderDark.withValues(alpha: 0.3) + : AppColors.glassBorder.withValues(alpha: 0.3), + width: 0.5, + ), + ), + ), + child: SafeArea( + bottom: false, + child: ClipRect( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: SizedBox( + height: kToolbarHeight, + child: NavigationToolbar( + leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null) + ? _buildBackButton(context) + : null), + middle: _buildTitle(context), + trailing: actions != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: actions!, + ) + : null, + centerMiddle: centerTitle, + middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing, + ), + ), + ), + if (bottom != null) bottom!, + ], + ), + ), + ), + ), + ), + ); + } + + Widget _buildBackButton(BuildContext context) { + return IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: onBackPressed ?? () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + }, + splashRadius: 24, + tooltip: '๋’ค๋กœ๊ฐ€๊ธฐ', + color: ThemedText.getContrastColor(context), + ); + } + + Widget _buildTitle(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: ThemedText.headline( + text: title, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + ), + ); + } + + /// ํˆฌ๋ช… ์Šคํƒ€์ผ ํŒฉํ† ๋ฆฌ + static GlassmorphicAppBar transparent({ + required String title, + List? actions, + Widget? leading, + VoidCallback? onBackPressed, + }) { + return GlassmorphicAppBar( + title: title, + actions: actions, + leading: leading, + blur: 30, + opacity: 0.05, + onBackPressed: onBackPressed, + ); + } + + /// ๋ฐ˜ํˆฌ๋ช… ์Šคํƒ€์ผ ํŒฉํ† ๋ฆฌ + static GlassmorphicAppBar translucent({ + required String title, + List? actions, + Widget? leading, + VoidCallback? onBackPressed, + }) { + return GlassmorphicAppBar( + title: title, + actions: actions, + leading: leading, + blur: 20, + opacity: 0.15, + onBackPressed: onBackPressed, + ); + } +} + +/// ํ™•์žฅ๋œ ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ์•ฑ๋ฐ” (์ด๋ฏธ์ง€๋‚˜ ์ถ”๊ฐ€ ์ฝ˜ํ…์ธ  ํฌํ•จ) +class GlassmorphicSliverAppBar extends StatelessWidget { + final String title; + final List? actions; + final Widget? leading; + final double expandedHeight; + final bool floating; + final bool pinned; + final bool snap; + final Widget? flexibleSpace; + final double blur; + final double opacity; + final bool automaticallyImplyLeading; + final VoidCallback? onBackPressed; + final bool centerTitle; + + const GlassmorphicSliverAppBar({ + super.key, + required this.title, + this.actions, + this.leading, + this.expandedHeight = kToolbarHeight, + this.floating = false, + this.pinned = true, + this.snap = false, + this.flexibleSpace, + this.blur = 20, + this.opacity = 0.1, + this.automaticallyImplyLeading = true, + this.onBackPressed, + this.centerTitle = false, + }); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + final canPop = Navigator.of(context).canPop(); + + return SliverAppBar( + expandedHeight: expandedHeight, + floating: floating, + pinned: pinned, + snap: snap, + backgroundColor: Colors.transparent, + elevation: 0, + automaticallyImplyLeading: false, + leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null) + ? IconButton( + icon: const Icon(Icons.arrow_back_ios_new_rounded), + onPressed: onBackPressed ?? () { + HapticFeedback.lightImpact(); + Navigator.of(context).pop(); + }, + splashRadius: 24, + tooltip: '๋’ค๋กœ๊ฐ€๊ธฐ', + ) + : null), + actions: actions, + centerTitle: centerTitle, + flexibleSpace: LayoutBuilder( + builder: (BuildContext context, BoxConstraints constraints) { + final top = constraints.biggest.height; + final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top; + + return FlexibleSpaceBar( + title: isCollapsed + ? ThemedText.headline( + text: title, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + letterSpacing: -0.2, + ), + ) + : null, + centerTitle: centerTitle, + titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16), + background: Stack( + fit: StackFit.expand, + children: [ + // ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ๋ฐฐ๊ฒฝ + ClipRRect( + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + (isDarkMode + ? AppColors.glassBackgroundDark + : AppColors.glassBackground).withValues(alpha: opacity), + (isDarkMode + ? AppColors.glassSurfaceDark + : AppColors.glassSurface).withValues(alpha: opacity * 0.8), + ], + ), + border: Border( + bottom: BorderSide( + color: isDarkMode + ? AppColors.glassBorderDark.withValues(alpha: 0.3) + : AppColors.glassBorder.withValues(alpha: 0.3), + width: 0.5, + ), + ), + ), + ), + ), + ), + // ํ™•์žฅ ์ƒํƒœ์—์„œ๋งŒ ๋ณด์ด๋Š” ํƒ€์ดํ‹€ + if (!isCollapsed) + Positioned( + left: 16, + right: 16, + bottom: 16, + child: ThemedText.headline( + text: title, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + ), + ), + ), + // ์ปค์Šคํ…€ flexibleSpace๊ฐ€ ์žˆ์œผ๋ฉด ์ถ”๊ฐ€ + if (flexibleSpace != null) flexibleSpace!, + ], + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart new file mode 100644 index 0000000..13c9d9b --- /dev/null +++ b/lib/widgets/glassmorphic_scaffold.dart @@ -0,0 +1,314 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'dart:math' as math; +import '../theme/app_colors.dart'; +import 'glassmorphic_app_bar.dart'; +import 'floating_navigation_bar.dart'; + +/// ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ๋””์ž์ธ์ด ์ ์šฉ๋œ ํ†ต์ผ๋œ ์Šค์บํด๋“œ +class GlassmorphicScaffold extends StatefulWidget { + final PreferredSizeWidget? appBar; + final Widget body; + final Widget? floatingActionButton; + final FloatingActionButtonLocation? floatingActionButtonLocation; + final List? backgroundGradient; + final bool extendBodyBehindAppBar; + final bool extendBody; + final Widget? bottomNavigationBar; + final bool useFloatingNavBar; + final int? floatingNavBarIndex; + final Function(int)? onFloatingNavBarTapped; + final bool resizeToAvoidBottomInset; + final Widget? drawer; + final Widget? endDrawer; + final Color? backgroundColor; + final bool enableParticles; + final bool enableWaveAnimation; + + const GlassmorphicScaffold({ + super.key, + this.appBar, + required this.body, + this.floatingActionButton, + this.floatingActionButtonLocation, + this.backgroundGradient, + this.extendBodyBehindAppBar = true, + this.extendBody = true, + this.bottomNavigationBar, + this.useFloatingNavBar = false, + this.floatingNavBarIndex, + this.onFloatingNavBarTapped, + this.resizeToAvoidBottomInset = true, + this.drawer, + this.endDrawer, + this.backgroundColor, + this.enableParticles = false, + this.enableWaveAnimation = false, + }); + + @override + State createState() => _GlassmorphicScaffoldState(); +} + +class _GlassmorphicScaffoldState extends State + with TickerProviderStateMixin { + late AnimationController _particleController; + late AnimationController _waveController; + ScrollController? _scrollController; + bool _isFloatingNavBarVisible = true; + + @override + void initState() { + super.initState(); + _particleController = AnimationController( + duration: const Duration(seconds: 20), + vsync: this, + )..repeat(); + + _waveController = AnimationController( + duration: const Duration(seconds: 10), + vsync: this, + )..repeat(); + + if (widget.useFloatingNavBar) { + _scrollController = ScrollController(); + _setupScrollListener(); + } + } + + void _setupScrollListener() { + _scrollController?.addListener(() { + final currentScroll = _scrollController!.position.pixels; + final maxScroll = _scrollController!.position.maxScrollExtent; + + // ์Šคํฌ๋กค ๋ฐฉํ–ฅ์— ๋”ฐ๋ผ ํ”Œ๋กœํŒ… ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ” ํ‘œ์‹œ/์ˆจ๊น€ + if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) { + if (_isFloatingNavBarVisible) { + setState(() => _isFloatingNavBarVisible = false); + } + } else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) { + if (!_isFloatingNavBarVisible) { + setState(() => _isFloatingNavBarVisible = true); + } + } + }); + } + + @override + void dispose() { + _particleController.dispose(); + _waveController.dispose(); + _scrollController?.dispose(); + super.dispose(); + } + + List _getBackgroundGradient() { + if (widget.backgroundGradient != null) { + return widget.backgroundGradient!; + } + + // ์‹œ๊ฐ„๋Œ€๋ณ„ ๊ธฐ๋ณธ ๊ทธ๋ผ๋””์–ธํŠธ + final hour = DateTime.now().hour; + if (hour >= 6 && hour < 10) { + return AppColors.morningGradient; + } else if (hour >= 10 && hour < 17) { + return AppColors.dayGradient; + } else if (hour >= 17 && hour < 20) { + return AppColors.eveningGradient; + } else { + return AppColors.nightGradient; + } + } + + @override + Widget build(BuildContext context) { + final backgroundGradient = _getBackgroundGradient(); + + return Stack( + children: [ + // ๋ฐฐ๊ฒฝ ๊ทธ๋ผ๋””์–ธํŠธ + _buildBackground(backgroundGradient), + + // ํŒŒํ‹ฐํด ํšจ๊ณผ (์„ ํƒ์ ) + if (widget.enableParticles) _buildParticles(), + + // ์›จ์ด๋ธŒ ์• ๋‹ˆ๋ฉ”์ด์…˜ (์„ ํƒ์ ) + if (widget.enableWaveAnimation) _buildWaveAnimation(), + + // ๋ฉ”์ธ ์Šค์บํด๋“œ + Scaffold( + backgroundColor: widget.backgroundColor ?? Colors.transparent, + appBar: widget.appBar, + body: widget.body, + floatingActionButton: widget.floatingActionButton, + floatingActionButtonLocation: widget.floatingActionButtonLocation, + bottomNavigationBar: widget.bottomNavigationBar, + extendBodyBehindAppBar: widget.extendBodyBehindAppBar, + extendBody: widget.extendBody, + resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset, + drawer: widget.drawer, + endDrawer: widget.endDrawer, + ), + + // ํ”Œ๋กœํŒ… ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ” (์„ ํƒ์ ) + if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null) + FloatingNavigationBar( + selectedIndex: widget.floatingNavBarIndex!, + isVisible: _isFloatingNavBarVisible, + onItemTapped: widget.onFloatingNavBarTapped ?? (_) {}, + ), + ], + ); + } + + Widget _buildBackground(List gradientColors) { + return Positioned.fill( + child: Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: gradientColors.map((color) => color.withValues(alpha: 0.1)).toList(), + ), + ), + ), + ); + } + + Widget _buildParticles() { + return Positioned.fill( + child: AnimatedBuilder( + animation: _particleController, + builder: (context, child) { + return CustomPaint( + painter: ParticlePainter( + animation: _particleController, + particleCount: 30, + ), + ); + }, + ), + ); + } + + Widget _buildWaveAnimation() { + return Positioned( + bottom: 0, + left: 0, + right: 0, + height: 200, + child: AnimatedBuilder( + animation: _waveController, + builder: (context, child) { + return CustomPaint( + painter: WavePainter( + animation: _waveController, + waveColor: AppColors.primaryColor.withValues(alpha: 0.1), + ), + ); + }, + ), + ); + } +} + +/// ํŒŒํ‹ฐํด ํŽ˜์ธํ„ฐ +class ParticlePainter extends CustomPainter { + final Animation animation; + final int particleCount; + final List particles = []; + + ParticlePainter({ + required this.animation, + this.particleCount = 50, + }) : super(repaint: animation) { + _initParticles(); + } + + void _initParticles() { + final random = math.Random(); + for (int i = 0; i < particleCount; i++) { + particles.add(Particle( + x: random.nextDouble(), + y: random.nextDouble(), + size: random.nextDouble() * 3 + 1, + speed: random.nextDouble() * 0.5 + 0.1, + opacity: random.nextDouble() * 0.5 + 0.1, + )); + } + } + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint()..style = PaintingStyle.fill; + + for (final particle in particles) { + final progress = animation.value; + final y = (particle.y + progress * particle.speed) % 1.0; + + paint.color = Colors.white.withValues(alpha: particle.opacity); + canvas.drawCircle( + Offset(particle.x * size.width, y * size.height), + particle.size, + paint, + ); + } + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +/// ์›จ์ด๋ธŒ ํŽ˜์ธํ„ฐ +class WavePainter extends CustomPainter { + final Animation animation; + final Color waveColor; + + WavePainter({ + required this.animation, + required this.waveColor, + }) : super(repaint: animation); + + @override + void paint(Canvas canvas, Size size) { + final paint = Paint() + ..color = waveColor + ..style = PaintingStyle.fill; + + final path = Path(); + final progress = animation.value; + + path.moveTo(0, size.height); + + for (double x = 0; x <= size.width; x++) { + final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 + + size.height * 0.5; + path.lineTo(x, y); + } + + path.lineTo(size.width, size.height); + path.close(); + + canvas.drawPath(path, paint); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => true; +} + +/// ํŒŒํ‹ฐํด ๋ฐ์ดํ„ฐ ํด๋ž˜์Šค +class Particle { + final double x; + final double y; + final double size; + final double speed; + final double opacity; + + Particle({ + required this.x, + required this.y, + required this.size, + required this.speed, + required this.opacity, + }); +} \ No newline at end of file diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart new file mode 100644 index 0000000..1a12f27 --- /dev/null +++ b/lib/widgets/glassmorphism_card.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'dart:ui'; +import '../theme/app_colors.dart'; + +class GlassmorphismCard extends StatelessWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double? width; + final double? height; + final double borderRadius; + final double blur; + final double opacity; + final Color? backgroundColor; + final Gradient? gradient; + final Border? border; + final List? boxShadow; + final VoidCallback? onTap; + + const GlassmorphismCard({ + super.key, + required this.child, + this.padding, + this.margin, + this.width, + this.height, + this.borderRadius = 16.0, + this.blur = 10.0, + this.opacity = 0.1, + this.backgroundColor, + this.gradient, + this.border, + this.boxShadow, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final isDarkMode = Theme.of(context).brightness == Brightness.dark; + + return Container( + width: width, + height: height, + margin: margin, + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(borderRadius), + child: ClipRRect( + borderRadius: BorderRadius.circular(borderRadius), + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: backgroundColor ?? (isDarkMode + ? AppColors.glassCardDark + : AppColors.glassCard), + gradient: gradient ?? LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: isDarkMode + ? AppColors.glassGradientDark + : AppColors.glassGradient, + ), + borderRadius: BorderRadius.circular(borderRadius), + border: border ?? Border.all( + color: isDarkMode + ? AppColors.glassBorderDark + : AppColors.glassBorder, + width: 1.5, + ), + boxShadow: boxShadow ?? [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 20, + spreadRadius: -5, + offset: const Offset(0, 10), + ), + ], + ), + child: child, + ), + ), + ), + ), + ), + ); + } +} + +// ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ ์šฉ๋œ ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ์นด๋“œ +class AnimatedGlassmorphismCard extends StatefulWidget { + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double? width; + final double? height; + final double borderRadius; + final double blur; + final double opacity; + final Duration animationDuration; + final VoidCallback? onTap; + + const AnimatedGlassmorphismCard({ + super.key, + required this.child, + this.padding, + this.margin, + this.width, + this.height, + this.borderRadius = 16.0, + this.blur = 10.0, + this.opacity = 0.1, + this.animationDuration = const Duration(milliseconds: 200), + this.onTap, + }); + + @override + State createState() => _AnimatedGlassmorphismCardState(); +} + +class _AnimatedGlassmorphismCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + late Animation _blurAnimation; + bool _isPressed = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: 0.98, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + + _blurAnimation = Tween( + begin: widget.blur, + end: widget.blur * 1.5, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + setState(() { + _isPressed = true; + }); + _controller.forward(); + } + + void _handleTapUp(TapUpDetails details) { + setState(() { + _isPressed = false; + }); + _controller.reverse(); + } + + void _handleTapCancel() { + setState(() { + _isPressed = false; + }); + _controller.reverse(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onTapCancel: _handleTapCancel, + onTap: widget.onTap, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: GlassmorphismCard( + padding: widget.padding, + margin: widget.margin, + width: widget.width, + height: widget.height, + borderRadius: widget.borderRadius, + blur: _blurAnimation.value, + opacity: widget.opacity, + child: widget.child, + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart new file mode 100644 index 0000000..971d68d --- /dev/null +++ b/lib/widgets/home_content.dart @@ -0,0 +1,154 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import '../utils/subscription_category_helper.dart'; +import '../widgets/native_ad_widget.dart'; +import '../widgets/main_summary_card.dart'; +import '../widgets/subscription_list_widget.dart'; +import '../widgets/empty_state_widget.dart'; +import '../widgets/glassmorphic_app_bar.dart'; +import '../theme/app_colors.dart'; +import '../routes/app_routes.dart'; + +class HomeContent extends StatelessWidget { + final AnimationController fadeController; + final AnimationController rotateController; + final AnimationController slideController; + final AnimationController pulseController; + final AnimationController waveController; + final ScrollController scrollController; + final VoidCallback onAddPressed; + + const HomeContent({ + super.key, + required this.fadeController, + required this.rotateController, + required this.slideController, + required this.pulseController, + required this.waveController, + required this.scrollController, + required this.onAddPressed, + }); + + @override + Widget build(BuildContext context) { + final provider = context.watch(); + + if (provider.isLoading) { + return const Center( + child: CircularProgressIndicator( + valueColor: AlwaysStoppedAnimation(Color(0xFF3B82F6)), + ), + ); + } + + if (provider.subscriptions.isEmpty) { + return EmptyStateWidget( + fadeController: fadeController, + rotateController: rotateController, + slideController: slideController, + onAddPressed: onAddPressed, + ); + } + + // ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„ ๊ตฌ๋… ๊ตฌ๋ถ„ + final categoryProvider = Provider.of(context, listen: false); + final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions( + provider.subscriptions, + categoryProvider, + ); + + return RefreshIndicator( + onRefresh: () async { + await provider.refreshSubscriptions(); + }, + color: const Color(0xFF3B82F6), + child: CustomScrollView( + controller: scrollController, + physics: const BouncingScrollPhysics(), + slivers: [ + const GlassmorphicSliverAppBar( + title: 'ํ™ˆ', + pinned: true, + expandedHeight: kToolbarHeight, + ), + SliverToBoxAdapter( + child: NativeAdWidget(key: const ValueKey('home_ad')), + ), + SliverToBoxAdapter( + child: SlideTransition( + position: Tween( + 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, + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(20, 24, 20, 4), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + SlideTransition( + position: Tween( + 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( + 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 + MediaQuery.of(context).padding.bottom, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/lazy_loading_list.dart b/lib/widgets/lazy_loading_list.dart new file mode 100644 index 0000000..2c1da22 --- /dev/null +++ b/lib/widgets/lazy_loading_list.dart @@ -0,0 +1,416 @@ +import 'package:flutter/material.dart'; +import 'dart:async'; +import '../utils/performance_optimizer.dart'; +import '../widgets/skeleton_loading.dart'; + +/// ๋ ˆ์ด์ง€ ๋กœ๋”ฉ์ด ์ ์šฉ๋œ ๋ฆฌ์ŠคํŠธ ์œ„์ ฏ +class LazyLoadingList extends StatefulWidget { + final Future> Function(int page, int pageSize) loadMore; + final Widget Function(BuildContext, T, int) itemBuilder; + final int pageSize; + final double scrollThreshold; + final Widget? loadingWidget; + final Widget? emptyWidget; + final Widget? errorWidget; + final bool enableRefresh; + final ScrollPhysics? physics; + final EdgeInsetsGeometry? padding; + + const LazyLoadingList({ + super.key, + required this.loadMore, + required this.itemBuilder, + this.pageSize = 20, + this.scrollThreshold = 0.8, + this.loadingWidget, + this.emptyWidget, + this.errorWidget, + this.enableRefresh = true, + this.physics, + this.padding, + }); + + @override + State> createState() => _LazyLoadingListState(); +} + +class _LazyLoadingListState extends State> { + final List _items = []; + final ScrollController _scrollController = ScrollController(); + + int _currentPage = 0; + bool _isLoading = false; + bool _hasMore = true; + String? _error; + + @override + void initState() { + super.initState(); + _loadInitialData(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isLoading || !_hasMore) return; + + final position = _scrollController.position; + final maxScroll = position.maxScrollExtent; + final currentScroll = position.pixels; + + if (currentScroll >= maxScroll * widget.scrollThreshold) { + _loadMoreData(); + } + } + + Future _loadInitialData() async { + setState(() { + _isLoading = true; + _error = null; + }); + + try { + final newItems = await PerformanceMeasure.measure( + name: 'Initial data load', + operation: () => widget.loadMore(0, widget.pageSize), + ); + + setState(() { + _items.clear(); + _items.addAll(newItems); + _currentPage = 0; + _hasMore = newItems.length >= widget.pageSize; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Future _loadMoreData() async { + if (_isLoading || !_hasMore) return; + + setState(() { + _isLoading = true; + }); + + try { + final nextPage = _currentPage + 1; + final newItems = await PerformanceMeasure.measure( + name: 'Load more data (page $nextPage)', + operation: () => widget.loadMore(nextPage, widget.pageSize), + ); + + setState(() { + _items.addAll(newItems); + _currentPage = nextPage; + _hasMore = newItems.length >= widget.pageSize; + _isLoading = false; + }); + } catch (e) { + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + Future _refresh() async { + await _loadInitialData(); + } + + @override + Widget build(BuildContext context) { + if (_error != null && _items.isEmpty) { + return Center( + child: widget.errorWidget ?? _buildDefaultErrorWidget(), + ); + } + + if (!_isLoading && _items.isEmpty) { + return Center( + child: widget.emptyWidget ?? _buildDefaultEmptyWidget(), + ); + } + + Widget listView = ListView.builder( + controller: _scrollController, + physics: widget.physics ?? PerformanceOptimizer.getOptimizedScrollPhysics(), + padding: widget.padding, + itemCount: _items.length + (_isLoading || _hasMore ? 1 : 0), + itemBuilder: (context, index) { + if (index < _items.length) { + return widget.itemBuilder(context, _items[index], index); + } + + // ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ + return Padding( + padding: const EdgeInsets.all(16.0), + child: Center( + child: widget.loadingWidget ?? _buildDefaultLoadingWidget(), + ), + ); + }, + ); + + if (widget.enableRefresh) { + return RefreshIndicator( + onRefresh: _refresh, + child: listView, + ); + } + + return listView; + } + + Widget _buildDefaultLoadingWidget() { + return const CircularProgressIndicator(); + } + + Widget _buildDefaultEmptyWidget() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.inbox_outlined, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + '๋ฐ์ดํ„ฐ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + ], + ); + } + + Widget _buildDefaultErrorWidget() { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 64, + color: Colors.red[400], + ), + const SizedBox(height: 16), + Text( + '์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค', + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 8), + Text( + _error ?? '', + style: TextStyle( + fontSize: 14, + color: Colors.grey[500], + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadInitialData, + child: const Text('๋‹ค์‹œ ์‹œ๋„'), + ), + ], + ); + } +} + +/// ์บ์‹œ๊ฐ€ ์ ์šฉ๋œ ๋ ˆ์ด์ง€ ๋กœ๋”ฉ ๋ฆฌ์ŠคํŠธ +class CachedLazyLoadingList extends StatefulWidget { + final String cacheKey; + final Future> Function(int page, int pageSize) loadMore; + final Widget Function(BuildContext, T, int) itemBuilder; + final int pageSize; + final Duration cacheDuration; + final Widget? loadingWidget; + final Widget? emptyWidget; + + const CachedLazyLoadingList({ + super.key, + required this.cacheKey, + required this.loadMore, + required this.itemBuilder, + this.pageSize = 20, + this.cacheDuration = const Duration(minutes: 5), + this.loadingWidget, + this.emptyWidget, + }); + + @override + State> createState() => _CachedLazyLoadingListState(); +} + +class _CachedLazyLoadingListState extends State> { + final Map> _pageCache = {}; + + Future> _loadWithCache(int page, int pageSize) async { + // ์บ์‹œ ํ™•์ธ + if (_pageCache.containsKey(page)) { + return _pageCache[page]!; + } + + // ๋ฐ์ดํ„ฐ ๋กœ๋“œ + final items = await widget.loadMore(page, pageSize); + + // ์บ์‹œ ์ €์žฅ + _pageCache[page] = items; + + // ์ผ์ • ์‹œ๊ฐ„ ํ›„ ์บ์‹œ ์ œ๊ฑฐ + Timer(widget.cacheDuration, () { + if (mounted) { + setState(() { + _pageCache.remove(page); + }); + } + }); + + return items; + } + + @override + Widget build(BuildContext context) { + return LazyLoadingList( + loadMore: _loadWithCache, + itemBuilder: widget.itemBuilder, + pageSize: widget.pageSize, + loadingWidget: widget.loadingWidget, + emptyWidget: widget.emptyWidget, + ); + } +} + +/// ๋ฌดํ•œ ์Šคํฌ๋กค ๊ทธ๋ฆฌ๋“œ ๋ทฐ +class LazyLoadingGrid extends StatefulWidget { + final Future> Function(int page, int pageSize) loadMore; + final Widget Function(BuildContext, T, int) itemBuilder; + final int crossAxisCount; + final int pageSize; + final double scrollThreshold; + final double childAspectRatio; + final double crossAxisSpacing; + final double mainAxisSpacing; + final EdgeInsetsGeometry? padding; + + const LazyLoadingGrid({ + super.key, + required this.loadMore, + required this.itemBuilder, + required this.crossAxisCount, + this.pageSize = 20, + this.scrollThreshold = 0.8, + this.childAspectRatio = 1.0, + this.crossAxisSpacing = 8.0, + this.mainAxisSpacing = 8.0, + this.padding, + }); + + @override + State> createState() => _LazyLoadingGridState(); +} + +class _LazyLoadingGridState extends State> { + final List _items = []; + final ScrollController _scrollController = ScrollController(); + + int _currentPage = 0; + bool _isLoading = false; + bool _hasMore = true; + + @override + void initState() { + super.initState(); + _loadInitialData(); + _scrollController.addListener(_onScroll); + } + + @override + void dispose() { + _scrollController.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_isLoading || !_hasMore) return; + + final position = _scrollController.position; + final maxScroll = position.maxScrollExtent; + final currentScroll = position.pixels; + + if (currentScroll >= maxScroll * widget.scrollThreshold) { + _loadMoreData(); + } + } + + Future _loadInitialData() async { + setState(() => _isLoading = true); + + final newItems = await widget.loadMore(0, widget.pageSize); + + setState(() { + _items.clear(); + _items.addAll(newItems); + _currentPage = 0; + _hasMore = newItems.length >= widget.pageSize; + _isLoading = false; + }); + } + + Future _loadMoreData() async { + if (_isLoading || !_hasMore) return; + + setState(() => _isLoading = true); + + final nextPage = _currentPage + 1; + final newItems = await widget.loadMore(nextPage, widget.pageSize); + + setState(() { + _items.addAll(newItems); + _currentPage = nextPage; + _hasMore = newItems.length >= widget.pageSize; + _isLoading = false; + }); + } + + @override + Widget build(BuildContext context) { + return GridView.builder( + controller: _scrollController, + padding: widget.padding, + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: widget.crossAxisCount, + childAspectRatio: widget.childAspectRatio, + crossAxisSpacing: widget.crossAxisSpacing, + mainAxisSpacing: widget.mainAxisSpacing, + ), + itemCount: _items.length + (_isLoading ? widget.crossAxisCount : 0), + itemBuilder: (context, index) { + if (index < _items.length) { + return widget.itemBuilder(context, _items[index], index); + } + + // ๋กœ๋”ฉ ์Šค์ผˆ๋ ˆํ†ค + return const SkeletonLoading( + height: 100, + borderRadius: 12, + ); + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index 2bb6f07..4f1f1a3 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -5,18 +5,17 @@ import '../providers/subscription_provider.dart'; import '../theme/app_colors.dart'; import '../utils/format_helper.dart'; import 'animated_wave_background.dart'; +import 'glassmorphism_card.dart'; /// ๋ฉ”์ธ ํ™”๋ฉด ์ƒ๋‹จ์— ํ‘œ์‹œ๋˜๋Š” ์š”์•ฝ ์นด๋“œ ์œ„์ ฏ /// -/// ์ด ๊ตฌ๋… ์ˆ˜์™€ ์›”๋ณ„ ์ด ์ง€์ถœ์„ ํ‘œ์‹œํ•˜๋ฉฐ, ๋ถ„์„ ํ™”๋ฉด์œผ๋กœ ์ด๋™ํ•˜๋Š” ๊ธฐ๋Šฅ์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +/// ์ด ๊ตฌ๋… ์ˆ˜์™€ ์›”๋ณ„ ์ด ์ง€์ถœ์„ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. class MainScreenSummaryCard extends StatelessWidget { final SubscriptionProvider provider; final AnimationController fadeController; final AnimationController pulseController; final AnimationController waveController; final AnimationController slideController; - final VoidCallback onTap; - const MainScreenSummaryCard({ Key? key, required this.provider, @@ -24,7 +23,6 @@ class MainScreenSummaryCard extends StatelessWidget { required this.pulseController, required this.waveController, required this.slideController, - required this.onTap, }) : super(key: key); @override @@ -40,16 +38,20 @@ class MainScreenSummaryCard extends StatelessWidget { CurvedAnimation(parent: fadeController, curve: Curves.easeIn)), child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), - child: GestureDetector( - onTap: () { - HapticFeedback.mediumImpact(); - onTap(); - }, - child: Card( - elevation: 4, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), + child: GlassmorphismCard( + borderRadius: 24, + blur: 15, + backgroundColor: AppColors.primaryColor.withValues(alpha: 0.2), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + AppColors.primaryColor.withValues(alpha: 0.3), + AppColors.primaryColor.withBlue( + (AppColors.primaryColor.blue * 1.3) + .clamp(0, 255) + .toInt()).withValues(alpha: 0.2), + ], ), child: Container( width: double.infinity, @@ -59,17 +61,7 @@ class MainScreenSummaryCard extends StatelessWidget { ), decoration: BoxDecoration( borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - AppColors.primaryColor, - AppColors.primaryColor.withBlue( - (AppColors.primaryColor.blue * 1.3) - .clamp(0, 255) - .toInt()), - ], - ), + color: Colors.transparent, ), child: ClipRRect( borderRadius: BorderRadius.circular(24), @@ -91,7 +83,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( '์ด๋ฒˆ ๋‹ฌ ์ด ๊ตฌ๋… ๋น„์šฉ', style: TextStyle( - color: Colors.white.withOpacity(0.9), + color: Colors.white.withValues(alpha: 0.9), fontSize: 15, fontWeight: FontWeight.w500, ), @@ -118,7 +110,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( '์›', style: TextStyle( - color: Colors.white.withOpacity(0.9), + color: Colors.white.withValues(alpha: 0.9), fontSize: 16, fontWeight: FontWeight.w500, ), @@ -153,13 +145,13 @@ class MainScreenSummaryCard extends StatelessWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - Colors.white.withOpacity(0.2), - Colors.white.withOpacity(0.15), + Colors.white.withValues(alpha: 0.2), + Colors.white.withValues(alpha: 0.15), ], ), borderRadius: BorderRadius.circular(12), border: Border.all( - color: Colors.white.withOpacity(0.3), + color: Colors.white.withValues(alpha: 0.3), width: 1, ), ), @@ -169,7 +161,7 @@ class MainScreenSummaryCard extends StatelessWidget { Container( padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.25), + color: Colors.white.withValues(alpha: 0.25), shape: BoxShape.circle, ), child: const Icon( @@ -185,7 +177,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( '์ด๋ฒคํŠธ ํ• ์ธ ์ค‘', style: TextStyle( - color: Colors.white.withOpacity(0.9), + color: Colors.white.withValues(alpha: 0.9), fontSize: 11, fontWeight: FontWeight.w500, ), @@ -208,7 +200,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( ' ์ ˆ์•ฝ ($activeEvents๊ฐœ)', style: TextStyle( - color: Colors.white.withOpacity(0.85), + color: Colors.white.withValues(alpha: 0.85), fontSize: 12, fontWeight: FontWeight.w500, ), @@ -224,20 +216,10 @@ class MainScreenSummaryCard extends StatelessWidget { ], ), ), - Positioned( - right: 16, - top: 16, - child: Icon( - Icons.arrow_forward_ios, - color: Colors.white.withOpacity(0.7), - size: 16, - ), - ), ], ), ), ), - ), ), ), ); @@ -249,7 +231,7 @@ class MainScreenSummaryCard extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), decoration: BoxDecoration( - color: Colors.white.withOpacity(0.15), + color: Colors.white.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(12), ), child: Column( @@ -258,7 +240,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( title, style: TextStyle( - color: Colors.white.withOpacity(0.85), + color: Colors.white.withValues(alpha: 0.85), fontSize: 12, fontWeight: FontWeight.w500, ), diff --git a/lib/widgets/native_ad_widget.dart b/lib/widgets/native_ad_widget.dart index 5ec83b0..5a18277 100644 --- a/lib/widgets/native_ad_widget.dart +++ b/lib/widgets/native_ad_widget.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:google_mobile_ads/google_mobile_ads.dart'; import 'package:flutter/foundation.dart' show kIsWeb; import 'dart:io' show Platform; +import 'glassmorphism_card.dart'; /// ๊ตฌ๊ธ€ ๋„ค์ดํ‹ฐ๋ธŒ ๊ด‘๊ณ  ์œ„์ ฏ (AdMob NativeAd) /// SRP์— ๋”ฐ๋ผ ๊ด‘๊ณ  ์ „์šฉ ์œ„์ ฏ์œผ๋กœ ๋ถ„๋ฆฌ @@ -84,9 +85,10 @@ class _NativeAdWidgetState extends State { Widget _buildWebPlaceholder() { return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: GlassmorphismCard( + borderRadius: 16, + blur: 10, + opacity: 0.1, child: Container( height: 80, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -186,9 +188,10 @@ class _NativeAdWidgetState extends State { // ๊ด‘๊ณ  ์ •์ƒ ๋…ธ์ถœ return Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Card( - elevation: 2, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: GlassmorphismCard( + borderRadius: 16, + blur: 10, + opacity: 0.1, child: SizedBox( height: 80, // ๋„ค์ดํ‹ฐ๋ธŒ ๊ด‘๊ณ  ๋†’์ด ์กฐ์ • child: AdWidget(ad: _nativeAd!), diff --git a/lib/widgets/skeleton_loading.dart b/lib/widgets/skeleton_loading.dart index 0ade582..f3ff7bc 100644 --- a/lib/widgets/skeleton_loading.dart +++ b/lib/widgets/skeleton_loading.dart @@ -1,24 +1,44 @@ import 'package:flutter/material.dart'; +import 'glassmorphism_card.dart'; class SkeletonLoading extends StatelessWidget { - const SkeletonLoading({Key? key}) : super(key: key); + final double? width; + final double? height; + final double borderRadius; + + const SkeletonLoading({ + Key? key, + this.width, + this.height, + this.borderRadius = 8.0, + }) : super(key: key); @override Widget build(BuildContext context) { + // ๋‹จ์ผ ์Šค์ผˆ๋ ˆํ†ค ์•„์ดํ…œ์ด ์š”์ฒญ๋œ ๊ฒฝ์šฐ + if (width != null || height != null) { + return _buildSingleSkeleton(); + } + + // ๊ธฐ๋ณธ ์ „์ฒด ํ™”๋ฉด ์Šค์ผˆ๋ ˆํ†ค return Column( children: [ // ์š”์•ฝ ์นด๋“œ ์Šค์ผˆ๋ ˆํ†ค - Card( + GlassmorphismCard( margin: const EdgeInsets.all(16), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( + padding: const EdgeInsets.all(16.0), + blur: 10, + opacity: 0.1, + child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 100, height: 24, - color: Colors.grey[300], + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), ), const SizedBox(height: 16), Row( @@ -29,7 +49,6 @@ class SkeletonLoading extends StatelessWidget { ], ), ], - ), ), ), // ๊ตฌ๋… ๋ชฉ๋ก ์Šค์ผˆ๋ ˆํ†ค @@ -37,32 +56,47 @@ class SkeletonLoading extends StatelessWidget { child: ListView.builder( itemCount: 5, itemBuilder: (context, index) { - return Card( + return GlassmorphismCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: ListTile( - contentPadding: const EdgeInsets.all(16), - title: Container( - width: 200, - height: 24, - color: Colors.grey[300], - ), - subtitle: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 8), - Container( - width: 150, - height: 16, - color: Colors.grey[300], + padding: const EdgeInsets.all(16), + blur: 10, + opacity: 0.1, + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + width: 200, + height: 24, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 8), + Container( + width: 150, + height: 16, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 4), + Container( + width: 180, + height: 16, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), + ), + ], ), - const SizedBox(height: 4), - Container( - width: 180, - height: 16, - color: Colors.grey[300], - ), - ], - ), + ), + ], ), ); }, @@ -72,6 +106,32 @@ class SkeletonLoading extends StatelessWidget { ); } + Widget _buildSingleSkeleton() { + return Container( + width: width, + height: height, + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(borderRadius), + ), + child: AnimatedContainer( + duration: const Duration(milliseconds: 1500), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(borderRadius), + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [ + Colors.grey[300]!, + Colors.grey[100]!, + Colors.grey[300]!, + ], + ), + ), + ), + ); + } + Widget _buildSkeletonColumn() { return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -79,15 +139,21 @@ class SkeletonLoading extends StatelessWidget { Container( width: 80, height: 16, - color: Colors.grey[300], + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), ), const SizedBox(height: 4), Container( width: 100, height: 24, - color: Colors.grey[300], + decoration: BoxDecoration( + color: Colors.grey[300], + borderRadius: BorderRadius.circular(4), + ), ), ], ); } -} +} \ No newline at end of file diff --git a/lib/widgets/spring_animation_widget.dart b/lib/widgets/spring_animation_widget.dart new file mode 100644 index 0000000..d42483a --- /dev/null +++ b/lib/widgets/spring_animation_widget.dart @@ -0,0 +1,350 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/physics.dart'; +import 'dart:math' as math; + +/// ๋ฌผ๋ฆฌ ๊ธฐ๋ฐ˜ ์Šคํ”„๋ง ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ ์šฉํ•˜๋Š” ์œ„์ ฏ +class SpringAnimationWidget extends StatefulWidget { + final Widget child; + final Duration delay; + final SpringDescription spring; + final Offset? initialOffset; + final double? initialScale; + final double? initialRotation; + + const SpringAnimationWidget({ + super.key, + required this.child, + this.delay = Duration.zero, + this.spring = const SpringDescription( + mass: 1, + stiffness: 100, + damping: 10, + ), + this.initialOffset, + this.initialScale, + this.initialRotation, + }); + + @override + State createState() => _SpringAnimationWidgetState(); +} + +class _SpringAnimationWidgetState extends State + with TickerProviderStateMixin { + late AnimationController _controller; + late Animation _offsetAnimation; + late Animation _scaleAnimation; + late Animation _rotationAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + ); + + // ์Šคํ”„๋ง ์‹œ๋ฎฌ๋ ˆ์ด์…˜ + final simulation = SpringSimulation( + widget.spring, + 0.0, + 1.0, + 0.0, + ); + + // ์˜คํ”„์…‹ ์• ๋‹ˆ๋ฉ”์ด์…˜ + _offsetAnimation = Tween( + begin: widget.initialOffset ?? const Offset(0, 50), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.elasticOut, + )); + + // ์Šค์ผ€์ผ ์• ๋‹ˆ๋ฉ”์ด์…˜ + _scaleAnimation = Tween( + begin: widget.initialScale ?? 0.5, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.elasticOut, + )); + + // ํšŒ์ „ ์• ๋‹ˆ๋ฉ”์ด์…˜ + _rotationAnimation = Tween( + begin: widget.initialRotation ?? 0.0, + end: 0.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.elasticOut, + )); + + // ์ง€์—ฐ ํ›„ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹œ์ž‘ + Future.delayed(widget.delay, () { + if (mounted) { + _controller.forward(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.translate( + offset: _offsetAnimation.value, + child: Transform.scale( + scale: _scaleAnimation.value, + child: Transform.rotate( + angle: _rotationAnimation.value, + child: child, + ), + ), + ); + }, + child: widget.child, + ); + } +} + +/// ๋ฐ”์šด์Šค ํšจ๊ณผ๊ฐ€ ์žˆ๋Š” ๋ฒ„ํŠผ +class BouncyButton extends StatefulWidget { + final Widget child; + final VoidCallback? onPressed; + final EdgeInsetsGeometry? padding; + final BoxDecoration? decoration; + + const BouncyButton({ + super.key, + required this.child, + this.onPressed, + this.padding, + this.decoration, + }); + + @override + State createState() => _BouncyButtonState(); +} + +class _BouncyButtonState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 200), + vsync: this, + ); + + _scaleAnimation = Tween( + begin: 1.0, + end: 0.95, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + _controller.forward(); + } + + void _handleTapUp(TapUpDetails details) { + _controller.reverse(); + widget.onPressed?.call(); + } + + void _handleTapCancel() { + _controller.reverse(); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onTapCancel: _handleTapCancel, + child: AnimatedBuilder( + animation: _scaleAnimation, + builder: (context, child) { + return Transform.scale( + scale: _scaleAnimation.value, + child: Container( + padding: widget.padding, + decoration: widget.decoration, + child: widget.child, + ), + ); + }, + ), + ); + } +} + +/// ์ค‘๋ ฅ ํšจ๊ณผ ์• ๋‹ˆ๋ฉ”์ด์…˜ +class GravityAnimation extends StatefulWidget { + final Widget child; + final double gravity; + final double bounceFactor; + final double initialVelocity; + + const GravityAnimation({ + super.key, + required this.child, + this.gravity = 9.8, + this.bounceFactor = 0.8, + this.initialVelocity = 0, + }); + + @override + State createState() => _GravityAnimationState(); +} + +class _GravityAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + double _position = 0; + double _velocity = 0; + double _floor = 300; + + @override + void initState() { + super.initState(); + _velocity = widget.initialVelocity; + _controller = AnimationController( + vsync: this, + duration: const Duration(seconds: 10), + )..addListener(_updatePhysics); + + _controller.repeat(); + } + + void _updatePhysics() { + setState(() { + // ์†๋„ ์—…๋ฐ์ดํŠธ (์ค‘๋ ฅ ์ ์šฉ) + _velocity += widget.gravity * 0.016; // 60fps ๊ฐ€์ • + + // ์œ„์น˜ ์—…๋ฐ์ดํŠธ + _position += _velocity; + + // ๋ฐ”๋‹ฅ ์ถฉ๋Œ ๊ฐ์ง€ + if (_position >= _floor) { + _position = _floor; + _velocity = -_velocity * widget.bounceFactor; + + // ๋„ˆ๋ฌด ์ž‘์€ ๋ฐ”์šด์Šค๋Š” ๋ฉˆ์ถค + if (_velocity.abs() < 1) { + _velocity = 0; + } + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Transform.translate( + offset: Offset(0, _position), + child: widget.child, + ); + } +} + +/// ๋ฌผ๊ฒฐ ํšจ๊ณผ ์• ๋‹ˆ๋ฉ”์ด์…˜ +class RippleAnimation extends StatefulWidget { + final Widget child; + final Color rippleColor; + final Duration duration; + + const RippleAnimation({ + super.key, + required this.child, + this.rippleColor = Colors.blue, + this.duration = const Duration(milliseconds: 600), + }); + + @override + State createState() => _RippleAnimationState(); +} + +class _RippleAnimationState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + + _animation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTap() { + _controller.forward(from: 0.0); + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _handleTap, + child: Stack( + alignment: Alignment.center, + children: [ + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: 100 + 200 * _animation.value, + height: 100 + 200 * _animation.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: widget.rippleColor.withValues(alpha: + (1 - _animation.value) * 0.3, + ), + ), + ); + }, + ), + widget.child, + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/staggered_list_animation.dart b/lib/widgets/staggered_list_animation.dart new file mode 100644 index 0000000..28f8683 --- /dev/null +++ b/lib/widgets/staggered_list_animation.dart @@ -0,0 +1,302 @@ +import 'package:flutter/material.dart'; +import 'dart:math' as math; + +/// ์Šคํƒœ๊ฑฐ ์• ๋‹ˆ๋ฉ”์ด์…˜์ด ์ ์šฉ๋œ ๋ฆฌ์ŠคํŠธ ์œ„์ ฏ +class StaggeredListAnimation extends StatefulWidget { + final List children; + final Duration itemDelay; + final Duration animationDuration; + final Curve curve; + final Axis direction; + + const StaggeredListAnimation({ + super.key, + required this.children, + this.itemDelay = const Duration(milliseconds: 100), + this.animationDuration = const Duration(milliseconds: 500), + this.curve = Curves.easeOutBack, + this.direction = Axis.vertical, + }); + + @override + State createState() => _StaggeredListAnimationState(); +} + +class _StaggeredListAnimationState extends State + with TickerProviderStateMixin { + final List _controllers = []; + final List> _fadeAnimations = []; + final List> _slideAnimations = []; + final List> _scaleAnimations = []; + + @override + void initState() { + super.initState(); + _createAnimations(); + _startAnimations(); + } + + void _createAnimations() { + for (int i = 0; i < widget.children.length; i++) { + final controller = AnimationController( + duration: widget.animationDuration, + vsync: this, + ); + + final fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: controller, + curve: widget.curve, + )); + + final slideAnimation = Tween( + begin: widget.direction == Axis.vertical + ? const Offset(0, 0.3) + : const Offset(0.3, 0), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller, + curve: widget.curve, + )); + + final scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: controller, + curve: widget.curve, + )); + + _controllers.add(controller); + _fadeAnimations.add(fadeAnimation); + _slideAnimations.add(slideAnimation); + _scaleAnimations.add(scaleAnimation); + } + } + + void _startAnimations() async { + for (int i = 0; i < _controllers.length; i++) { + await Future.delayed(widget.itemDelay * i); + if (mounted) { + _controllers[i].forward(); + } + } + } + + @override + void dispose() { + for (final controller in _controllers) { + controller.dispose(); + } + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return widget.direction == Axis.vertical + ? Column( + children: _buildAnimatedChildren(), + ) + : Row( + children: _buildAnimatedChildren(), + ); + } + + List _buildAnimatedChildren() { + return List.generate(widget.children.length, (index) { + return AnimatedBuilder( + animation: _controllers[index], + builder: (context, child) { + return FadeTransition( + opacity: _fadeAnimations[index], + child: SlideTransition( + position: _slideAnimations[index], + child: ScaleTransition( + scale: _scaleAnimations[index], + child: widget.children[index], + ), + ), + ); + }, + ); + }); + } +} + +/// ๊ฐœ๋ณ„ ์Šคํƒœ๊ฑฐ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์•„์ดํ…œ +class StaggeredAnimationItem extends StatefulWidget { + final Widget child; + final int index; + final Duration delay; + final Duration duration; + final Curve curve; + + const StaggeredAnimationItem({ + super.key, + required this.child, + required this.index, + this.delay = const Duration(milliseconds: 100), + this.duration = const Duration(milliseconds: 500), + this.curve = Curves.easeOutBack, + }); + + @override + State createState() => _StaggeredAnimationItemState(); +} + +class _StaggeredAnimationItemState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _fadeAnimation; + late Animation _slideAnimation; + late Animation _scaleAnimation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: widget.curve, + )); + + _slideAnimation = Tween( + begin: const Offset(0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _controller, + curve: widget.curve, + )); + + _scaleAnimation = Tween( + begin: 0.8, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: widget.curve, + )); + + // ์ง€์—ฐ ํ›„ ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹œ์ž‘ + Future.delayed(widget.delay * widget.index, () { + if (mounted) { + _controller.forward(); + } + }); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: ScaleTransition( + scale: _scaleAnimation, + child: widget.child, + ), + ), + ); + }, + ); + } +} + +/// ์นด๋“œ ํ”Œ๋ฆฝ ์• ๋‹ˆ๋ฉ”์ด์…˜ +class FlipAnimationCard extends StatefulWidget { + final Widget front; + final Widget back; + final Duration duration; + + const FlipAnimationCard({ + super.key, + required this.front, + required this.back, + this.duration = const Duration(milliseconds: 800), + }); + + @override + State createState() => _FlipAnimationCardState(); +} + +class _FlipAnimationCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + bool _isFlipped = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: widget.duration, + vsync: this, + ); + + _animation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _flip() { + if (_isFlipped) { + _controller.reverse(); + } else { + _controller.forward(); + } + _isFlipped = !_isFlipped; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: _flip, + child: AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final isShowingFront = _animation.value < 0.5; + return Transform( + alignment: Alignment.center, + transform: Matrix4.identity() + ..setEntry(3, 2, 0.001) + ..rotateY(math.pi * _animation.value), + child: isShowingFront + ? widget.front + : Transform( + alignment: Alignment.center, + transform: Matrix4.identity()..rotateY(math.pi), + child: widget.back, + ), + ); + }, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index d48b583..d781aec 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -1,12 +1,15 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import 'dart:math' as math; import '../models/subscription_model.dart'; import '../screens/detail_screen.dart'; import 'website_icon.dart'; +import 'app_navigator.dart'; import '../theme/app_colors.dart'; import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; +import 'glassmorphism_card.dart'; class SubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; @@ -230,67 +233,30 @@ class _SubscriptionCardState extends State color: Colors.transparent, child: InkWell( onTap: () async { - final result = await Navigator.push( - context, - PageRouteBuilder( - pageBuilder: (context, animation, secondaryAnimation) => - DetailScreen(subscription: widget.subscription), - transitionsBuilder: - (context, animation, secondaryAnimation, child) { - const begin = Offset(0.0, 0.05); - const end = Offset.zero; - const curve = Curves.easeOutCubic; - - var tween = Tween(begin: begin, end: end) - .chain(CurveTween(curve: curve)); - - var fadeAnimation = - Tween(begin: 0.6, end: 1.0) - .chain(CurveTween(curve: curve)) - .animate(animation); - - return FadeTransition( - opacity: fadeAnimation, - child: SlideTransition( - position: animation.drive(tween), - child: child, - ), - ); - }, - ), - ); - - if (result == true) { - // ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ์žˆ์„ ๊ฒฝ์šฐ ๋ฏธ๋ฆฌ ์ €์žฅ๋œ Provider ์ฐธ์กฐ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ ๊ตฌ๋… ๋ชฉ๋ก ๊ฐฑ์‹  - await _subscriptionProvider.refreshSubscriptions(); - - // ๋ฉ”์ธ ํ™”๋ฉด์˜ State๋ฅผ ๊ฐฑ์‹ ํ•˜๊ธฐ ์œ„ํ•ด ๋ฏธ์„ธํ•œ ์ง€์—ฐ ํ›„ ๋‹ค์‹œ ํ•œ๋ฒˆ ์•Œ๋ฆผ - // mounted ์ƒํƒœ๋ฅผ ํ™•์ธํ•˜์—ฌ dispose๋œ ์œ„์ ฏ์—์„œ Provider๋ฅผ ์ฐธ์กฐํ•˜์ง€ ์•Š๋„๋ก ํ•ฉ๋‹ˆ๋‹ค. - Future.delayed(const Duration(milliseconds: 100), () { - // ์œ„์ ฏ์ด ์•„์ง ๋งˆ์šดํŠธ ์ƒํƒœ์ธ์ง€ ํ™•์ธํ•˜๊ณ , ๋ฏธ๋ฆฌ ์ €์žฅ๋œ Provider ์ฐธ์กฐ ์‚ฌ์šฉ - if (mounted) { - _subscriptionProvider.notifyListeners(); - } - }); - } + await AppNavigator.toDetail(context, widget.subscription); }, - splashColor: AppColors.primaryColor.withOpacity(0.1), - highlightColor: AppColors.primaryColor.withOpacity(0.05), + splashColor: AppColors.primaryColor.withValues(alpha: 0.1), + highlightColor: AppColors.primaryColor.withValues(alpha: 0.05), borderRadius: BorderRadius.circular(16), - child: Container( + child: AnimatedGlassmorphismCard( + onTap: () {}, // onTap์€ ์ด๋ฏธ InkWell์—์„œ ์ฒ˜๋ฆฌ๋จ + padding: EdgeInsets.zero, + borderRadius: 16, + blur: _isHovering ? 15 : 10, + child: Container( clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: cardColor, borderRadius: BorderRadius.circular(16), border: Border.all( color: _isHovering - ? AppColors.primaryColor.withOpacity(0.3) + ? AppColors.primaryColor.withValues(alpha: 0.3) : AppColors.borderColor, width: _isHovering ? 1.5 : 0.5, ), boxShadow: [ BoxShadow( - color: AppColors.primaryColor.withOpacity( + color: AppColors.primaryColor.withValues(alpha: 0.03 + (0.05 * _hoverController.value)), blurRadius: 8 + (8 * _hoverController.value), spreadRadius: 0, @@ -502,9 +468,9 @@ class _SubscriptionCardState extends State decoration: BoxDecoration( color: isNearBilling ? AppColors.warningColor - .withOpacity(0.1) + .withValues(alpha: 0.1) : AppColors.successColor - .withOpacity(0.1), + .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(12), ), @@ -551,7 +517,7 @@ class _SubscriptionCardState extends State vertical: 2, ), decoration: BoxDecoration( - color: const Color(0xFFFF6B6B).withOpacity(0.1), + color: const Color(0xFFFF6B6B).withValues(alpha: 0.1), borderRadius: BorderRadius.circular(8), ), child: Row( @@ -607,6 +573,7 @@ class _SubscriptionCardState extends State ], ), ), + ), ), ), ); diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index bbf4761..fb53561 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -2,6 +2,13 @@ import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; import '../widgets/subscription_card.dart'; import '../widgets/category_header_widget.dart'; +import '../widgets/swipeable_subscription_card.dart'; +import '../widgets/staggered_list_animation.dart'; +import '../screens/detail_screen.dart'; +import '../widgets/animated_page_transitions.dart'; +import '../widgets/app_navigator.dart'; +import 'package:provider/provider.dart'; +import '../providers/subscription_provider.dart'; /// ์นดํ…Œ๊ณ ๋ฆฌ๋ณ„๋กœ ๊ตฌ๋… ๋ชฉ๋ก์„ ํ‘œ์‹œํ•˜๋Š” ์œ„์ ฏ class SubscriptionListWidget extends StatelessWidget { @@ -75,8 +82,29 @@ class SubscriptionListWidget extends StatelessWidget { curve: Curves.easeOut))), child: Padding( padding: const EdgeInsets.only(bottom: 12.0), - child: SubscriptionCard( - subscription: subscriptions[subIndex], + child: StaggeredAnimationItem( + index: subIndex, + delay: const Duration(milliseconds: 50), + child: SwipeableSubscriptionCard( + subscription: subscriptions[subIndex], + onTap: () { + AppNavigator.toDetail(context, subscriptions[subIndex]); + }, + onEdit: () { + // ํŽธ์ง‘ ํ™”๋ฉด์œผ๋กœ ์ด๋™ + AppNavigator.toDetail(context, subscriptions[subIndex]); + }, + onDelete: () async { + // ์‚ญ์ œ ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ + final provider = Provider.of( + context, + listen: false, + ); + await provider.deleteSubscription( + subscriptions[subIndex].id, + ); + }, + ), ), ), ); diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart new file mode 100644 index 0000000..3cccc6d --- /dev/null +++ b/lib/widgets/swipeable_subscription_card.dart @@ -0,0 +1,227 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'dart:math' as math; +import '../models/subscription_model.dart'; +import '../utils/haptic_feedback_helper.dart'; +import 'subscription_card.dart'; +import '../theme/app_colors.dart'; + +class SwipeableSubscriptionCard extends StatefulWidget { + final SubscriptionModel subscription; + final VoidCallback? onEdit; + final VoidCallback? onDelete; + final VoidCallback? onTap; + + const SwipeableSubscriptionCard({ + super.key, + required this.subscription, + this.onEdit, + this.onDelete, + this.onTap, + }); + + @override + State createState() => _SwipeableSubscriptionCardState(); +} + +class _SwipeableSubscriptionCardState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + double _dragStartX = 0; + double _dragExtent = 0; + bool _isSwipingLeft = false; + bool _hapticTriggered = false; + + static const double _swipeThreshold = 80.0; + static const double _deleteThreshold = 150.0; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(milliseconds: 300), + vsync: this, + ); + _animation = Tween( + begin: 0.0, + end: 0.0, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutExpo, + )); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleDragStart(DragStartDetails details) { + _dragStartX = details.localPosition.dx; + _hapticTriggered = false; + } + + void _handleDragUpdate(DragUpdateDetails details) { + final delta = details.localPosition.dx - _dragStartX; + setState(() { + _dragExtent = delta; + _isSwipingLeft = delta < 0; + }); + + // ํ–…ํ‹ฑ ํ”ผ๋“œ๋ฐฑ ํŠธ๋ฆฌ๊ฑฐ + if (!_hapticTriggered && _dragExtent.abs() > _swipeThreshold) { + _hapticTriggered = true; + HapticFeedbackHelper.mediumImpact(); + } + + // ์‚ญ์ œ ์ž„๊ณ„๊ฐ’์— ๋„๋‹ฌํ–ˆ์„ ๋•Œ ๊ฐ•ํ•œ ํ–…ํ‹ฑ + if (_dragExtent.abs() > _deleteThreshold && _hapticTriggered) { + HapticFeedbackHelper.heavyImpact(); + _hapticTriggered = false; // ๋ฐ˜๋ณต ๋ฐฉ์ง€ + } + } + + void _handleDragEnd(DragEndDetails details) { + final velocity = details.velocity.pixelsPerSecond.dx; + final extent = _dragExtent.abs(); + + if (extent > _deleteThreshold || velocity.abs() > 800) { + // ์‚ญ์ œ ์•ก์…˜ + if (_isSwipingLeft && widget.onDelete != null) { + HapticFeedbackHelper.success(); + _animateToOffset(-MediaQuery.of(context).size.width); + Future.delayed(const Duration(milliseconds: 300), () { + widget.onDelete!(); + }); + } else if (!_isSwipingLeft && widget.onEdit != null) { + HapticFeedbackHelper.success(); + _animateToOffset(MediaQuery.of(context).size.width); + Future.delayed(const Duration(milliseconds: 300), () { + widget.onEdit!(); + }); + } + } else if (extent > _swipeThreshold) { + // ์•ก์…˜ ๋ฒ„ํŠผ ํ‘œ์‹œ + HapticFeedbackHelper.lightImpact(); + _animateToOffset(_isSwipingLeft ? -_swipeThreshold : _swipeThreshold); + } else { + // ์›์œ„์น˜๋กœ ๋ณต๊ท€ + _animateToOffset(0); + } + } + + void _animateToOffset(double offset) { + _animation = Tween( + begin: _dragExtent, + end: offset, + ).animate(CurvedAnimation( + parent: _controller, + curve: Curves.easeOutExpo, + )); + _controller.forward(from: 0).then((_) { + setState(() { + _dragExtent = offset; + }); + }); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + // ๋ฐฐ๊ฒฝ ์•ก์…˜ ๋ฒ„ํŠผ๋“ค + Positioned.fill( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _isSwipingLeft + ? AppColors.dangerColor + : AppColors.primaryColor, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + // ํŽธ์ง‘ ๋ฒ„ํŠผ (์˜ค๋ฅธ์ชฝ ์Šค์™€์ดํ”„) + if (!_isSwipingLeft) + Padding( + padding: const EdgeInsets.only(left: 24), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _dragExtent > 40 ? 1.0 : 0.0, + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: _dragExtent > 40 ? 1.0 : 0.5, + child: const Icon( + Icons.edit_rounded, + color: Colors.white, + size: 28, + ), + ), + ), + ), + // ์‚ญ์ œ ๋ฒ„ํŠผ (์™ผ์ชฝ ์Šค์™€์ดํ”„) + if (_isSwipingLeft) + Padding( + padding: const EdgeInsets.only(right: 24), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: _dragExtent.abs() > 40 ? 1.0 : 0.0, + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: _dragExtent.abs() > 40 ? 1.0 : 0.5, + child: Icon( + _dragExtent.abs() > _deleteThreshold + ? Icons.delete_forever_rounded + : Icons.delete_rounded, + color: Colors.white, + size: 28, + ), + ), + ), + ), + ], + ), + ), + ), + + // ์Šค์™€์ดํ”„ ๊ฐ€๋Šฅํ•œ ์นด๋“œ + AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Transform.translate( + offset: Offset(_animation.value, 0), + child: child, + ); + }, + child: GestureDetector( + onHorizontalDragStart: _handleDragStart, + onHorizontalDragUpdate: _handleDragUpdate, + onHorizontalDragEnd: _handleDragEnd, + child: Transform.translate( + offset: Offset(_dragExtent, 0), + child: Transform.scale( + scale: 1.0 - (_dragExtent.abs() / 2000), + child: Transform.rotate( + angle: _dragExtent / 2000, + child: GestureDetector( + onTap: () { + if (_dragExtent.abs() < 10) { + widget.onTap?.call(); + } + }, + child: SubscriptionCard( + subscription: widget.subscription, + ), + ), + ), + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/themed_text.dart b/lib/widgets/themed_text.dart new file mode 100644 index 0000000..29367d5 --- /dev/null +++ b/lib/widgets/themed_text.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import '../theme/app_colors.dart'; + +/// ๋ฐฐ๊ฒฝ์— ๋”ฐ๋ผ ์ž๋™์œผ๋กœ ์ƒ‰์ƒ ๋Œ€๋น„๋ฅผ ์กฐ์ •ํ•˜๋Š” ํ…์ŠคํŠธ ์œ„์ ฏ +class ThemedText extends StatelessWidget { + final String text; + final TextStyle? style; + final TextAlign? textAlign; + final TextOverflow? overflow; + final int? maxLines; + final bool softWrap; + final bool forceLight; + final bool forceDark; + final double? opacity; + final double? fontSize; + final FontWeight? fontWeight; + final double? letterSpacing; + final Color? color; + + const ThemedText( + this.text, { + super.key, + this.style, + this.textAlign, + this.overflow, + this.maxLines, + this.softWrap = true, + this.forceLight = false, + this.forceDark = false, + this.opacity, + this.fontSize, + this.fontWeight, + this.letterSpacing, + this.color, + }); + + /// ๋ฐฐ๊ฒฝ ๋ฐ๊ธฐ์— ๋”ฐ๋ฅธ ํ…์ŠคํŠธ ์ƒ‰์ƒ ๊ฒฐ์ • + static Color getContrastColor(BuildContext context, { + bool forceLight = false, + bool forceDark = false, + }) { + if (forceLight) return Colors.white; + if (forceDark) return AppColors.textPrimary; + + final brightness = Theme.of(context).brightness; + final backgroundColor = Theme.of(context).scaffoldBackgroundColor; + + // ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ํ™˜๊ฒฝ์—์„œ๋Š” ๋ณดํ†ต ์–ด๋‘์šด ๋ฐฐ๊ฒฝ ์œ„์— ๋ฐ์€ ํ…์ŠคํŠธ + if (_isGlassmorphicContext(context)) { + return brightness == Brightness.dark + ? Colors.white.withValues(alpha: 0.95) + : AppColors.textPrimary; + } + + // ์ผ๋ฐ˜ ํ™˜๊ฒฝ + return brightness == Brightness.dark + ? Colors.white + : AppColors.textPrimary; + } + + /// ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ์ปจํ…์ŠคํŠธ์ธ์ง€ ํ™•์ธ + static bool _isGlassmorphicContext(BuildContext context) { + // ๋ถ€๋ชจ ์œ„์ ฏ ์ฒด์ธ์—์„œ ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ์นด๋“œ๊ฐ€ ์žˆ๋Š”์ง€ ํ™•์ธ + final glassmorphic = context.findAncestorWidgetOfExactType(); + return glassmorphic != null; + } + + @override + Widget build(BuildContext context) { + final textColor = color ?? getContrastColor( + context, + forceLight: forceLight, + forceDark: forceDark, + ); + + final finalColor = opacity != null + ? textColor.withValues(alpha: opacity!) + : textColor; + + final defaultStyle = DefaultTextStyle.of(context).style; + + // ๊ฐœ๋ณ„ ์Šคํƒ€์ผ ์†์„ฑ๋“ค์„ ๋ณ‘ํ•ฉ + final baseStyle = TextStyle( + fontSize: fontSize, + fontWeight: fontWeight, + letterSpacing: letterSpacing, + color: finalColor, + ); + + final effectiveStyle = defaultStyle.merge(baseStyle).merge(style); + + return Text( + text, + style: effectiveStyle, + textAlign: textAlign, + overflow: overflow, + maxLines: maxLines, + softWrap: softWrap, + ); + } + + /// ์ œ๋ชฉ์šฉ ์Šคํƒ€์ผ ํŒฉํ† ๋ฆฌ + static ThemedText headline({ + required String text, + TextStyle? style, + bool forceLight = false, + bool forceDark = false, + }) { + return ThemedText( + text, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.bold, + ).merge(style), + forceLight: forceLight, + forceDark: forceDark, + ); + } + + /// ๋ถ€์ œ๋ชฉ์šฉ ์Šคํƒ€์ผ ํŒฉํ† ๋ฆฌ + static ThemedText subtitle({ + required String text, + TextStyle? style, + bool forceLight = false, + bool forceDark = false, + double opacity = 0.8, + }) { + return ThemedText( + text, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ).merge(style), + forceLight: forceLight, + forceDark: forceDark, + opacity: opacity, + ); + } + + /// ๋ณธ๋ฌธ์šฉ ์Šคํƒ€์ผ ํŒฉํ† ๋ฆฌ + static ThemedText body({ + required String text, + TextStyle? style, + bool forceLight = false, + bool forceDark = false, + double opacity = 0.9, + }) { + return ThemedText( + text, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.normal, + ).merge(style), + forceLight: forceLight, + forceDark: forceDark, + opacity: opacity, + ); + } + + /// ์บก์…˜์šฉ ์Šคํƒ€์ผ ํŒฉํ† ๋ฆฌ + static ThemedText caption({ + required String text, + TextStyle? style, + bool forceLight = false, + bool forceDark = false, + double opacity = 0.7, + }) { + return ThemedText( + text, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.normal, + ).merge(style), + forceLight: forceLight, + forceDark: forceDark, + opacity: opacity, + ); + } +} + +/// ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ์ปจํ…์ŠคํŠธ๋ฅผ ํ‘œ์‹œํ•˜๋Š” ๋งˆ์ปค ์œ„์ ฏ +class GlassmorphicIndicator extends InheritedWidget { + const GlassmorphicIndicator({ + super.key, + required super.child, + }); + + static GlassmorphicIndicator? of(BuildContext context) { + return context.dependOnInheritedWidgetOfExactType(); + } + + @override + bool updateShouldNotify(GlassmorphicIndicator oldWidget) => false; +} + +/// ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ํ™˜๊ฒฝ์—์„œ ํ…์ŠคํŠธ ์ƒ‰์ƒ์„ ์ž๋™ ์กฐ์ •ํ•˜๋Š” ๋ž˜ํผ +class GlassmorphicTextWrapper extends StatelessWidget { + final Widget child; + + const GlassmorphicTextWrapper({ + super.key, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return GlassmorphicIndicator( + child: DefaultTextStyle( + style: DefaultTextStyle.of(context).style.copyWith( + color: ThemedText.getContrastColor(context), + ), + child: child, + ), + ); + } +} \ No newline at end of file diff --git a/macos/Podfile.lock b/macos/Podfile.lock new file mode 100644 index 0000000..ec872a9 --- /dev/null +++ b/macos/Podfile.lock @@ -0,0 +1,75 @@ +PODS: + - flutter_local_notifications (0.0.1): + - FlutterMacOS + - flutter_secure_storage_macos (6.1.3): + - FlutterMacOS + - FlutterMacOS (1.0.0) + - local_auth_darwin (0.0.1): + - Flutter + - FlutterMacOS + - path_provider_foundation (0.0.1): + - Flutter + - FlutterMacOS + - share_plus (0.0.1): + - FlutterMacOS + - shared_preferences_foundation (0.0.1): + - Flutter + - FlutterMacOS + - sqflite_darwin (0.0.4): + - Flutter + - FlutterMacOS + - url_launcher_macos (0.0.1): + - FlutterMacOS + - webview_flutter_wkwebview (0.0.1): + - Flutter + - FlutterMacOS + +DEPENDENCIES: + - flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`) + - flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`) + - FlutterMacOS (from `Flutter/ephemeral`) + - local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`) + - path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`) + - share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`) + - shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`) + - sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`) + - url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`) + - webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`) + +EXTERNAL SOURCES: + flutter_local_notifications: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos + flutter_secure_storage_macos: + :path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos + FlutterMacOS: + :path: Flutter/ephemeral + local_auth_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin + path_provider_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin + share_plus: + :path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos + shared_preferences_foundation: + :path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin + sqflite_darwin: + :path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin + url_launcher_macos: + :path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos + webview_flutter_wkwebview: + :path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin + +SPEC CHECKSUMS: + flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4 + flutter_secure_storage_macos: c2754d3483d20bb207bb9e5a14f1b8e771abcdb9 + FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 + local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3 + path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 + share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7 + shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 + sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d + url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404 + webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188 + +PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82 + +COCOAPODS: 1.16.2 diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index c7b805b..c53d1e9 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,8 @@ 33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; }; 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; + 72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4015C586B270010D4F62A7 /* Pods_Runner.framework */; }; + EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -64,7 +66,7 @@ 331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = ""; }; 335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = ""; }; - 33CC10ED2044A3C60003C045 /* submanager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "submanager.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 33CC10ED2044A3C60003C045 /* submanager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = submanager.app; sourceTree = BUILT_PRODUCTS_DIR; }; 33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = ""; }; 33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = ""; }; @@ -76,8 +78,16 @@ 33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = ""; }; 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; + 449D7C4E91ECC2307A618B21 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; + 4C4015C586B270010D4F62A7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; + 9119DFDCC41763FA448B6987 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; + 98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; + ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + BC9E591D2F7240B7A962E51A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; + F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; + FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -85,6 +95,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -92,12 +103,27 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 2A845BBB3A2FF55EFF11D802 /* Pods */ = { + isa = PBXGroup; + children = ( + 9119DFDCC41763FA448B6987 /* Pods-Runner.debug.xcconfig */, + BC9E591D2F7240B7A962E51A /* Pods-Runner.release.xcconfig */, + 449D7C4E91ECC2307A618B21 /* Pods-Runner.profile.xcconfig */, + F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */, + 98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */, + ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */, + ); + name = Pods; + path = Pods; + sourceTree = ""; + }; 331C80D6294CF71000263BE5 /* RunnerTests */ = { isa = PBXGroup; children = ( @@ -125,6 +151,7 @@ 331C80D6294CF71000263BE5 /* RunnerTests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, + 2A845BBB3A2FF55EFF11D802 /* Pods */, ); sourceTree = ""; }; @@ -175,6 +202,8 @@ D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( + 4C4015C586B270010D4F62A7 /* Pods_Runner.framework */, + FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -186,6 +215,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( + 451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */, 331C80D1294CF70F00263BE5 /* Sources */, 331C80D2294CF70F00263BE5 /* Frameworks */, 331C80D3294CF70F00263BE5 /* Resources */, @@ -204,11 +234,13 @@ isa = PBXNativeTarget; buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( + 7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */, 33CC10E92044A3C60003C045 /* Sources */, 33CC10EA2044A3C60003C045 /* Frameworks */, 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, + CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -329,6 +361,67 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; + 451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + 7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; + CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Embed Pods Frameworks"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -380,6 +473,7 @@ /* Begin XCBuildConfiguration section */ 331C80DB294CF71000263BE5 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -394,6 +488,7 @@ }; 331C80DC294CF71000263BE5 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; @@ -408,6 +503,7 @@ }; 331C80DD294CF71000263BE5 /* Profile */ = { isa = XCBuildConfiguration; + baseConfigurationReference = ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CURRENT_PROJECT_VERSION = 1; diff --git a/macos/Runner.xcworkspace/contents.xcworkspacedata b/macos/Runner.xcworkspace/contents.xcworkspacedata index 1d526a1..21a3cc1 100644 --- a/macos/Runner.xcworkspace/contents.xcworkspacedata +++ b/macos/Runner.xcworkspace/contents.xcworkspacedata @@ -4,4 +4,7 @@ + +