From 83c5e3d64e4c922ba97b0e2d5ffbfc52077b4489 Mon Sep 17 00:00:00 2001 From: JiWoong Sul Date: Fri, 11 Jul 2025 00:21:18 +0900 Subject: [PATCH] Refactor screens to MVC architecture with modular widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extract business logic from screens into dedicated controllers - Split large screen files into smaller, reusable widget components - Add controllers for AddSubscriptionScreen and DetailScreen - Create modular widgets for subscription and detail features - Improve code organization and maintainability - Remove duplicated code and improve reusability ๐Ÿค– Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- doc/color.md | 113 +- .../add_subscription_controller.dart | 418 +++ lib/controllers/detail_screen_controller.dart | 422 +++ lib/providers/notification_provider.dart | 1 - lib/providers/subscription_provider.dart | 32 - lib/screens/add_subscription_screen.dart | 2022 +-------------- lib/screens/add_subscription_screen_old.dart | 2015 +++++++++++++++ lib/screens/app_lock_screen.dart | 2 +- lib/screens/category_management_screen.dart | 1 - lib/screens/detail_screen.dart | 2298 +---------------- lib/screens/detail_screen_old.dart | 2215 ++++++++++++++++ lib/screens/main_screen.dart | 16 +- lib/screens/settings_screen.dart | 73 +- lib/screens/sms_scan_screen.dart | 3 - lib/screens/splash_screen.dart | 8 +- lib/services/exchange_rate_service.dart | 1 - lib/services/notification_service.dart | 15 +- lib/services/sms_scanner.dart | 73 - lib/services/subscription_url_matcher.dart | 1 - lib/theme/adaptive_theme.dart | 6 +- lib/theme/app_theme.dart | 48 +- lib/utils/memory_manager.dart | 1 - lib/utils/performance_optimizer.dart | 1 - lib/utils/subscription_category_helper.dart | 1 - .../add_subscription_app_bar.dart | 95 + .../add_subscription_event_section.dart | 142 + .../add_subscription_form.dart | 431 ++++ .../add_subscription_header.dart | 88 + .../add_subscription_save_button.dart | 52 + lib/widgets/app_navigator.dart | 6 - lib/widgets/common/buttons/danger_button.dart | 173 ++ .../common/buttons/primary_button.dart | 112 + .../common/buttons/secondary_button.dart | 203 ++ lib/widgets/common/cards/section_card.dart | 229 ++ .../common/dialogs/confirmation_dialog.dart | 353 +++ .../common/dialogs/loading_overlay.dart | 238 ++ .../common/form_fields/base_text_field.dart | 145 ++ .../form_fields/currency_input_field.dart | 138 + .../common/form_fields/date_picker_field.dart | 263 ++ lib/widgets/detail/detail_action_buttons.dart | 46 + lib/widgets/detail/detail_event_section.dart | 221 ++ lib/widgets/detail/detail_form_section.dart | 370 +++ lib/widgets/detail/detail_header_section.dart | 247 ++ lib/widgets/detail/detail_url_section.dart | 178 ++ lib/widgets/exchange_rate_widget.dart | 2 +- lib/widgets/floating_navigation_bar.dart | 3 - lib/widgets/glassmorphic_scaffold.dart | 2 - lib/widgets/glassmorphism_card.dart | 10 - lib/widgets/home_content.dart | 9 +- lib/widgets/main_summary_card.dart | 2 - lib/widgets/spring_animation_widget.dart | 9 - lib/widgets/subscription_card.dart | 34 +- lib/widgets/subscription_list_widget.dart | 7 +- lib/widgets/swipeable_subscription_card.dart | 2 - lib/widgets/themed_text.dart | 1 - lib/widgets/website_icon.dart | 74 +- 56 files changed, 9092 insertions(+), 4579 deletions(-) create mode 100644 lib/controllers/add_subscription_controller.dart create mode 100644 lib/controllers/detail_screen_controller.dart create mode 100644 lib/screens/add_subscription_screen_old.dart create mode 100644 lib/screens/detail_screen_old.dart create mode 100644 lib/widgets/add_subscription/add_subscription_app_bar.dart create mode 100644 lib/widgets/add_subscription/add_subscription_event_section.dart create mode 100644 lib/widgets/add_subscription/add_subscription_form.dart create mode 100644 lib/widgets/add_subscription/add_subscription_header.dart create mode 100644 lib/widgets/add_subscription/add_subscription_save_button.dart create mode 100644 lib/widgets/common/buttons/danger_button.dart create mode 100644 lib/widgets/common/buttons/primary_button.dart create mode 100644 lib/widgets/common/buttons/secondary_button.dart create mode 100644 lib/widgets/common/cards/section_card.dart create mode 100644 lib/widgets/common/dialogs/confirmation_dialog.dart create mode 100644 lib/widgets/common/dialogs/loading_overlay.dart create mode 100644 lib/widgets/common/form_fields/base_text_field.dart create mode 100644 lib/widgets/common/form_fields/currency_input_field.dart create mode 100644 lib/widgets/common/form_fields/date_picker_field.dart create mode 100644 lib/widgets/detail/detail_action_buttons.dart create mode 100644 lib/widgets/detail/detail_event_section.dart create mode 100644 lib/widgets/detail/detail_form_section.dart create mode 100644 lib/widgets/detail/detail_header_section.dart create mode 100644 lib/widgets/detail/detail_url_section.dart diff --git a/doc/color.md b/doc/color.md index b358945..174753e 100644 --- a/doc/color.md +++ b/doc/color.md @@ -1,58 +1,79 @@ -## ๊ตฌ๋…๊ด€๋ฆฌ ์•ฑ ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜ ์ƒ‰์ƒ ๊ฐ€์ด๋“œ -**์‹ ๋ขฐ์„ฑ, ํŽธ์•ˆํ•จ, ํŠธ๋ Œ๋“œํ•จ**์„ ๋ชจ๋‘ ์žก๋Š” ์ปฌ๋Ÿฌ ์กฐํ•ฉ ์ถ”์ฒœ +# ๊ตฌ๋…๊ด€๋ฆฌ ์•ฑ ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜ ์ปฌ๋Ÿฌ & ํ…์ŠคํŠธ ์ปฌ๋Ÿฌ ๊ฐ€์ด๋“œ -### 1. ์ปฌ๋Ÿฌ ์„ ์ • ์›์น™ +๊ตฌ๋…๊ด€๋ฆฌ ์•ฑ์— ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜์„ ์ ์šฉํ•  ๋•Œ, **์‹ ๋ขฐ์„ฑ, ํŽธ์•ˆํ•จ, ํŠธ๋ Œ๋“œํ•จ**์„ ๋ชจ๋‘ ์žก์œผ๋ฉด์„œ๋„ **ํ…์ŠคํŠธ ๊ฐ€๋…์„ฑ**์„ ์ตœ์šฐ์„ ์œผ๋กœ ๊ณ ๋ คํ•œ ์ปฌ๋Ÿฌ ํŒ”๋ ˆํŠธ์™€ ํ™œ์šฉ๋ฒ•์„ ์•ˆ๋‚ดํ•ฉ๋‹ˆ๋‹ค. -- **์‹ ๋ขฐ์„ฑ:** ๋ธ”๋ฃจ ๊ณ„์—ด, ๊ทธ๋ ˆ์ด, ํ™”์ดํŠธ ๋“ฑ ์•ˆ์ •์ ์ด๊ณ  ์ „๋ฌธ์ ์ธ ๋А๋‚Œ์˜ ์ƒ‰์ƒ -- **ํŽธ์•ˆํ•จ:** ์ €์ฑ„๋„ ํŒŒ์Šคํ…”, ์—ฐํ•œ ๋ธ”๋ฃจยท๋ฏผํŠธ, ๋”ฐ๋œปํ•œ ๋ฒ ์ด์ง€ ๋“ฑ ๋ˆˆ์— ๋ถ€๋‹ด ์—†๋Š” ์ƒ‰์ƒ -- **ํŠธ๋ Œ๋“œํ•จ:** ๊ทธ๋ผ๋””์–ธํŠธ, ๋ฐ˜ํˆฌ๋ช… ๋ ˆ์ด์–ด, ์•ฝ๊ฐ„์˜ ๋„ค์˜จ ํฌ์ธํŠธ ๋“ฑ ํ˜„๋Œ€์  ๊ฐ๊ฐ +## 1. ์ปฌ๋Ÿฌ ํŒ”๋ ˆํŠธ ์ œ์•ˆ -### 2. ์ถ”์ฒœ ์ปฌ๋Ÿฌ ํŒ”๋ ˆํŠธ +| ์šฉ๋„ | ์ปฌ๋Ÿฌ๋ช… | Hex ์ฝ”๋“œ | ์„ค๋ช…/๋А๋‚Œ | +|--------------|--------------|--------------|--------------------------| +| ๋ฉ”์ธ | Deep Blue | #2563eb | ์‹ ๋ขฐ, ํฌ์ธํŠธ | +| ์„œ๋ธŒ | Sky Blue | #60a5fa | ํŠธ๋ Œ๋””, ๊ทธ๋ผ๋””์–ธํŠธ | +| ํฌ์ธํŠธ | Soft Mint | #38bdf8 | ์ƒ์พŒํ•จ, ํฌ์ธํŠธ | +| ๋ฐฐ๊ฒฝ | Light Gray | #f1f5f9 | ํŽธ์•ˆํ•จ, ๋ฐ์€ ๋ฐฐ๊ฒฝ | +| ๊ธ€๋ž˜์Šค ํšจ๊ณผ | White Glass | #ffffff(ํˆฌ๋ช…)| ๋ฐ˜ํˆฌ๋ช… ๊ธ€๋ž˜์Šค ํšจ๊ณผ | +| ํฌ์ธํŠธ | Pink Accent | #f472b6 | ํŠธ๋ Œ๋””, ์•ก์„ผํŠธ | +| ๊ทธ๋ฆผ์ž | Shadow Black | rgba(0,0,0,0.08) | ๊นŠ์ด๊ฐ ๋ถ€์—ฌ | -| ์šฉ๋„ | ์ถ”์ฒœ ์ƒ‰์ƒ ์˜ˆ์‹œ (Hex) | ์„ค๋ช… | -|--------------|-------------------------------|---------------------------------------| -| ๋ฉ”์ธ | #2563eb, #60a5fa, #e0e7ef | ์‹ ๋ขฐ๊ฐ ์ฃผ๋Š” ๋ธ”๋ฃจ ๊ณ„์—ด ๊ทธ๋ผ๋””์–ธํŠธ | -| ์„œ๋ธŒ | #f9fafb, #f1f5f9, #f3f4f6 | ๋ฐ์€ ํ™”์ดํŠธยท๊ทธ๋ ˆ์ด, ํŽธ์•ˆํ•œ ๋ฐฐ๊ฒฝ | -| ํฌ์ธํŠธ | #38bdf8, #7dd3fc, #f472b6 | ํŠธ๋ Œ๋””ํ•œ ๋ฏผํŠธ, ์—ฐํ•‘ํฌ, ๋ฐ์€ ๋ธ”๋ฃจ | -| ํ…Œ๋‘๋ฆฌ/๋ธ”๋Ÿฌ | rgba(255,255,255,0.3) | ๊ธ€๋ž˜์Šค ํšจ๊ณผ์šฉ ๋ฐ˜ํˆฌ๋ช… ํ™”์ดํŠธ | -| ๊ทธ๋ฆผ์ž | rgba(0,0,0,0.08) | ๋ถ€๋“œ๋Ÿฌ์šด ๊นŠ์ด๊ฐ ๋ถ€์—ฌ | +## 2. ํ…์ŠคํŠธ ์ƒ‰์ƒ ๊ฐ€์ด๋“œ -### 3. ์‹ค์ „ ์ ์šฉ ์˜ˆ์‹œ +๋ฐ์€ ๋ฐฐ๊ฒฝ(์˜ˆ: #f1f5f9, #ffffff(ํˆฌ๋ช…)) ์œ„์—๋Š” **์–ด๋‘์šด ํ…์ŠคํŠธ**๋ฅผ, +์ง„ํ•œ ์ปฌ๋Ÿฌ(์˜ˆ: #2563eb, #38bdf8) ์œ„์—๋Š” **๋ฐ์€ ํ…์ŠคํŠธ**๋ฅผ ์‚ฌ์šฉํ•ด์•ผ ๊ฐ€๋…์„ฑ์ด ์ข‹์Šต๋‹ˆ๋‹ค. -- **๋ฐฐ๊ฒฝ:** - ์—ฐํ•œ ๋ธ”๋ฃจ(#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)], - ) +| ๋ฐฐ๊ฒฝ ์ปฌ๋Ÿฌ | ์ถ”์ฒœ ํ…์ŠคํŠธ ์ปฌ๋Ÿฌ | ์šฉ๋„/์„ค๋ช… | +|------------------|----------------------|-----------------------------------| +| Light Gray (#f1f5f9) | Dark Navy (#1e293b) | ๋ฉ”์ธ ํ…์ŠคํŠธ, ํƒ€์ดํ‹€, ๋ฒ„ํŠผ | +| White Glass (ํˆฌ๋ช…) | Deep Blue (#2563eb) | ๊ฐ•์กฐ ํ…์ŠคํŠธ, ๋ฒ„ํŠผ | +| Deep Blue (#2563eb) | Pure White (#ffffff) | ๋ฒ„ํŠผ, ๋ฐ˜์ „ ํ…์ŠคํŠธ | +| Sky Blue (#60a5fa) | Navy Gray (#334155) | ์„œ๋ธŒ ํ…์ŠคํŠธ, ๋ถ€๊ฐ€ ์„ค๋ช… | +| Soft Mint (#38bdf8) | Navy Gray (#334155) | ํฌ์ธํŠธ ํ…์ŠคํŠธ | +| Pink Accent (#f472b6)| Deep Blue (#2563eb) | ๊ฐ•์กฐ, ํฌ์ธํŠธ ํ…์ŠคํŠธ | -### 4. ์ฐธ๊ณ  ํŒ +## 3. ์‹ค์ „ ์ ์šฉ ์˜ˆ์‹œ -- ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜์€ **ํˆฌ๋ช…๋„ยท๋ธ”๋Ÿฌ**์™€ ํ•จ๊ป˜ **๋ฐ๊ณ  ๊นจ๋—ํ•œ ์ƒ‰์ƒ**์„ ์กฐํ•ฉํ•˜๋ฉด ์‹ ๋ขฐ๊ฐ๊ณผ ํŠธ๋ Œ๋””ํ•จ์„ ๋™์‹œ์— ์ค„ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. -- ํฌ์ธํŠธ ์ปฌ๋Ÿฌ๋ฅผ ๋„ˆ๋ฌด ๊ฐ•ํ•˜๊ฒŒ ์“ฐ๊ธฐ๋ณด๋‹ค๋Š”, ์ „์ฒด์ ์œผ๋กœ **๋ฐ๊ณ  ๋ถ€๋“œ๋Ÿฌ์šด ํ†ค**์— ์•ฝ๊ฐ„์˜ ์ปฌ๋Ÿฌ๋งŒ ๋”ํ•˜๋Š” ๊ฒƒ์ด ํŽธ์•ˆํ•จ์„ ๊ทน๋Œ€ํ™”ํ•ฉ๋‹ˆ๋‹ค. -- ์‹ค์ œ ์ธ๊ธฐ ์•ฑ(Reflect, T.RICKS, Coffee ๋“ฑ)๋„ ๋ธ”๋ฃจยทํ™”์ดํŠธยท๋ฏผํŠธ ๊ณ„์—ด์„ ์ฃผ๋กœ ํ™œ์šฉํ•ฉ๋‹ˆ๋‹ค. +- **๋ฐฐ๊ฒฝ**: Light Gray (#f1f5f9) +- **๊ธ€๋ž˜์Šค ์นด๋“œ**: White Glass (rgba(255,255,255,0.2)), ํ…Œ๋‘๋ฆฌ Deep Blue (#2563eb) +- **๋ฉ”์ธ ํ…์ŠคํŠธ**: Dark Navy (#1e293b) +- **์„œ๋ธŒ/์„ค๋ช… ํ…์ŠคํŠธ**: Navy Gray (#334155) +- **๋ฒ„ํŠผ ๋ฐฐ๊ฒฝ**: Deep Blue (#2563eb) +- **๋ฒ„ํŠผ ํ…์ŠคํŠธ**: Pure White (#ffffff) +- **ํฌ์ธํŠธ/์•ก์„ผํŠธ**: Soft Mint (#38bdf8), Pink Accent (#f472b6) -### 5. ์ปฌ๋Ÿฌ ํŒ”๋ ˆํŠธ ์˜ˆ์‹œ +## 4. ๊ทธ๋ผ๋””์–ธํŠธ ๋ฐ ๊ธ€๋ž˜์Šค ํšจ๊ณผ ์˜ˆ์‹œ -| ์ด๋ฆ„ | Hex ์ฝ”๋“œ | ์šฉ๋„/๋А๋‚Œ | -|-------------|------------|-------------------| -| Deep Blue | #2563eb | ์‹ ๋ขฐ, ๋ฉ”์ธ | -| Sky Blue | #60a5fa | ํŠธ๋ Œ๋“œ, ๊ทธ๋ผ๋””์–ธํŠธ| -| Soft Mint | #38bdf8 | ํฌ์ธํŠธ, ์ƒ์พŒํ•จ | -| Light Gray | #f1f5f9 | ๋ฐฐ๊ฒฝ, ํŽธ์•ˆํ•จ | -| White Glass | #ffffff(ํˆฌ๋ช…๋„) | ๊ธ€๋ž˜์Šค ํšจ๊ณผ | -| Pink Accent | #f472b6 | ํฌ์ธํŠธ, ํŠธ๋ Œ๋”” | +```dart +// Flutter ์˜ˆ์‹œ (Dart) +LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Color(0xFF2563eb), + Color(0xFF60a5fa), + Color(0xFFe0e7ef), + ], +) +``` +- ๊ธ€๋ž˜์Šค ์นด๋“œ ๋ฐฐ๊ฒฝ: rgba(255,255,255,0.2) + blur + border(Deep Blue) +- ํ…์ŠคํŠธ: #1e293b(์ง„ํ•œ ๋„ค์ด๋น„) ๋˜๋Š” #2563eb(๋”ฅ๋ธ”๋ฃจ) ์‚ฌ์šฉ -### 6. ๋งˆ๋ฌด๋ฆฌ +## 5. ๋””์ž์ธ ํŒ -- **๋ธ”๋ฃจ+ํ™”์ดํŠธ+๋ฏผํŠธ** ์กฐํ•ฉ์€ ์‹ ๋ขฐ์„ฑ, ํŽธ์•ˆํ•จ, ํŠธ๋ Œ๋“œํ•จ์„ ๋ชจ๋‘ ๋งŒ์กฑ์‹œํ‚ต๋‹ˆ๋‹ค. -- ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜ ํšจ๊ณผ์™€ ํ•จ๊ป˜๋ผ๋ฉด, ์œ„ ํŒ”๋ ˆํŠธ๋กœ ์„ธ๋ จ๋˜๊ณ  ํ˜„๋Œ€์ ์ธ ๊ตฌ๋…๊ด€๋ฆฌ ์•ฑ UI๋ฅผ ์™„์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. -- ์‹ค์ œ ์ ์šฉ ์‹œ, ๋ฐ์€ ๋ฐฐ๊ฒฝ๊ณผ ๋ถ€๋“œ๋Ÿฌ์šด ๊ทธ๋ผ๋””์–ธํŠธ, ํฌ์ธํŠธ ์ปฌ๋Ÿฌ๋ฅผ ์ ์ ˆํžˆ ์กฐํ•ฉํ•ด๋ณด์„ธ์š”. \ No newline at end of file +- **ํ…์ŠคํŠธ ๋Œ€๋น„**๋ฅผ ํ•ญ์ƒ ์ฒดํฌํ•˜์„ธ์š”. + ๋ฐ์€ ๋ฐฐ๊ฒฝ์—๋Š” ์–ด๋‘์šด ํ…์ŠคํŠธ, ์ง„ํ•œ ๋ฐฐ๊ฒฝ์—๋Š” ๋ฐ์€ ํ…์ŠคํŠธ! +- **ํฌ์ธํŠธ ์ปฌ๋Ÿฌ**๋Š” ๋ฒ„ํŠผ, ์•„์ด์ฝ˜, ๊ฐ•์กฐ ํ…์ŠคํŠธ์—๋งŒ ์ œํ•œ์ ์œผ๋กœ ์‚ฌ์šฉํ•˜๋ฉด ์„ธ๋ จ๋จ์ด ์‚ด์•„๋‚ฉ๋‹ˆ๋‹ค. +- **๊ธ€๋ž˜์Šค ํšจ๊ณผ**๋Š” ํˆฌ๋ช…๋„์™€ ๋ธ”๋Ÿฌ, ๊ทธ๋ฆฌ๊ณ  ๊ฒฝ๊ณ„์„  ์ปฌ๋Ÿฌ(์˜ˆ: #2563eb, #60a5fa)๋กœ ๊นŠ์ด๊ฐ์„ ๋”ํ•˜์„ธ์š”. + +## 6. ์ปฌ๋Ÿฌ/ํ…์ŠคํŠธ ์กฐํ•ฉ ์š”์•ฝํ‘œ + +| ๋ฐฐ๊ฒฝ์ƒ‰ | ํ…์ŠคํŠธ์ƒ‰ | ์šฉ๋„ ์˜ˆ์‹œ | +|------------------|------------------|--------------------| +| #f1f5f9 | #1e293b | ๋ฉ”์ธ ํƒ€์ดํ‹€, ๋‚ด์šฉ | +| #ffffff(ํˆฌ๋ช…) | #2563eb | ์นด๋“œ ๋‚ด ๊ฐ•์กฐ | +| #2563eb | #ffffff | ๋ฒ„ํŠผ, ๋ฐ˜์ „ ๊ฐ•์กฐ | +| #60a5fa | #334155 | ์„œ๋ธŒ, ์„ค๋ช… | +| #38bdf8 | #334155 | ํฌ์ธํŠธ, ์„œ๋ธŒํ…์ŠคํŠธ | + +## ๊ฒฐ๋ก  + +- **๋ธ”๋ฃจ+ํ™”์ดํŠธ+๋ฏผํŠธ** ์กฐํ•ฉ๊ณผ, **๋ฐ์€ ๋ฐฐ๊ฒฝ+์–ด๋‘์šด ํ…์ŠคํŠธ** ์›์น™์œผ๋กœ ์‹ ๋ขฐ์„ฑ, ํŽธ์•ˆํ•จ, ํŠธ๋ Œ๋“œํ•จ, ๊ฐ€๋…์„ฑ ๋ชจ๋‘ ์ฑ™๊ธธ ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. +- ์‹ค์ œ ์•ฑ์— ์ ์šฉํ•  ๋•Œ๋Š” ์œ„ ํ‘œ๋ฅผ ์ฐธ๊ณ ํ•ด ๊ฐ ์ƒํ™ฉ๋ณ„๋กœ ํ…์ŠคํŠธ ์ปฌ๋Ÿฌ๋ฅผ ๊ผญ ๋งž์ถฐ์ฃผ์„ธ์š”. +- ๊ธ€๋ž˜์Šค๋ชจํ”ผ์–ด์ฆ˜ ํšจ๊ณผ์™€ ๋Œ€๋น„ ๋†’์€ ํ…์ŠคํŠธ ์กฐํ•ฉ์œผ๋กœ, ์„ธ๋ จ๋˜๊ณ  ์‚ฌ์šฉ์„ฑ ์ข‹์€ ๊ตฌ๋…๊ด€๋ฆฌ ์•ฑ์„ ์™„์„ฑํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค. \ No newline at end of file diff --git a/lib/controllers/add_subscription_controller.dart b/lib/controllers/add_subscription_controller.dart new file mode 100644 index 0000000..86da619 --- /dev/null +++ b/lib/controllers/add_subscription_controller.dart @@ -0,0 +1,418 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import '../services/sms_service.dart'; +import '../services/subscription_url_matcher.dart'; + +/// AddSubscriptionScreen์˜ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๊ด€๋ฆฌํ•˜๋Š” Controller +class AddSubscriptionController { + final BuildContext context; + + // Form Key + final formKey = GlobalKey(); + + // Text Controllers + final serviceNameController = TextEditingController(); + final monthlyCostController = TextEditingController(); + final nextBillingDateController = TextEditingController(); + final websiteUrlController = TextEditingController(); + final eventPriceController = TextEditingController(); + + // Form State + String billingCycle = '์›”๊ฐ„'; + String currency = 'KRW'; + DateTime? nextBillingDate; + bool isLoading = false; + String? selectedCategoryId; + + // Event State + bool isEventActive = false; + DateTime? eventStartDate = DateTime.now(); + DateTime? eventEndDate = DateTime.now().add(const Duration(days: 30)); + + // Focus Nodes + final serviceNameFocus = FocusNode(); + final monthlyCostFocus = FocusNode(); + final billingCycleFocus = FocusNode(); + final nextBillingDateFocus = FocusNode(); + final websiteUrlFocus = FocusNode(); + final categoryFocus = FocusNode(); + final currencyFocus = FocusNode(); + + // Animation Controller + AnimationController? animationController; + Animation? fadeAnimation; + Animation? slideAnimation; + + // Scroll Controller + final ScrollController scrollController = ScrollController(); + double scrollOffset = 0; + + // UI State + int currentEditingField = -1; + bool isSaveHovered = false; + + // Gradient Colors + final List gradientColors = [ + const Color(0xFF3B82F6), + const Color(0xFF0EA5E9), + const Color(0xFF06B6D4), + ]; + + AddSubscriptionController({required this.context}); + + /// ์ดˆ๊ธฐํ™” + void initialize({required TickerProvider vsync}) { + // ๊ฒฐ์ œ์ผ ๊ธฐ๋ณธ๊ฐ’์„ ์˜ค๋Š˜ ๋‚ ์งœ๋กœ ์„ค์ • + nextBillingDate = DateTime.now(); + + // ์„œ๋น„์Šค๋ช… ์ปจํŠธ๋กค๋Ÿฌ์— ๋ฆฌ์Šค๋„ˆ ์ถ”๊ฐ€ + serviceNameController.addListener(onServiceNameChanged); + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํŠธ๋กค๋Ÿฌ ์ดˆ๊ธฐํ™” + animationController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 800), + ); + + fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeIn, + )); + + slideAnimation = Tween( + begin: const Offset(0.0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeOut, + )); + + // ์Šคํฌ๋กค ๋ฆฌ์Šค๋„ˆ + scrollController.addListener(() { + scrollOffset = scrollController.offset; + }); + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹œ์ž‘ + animationController!.forward(); + } + + /// ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ + void dispose() { + // Controllers + serviceNameController.dispose(); + monthlyCostController.dispose(); + nextBillingDateController.dispose(); + websiteUrlController.dispose(); + eventPriceController.dispose(); + + // Focus Nodes + serviceNameFocus.dispose(); + monthlyCostFocus.dispose(); + billingCycleFocus.dispose(); + nextBillingDateFocus.dispose(); + websiteUrlFocus.dispose(); + categoryFocus.dispose(); + currencyFocus.dispose(); + + // Animation + animationController?.dispose(); + + // Scroll + scrollController.dispose(); + } + + /// ์„œ๋น„์Šค๋ช… ๋ณ€๊ฒฝ์‹œ ํ˜ธ์ถœ + void onServiceNameChanged() { + autoSelectCategory(); + } + + /// ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ + void autoSelectCategory() { + final categoryProvider = Provider.of(context, listen: false); + final categories = categoryProvider.categories; + + final serviceName = serviceNameController.text.toLowerCase(); + + // ์„œ๋น„์Šค๋ช…์— ๊ธฐ๋ฐ˜ํ•œ ์นดํ…Œ๊ณ ๋ฆฌ ๋งค์นญ ๋กœ์ง + dynamic matchedCategory; + + // ์—”ํ„ฐํ…Œ์ธ๋จผํŠธ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || + serviceName.contains('disney') || + serviceName.contains('์™“์ฑ ') || + serviceName.contains('ํ‹ฐ๋น™') || + serviceName.contains('์›จ์ด๋ธŒ') || + serviceName.contains('coupang play') || + serviceName.contains('์ฟ ํŒกํ”Œ๋ ˆ์ด')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์—”ํ„ฐํ…Œ์ธ๋จผํŠธ', + orElse: () => categories.first, + ); + } + // ์Œ์•… ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('๋ฉœ๋ก ') || + serviceName.contains('์ง€๋‹ˆ') || + serviceName.contains('ํ”Œ๋กœ') || + serviceName.contains('๋ฒ…์Šค')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์Œ์•…', + orElse: () => categories.first, + ); + } + // ์ƒ์‚ฐ์„ฑ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('google') || + serviceName.contains('dropbox') || + serviceName.contains('icloud') || + serviceName.contains('adobe')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์ƒ์‚ฐ์„ฑ', + orElse: () => categories.first, + ); + } + // ๊ฒŒ์ž„ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('xbox') || + serviceName.contains('playstation') || + serviceName.contains('nintendo') || + serviceName.contains('steam') || + serviceName.contains('๊ฒŒ์ž„')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '๊ฒŒ์ž„', + orElse: () => categories.first, + ); + } + // ๊ต์œก ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('coursera') || + serviceName.contains('udemy') || + serviceName.contains('์ธํ”„๋Ÿฐ') || + serviceName.contains('ํŒจ์ŠคํŠธ์บ ํผ์Šค') || + serviceName.contains('ํด๋ž˜์Šค101')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '๊ต์œก', + orElse: () => categories.first, + ); + } + // ์‡ผํ•‘ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('์ฟ ํŒก') || + serviceName.contains('coupang') || + serviceName.contains('amazon') || + serviceName.contains('๋„ค์ด๋ฒ„') || + serviceName.contains('11๋ฒˆ๊ฐ€')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์‡ผํ•‘', + orElse: () => categories.first, + ); + } + + if (matchedCategory != null) { + selectedCategoryId = matchedCategory.id; + } + } + + /// SMS ์Šค์บ” + Future scanSMS({required Function setState}) async { + if (kIsWeb) return; + + setState(() => isLoading = true); + + try { + if (!await SMSService.hasSMSPermission()) { + final granted = await SMSService.requestSMSPermission(); + if (!granted) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.error_outline, color: Colors.white), + SizedBox(width: 12), + Expanded(child: Text('SMS ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + return; + } + } + + final subscriptions = await SMSService.scanSubscriptions(); + if (subscriptions.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.info_outline, color: Colors.white), + SizedBox(width: 12), + Expanded(child: Text('๊ตฌ๋… ๊ด€๋ จ SMS๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + return; + } + + final subscription = subscriptions.first; + setState(() { + serviceNameController.text = subscription['serviceName'] ?? ''; + + // ๋น„์šฉ ์ฒ˜๋ฆฌ ๋ฐ ํ†ตํ™” ๋‹จ์œ„ ์ž๋™ ๊ฐ์ง€ + final costValue = subscription['monthlyCost']?.toString() ?? ''; + + if (costValue.isNotEmpty) { + // ๋‹ฌ๋Ÿฌ ํ‘œ์‹œ๊ฐ€ ์žˆ๊ฑฐ๋‚˜ ์†Œ์ˆ˜์ ์ด ์žˆ์œผ๋ฉด ๋‹ฌ๋Ÿฌ๋กœ ํŒ๋‹จ + if (costValue.contains('\$') || costValue.contains('.')) { + currency = 'USD'; + String numericValue = costValue.replaceAll('\$', '').trim(); + if (!numericValue.contains('.')) { + numericValue = '$numericValue.00'; + } + final double parsedValue = + double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; + monthlyCostController.text = + NumberFormat('#,##0.00').format(parsedValue); + } else { + currency = 'KRW'; + String numericValue = + costValue.replaceAll('โ‚ฉ', '').replaceAll(',', '').trim(); + final int parsedValue = int.tryParse(numericValue) ?? 0; + monthlyCostController.text = + NumberFormat.decimalPattern().format(parsedValue); + } + } else { + monthlyCostController.text = ''; + } + + billingCycle = subscription['billingCycle'] ?? '์›”๊ฐ„'; + nextBillingDate = subscription['nextBillingDate'] != null + ? DateTime.parse(subscription['nextBillingDate']) + : DateTime.now(); + + // ์„œ๋น„์Šค๋ช…์ด ์žˆ์œผ๋ฉด URL ์ž๋™ ๋งค์นญ ์‹œ๋„ + if (subscription['serviceName'] != null && + subscription['serviceName'].isNotEmpty) { + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); + if (suggestedUrl != null) { + websiteUrlController.text = suggestedUrl; + } + + // ์„œ๋น„์Šค๋ช… ๊ธฐ๋ฐ˜์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ + autoSelectCategory(); + } + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์žฌ์ƒ + animationController!.reset(); + animationController!.forward(); + }); + } catch (e) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text('SMS ์Šค์บ” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + ), + ); + } + } finally { + if (context.mounted) { + setState(() => isLoading = false); + } + } + } + + /// ๊ตฌ๋… ์ €์žฅ + Future saveSubscription({required Function setState}) async { + if (formKey.currentState!.validate() && nextBillingDate != null) { + setState(() { + isLoading = true; + }); + + try { + // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ + final monthlyCost = + double.parse(monthlyCostController.text.replaceAll(',', '')); + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ํŒŒ์‹ฑ + double? eventPrice; + if (isEventActive && eventPriceController.text.isNotEmpty) { + eventPrice = double.tryParse( + eventPriceController.text.replaceAll(',', '') + ); + } + + await Provider.of(context, listen: false) + .addSubscription( + serviceName: serviceNameController.text.trim(), + monthlyCost: monthlyCost, + billingCycle: billingCycle, + nextBillingDate: nextBillingDate!, + websiteUrl: websiteUrlController.text.trim(), + categoryId: selectedCategoryId, + currency: currency, + isEventActive: isEventActive, + eventStartDate: eventStartDate, + eventEndDate: eventEndDate, + eventPrice: eventPrice, + ); + + if (context.mounted) { + Navigator.pop(context, true); // ์„ฑ๊ณต ์—ฌ๋ถ€ ๋ฐ˜ํ™˜ + } + } catch (e) { + setState(() { + isLoading = false; + }); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } else { + scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } +} \ No newline at end of file diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart new file mode 100644 index 0000000..1813514 --- /dev/null +++ b/lib/controllers/detail_screen_controller.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../models/subscription_model.dart'; +import '../models/category_model.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import '../services/subscription_url_matcher.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:intl/intl.dart'; + +/// DetailScreen์˜ ๋น„์ฆˆ๋‹ˆ์Šค ๋กœ์ง์„ ๊ด€๋ฆฌํ•˜๋Š” Controller +class DetailScreenController { + final BuildContext context; + final SubscriptionModel subscription; + + // Text Controllers + late TextEditingController serviceNameController; + late TextEditingController monthlyCostController; + late TextEditingController websiteUrlController; + late TextEditingController eventPriceController; + + // Form State + late String billingCycle; + late DateTime nextBillingDate; + String? selectedCategoryId; + late String currency; + bool isLoading = false; + + // Event State + late bool isEventActive; + DateTime? eventStartDate; + DateTime? eventEndDate; + + // Focus Nodes + final serviceNameFocus = FocusNode(); + final monthlyCostFocus = FocusNode(); + final billingCycleFocus = FocusNode(); + final nextBillingDateFocus = FocusNode(); + final websiteUrlFocus = FocusNode(); + final categoryFocus = FocusNode(); + final currencyFocus = FocusNode(); + + // UI State + final ScrollController scrollController = ScrollController(); + double scrollOffset = 0; + int currentEditingField = -1; + bool isDeleteHovered = false; + bool isSaveHovered = false; + bool isCancelHovered = false; + + // Animation Controller + AnimationController? animationController; + Animation? fadeAnimation; + Animation? slideAnimation; + Animation? rotateAnimation; + + DetailScreenController({ + required this.context, + required this.subscription, + }); + + /// ์ดˆ๊ธฐํ™” + void initialize({required TickerProvider vsync}) { + // Text Controllers ์ดˆ๊ธฐํ™” + serviceNameController = TextEditingController(text: subscription.serviceName); + monthlyCostController = TextEditingController(text: subscription.monthlyCost.toString()); + websiteUrlController = TextEditingController(text: subscription.websiteUrl ?? ''); + eventPriceController = TextEditingController(); + + // Form State ์ดˆ๊ธฐํ™” + billingCycle = subscription.billingCycle; + nextBillingDate = subscription.nextBillingDate; + selectedCategoryId = subscription.categoryId; + currency = subscription.currency; + + // Event State ์ดˆ๊ธฐํ™” + isEventActive = subscription.isEventActive; + eventStartDate = subscription.eventStartDate; + eventEndDate = subscription.eventEndDate; + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์ดˆ๊ธฐํ™” + if (subscription.eventPrice != null) { + if (currency == 'KRW') { + eventPriceController.text = NumberFormat.decimalPattern() + .format(subscription.eventPrice!.toInt()); + } else { + eventPriceController.text = + NumberFormat('#,##0.00').format(subscription.eventPrice!); + } + } + + // ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ฅธ ๊ธˆ์•ก ํ‘œ์‹œ ํ˜•์‹ ์กฐ์ • + _updateMonthlyCostFormat(); + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ดˆ๊ธฐํ™” + animationController = AnimationController( + duration: const Duration(milliseconds: 800), + vsync: vsync, + ); + + fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeInOut, + )); + + slideAnimation = Tween( + begin: const Offset(0.0, 0.3), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeOutCubic, + )); + + rotateAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: animationController!, + curve: Curves.easeOutCubic, + )); + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์‹œ์ž‘ + animationController!.forward(); + + // ์„œ๋น„์Šค๋ช… ๋ณ€๊ฒฝ ๊ฐ์ง€ ๋ฆฌ์Šค๋„ˆ + serviceNameController.addListener(onServiceNameChanged); + + // ์Šคํฌ๋กค ๋ฆฌ์Šค๋„ˆ + scrollController.addListener(() { + scrollOffset = scrollController.offset; + }); + } + + /// ๋ฆฌ์†Œ์Šค ์ •๋ฆฌ + void dispose() { + // Controllers + serviceNameController.dispose(); + monthlyCostController.dispose(); + websiteUrlController.dispose(); + eventPriceController.dispose(); + + // Focus Nodes + serviceNameFocus.dispose(); + monthlyCostFocus.dispose(); + billingCycleFocus.dispose(); + nextBillingDateFocus.dispose(); + websiteUrlFocus.dispose(); + categoryFocus.dispose(); + currencyFocus.dispose(); + + // Animation + animationController?.dispose(); + + // Scroll + scrollController.dispose(); + } + + /// ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ฅธ ๊ธˆ์•ก ํ‘œ์‹œ ํ˜•์‹ ์—…๋ฐ์ดํŠธ + void _updateMonthlyCostFormat() { + if (currency == 'KRW') { + // ์›ํ™”๋Š” ์†Œ์ˆ˜์  ์—†์ด ํ‘œ์‹œ + final intValue = subscription.monthlyCost.toInt(); + monthlyCostController.text = NumberFormat.decimalPattern().format(intValue); + } else { + // ๋‹ฌ๋Ÿฌ๋Š” ์†Œ์ˆ˜์  2์ž๋ฆฌ๊นŒ์ง€ ํ‘œ์‹œ + monthlyCostController.text = NumberFormat('#,##0.00').format(subscription.monthlyCost); + } + } + + /// ์„œ๋น„์Šค๋ช… ๋ณ€๊ฒฝ์‹œ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ + void onServiceNameChanged() { + autoSelectCategory(); + } + + /// ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ + void autoSelectCategory() { + final categoryProvider = Provider.of(context, listen: false); + final categories = categoryProvider.categories; + + final serviceName = serviceNameController.text.toLowerCase(); + + // ์„œ๋น„์Šค๋ช…์— ๊ธฐ๋ฐ˜ํ•œ ์นดํ…Œ๊ณ ๋ฆฌ ๋งค์นญ ๋กœ์ง + CategoryModel? matchedCategory; + + // ์—”ํ„ฐํ…Œ์ธ๋จผํŠธ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || + serviceName.contains('disney') || + serviceName.contains('์™“์ฑ ') || + serviceName.contains('ํ‹ฐ๋น™') || + serviceName.contains('์›จ์ด๋ธŒ') || + serviceName.contains('coupang play') || + serviceName.contains('์ฟ ํŒกํ”Œ๋ ˆ์ด')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์—”ํ„ฐํ…Œ์ธ๋จผํŠธ', + orElse: () => categories.first, + ); + } + // ์Œ์•… ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('๋ฉœ๋ก ') || + serviceName.contains('์ง€๋‹ˆ') || + serviceName.contains('ํ”Œ๋กœ') || + serviceName.contains('๋ฒ…์Šค')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์Œ์•…', + orElse: () => categories.first, + ); + } + // ์ƒ์‚ฐ์„ฑ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('google') || + serviceName.contains('dropbox') || + serviceName.contains('icloud') || + serviceName.contains('adobe')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์ƒ์‚ฐ์„ฑ', + orElse: () => categories.first, + ); + } + // ๊ฒŒ์ž„ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('xbox') || + serviceName.contains('playstation') || + serviceName.contains('nintendo') || + serviceName.contains('steam') || + serviceName.contains('๊ฒŒ์ž„')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '๊ฒŒ์ž„', + orElse: () => categories.first, + ); + } + // ๊ต์œก ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('coursera') || + serviceName.contains('udemy') || + serviceName.contains('์ธํ”„๋Ÿฐ') || + serviceName.contains('ํŒจ์ŠคํŠธ์บ ํผ์Šค') || + serviceName.contains('ํด๋ž˜์Šค101')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '๊ต์œก', + orElse: () => categories.first, + ); + } + // ์‡ผํ•‘ ๊ด€๋ จ ํ‚ค์›Œ๋“œ + else if (serviceName.contains('์ฟ ํŒก') || + serviceName.contains('coupang') || + serviceName.contains('amazon') || + serviceName.contains('๋„ค์ด๋ฒ„') || + serviceName.contains('11๋ฒˆ๊ฐ€')) { + matchedCategory = categories.firstWhere( + (cat) => cat.name == '์‡ผํ•‘', + orElse: () => categories.first, + ); + } + + if (matchedCategory != null) { + selectedCategoryId = matchedCategory.id; + } + } + + /// ๊ตฌ๋… ์ •๋ณด ์—…๋ฐ์ดํŠธ + Future updateSubscription() async { + final provider = Provider.of(context, listen: false); + + // ์›น์‚ฌ์ดํŠธ URL์ด ๋น„์–ด์žˆ๋Š” ๊ฒฝ์šฐ ์ž๋™ ๋งค์นญ ๋‹ค์‹œ ์‹œ๋„ + String? websiteUrl = websiteUrlController.text; + if (websiteUrl.isEmpty) { + websiteUrl = SubscriptionUrlMatcher.suggestUrl(serviceNameController.text); + } + + // ๊ตฌ๋… ์ •๋ณด ์—…๋ฐ์ดํŠธ + + // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ + double monthlyCost = 0.0; + try { + monthlyCost = double.parse(monthlyCostController.text.replaceAll(',', '')); + } catch (e) { + // ํŒŒ์‹ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ + monthlyCost = subscription.monthlyCost; + } + + subscription.serviceName = serviceNameController.text; + subscription.monthlyCost = monthlyCost; + subscription.websiteUrl = websiteUrl; + subscription.billingCycle = billingCycle; + subscription.nextBillingDate = nextBillingDate; + subscription.categoryId = selectedCategoryId; + subscription.currency = currency; + + // ์ด๋ฒคํŠธ ์ •๋ณด ์—…๋ฐ์ดํŠธ + subscription.isEventActive = isEventActive; + subscription.eventStartDate = eventStartDate; + subscription.eventEndDate = eventEndDate; + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ํŒŒ์‹ฑ + if (isEventActive && eventPriceController.text.isNotEmpty) { + try { + subscription.eventPrice = + double.parse(eventPriceController.text.replaceAll(',', '')); + } catch (e) { + subscription.eventPrice = null; + } + } else { + subscription.eventPrice = null; + } + + // ๊ตฌ๋… ์—…๋ฐ์ดํŠธ + await provider.updateSubscription(subscription); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.check_circle_rounded, color: Colors.white), + SizedBox(width: 12), + Text('๊ตฌ๋… ์ •๋ณด๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFF10B981), + duration: const Duration(seconds: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + + // ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ๋ฐ˜์˜๋  ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ ์œ„ํ•ด ์งง๊ฒŒ ์ง€์—ฐ ํ›„ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ + await Future.delayed(const Duration(milliseconds: 100)); + if (context.mounted) { + Navigator.of(context).pop(true); + } + } + } + + /// ๊ตฌ๋… ์‚ญ์ œ + Future deleteSubscription() async { + if (context.mounted) { + final provider = Provider.of(context, listen: false); + await provider.deleteSubscription(subscription.id); + + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Row( + children: [ + Icon(Icons.delete_forever_rounded, color: Colors.white), + SizedBox(width: 12), + Text('๊ตฌ๋…์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFFDC2626), + duration: const Duration(seconds: 2), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + Navigator.of(context).pop(); + } + } + } + + /// ํ•ด์ง€ ํŽ˜์ด์ง€ ์—ด๊ธฐ + Future openCancellationPage() async { + if (subscription.websiteUrl != null && + subscription.websiteUrl!.isNotEmpty) { + final Uri url = Uri.parse(subscription.websiteUrl!); + if (!await launchUrl(url, mode: LaunchMode.externalApplication)) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('์›น์‚ฌ์ดํŠธ๋ฅผ ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'), + backgroundColor: Colors.red, + ), + ); + } + } + } else { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('์›น์‚ฌ์ดํŠธ ์ •๋ณด๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค. ํ•ด์ง€๋Š” ์›น์‚ฌ์ดํŠธ์—์„œ ์ง„ํ–‰ํ•ด์ฃผ์„ธ์š”.'), + backgroundColor: Colors.orange, + ), + ); + } + } + } + + /// ์นด๋“œ ์ƒ‰์ƒ ๊ฐ€์ ธ์˜ค๊ธฐ + Color getCardColor() { + // ์„œ๋น„์Šค ์ด๋ฆ„์— ๋”ฐ๋ผ ์ผ๊ด€๋œ ์ƒ‰์ƒ ์ƒ์„ฑ + final int hash = subscription.serviceName.hashCode.abs(); + final List colors = [ + const Color(0xFF3B82F6), // ํŒŒ๋ž‘ + const Color(0xFF10B981), // ์ดˆ๋ก + const Color(0xFF8B5CF6), // ๋ณด๋ผ + const Color(0xFFF59E0B), // ๋…ธ๋ž‘ + const Color(0xFFEF4444), // ๋นจ๊ฐ• + const Color(0xFF0EA5E9), // ํ•˜๋Š˜ + const Color(0xFFEC4899), // ๋ถ„ํ™ + ]; + + return colors[hash % colors.length]; + } + + /// ๊ทธ๋ผ๋ฐ์ด์…˜ ๊ฐ€์ ธ์˜ค๊ธฐ + LinearGradient getGradient(Color baseColor) { + return LinearGradient( + colors: [ + baseColor, + baseColor.withValues(alpha: 0.8), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + } +} \ No newline at end of file diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index 81b424f..ff5c2b1 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter/foundation.dart'; import '../services/notification_service.dart'; diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 82364ab..57804de 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -4,9 +4,6 @@ import 'package:hive/hive.dart'; import 'package:uuid/uuid.dart'; import '../models/subscription_model.dart'; import '../services/notification_service.dart'; -import 'package:provider/provider.dart'; -import 'notification_provider.dart'; -import '../navigator_key.dart'; class SubscriptionProvider extends ChangeNotifier { late Box _subscriptionBox; @@ -156,35 +153,6 @@ class SubscriptionProvider extends ChangeNotifier { } } - Future _scheduleNotifications() async { - final BuildContext? context = navigatorKey.currentContext; - if (context == null) return; - - final notificationProvider = Provider.of( - context, - listen: false, - ); - - if (!notificationProvider.isEnabled || - !notificationProvider.isPaymentEnabled) { - return; - } - - for (final subscription in _subscriptions) { - final notificationDate = subscription.nextBillingDate.subtract( - const Duration(days: 3), - ); - - if (notificationDate.isAfter(DateTime.now())) { - await NotificationService.scheduleNotification( - id: subscription.id.hashCode, - title: '๊ตฌ๋… ๊ฒฐ์ œ ์˜ˆ์ • ์•Œ๋ฆผ', - body: '${subscription.serviceName}์˜ ๊ฒฐ์ œ๊ฐ€ 3์ผ ํ›„ ์˜ˆ์ •๋˜์–ด ์žˆ์Šต๋‹ˆ๋‹ค.', - scheduledDate: notificationDate, - ); - } - } - } Future clearAllSubscriptions() async { _isLoading = true; diff --git a/lib/screens/add_subscription_screen.dart b/lib/screens/add_subscription_screen.dart index 894f96e..1ab6662 100644 --- a/lib/screens/add_subscription_screen.dart +++ b/lib/screens/add_subscription_screen.dart @@ -1,16 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'package:intl/intl.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:flutter/services.dart'; -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 '../services/sms_service.dart'; -import '../services/subscription_url_matcher.dart'; -import '../services/exchange_rate_service.dart'; +import '../controllers/add_subscription_controller.dart'; +import '../widgets/add_subscription/add_subscription_app_bar.dart'; +import '../widgets/add_subscription/add_subscription_header.dart'; +import '../widgets/add_subscription/add_subscription_form.dart'; +import '../widgets/add_subscription/add_subscription_event_section.dart'; +import '../widgets/add_subscription/add_subscription_save_button.dart'; +/// ์ƒˆ๋กœ์šด ๊ตฌ๋…์„ ์ถ”๊ฐ€ํ•˜๋Š” ํ™”๋ฉด class AddSubscriptionScreen extends StatefulWidget { const AddSubscriptionScreen({Key? key}) : super(key: key); @@ -20,2002 +16,88 @@ class AddSubscriptionScreen extends StatefulWidget { class _AddSubscriptionScreenState extends State with SingleTickerProviderStateMixin { - final _formKey = GlobalKey(); - final _serviceNameController = TextEditingController(); - final _monthlyCostController = TextEditingController(); - final _nextBillingDateController = TextEditingController(); - final _websiteUrlController = TextEditingController(); - String _billingCycle = '์›”๊ฐ„'; - String _currency = 'KRW'; - DateTime? _nextBillingDate; - bool _isLoading = false; - String? _selectedCategoryId; - - // ์ด๋ฒคํŠธ ๊ด€๋ จ ์ƒํƒœ ๋ณ€์ˆ˜ - bool _isEventActive = false; - DateTime? _eventStartDate = DateTime.now(); // ์˜ค๋Š˜๋กœ ์ดˆ๊ธฐํ™” - DateTime? _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30์ผ ํ›„๋กœ ์ดˆ๊ธฐํ™” - final _eventPriceController = TextEditingController(); - - // ํฌ์ปค์Šค ๋…ธ๋“œ ์ถ”๊ฐ€ - final _serviceNameFocus = FocusNode(); - final _monthlyCostFocus = FocusNode(); - final _billingCycleFocus = FocusNode(); - final _nextBillingDateFocus = FocusNode(); - final _websiteUrlFocus = FocusNode(); - final _categoryFocus = FocusNode(); - final _currencyFocus = FocusNode(); - - // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํŠธ๋กค๋Ÿฌ - late AnimationController _animationController; - late Animation _fadeAnimation; - late Animation _slideAnimation; - - // ์Šคํฌ๋กค ์ปจํŠธ๋กค๋Ÿฌ - final ScrollController _scrollController = ScrollController(); - double _scrollOffset = 0; - - // ํ˜„์žฌ ํŽธ์ง‘ ์ค‘์ธ ํ•„๋“œ - int _currentEditingField = -1; - - // ํ˜ธ๋ฒ„ ์ƒํƒœ - bool _isSaveHovered = false; - - final List _gradientColors = [ - const Color(0xFF3B82F6), - const Color(0xFF0EA5E9), - const Color(0xFF06B6D4), - ]; + late AddSubscriptionController _controller; @override void initState() { super.initState(); - - // ๊ฒฐ์ œ์ผ ๊ธฐ๋ณธ๊ฐ’์„ ์˜ค๋Š˜ ๋‚ ์งœ๋กœ ์„ค์ • - _nextBillingDate = DateTime.now(); - - // ๋””๋ฒ„๊น… ์ •๋ณด ์ถœ๋ ฅ - print('ํ™˜๊ฒฝ ์ •๋ณด: kIsWeb = $kIsWeb'); - print('์ดˆ๊ธฐ ํ†ตํ™” ๋‹จ์œ„: $_currency'); - - // ์„œ๋น„์Šค๋ช… ์ปจํŠธ๋กค๋Ÿฌ์— ๋ฆฌ์Šค๋„ˆ ์ถ”๊ฐ€ - _serviceNameController.addListener(_onServiceNameChanged); - - // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํŠธ๋กค๋Ÿฌ ์ดˆ๊ธฐํ™” - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), - ); - - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeIn, - )); - - _slideAnimation = Tween( - begin: const Offset(0.0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - )); - - _scrollController.addListener(() { - setState(() { - _scrollOffset = _scrollController.offset; - }); - }); - - _animationController.forward(); + _controller = AddSubscriptionController(context: context); + _controller.initialize(vsync: this); } @override void dispose() { - _serviceNameController.removeListener(_onServiceNameChanged); - _serviceNameController.dispose(); - _monthlyCostController.dispose(); - _nextBillingDateController.dispose(); - _websiteUrlController.dispose(); - _eventPriceController.dispose(); - _animationController.dispose(); - _scrollController.dispose(); - - // ํฌ์ปค์Šค ๋…ธ๋“œ ํ•ด์ œ - _serviceNameFocus.dispose(); - _monthlyCostFocus.dispose(); - _billingCycleFocus.dispose(); - _nextBillingDateFocus.dispose(); - _websiteUrlFocus.dispose(); - _categoryFocus.dispose(); - _currencyFocus.dispose(); - + _controller.dispose(); super.dispose(); } - // ์„œ๋น„์Šค๋ช…์ด ๋ณ€๊ฒฝ๋  ๋•Œ ํ˜ธ์ถœ๋˜๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜ - void _onServiceNameChanged() { - if (_serviceNameController.text.isNotEmpty && - _websiteUrlController.text.isEmpty) { - // ์ž๋™ URL ๋งค์นญ ์‹œ๋„ - final suggestedUrl = - SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); - - // ๋งค์นญ๋œ URL์ด ์žˆ์œผ๋ฉด ํ…์ŠคํŠธ ์ปจํŠธ๋กค๋Ÿฌ์— ์„ค์ • - if (suggestedUrl != null && suggestedUrl.isNotEmpty) { - setState(() { - _websiteUrlController.text = suggestedUrl; - }); - } - } - - // ์„œ๋น„์Šค๋ช…์ด ๋ณ€๊ฒฝ๋  ๋•Œ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ ์‹œ๋„ - if (_serviceNameController.text.isNotEmpty && _selectedCategoryId == null) { - _autoSelectCategory(); - } - } - - // ์„œ๋น„์Šค๋ช…์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ ํ•จ์ˆ˜ - void _autoSelectCategory() { - if (_serviceNameController.text.isEmpty) return; - - final serviceName = _serviceNameController.text.toLowerCase(); - final categoryProvider = - Provider.of(context, listen: false); - - // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ๋ฆฌํ„ด - if (categoryProvider.categories.isEmpty) return; - - // OTT ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.ottServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // OTT ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final ottCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('OTT') || - cat.name.contains('๋ฏธ๋””์–ด') || - cat.name.contains('์˜์ƒ'), - ); - - setState(() { - _selectedCategoryId = ottCategory.id; - }); - return; - } catch (_) { - // OTT ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ์Œ์•… ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.musicServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ์Œ์•… ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final musicCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('์Œ์•…') || cat.name.contains('์ŠคํŠธ๋ฆฌ๋ฐ'), - ); - - setState(() { - _selectedCategoryId = musicCategory.id; - }); - return; - } catch (_) { - // ์Œ์•… ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // AI ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.aiServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // AI ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final aiCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('AI') || cat.name.contains('์ธ๊ณต์ง€๋Šฅ'), - ); - - setState(() { - _selectedCategoryId = aiCategory.id; - }); - return; - } catch (_) { - // AI ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ํ”„๋กœ๊ทธ๋ž˜๋ฐ/๊ฐœ๋ฐœ ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ๊ฐœ๋ฐœ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final devCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('๊ฐœ๋ฐœ') || cat.name.contains('ํ”„๋กœ๊ทธ๋ž˜๋ฐ'), - ); - - setState(() { - _selectedCategoryId = devCategory.id; - }); - return; - } catch (_) { - // ๊ฐœ๋ฐœ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ์˜คํ”ผ์Šค/ํ˜‘์—… ํˆด ํ™•์ธ - if (SubscriptionUrlMatcher.officeTools.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ์˜คํ”ผ์Šค ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final officeCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('์˜คํ”ผ์Šค') || - cat.name.contains('ํ˜‘์—…') || - cat.name.contains('์—…๋ฌด'), - ); - - setState(() { - _selectedCategoryId = officeCategory.id; - }); - return; - } catch (_) { - // ์˜คํ”ผ์Šค ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ๊ธฐํƒ€ ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.otherServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ๊ธฐํƒ€ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final otherCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('๊ธฐํƒ€') || cat.name.contains('๊ฒŒ์ž„'), - ); - - setState(() { - _selectedCategoryId = otherCategory.id; - }); - } catch (_) { - // ๊ธฐํƒ€ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - } - - Future _scanSMS() async { - if (kIsWeb) return; - - setState(() => _isLoading = true); - - try { - if (!await SMSService.hasSMSPermission()) { - final granted = await SMSService.requestSMSPermission(); - if (!granted) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.white), - const SizedBox(width: 12), - const Expanded(child: Text('SMS ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.')), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ); - } - return; - } - } - - final subscriptions = await SMSService.scanSubscriptions(); - if (subscriptions.isEmpty) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.info_outline, color: Colors.white), - const SizedBox(width: 12), - const Expanded(child: Text('๊ตฌ๋… ๊ด€๋ จ SMS๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.orange, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ); - } - return; - } - - final subscription = subscriptions.first; - setState(() { - _serviceNameController.text = subscription['serviceName'] ?? ''; - - // ๋น„์šฉ ์ฒ˜๋ฆฌ ๋ฐ ํ†ตํ™” ๋‹จ์œ„ ์ž๋™ ๊ฐ์ง€ - final costValue = subscription['monthlyCost']?.toString() ?? ''; - - // costValue๊ฐ€ ๋น„์–ด์žˆ์ง€ ์•Š์„ ๊ฒฝ์šฐ์—๋งŒ ์ฒ˜๋ฆฌ - if (costValue.isNotEmpty) { - // ๋‹ฌ๋Ÿฌ ํ‘œ์‹œ๊ฐ€ ์žˆ๊ฑฐ๋‚˜ ์†Œ์ˆ˜์ ์ด ์žˆ์œผ๋ฉด ๋‹ฌ๋Ÿฌ๋กœ ํŒ๋‹จ - if (costValue.contains('\$') || costValue.contains('.')) { - // ๋‹ฌ๋Ÿฌ๋กœ ์„ค์ • - _currency = 'USD'; - - // ๋‹ฌ๋Ÿฌ ๊ธฐํ˜ธ ์ œ๊ฑฐ ๋ฐ ์ˆซ์ž๋งŒ ์ถ”์ถœ - String numericValue = costValue.replaceAll('\$', '').trim(); - - // ์†Œ์ˆ˜์ ์ด ์—†๋Š” ๊ฒฝ์šฐ ์†Œ์ˆ˜์  ์ถ”๊ฐ€ - if (!numericValue.contains('.')) { - numericValue = '$numericValue.00'; - } - - // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ํ•˜์—ฌ ํฌ๋งทํŒ… - final double parsedValue = - double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; - _monthlyCostController.text = - NumberFormat('#,##0.00').format(parsedValue); - } else { - // ์›ํ™”๋กœ ์„ค์ • - _currency = 'KRW'; - - // โ‚ฉ ๊ธฐํ˜ธ์™€ ์ฝค๋งˆ ์ œ๊ฑฐ - String numericValue = - costValue.replaceAll('โ‚ฉ', '').replaceAll(',', '').trim(); - - // ์ˆซ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ •์ˆ˜๋กœ ํฌ๋งทํŒ… - final int parsedValue = int.tryParse(numericValue) ?? 0; - _monthlyCostController.text = - NumberFormat.decimalPattern().format(parsedValue); - } - } else { - _monthlyCostController.text = ''; - } - - _billingCycle = subscription['billingCycle'] ?? '์›”๊ฐ„'; - _nextBillingDate = subscription['nextBillingDate'] != null - ? DateTime.parse(subscription['nextBillingDate']) - : DateTime.now(); - - // ์„œ๋น„์Šค๋ช…์ด ์žˆ์œผ๋ฉด URL ์ž๋™ ๋งค์นญ ์‹œ๋„ - if (subscription['serviceName'] != null && - subscription['serviceName'].isNotEmpty) { - final suggestedUrl = - SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); - if (suggestedUrl != null) { - _websiteUrlController.text = suggestedUrl; - } - - // ์„œ๋น„์Šค๋ช… ๊ธฐ๋ฐ˜์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ - _autoSelectCategory(); - } - - // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์žฌ์ƒ - _animationController.reset(); - _animationController.forward(); - }); - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.white), - const SizedBox(width: 12), - Expanded(child: Text('SMS ์Šค์บ” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e')), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red, - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - } - } finally { - if (mounted) { - setState(() => _isLoading = false); - } - } - } - - Future _saveSubscription() async { - if (_formKey.currentState!.validate() && _nextBillingDate != null) { - setState(() { - _isLoading = true; - }); - - try { - // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ - final monthlyCost = - double.parse(_monthlyCostController.text.replaceAll(',', '')); - - // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ํŒŒ์‹ฑ - double? eventPrice; - if (_isEventActive && _eventPriceController.text.isNotEmpty) { - eventPrice = double.tryParse(_eventPriceController.text.replaceAll(',', '')); - } - - await Provider.of(context, listen: false) - .addSubscription( - serviceName: _serviceNameController.text.trim(), - monthlyCost: monthlyCost, - billingCycle: _billingCycle, - nextBillingDate: _nextBillingDate!, - websiteUrl: _websiteUrlController.text.trim(), - categoryId: _selectedCategoryId, - currency: _currency, - isEventActive: _isEventActive, - eventStartDate: _eventStartDate, - eventEndDate: _eventEndDate, - eventPrice: eventPrice, - ); - - if (mounted) { - Navigator.pop(context, true); // ์„ฑ๊ณต ์—ฌ๋ถ€ ๋ฐ˜ํ™˜ - } - } catch (e) { - setState(() { - _isLoading = false; - }); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: $e'), - backgroundColor: Colors.red, - ), - ); - } - } - } else { - _scrollController.animateTo( - 0.0, - duration: const Duration(milliseconds: 300), - curve: Curves.easeOut, - ); - } + void _onScroll() { + setState(() { + _controller.scrollOffset = _controller.scrollController.offset; + }); } @override Widget build(BuildContext context) { - final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100)); - - // ํ†ตํ™” ๊ธฐํ˜ธ ํ…์ŠคํŠธ (๋””๋ฒ„๊น…์šฉ) - print('ํ˜„์žฌ ํ†ตํ™” ๋‹จ์œ„: $_currency'); - final currencySymbol = _currency == 'KRW' ? 'โ‚ฉ' : '\$'; - print('ํ†ตํ™” ๊ธฐํ˜ธ: $currencySymbol'); + // ์Šคํฌ๋กค ๋ฆฌ์Šค๋„ˆ ์ถ”๊ฐ€ + _controller.scrollController.removeListener(_onScroll); + _controller.scrollController.addListener(_onScroll); return Scaffold( backgroundColor: const Color(0xFFF8FAFC), extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: appBarOpacity), - boxShadow: appBarOpacity > 0.6 - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 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.withValues(alpha: 0.2), - offset: const Offset(0, 1), - blurRadius: 2, - ) - ] - : null, - ), - ), - elevation: 0, - backgroundColor: Colors.transparent, - actions: [ - if (!kIsWeb) - _isLoading - ? const Padding( - padding: EdgeInsets.only(right: 16.0), - child: Center( - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Color(0xFF3B82F6)), - ), - ), - ), - ) - : IconButton( - icon: const FaIcon(FontAwesomeIcons.message, - size: 20, color: Color(0xFF3B82F6)), - onPressed: _scanSMS, - tooltip: 'SMS์—์„œ ๊ตฌ๋… ์ •๋ณด ์Šค์บ”', - ), - ], - ), - ), - ), + appBar: AddSubscriptionAppBar( + controller: _controller, + scrollOffset: _controller.scrollOffset, + onScanSMS: () => _controller.scanSMS(setState: setState), ), body: SingleChildScrollView( - controller: _scrollController, + controller: _controller.scrollController, physics: const BouncingScrollPhysics(), padding: const EdgeInsets.all(16), child: Form( - key: _formKey, + key: _controller.formKey, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox(height: MediaQuery.of(context).padding.top + 60), + // ํ—ค๋” ์„น์…˜ - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: Container( - margin: const EdgeInsets.only(bottom: 24), - padding: const EdgeInsets.all(24), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - colors: _gradientColors, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), - boxShadow: [ - BoxShadow( - color: _gradientColors[0].withValues(alpha: 0.3), - blurRadius: 20, - spreadRadius: 0, - offset: const Offset(0, 8), - ), - ], - ), - child: Row( - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(16), - ), - child: const Icon( - Icons.add_rounded, - size: 32, - color: Colors.white, - ), - ), - const SizedBox(width: 16), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '์ƒˆ ๊ตฌ๋… ์ถ”๊ฐ€', - style: TextStyle( - fontSize: 24, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -0.5, - ), - ), - SizedBox(height: 4), - Text( - '์„œ๋น„์Šค ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white70, - ), - ), - ], - ), - ), - ], - ), - ), - ), + AddSubscriptionHeader( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, ), - - // ์„œ๋น„์Šค ์ •๋ณด ์นด๋“œ - FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 1.0, curve: Curves.easeIn), - ), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.4), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), - )), - child: Card( - elevation: 1, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - ShaderMask( - shaderCallback: (bounds) => LinearGradient( - colors: _gradientColors, - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ).createShader(bounds), - child: const Icon( - FontAwesomeIcons.fileLines, - size: 20, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - const Text( - '์„œ๋น„์Šค ์ •๋ณด', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - letterSpacing: -0.5, - color: Color(0xFF1E293B), - ), - ), - ], - ), - const SizedBox(height: 24), - - // ์„œ๋น„์Šค๋ช… ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 0 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์„œ๋น„์Šค๋ช…', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _serviceNameController, - focusNode: _serviceNameFocus, - textInputAction: TextInputAction.next, - onTap: () => - setState(() => _currentEditingField = 0), - onEditingComplete: () { - _monthlyCostFocus.requestFocus(); - setState(() => _currentEditingField = -1); - }, - validator: (value) { - if (value == null || value.isEmpty) { - return '์„œ๋น„์Šค๋ช…์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; - } - return null; - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, horizontal: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - errorBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Colors.red, - width: 2, - ), - ), - hintText: '๋„ทํ”Œ๋ฆญ์Šค', - hintStyle: TextStyle( - color: Colors.grey.shade500, - fontSize: 16, - ), - ), - ), - ], - ), - ), - - // ์›” ๋น„์šฉ ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 1 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ํ™˜์œจ ์ •๋ณด์™€ ๋น„์šฉ ์ž…๋ ฅ ์ œ๋ชฉ ํ‘œ์‹œ (์ƒ๋‹จ) - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Text( - '๋น„์šฉ ์ž…๋ ฅ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - if (_currency == 'USD') - FutureBuilder( - future: ExchangeRateService() - .getFormattedExchangeRateInfo(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data!, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - // ํ†ตํ™” ๋‹จ์œ„ ์„ ํƒ (์ขŒ์ธก) - Expanded( - flex: 3, // 25% ๋„ˆ๋น„ ์ฐจ์ง€ - child: DropdownButtonFormField( - value: _currency, - focusNode: _currencyFocus, - isDense: true, - onTap: () => setState( - () => _currentEditingField = 1), - onChanged: (value) { - if (value != null) { - setState(() { - _currency = value; - - // ํ†ตํ™” ๋‹จ์œ„ ๋ณ€๊ฒฝ ์‹œ ์ž…๋ ฅ ๊ฐ’ ๋ณ€ํ™˜ - final currentText = - _monthlyCostController.text; - if (currentText.isNotEmpty) { - // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ - final numericValue = - double.tryParse(currentText - .replaceAll(',', '')); - - if (numericValue != null) { - if (value == 'KRW') { - // ๋‹ฌ๋Ÿฌ โ†’ ์›ํ™”: ์†Œ์ˆ˜์  ์ œ๊ฑฐ - _monthlyCostController - .text = NumberFormat - .decimalPattern() - .format(numericValue - .toInt()); - } else { - // ์›ํ™” โ†’ ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  2์ž๋ฆฌ ์ถ”๊ฐ€ - _monthlyCostController - .text = NumberFormat( - '#,##0.00') - .format(numericValue); - } - } - } - - // ํ™”๋ฉด ๊ฐฑ์‹ ํ•˜์—ฌ ํ†ตํ™” ๊ธฐํ˜ธ๋„ ์—…๋ฐ์ดํŠธ - _monthlyCostFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric( - vertical: 16, horizontal: 12), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: - Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - ), - icon: const Icon( - Icons.arrow_drop_down, - color: Color(0xFF3B82F6), - ), - items: ['KRW', 'USD'] - .map((currency) => DropdownMenuItem( - value: currency, - child: Text( - currency == 'KRW' - ? 'KRW' - : 'USD', - style: const TextStyle( - fontSize: 14, - fontWeight: - FontWeight.w500, - color: Colors.black87, - ), - ), - )) - .toList(), - ), - ), - const SizedBox(width: 8), - // ์›” ๋น„์šฉ ์ž…๋ ฅ ํ•„๋“œ (์šฐ์ธก) - ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ ๋ฐ ๋ฐฐ๊ฒฝ์ƒ‰ ํ†ต์ผ - Expanded( - flex: 7, // 75% ๋„ˆ๋น„ ์ฐจ์ง€ - child: Container( - height: 50, // ๋†’์ด๋ฅผ 56์—์„œ 50์œผ๋กœ ์ค„์ž„ - // ์šฐ์ธก์—์„œ 40ํ”ฝ์…€ ์ค„์ด๊ธฐ - margin: const EdgeInsets.only(right: 0), - // ๋‚ด๋ถ€ ํŒจ๋”ฉ์„ ๊ณ ์ •๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€ - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - // ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ฅธ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ (๋ฐฐ๊ฒฝ์ƒ‰๋งŒ ๋ณ€๊ฒฝ) - color: _currentEditingField == 1 - ? const Color( - 0xFFF3F4F6) // ํฌ์ปค์Šค ์ƒํƒœ์ผ ๋•Œ ์—ฐํ•œ ํšŒ์ƒ‰ - : Colors - .transparent, // ํฌ์ปค์Šค ์—†์„ ๋•Œ ํˆฌ๋ช… - borderRadius: - BorderRadius.circular(12), - // ํ…Œ๋‘๋ฆฌ ์„ค์ • (ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ผ ์ƒ‰์ƒ๋งŒ ๋ณ€๊ฒฝ) - border: Border.all( - color: _currentEditingField == 1 - ? const Color(0xFF3B82F6) - : Colors.grey.withValues(alpha: - 0.4), // ํฌ์ปค์Šค ์—†์„ ๋•Œ ๋” ์ง„ํ•œ ํšŒ์ƒ‰ - width: _currentEditingField == 1 - ? 2 - : 1, - ), - ), - child: Row( - children: [ - // ํ†ตํ™” ๊ธฐํ˜ธ - ํ•ญ์ƒ ํ‘œ์‹œ๋˜๋„๋ก ์ˆ˜์ • - Container( - width: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - // ํ…Œ๋‘๋ฆฌ ์ถ”๊ฐ€ (์ขŒ์ธก ํ†ตํ™”์„ ํƒ๋ž€๊ณผ ๋™์ผํ•œ ์Šคํƒ€์ผ) - border: Border( - right: BorderSide( - color: Colors.grey - .withValues(alpha: 0.2), - width: 1, - ), - ), - ), - child: Text( - _currency == 'KRW' ? 'โ‚ฉ' : '\$', - style: const TextStyle( - color: Color(0xFF3B82F6), - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - // ์‹ค์ œ ์ž…๋ ฅ ํ•„๋“œ - ๊ธฐํ˜ธ ๊ด€๋ จ ์ฝ”๋“œ ์ œ๊ฑฐํ•˜์—ฌ ์ค‘๋ณต ๋ฐฉ์ง€ - Expanded( - child: Stack( - alignment: - Alignment.centerRight, - children: [ - TextField( - controller: - _monthlyCostController, - focusNode: - _monthlyCostFocus, - textInputAction: - TextInputAction.next, - keyboardType: - const TextInputType - .numberWithOptions( - decimal: true), - inputFormatters: [ - // ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์ž…๋ ฅ ํ˜•์‹ ์ ์šฉ - FilteringTextInputFormatter - .allow( - _currency == 'KRW' - ? RegExp( - r'[0-9,]') // ์›ํ™”: ์ •์ˆ˜๋งŒ ํ—ˆ์šฉ - : RegExp( - r'[0-9,.]'), // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ—ˆ์šฉ - ), - // ์ปค์Šคํ…€ ํฌ๋งทํ„ฐ - 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ - TextInputFormatter - .withFunction( - (oldValue, - newValue) { - // ์ž…๋ ฅ๊ฐ’์—์„œ ์ฝค๋งˆ ์ œ๊ฑฐ - final text = newValue - .text - .replaceAll( - ',', ''); - - if (text.isEmpty) { - return newValue - .copyWith( - text: ''); - } - - // ์ˆซ์ž ํ˜•์‹ ๊ฒ€์ฆ - if (_currency == - 'KRW') { - // ์›ํ™”: ์ •์ˆ˜ ํ˜•์‹ - if (double.tryParse( - text) == - null) { - return oldValue; - } - - // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ - final formattedValue = - NumberFormat - .decimalPattern() - .format( - int.parse( - text)); - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } else { - // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ˜•์‹ - if (double.tryParse( - text) == - null && - text != '.') { - return oldValue; - } - - // ์†Œ์ˆ˜์  ์ดํ•˜ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ๋ถ€๋ถ„ ๋ถ„๋ฆฌ - final parts = - text.split('.'); - final integerPart = - parts[0]; - final decimalPart = parts - .length > - 1 - ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' - : ''; - - // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ (์ •์ˆ˜ ๋ถ€๋ถ„๋งŒ) - String formattedValue; - if (integerPart - .isEmpty) { - formattedValue = - '0$decimalPart'; - } else { - final formatted = NumberFormat - .decimalPattern() - .format(int.parse( - integerPart)); - formattedValue = - '$formatted$decimalPart'; - } - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } - }), - ], - onTap: () => setState(() => - _currentEditingField = - 1), - onSubmitted: (_) { - _billingCycleFocus - .requestFocus(); - setState(() => - _currentEditingField = - -1); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: - FontWeight.w500, - ), - decoration: InputDecoration( - border: InputBorder.none, - // ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋œ ํŒจ๋”ฉ ์œ ์ง€ - contentPadding: - const EdgeInsets - .symmetric( - vertical: 14, - horizontal: 8), - hintText: - _currency == 'KRW' - ? '9,000' - : '9.99', - hintStyle: TextStyle( - color: Colors - .grey.shade500, - fontSize: 16, - ), - // ๋ชจ๋“  ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ - enabledBorder: - InputBorder.none, - focusedBorder: - InputBorder.none, - errorBorder: - InputBorder.none, - disabledBorder: - InputBorder.none, - focusedErrorBorder: - InputBorder.none, - ), - ), - // ๋‹ฌ๋Ÿฌ์ผ ๋•Œ ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก ํ‘œ์‹œ - if (_currency == 'USD') - ValueListenableBuilder< - TextEditingValue>( - valueListenable: - _monthlyCostController, - builder: (context, value, - child) { - // ์ž…๋ ฅ๊ฐ’์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ํ™˜์‚ฐ ๊ธˆ์•ก ๊ฐฑ์‹  - return FutureBuilder< - String>( - future: ExchangeRateService() - .getFormattedKrwAmount( - double.tryParse(value - .text - .replaceAll( - ',', - '')) ?? - 0.0), - builder: (context, - snapshot) { - if (snapshot - .hasData && - snapshot.data! - .isNotEmpty) { - return Padding( - padding: - const EdgeInsets - .only( - right: - 12.0), - child: Text( - snapshot - .data!, - style: - const TextStyle( - fontSize: - 14, - color: Colors - .blue, - fontWeight: - FontWeight - .w500, - ), - ), - ); - } - return const SizedBox - .shrink(); - }, - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - ], - ), - ), - - // ๊ฒฐ์ œ ์ฃผ๊ธฐ ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 2 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '๊ฒฐ์ œ ์ฃผ๊ธฐ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: _billingCycle, - focusNode: _billingCycleFocus, - onTap: () => - setState(() => _currentEditingField = 2), - onChanged: (value) { - if (value != null) { - setState(() { - _billingCycle = value; - _currentEditingField = -1; - _nextBillingDateFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - prefixIcon: const Icon( - Icons.calendar_today_rounded, - color: Color(0xFF3B82F6), - ), - ), - icon: const Icon( - Icons.arrow_drop_down_circle_outlined, - color: Color(0xFF3B82F6), - ), - elevation: 2, - dropdownColor: Colors.white, - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - items: ['์›”๊ฐ„', '์—ฐ๊ฐ„', '์ฃผ๊ฐ„'] - .map((cycle) => DropdownMenuItem( - value: cycle, - child: Text( - cycle, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - )) - .toList(), - ), - ], - ), - ), - - // ๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 3 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '๋‹ค์Œ ๊ฒฐ์ œ์ผ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - InkWell( - focusNode: _nextBillingDateFocus, - onTap: () async { - setState(() => _currentEditingField = 3); - final DateTime? picked = - await showDatePicker( - context: context, - initialDate: - _nextBillingDate ?? DateTime.now(), - firstDate: DateTime.now(), - lastDate: DateTime.now().add( - const Duration(days: 365 * 2), - ), - builder: (BuildContext context, - Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: _gradientColors[0], - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _nextBillingDate = picked; - _currentEditingField = -1; - _websiteUrlFocus.requestFocus(); - }); - } else { - setState(() => _currentEditingField = -1); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: _nextBillingDate == null - ? Colors.red - : Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - const Icon( - Icons.event_rounded, - color: Color(0xFF3B82F6), - ), - const SizedBox(width: 12), - Text( - _nextBillingDate == null - ? '๊ฒฐ์ œ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”' - : DateFormat('yyyy๋…„ MM์›” dd์ผ') - .format(_nextBillingDate!), - style: TextStyle( - fontSize: 16, - color: _nextBillingDate == null - ? Colors.grey.shade500 - : const Color(0xFF1E293B), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ), - - // ์›น์‚ฌ์ดํŠธ URL ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 4 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์›น์‚ฌ์ดํŠธ URL (์„ ํƒ)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _websiteUrlController, - focusNode: _websiteUrlFocus, - textInputAction: TextInputAction.done, - onTap: () => - setState(() => _currentEditingField = 4), - onEditingComplete: () { - setState(() => _currentEditingField = 5); - _categoryFocus.requestFocus(); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, horizontal: 20), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - hintText: 'https://netflix.com', - hintStyle: TextStyle( - color: Colors.grey.shade500, - fontSize: 16, - ), - ), - ), - ], - ), - ), - - // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 5 - ? const Color(0xFF3B82F6).withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์นดํ…Œ๊ณ ๋ฆฌ (์„ ํƒ)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - Consumer( - builder: (context, categoryProvider, child) { - // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์„ ๋•Œ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ - if (categoryProvider.categories.isEmpty) { - return InkWell( - onTap: () { - // ์„œ๋น„์Šค๋ช… ๋ถ„์„ ํ›„ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ - _autoSelectCategory(); - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - decoration: BoxDecoration( - border: Border.all( - color: - Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: - BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - const Icon( - Icons.category_rounded, - color: Color(0xFF3B82F6), - ), - const SizedBox(width: 12), - const Expanded( - child: Text( - '์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ค์ •', - style: TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ), - const Icon( - Icons.sync, - color: Color(0xFF3B82F6), - ), - ], - ), - ), - ); - } - - // ์นดํ…Œ๊ณ ๋ฆฌ ๋“œ๋กญ๋‹ค์šด ํ‘œ์‹œ - return DropdownButtonFormField( - value: _selectedCategoryId, - focusNode: _categoryFocus, - onTap: () => setState( - () => _currentEditingField = 5), - onChanged: (value) { - setState(() { - _selectedCategoryId = value; - _currentEditingField = -1; - _categoryFocus.unfocus(); - }); - }, - icon: const Icon( - Icons.arrow_drop_down_circle_outlined, - color: Color(0xFF3B82F6), - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric( - vertical: 16, horizontal: 20), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - ), - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - hint: const Text( - '์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ), - isExpanded: true, - items: [ - DropdownMenuItem( - value: null, - child: const Text( - '์นดํ…Œ๊ณ ๋ฆฌ ์—†์Œ', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - ...categoryProvider.categories - .map((category) { - return DropdownMenuItem( - value: category.id, - child: Text( - category.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - ); - }).toList(), - ], - ); - }, - ), - ], - ), - ), - - // ์ด๋ฒคํŠธ ์„ค์ • ์„น์…˜ - Container( - margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _isEventActive - ? const Color(0xFF3B82F6) - : Colors.grey.withValues(alpha: 0.2), - width: _isEventActive ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Checkbox( - value: _isEventActive, - onChanged: (value) { - setState(() { - _isEventActive = value ?? false; - if (!_isEventActive) { - // ์ด๋ฒคํŠธ ๋น„ํ™œ์„ฑํ™” ์‹œ ๊ด€๋ จ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” - _eventStartDate = DateTime.now(); // ์˜ค๋Š˜๋กœ ์žฌ์„ค์ • - _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30์ผ ํ›„๋กœ ์žฌ์„ค์ • - _eventPriceController.clear(); - } else { - // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ๋‚ ์งœ๊ฐ€ null์ด๋ฉด ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • - _eventStartDate ??= DateTime.now(); - _eventEndDate ??= DateTime.now().add(const Duration(days: 30)); - } - }); - }, - activeColor: const Color(0xFF3B82F6), - ), - const Text( - '์ด๋ฒคํŠธ/ํ• ์ธ ์„ค์ •', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(width: 8), - Icon( - Icons.local_offer, - size: 20, - color: _isEventActive - ? const Color(0xFF3B82F6) - : Colors.grey, - ), - ], - ), - - // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ์ถ”๊ฐ€ ํ•„๋“œ ํ‘œ์‹œ - AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: _isEventActive ? null : 0, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: _isEventActive ? 1.0 : 0.0, - child: Column( - children: [ - const SizedBox(height: 16), - - // ์ด๋ฒคํŠธ ๊ธฐ๊ฐ„ ์„ค์ • - Row( - children: [ - // ์‹œ์ž‘์ผ - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventStartDate ?? DateTime.now(), - firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: const Color(0xFF3B82F6), - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventStartDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์‹œ์ž‘์ผ', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventStartDate == null - ? '์„ ํƒ' - : DateFormat('MM/dd').format(_eventStartDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 8), - const Icon(Icons.arrow_forward, color: Colors.grey), - const SizedBox(width: 8), - // ์ข…๋ฃŒ์ผ - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), - firstDate: _eventStartDate ?? DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: const Color(0xFF3B82F6), - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventEndDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์ข…๋ฃŒ์ผ', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventEndDate == null - ? '์„ ํƒ' - : DateFormat('MM/dd').format(_eventEndDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์ž…๋ ฅ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF3B82F6), - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _eventPriceController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), - ], - decoration: InputDecoration( - hintText: 'ํ• ์ธ๋œ ๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”', - prefixText: _currency == 'KRW' ? 'โ‚ฉ ' : '\$ ', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: Color(0xFF3B82F6), - width: 2, - ), - ), - ), - onChanged: (value) { - // ์ฝค๋งˆ ์ž๋™ ์ถ”๊ฐ€ - if (value.isNotEmpty && !value.contains('.')) { - final number = int.tryParse(value.replaceAll(',', '')); - if (number != null) { - final formatted = NumberFormat('#,###').format(number); - if (formatted != value) { - _eventPriceController.value = TextEditingValue( - text: formatted, - selection: TextSelection.collapsed( - offset: formatted.length, - ), - ); - } - } - } - }, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), + + // ์„œ๋น„์Šค ์ •๋ณด ํผ + AddSubscriptionForm( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + setState: setState, + ), + const SizedBox(height: 16), + + // ์ด๋ฒคํŠธ/ํ• ์ธ ์„น์…˜ + AddSubscriptionEventSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + setState: setState, ), - const SizedBox(height: 32), - + // ์ €์žฅ ๋ฒ„ํŠผ - FadeTransition( - opacity: Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation( - parent: _animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeIn), - ), - ), - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.6), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic), - )), - child: MouseRegion( - onEnter: (_) => setState(() => _isSaveHovered = true), - onExit: (_) => setState(() => _isSaveHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: double.infinity, - height: 60, - transform: _isSaveHovered - ? (Matrix4.identity()..scale(1.02)) - : Matrix4.identity(), - child: ElevatedButton( - onPressed: _isLoading ? null : _saveSubscription, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFF3B82F6), - foregroundColor: Colors.white, - disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3), - disabledForegroundColor: - 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).withValues(alpha: 0.5), - ), - child: _isLoading - ? const SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, - valueColor: AlwaysStoppedAnimation( - Colors.white), - ), - ) - : Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.add_circle_outline, - color: Colors.white, - size: _isSaveHovered ? 24 : 20, - ), - const SizedBox(width: 8), - const Text( - '๊ตฌ๋… ์ถ”๊ฐ€ํ•˜๊ธฐ', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ), - ), + AddSubscriptionSaveButton( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + setState: setState, ), - - const SizedBox(height: 80), ], ), ), ), ); } -} +} \ No newline at end of file diff --git a/lib/screens/add_subscription_screen_old.dart b/lib/screens/add_subscription_screen_old.dart new file mode 100644 index 0000000..0bc3f87 --- /dev/null +++ b/lib/screens/add_subscription_screen_old.dart @@ -0,0 +1,2015 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:intl/intl.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; +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 '../services/sms_service.dart'; +import '../services/subscription_url_matcher.dart'; +import '../services/exchange_rate_service.dart'; + +class AddSubscriptionScreen extends StatefulWidget { + const AddSubscriptionScreen({Key? key}) : super(key: key); + + @override + State createState() => _AddSubscriptionScreenState(); +} + +class _AddSubscriptionScreenState extends State + with SingleTickerProviderStateMixin { + final _formKey = GlobalKey(); + final _serviceNameController = TextEditingController(); + final _monthlyCostController = TextEditingController(); + final _nextBillingDateController = TextEditingController(); + final _websiteUrlController = TextEditingController(); + String _billingCycle = '์›”๊ฐ„'; + String _currency = 'KRW'; + DateTime? _nextBillingDate; + bool _isLoading = false; + String? _selectedCategoryId; + + // ์ด๋ฒคํŠธ ๊ด€๋ จ ์ƒํƒœ ๋ณ€์ˆ˜ + bool _isEventActive = false; + DateTime? _eventStartDate = DateTime.now(); // ์˜ค๋Š˜๋กœ ์ดˆ๊ธฐํ™” + DateTime? _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30์ผ ํ›„๋กœ ์ดˆ๊ธฐํ™” + final _eventPriceController = TextEditingController(); + + // ํฌ์ปค์Šค ๋…ธ๋“œ ์ถ”๊ฐ€ + final _serviceNameFocus = FocusNode(); + final _monthlyCostFocus = FocusNode(); + final _billingCycleFocus = FocusNode(); + final _nextBillingDateFocus = FocusNode(); + final _websiteUrlFocus = FocusNode(); + final _categoryFocus = FocusNode(); + final _currencyFocus = FocusNode(); + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํŠธ๋กค๋Ÿฌ + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + + // ์Šคํฌ๋กค ์ปจํŠธ๋กค๋Ÿฌ + final ScrollController _scrollController = ScrollController(); + double _scrollOffset = 0; + + // ํ˜„์žฌ ํŽธ์ง‘ ์ค‘์ธ ํ•„๋“œ + int _currentEditingField = -1; + + // ํ˜ธ๋ฒ„ ์ƒํƒœ + bool _isSaveHovered = false; + + final List _gradientColors = [ + const Color(0xFF3B82F6), + const Color(0xFF0EA5E9), + const Color(0xFF06B6D4), + ]; + + @override + void initState() { + super.initState(); + + // ๊ฒฐ์ œ์ผ ๊ธฐ๋ณธ๊ฐ’์„ ์˜ค๋Š˜ ๋‚ ์งœ๋กœ ์„ค์ • + _nextBillingDate = DateTime.now(); + + // ๋””๋ฒ„๊น… ์ •๋ณด ์ถœ๋ ฅ + + // ์„œ๋น„์Šค๋ช… ์ปจํŠธ๋กค๋Ÿฌ์— ๋ฆฌ์Šค๋„ˆ ์ถ”๊ฐ€ + _serviceNameController.addListener(_onServiceNameChanged); + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์ปจํŠธ๋กค๋Ÿฌ ์ดˆ๊ธฐํ™” + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeIn, + )); + + _slideAnimation = Tween( + begin: const Offset(0.0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + + _scrollController.addListener(() { + setState(() { + _scrollOffset = _scrollController.offset; + }); + }); + + _animationController.forward(); + } + + @override + void dispose() { + _serviceNameController.removeListener(_onServiceNameChanged); + _serviceNameController.dispose(); + _monthlyCostController.dispose(); + _nextBillingDateController.dispose(); + _websiteUrlController.dispose(); + _eventPriceController.dispose(); + _animationController.dispose(); + _scrollController.dispose(); + + // ํฌ์ปค์Šค ๋…ธ๋“œ ํ•ด์ œ + _serviceNameFocus.dispose(); + _monthlyCostFocus.dispose(); + _billingCycleFocus.dispose(); + _nextBillingDateFocus.dispose(); + _websiteUrlFocus.dispose(); + _categoryFocus.dispose(); + _currencyFocus.dispose(); + + super.dispose(); + } + + // ์„œ๋น„์Šค๋ช…์ด ๋ณ€๊ฒฝ๋  ๋•Œ ํ˜ธ์ถœ๋˜๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜ + void _onServiceNameChanged() { + if (_serviceNameController.text.isNotEmpty && + _websiteUrlController.text.isEmpty) { + // ์ž๋™ URL ๋งค์นญ ์‹œ๋„ + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); + + // ๋งค์นญ๋œ URL์ด ์žˆ์œผ๋ฉด ํ…์ŠคํŠธ ์ปจํŠธ๋กค๋Ÿฌ์— ์„ค์ • + if (suggestedUrl != null && suggestedUrl.isNotEmpty) { + setState(() { + _websiteUrlController.text = suggestedUrl; + }); + } + } + + // ์„œ๋น„์Šค๋ช…์ด ๋ณ€๊ฒฝ๋  ๋•Œ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ ์‹œ๋„ + if (_serviceNameController.text.isNotEmpty && _selectedCategoryId == null) { + _autoSelectCategory(); + } + } + + // ์„œ๋น„์Šค๋ช…์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ ํ•จ์ˆ˜ + void _autoSelectCategory() { + if (_serviceNameController.text.isEmpty) return; + + final serviceName = _serviceNameController.text.toLowerCase(); + final categoryProvider = + Provider.of(context, listen: false); + + // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ๋ฆฌํ„ด + if (categoryProvider.categories.isEmpty) return; + + // OTT ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.ottServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // OTT ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final ottCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('OTT') || + cat.name.contains('๋ฏธ๋””์–ด') || + cat.name.contains('์˜์ƒ'), + ); + + setState(() { + _selectedCategoryId = ottCategory.id; + }); + return; + } catch (_) { + // OTT ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ์Œ์•… ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.musicServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ์Œ์•… ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final musicCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('์Œ์•…') || cat.name.contains('์ŠคํŠธ๋ฆฌ๋ฐ'), + ); + + setState(() { + _selectedCategoryId = musicCategory.id; + }); + return; + } catch (_) { + // ์Œ์•… ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // AI ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.aiServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // AI ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final aiCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('AI') || cat.name.contains('์ธ๊ณต์ง€๋Šฅ'), + ); + + setState(() { + _selectedCategoryId = aiCategory.id; + }); + return; + } catch (_) { + // AI ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ํ”„๋กœ๊ทธ๋ž˜๋ฐ/๊ฐœ๋ฐœ ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ๊ฐœ๋ฐœ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final devCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('๊ฐœ๋ฐœ') || cat.name.contains('ํ”„๋กœ๊ทธ๋ž˜๋ฐ'), + ); + + setState(() { + _selectedCategoryId = devCategory.id; + }); + return; + } catch (_) { + // ๊ฐœ๋ฐœ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ์˜คํ”ผ์Šค/ํ˜‘์—… ํˆด ํ™•์ธ + if (SubscriptionUrlMatcher.officeTools.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ์˜คํ”ผ์Šค ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final officeCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('์˜คํ”ผ์Šค') || + cat.name.contains('ํ˜‘์—…') || + cat.name.contains('์—…๋ฌด'), + ); + + setState(() { + _selectedCategoryId = officeCategory.id; + }); + return; + } catch (_) { + // ์˜คํ”ผ์Šค ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ๊ธฐํƒ€ ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.otherServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ๊ธฐํƒ€ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final otherCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('๊ธฐํƒ€') || cat.name.contains('๊ฒŒ์ž„'), + ); + + setState(() { + _selectedCategoryId = otherCategory.id; + }); + } catch (_) { + // ๊ธฐํƒ€ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + } + + Future _scanSMS() async { + if (kIsWeb) return; + + setState(() => _isLoading = true); + + try { + if (!await SMSService.hasSMSPermission()) { + final granted = await SMSService.requestSMSPermission(); + if (!granted) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + const Expanded(child: Text('SMS ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + return; + } + } + + final subscriptions = await SMSService.scanSubscriptions(); + if (subscriptions.isEmpty) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.info_outline, color: Colors.white), + const SizedBox(width: 12), + const Expanded(child: Text('๊ตฌ๋… ๊ด€๋ จ SMS๋ฅผ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.orange, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + return; + } + + final subscription = subscriptions.first; + setState(() { + _serviceNameController.text = subscription['serviceName'] ?? ''; + + // ๋น„์šฉ ์ฒ˜๋ฆฌ ๋ฐ ํ†ตํ™” ๋‹จ์œ„ ์ž๋™ ๊ฐ์ง€ + final costValue = subscription['monthlyCost']?.toString() ?? ''; + + // costValue๊ฐ€ ๋น„์–ด์žˆ์ง€ ์•Š์„ ๊ฒฝ์šฐ์—๋งŒ ์ฒ˜๋ฆฌ + if (costValue.isNotEmpty) { + // ๋‹ฌ๋Ÿฌ ํ‘œ์‹œ๊ฐ€ ์žˆ๊ฑฐ๋‚˜ ์†Œ์ˆ˜์ ์ด ์žˆ์œผ๋ฉด ๋‹ฌ๋Ÿฌ๋กœ ํŒ๋‹จ + if (costValue.contains('\$') || costValue.contains('.')) { + // ๋‹ฌ๋Ÿฌ๋กœ ์„ค์ • + _currency = 'USD'; + + // ๋‹ฌ๋Ÿฌ ๊ธฐํ˜ธ ์ œ๊ฑฐ ๋ฐ ์ˆซ์ž๋งŒ ์ถ”์ถœ + String numericValue = costValue.replaceAll('\$', '').trim(); + + // ์†Œ์ˆ˜์ ์ด ์—†๋Š” ๊ฒฝ์šฐ ์†Œ์ˆ˜์  ์ถ”๊ฐ€ + if (!numericValue.contains('.')) { + numericValue = '$numericValue.00'; + } + + // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ํ•˜์—ฌ ํฌ๋งทํŒ… + final double parsedValue = + double.tryParse(numericValue.replaceAll(',', '')) ?? 0.0; + _monthlyCostController.text = + NumberFormat('#,##0.00').format(parsedValue); + } else { + // ์›ํ™”๋กœ ์„ค์ • + _currency = 'KRW'; + + // โ‚ฉ ๊ธฐํ˜ธ์™€ ์ฝค๋งˆ ์ œ๊ฑฐ + String numericValue = + costValue.replaceAll('โ‚ฉ', '').replaceAll(',', '').trim(); + + // ์ˆซ์ž๋กœ ๋ณ€ํ™˜ํ•˜์—ฌ ์ •์ˆ˜๋กœ ํฌ๋งทํŒ… + final int parsedValue = int.tryParse(numericValue) ?? 0; + _monthlyCostController.text = + NumberFormat.decimalPattern().format(parsedValue); + } + } else { + _monthlyCostController.text = ''; + } + + _billingCycle = subscription['billingCycle'] ?? '์›”๊ฐ„'; + _nextBillingDate = subscription['nextBillingDate'] != null + ? DateTime.parse(subscription['nextBillingDate']) + : DateTime.now(); + + // ์„œ๋น„์Šค๋ช…์ด ์žˆ์œผ๋ฉด URL ์ž๋™ ๋งค์นญ ์‹œ๋„ + if (subscription['serviceName'] != null && + subscription['serviceName'].isNotEmpty) { + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(subscription['serviceName']); + if (suggestedUrl != null) { + _websiteUrlController.text = suggestedUrl; + } + + // ์„œ๋น„์Šค๋ช… ๊ธฐ๋ฐ˜์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ + _autoSelectCategory(); + } + + // ์• ๋‹ˆ๋ฉ”์ด์…˜ ์žฌ์ƒ + _animationController.reset(); + _animationController.forward(); + }); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.white), + const SizedBox(width: 12), + Expanded(child: Text('SMS ์Šค์บ” ์ค‘ ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $e')), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red, + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _saveSubscription() async { + if (_formKey.currentState!.validate() && _nextBillingDate != null) { + setState(() { + _isLoading = true; + }); + + try { + // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ + final monthlyCost = + double.parse(_monthlyCostController.text.replaceAll(',', '')); + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ํŒŒ์‹ฑ + double? eventPrice; + if (_isEventActive && _eventPriceController.text.isNotEmpty) { + eventPrice = double.tryParse(_eventPriceController.text.replaceAll(',', '')); + } + + await Provider.of(context, listen: false) + .addSubscription( + serviceName: _serviceNameController.text.trim(), + monthlyCost: monthlyCost, + billingCycle: _billingCycle, + nextBillingDate: _nextBillingDate!, + websiteUrl: _websiteUrlController.text.trim(), + categoryId: _selectedCategoryId, + currency: _currency, + isEventActive: _isEventActive, + eventStartDate: _eventStartDate, + eventEndDate: _eventEndDate, + eventPrice: eventPrice, + ); + + if (mounted) { + Navigator.pop(context, true); // ์„ฑ๊ณต ์—ฌ๋ถ€ ๋ฐ˜ํ™˜ + } + } catch (e) { + setState(() { + _isLoading = false; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('์ €์žฅ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } else { + _scrollController.animateTo( + 0.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOut, + ); + } + } + + @override + Widget build(BuildContext context) { + final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100)); + + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: appBarOpacity), + boxShadow: appBarOpacity > 0.6 + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 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.withValues(alpha: 0.2), + offset: const Offset(0, 1), + blurRadius: 2, + ) + ] + : null, + ), + ), + elevation: 0, + backgroundColor: Colors.transparent, + actions: [ + if (!kIsWeb) + _isLoading + ? const Padding( + padding: EdgeInsets.only(right: 16.0), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Color(0xFF3B82F6)), + ), + ), + ), + ) + : IconButton( + icon: const FaIcon(FontAwesomeIcons.message, + size: 20, color: Color(0xFF3B82F6)), + onPressed: _scanSMS, + tooltip: 'SMS์—์„œ ๊ตฌ๋… ์ •๋ณด ์Šค์บ”', + ), + ], + ), + ), + ), + ), + body: SingleChildScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: MediaQuery.of(context).padding.top + 60), + // ํ—ค๋” ์„น์…˜ + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Container( + margin: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: _gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: _gradientColors[0].withValues(alpha: 0.3), + blurRadius: 20, + spreadRadius: 0, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.add_rounded, + size: 32, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '์ƒˆ ๊ตฌ๋… ์ถ”๊ฐ€', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + SizedBox(height: 4), + Text( + '์„œ๋น„์Šค ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white70, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + + // ์„œ๋น„์Šค ์ •๋ณด ์นด๋“œ + FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval(0.2, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + ShaderMask( + shaderCallback: (bounds) => LinearGradient( + colors: _gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(bounds), + child: const Icon( + FontAwesomeIcons.fileLines, + size: 20, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + const Text( + '์„œ๋น„์Šค ์ •๋ณด', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 24), + + // ์„œ๋น„์Šค๋ช… ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 0 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์„œ๋น„์Šค๋ช…', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _serviceNameController, + focusNode: _serviceNameFocus, + textInputAction: TextInputAction.next, + onTap: () => + setState(() => _currentEditingField = 0), + onEditingComplete: () { + _monthlyCostFocus.requestFocus(); + setState(() => _currentEditingField = -1); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return '์„œ๋น„์Šค๋ช…์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; + } + return null; + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, horizontal: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Colors.red, + width: 2, + ), + ), + hintText: '๋„ทํ”Œ๋ฆญ์Šค', + hintStyle: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + ), + ), + ), + ], + ), + ), + + // ์›” ๋น„์šฉ ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 1 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ํ™˜์œจ ์ •๋ณด์™€ ๋น„์šฉ ์ž…๋ ฅ ์ œ๋ชฉ ํ‘œ์‹œ (์ƒ๋‹จ) + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Text( + '๋น„์šฉ ์ž…๋ ฅ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + if (_currency == 'USD') + FutureBuilder( + future: ExchangeRateService() + .getFormattedExchangeRateInfo(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + // ํ†ตํ™” ๋‹จ์œ„ ์„ ํƒ (์ขŒ์ธก) + Expanded( + flex: 3, // 25% ๋„ˆ๋น„ ์ฐจ์ง€ + child: DropdownButtonFormField( + value: _currency, + focusNode: _currencyFocus, + isDense: true, + onTap: () => setState( + () => _currentEditingField = 1), + onChanged: (value) { + if (value != null) { + setState(() { + _currency = value; + + // ํ†ตํ™” ๋‹จ์œ„ ๋ณ€๊ฒฝ ์‹œ ์ž…๋ ฅ ๊ฐ’ ๋ณ€ํ™˜ + final currentText = + _monthlyCostController.text; + if (currentText.isNotEmpty) { + // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ + final numericValue = + double.tryParse(currentText + .replaceAll(',', '')); + + if (numericValue != null) { + if (value == 'KRW') { + // ๋‹ฌ๋Ÿฌ โ†’ ์›ํ™”: ์†Œ์ˆ˜์  ์ œ๊ฑฐ + _monthlyCostController + .text = NumberFormat + .decimalPattern() + .format(numericValue + .toInt()); + } else { + // ์›ํ™” โ†’ ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  2์ž๋ฆฌ ์ถ”๊ฐ€ + _monthlyCostController + .text = NumberFormat( + '#,##0.00') + .format(numericValue); + } + } + } + + // ํ™”๋ฉด ๊ฐฑ์‹ ํ•˜์—ฌ ํ†ตํ™” ๊ธฐํ˜ธ๋„ ์—…๋ฐ์ดํŠธ + _monthlyCostFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric( + vertical: 16, horizontal: 12), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: + Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + ), + icon: const Icon( + Icons.arrow_drop_down, + color: Color(0xFF3B82F6), + ), + items: ['KRW', 'USD'] + .map((currency) => DropdownMenuItem( + value: currency, + child: Text( + currency == 'KRW' + ? 'KRW' + : 'USD', + style: const TextStyle( + fontSize: 14, + fontWeight: + FontWeight.w500, + color: Colors.black87, + ), + ), + )) + .toList(), + ), + ), + const SizedBox(width: 8), + // ์›” ๋น„์šฉ ์ž…๋ ฅ ํ•„๋“œ (์šฐ์ธก) - ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ ๋ฐ ๋ฐฐ๊ฒฝ์ƒ‰ ํ†ต์ผ + Expanded( + flex: 7, // 75% ๋„ˆ๋น„ ์ฐจ์ง€ + child: Container( + height: 50, // ๋†’์ด๋ฅผ 56์—์„œ 50์œผ๋กœ ์ค„์ž„ + // ์šฐ์ธก์—์„œ 40ํ”ฝ์…€ ์ค„์ด๊ธฐ + margin: const EdgeInsets.only(right: 0), + // ๋‚ด๋ถ€ ํŒจ๋”ฉ์„ ๊ณ ์ •๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€ + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + // ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ฅธ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ (๋ฐฐ๊ฒฝ์ƒ‰๋งŒ ๋ณ€๊ฒฝ) + color: _currentEditingField == 1 + ? const Color( + 0xFFF3F4F6) // ํฌ์ปค์Šค ์ƒํƒœ์ผ ๋•Œ ์—ฐํ•œ ํšŒ์ƒ‰ + : Colors + .transparent, // ํฌ์ปค์Šค ์—†์„ ๋•Œ ํˆฌ๋ช… + borderRadius: + BorderRadius.circular(12), + // ํ…Œ๋‘๋ฆฌ ์„ค์ • (ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ผ ์ƒ‰์ƒ๋งŒ ๋ณ€๊ฒฝ) + border: Border.all( + color: _currentEditingField == 1 + ? const Color(0xFF3B82F6) + : Colors.grey.withValues(alpha: + 0.4), // ํฌ์ปค์Šค ์—†์„ ๋•Œ ๋” ์ง„ํ•œ ํšŒ์ƒ‰ + width: _currentEditingField == 1 + ? 2 + : 1, + ), + ), + child: Row( + children: [ + // ํ†ตํ™” ๊ธฐํ˜ธ - ํ•ญ์ƒ ํ‘œ์‹œ๋˜๋„๋ก ์ˆ˜์ • + Container( + width: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + // ํ…Œ๋‘๋ฆฌ ์ถ”๊ฐ€ (์ขŒ์ธก ํ†ตํ™”์„ ํƒ๋ž€๊ณผ ๋™์ผํ•œ ์Šคํƒ€์ผ) + border: Border( + right: BorderSide( + color: Colors.grey + .withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: Text( + _currency == 'KRW' ? 'โ‚ฉ' : '\$', + style: const TextStyle( + color: Color(0xFF3B82F6), + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + // ์‹ค์ œ ์ž…๋ ฅ ํ•„๋“œ - ๊ธฐํ˜ธ ๊ด€๋ จ ์ฝ”๋“œ ์ œ๊ฑฐํ•˜์—ฌ ์ค‘๋ณต ๋ฐฉ์ง€ + Expanded( + child: Stack( + alignment: + Alignment.centerRight, + children: [ + TextField( + controller: + _monthlyCostController, + focusNode: + _monthlyCostFocus, + textInputAction: + TextInputAction.next, + keyboardType: + const TextInputType + .numberWithOptions( + decimal: true), + inputFormatters: [ + // ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์ž…๋ ฅ ํ˜•์‹ ์ ์šฉ + FilteringTextInputFormatter + .allow( + _currency == 'KRW' + ? RegExp( + r'[0-9,]') // ์›ํ™”: ์ •์ˆ˜๋งŒ ํ—ˆ์šฉ + : RegExp( + r'[0-9,.]'), // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ—ˆ์šฉ + ), + // ์ปค์Šคํ…€ ํฌ๋งทํ„ฐ - 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ + TextInputFormatter + .withFunction( + (oldValue, + newValue) { + // ์ž…๋ ฅ๊ฐ’์—์„œ ์ฝค๋งˆ ์ œ๊ฑฐ + final text = newValue + .text + .replaceAll( + ',', ''); + + if (text.isEmpty) { + return newValue + .copyWith( + text: ''); + } + + // ์ˆซ์ž ํ˜•์‹ ๊ฒ€์ฆ + if (_currency == + 'KRW') { + // ์›ํ™”: ์ •์ˆ˜ ํ˜•์‹ + if (double.tryParse( + text) == + null) { + return oldValue; + } + + // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ + final formattedValue = + NumberFormat + .decimalPattern() + .format( + int.parse( + text)); + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } else { + // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ˜•์‹ + if (double.tryParse( + text) == + null && + text != '.') { + return oldValue; + } + + // ์†Œ์ˆ˜์  ์ดํ•˜ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ๋ถ€๋ถ„ ๋ถ„๋ฆฌ + final parts = + text.split('.'); + final integerPart = + parts[0]; + final decimalPart = parts + .length > + 1 + ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' + : ''; + + // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ (์ •์ˆ˜ ๋ถ€๋ถ„๋งŒ) + String formattedValue; + if (integerPart + .isEmpty) { + formattedValue = + '0$decimalPart'; + } else { + final formatted = NumberFormat + .decimalPattern() + .format(int.parse( + integerPart)); + formattedValue = + '$formatted$decimalPart'; + } + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } + }), + ], + onTap: () => setState(() => + _currentEditingField = + 1), + onSubmitted: (_) { + _billingCycleFocus + .requestFocus(); + setState(() => + _currentEditingField = + -1); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: + FontWeight.w500, + ), + decoration: InputDecoration( + border: InputBorder.none, + // ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋œ ํŒจ๋”ฉ ์œ ์ง€ + contentPadding: + const EdgeInsets + .symmetric( + vertical: 14, + horizontal: 8), + hintText: + _currency == 'KRW' + ? '9,000' + : '9.99', + hintStyle: TextStyle( + color: Colors + .grey.shade500, + fontSize: 16, + ), + // ๋ชจ๋“  ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ + enabledBorder: + InputBorder.none, + focusedBorder: + InputBorder.none, + errorBorder: + InputBorder.none, + disabledBorder: + InputBorder.none, + focusedErrorBorder: + InputBorder.none, + ), + ), + // ๋‹ฌ๋Ÿฌ์ผ ๋•Œ ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก ํ‘œ์‹œ + if (_currency == 'USD') + ValueListenableBuilder< + TextEditingValue>( + valueListenable: + _monthlyCostController, + builder: (context, value, + child) { + // ์ž…๋ ฅ๊ฐ’์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ํ™˜์‚ฐ ๊ธˆ์•ก ๊ฐฑ์‹  + return FutureBuilder< + String>( + future: ExchangeRateService() + .getFormattedKrwAmount( + double.tryParse(value + .text + .replaceAll( + ',', + '')) ?? + 0.0), + builder: (context, + snapshot) { + if (snapshot + .hasData && + snapshot.data! + .isNotEmpty) { + return Padding( + padding: + const EdgeInsets + .only( + right: + 12.0), + child: Text( + snapshot + .data!, + style: + const TextStyle( + fontSize: + 14, + color: Colors + .blue, + fontWeight: + FontWeight + .w500, + ), + ), + ); + } + return const SizedBox + .shrink(); + }, + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ], + ), + ), + + // ๊ฒฐ์ œ ์ฃผ๊ธฐ ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 2 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '๊ฒฐ์ œ ์ฃผ๊ธฐ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _billingCycle, + focusNode: _billingCycleFocus, + onTap: () => + setState(() => _currentEditingField = 2), + onChanged: (value) { + if (value != null) { + setState(() { + _billingCycle = value; + _currentEditingField = -1; + _nextBillingDateFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + prefixIcon: const Icon( + Icons.calendar_today_rounded, + color: Color(0xFF3B82F6), + ), + ), + icon: const Icon( + Icons.arrow_drop_down_circle_outlined, + color: Color(0xFF3B82F6), + ), + elevation: 2, + dropdownColor: Colors.white, + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + items: ['์›”๊ฐ„', '์—ฐ๊ฐ„', '์ฃผ๊ฐ„'] + .map((cycle) => DropdownMenuItem( + value: cycle, + child: Text( + cycle, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + )) + .toList(), + ), + ], + ), + ), + + // ๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 3 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '๋‹ค์Œ ๊ฒฐ์ œ์ผ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + InkWell( + focusNode: _nextBillingDateFocus, + onTap: () async { + setState(() => _currentEditingField = 3); + final DateTime? picked = + await showDatePicker( + context: context, + initialDate: + _nextBillingDate ?? DateTime.now(), + firstDate: DateTime.now(), + lastDate: DateTime.now().add( + const Duration(days: 365 * 2), + ), + builder: (BuildContext context, + Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: _gradientColors[0], + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _nextBillingDate = picked; + _currentEditingField = -1; + _websiteUrlFocus.requestFocus(); + }); + } else { + setState(() => _currentEditingField = -1); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: _nextBillingDate == null + ? Colors.red + : Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Row( + children: [ + const Icon( + Icons.event_rounded, + color: Color(0xFF3B82F6), + ), + const SizedBox(width: 12), + Text( + _nextBillingDate == null + ? '๊ฒฐ์ œ์ผ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”' + : DateFormat('yyyy๋…„ MM์›” dd์ผ') + .format(_nextBillingDate!), + style: TextStyle( + fontSize: 16, + color: _nextBillingDate == null + ? Colors.grey.shade500 + : const Color(0xFF1E293B), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // ์›น์‚ฌ์ดํŠธ URL ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 4 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์›น์‚ฌ์ดํŠธ URL (์„ ํƒ)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _websiteUrlController, + focusNode: _websiteUrlFocus, + textInputAction: TextInputAction.done, + onTap: () => + setState(() => _currentEditingField = 4), + onEditingComplete: () { + setState(() => _currentEditingField = 5); + _categoryFocus.requestFocus(); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, horizontal: 20), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + hintText: 'https://netflix.com', + hintStyle: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + ), + ), + ), + ], + ), + ), + + // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 5 + ? const Color(0xFF3B82F6).withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์นดํ…Œ๊ณ ๋ฆฌ (์„ ํƒ)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + Consumer( + builder: (context, categoryProvider, child) { + // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์„ ๋•Œ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + if (categoryProvider.categories.isEmpty) { + return InkWell( + onTap: () { + // ์„œ๋น„์Šค๋ช… ๋ถ„์„ ํ›„ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ + _autoSelectCategory(); + }, + child: Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + decoration: BoxDecoration( + border: Border.all( + color: + Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: + BorderRadius.circular(12), + color: Colors.white, + ), + child: const Row( + children: [ + const Icon( + Icons.category_rounded, + color: Color(0xFF3B82F6), + ), + const SizedBox(width: 12), + const Expanded( + child: Text( + '์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ค์ •', + style: TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ), + const Icon( + Icons.sync, + color: Color(0xFF3B82F6), + ), + ], + ), + ), + ); + } + + // ์นดํ…Œ๊ณ ๋ฆฌ ๋“œ๋กญ๋‹ค์šด ํ‘œ์‹œ + return DropdownButtonFormField( + value: _selectedCategoryId, + focusNode: _categoryFocus, + onTap: () => setState( + () => _currentEditingField = 5), + onChanged: (value) { + setState(() { + _selectedCategoryId = value; + _currentEditingField = -1; + _categoryFocus.unfocus(); + }); + }, + icon: const Icon( + Icons.arrow_drop_down_circle_outlined, + color: Color(0xFF3B82F6), + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric( + vertical: 16, horizontal: 20), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + ), + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + hint: const Text( + '์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + isExpanded: true, + items: [ + DropdownMenuItem( + value: null, + child: const Text( + '์นดํ…Œ๊ณ ๋ฆฌ ์—†์Œ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + ...categoryProvider.categories + .map((category) { + return DropdownMenuItem( + value: category.id, + child: Text( + category.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ); + }).toList(), + ], + ); + }, + ), + ], + ), + ), + + // ์ด๋ฒคํŠธ ์„ค์ • ์„น์…˜ + Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _isEventActive + ? const Color(0xFF3B82F6) + : Colors.grey.withValues(alpha: 0.2), + width: _isEventActive ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: _isEventActive, + onChanged: (value) { + setState(() { + _isEventActive = value ?? false; + if (!_isEventActive) { + // ์ด๋ฒคํŠธ ๋น„ํ™œ์„ฑํ™” ์‹œ ๊ด€๋ จ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” + _eventStartDate = DateTime.now(); // ์˜ค๋Š˜๋กœ ์žฌ์„ค์ • + _eventEndDate = DateTime.now().add(const Duration(days: 30)); // 30์ผ ํ›„๋กœ ์žฌ์„ค์ • + _eventPriceController.clear(); + } else { + // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ๋‚ ์งœ๊ฐ€ null์ด๋ฉด ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • + _eventStartDate ??= DateTime.now(); + _eventEndDate ??= DateTime.now().add(const Duration(days: 30)); + } + }); + }, + activeColor: const Color(0xFF3B82F6), + ), + const Text( + '์ด๋ฒคํŠธ/ํ• ์ธ ์„ค์ •', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.local_offer, + size: 20, + color: _isEventActive + ? const Color(0xFF3B82F6) + : Colors.grey, + ), + ], + ), + + // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ์ถ”๊ฐ€ ํ•„๋“œ ํ‘œ์‹œ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: _isEventActive ? null : 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isEventActive ? 1.0 : 0.0, + child: Column( + children: [ + const SizedBox(height: 16), + + // ์ด๋ฒคํŠธ ๊ธฐ๊ฐ„ ์„ค์ • + Row( + children: [ + // ์‹œ์ž‘์ผ + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventStartDate ?? DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 365)), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF3B82F6), + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventStartDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์‹œ์ž‘์ผ', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventStartDate == null + ? '์„ ํƒ' + : DateFormat('MM/dd').format(_eventStartDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, color: Colors.grey), + const SizedBox(width: 8), + // ์ข…๋ฃŒ์ผ + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), + firstDate: _eventStartDate ?? DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: const ColorScheme.light( + primary: Color(0xFF3B82F6), + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventEndDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์ข…๋ฃŒ์ผ', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventEndDate == null + ? '์„ ํƒ' + : DateFormat('MM/dd').format(_eventEndDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์ž…๋ ฅ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF3B82F6), + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _eventPriceController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), + ], + decoration: InputDecoration( + hintText: 'ํ• ์ธ๋œ ๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”', + prefixText: _currency == 'KRW' ? 'โ‚ฉ ' : '\$ ', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide( + color: Color(0xFF3B82F6), + width: 2, + ), + ), + ), + onChanged: (value) { + // ์ฝค๋งˆ ์ž๋™ ์ถ”๊ฐ€ + if (value.isNotEmpty && !value.contains('.')) { + final number = int.tryParse(value.replaceAll(',', '')); + if (number != null) { + final formatted = NumberFormat('#,###').format(number); + if (formatted != value) { + _eventPriceController.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: formatted.length, + ), + ); + } + } + } + }, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + + const SizedBox(height: 32), + + // ์ €์žฅ ๋ฒ„ํŠผ + FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: _animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic), + )), + child: MouseRegion( + onEnter: (_) => setState(() => _isSaveHovered = true), + onExit: (_) => setState(() => _isSaveHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.infinity, + height: 60, + transform: _isSaveHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: _isLoading ? null : _saveSubscription, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFF3B82F6), + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3), + disabledForegroundColor: + 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).withValues(alpha: 0.5), + ), + child: _isLoading + ? const SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Colors.white), + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_circle_outline, + color: Colors.white, + size: _isSaveHovered ? 24 : 20, + ), + const SizedBox(width: 8), + const Text( + '๊ตฌ๋… ์ถ”๊ฐ€ํ•˜๊ธฐ', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + ), + + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/app_lock_screen.dart b/lib/screens/app_lock_screen.dart index a7c553f..c3a9184 100644 --- a/lib/screens/app_lock_screen.dart +++ b/lib/screens/app_lock_screen.dart @@ -38,7 +38,7 @@ class AppLockScreen extends StatelessWidget { onPressed: () async { final appLock = context.read(); final success = await appLock.authenticate(); - if (!success) { + if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('์ธ์ฆ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค. ๋‹ค์‹œ ์‹œ๋„ํ•ด์ฃผ์„ธ์š”.'), diff --git a/lib/screens/category_management_screen.dart b/lib/screens/category_management_screen.dart index 5e137a2..1884938 100644 --- a/lib/screens/category_management_screen.dart +++ b/lib/screens/category_management_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/category_provider.dart'; -import '../models/category_model.dart'; class CategoryManagementScreen extends StatefulWidget { const CategoryManagementScreen({super.key}); diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index 767993b..3cd35e9 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -1,18 +1,13 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; -import 'dart:math' as math; import '../models/subscription_model.dart'; -import '../models/category_model.dart'; -import '../providers/subscription_provider.dart'; -import '../providers/category_provider.dart'; -import 'package:intl/intl.dart'; -import '../widgets/website_icon.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import '../services/subscription_url_matcher.dart'; -import 'package:url_launcher/url_launcher.dart'; -import 'package:flutter/services.dart'; // TextInputFormatter ์‚ฌ์šฉ์„ ์œ„ํ•œ import ์ถ”๊ฐ€ -import '../services/exchange_rate_service.dart'; // ํ™˜์œจ ์„œ๋น„์Šค๋งŒ ์‚ฌ์šฉ +import '../controllers/detail_screen_controller.dart'; +import '../widgets/detail/detail_header_section.dart'; +import '../widgets/detail/detail_form_section.dart'; +import '../widgets/detail/detail_event_section.dart'; +import '../widgets/detail/detail_url_section.dart'; +import '../widgets/detail/detail_action_buttons.dart'; +/// ๊ตฌ๋… ์ƒ์„ธ ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•˜๊ณ  ํŽธ์ง‘ํ•  ์ˆ˜ ์žˆ๋Š” ํ™”๋ฉด class DetailScreen extends StatefulWidget { final SubscriptionModel subscription; @@ -27,2206 +22,125 @@ class DetailScreen extends StatefulWidget { class _DetailScreenState extends State with SingleTickerProviderStateMixin { - late TextEditingController _serviceNameController; - late TextEditingController _monthlyCostController; - late TextEditingController _websiteUrlController; - late String _billingCycle; - late DateTime _nextBillingDate; - late AnimationController _animationController; - late Animation _fadeAnimation; - late Animation _slideAnimation; - late Animation _rotateAnimation; - String? _selectedCategoryId; // ์„ ํƒ๋œ ์นดํ…Œ๊ณ ๋ฆฌ ID - late String _currency; // ํ†ตํ™” ๋‹จ์œ„: '์›ํ™”' ๋˜๋Š” '๋‹ฌ๋Ÿฌ' - bool _isLoading = false; // ๋กœ๋”ฉ ์ƒํƒœ - - // ์ด๋ฒคํŠธ ๊ด€๋ จ ์ƒํƒœ ๋ณ€์ˆ˜ - late bool _isEventActive; - DateTime? _eventStartDate; - DateTime? _eventEndDate; - late TextEditingController _eventPriceController; - - // ํฌ์ปค์Šค ๋…ธ๋“œ ์ถ”๊ฐ€ - final _serviceNameFocus = FocusNode(); - final _monthlyCostFocus = FocusNode(); - final _billingCycleFocus = FocusNode(); - final _nextBillingDateFocus = FocusNode(); - final _websiteUrlFocus = FocusNode(); - final _categoryFocus = FocusNode(); // ์นดํ…Œ๊ณ ๋ฆฌ ํฌ์ปค์Šค ๋…ธ๋“œ - final _currencyFocus = FocusNode(); // ํ†ตํ™” ๋‹จ์œ„ ํฌ์ปค์Šค ๋…ธ๋“œ - - final ScrollController _scrollController = ScrollController(); - double _scrollOffset = 0; - - // ํ˜„์žฌ ํŽธ์ง‘ ์ค‘์ธ ํ•„๋“œ - int _currentEditingField = -1; - - // ํ˜ธ๋ฒ„ ์ƒํƒœ - bool _isDeleteHovered = false; - bool _isSaveHovered = false; - bool _isCancelHovered = false; + late DetailScreenController _controller; @override void initState() { super.initState(); - _serviceNameController = - TextEditingController(text: widget.subscription.serviceName); - _monthlyCostController = - TextEditingController(text: widget.subscription.monthlyCost.toString()); - _websiteUrlController = - TextEditingController(text: widget.subscription.websiteUrl ?? ''); - _billingCycle = widget.subscription.billingCycle; - _nextBillingDate = widget.subscription.nextBillingDate; - _selectedCategoryId = widget.subscription.categoryId; // ์นดํ…Œ๊ณ ๋ฆฌ ID ์„ค์ • - _currency = widget.subscription.currency; // ํ†ตํ™” ๋‹จ์œ„ ์„ค์ • - - // ์ด๋ฒคํŠธ ๊ด€๋ จ ์ดˆ๊ธฐํ™” - _isEventActive = widget.subscription.isEventActive; - _eventStartDate = widget.subscription.eventStartDate; - _eventEndDate = widget.subscription.eventEndDate; - _eventPriceController = TextEditingController(); - - // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์ดˆ๊ธฐํ™” - if (widget.subscription.eventPrice != null) { - if (_currency == 'KRW') { - _eventPriceController.text = NumberFormat.decimalPattern() - .format(widget.subscription.eventPrice!.toInt()); - } else { - _eventPriceController.text = - NumberFormat('#,##0.00').format(widget.subscription.eventPrice!); - } - } - - // ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ฅธ ๊ธˆ์•ก ํ‘œ์‹œ ํ˜•์‹ ์กฐ์ • - if (_currency == 'KRW') { - // ์›ํ™”: ์ •์ˆ˜ ํ˜•์‹์œผ๋กœ ํ‘œ์‹œ (์ฝค๋งˆ ํฌํ•จ) - _monthlyCostController.text = NumberFormat.decimalPattern() - .format(widget.subscription.monthlyCost.toInt()); - } else { - // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  2์ž๋ฆฌ๊นŒ์ง€ ํ‘œ์‹œ (์ฝค๋งˆ ํฌํ•จ) - _monthlyCostController.text = - NumberFormat('#,##0.00').format(widget.subscription.monthlyCost); - } - - // ์นดํ…Œ๊ณ ๋ฆฌ ID๊ฐ€ ์—†์œผ๋ฉด ์„œ๋น„์Šค๋ช… ๊ธฐ๋ฐ˜์œผ๋กœ ์ž๋™ ์„ ํƒ ์‹œ๋„ - if (_selectedCategoryId == null) { - _autoSelectCategory(); - } - - // ์„œ๋น„์Šค๋ช… ์ปจํŠธ๋กค๋Ÿฌ์— ๋ฆฌ์Šค๋„ˆ ์ถ”๊ฐ€ - _serviceNameController.addListener(_onServiceNameChanged); - - _animationController = AnimationController( - vsync: this, - duration: const Duration(milliseconds: 800), + _controller = DetailScreenController( + context: context, + subscription: widget.subscription, ); - - _fadeAnimation = Tween( - begin: 0.0, - end: 1.0, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.6, curve: Curves.easeIn), - )); - - _slideAnimation = Tween( - begin: const Offset(0.0, 0.2), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: Curves.easeOutCubic, - )); - - _rotateAnimation = Tween( - begin: 0.0, - end: 2 * math.pi, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack), - )); - - _scrollController.addListener(() { - setState(() { - _scrollOffset = _scrollController.offset; - }); - }); - - _animationController.forward(); + _controller.initialize(vsync: this); } @override void dispose() { - // ์„œ๋น„์Šค๋ช… ์ปจํŠธ๋กค๋Ÿฌ ๋ฆฌ์Šค๋„ˆ ์ œ๊ฑฐ - _serviceNameController.removeListener(_onServiceNameChanged); - - // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์œผ๋ฉด ๊ตฌ๋… ์ •๋ณด ์—…๋ฐ์ดํŠธ - if (_selectedCategoryId != widget.subscription.categoryId) { - widget.subscription.categoryId = _selectedCategoryId; - final provider = - Provider.of(context, listen: false); - provider.updateSubscription(widget.subscription); - } - - _serviceNameController.dispose(); - _monthlyCostController.dispose(); - _websiteUrlController.dispose(); - _eventPriceController.dispose(); - _animationController.dispose(); - _scrollController.dispose(); - - // ํฌ์ปค์Šค ๋…ธ๋“œ ํ•ด์ œ - _serviceNameFocus.dispose(); - _monthlyCostFocus.dispose(); - _billingCycleFocus.dispose(); - _nextBillingDateFocus.dispose(); - _websiteUrlFocus.dispose(); - _categoryFocus.dispose(); - _currencyFocus.dispose(); - + _controller.dispose(); super.dispose(); } - // ์„œ๋น„์Šค๋ช…์ด ๋ณ€๊ฒฝ๋  ๋•Œ ํ˜ธ์ถœ๋˜๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜ - void _onServiceNameChanged() { - // ์›น์‚ฌ์ดํŠธ URL์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ๊ธฐ์กด URL์ด ์„œ๋น„์Šค์™€ ๋งค์นญ๋˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ž๋™ ๋งค์นญ - if (_serviceNameController.text.isNotEmpty && - (_websiteUrlController.text.isEmpty || - SubscriptionUrlMatcher.findMatchingUrl( - _serviceNameController.text) != - _websiteUrlController.text)) { - // ์ž๋™ URL ๋งค์นญ ์‹œ๋„ - final suggestedUrl = - SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); - // ๋งค์นญ๋œ URL์ด ์žˆ์œผ๋ฉด ํ…์ŠคํŠธ ์ปจํŠธ๋กค๋Ÿฌ์— ์„ค์ • - if (suggestedUrl != null && suggestedUrl.isNotEmpty) { - setState(() { - _websiteUrlController.text = suggestedUrl; - }); - } - } - } + @override + Widget build(BuildContext context) { + final baseColor = _controller.getCardColor(); - Future _updateSubscription() async { - final provider = Provider.of(context, listen: false); - - // ์›น์‚ฌ์ดํŠธ URL์ด ๋น„์–ด์žˆ๋Š” ๊ฒฝ์šฐ ์ž๋™ ๋งค์นญ ๋‹ค์‹œ ์‹œ๋„ - String? websiteUrl = _websiteUrlController.text; - if (websiteUrl.isEmpty) { - websiteUrl = - SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); - } - - // ๊ตฌ๋… ์ •๋ณด ์—…๋ฐ์ดํŠธ - final oldCategoryId = widget.subscription.categoryId; - final newCategoryId = _selectedCategoryId; - - // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ - double monthlyCost = 0.0; - try { - monthlyCost = - double.parse(_monthlyCostController.text.replaceAll(',', '')); - } catch (e) { - // ํŒŒ์‹ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ - monthlyCost = widget.subscription.monthlyCost; - } - - widget.subscription.serviceName = _serviceNameController.text; - widget.subscription.monthlyCost = monthlyCost; - widget.subscription.websiteUrl = websiteUrl; - widget.subscription.billingCycle = _billingCycle; - widget.subscription.nextBillingDate = _nextBillingDate; - widget.subscription.categoryId = _selectedCategoryId; // ์นดํ…Œ๊ณ ๋ฆฌ ์—…๋ฐ์ดํŠธ - widget.subscription.currency = _currency; // ํ†ตํ™” ๋‹จ์œ„ ์—…๋ฐ์ดํŠธ - - // ์ด๋ฒคํŠธ ์ •๋ณด ์—…๋ฐ์ดํŠธ - widget.subscription.isEventActive = _isEventActive; - widget.subscription.eventStartDate = _eventStartDate; - widget.subscription.eventEndDate = _eventEndDate; - - // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ํŒŒ์‹ฑ - if (_isEventActive && _eventPriceController.text.isNotEmpty) { - try { - widget.subscription.eventPrice = - double.parse(_eventPriceController.text.replaceAll(',', '')); - } catch (e) { - widget.subscription.eventPrice = null; - } - } else { - widget.subscription.eventPrice = null; - } - - // ๊ตฌ๋… ์—…๋ฐ์ดํŠธ - await provider.updateSubscription(widget.subscription); - - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.check_circle_rounded, color: Colors.white), - const SizedBox(width: 12), - const Text('๊ตฌ๋… ์ •๋ณด๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: const Color(0xFF10B981), - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - - // ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ๋ฐ˜์˜๋  ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ ์œ„ํ•ด ์งง๊ฒŒ ์ง€์—ฐ ํ›„ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ - // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ๋ณ€๊ฒฝ๋œ ๊ฒฝ์šฐ์—๋งŒ true๋ฅผ ๋ฐ˜ํ™˜ - final categoryChanged = oldCategoryId != newCategoryId; - await Future.delayed(const Duration(milliseconds: 100)); - Navigator.of(context).pop(true); - } - } - - Future _deleteSubscription() async { - final confirmed = await showDialog( - context: context, - builder: (context) => AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - title: const Text( - '๊ตฌ๋… ์‚ญ์ œ', - style: TextStyle( - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFEF2F2), - borderRadius: BorderRadius.circular(16), - ), - child: const Icon( - Icons.warning_amber_rounded, - color: Color(0xFFDC2626), - size: 48, - ), + return Scaffold( + backgroundColor: const Color(0xFFF5F5F7), + body: CustomScrollView( + controller: _controller.scrollController, + slivers: [ + // ์ƒ๋‹จ ํ—ค๋” ์„น์…˜ + SliverToBoxAdapter( + child: DetailHeaderSection( + subscription: widget.subscription, + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + rotateAnimation: _controller.rotateAnimation!, ), - const SizedBox(height: 16), - const Text('์ด ๊ตฌ๋…์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'), - ], - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(false), - child: const Text('์ทจ์†Œ'), ), - ElevatedButton( - onPressed: () => Navigator.of(context).pop(true), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFDC2626), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), + // ๋ณธ๋ฌธ ์ฝ˜ํ…์ธ  + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + children: [ + // ํŽธ์ง‘ ๋ชจ๋“œ ์•ˆ๋‚ด + Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + children: [ + Icon( + Icons.edit_rounded, + color: baseColor, + size: 20, + ), + const SizedBox(width: 8), + Text( + 'ํŽธ์ง‘ ๋ชจ๋“œ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: baseColor, + ), + ), + const Spacer(), + Text( + '๋ณ€๊ฒฝ์‚ฌํ•ญ์€ ์ €์žฅ ํ›„ ์ ์šฉ๋ฉ๋‹ˆ๋‹ค', + style: TextStyle( + fontSize: 14, + color: baseColor.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + const SizedBox(height: 16), + + // ๊ธฐ๋ณธ ์ •๋ณด ํผ ์„น์…˜ + DetailFormSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 16), + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์„น์…˜ + DetailEventSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 16), + + // ์›น์‚ฌ์ดํŠธ URL ์„น์…˜ + DetailUrlSection( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + const SizedBox(height: 32), + + // ์•ก์…˜ ๋ฒ„ํŠผ + DetailActionButtons( + controller: _controller, + fadeAnimation: _controller.fadeAnimation!, + slideAnimation: _controller.slideAnimation!, + ), + ], ), ), - child: const Text( - '์‚ญ์ œ', - style: TextStyle(color: Colors.white), - ), ), ], ), ); - - if (confirmed == true && mounted) { - final provider = - Provider.of(context, listen: false); - await provider.deleteSubscription(widget.subscription.id); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Row( - children: [ - const Icon(Icons.delete_forever_rounded, color: Colors.white), - const SizedBox(width: 12), - const Text('๊ตฌ๋…์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), - ], - ), - behavior: SnackBarBehavior.floating, - backgroundColor: const Color(0xFFDC2626), - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - Navigator.of(context).pop(); - } - } } - - // ๋ฐฐ๊ฒฝ ๊ทธ๋ผ๋ฐ์ด์…˜๊ณผ ์ƒ‰์ƒ ๊ฐ€์ ธ์˜ค๊ธฐ - Color _getCardColor() { - // ์„œ๋น„์Šค ์ด๋ฆ„์— ๋”ฐ๋ผ ์ผ๊ด€๋œ ์ƒ‰์ƒ ์ƒ์„ฑ - final int hash = widget.subscription.serviceName.hashCode.abs(); - final List colors = [ - const Color(0xFF3B82F6), // ํŒŒ๋ž‘ - const Color(0xFF10B981), // ์ดˆ๋ก - const Color(0xFF8B5CF6), // ๋ณด๋ผ - const Color(0xFFF59E0B), // ๋…ธ๋ž‘ - const Color(0xFFEF4444), // ๋นจ๊ฐ• - const Color(0xFF0EA5E9), // ํ•˜๋Š˜ - const Color(0xFFEC4899), // ๋ถ„ํ™ - ]; - - return colors[hash % colors.length]; - } - - LinearGradient _getGradient(Color baseColor) { - return LinearGradient( - colors: [ - baseColor, - baseColor.withValues(alpha: 0.7), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ); - } - - // ์„œ๋น„์Šค๋ช…์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ ํ•จ์ˆ˜ - void _autoSelectCategory() { - if (_serviceNameController.text.isEmpty) return; - - final serviceName = _serviceNameController.text.toLowerCase(); - final categoryProvider = - Provider.of(context, listen: false); - - // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ๋ฆฌํ„ด - if (categoryProvider.categories.isEmpty) return; - - // OTT ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.ottServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // OTT ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final ottCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('OTT') || - cat.name.contains('๋ฏธ๋””์–ด') || - cat.name.contains('์˜์ƒ'), - ); - - setState(() { - _selectedCategoryId = ottCategory.id; - }); - return; - } catch (_) { - // OTT ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ์Œ์•… ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.musicServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ์Œ์•… ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final musicCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('์Œ์•…') || cat.name.contains('์ŠคํŠธ๋ฆฌ๋ฐ'), - ); - - setState(() { - _selectedCategoryId = musicCategory.id; - }); - return; - } catch (_) { - // ์Œ์•… ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // AI ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.aiServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // AI ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final aiCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('AI') || cat.name.contains('์ธ๊ณต์ง€๋Šฅ'), - ); - - setState(() { - _selectedCategoryId = aiCategory.id; - }); - return; - } catch (_) { - // AI ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ํ”„๋กœ๊ทธ๋ž˜๋ฐ/๊ฐœ๋ฐœ ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ๊ฐœ๋ฐœ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final devCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('๊ฐœ๋ฐœ') || cat.name.contains('ํ”„๋กœ๊ทธ๋ž˜๋ฐ'), - ); - - setState(() { - _selectedCategoryId = devCategory.id; - }); - return; - } catch (_) { - // ๊ฐœ๋ฐœ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ์˜คํ”ผ์Šค/ํ˜‘์—… ํˆด ํ™•์ธ - if (SubscriptionUrlMatcher.officeTools.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ์˜คํ”ผ์Šค ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final officeCategory = categoryProvider.categories.firstWhere( - (cat) => - cat.name.contains('์˜คํ”ผ์Šค') || - cat.name.contains('ํ˜‘์—…') || - cat.name.contains('์—…๋ฌด'), - ); - - setState(() { - _selectedCategoryId = officeCategory.id; - }); - return; - } catch (_) { - // ์˜คํ”ผ์Šค ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - - // ๊ธฐํƒ€ ์„œ๋น„์Šค ํ™•์ธ - if (SubscriptionUrlMatcher.otherServices.keys.any((key) => - serviceName.contains(key.toLowerCase()) || - key.toLowerCase().contains(serviceName))) { - // ๊ธฐํƒ€ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ - try { - final otherCategory = categoryProvider.categories.firstWhere( - (cat) => cat.name.contains('๊ธฐํƒ€') || cat.name.contains('๊ฒŒ์ž„'), - ); - - setState(() { - _selectedCategoryId = otherCategory.id; - }); - } catch (_) { - // ๊ธฐํƒ€ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ - if (categoryProvider.categories.isNotEmpty) { - setState(() { - _selectedCategoryId = categoryProvider.categories.first.id; - }); - } - } - } - } - - // URL์„ ์™ธ๋ถ€ ์•ฑ์—์„œ ์—ฌ๋Š” ํ•จ์ˆ˜ - Future _openCancellationPage() async { - final serviceName = widget.subscription.serviceName; - final websiteUrl = widget.subscription.websiteUrl; - - // ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€ URL ์ฐพ๊ธฐ - final cancellationUrl = - SubscriptionUrlMatcher.findCancellationUrl(serviceName); - - if (cancellationUrl == null) { - // ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•ˆ๋‚ด - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('๊ณต์‹ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๊ฐ€ ์ œ๊ณต๋˜์ง€ ์•Š๋Š” ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค.'), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.grey.shade700, - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - } - return; - } - - try { - final Uri url = Uri.parse(cancellationUrl); - if (await canLaunchUrl(url)) { - await launchUrl(url, mode: LaunchMode.externalApplication); - } else { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: const Text('ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๋ฅผ ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red.shade700, - duration: const Duration(seconds: 2), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10)), - ), - ); - } - } - } catch (e) { - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: $e'), - behavior: SnackBarBehavior.floating, - backgroundColor: Colors.red.shade700, - duration: const Duration(seconds: 2), - shape: - RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - ), - ); - } - } - } - - @override - Widget build(BuildContext context) { - final daysUntilBilling = - widget.subscription.nextBillingDate.difference(DateTime.now()).inDays; - final isNearBilling = daysUntilBilling <= 7; - final baseColor = _getCardColor(); - final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 150)); - - return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), - extendBodyBehindAppBar: true, - appBar: PreferredSize( - preferredSize: const Size.fromHeight(60), - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: appBarOpacity), - boxShadow: appBarOpacity > 0.6 - ? [ - BoxShadow( - color: Colors.black.withValues(alpha: 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: Color(0xFF1E293B), - shadows: appBarOpacity > 0.6 - ? [ - Shadow( - color: Colors.black.withValues(alpha: 0.2), - offset: const Offset(0, 1), - blurRadius: 2, - ) - ] - : null, - ), - ), - elevation: 0, - backgroundColor: Colors.transparent, - actions: [ - // ํ•ด์ง€ ์•ˆ๋‚ด ๋ฒ„ํŠผ - if (SubscriptionUrlMatcher.hasCancellationPage( - widget.subscription.serviceName)) - MouseRegion( - onEnter: (_) => setState(() => _isCancelHovered = true), - onExit: (_) => setState(() => _isCancelHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.symmetric( - horizontal: 8, vertical: 6), - decoration: BoxDecoration( - color: _isCancelHovered - ? const Color(0xFFF1F5F9) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: TextButton.icon( - icon: const Icon( - Icons.open_in_browser, - size: 18, - color: Color(0xFF6B7280), - ), - label: const Text( - 'ํ•ด์ง€ ์•ˆ๋‚ด', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Color(0xFF6B7280), - ), - ), - onPressed: _openCancellationPage, - style: TextButton.styleFrom( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - minimumSize: Size.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - ), - ), - ), - ), - MouseRegion( - onEnter: (_) => setState(() => _isDeleteHovered = true), - onExit: (_) => setState(() => _isDeleteHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(right: 8), - decoration: BoxDecoration( - color: _isDeleteHovered - ? const Color(0xFFFEF2F2) - : Colors.transparent, - borderRadius: BorderRadius.circular(8), - ), - child: IconButton( - icon: const FaIcon(FontAwesomeIcons.trashCan, - size: 20, color: Color(0xFFDC2626)), - tooltip: '์‚ญ์ œ', - onPressed: _deleteSubscription, - ), - ), - ), - ], - ), - ), - ), - ), - body: SingleChildScrollView( - controller: _scrollController, - physics: const BouncingScrollPhysics(), - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox(height: MediaQuery.of(context).padding.top + 60), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: _slideAnimation, - child: Hero( - tag: 'subscription_${widget.subscription.id}', - child: Card( - elevation: 8, - shadowColor: baseColor.withValues(alpha: 0.4), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(24), - ), - child: Container( - constraints: BoxConstraints( - maxHeight: - MediaQuery.of(context).size.height * 0.3), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - baseColor.withValues(alpha: 0.8), - baseColor, - ], - ), - ), - padding: const EdgeInsets.all(24), - child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - AnimatedBuilder( - animation: _animationController, - builder: (context, child) { - return Transform.rotate( - angle: _rotateAnimation.value, - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: - BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black - .withValues(alpha: 0.1), - blurRadius: 10, - spreadRadius: 0, - ), - ], - ), - child: WebsiteIcon( - key: ValueKey( - 'detail_icon_${widget.subscription.id}'), - url: widget.subscription.websiteUrl, - serviceName: - widget.subscription.serviceName, - size: 48, - ), - ), - ); - }, - ), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - widget.subscription.serviceName, - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - color: Colors.white, - letterSpacing: -0.5, - shadows: [ - Shadow( - color: Colors.black26, - offset: Offset(0, 2), - blurRadius: 4, - ), - ], - ), - ), - const SizedBox(height: 4), - Text( - '${widget.subscription.billingCycle} ๊ฒฐ์ œ', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: - Colors.white.withValues(alpha: 0.8), - ), - ), - ], - ), - ), - ], - ), - const SizedBox(height: 20), - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.15), - borderRadius: BorderRadius.circular(16), - ), - child: Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - children: [ - Column( - crossAxisAlignment: - CrossAxisAlignment.start, - children: [ - Text( - '๋‹ค์Œ ๊ฒฐ์ œ์ผ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: - Colors.white.withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 4), - Text( - DateFormat('yyyy๋…„ MM์›” dd์ผ').format( - widget.subscription - .nextBillingDate), - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: Colors.white, - ), - ), - ], - ), - Column( - crossAxisAlignment: - CrossAxisAlignment.end, - children: [ - Text( - '์›” ์ง€์ถœ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: - Colors.white.withValues(alpha: 0.8), - ), - ), - const SizedBox(height: 4), - Text( - NumberFormat.currency( - locale: _currency == 'KRW' - ? 'ko_KR' - : 'en_US', - symbol: - _currency == 'KRW' ? 'โ‚ฉ' : '\$', - decimalDigits: - _currency == 'KRW' ? 0 : 2, - ).format( - widget.subscription.monthlyCost), - style: const TextStyle( - fontSize: 28, - fontWeight: FontWeight.w800, - color: Colors.white, - ), - ), - ], - ), - ], - ), - ), - if (isNearBilling) ...[ - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, - vertical: 10, - ), - decoration: BoxDecoration( - color: const Color(0xFFDC2626) - .withValues(alpha: 0.2), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: Colors.white.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - children: [ - const Icon( - Icons.access_time_rounded, - size: 20, - color: Colors.white, - ), - const SizedBox(width: 8), - Expanded( - child: Text( - daysUntilBilling == 0 - ? '์˜ค๋Š˜ ๊ฒฐ์ œ ์˜ˆ์ •' - : '$daysUntilBilling์ผ ํ›„ ๊ฒฐ์ œ ์˜ˆ์ •', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ), - ], - ), - ), - ], - ], - ), - ), - ), - ), - ), - ), - ), - const SizedBox(height: 32), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.4), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), - )), - child: Text( - '๊ตฌ๋… ์ •๋ณด ์ˆ˜์ •', - style: TextStyle( - fontSize: 20, - fontWeight: FontWeight.w700, - color: baseColor, - letterSpacing: -0.5, - ), - ), - ), - ), - const SizedBox(height: 16), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.6), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), - )), - child: Card( - elevation: 1, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // ์„œ๋น„์Šค๋ช… ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 0 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '์„œ๋น„์Šค๋ช…', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _serviceNameController, - focusNode: _serviceNameFocus, - textInputAction: TextInputAction.next, - onTap: () => - setState(() => _currentEditingField = 0), - onEditingComplete: () { - _monthlyCostFocus.requestFocus(); - setState(() => _currentEditingField = -1); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.business_rounded, - color: baseColor, - ), - ), - ), - ], - ), - ), - - // ์›” ๋น„์šฉ ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 1 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ํ™˜์œจ ์ •๋ณด์™€ ๋น„์šฉ ์ž…๋ ฅ ์ œ๋ชฉ ํ‘œ์‹œ (์ƒ๋‹จ) - Row( - mainAxisAlignment: - MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - '๋น„์šฉ ์ž…๋ ฅ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - if (_currency == 'USD') - FutureBuilder( - future: ExchangeRateService() - .getFormattedExchangeRateInfo(), - builder: (context, snapshot) { - if (snapshot.hasData) { - return Text( - snapshot.data!, - style: TextStyle( - fontSize: 12, - color: Colors.grey[600], - fontWeight: FontWeight.w500, - ), - ); - } - return const SizedBox.shrink(); - }, - ), - ], - ), - const SizedBox(height: 8), - Row( - children: [ - // ํ†ตํ™” ๋‹จ์œ„ ์„ ํƒ (์ขŒ์ธก) - Expanded( - flex: 3, // 25% ๋„ˆ๋น„ ์ฐจ์ง€ - child: DropdownButtonFormField( - value: _currency, - focusNode: _currencyFocus, - isDense: true, - onTap: () => setState( - () => _currentEditingField = 1), - onChanged: (value) { - if (value != null) { - setState(() { - _currency = value; - - // ํ†ตํ™” ๋‹จ์œ„ ๋ณ€๊ฒฝ ์‹œ ์ž…๋ ฅ ๊ฐ’ ๋ณ€ํ™˜ - final currentText = - _monthlyCostController.text; - if (currentText.isNotEmpty) { - // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ - final numericValue = - double.tryParse(currentText - .replaceAll(',', '')); - - if (numericValue != null) { - if (value == 'KRW') { - // ๋‹ฌ๋Ÿฌ โ†’ ์›ํ™”: ์†Œ์ˆ˜์  ์ œ๊ฑฐ - _monthlyCostController - .text = NumberFormat - .decimalPattern() - .format(numericValue - .toInt()); - } else { - // ์›ํ™” โ†’ ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  2์ž๋ฆฌ ์ถ”๊ฐ€ - _monthlyCostController - .text = NumberFormat( - '#,##0.00') - .format(numericValue); - } - } - } - - // ํ™”๋ฉด ๊ฐฑ์‹ ํ•˜์—ฌ ํ†ตํ™” ๊ธฐํ˜ธ๋„ ์—…๋ฐ์ดํŠธ - _monthlyCostFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric( - vertical: 16, horizontal: 12), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: - Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - ), - icon: Icon( - Icons.arrow_drop_down, - color: baseColor, - ), - items: ['KRW', 'USD'] - .map((currency) => DropdownMenuItem( - value: currency, - child: Text( - currency == 'KRW' - ? 'KRW' - : 'USD', - style: const TextStyle( - fontSize: 14, - fontWeight: - FontWeight.w500, - color: Colors.black87, - ), - ), - )) - .toList(), - ), - ), - const SizedBox(width: 8), - // ๋น„์šฉ ์ž…๋ ฅ ํ•„๋“œ (์šฐ์ธก) - Expanded( - flex: 7, // 75% ๋„ˆ๋น„ ์ฐจ์ง€ - child: Container( - height: 50, // ๋†’์ด๋ฅผ 56์—์„œ 50์œผ๋กœ ์ค„์ž„ - // ์šฐ์ธก์—์„œ 40ํ”ฝ์…€ ์ค„์ด๊ธฐ - margin: const EdgeInsets.only(right: 0), - // ๋‚ด๋ถ€ ํŒจ๋”ฉ์„ ๊ณ ์ •๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€ - padding: const EdgeInsets.all(4), - decoration: BoxDecoration( - // ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ฅธ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ - color: _currentEditingField == 1 - ? const Color( - 0xFFF3F4F6) // ํฌ์ปค์Šค ์ƒํƒœ์ผ ๋•Œ ์—ฐํ•œ ํšŒ์ƒ‰ - : Colors - .transparent, // ํฌ์ปค์Šค ์—†์„ ๋•Œ ํˆฌ๋ช… - borderRadius: - BorderRadius.circular(12), - // ํ…Œ๋‘๋ฆฌ ์„ค์ • (ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ผ ์ƒ‰์ƒ๋งŒ ๋ณ€๊ฒฝ) - border: Border.all( - color: _currentEditingField == 1 - ? baseColor - : Colors.grey.withValues(alpha: - 0.4), // ํฌ์ปค์Šค ์—†์„ ๋•Œ ๋” ์ง„ํ•œ ํšŒ์ƒ‰ - width: _currentEditingField == 1 - ? 2 - : 1, - ), - ), - child: Row( - children: [ - // ํ†ตํ™” ๊ธฐํ˜ธ - ํ•ญ์ƒ ํ‘œ์‹œ๋˜๋„๋ก ์„ค์ • - Container( - width: 40, - alignment: Alignment.center, - decoration: BoxDecoration( - // ํ…Œ๋‘๋ฆฌ ์ถ”๊ฐ€ (์ขŒ์ธก ํ†ตํ™”์„ ํƒ๋ž€๊ณผ ๋™์ผํ•œ ์Šคํƒ€์ผ) - border: Border( - right: BorderSide( - color: Colors.grey - .withValues(alpha: 0.2), - width: 1, - ), - ), - ), - child: Text( - _currency == 'KRW' ? 'โ‚ฉ' : '\$', - style: TextStyle( - color: baseColor, - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - // ์‹ค์ œ ์ž…๋ ฅ ํ•„๋“œ - Expanded( - child: Stack( - alignment: - Alignment.centerRight, - children: [ - TextField( - controller: - _monthlyCostController, - focusNode: - _monthlyCostFocus, - textInputAction: - TextInputAction.next, - keyboardType: - const TextInputType - .numberWithOptions( - decimal: true), - inputFormatters: [ - // ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์ž…๋ ฅ ํ˜•์‹ ์ ์šฉ - FilteringTextInputFormatter - .allow( - _currency == 'KRW' - ? RegExp( - r'[0-9,]') // ์›ํ™”: ์ •์ˆ˜๋งŒ ํ—ˆ์šฉ - : RegExp( - r'[0-9,.]'), // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ—ˆ์šฉ - ), - // ์ปค์Šคํ…€ ํฌ๋งทํ„ฐ - 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ - TextInputFormatter - .withFunction( - (oldValue, - newValue) { - // ์ž…๋ ฅ๊ฐ’์—์„œ ์ฝค๋งˆ ์ œ๊ฑฐ - final text = newValue - .text - .replaceAll( - ',', ''); - - if (text.isEmpty) { - return newValue - .copyWith( - text: ''); - } - - // ์ˆซ์ž ํ˜•์‹ ๊ฒ€์ฆ - if (_currency == - 'KRW') { - // ์›ํ™”: ์ •์ˆ˜ ํ˜•์‹ - if (double.tryParse( - text) == - null) { - return oldValue; - } - - // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ - final formattedValue = - NumberFormat - .decimalPattern() - .format( - int.parse( - text)); - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } else { - // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ˜•์‹ - if (double.tryParse( - text) == - null && - text != '.') { - return oldValue; - } - - // ์†Œ์ˆ˜์  ์ดํ•˜ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ๋ถ€๋ถ„ ๋ถ„๋ฆฌ - final parts = - text.split('.'); - final integerPart = - parts[0]; - final decimalPart = parts - .length > - 1 - ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' - : ''; - - // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ (์ •์ˆ˜ ๋ถ€๋ถ„๋งŒ) - String formattedValue; - if (integerPart - .isEmpty) { - formattedValue = - '0$decimalPart'; - } else { - final formatted = NumberFormat - .decimalPattern() - .format(int.parse( - integerPart)); - formattedValue = - '$formatted$decimalPart'; - } - - return newValue - .copyWith( - text: - formattedValue, - selection: TextSelection - .collapsed( - offset: formattedValue - .length), - ); - } - }), - ], - onTap: () => setState(() => - _currentEditingField = - 1), - onSubmitted: (_) { - _billingCycleFocus - .requestFocus(); - setState(() => - _currentEditingField = - -1); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: - FontWeight.w500, - ), - decoration: InputDecoration( - border: InputBorder.none, - // ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋œ ํŒจ๋”ฉ ์œ ์ง€ - contentPadding: - const EdgeInsets - .symmetric( - vertical: 14, - horizontal: 8), - hintText: - _currency == 'KRW' - ? '9,000' - : '9.99', - hintStyle: TextStyle( - color: Colors - .grey.shade500, - fontSize: 16, - ), - // ๋ชจ๋“  ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ - enabledBorder: - InputBorder.none, - focusedBorder: - InputBorder.none, - errorBorder: - InputBorder.none, - disabledBorder: - InputBorder.none, - focusedErrorBorder: - InputBorder.none, - ), - ), - // ๋‹ฌ๋Ÿฌ์ผ ๋•Œ ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก ํ‘œ์‹œ - if (_currency == 'USD') - ValueListenableBuilder< - TextEditingValue>( - valueListenable: - _monthlyCostController, - builder: (context, value, - child) { - // ์ž…๋ ฅ๊ฐ’์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ํ™˜์‚ฐ ๊ธˆ์•ก ๊ฐฑ์‹  - return FutureBuilder< - String>( - future: ExchangeRateService() - .getFormattedKrwAmount( - double.tryParse(value - .text - .replaceAll( - ',', - '')) ?? - 0.0), - builder: (context, - snapshot) { - if (snapshot - .hasData && - snapshot.data! - .isNotEmpty) { - return Padding( - padding: - const EdgeInsets - .only( - right: - 12.0), - child: Text( - snapshot - .data!, - style: - const TextStyle( - fontSize: - 14, - color: Colors - .blue, - fontWeight: - FontWeight - .w500, - ), - ), - ); - } - return const SizedBox - .shrink(); - }, - ); - }, - ), - ], - ), - ), - ], - ), - ), - ), - ], - ), - // ํ™˜์œจ ์ •๋ณด ์œ„์ ฏ ์ถ”๊ฐ€ (๋‹ฌ๋Ÿฌ ์„ ํƒ ์‹œ์—๋งŒ ํ‘œ์‹œ) - ], - ), - ), - - // ๊ฒฐ์ œ ์ฃผ๊ธฐ ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 2 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '๊ฒฐ์ œ ์ฃผ๊ธฐ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - value: _billingCycle, - focusNode: _billingCycleFocus, - onTap: () => - setState(() => _currentEditingField = 2), - onChanged: (value) { - if (value != null) { - setState(() { - _billingCycle = value; - _currentEditingField = -1; - _nextBillingDateFocus.requestFocus(); - }); - } - }, - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.calendar_today_rounded, - color: baseColor, - ), - ), - dropdownColor: Colors.white, - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - items: ['์›”๊ฐ„', '์—ฐ๊ฐ„', '์ฃผ๊ฐ„', '์ผ๊ฐ„'] - .map((cycle) => DropdownMenuItem( - value: cycle, - child: Text(cycle), - )) - .toList(), - ), - ], - ), - ), - - // ๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 3 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '๋‹ค์Œ ๊ฒฐ์ œ์ผ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - InkWell( - focusNode: _nextBillingDateFocus, - onTap: () async { - setState(() => _currentEditingField = 3); - final DateTime? picked = - await showDatePicker( - context: context, - initialDate: _nextBillingDate, - firstDate: DateTime.now(), - lastDate: DateTime.now().add( - const Duration(days: 365 * 2), - ), - builder: (BuildContext context, - Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: baseColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _nextBillingDate = picked; - _currentEditingField = -1; - _websiteUrlFocus.requestFocus(); - }); - } else { - setState(() => _currentEditingField = -1); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - Icon( - Icons.event_rounded, - color: baseColor, - ), - const SizedBox(width: 12), - Text( - DateFormat('yyyy๋…„ MM์›” dd์ผ') - .format(_nextBillingDate), - style: const TextStyle( - fontSize: 16, - color: Color(0xFF1E293B), - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ], - ), - ), - - // ์›น์‚ฌ์ดํŠธ URL ํ•„๋“œ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 4 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '์›น์‚ฌ์ดํŠธ URL (์„ ํƒ)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _websiteUrlController, - focusNode: _websiteUrlFocus, - textInputAction: TextInputAction.done, - onTap: () => - setState(() => _currentEditingField = 4), - onEditingComplete: () { - setState(() => _currentEditingField = 5); - _categoryFocus.requestFocus(); - }, - style: const TextStyle( - color: Color(0xFF1E293B), - fontSize: 16, - fontWeight: FontWeight.w500, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - hintText: 'https://example.com', - hintStyle: TextStyle( - color: Colors.grey.shade500, - fontSize: 16, - ), - prefixIcon: Icon( - Icons.language_rounded, - color: baseColor, - ), - ), - ), - ], - ), - ), - - // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ ํ•„๋“œ ์ถ”๊ฐ€ - AnimatedContainer( - duration: const Duration(milliseconds: 200), - margin: const EdgeInsets.only(bottom: 20), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - color: _currentEditingField == 5 - ? baseColor.withValues(alpha: 0.1) - : Colors.transparent, - ), - padding: const EdgeInsets.all(8), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '์นดํ…Œ๊ณ ๋ฆฌ (์„ ํƒ)', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - Consumer( - builder: (context, categoryProvider, child) { - // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์„ ๋•Œ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ - if (categoryProvider.categories.isEmpty) { - return Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - decoration: BoxDecoration( - border: Border.all( - color: Colors.grey.withValues(alpha: 0.2), - ), - borderRadius: - BorderRadius.circular(12), - color: Colors.white, - ), - child: Row( - children: [ - Icon( - Icons.category_rounded, - color: baseColor, - ), - const SizedBox(width: 12), - const Text( - '์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ), - ], - ), - ); - } - - // ์นดํ…Œ๊ณ ๋ฆฌ ๋“œ๋กญ๋‹ค์šด ํ‘œ์‹œ - return DropdownButtonFormField( - value: _selectedCategoryId, - focusNode: _categoryFocus, - onTap: () => setState( - () => _currentEditingField = 5), - onChanged: (value) { - setState(() { - _selectedCategoryId = value; - _currentEditingField = -1; - _categoryFocus.unfocus(); - }); - }, - icon: Icon( - Icons.arrow_drop_down_circle_outlined, - color: baseColor, - ), - decoration: InputDecoration( - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.all(16), - border: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: - BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.category_rounded, - color: baseColor, - ), - ), - style: const TextStyle( - color: Colors.black87, - fontSize: 16, - fontWeight: FontWeight.w500, - ), - hint: const Text( - '์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ', - style: TextStyle( - color: Colors.grey, - fontSize: 16, - ), - ), - isExpanded: true, - items: [ - DropdownMenuItem( - value: null, - child: const Text( - '์นดํ…Œ๊ณ ๋ฆฌ ์—†์Œ', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.grey, - ), - ), - ), - ...categoryProvider.categories - .map((category) { - return DropdownMenuItem( - value: category.id, - child: Text( - category.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w500, - color: Colors.black87, - ), - ), - ); - }).toList(), - ], - ); - }, - ), - ], - ), - ), - - // ์ด๋ฒคํŠธ ์„ค์ • ์„น์…˜ - Container( - margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: _isEventActive - ? baseColor - : Colors.grey.withValues(alpha: 0.2), - width: _isEventActive ? 2 : 1, - ), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Checkbox( - value: _isEventActive, - onChanged: (value) { - setState(() { - _isEventActive = value ?? false; - if (!_isEventActive) { - // ์ด๋ฒคํŠธ ๋น„ํ™œ์„ฑํ™” ์‹œ ๊ด€๋ จ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” - _eventStartDate = null; - _eventEndDate = null; - _eventPriceController.clear(); - } - }); - }, - activeColor: baseColor, - ), - const Text( - '์ด๋ฒคํŠธ/ํ• ์ธ ์„ค์ •', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(width: 8), - Icon( - Icons.local_offer, - size: 20, - color: _isEventActive - ? baseColor - : Colors.grey, - ), - ], - ), - - // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ์ถ”๊ฐ€ ํ•„๋“œ ํ‘œ์‹œ - AnimatedContainer( - duration: const Duration(milliseconds: 300), - height: _isEventActive ? null : 0, - child: AnimatedOpacity( - duration: const Duration(milliseconds: 300), - opacity: _isEventActive ? 1.0 : 0.0, - child: Column( - children: [ - const SizedBox(height: 16), - - // ์ด๋ฒคํŠธ ๊ธฐ๊ฐ„ ์„ค์ • - Row( - children: [ - // ์‹œ์ž‘์ผ - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventStartDate ?? DateTime.now(), - firstDate: DateTime.now().subtract(const Duration(days: 365)), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: baseColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventStartDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์‹œ์ž‘์ผ', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventStartDate == null - ? '์„ ํƒ' - : DateFormat('MM/dd').format(_eventStartDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - const SizedBox(width: 8), - const Icon(Icons.arrow_forward, color: Colors.grey), - const SizedBox(width: 8), - // ์ข…๋ฃŒ์ผ - Expanded( - child: InkWell( - onTap: () async { - final DateTime? picked = await showDatePicker( - context: context, - initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), - firstDate: _eventStartDate ?? DateTime.now(), - lastDate: DateTime.now().add(const Duration(days: 365 * 2)), - builder: (BuildContext context, Widget? child) { - return Theme( - data: ThemeData.light().copyWith( - colorScheme: ColorScheme.light( - primary: baseColor, - onPrimary: Colors.white, - surface: Colors.white, - onSurface: Colors.black, - ), - ), - child: child!, - ); - }, - ); - if (picked != null) { - setState(() { - _eventEndDate = picked; - }); - } - }, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), - borderRadius: BorderRadius.circular(12), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '์ข…๋ฃŒ์ผ', - style: TextStyle( - fontSize: 12, - color: Colors.grey, - ), - ), - const SizedBox(height: 4), - Text( - _eventEndDate == null - ? '์„ ํƒ' - : DateFormat('MM/dd').format(_eventEndDate!), - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ), - ), - ), - ], - ), - - const SizedBox(height: 16), - - // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์ž…๋ ฅ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: baseColor, - ), - ), - const SizedBox(height: 8), - TextFormField( - controller: _eventPriceController, - keyboardType: const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), - ], - decoration: InputDecoration( - hintText: 'ํ• ์ธ๋œ ๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”', - prefixText: _currency == 'KRW' ? 'โ‚ฉ ' : '\$ ', - filled: true, - fillColor: Colors.white, - contentPadding: const EdgeInsets.symmetric( - vertical: 16, - horizontal: 16, - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: Colors.grey.withValues(alpha: 0.2), - ), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide( - color: baseColor, - width: 2, - ), - ), - prefixIcon: Icon( - Icons.sell, - color: baseColor, - ), - ), - onChanged: (value) { - // ์ฝค๋งˆ ์ž๋™ ์ถ”๊ฐ€ - if (value.isNotEmpty && !value.contains('.')) { - final number = int.tryParse(value.replaceAll(',', '')); - if (number != null) { - final formatted = NumberFormat('#,###').format(number); - if (formatted != value) { - _eventPriceController.value = TextEditingValue( - text: formatted, - selection: TextSelection.collapsed( - offset: formatted.length, - ), - ); - } - } - } - }, - ), - ], - ), - ], - ), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - ), - const SizedBox(height: 32), - FadeTransition( - opacity: _fadeAnimation, - child: SlideTransition( - position: Tween( - begin: const Offset(0.0, 0.8), - end: Offset.zero, - ).animate(CurvedAnimation( - parent: _animationController, - curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), - )), - child: MouseRegion( - onEnter: (_) => setState(() => _isSaveHovered = true), - onExit: (_) => setState(() => _isSaveHovered = false), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - width: double.infinity, - height: 60, - transform: _isSaveHovered - ? (Matrix4.identity()..scale(1.02)) - : Matrix4.identity(), - child: ElevatedButton( - onPressed: _updateSubscription, - style: ElevatedButton.styleFrom( - backgroundColor: baseColor, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(16), - ), - padding: const EdgeInsets.symmetric(vertical: 16), - elevation: _isSaveHovered ? 8 : 4, - shadowColor: baseColor.withValues(alpha: 0.5), - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.save_rounded, - color: Colors.white, - size: _isSaveHovered ? 24 : 20, - ), - const SizedBox(width: 8), - const Text( - '๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - ), - ], - ), - ), - ), - ), - ), - ), - const SizedBox(height: 80), - ], - ), - ), - ), - ); - } -} +} \ No newline at end of file diff --git a/lib/screens/detail_screen_old.dart b/lib/screens/detail_screen_old.dart new file mode 100644 index 0000000..e7c212f --- /dev/null +++ b/lib/screens/detail_screen_old.dart @@ -0,0 +1,2215 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'dart:math' as math; +import '../models/subscription_model.dart'; +import '../providers/subscription_provider.dart'; +import '../providers/category_provider.dart'; +import 'package:intl/intl.dart'; +import '../widgets/website_icon.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../services/subscription_url_matcher.dart'; +import 'package:url_launcher/url_launcher.dart'; +import 'package:flutter/services.dart'; // TextInputFormatter ์‚ฌ์šฉ์„ ์œ„ํ•œ import ์ถ”๊ฐ€ +import '../services/exchange_rate_service.dart'; // ํ™˜์œจ ์„œ๋น„์Šค๋งŒ ์‚ฌ์šฉ + +class DetailScreen extends StatefulWidget { + final SubscriptionModel subscription; + + const DetailScreen({ + super.key, + required this.subscription, + }); + + @override + State createState() => _DetailScreenState(); +} + +class _DetailScreenState extends State + with SingleTickerProviderStateMixin { + late TextEditingController _serviceNameController; + late TextEditingController _monthlyCostController; + late TextEditingController _websiteUrlController; + late String _billingCycle; + late DateTime _nextBillingDate; + late AnimationController _animationController; + late Animation _fadeAnimation; + late Animation _slideAnimation; + late Animation _rotateAnimation; + String? _selectedCategoryId; // ์„ ํƒ๋œ ์นดํ…Œ๊ณ ๋ฆฌ ID + late String _currency; // ํ†ตํ™” ๋‹จ์œ„: '์›ํ™”' ๋˜๋Š” '๋‹ฌ๋Ÿฌ' + + // ์ด๋ฒคํŠธ ๊ด€๋ จ ์ƒํƒœ ๋ณ€์ˆ˜ + late bool _isEventActive; + DateTime? _eventStartDate; + DateTime? _eventEndDate; + late TextEditingController _eventPriceController; + + // ํฌ์ปค์Šค ๋…ธ๋“œ ์ถ”๊ฐ€ + final _serviceNameFocus = FocusNode(); + final _monthlyCostFocus = FocusNode(); + final _billingCycleFocus = FocusNode(); + final _nextBillingDateFocus = FocusNode(); + final _websiteUrlFocus = FocusNode(); + final _categoryFocus = FocusNode(); // ์นดํ…Œ๊ณ ๋ฆฌ ํฌ์ปค์Šค ๋…ธ๋“œ + final _currencyFocus = FocusNode(); // ํ†ตํ™” ๋‹จ์œ„ ํฌ์ปค์Šค ๋…ธ๋“œ + + final ScrollController _scrollController = ScrollController(); + double _scrollOffset = 0; + + // ํ˜„์žฌ ํŽธ์ง‘ ์ค‘์ธ ํ•„๋“œ + int _currentEditingField = -1; + + // ํ˜ธ๋ฒ„ ์ƒํƒœ + bool _isDeleteHovered = false; + bool _isSaveHovered = false; + bool _isCancelHovered = false; + + @override + void initState() { + super.initState(); + _serviceNameController = + TextEditingController(text: widget.subscription.serviceName); + _monthlyCostController = + TextEditingController(text: widget.subscription.monthlyCost.toString()); + _websiteUrlController = + TextEditingController(text: widget.subscription.websiteUrl ?? ''); + _billingCycle = widget.subscription.billingCycle; + _nextBillingDate = widget.subscription.nextBillingDate; + _selectedCategoryId = widget.subscription.categoryId; // ์นดํ…Œ๊ณ ๋ฆฌ ID ์„ค์ • + _currency = widget.subscription.currency; // ํ†ตํ™” ๋‹จ์œ„ ์„ค์ • + + // ์ด๋ฒคํŠธ ๊ด€๋ จ ์ดˆ๊ธฐํ™” + _isEventActive = widget.subscription.isEventActive; + _eventStartDate = widget.subscription.eventStartDate; + _eventEndDate = widget.subscription.eventEndDate; + _eventPriceController = TextEditingController(); + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์ดˆ๊ธฐํ™” + if (widget.subscription.eventPrice != null) { + if (_currency == 'KRW') { + _eventPriceController.text = NumberFormat.decimalPattern() + .format(widget.subscription.eventPrice!.toInt()); + } else { + _eventPriceController.text = + NumberFormat('#,##0.00').format(widget.subscription.eventPrice!); + } + } + + // ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ฅธ ๊ธˆ์•ก ํ‘œ์‹œ ํ˜•์‹ ์กฐ์ • + if (_currency == 'KRW') { + // ์›ํ™”: ์ •์ˆ˜ ํ˜•์‹์œผ๋กœ ํ‘œ์‹œ (์ฝค๋งˆ ํฌํ•จ) + _monthlyCostController.text = NumberFormat.decimalPattern() + .format(widget.subscription.monthlyCost.toInt()); + } else { + // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  2์ž๋ฆฌ๊นŒ์ง€ ํ‘œ์‹œ (์ฝค๋งˆ ํฌํ•จ) + _monthlyCostController.text = + NumberFormat('#,##0.00').format(widget.subscription.monthlyCost); + } + + // ์นดํ…Œ๊ณ ๋ฆฌ ID๊ฐ€ ์—†์œผ๋ฉด ์„œ๋น„์Šค๋ช… ๊ธฐ๋ฐ˜์œผ๋กœ ์ž๋™ ์„ ํƒ ์‹œ๋„ + if (_selectedCategoryId == null) { + _autoSelectCategory(); + } + + // ์„œ๋น„์Šค๋ช… ์ปจํŠธ๋กค๋Ÿฌ์— ๋ฆฌ์Šค๋„ˆ ์ถ”๊ฐ€ + _serviceNameController.addListener(_onServiceNameChanged); + + _animationController = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 800), + ); + + _fadeAnimation = Tween( + begin: 0.0, + end: 1.0, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.6, curve: Curves.easeIn), + )); + + _slideAnimation = Tween( + begin: const Offset(0.0, 0.2), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: Curves.easeOutCubic, + )); + + _rotateAnimation = Tween( + begin: 0.0, + end: 2 * math.pi, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack), + )); + + _scrollController.addListener(() { + setState(() { + _scrollOffset = _scrollController.offset; + }); + }); + + _animationController.forward(); + } + + @override + void dispose() { + // ์„œ๋น„์Šค๋ช… ์ปจํŠธ๋กค๋Ÿฌ ๋ฆฌ์Šค๋„ˆ ์ œ๊ฑฐ + _serviceNameController.removeListener(_onServiceNameChanged); + + // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ๋ณ€๊ฒฝ๋˜์—ˆ์œผ๋ฉด ๊ตฌ๋… ์ •๋ณด ์—…๋ฐ์ดํŠธ + if (_selectedCategoryId != widget.subscription.categoryId) { + widget.subscription.categoryId = _selectedCategoryId; + final provider = + Provider.of(context, listen: false); + provider.updateSubscription(widget.subscription); + } + + _serviceNameController.dispose(); + _monthlyCostController.dispose(); + _websiteUrlController.dispose(); + _eventPriceController.dispose(); + _animationController.dispose(); + _scrollController.dispose(); + + // ํฌ์ปค์Šค ๋…ธ๋“œ ํ•ด์ œ + _serviceNameFocus.dispose(); + _monthlyCostFocus.dispose(); + _billingCycleFocus.dispose(); + _nextBillingDateFocus.dispose(); + _websiteUrlFocus.dispose(); + _categoryFocus.dispose(); + _currencyFocus.dispose(); + + super.dispose(); + } + + // ์„œ๋น„์Šค๋ช…์ด ๋ณ€๊ฒฝ๋  ๋•Œ ํ˜ธ์ถœ๋˜๋Š” ์ฝœ๋ฐฑ ํ•จ์ˆ˜ + void _onServiceNameChanged() { + // ์›น์‚ฌ์ดํŠธ URL์ด ๋น„์–ด์žˆ๊ฑฐ๋‚˜ ๊ธฐ์กด URL์ด ์„œ๋น„์Šค์™€ ๋งค์นญ๋˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ์—๋งŒ ์ž๋™ ๋งค์นญ + if (_serviceNameController.text.isNotEmpty && + (_websiteUrlController.text.isEmpty || + SubscriptionUrlMatcher.findMatchingUrl( + _serviceNameController.text) != + _websiteUrlController.text)) { + // ์ž๋™ URL ๋งค์นญ ์‹œ๋„ + final suggestedUrl = + SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); + + // ๋งค์นญ๋œ URL์ด ์žˆ์œผ๋ฉด ํ…์ŠคํŠธ ์ปจํŠธ๋กค๋Ÿฌ์— ์„ค์ • + if (suggestedUrl != null && suggestedUrl.isNotEmpty) { + setState(() { + _websiteUrlController.text = suggestedUrl; + }); + } + } + } + + Future _updateSubscription() async { + final provider = Provider.of(context, listen: false); + + // ์›น์‚ฌ์ดํŠธ URL์ด ๋น„์–ด์žˆ๋Š” ๊ฒฝ์šฐ ์ž๋™ ๋งค์นญ ๋‹ค์‹œ ์‹œ๋„ + String? websiteUrl = _websiteUrlController.text; + if (websiteUrl.isEmpty) { + websiteUrl = + SubscriptionUrlMatcher.suggestUrl(_serviceNameController.text); + } + + // ๊ตฌ๋… ์ •๋ณด ์—…๋ฐ์ดํŠธ + // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ + double monthlyCost = 0.0; + try { + monthlyCost = + double.parse(_monthlyCostController.text.replaceAll(',', '')); + } catch (e) { + // ํŒŒ์‹ฑ ์˜ค๋ฅ˜ ๋ฐœ์ƒ ์‹œ ๊ธฐ๋ณธ๊ฐ’ ์‚ฌ์šฉ + monthlyCost = widget.subscription.monthlyCost; + } + + widget.subscription.serviceName = _serviceNameController.text; + widget.subscription.monthlyCost = monthlyCost; + widget.subscription.websiteUrl = websiteUrl; + widget.subscription.billingCycle = _billingCycle; + widget.subscription.nextBillingDate = _nextBillingDate; + widget.subscription.categoryId = _selectedCategoryId; // ์นดํ…Œ๊ณ ๋ฆฌ ์—…๋ฐ์ดํŠธ + widget.subscription.currency = _currency; // ํ†ตํ™” ๋‹จ์œ„ ์—…๋ฐ์ดํŠธ + + // ์ด๋ฒคํŠธ ์ •๋ณด ์—…๋ฐ์ดํŠธ + widget.subscription.isEventActive = _isEventActive; + widget.subscription.eventStartDate = _eventStartDate; + widget.subscription.eventEndDate = _eventEndDate; + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ํŒŒ์‹ฑ + if (_isEventActive && _eventPriceController.text.isNotEmpty) { + try { + widget.subscription.eventPrice = + double.parse(_eventPriceController.text.replaceAll(',', '')); + } catch (e) { + widget.subscription.eventPrice = null; + } + } else { + widget.subscription.eventPrice = null; + } + + // ๊ตฌ๋… ์—…๋ฐ์ดํŠธ + await provider.updateSubscription(widget.subscription); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.check_circle_rounded, color: Colors.white), + const SizedBox(width: 12), + const Text('๊ตฌ๋… ์ •๋ณด๊ฐ€ ์—…๋ฐ์ดํŠธ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFF10B981), + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + + // ๋ณ€๊ฒฝ ์‚ฌํ•ญ์ด ๋ฐ˜์˜๋  ์‹œ๊ฐ„์„ ์ฃผ๊ธฐ ์œ„ํ•ด ์งง๊ฒŒ ์ง€์—ฐ ํ›„ ๊ฒฐ๊ณผ ๋ฐ˜ํ™˜ + await Future.delayed(const Duration(milliseconds: 100)); + if (!context.mounted) return; + Navigator.of(context).pop(true); + } + } + + Future _deleteSubscription() async { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: const Text( + '๊ตฌ๋… ์‚ญ์ œ', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFEF2F2), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.warning_amber_rounded, + color: Color(0xFFDC2626), + size: 48, + ), + ), + const SizedBox(height: 16), + const Text('์ด ๊ตฌ๋…์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?\n์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('์ทจ์†Œ'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFDC2626), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: const Text( + '์‚ญ์ œ', + style: TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final provider = + Provider.of(context, listen: false); + await provider.deleteSubscription(widget.subscription.id); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Row( + children: [ + const Icon(Icons.delete_forever_rounded, color: Colors.white), + const SizedBox(width: 12), + const Text('๊ตฌ๋…์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'), + ], + ), + behavior: SnackBarBehavior.floating, + backgroundColor: const Color(0xFFDC2626), + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + Navigator.of(context).pop(); + } + } + } + + // ๋ฐฐ๊ฒฝ ๊ทธ๋ผ๋ฐ์ด์…˜๊ณผ ์ƒ‰์ƒ ๊ฐ€์ ธ์˜ค๊ธฐ + Color _getCardColor() { + // ์„œ๋น„์Šค ์ด๋ฆ„์— ๋”ฐ๋ผ ์ผ๊ด€๋œ ์ƒ‰์ƒ ์ƒ์„ฑ + final int hash = widget.subscription.serviceName.hashCode.abs(); + final List colors = [ + const Color(0xFF3B82F6), // ํŒŒ๋ž‘ + const Color(0xFF10B981), // ์ดˆ๋ก + const Color(0xFF8B5CF6), // ๋ณด๋ผ + const Color(0xFFF59E0B), // ๋…ธ๋ž‘ + const Color(0xFFEF4444), // ๋นจ๊ฐ• + const Color(0xFF0EA5E9), // ํ•˜๋Š˜ + const Color(0xFFEC4899), // ๋ถ„ํ™ + ]; + + return colors[hash % colors.length]; + } + + + // ์„œ๋น„์Šค๋ช…์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์นดํ…Œ๊ณ ๋ฆฌ ์ž๋™ ์„ ํƒ ํ•จ์ˆ˜ + void _autoSelectCategory() { + if (_serviceNameController.text.isEmpty) return; + + final serviceName = _serviceNameController.text.toLowerCase(); + final categoryProvider = + Provider.of(context, listen: false); + + // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ๋ฆฌํ„ด + if (categoryProvider.categories.isEmpty) return; + + // OTT ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.ottServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // OTT ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final ottCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('OTT') || + cat.name.contains('๋ฏธ๋””์–ด') || + cat.name.contains('์˜์ƒ'), + ); + + setState(() { + _selectedCategoryId = ottCategory.id; + }); + return; + } catch (_) { + // OTT ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ์Œ์•… ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.musicServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ์Œ์•… ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final musicCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('์Œ์•…') || cat.name.contains('์ŠคํŠธ๋ฆฌ๋ฐ'), + ); + + setState(() { + _selectedCategoryId = musicCategory.id; + }); + return; + } catch (_) { + // ์Œ์•… ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // AI ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.aiServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // AI ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final aiCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('AI') || cat.name.contains('์ธ๊ณต์ง€๋Šฅ'), + ); + + setState(() { + _selectedCategoryId = aiCategory.id; + }); + return; + } catch (_) { + // AI ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ํ”„๋กœ๊ทธ๋ž˜๋ฐ/๊ฐœ๋ฐœ ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.programmingServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ๊ฐœ๋ฐœ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final devCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('๊ฐœ๋ฐœ') || cat.name.contains('ํ”„๋กœ๊ทธ๋ž˜๋ฐ'), + ); + + setState(() { + _selectedCategoryId = devCategory.id; + }); + return; + } catch (_) { + // ๊ฐœ๋ฐœ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ์˜คํ”ผ์Šค/ํ˜‘์—… ํˆด ํ™•์ธ + if (SubscriptionUrlMatcher.officeTools.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ์˜คํ”ผ์Šค ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final officeCategory = categoryProvider.categories.firstWhere( + (cat) => + cat.name.contains('์˜คํ”ผ์Šค') || + cat.name.contains('ํ˜‘์—…') || + cat.name.contains('์—…๋ฌด'), + ); + + setState(() { + _selectedCategoryId = officeCategory.id; + }); + return; + } catch (_) { + // ์˜คํ”ผ์Šค ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + + // ๊ธฐํƒ€ ์„œ๋น„์Šค ํ™•์ธ + if (SubscriptionUrlMatcher.otherServices.keys.any((key) => + serviceName.contains(key.toLowerCase()) || + key.toLowerCase().contains(serviceName))) { + // ๊ธฐํƒ€ ๊ด€๋ จ ์นดํ…Œ๊ณ ๋ฆฌ ์ฐพ๊ธฐ + try { + final otherCategory = categoryProvider.categories.firstWhere( + (cat) => cat.name.contains('๊ธฐํƒ€') || cat.name.contains('๊ฒŒ์ž„'), + ); + + setState(() { + _selectedCategoryId = otherCategory.id; + }); + } catch (_) { + // ๊ธฐํƒ€ ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์œผ๋ฉด ์ฒซ ๋ฒˆ์งธ ์นดํ…Œ๊ณ ๋ฆฌ ์‚ฌ์šฉ + if (categoryProvider.categories.isNotEmpty) { + setState(() { + _selectedCategoryId = categoryProvider.categories.first.id; + }); + } + } + } + } + + // URL์„ ์™ธ๋ถ€ ์•ฑ์—์„œ ์—ฌ๋Š” ํ•จ์ˆ˜ + Future _openCancellationPage() async { + final serviceName = widget.subscription.serviceName; + + // ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€ URL ์ฐพ๊ธฐ + final cancellationUrl = + SubscriptionUrlMatcher.findCancellationUrl(serviceName); + + if (cancellationUrl == null) { + // ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๊ฐ€ ์—†๋Š” ๊ฒฝ์šฐ ์‚ฌ์šฉ์ž์—๊ฒŒ ์•ˆ๋‚ด + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('๊ณต์‹ ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๊ฐ€ ์ œ๊ณต๋˜์ง€ ์•Š๋Š” ์„œ๋น„์Šค์ž…๋‹ˆ๋‹ค.'), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.grey.shade700, + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + return; + } + + try { + final Uri url = Uri.parse(cancellationUrl); + if (await canLaunchUrl(url)) { + await launchUrl(url, mode: LaunchMode.externalApplication); + } else { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: const Text('ํ•ด์ง€ ์•ˆ๋‚ด ํŽ˜์ด์ง€๋ฅผ ์—ด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red.shade700, + duration: const Duration(seconds: 2), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + ); + } + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: $e'), + behavior: SnackBarBehavior.floating, + backgroundColor: Colors.red.shade700, + duration: const Duration(seconds: 2), + shape: + RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final daysUntilBilling = + widget.subscription.nextBillingDate.difference(DateTime.now()).inDays; + final isNearBilling = daysUntilBilling <= 7; + final baseColor = _getCardColor(); + final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 150)); + + return Scaffold( + backgroundColor: const Color(0xFFF8FAFC), + extendBodyBehindAppBar: true, + appBar: PreferredSize( + preferredSize: const Size.fromHeight(60), + child: Container( + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: appBarOpacity), + boxShadow: appBarOpacity > 0.6 + ? [ + BoxShadow( + color: Colors.black.withValues(alpha: 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.withValues(alpha: 0.2), + offset: const Offset(0, 1), + blurRadius: 2, + ) + ] + : null, + ), + ), + elevation: 0, + backgroundColor: Colors.transparent, + actions: [ + // ํ•ด์ง€ ์•ˆ๋‚ด ๋ฒ„ํŠผ + if (SubscriptionUrlMatcher.hasCancellationPage( + widget.subscription.serviceName)) + MouseRegion( + onEnter: (_) => setState(() => _isCancelHovered = true), + onExit: (_) => setState(() => _isCancelHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.symmetric( + horizontal: 8, vertical: 6), + decoration: BoxDecoration( + color: _isCancelHovered + ? const Color(0xFFF1F5F9) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: TextButton.icon( + icon: const Icon( + Icons.open_in_browser, + size: 18, + color: Color(0xFF6B7280), + ), + label: const Text( + 'ํ•ด์ง€ ์•ˆ๋‚ด', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Color(0xFF6B7280), + ), + ), + onPressed: _openCancellationPage, + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ), + ), + ), + MouseRegion( + onEnter: (_) => setState(() => _isDeleteHovered = true), + onExit: (_) => setState(() => _isDeleteHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(right: 8), + decoration: BoxDecoration( + color: _isDeleteHovered + ? const Color(0xFFFEF2F2) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: IconButton( + icon: const FaIcon(FontAwesomeIcons.trashCan, + size: 20, color: Color(0xFFDC2626)), + tooltip: '์‚ญ์ œ', + onPressed: _deleteSubscription, + ), + ), + ), + ], + ), + ), + ), + ), + body: SingleChildScrollView( + controller: _scrollController, + physics: const BouncingScrollPhysics(), + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: MediaQuery.of(context).padding.top + 60), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: _slideAnimation, + child: Hero( + tag: 'subscription_${widget.subscription.id}', + child: Card( + elevation: 8, + shadowColor: baseColor.withValues(alpha: 0.4), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(24), + ), + child: Container( + constraints: BoxConstraints( + maxHeight: + MediaQuery.of(context).size.height * 0.3), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + baseColor.withValues(alpha: 0.8), + baseColor, + ], + ), + ), + padding: const EdgeInsets.all(24), + child: SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + AnimatedBuilder( + animation: _animationController, + builder: (context, child) { + return Transform.rotate( + angle: _rotateAnimation.value, + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: + BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black + .withValues(alpha: 0.1), + blurRadius: 10, + spreadRadius: 0, + ), + ], + ), + child: WebsiteIcon( + key: ValueKey( + 'detail_icon_${widget.subscription.id}'), + url: widget.subscription.websiteUrl, + serviceName: + widget.subscription.serviceName, + size: 48, + ), + ), + ); + }, + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + widget.subscription.serviceName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + shadows: [ + Shadow( + color: Colors.black26, + offset: Offset(0, 2), + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + '${widget.subscription.billingCycle} ๊ฒฐ์ œ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: + Colors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + children: [ + Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '๋‹ค์Œ ๊ฒฐ์ œ์ผ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: + Colors.white.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 4), + Text( + DateFormat('yyyy๋…„ MM์›” dd์ผ').format( + widget.subscription + .nextBillingDate), + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ), + Column( + crossAxisAlignment: + CrossAxisAlignment.end, + children: [ + Text( + '์›” ์ง€์ถœ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: + Colors.white.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 4), + Text( + NumberFormat.currency( + locale: _currency == 'KRW' + ? 'ko_KR' + : 'en_US', + symbol: + _currency == 'KRW' ? 'โ‚ฉ' : '\$', + decimalDigits: + _currency == 'KRW' ? 0 : 2, + ).format( + widget.subscription.monthlyCost), + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + ), + ), + ], + ), + ], + ), + ), + if (isNearBilling) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + decoration: BoxDecoration( + color: const Color(0xFFDC2626) + .withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: Colors.white.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + const Icon( + Icons.access_time_rounded, + size: 20, + color: Colors.white, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + daysUntilBilling == 0 + ? '์˜ค๋Š˜ ๊ฒฐ์ œ ์˜ˆ์ •' + : '$daysUntilBilling์ผ ํ›„ ๊ฒฐ์ œ ์˜ˆ์ •', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ), + ), + ), + const SizedBox(height: 32), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), + )), + child: Text( + '๊ตฌ๋… ์ •๋ณด ์ˆ˜์ •', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: baseColor, + letterSpacing: -0.5, + ), + ), + ), + ), + const SizedBox(height: 16), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // ์„œ๋น„์Šค๋ช… ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 0 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '์„œ๋น„์Šค๋ช…', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _serviceNameController, + focusNode: _serviceNameFocus, + textInputAction: TextInputAction.next, + onTap: () => + setState(() => _currentEditingField = 0), + onEditingComplete: () { + _monthlyCostFocus.requestFocus(); + setState(() => _currentEditingField = -1); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.business_rounded, + color: baseColor, + ), + ), + ), + ], + ), + ), + + // ์›” ๋น„์šฉ ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 1 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ํ™˜์œจ ์ •๋ณด์™€ ๋น„์šฉ ์ž…๋ ฅ ์ œ๋ชฉ ํ‘œ์‹œ (์ƒ๋‹จ) + Row( + mainAxisAlignment: + MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text( + '๋น„์šฉ ์ž…๋ ฅ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + if (_currency == 'USD') + FutureBuilder( + future: ExchangeRateService() + .getFormattedExchangeRateInfo(), + builder: (context, snapshot) { + if (snapshot.hasData) { + return Text( + snapshot.data!, + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + fontWeight: FontWeight.w500, + ), + ); + } + return const SizedBox.shrink(); + }, + ), + ], + ), + const SizedBox(height: 8), + Row( + children: [ + // ํ†ตํ™” ๋‹จ์œ„ ์„ ํƒ (์ขŒ์ธก) + Expanded( + flex: 3, // 25% ๋„ˆ๋น„ ์ฐจ์ง€ + child: DropdownButtonFormField( + value: _currency, + focusNode: _currencyFocus, + isDense: true, + onTap: () => setState( + () => _currentEditingField = 1), + onChanged: (value) { + if (value != null) { + setState(() { + _currency = value; + + // ํ†ตํ™” ๋‹จ์œ„ ๋ณ€๊ฒฝ ์‹œ ์ž…๋ ฅ ๊ฐ’ ๋ณ€ํ™˜ + final currentText = + _monthlyCostController.text; + if (currentText.isNotEmpty) { + // ์ฝค๋งˆ ์ œ๊ฑฐํ•˜๊ณ  ์ˆซ์ž๋งŒ ์ถ”์ถœ + final numericValue = + double.tryParse(currentText + .replaceAll(',', '')); + + if (numericValue != null) { + if (value == 'KRW') { + // ๋‹ฌ๋Ÿฌ โ†’ ์›ํ™”: ์†Œ์ˆ˜์  ์ œ๊ฑฐ + _monthlyCostController + .text = NumberFormat + .decimalPattern() + .format(numericValue + .toInt()); + } else { + // ์›ํ™” โ†’ ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  2์ž๋ฆฌ ์ถ”๊ฐ€ + _monthlyCostController + .text = NumberFormat( + '#,##0.00') + .format(numericValue); + } + } + } + + // ํ™”๋ฉด ๊ฐฑ์‹ ํ•˜์—ฌ ํ†ตํ™” ๊ธฐํ˜ธ๋„ ์—…๋ฐ์ดํŠธ + _monthlyCostFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric( + vertical: 16, horizontal: 12), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: + Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + ), + icon: Icon( + Icons.arrow_drop_down, + color: baseColor, + ), + items: ['KRW', 'USD'] + .map((currency) => DropdownMenuItem( + value: currency, + child: Text( + currency == 'KRW' + ? 'KRW' + : 'USD', + style: const TextStyle( + fontSize: 14, + fontWeight: + FontWeight.w500, + color: Colors.black87, + ), + ), + )) + .toList(), + ), + ), + const SizedBox(width: 8), + // ๋น„์šฉ ์ž…๋ ฅ ํ•„๋“œ (์šฐ์ธก) + Expanded( + flex: 7, // 75% ๋„ˆ๋น„ ์ฐจ์ง€ + child: Container( + height: 50, // ๋†’์ด๋ฅผ 56์—์„œ 50์œผ๋กœ ์ค„์ž„ + // ์šฐ์ธก์—์„œ 40ํ”ฝ์…€ ์ค„์ด๊ธฐ + margin: const EdgeInsets.only(right: 0), + // ๋‚ด๋ถ€ ํŒจ๋”ฉ์„ ๊ณ ์ •๊ฐ’์œผ๋กœ ์„ค์ •ํ•˜์—ฌ ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋˜๊ฒŒ ์œ ์ง€ + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + // ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ฅธ ๋ฐฐ๊ฒฝ์ƒ‰ ๋ณ€๊ฒฝ + color: _currentEditingField == 1 + ? const Color( + 0xFFF3F4F6) // ํฌ์ปค์Šค ์ƒํƒœ์ผ ๋•Œ ์—ฐํ•œ ํšŒ์ƒ‰ + : Colors + .transparent, // ํฌ์ปค์Šค ์—†์„ ๋•Œ ํˆฌ๋ช… + borderRadius: + BorderRadius.circular(12), + // ํ…Œ๋‘๋ฆฌ ์„ค์ • (ํฌ์ปค์Šค ์ƒํƒœ์— ๋”ฐ๋ผ ์ƒ‰์ƒ๋งŒ ๋ณ€๊ฒฝ) + border: Border.all( + color: _currentEditingField == 1 + ? baseColor + : Colors.grey.withValues(alpha: + 0.4), // ํฌ์ปค์Šค ์—†์„ ๋•Œ ๋” ์ง„ํ•œ ํšŒ์ƒ‰ + width: _currentEditingField == 1 + ? 2 + : 1, + ), + ), + child: Row( + children: [ + // ํ†ตํ™” ๊ธฐํ˜ธ - ํ•ญ์ƒ ํ‘œ์‹œ๋˜๋„๋ก ์„ค์ • + Container( + width: 40, + alignment: Alignment.center, + decoration: BoxDecoration( + // ํ…Œ๋‘๋ฆฌ ์ถ”๊ฐ€ (์ขŒ์ธก ํ†ตํ™”์„ ํƒ๋ž€๊ณผ ๋™์ผํ•œ ์Šคํƒ€์ผ) + border: Border( + right: BorderSide( + color: Colors.grey + .withValues(alpha: 0.2), + width: 1, + ), + ), + ), + child: Text( + _currency == 'KRW' ? 'โ‚ฉ' : '\$', + style: TextStyle( + color: baseColor, + fontSize: 18, + fontWeight: FontWeight.bold, + ), + ), + ), + // ์‹ค์ œ ์ž…๋ ฅ ํ•„๋“œ + Expanded( + child: Stack( + alignment: + Alignment.centerRight, + children: [ + TextField( + controller: + _monthlyCostController, + focusNode: + _monthlyCostFocus, + textInputAction: + TextInputAction.next, + keyboardType: + const TextInputType + .numberWithOptions( + decimal: true), + inputFormatters: [ + // ํ†ตํ™” ๋‹จ์œ„์— ๋”ฐ๋ผ ๋‹ค๋ฅธ ์ž…๋ ฅ ํ˜•์‹ ์ ์šฉ + FilteringTextInputFormatter + .allow( + _currency == 'KRW' + ? RegExp( + r'[0-9,]') // ์›ํ™”: ์ •์ˆ˜๋งŒ ํ—ˆ์šฉ + : RegExp( + r'[0-9,.]'), // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ—ˆ์šฉ + ), + // ์ปค์Šคํ…€ ํฌ๋งทํ„ฐ - 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ + TextInputFormatter + .withFunction( + (oldValue, + newValue) { + // ์ž…๋ ฅ๊ฐ’์—์„œ ์ฝค๋งˆ ์ œ๊ฑฐ + final text = newValue + .text + .replaceAll( + ',', ''); + + if (text.isEmpty) { + return newValue + .copyWith( + text: ''); + } + + // ์ˆซ์ž ํ˜•์‹ ๊ฒ€์ฆ + if (_currency == + 'KRW') { + // ์›ํ™”: ์ •์ˆ˜ ํ˜•์‹ + if (double.tryParse( + text) == + null) { + return oldValue; + } + + // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ + final formattedValue = + NumberFormat + .decimalPattern() + .format( + int.parse( + text)); + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } else { + // ๋‹ฌ๋Ÿฌ: ์†Œ์ˆ˜์  ํ˜•์‹ + if (double.tryParse( + text) == + null && + text != '.') { + return oldValue; + } + + // ์†Œ์ˆ˜์  ์ดํ•˜ ์ฒ˜๋ฆฌ๋ฅผ ์œ„ํ•ด ๋ถ€๋ถ„ ๋ถ„๋ฆฌ + final parts = + text.split('.'); + final integerPart = + parts[0]; + final decimalPart = parts + .length > + 1 + ? '.${parts[1].length > 2 ? parts[1].substring(0, 2) : parts[1]}' + : ''; + + // 3์ž๋ฆฌ๋งˆ๋‹ค ์ฝค๋งˆ ์ถ”๊ฐ€ (์ •์ˆ˜ ๋ถ€๋ถ„๋งŒ) + String formattedValue; + if (integerPart + .isEmpty) { + formattedValue = + '0$decimalPart'; + } else { + final formatted = NumberFormat + .decimalPattern() + .format(int.parse( + integerPart)); + formattedValue = + '$formatted$decimalPart'; + } + + return newValue + .copyWith( + text: + formattedValue, + selection: TextSelection + .collapsed( + offset: formattedValue + .length), + ); + } + }), + ], + onTap: () => setState(() => + _currentEditingField = + 1), + onSubmitted: (_) { + _billingCycleFocus + .requestFocus(); + setState(() => + _currentEditingField = + -1); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: + FontWeight.w500, + ), + decoration: InputDecoration( + border: InputBorder.none, + // ํฌ์ปค์Šค ์ƒํƒœ์™€ ๊ด€๊ณ„์—†์ด ์ผ๊ด€๋œ ํŒจ๋”ฉ ์œ ์ง€ + contentPadding: + const EdgeInsets + .symmetric( + vertical: 14, + horizontal: 8), + hintText: + _currency == 'KRW' + ? '9,000' + : '9.99', + hintStyle: TextStyle( + color: Colors + .grey.shade500, + fontSize: 16, + ), + // ๋ชจ๋“  ํ…Œ๋‘๋ฆฌ ์ œ๊ฑฐ + enabledBorder: + InputBorder.none, + focusedBorder: + InputBorder.none, + errorBorder: + InputBorder.none, + disabledBorder: + InputBorder.none, + focusedErrorBorder: + InputBorder.none, + ), + ), + // ๋‹ฌ๋Ÿฌ์ผ ๋•Œ ์›ํ™” ํ™˜์‚ฐ ๊ธˆ์•ก ํ‘œ์‹œ + if (_currency == 'USD') + ValueListenableBuilder< + TextEditingValue>( + valueListenable: + _monthlyCostController, + builder: (context, value, + child) { + // ์ž…๋ ฅ๊ฐ’์ด ๋ฐ”๋€” ๋•Œ๋งˆ๋‹ค ํ™˜์‚ฐ ๊ธˆ์•ก ๊ฐฑ์‹  + return FutureBuilder< + String>( + future: ExchangeRateService() + .getFormattedKrwAmount( + double.tryParse(value + .text + .replaceAll( + ',', + '')) ?? + 0.0), + builder: (context, + snapshot) { + if (snapshot + .hasData && + snapshot.data! + .isNotEmpty) { + return Padding( + padding: + const EdgeInsets + .only( + right: + 12.0), + child: Text( + snapshot + .data!, + style: + const TextStyle( + fontSize: + 14, + color: Colors + .blue, + fontWeight: + FontWeight + .w500, + ), + ), + ); + } + return const SizedBox + .shrink(); + }, + ); + }, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + // ํ™˜์œจ ์ •๋ณด ์œ„์ ฏ ์ถ”๊ฐ€ (๋‹ฌ๋Ÿฌ ์„ ํƒ ์‹œ์—๋งŒ ํ‘œ์‹œ) + ], + ), + ), + + // ๊ฒฐ์ œ ์ฃผ๊ธฐ ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 2 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '๊ฒฐ์ œ ์ฃผ๊ธฐ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + value: _billingCycle, + focusNode: _billingCycleFocus, + onTap: () => + setState(() => _currentEditingField = 2), + onChanged: (value) { + if (value != null) { + setState(() { + _billingCycle = value; + _currentEditingField = -1; + _nextBillingDateFocus.requestFocus(); + }); + } + }, + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.calendar_today_rounded, + color: baseColor, + ), + ), + dropdownColor: Colors.white, + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + items: ['์›”๊ฐ„', '์—ฐ๊ฐ„', '์ฃผ๊ฐ„', '์ผ๊ฐ„'] + .map((cycle) => DropdownMenuItem( + value: cycle, + child: Text(cycle), + )) + .toList(), + ), + ], + ), + ), + + // ๋‹ค์Œ ๊ฒฐ์ œ์ผ ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 3 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '๋‹ค์Œ ๊ฒฐ์ œ์ผ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + InkWell( + focusNode: _nextBillingDateFocus, + onTap: () async { + setState(() => _currentEditingField = 3); + final DateTime? picked = + await showDatePicker( + context: context, + initialDate: _nextBillingDate, + firstDate: DateTime.now(), + lastDate: DateTime.now().add( + const Duration(days: 365 * 2), + ), + builder: (BuildContext context, + Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: baseColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _nextBillingDate = picked; + _currentEditingField = -1; + _websiteUrlFocus.requestFocus(); + }); + } else { + setState(() => _currentEditingField = -1); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: BorderRadius.circular(12), + color: Colors.white, + ), + child: Row( + children: [ + Icon( + Icons.event_rounded, + color: baseColor, + ), + const SizedBox(width: 12), + Text( + DateFormat('yyyy๋…„ MM์›” dd์ผ') + .format(_nextBillingDate), + style: const TextStyle( + fontSize: 16, + color: Color(0xFF1E293B), + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ], + ), + ), + + // ์›น์‚ฌ์ดํŠธ URL ํ•„๋“œ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 4 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '์›น์‚ฌ์ดํŠธ URL (์„ ํƒ)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _websiteUrlController, + focusNode: _websiteUrlFocus, + textInputAction: TextInputAction.done, + onTap: () => + setState(() => _currentEditingField = 4), + onEditingComplete: () { + setState(() => _currentEditingField = 5); + _categoryFocus.requestFocus(); + }, + style: const TextStyle( + color: Color(0xFF1E293B), + fontSize: 16, + fontWeight: FontWeight.w500, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + hintText: 'https://example.com', + hintStyle: TextStyle( + color: Colors.grey.shade500, + fontSize: 16, + ), + prefixIcon: Icon( + Icons.language_rounded, + color: baseColor, + ), + ), + ), + ], + ), + ), + + // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ ํ•„๋“œ ์ถ”๊ฐ€ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + margin: const EdgeInsets.only(bottom: 20), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: _currentEditingField == 5 + ? baseColor.withValues(alpha: 0.1) + : Colors.transparent, + ), + padding: const EdgeInsets.all(8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '์นดํ…Œ๊ณ ๋ฆฌ (์„ ํƒ)', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + Consumer( + builder: (context, categoryProvider, child) { + // ์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์„ ๋•Œ ๋ฉ”์‹œ์ง€ ํ‘œ์‹œ + if (categoryProvider.categories.isEmpty) { + return Container( + width: double.infinity, + padding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + decoration: BoxDecoration( + border: Border.all( + color: Colors.grey.withValues(alpha: 0.2), + ), + borderRadius: + BorderRadius.circular(12), + color: Colors.white, + ), + child: Row( + children: [ + Icon( + Icons.category_rounded, + color: baseColor, + ), + const SizedBox(width: 12), + const Text( + '์นดํ…Œ๊ณ ๋ฆฌ๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + ], + ), + ); + } + + // ์นดํ…Œ๊ณ ๋ฆฌ ๋“œ๋กญ๋‹ค์šด ํ‘œ์‹œ + return DropdownButtonFormField( + value: _selectedCategoryId, + focusNode: _categoryFocus, + onTap: () => setState( + () => _currentEditingField = 5), + onChanged: (value) { + setState(() { + _selectedCategoryId = value; + _currentEditingField = -1; + _categoryFocus.unfocus(); + }); + }, + icon: Icon( + Icons.arrow_drop_down_circle_outlined, + color: baseColor, + ), + decoration: InputDecoration( + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: + BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.category_rounded, + color: baseColor, + ), + ), + style: const TextStyle( + color: Colors.black87, + fontSize: 16, + fontWeight: FontWeight.w500, + ), + hint: const Text( + '์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ', + style: TextStyle( + color: Colors.grey, + fontSize: 16, + ), + ), + isExpanded: true, + items: [ + DropdownMenuItem( + value: null, + child: const Text( + '์นดํ…Œ๊ณ ๋ฆฌ ์—†์Œ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.grey, + ), + ), + ), + ...categoryProvider.categories + .map((category) { + return DropdownMenuItem( + value: category.id, + child: Text( + category.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.black87, + ), + ), + ); + }).toList(), + ], + ); + }, + ), + ], + ), + ), + + // ์ด๋ฒคํŠธ ์„ค์ • ์„น์…˜ + Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: _isEventActive + ? baseColor + : Colors.grey.withValues(alpha: 0.2), + width: _isEventActive ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: _isEventActive, + onChanged: (value) { + setState(() { + _isEventActive = value ?? false; + if (!_isEventActive) { + // ์ด๋ฒคํŠธ ๋น„ํ™œ์„ฑํ™” ์‹œ ๊ด€๋ จ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” + _eventStartDate = null; + _eventEndDate = null; + _eventPriceController.clear(); + } + }); + }, + activeColor: baseColor, + ), + const Text( + '์ด๋ฒคํŠธ/ํ• ์ธ ์„ค์ •', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.local_offer, + size: 20, + color: _isEventActive + ? baseColor + : Colors.grey, + ), + ], + ), + + // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ์ถ”๊ฐ€ ํ•„๋“œ ํ‘œ์‹œ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: _isEventActive ? null : 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: _isEventActive ? 1.0 : 0.0, + child: Column( + children: [ + const SizedBox(height: 16), + + // ์ด๋ฒคํŠธ ๊ธฐ๊ฐ„ ์„ค์ • + Row( + children: [ + // ์‹œ์ž‘์ผ + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventStartDate ?? DateTime.now(), + firstDate: DateTime.now().subtract(const Duration(days: 365)), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: baseColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventStartDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์‹œ์ž‘์ผ', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventStartDate == null + ? '์„ ํƒ' + : DateFormat('MM/dd').format(_eventStartDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + const Icon(Icons.arrow_forward, color: Colors.grey), + const SizedBox(width: 8), + // ์ข…๋ฃŒ์ผ + Expanded( + child: InkWell( + onTap: () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: _eventEndDate ?? DateTime.now().add(const Duration(days: 30)), + firstDate: _eventStartDate ?? DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: baseColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + ), + child: child!, + ); + }, + ); + if (picked != null) { + setState(() { + _eventEndDate = picked; + }); + } + }, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.withValues(alpha: 0.3)), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์ข…๋ฃŒ์ผ', + style: TextStyle( + fontSize: 12, + color: Colors.grey, + ), + ), + const SizedBox(height: 4), + Text( + _eventEndDate == null + ? '์„ ํƒ' + : DateFormat('MM/dd').format(_eventEndDate!), + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + ), + ), + ], + ), + + const SizedBox(height: 16), + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์ž…๋ ฅ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: baseColor, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: _eventPriceController, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), + ], + decoration: InputDecoration( + hintText: 'ํ• ์ธ๋œ ๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”', + prefixText: _currency == 'KRW' ? 'โ‚ฉ ' : '\$ ', + filled: true, + fillColor: Colors.white, + contentPadding: const EdgeInsets.symmetric( + vertical: 16, + horizontal: 16, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Colors.grey.withValues(alpha: 0.2), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: baseColor, + width: 2, + ), + ), + prefixIcon: Icon( + Icons.sell, + color: baseColor, + ), + ), + onChanged: (value) { + // ์ฝค๋งˆ ์ž๋™ ์ถ”๊ฐ€ + if (value.isNotEmpty && !value.contains('.')) { + final number = int.tryParse(value.replaceAll(',', '')); + if (number != null) { + final formatted = NumberFormat('#,###').format(number); + if (formatted != value) { + _eventPriceController.value = TextEditingValue( + text: formatted, + selection: TextSelection.collapsed( + offset: formatted.length, + ), + ); + } + } + } + }, + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 32), + FadeTransition( + opacity: _fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: _animationController, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: MouseRegion( + onEnter: (_) => setState(() => _isSaveHovered = true), + onExit: (_) => setState(() => _isSaveHovered = false), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: double.infinity, + height: 60, + transform: _isSaveHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: _updateSubscription, + style: ElevatedButton.styleFrom( + backgroundColor: baseColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(16), + ), + padding: const EdgeInsets.symmetric(vertical: 16), + elevation: _isSaveHovered ? 8 : 4, + shadowColor: baseColor.withValues(alpha: 0.5), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.save_rounded, + color: Colors.white, + size: _isSaveHovered ? 24 : 20, + ), + const SizedBox(width: 8), + const Text( + '๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ), + ), + ), + ), + const SizedBox(height: 80), + ], + ), + ), + ), + ); + } +} diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 5da87eb..a061335 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; -import '../providers/subscription_provider.dart'; import '../providers/app_lock_provider.dart'; import '../providers/navigation_provider.dart'; import '../theme/app_colors.dart'; @@ -13,7 +12,6 @@ import 'sms_scan_screen.dart'; import '../utils/animation_controller_helper.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 { @@ -33,7 +31,6 @@ class _MainScreenState extends State late AnimationController _waveController; late ScrollController _scrollController; late FloatingNavBarScrollController _navBarScrollController; - bool _isNavBarVisible = true; // ํ™”๋ฉด ๋ชฉ๋ก late final List _screens; @@ -67,8 +64,8 @@ class _MainScreenState extends State _navBarScrollController = FloatingNavBarScrollController( scrollController: _scrollController, - onHide: () => setState(() => _isNavBarVisible = false), - onShow: () => setState(() => _isNavBarVisible = true), + onHide: () {}, + onShow: () {}, ); // ํ™”๋ฉด ๋ชฉ๋ก ์ดˆ๊ธฐํ™” @@ -162,17 +159,18 @@ class _MainScreenState extends State // ๊ตฌ๋…์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€๋œ ๊ฒฝ์šฐ if (result == true) { // ์ƒ๋‹จ์— ์Šค๋‚ต๋ฐ” ํ‘œ์‹œ + if (!context.mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Row( + content: const Row( children: [ - const Icon( + Icon( Icons.check_circle, color: Colors.white, size: 20, ), - const SizedBox(width: 12), - const Text( + SizedBox(width: 12), + Text( '๊ตฌ๋…์ด ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค', style: TextStyle( fontSize: 16, diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index bb26510..4d6467b 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -1,24 +1,13 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart' show kIsWeb; 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'; -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}); @@ -76,65 +65,7 @@ class SettingsScreen extends StatelessWidget { ); } - Future _backupData(BuildContext context) async { - try { - final provider = context.read(); - final subscriptions = provider.subscriptions; - // ์ž„์‹œ ๋””๋ ‰ํ† ๋ฆฌ์— ๋ฐฑ์—… ํŒŒ์ผ ์ƒ์„ฑ - final tempDir = await getTemporaryDirectory(); - final backupFile = - File(path.join(tempDir.path, 'submanager_backup.json')); - - // ๊ตฌ๋… ๋ฐ์ดํ„ฐ๋ฅผ JSON ํ˜•์‹์œผ๋กœ ์ €์žฅ - final jsonData = subscriptions - .map((sub) => { - 'id': sub.id, - 'serviceName': sub.serviceName, - 'monthlyCost': sub.monthlyCost, - 'billingCycle': sub.billingCycle, - 'nextBillingDate': sub.nextBillingDate.toIso8601String(), - 'isAutoDetected': sub.isAutoDetected, - 'repeatCount': sub.repeatCount, - 'lastPaymentDate': sub.lastPaymentDate?.toIso8601String(), - }) - .toList(); - - await backupFile.writeAsString(jsonData.toString()); - - // ํŒŒ์ผ ๊ณต์œ  - await Share.shareXFiles( - [XFile(backupFile.path)], - text: 'SubManager ๋ฐฑ์—… ํŒŒ์ผ', - ); - - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('๋ฐฑ์—… ํŒŒ์ผ์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค')), - ); - } - } catch (e) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('๋ฐฑ์—… ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: $e')), - ); - } - } - } - - // SMS ์Šค์บ” ํ™”๋ฉด์œผ๋กœ ์ด๋™ - void _navigateToSmsScan(BuildContext context) async { - final added = await Navigator.push( - context, - MaterialPageRoute(builder: (context) => const SmsScanScreen()), - ); - - if (added == true && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('๊ตฌ๋…์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค')), - ); - } - } @override Widget build(BuildContext context) { @@ -455,10 +386,10 @@ class SettingsScreen extends StatelessWidget { ), // ๋ฐ์ดํ„ฐ ๊ด€๋ฆฌ - GlassmorphismCard( + const GlassmorphismCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.all(8), - child: Column( + child: const Column( children: [ // ๋ฐ์ดํ„ฐ ๋ฐฑ์—… ๊ธฐ๋Šฅ ๋น„ํ™œ์„ฑํ™” // ListTile( diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index 025d725..62c9c6d 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -7,11 +7,8 @@ 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}); diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index e2121c5..ac3bceb 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -1,14 +1,8 @@ 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'; class SplashScreen extends StatefulWidget { const SplashScreen({super.key}); @@ -289,7 +283,7 @@ class _SplashScreenState extends State BlendMode.srcIn, shaderCallback: (bounds) => - LinearGradient( + const LinearGradient( colors: AppColors .blueGradient, begin: diff --git a/lib/services/exchange_rate_service.dart b/lib/services/exchange_rate_service.dart index 33cc4a9..978a470 100644 --- a/lib/services/exchange_rate_service.dart +++ b/lib/services/exchange_rate_service.dart @@ -1,6 +1,5 @@ import 'package:http/http.dart' as http; import 'dart:convert'; -import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; /// ํ™˜์œจ ์ •๋ณด ์„œ๋น„์Šค ํด๋ž˜์Šค diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 21cb530..7549e75 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -152,13 +152,6 @@ class NotificationService { } } - // ์•Œ๋ฆผ ์„œ๋น„์Šค๊ฐ€ ์ดˆ๊ธฐํ™”๋˜์—ˆ๋Š”์ง€ ํ™•์ธํ•˜๋Š” ๋ฉ”์„œ๋“œ - static bool _isInitialized() { - // ์›น ํ”Œ๋žซํผ์ธ ๊ฒฝ์šฐ ํ•ญ์ƒ false ๋ฐ˜ํ™˜ - if (_isWeb) return false; - // ์ดˆ๊ธฐํ™” ํ”Œ๋ž˜๊ทธ ํ™•์ธ - return _initialized; - } static Future requestPermission() async { final result = await _notifications @@ -182,7 +175,7 @@ class NotificationService { } try { - final androidDetails = AndroidNotificationDetails( + const androidDetails = AndroidNotificationDetails( 'subscription_channel', '๊ตฌ๋… ์•Œ๋ฆผ', channelDescription: '๊ตฌ๋… ๊ด€๋ จ ์•Œ๋ฆผ์„ ๋ณด์—ฌ์ค๋‹ˆ๋‹ค.', @@ -257,7 +250,7 @@ class NotificationService { try { final notificationId = subscription.id.hashCode; - final androidDetails = AndroidNotificationDetails( + const androidDetails = AndroidNotificationDetails( 'subscription_channel', '๊ตฌ๋… ์•Œ๋ฆผ', channelDescription: '๊ตฌ๋… ๋งŒ๋ฃŒ ์•Œ๋ฆผ์„ ๋ณด๋‚ด๋Š” ์ฑ„๋„์ž…๋‹ˆ๋‹ค.', @@ -265,13 +258,13 @@ class NotificationService { priority: Priority.high, ); - final iosDetails = DarwinNotificationDetails( + const iosDetails = DarwinNotificationDetails( presentAlert: true, presentBadge: true, presentSound: true, ); - final notificationDetails = NotificationDetails( + const notificationDetails = NotificationDetails( android: androidDetails, iOS: iosDetails, ); diff --git a/lib/services/sms_scanner.dart b/lib/services/sms_scanner.dart index c90b72f..2808949 100644 --- a/lib/services/sms_scanner.dart +++ b/lib/services/sms_scanner.dart @@ -80,7 +80,6 @@ class SmsScanner { final nextBillingDateStr = sms['nextBillingDate'] as String?; // ์‹ค์ œ ๋ฐ˜๋ณต ํšŸ์ˆ˜ ์‚ฌ์šฉ (ํ…Œ์ŠคํŠธ ๋ฐ์ดํ„ฐ์—์„œ๋Š” ์ด๋ฏธ ์ œ๊ณต๋จ) final actualRepeatCount = repeatCount > 0 ? repeatCount : 1; - final isRecurring = (sms['isRecurring'] as bool?) ?? (repeatCount >= 2); final message = sms['message'] as String? ?? ''; // ํ†ตํ™” ๋‹จ์œ„ ๊ฐ์ง€ - ๋ฉ”์‹œ์ง€ ๋‚ด์šฉ๊ณผ ์„œ๋น„์Šค๋ช… ๋ชจ๋‘ ๊ฒ€์‚ฌ @@ -205,79 +204,7 @@ class SmsScanner { return serviceUrls[serviceName]; } - bool _containsSubscriptionKeywords(String text) { - final keywords = [ - '๊ตฌ๋…', - '๊ฒฐ์ œ', - '์ฒญ๊ตฌ', - '์ •๊ธฐ', - '์ž๋™', - 'subscription', - 'payment', - 'bill', - 'invoice' - ]; - return keywords - .any((keyword) => text.toLowerCase().contains(keyword.toLowerCase())); - } - double? _extractAmount(String text) { - final RegExp amountRegex = RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)'); - final match = amountRegex.firstMatch(text); - if (match != null) { - final amountStr = match.group(1)?.replaceAll(',', ''); - return double.tryParse(amountStr ?? ''); - } - return null; - } - - String? _extractServiceName(String text) { - final serviceNames = [ - 'Netflix', - 'Spotify', - 'Disney+', - 'Apple Music', - 'YouTube Premium', - 'Amazon Prime', - 'Microsoft 365', - 'Google One', - 'iCloud', - 'Dropbox' - ]; - - for (final name in serviceNames) { - if (text.contains(name)) { - return name; - } - } - return null; - } - - String _extractBillingCycle(String text) { - if (text.contains('์›”') || text.contains('month')) { - return 'monthly'; - } else if (text.contains('๋…„') || text.contains('year')) { - return 'yearly'; - } else if (text.contains('์ฃผ') || text.contains('week')) { - return 'weekly'; - } - return 'monthly'; // ๊ธฐ๋ณธ๊ฐ’ - } - - DateTime _extractNextBillingDate(String text) { - final RegExp dateRegex = RegExp(r'(\d{4}[-/]\d{2}[-/]\d{2})'); - final match = dateRegex.firstMatch(text); - if (match != null) { - final dateStr = match.group(1); - if (dateStr != null) { - final date = DateTime.tryParse(dateStr); - if (date != null) { - return date; - } - } - } - return DateTime.now().add(const Duration(days: 30)); // ๊ธฐ๋ณธ๊ฐ’: 30์ผ ํ›„ - } // ๋ฉ”์‹œ์ง€์—์„œ ํ†ตํ™” ๋‹จ์œ„๋ฅผ ๊ฐ์ง€ํ•˜๋Š” ํ•จ์ˆ˜ String _detectCurrency(String message) { diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index f16565c..41d9493 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; /// ๊ตฌ๋… ์„œ๋น„์Šค์™€ ์›น์‚ฌ์ดํŠธ URL ๋งค์นญ์„ ์ฒ˜๋ฆฌํ•˜๋Š” ์„œ๋น„์Šค ํด๋ž˜์Šค class SubscriptionUrlMatcher { diff --git a/lib/theme/adaptive_theme.dart b/lib/theme/adaptive_theme.dart index cc93e95..87b2196 100644 --- a/lib/theme/adaptive_theme.dart +++ b/lib/theme/adaptive_theme.dart @@ -13,7 +13,7 @@ class AdaptiveTheme { return ThemeData( useMaterial3: true, brightness: Brightness.dark, - colorScheme: ColorScheme.dark( + colorScheme: const ColorScheme.dark( primary: AppColors.primaryColor, onPrimary: Colors.white, secondary: AppColors.secondaryColor, @@ -134,11 +134,11 @@ class AdaptiveTheme { ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5), + borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.dangerColor, width: 1), + borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), ), labelStyle: TextStyle( color: Colors.white.withValues(alpha: 0.7), diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index 2753e98..5faac04 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -4,7 +4,7 @@ import 'app_colors.dart'; class AppTheme { static ThemeData lightTheme = ThemeData( useMaterial3: true, - colorScheme: ColorScheme.light( + colorScheme: const ColorScheme.light( primary: AppColors.primaryColor, onPrimary: Colors.white, secondary: AppColors.secondaryColor, @@ -24,48 +24,48 @@ class AppTheme { shadowColor: Colors.black.withValues(alpha: 0.04), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: BorderSide(color: AppColors.borderColor, width: 0.5), + side: const BorderSide(color: AppColors.borderColor, width: 0.5), ), clipBehavior: Clip.antiAlias, margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), ), // ์•ฑ๋ฐ” ์Šคํƒ€์ผ - ๊น”๋”ํ•˜๊ณ  ํˆฌ๋ช…ํ•œ ๋””์ž์ธ - appBarTheme: AppBarTheme( + appBarTheme: const AppBarTheme( backgroundColor: AppColors.surfaceColor, foregroundColor: AppColors.textPrimary, elevation: 0, centerTitle: false, - titleTextStyle: TextStyle( + titleTextStyle: const TextStyle( color: AppColors.textPrimary, fontSize: 22, fontWeight: FontWeight.w600, letterSpacing: -0.2, ), - iconTheme: IconThemeData( + iconTheme: const IconThemeData( color: AppColors.secondaryColor, size: 24, ), ), // ํƒ€์ดํฌ๊ทธ๋ž˜ํ”ผ - Metronic Tailwind ์Šคํƒ€์ผ - textTheme: TextTheme( + textTheme: const TextTheme( // ํ—ค๋“œ๋ผ์ธ - ํŽ˜์ด์ง€ ์ œ๋ชฉ - headlineLarge: TextStyle( + headlineLarge: const TextStyle( color: AppColors.textPrimary, fontSize: 32, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineMedium: TextStyle( + headlineMedium: const TextStyle( color: AppColors.textPrimary, fontSize: 28, fontWeight: FontWeight.w700, letterSpacing: -0.5, height: 1.2, ), - headlineSmall: TextStyle( + headlineSmall: const TextStyle( color: AppColors.textPrimary, fontSize: 24, fontWeight: FontWeight.w600, @@ -74,7 +74,7 @@ class AppTheme { ), // ํƒ€์ดํ‹€ - ์นด๋“œ, ์„น์…˜ ์ œ๋ชฉ - titleLarge: TextStyle( + titleLarge: const TextStyle( color: AppColors.textPrimary, fontSize: 20, fontWeight: FontWeight.w600, @@ -154,31 +154,31 @@ class AppTheme { ), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.borderColor, width: 1), + borderSide: const BorderSide(color: AppColors.borderColor, width: 1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5), + borderSide: const BorderSide(color: AppColors.primaryColor, width: 1.5), ), errorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.dangerColor, width: 1), + borderSide: const BorderSide(color: AppColors.dangerColor, width: 1), ), focusedErrorBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(12), - borderSide: BorderSide(color: AppColors.dangerColor, width: 1.5), + borderSide: const BorderSide(color: AppColors.dangerColor, width: 1.5), ), - labelStyle: TextStyle( + labelStyle: const TextStyle( color: AppColors.textSecondary, fontSize: 14, fontWeight: FontWeight.w500, ), - hintStyle: TextStyle( + hintStyle: const TextStyle( color: AppColors.textMuted, fontSize: 14, fontWeight: FontWeight.w400, ), - errorStyle: TextStyle( + errorStyle: const TextStyle( color: AppColors.dangerColor, fontSize: 12, fontWeight: FontWeight.w400, @@ -230,7 +230,7 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), - side: BorderSide(color: AppColors.borderColor, width: 1), + side: const BorderSide(color: AppColors.borderColor, width: 1), textStyle: const TextStyle( fontSize: 15, fontWeight: FontWeight.w600, @@ -282,7 +282,7 @@ class AppTheme { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(4), ), - side: BorderSide(color: AppColors.borderColor, width: 1.5), + side: const BorderSide(color: AppColors.borderColor, width: 1.5), ), // ๋ผ๋””์˜ค ๋ฒ„ํŠผ ์Šคํƒ€์ผ @@ -307,16 +307,16 @@ class AppTheme { ), // ํƒญ๋ฐ” ์Šคํƒ€์ผ - tabBarTheme: TabBarTheme( + tabBarTheme: const TabBarTheme( labelColor: AppColors.primaryColor, unselectedLabelColor: AppColors.textSecondary, indicatorColor: AppColors.primaryColor, - labelStyle: TextStyle( + labelStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, letterSpacing: 0.1, ), - unselectedLabelStyle: TextStyle( + unselectedLabelStyle: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, letterSpacing: 0.1, @@ -324,7 +324,7 @@ class AppTheme { ), // ๋””๋ฐ”์ด๋” ์Šคํƒ€์ผ - dividerTheme: DividerThemeData( + dividerTheme: const DividerThemeData( color: AppColors.dividerColor, thickness: 1, space: 16, @@ -342,7 +342,7 @@ class AppTheme { // ์Šค๋‚ต๋ฐ” ์Šคํƒ€์ผ snackBarTheme: SnackBarThemeData( backgroundColor: AppColors.textPrimary, - contentTextStyle: TextStyle( + contentTextStyle: const TextStyle( color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500, diff --git a/lib/utils/memory_manager.dart b/lib/utils/memory_manager.dart index 7751f25..b7f6d57 100644 --- a/lib/utils/memory_manager.dart +++ b/lib/utils/memory_manager.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/foundation.dart'; -import 'dart:collection'; import 'dart:async'; /// ๋ฉ”๋ชจ๋ฆฌ ๊ด€๋ฆฌ๋ฅผ ์œ„ํ•œ ํ—ฌํผ ํด๋ž˜์Šค diff --git a/lib/utils/performance_optimizer.dart b/lib/utils/performance_optimizer.dart index 3059806..4568720 100644 --- a/lib/utils/performance_optimizer.dart +++ b/lib/utils/performance_optimizer.dart @@ -2,7 +2,6 @@ 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 { diff --git a/lib/utils/subscription_category_helper.dart b/lib/utils/subscription_category_helper.dart index 74eb0cc..ae0057f 100644 --- a/lib/utils/subscription_category_helper.dart +++ b/lib/utils/subscription_category_helper.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import '../models/subscription_model.dart'; import '../providers/category_provider.dart'; import '../services/subscription_url_matcher.dart'; diff --git a/lib/widgets/add_subscription/add_subscription_app_bar.dart b/lib/widgets/add_subscription/add_subscription_app_bar.dart new file mode 100644 index 0000000..65a4a8f --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_app_bar.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import 'dart:math' as math; +import '../../controllers/add_subscription_controller.dart'; + +/// ๊ตฌ๋… ์ถ”๊ฐ€ ํ™”๋ฉด์˜ App Bar +class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidget { + final AddSubscriptionController controller; + final double scrollOffset; + final VoidCallback onScanSMS; + + const AddSubscriptionAppBar({ + super.key, + required this.controller, + required this.scrollOffset, + required this.onScanSMS, + }); + + @override + Size get preferredSize => const Size.fromHeight(60); + + @override + Widget build(BuildContext context) { + final double appBarOpacity = math.max(0, math.min(1, scrollOffset / 100)); + + return 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, + actions: [ + if (!kIsWeb) + controller.isLoading + ? const Padding( + padding: EdgeInsets.only(right: 16.0), + child: Center( + child: SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + valueColor: AlwaysStoppedAnimation( + Color(0xFF3B82F6)), + ), + ), + ), + ) + : IconButton( + icon: const FaIcon( + FontAwesomeIcons.message, + size: 20, + color: Color(0xFF3B82F6), + ), + onPressed: onScanSMS, + tooltip: 'SMS์—์„œ ๊ตฌ๋… ์ •๋ณด ์Šค์บ”', + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart new file mode 100644 index 0000000..d88a343 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import '../../controllers/add_subscription_controller.dart'; +import '../common/form_fields/currency_input_field.dart'; +import '../common/form_fields/date_picker_field.dart'; + +/// ๊ตฌ๋… ์ถ”๊ฐ€ ํ™”๋ฉด์˜ ์ด๋ฒคํŠธ/ํ• ์ธ ์„น์…˜ +class AddSubscriptionEventSection extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Function setState; + + const AddSubscriptionEventSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.setState, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.5), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Container( + margin: const EdgeInsets.only(bottom: 20), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: controller.isEventActive + ? const Color(0xFF3B82F6) + : Colors.grey.withOpacity(0.2), + width: controller.isEventActive ? 2 : 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Checkbox( + value: controller.isEventActive, + onChanged: (value) { + setState(() { + controller.isEventActive = value ?? false; + if (!controller.isEventActive) { + // ์ด๋ฒคํŠธ ๋น„ํ™œ์„ฑํ™” ์‹œ ๊ด€๋ จ ๋ฐ์ดํ„ฐ ์ดˆ๊ธฐํ™” + controller.eventStartDate = DateTime.now(); + controller.eventEndDate = DateTime.now().add(const Duration(days: 30)); + controller.eventPriceController.clear(); + } else { + // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ๋‚ ์งœ๊ฐ€ null์ด๋ฉด ๊ธฐ๋ณธ๊ฐ’ ์„ค์ • + controller.eventStartDate ??= DateTime.now(); + controller.eventEndDate ??= DateTime.now().add(const Duration(days: 30)); + } + }); + }, + activeColor: const Color(0xFF3B82F6), + ), + const Text( + '์ด๋ฒคํŠธ/ํ• ์ธ ์„ค์ •', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Color(0xFF1E293B), + ), + ), + const SizedBox(width: 8), + Icon( + Icons.local_offer, + size: 20, + color: controller.isEventActive + ? const Color(0xFF3B82F6) + : Colors.grey, + ), + ], + ), + + // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์‹œ ์ถ”๊ฐ€ ํ•„๋“œ ํ‘œ์‹œ + AnimatedContainer( + duration: const Duration(milliseconds: 300), + height: controller.isEventActive ? null : 0, + child: AnimatedOpacity( + duration: const Duration(milliseconds: 300), + opacity: controller.isEventActive ? 1.0 : 0.0, + child: Column( + children: [ + const SizedBox(height: 20), + + // ์ด๋ฒคํŠธ ๊ธฐ๊ฐ„ + DateRangePickerField( + startDate: controller.eventStartDate, + endDate: controller.eventEndDate, + onStartDateSelected: (date) { + setState(() { + controller.eventStartDate = date; + }); + }, + onEndDateSelected: (date) { + setState(() { + controller.eventEndDate = date; + }); + }, + startLabel: '์‹œ์ž‘์ผ', + endLabel: '์ข…๋ฃŒ์ผ', + primaryColor: const Color(0xFF3B82F6), + ), + const SizedBox(height: 20), + + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ + CurrencyInputField( + controller: controller.eventPriceController, + currency: controller.currency, + label: '์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ', + hintText: 'ํ• ์ธ๋œ ๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”', + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_form.dart b/lib/widgets/add_subscription/add_subscription_form.dart new file mode 100644 index 0000000..71f06e8 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_form.dart @@ -0,0 +1,431 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:font_awesome_flutter/font_awesome_flutter.dart'; +import '../../controllers/add_subscription_controller.dart'; +import '../../providers/category_provider.dart'; +import '../common/form_fields/base_text_field.dart'; +import '../common/form_fields/currency_input_field.dart'; +import '../common/form_fields/date_picker_field.dart'; + +/// ๊ตฌ๋… ์ถ”๊ฐ€ ํ™”๋ฉด์˜ ํผ ์„น์…˜ +class AddSubscriptionForm extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Function setState; + + const AddSubscriptionForm({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.setState, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.2, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.4), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ํ—ค๋” + Row( + children: [ + ShaderMask( + shaderCallback: (bounds) => LinearGradient( + colors: controller.gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ).createShader(bounds), + child: const Icon( + FontAwesomeIcons.fileLines, + size: 20, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + const Text( + '์„œ๋น„์Šค ์ •๋ณด', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + letterSpacing: -0.5, + color: Color(0xFF1E293B), + ), + ), + ], + ), + const SizedBox(height: 24), + + // ์„œ๋น„์Šค๋ช… ํ•„๋“œ + BaseTextField( + controller: controller.serviceNameController, + focusNode: controller.serviceNameFocus, + label: '์„œ๋น„์Šค๋ช…', + hintText: '์˜ˆ: Netflix, Spotify', + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.monthlyCostFocus.requestFocus(); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return '์„œ๋น„์Šค๋ช…์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; + } + return null; + }, + ), + const SizedBox(height: 20), + + // ์›” ์ง€์ถœ ๋ฐ ํ†ตํ™” ์„ ํƒ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: CurrencyInputField( + controller: controller.monthlyCostController, + currency: controller.currency, + label: '์›” ์ง€์ถœ', + focusNode: controller.monthlyCostFocus, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.billingCycleFocus.requestFocus(); + }, + validator: (value) { + if (value == null || value.isEmpty) { + return '๊ธˆ์•ก์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; + } + return null; + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'ํ†ตํ™”', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CurrencySelector( + currency: controller.currency, + onChanged: (value) { + setState(() { + controller.currency = value; + }); + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // ๊ฒฐ์ œ ์ฃผ๊ธฐ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '๊ฒฐ์ œ ์ฃผ๊ธฐ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _BillingCycleSelector( + billingCycle: controller.billingCycle, + gradientColors: controller.gradientColors, + onChanged: (value) { + setState(() { + controller.billingCycle = value; + }); + }, + ), + ], + ), + const SizedBox(height: 20), + + // ๋‹ค์Œ ๊ฒฐ์ œ์ผ + DatePickerField( + selectedDate: controller.nextBillingDate ?? DateTime.now(), + onDateSelected: (date) { + setState(() { + controller.nextBillingDate = date; + }); + }, + label: '๋‹ค์Œ ๊ฒฐ์ œ์ผ', + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + primaryColor: controller.gradientColors[0], + ), + const SizedBox(height: 20), + + // ์›น์‚ฌ์ดํŠธ URL + BaseTextField( + controller: controller.websiteUrlController, + focusNode: controller.websiteUrlFocus, + label: '์›น์‚ฌ์ดํŠธ URL (์„ ํƒ)', + hintText: 'https://example.com', + keyboardType: TextInputType.url, + prefixIcon: Icon( + Icons.link_rounded, + color: Colors.grey[600], + ), + ), + const SizedBox(height: 20), + + // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ + Consumer( + builder: (context, categoryProvider, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์นดํ…Œ๊ณ ๋ฆฌ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: controller.selectedCategoryId, + gradientColors: controller.gradientColors, + onChanged: (categoryId) { + setState(() { + controller.selectedCategoryId = categoryId; + }); + }, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// ํ†ตํ™” ์„ ํƒ๊ธฐ +class _CurrencySelector extends StatelessWidget { + final String currency; + final ValueChanged onChanged; + + const _CurrencySelector({ + required this.currency, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _CurrencyOption( + label: 'โ‚ฉ', + value: 'KRW', + isSelected: currency == 'KRW', + onTap: () => onChanged('KRW'), + ), + const SizedBox(width: 8), + _CurrencyOption( + label: '\$', + value: 'USD', + isSelected: currency == 'USD', + onTap: () => onChanged('USD'), + ), + ], + ); + } +} + +/// ํ†ตํ™” ์˜ต์…˜ +class _CurrencyOption extends StatelessWidget { + final String label; + final String value; + final bool isSelected; + final VoidCallback onTap; + + const _CurrencyOption({ + required this.label, + required this.value, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? const Color(0xFF3B82F6) + : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.grey[600], + ), + ), + ), + ), + ), + ); + } +} + +/// ๊ฒฐ์ œ ์ฃผ๊ธฐ ์„ ํƒ๊ธฐ +class _BillingCycleSelector extends StatelessWidget { + final String billingCycle; + final List gradientColors; + final ValueChanged onChanged; + + const _BillingCycleSelector({ + required this.billingCycle, + required this.gradientColors, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final cycles = ['์›”๊ฐ„', '๋ถ„๊ธฐ๋ณ„', '๋ฐ˜๊ธฐ๋ณ„', '์—ฐ๊ฐ„']; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: cycles.map((cycle) { + final isSelected = billingCycle == cycle; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + onTap: () => onChanged(cycle), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + decoration: BoxDecoration( + color: isSelected + ? gradientColors[0] + : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + cycle, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.grey[700], + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +/// ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ๊ธฐ +class _CategorySelector extends StatelessWidget { + final List categories; + final String? selectedCategoryId; + final List gradientColors; + final ValueChanged onChanged; + + const _CategorySelector({ + required this.categories, + required this.selectedCategoryId, + required this.gradientColors, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: categories.map((category) { + final isSelected = selectedCategoryId == category.id; + return InkWell( + onTap: () => onChanged(category.id), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: isSelected + ? gradientColors[0] + : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + category.emoji, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 6), + Text( + category.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.grey[700], + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_header.dart b/lib/widgets/add_subscription/add_subscription_header.dart new file mode 100644 index 0000000..8265184 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_header.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import '../../controllers/add_subscription_controller.dart'; + +/// ๊ตฌ๋… ์ถ”๊ฐ€ ํ™”๋ฉด์˜ ํ—ค๋” ์„น์…˜ +class AddSubscriptionHeader extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const AddSubscriptionHeader({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: slideAnimation, + child: Container( + margin: const EdgeInsets.only(bottom: 24), + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + gradient: LinearGradient( + colors: controller.gradientColors, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + boxShadow: [ + BoxShadow( + color: controller.gradientColors[0].withOpacity(0.3), + blurRadius: 20, + spreadRadius: 0, + offset: const Offset(0, 8), + ), + ], + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.2), + borderRadius: BorderRadius.circular(16), + ), + child: const Icon( + Icons.add_rounded, + size: 32, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '์ƒˆ ๊ตฌ๋… ์ถ”๊ฐ€', + style: TextStyle( + fontSize: 24, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + ), + ), + SizedBox(height: 4), + Text( + '์„œ๋น„์Šค ์ •๋ณด๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white70, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/add_subscription/add_subscription_save_button.dart b/lib/widgets/add_subscription/add_subscription_save_button.dart new file mode 100644 index 0000000..b924956 --- /dev/null +++ b/lib/widgets/add_subscription/add_subscription_save_button.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; +import '../../controllers/add_subscription_controller.dart'; +import '../common/buttons/primary_button.dart'; + +/// ๊ตฌ๋… ์ถ”๊ฐ€ ํ™”๋ฉด์˜ ์ €์žฅ ๋ฒ„ํŠผ +class AddSubscriptionSaveButton extends StatelessWidget { + final AddSubscriptionController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Function setState; + + const AddSubscriptionSaveButton({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.setState, + }); + + @override + Widget build(BuildContext context) { + return FadeTransition( + opacity: Tween(begin: 0.0, end: 1.0).animate( + CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.6, 1.0, curve: Curves.easeIn), + ), + ), + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.6, 1.0, curve: Curves.easeOutCubic), + )), + child: Padding( + padding: const EdgeInsets.only(bottom: 80), + child: PrimaryButton( + text: '๊ตฌ๋… ์ถ”๊ฐ€ํ•˜๊ธฐ', + icon: Icons.add_circle_outline, + onPressed: controller.isLoading + ? null + : () => controller.saveSubscription(setState: setState), + isLoading: controller.isLoading, + backgroundColor: const Color(0xFF3B82F6), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index 4e44de6..f0a689f 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -1,12 +1,6 @@ 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'; diff --git a/lib/widgets/common/buttons/danger_button.dart b/lib/widgets/common/buttons/danger_button.dart new file mode 100644 index 0000000..65967d8 --- /dev/null +++ b/lib/widgets/common/buttons/danger_button.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; + +/// ์œ„ํ—˜ํ•œ ์•ก์…˜์— ์‚ฌ์šฉ๋˜๋Š” Danger ๋ฒ„ํŠผ +/// ์‚ญ์ œ, ์ทจ์†Œ, ์ข…๋ฃŒ ๋“ฑ์˜ ์œ„ํ—˜ํ•œ ์•ก์…˜์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. +class DangerButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final bool requireConfirmation; + final String? confirmationTitle; + final String? confirmationMessage; + final IconData? icon; + final double? width; + final double height; + final double fontSize; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final bool enableHoverEffect; + + const DangerButton({ + super.key, + required this.text, + this.onPressed, + this.requireConfirmation = false, + this.confirmationTitle, + this.confirmationMessage, + this.icon, + this.width, + this.height = 60, + this.fontSize = 18, + this.padding, + this.borderRadius = 16, + this.enableHoverEffect = true, + }); + + @override + State createState() => _DangerButtonState(); +} + +class _DangerButtonState extends State { + bool _isHovered = false; + + static const Color _dangerColor = Color(0xFFDC2626); + + Future _handlePress() async { + if (widget.requireConfirmation) { + final confirmed = await showDialog( + context: context, + builder: (context) => AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + widget.confirmationTitle ?? widget.text, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: _dangerColor.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + widget.icon ?? Icons.warning_amber_rounded, + color: _dangerColor, + size: 48, + ), + ), + const SizedBox(height: 16), + Text( + widget.confirmationMessage ?? + '์ด ์ž‘์—…์€ ๋˜๋Œ๋ฆด ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.\n๊ณ„์†ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 1.5, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('์ทจ์†Œ'), + ), + ElevatedButton( + onPressed: () => Navigator.of(context).pop(true), + style: ElevatedButton.styleFrom( + backgroundColor: _dangerColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + widget.text, + style: const TextStyle(color: Colors.white), + ), + ), + ], + ), + ); + + if (confirmed == true) { + widget.onPressed?.call(); + } + } else { + widget.onPressed?.call(); + } + } + + @override + Widget build(BuildContext context) { + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.width ?? double.infinity, + height: widget.height, + transform: widget.enableHoverEffect && _isHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: widget.onPressed != null ? _handlePress : null, + style: ElevatedButton.styleFrom( + backgroundColor: _dangerColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), + elevation: widget.enableHoverEffect && _isHovered ? 8 : 4, + shadowColor: _dangerColor.withOpacity(0.5), + disabledBackgroundColor: _dangerColor.withOpacity(0.6), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: Colors.white, + size: _isHovered ? 24 : 20, + ), + const SizedBox(width: 8), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w600, + color: Colors.white, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} \ No newline at end of file diff --git a/lib/widgets/common/buttons/primary_button.dart b/lib/widgets/common/buttons/primary_button.dart new file mode 100644 index 0000000..49a598c --- /dev/null +++ b/lib/widgets/common/buttons/primary_button.dart @@ -0,0 +1,112 @@ +import 'package:flutter/material.dart'; + +/// ์ฃผ์š” ์•ก์…˜์— ์‚ฌ์šฉ๋˜๋Š” Primary ๋ฒ„ํŠผ +/// ์ €์žฅ, ์ถ”๊ฐ€, ํ™•์ธ ๋“ฑ์˜ ์ฃผ์š” ์•ก์…˜์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. +class PrimaryButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final bool isLoading; + final IconData? icon; + final double? width; + final double height; + final Color? backgroundColor; + final Color? foregroundColor; + final double fontSize; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final bool enableHoverEffect; + + const PrimaryButton({ + super.key, + required this.text, + this.onPressed, + this.isLoading = false, + this.icon, + this.width, + this.height = 60, + this.backgroundColor, + this.foregroundColor, + this.fontSize = 18, + this.padding, + this.borderRadius = 16, + this.enableHoverEffect = true, + }); + + @override + State createState() => _PrimaryButtonState(); +} + +class _PrimaryButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveBackgroundColor = widget.backgroundColor ?? theme.primaryColor; + final effectiveForegroundColor = widget.foregroundColor ?? Colors.white; + + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.width ?? double.infinity, + height: widget.height, + transform: widget.enableHoverEffect && _isHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: ElevatedButton( + onPressed: widget.isLoading ? null : widget.onPressed, + style: ElevatedButton.styleFrom( + backgroundColor: effectiveBackgroundColor, + foregroundColor: effectiveForegroundColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16), + elevation: widget.enableHoverEffect && _isHovered ? 8 : 4, + shadowColor: effectiveBackgroundColor.withOpacity(0.5), + disabledBackgroundColor: effectiveBackgroundColor.withOpacity(0.6), + ), + child: widget.isLoading + ? SizedBox( + width: 24, + height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: effectiveForegroundColor, + ), + ) + : Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: effectiveForegroundColor, + size: _isHovered ? 24 : 20, + ), + const SizedBox(width: 8), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w600, + color: effectiveForegroundColor, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} \ No newline at end of file diff --git a/lib/widgets/common/buttons/secondary_button.dart b/lib/widgets/common/buttons/secondary_button.dart new file mode 100644 index 0000000..bcbc2a1 --- /dev/null +++ b/lib/widgets/common/buttons/secondary_button.dart @@ -0,0 +1,203 @@ +import 'package:flutter/material.dart'; + +/// ๋ถ€์ฐจ์ ์ธ ์•ก์…˜์— ์‚ฌ์šฉ๋˜๋Š” Secondary ๋ฒ„ํŠผ +/// ์ทจ์†Œ, ๋˜๋Œ์•„๊ฐ€๊ธฐ, ๋ถ€๊ฐ€ ์˜ต์…˜ ๋“ฑ์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. +class SecondaryButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final IconData? icon; + final double? width; + final double height; + final Color? borderColor; + final Color? textColor; + final double fontSize; + final EdgeInsetsGeometry? padding; + final double borderRadius; + final double borderWidth; + final bool enableHoverEffect; + + const SecondaryButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.width, + this.height = 56, + this.borderColor, + this.textColor, + this.fontSize = 16, + this.padding, + this.borderRadius = 16, + this.borderWidth = 1.5, + this.enableHoverEffect = true, + }); + + @override + State createState() => _SecondaryButtonState(); +} + +class _SecondaryButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveBorderColor = widget.borderColor ?? + theme.colorScheme.onSurface.withOpacity(0.2); + final effectiveTextColor = widget.textColor ?? + theme.colorScheme.onSurface.withOpacity(0.8); + + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + width: widget.width, + height: widget.height, + transform: widget.enableHoverEffect && _isHovered + ? (Matrix4.identity()..scale(1.02)) + : Matrix4.identity(), + child: OutlinedButton( + onPressed: widget.onPressed, + style: OutlinedButton.styleFrom( + foregroundColor: effectiveTextColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(widget.borderRadius), + ), + side: BorderSide( + color: _isHovered + ? effectiveBorderColor.withOpacity(0.4) + : effectiveBorderColor, + width: widget.borderWidth, + ), + padding: widget.padding ?? const EdgeInsets.symmetric( + vertical: 12, + horizontal: 24, + ), + backgroundColor: _isHovered + ? theme.colorScheme.onSurface.withOpacity(0.05) + : Colors.transparent, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + color: effectiveTextColor, + size: 20, + ), + const SizedBox(width: 8), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w500, + color: effectiveTextColor, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} + +/// ํ…์ŠคํŠธ ๋งํฌ ์Šคํƒ€์ผ์˜ ๋ฒ„ํŠผ +/// ๊ฐ„๋‹จํ•œ ์•ก์…˜์ด๋‚˜ ๋งํฌ์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. +class TextLinkButton extends StatefulWidget { + final String text; + final VoidCallback? onPressed; + final IconData? icon; + final Color? color; + final double fontSize; + final bool enableHoverEffect; + + const TextLinkButton({ + super.key, + required this.text, + this.onPressed, + this.icon, + this.color, + this.fontSize = 14, + this.enableHoverEffect = true, + }); + + @override + State createState() => _TextLinkButtonState(); +} + +class _TextLinkButtonState extends State { + bool _isHovered = false; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveColor = widget.color ?? theme.colorScheme.primary; + + Widget button = AnimatedContainer( + duration: const Duration(milliseconds: 200), + decoration: BoxDecoration( + color: _isHovered + ? theme.colorScheme.onSurface.withOpacity(0.05) + : Colors.transparent, + borderRadius: BorderRadius.circular(8), + ), + child: TextButton( + onPressed: widget.onPressed, + style: TextButton.styleFrom( + foregroundColor: effectiveColor, + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + minimumSize: Size.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.icon != null) ...[ + Icon( + widget.icon, + size: 18, + color: effectiveColor, + ), + const SizedBox(width: 6), + ], + Text( + widget.text, + style: TextStyle( + fontSize: widget.fontSize, + fontWeight: FontWeight.w500, + color: effectiveColor, + decoration: _isHovered + ? TextDecoration.underline + : TextDecoration.none, + ), + ), + ], + ), + ), + ); + + if (widget.enableHoverEffect) { + return MouseRegion( + onEnter: (_) => setState(() => _isHovered = true), + onExit: (_) => setState(() => _isHovered = false), + child: button, + ); + } + + return button; + } +} \ No newline at end of file diff --git a/lib/widgets/common/cards/section_card.dart b/lib/widgets/common/cards/section_card.dart new file mode 100644 index 0000000..1680d66 --- /dev/null +++ b/lib/widgets/common/cards/section_card.dart @@ -0,0 +1,229 @@ +import 'package:flutter/material.dart'; + +/// ์„น์…˜๋ณ„ ์ปจํ…์ธ ๋ฅผ ๊ฐ์‹ธ๋Š” ๊ธฐ๋ณธ ์นด๋“œ ์œ„์ ฏ +/// ํผ ์„น์…˜, ์ •๋ณด ํ‘œ์‹œ ์„น์…˜ ๋“ฑ์— ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค. +class SectionCard extends StatelessWidget { + final String? title; + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final Color? backgroundColor; + final double borderRadius; + final List? boxShadow; + final Border? border; + final double? height; + final double? width; + final VoidCallback? onTap; + + const SectionCard({ + super.key, + this.title, + required this.child, + this.padding, + this.margin, + this.backgroundColor, + this.borderRadius = 20, + this.boxShadow, + this.border, + this.height, + this.width, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveBackgroundColor = backgroundColor ?? Colors.white; + final effectiveShadow = boxShadow ?? [ + BoxShadow( + color: Colors.black.withOpacity(0.05), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ]; + + Widget card = Container( + height: height, + width: width, + margin: margin, + decoration: BoxDecoration( + color: effectiveBackgroundColor, + borderRadius: BorderRadius.circular(borderRadius), + boxShadow: effectiveShadow, + border: border, + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) ...[ + Text( + title!, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 16), + ], + child, + ], + ), + ), + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(borderRadius), + child: card, + ); + } + + return card; + } +} + +/// ํˆฌ๋ช…ํ•œ ๋ฐฐ๊ฒฝ์˜ ์„น์…˜ ์นด๋“œ +/// ์–ด๋‘์šด ๋ฐฐ๊ฒฝ ์œ„์—์„œ ์‚ฌ์šฉํ•˜๊ธฐ ์ ํ•ฉํ•ฉ๋‹ˆ๋‹ค. +class TransparentSectionCard extends StatelessWidget { + final String? title; + final Widget child; + final EdgeInsetsGeometry? padding; + final EdgeInsetsGeometry? margin; + final double opacity; + final double borderRadius; + final Color? borderColor; + final VoidCallback? onTap; + + const TransparentSectionCard({ + super.key, + this.title, + required this.child, + this.padding, + this.margin, + this.opacity = 0.15, + this.borderRadius = 16, + this.borderColor, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + Widget card = Container( + margin: margin, + decoration: BoxDecoration( + color: Colors.white.withOpacity(opacity), + borderRadius: BorderRadius.circular(borderRadius), + border: borderColor != null + ? Border.all(color: borderColor!, width: 1) + : null, + ), + child: Padding( + padding: padding ?? const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (title != null) ...[ + Text( + title!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.white.withOpacity(0.9), + ), + ), + const SizedBox(height: 12), + ], + child, + ], + ), + ), + ); + + if (onTap != null) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(borderRadius), + child: card, + ); + } + + return card; + } +} + +/// ์ •๋ณด ํ‘œ์‹œ์šฉ ์นด๋“œ +/// ์ฝ๊ธฐ ์ „์šฉ ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•  ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +class InfoCard extends StatelessWidget { + final String label; + final String value; + final IconData? icon; + final Color? iconColor; + final Color? backgroundColor; + final EdgeInsetsGeometry? padding; + final double borderRadius; + + const InfoCard({ + super.key, + required this.label, + required this.value, + this.icon, + this.iconColor, + this.backgroundColor, + this.padding, + this.borderRadius = 12, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + padding: padding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + color: backgroundColor ?? theme.colorScheme.surface, + borderRadius: BorderRadius.circular(borderRadius), + ), + child: Row( + children: [ + if (icon != null) ...[ + Icon( + icon, + size: 24, + color: iconColor ?? theme.colorScheme.primary, + ), + const SizedBox(width: 12), + ], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(height: 4), + Text( + value, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + ], + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/dialogs/confirmation_dialog.dart b/lib/widgets/common/dialogs/confirmation_dialog.dart new file mode 100644 index 0000000..589cd0a --- /dev/null +++ b/lib/widgets/common/dialogs/confirmation_dialog.dart @@ -0,0 +1,353 @@ +import 'package:flutter/material.dart'; + +/// ํ™•์ธ ๋‹ค์ด์–ผ๋กœ๊ทธ ์œ„์ ฏ +/// ์‚ฌ์šฉ์ž์—๊ฒŒ ์ค‘์š”ํ•œ ์ž‘์—…์„ ํ™•์ธ๋ฐ›์„ ๋•Œ ์‚ฌ์šฉํ•ฉ๋‹ˆ๋‹ค. +class ConfirmationDialog extends StatelessWidget { + final String title; + final String? message; + final Widget? content; + final String confirmText; + final String cancelText; + final VoidCallback? onConfirm; + final VoidCallback? onCancel; + final Color? confirmColor; + final IconData? icon; + final Color? iconColor; + final double iconSize; + + const ConfirmationDialog({ + super.key, + required this.title, + this.message, + this.content, + this.confirmText = 'ํ™•์ธ', + this.cancelText = '์ทจ์†Œ', + this.onConfirm, + this.onCancel, + this.confirmColor, + this.icon, + this.iconColor, + this.iconSize = 48, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectiveConfirmColor = confirmColor ?? theme.primaryColor; + + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + title: Text( + title, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 20, + ), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: (iconColor ?? effectiveConfirmColor).withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + icon, + color: iconColor ?? effectiveConfirmColor, + size: iconSize, + ), + ), + const SizedBox(height: 16), + ], + if (content != null) + content! + else if (message != null) + Text( + message!, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 16, + height: 1.5, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + onCancel?.call(); + }, + child: Text(cancelText), + ), + ElevatedButton( + onPressed: () { + Navigator.of(context).pop(true); + onConfirm?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: effectiveConfirmColor, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + child: Text( + confirmText, + style: const TextStyle(color: Colors.white), + ), + ), + ], + ); + } + + /// ๋‹ค์ด์–ผ๋กœ๊ทธ๋ฅผ ํ‘œ์‹œํ•˜๊ณ  ๊ฒฐ๊ณผ๋ฅผ ๋ฐ˜ํ™˜ํ•˜๋Š” ์ •์  ๋ฉ”์„œ๋“œ + static Future show({ + required BuildContext context, + required String title, + String? message, + Widget? content, + String confirmText = 'ํ™•์ธ', + String cancelText = '์ทจ์†Œ', + Color? confirmColor, + IconData? icon, + Color? iconColor, + double iconSize = 48, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ConfirmationDialog( + title: title, + message: message, + content: content, + confirmText: confirmText, + cancelText: cancelText, + confirmColor: confirmColor, + icon: icon, + iconColor: iconColor, + iconSize: iconSize, + ), + ); + } +} + +/// ์„ฑ๊ณต ๋‹ค์ด์–ผ๋กœ๊ทธ +class SuccessDialog extends StatelessWidget { + final String title; + final String? message; + final String buttonText; + final VoidCallback? onPressed; + + const SuccessDialog({ + super.key, + required this.title, + this.message, + this.buttonText = 'ํ™•์ธ', + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.green.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.check_circle, + color: Colors.green, + size: 64, + ), + ), + const SizedBox(height: 24), + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 12), + Text( + message!, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + actions: [ + Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + onPressed?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.green, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text( + buttonText, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + ), + ], + ); + } + + static Future show({ + required BuildContext context, + required String title, + String? message, + String buttonText = 'ํ™•์ธ', + VoidCallback? onPressed, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => SuccessDialog( + title: title, + message: message, + buttonText: buttonText, + onPressed: onPressed, + ), + ); + } +} + +/// ์—๋Ÿฌ ๋‹ค์ด์–ผ๋กœ๊ทธ +class ErrorDialog extends StatelessWidget { + final String title; + final String? message; + final String buttonText; + final VoidCallback? onPressed; + + const ErrorDialog({ + super.key, + required this.title, + this.message, + this.buttonText = 'ํ™•์ธ', + this.onPressed, + }); + + @override + Widget build(BuildContext context) { + return AlertDialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.red.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.error_outline, + color: Colors.red, + size: 64, + ), + ), + const SizedBox(height: 24), + Text( + title, + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + if (message != null) ...[ + const SizedBox(height: 12), + Text( + message!, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), + textAlign: TextAlign.center, + ), + ], + ], + ), + actions: [ + Center( + child: ElevatedButton( + onPressed: () { + Navigator.of(context).pop(); + onPressed?.call(); + }, + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric( + horizontal: 32, + vertical: 12, + ), + ), + child: Text( + buttonText, + style: const TextStyle( + color: Colors.white, + fontSize: 16, + ), + ), + ), + ), + ], + ); + } + + static Future show({ + required BuildContext context, + required String title, + String? message, + String buttonText = 'ํ™•์ธ', + VoidCallback? onPressed, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => ErrorDialog( + title: title, + message: message, + buttonText: buttonText, + onPressed: onPressed, + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/dialogs/loading_overlay.dart b/lib/widgets/common/dialogs/loading_overlay.dart new file mode 100644 index 0000000..2440d4d --- /dev/null +++ b/lib/widgets/common/dialogs/loading_overlay.dart @@ -0,0 +1,238 @@ +import 'package:flutter/material.dart'; + +/// ๋กœ๋”ฉ ์˜ค๋ฒ„๋ ˆ์ด ์œ„์ ฏ +/// ๋น„๋™๊ธฐ ์ž‘์—… ์ค‘ ํ™”๋ฉด์„ ๋ฎ๋Š” ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. +class LoadingOverlay extends StatelessWidget { + final bool isLoading; + final Widget child; + final String? message; + final Color? backgroundColor; + final Color? indicatorColor; + final double opacity; + + const LoadingOverlay({ + super.key, + required this.isLoading, + required this.child, + this.message, + this.backgroundColor, + this.indicatorColor, + this.opacity = 0.7, + }); + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + child, + if (isLoading) + Container( + color: (backgroundColor ?? Colors.black).withOpacity(opacity), + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: indicatorColor ?? Theme.of(context).primaryColor, + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message!, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ), + ), + ], + ); + } +} + +/// ๋กœ๋”ฉ ๋‹ค์ด์–ผ๋กœ๊ทธ +/// ๋ชจ๋‹ฌ ํ˜•ํƒœ์˜ ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. +class LoadingDialog { + static Future show({ + required BuildContext context, + String? message, + Color? barrierColor, + bool barrierDismissible = false, + }) { + return showDialog( + context: context, + barrierDismissible: barrierDismissible, + barrierColor: barrierColor ?? Colors.black54, + builder: (context) => WillPopScope( + onWillPop: () async => barrierDismissible, + child: Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + CircularProgressIndicator( + color: Theme.of(context).primaryColor, + ), + if (message != null) ...[ + const SizedBox(height: 16), + Text( + message, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } + + static void hide(BuildContext context) { + Navigator.of(context).pop(); + } +} + +/// ์ปค์Šคํ…€ ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ +/// ๋‹ค์–‘ํ•œ ์Šคํƒ€์ผ์˜ ๋กœ๋”ฉ ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +class CustomLoadingIndicator extends StatefulWidget { + final double size; + final Color? color; + final LoadingStyle style; + + const CustomLoadingIndicator({ + super.key, + this.size = 50, + this.color, + this.style = LoadingStyle.circular, + }); + + @override + State createState() => _CustomLoadingIndicatorState(); +} + +class _CustomLoadingIndicatorState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + late Animation _animation; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + duration: const Duration(seconds: 1), + vsync: this, + )..repeat(); + + _animation = CurvedAnimation( + parent: _controller, + curve: Curves.easeInOut, + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final effectiveColor = widget.color ?? Theme.of(context).primaryColor; + + switch (widget.style) { + case LoadingStyle.circular: + return SizedBox( + width: widget.size, + height: widget.size, + child: CircularProgressIndicator( + color: effectiveColor, + strokeWidth: 3, + ), + ); + + case LoadingStyle.dots: + return SizedBox( + width: widget.size, + height: widget.size / 3, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: List.generate(3, (index) { + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + final delay = index * 0.2; + final value = (_animation.value - delay).clamp(0.0, 1.0); + return Container( + width: widget.size / 5, + height: widget.size / 5, + decoration: BoxDecoration( + color: effectiveColor.withOpacity(0.3 + value * 0.7), + shape: BoxShape.circle, + ), + ); + }, + ); + }), + ), + ); + + case LoadingStyle.pulse: + return AnimatedBuilder( + animation: _animation, + builder: (context, child) { + return Container( + width: widget.size, + height: widget.size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: effectiveColor.withOpacity(0.3), + ), + child: Center( + child: Container( + width: widget.size * (0.3 + _animation.value * 0.5), + height: widget.size * (0.3 + _animation.value * 0.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: effectiveColor.withOpacity(1 - _animation.value), + ), + ), + ), + ); + }, + ); + } + } +} + +enum LoadingStyle { + circular, + dots, + pulse, +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/base_text_field.dart b/lib/widgets/common/form_fields/base_text_field.dart new file mode 100644 index 0000000..061158d --- /dev/null +++ b/lib/widgets/common/form_fields/base_text_field.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// ๊ณตํ†ต ํ…์ŠคํŠธ ํ•„๋“œ ์œ„์ ฏ +/// ํ”„๋กœ์ ํŠธ ์ „์ฒด์—์„œ ์ผ๊ด€๋œ ์Šคํƒ€์ผ์˜ ํ…์ŠคํŠธ ์ž…๋ ฅ ํ•„๋“œ๋ฅผ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +class BaseTextField extends StatelessWidget { + final TextEditingController controller; + final FocusNode? focusNode; + final String? label; + final String? hintText; + final TextInputAction? textInputAction; + final TextInputType? keyboardType; + final List? inputFormatters; + final Function()? onTap; + final Function()? onEditingComplete; + final Function(String)? onChanged; + final String? Function(String?)? validator; + final bool enabled; + final Widget? prefixIcon; + final String? prefixText; + final Widget? suffixIcon; + final bool obscureText; + final int? maxLines; + final int? minLines; + final bool readOnly; + final TextStyle? style; + final EdgeInsetsGeometry? contentPadding; + final Color? fillColor; + final Color? cursorColor; + + const BaseTextField({ + super.key, + required this.controller, + this.focusNode, + this.label, + this.hintText, + this.textInputAction = TextInputAction.next, + this.keyboardType, + this.inputFormatters, + this.onTap, + this.onEditingComplete, + this.onChanged, + this.validator, + this.enabled = true, + this.prefixIcon, + this.prefixText, + this.suffixIcon, + this.obscureText = false, + this.maxLines = 1, + this.minLines, + this.readOnly = false, + this.style, + this.contentPadding, + this.fillColor, + this.cursorColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (label != null) ...[ + Text( + label!, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + ], + TextFormField( + controller: controller, + focusNode: focusNode, + textInputAction: textInputAction, + keyboardType: keyboardType, + inputFormatters: inputFormatters, + onTap: onTap, + onEditingComplete: onEditingComplete, + onChanged: onChanged, + validator: validator, + enabled: enabled, + obscureText: obscureText, + maxLines: maxLines, + minLines: minLines, + readOnly: readOnly, + cursorColor: cursorColor ?? theme.primaryColor, + style: style ?? TextStyle( + fontSize: 16, + color: theme.colorScheme.onSurface, + ), + decoration: InputDecoration( + hintText: hintText, + hintStyle: TextStyle( + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + prefixIcon: prefixIcon, + prefixText: prefixText, + suffixIcon: suffixIcon, + filled: true, + fillColor: fillColor ?? Colors.white, + contentPadding: contentPadding ?? const EdgeInsets.all(16), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.primaryColor, + width: 2, + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + disabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide.none, + ), + errorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.colorScheme.error, + width: 1, + ), + ), + focusedErrorBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(16), + borderSide: BorderSide( + color: theme.colorScheme.error, + width: 2, + ), + ), + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/currency_input_field.dart b/lib/widgets/common/form_fields/currency_input_field.dart new file mode 100644 index 0000000..3c32ece --- /dev/null +++ b/lib/widgets/common/form_fields/currency_input_field.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'base_text_field.dart'; + +/// ํ†ตํ™” ์ž…๋ ฅ ํ•„๋“œ ์œ„์ ฏ +/// ์›ํ™”(KRW)์™€ ๋‹ฌ๋Ÿฌ(USD)๋ฅผ ์ง€์›ํ•˜๋ฉฐ ์ž๋™ ํฌ๋งทํŒ…์„ ์ œ๊ณตํ•ฉ๋‹ˆ๋‹ค. +class CurrencyInputField extends StatefulWidget { + final TextEditingController controller; + final String currency; // 'KRW' or 'USD' + final String? label; + final String? hintText; + final Function(double?)? onChanged; + final String? Function(String?)? validator; + final FocusNode? focusNode; + final TextInputAction? textInputAction; + final Function()? onEditingComplete; + final bool enabled; + + const CurrencyInputField({ + super.key, + required this.controller, + required this.currency, + this.label, + this.hintText, + this.onChanged, + this.validator, + this.focusNode, + this.textInputAction, + this.onEditingComplete, + this.enabled = true, + }); + + @override + State createState() => _CurrencyInputFieldState(); +} + +class _CurrencyInputFieldState extends State { + late TextEditingController _formattedController; + + @override + void initState() { + super.initState(); + _formattedController = TextEditingController(); + _updateFormattedValue(); + widget.controller.addListener(_onControllerChanged); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChanged); + _formattedController.dispose(); + super.dispose(); + } + + void _onControllerChanged() { + _updateFormattedValue(); + } + + void _updateFormattedValue() { + final value = double.tryParse(widget.controller.text.replaceAll(',', '')); + if (value != null) { + _formattedController.text = _formatCurrency(value); + } else { + _formattedController.text = ''; + } + } + + String _formatCurrency(double value) { + if (widget.currency == 'KRW') { + return NumberFormat.decimalPattern().format(value.toInt()); + } else { + return NumberFormat('#,##0.00').format(value); + } + } + + double? _parseValue(String text) { + final cleanText = text.replaceAll(',', '').replaceAll('โ‚ฉ', '').replaceAll('\$', '').trim(); + return double.tryParse(cleanText); + } + + String get _prefixText { + return widget.currency == 'KRW' ? 'โ‚ฉ ' : '\$ '; + } + + String get _defaultHintText { + return widget.currency == 'KRW' ? '๊ธˆ์•ก์„ ์ž…๋ ฅํ•˜์„ธ์š”' : 'Enter amount'; + } + + @override + Widget build(BuildContext context) { + return BaseTextField( + controller: _formattedController, + focusNode: widget.focusNode, + label: widget.label, + hintText: widget.hintText ?? _defaultHintText, + textInputAction: widget.textInputAction, + keyboardType: const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), + ], + prefixText: _prefixText, + onEditingComplete: widget.onEditingComplete, + enabled: widget.enabled, + onChanged: (value) { + final parsedValue = _parseValue(value); + if (parsedValue != null) { + widget.controller.text = parsedValue.toString(); + widget.onChanged?.call(parsedValue); + } else { + widget.controller.text = ''; + widget.onChanged?.call(null); + } + + // ํฌ๋งทํŒ… ์—…๋ฐ์ดํŠธ + if (parsedValue != null) { + final formattedText = _formatCurrency(parsedValue); + if (formattedText != value) { + _formattedController.value = TextEditingValue( + text: formattedText, + selection: TextSelection.collapsed(offset: formattedText.length), + ); + } + } + }, + validator: widget.validator ?? (value) { + if (value == null || value.isEmpty) { + return '๊ธˆ์•ก์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; + } + final parsedValue = _parseValue(value); + if (parsedValue == null || parsedValue <= 0) { + return '์˜ฌ๋ฐ”๋ฅธ ๊ธˆ์•ก์„ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”'; + } + return null; + }, + ); + } +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/date_picker_field.dart b/lib/widgets/common/form_fields/date_picker_field.dart new file mode 100644 index 0000000..d9e5d83 --- /dev/null +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -0,0 +1,263 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; + +/// ๋‚ ์งœ ์„ ํƒ ํ•„๋“œ ์œ„์ ฏ +/// ํƒญํ•˜๋ฉด ๋‚ ์งœ ์„ ํƒ๊ธฐ๊ฐ€ ํ‘œ์‹œ๋˜๋ฉฐ, ์„ ํƒ๋œ ๋‚ ์งœ๋ฅผ ๋ณด๊ธฐ ์ข‹์€ ํ˜•์‹์œผ๋กœ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. +class DatePickerField extends StatelessWidget { + final DateTime selectedDate; + final Function(DateTime) onDateSelected; + final String label; + final String? hintText; + final DateTime? firstDate; + final DateTime? lastDate; + final bool enabled; + final FocusNode? focusNode; + final Color? backgroundColor; + final EdgeInsetsGeometry? contentPadding; + final String? dateFormat; + final Color? primaryColor; + + const DatePickerField({ + super.key, + required this.selectedDate, + required this.onDateSelected, + required this.label, + this.hintText, + this.firstDate, + this.lastDate, + this.enabled = true, + this.focusNode, + this.backgroundColor, + this.contentPadding, + this.dateFormat, + this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectivePrimaryColor = primaryColor ?? theme.primaryColor; + final effectiveDateFormat = dateFormat ?? 'yyyy๋…„ MM์›” dd์ผ'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + ), + ), + const SizedBox(height: 8), + InkWell( + focusNode: focusNode, + onTap: enabled ? () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: selectedDate, + firstDate: firstDate ?? DateTime.now().subtract(const Duration(days: 365 * 10)), + lastDate: lastDate ?? DateTime.now().add(const Duration(days: 365 * 10)), + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: effectivePrimaryColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null && picked != selectedDate) { + onDateSelected(picked); + } + } : null, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: contentPadding ?? const EdgeInsets.all(16), + decoration: BoxDecoration( + color: backgroundColor ?? Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.transparent, + ), + ), + child: Row( + children: [ + Expanded( + child: Text( + DateFormat(effectiveDateFormat).format(selectedDate), + style: TextStyle( + fontSize: 16, + color: enabled + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withOpacity(0.5), + ), + ), + ), + Icon( + Icons.calendar_today, + size: 20, + color: enabled + ? theme.colorScheme.onSurface.withOpacity(0.6) + : theme.colorScheme.onSurface.withOpacity(0.3), + ), + ], + ), + ), + ), + ], + ); + } +} + +/// ๋‚ ์งœ ๋ฒ”์œ„ ์„ ํƒ ํ•„๋“œ ์œ„์ ฏ +/// ์‹œ์ž‘์ผ๊ณผ ์ข…๋ฃŒ์ผ์„ ์„ ํƒํ•  ์ˆ˜ ์žˆ๋Š” ํ•„๋“œ์ž…๋‹ˆ๋‹ค. +class DateRangePickerField extends StatelessWidget { + final DateTime? startDate; + final DateTime? endDate; + final Function(DateTime?) onStartDateSelected; + final Function(DateTime?) onEndDateSelected; + final String startLabel; + final String endLabel; + final bool enabled; + final Color? primaryColor; + + const DateRangePickerField({ + super.key, + required this.startDate, + required this.endDate, + required this.onStartDateSelected, + required this.onEndDateSelected, + this.startLabel = '์‹œ์ž‘์ผ', + this.endLabel = '์ข…๋ฃŒ์ผ', + this.enabled = true, + this.primaryColor, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: _DateRangeItem( + date: startDate, + label: startLabel, + enabled: enabled, + primaryColor: primaryColor, + onDateSelected: onStartDateSelected, + firstDate: DateTime.now().subtract(const Duration(days: 365)), + lastDate: endDate ?? DateTime.now().add(const Duration(days: 365 * 2)), + ), + ), + const SizedBox(width: 12), + Expanded( + child: _DateRangeItem( + date: endDate, + label: endLabel, + enabled: enabled && startDate != null, + primaryColor: primaryColor, + onDateSelected: onEndDateSelected, + firstDate: startDate ?? DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + ), + ), + ], + ); + } +} + +class _DateRangeItem extends StatelessWidget { + final DateTime? date; + final String label; + final bool enabled; + final Color? primaryColor; + final Function(DateTime?) onDateSelected; + final DateTime firstDate; + final DateTime lastDate; + + const _DateRangeItem({ + required this.date, + required this.label, + required this.enabled, + required this.primaryColor, + required this.onDateSelected, + required this.firstDate, + required this.lastDate, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final effectivePrimaryColor = primaryColor ?? theme.primaryColor; + + return InkWell( + onTap: enabled ? () async { + final DateTime? picked = await showDatePicker( + context: context, + initialDate: date ?? DateTime.now(), + firstDate: firstDate, + lastDate: lastDate, + builder: (BuildContext context, Widget? child) { + return Theme( + data: ThemeData.light().copyWith( + colorScheme: ColorScheme.light( + primary: effectivePrimaryColor, + onPrimary: Colors.white, + surface: Colors.white, + onSurface: Colors.black, + ), + dialogBackgroundColor: Colors.white, + ), + child: child!, + ); + }, + ); + + if (picked != null) { + onDateSelected(picked); + } + } : null, + borderRadius: BorderRadius.circular(16), + child: Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: TextStyle( + fontSize: 12, + color: theme.colorScheme.onSurface.withOpacity(0.6), + ), + ), + const SizedBox(height: 4), + Text( + date != null + ? DateFormat('MM/dd').format(date!) + : '์„ ํƒ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: date != null + ? theme.colorScheme.onSurface + : theme.colorScheme.onSurface.withOpacity(0.4), + ), + ), + ], + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_action_buttons.dart b/lib/widgets/detail/detail_action_buttons.dart new file mode 100644 index 0000000..4f70b22 --- /dev/null +++ b/lib/widgets/detail/detail_action_buttons.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../common/buttons/primary_button.dart'; + +/// ์ƒ์„ธ ํ™”๋ฉด ์•ก์…˜ ๋ฒ„ํŠผ ์„น์…˜ +/// ์ €์žฅ ๋ฒ„ํŠผ์„ ํฌํ•จํ•˜๋Š” ์„น์…˜์ž…๋‹ˆ๋‹ค. +class DetailActionButtons extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailActionButtons({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Padding( + padding: const EdgeInsets.only(bottom: 80), + child: PrimaryButton( + text: '๋ณ€๊ฒฝ์‚ฌํ•ญ ์ €์žฅ', + icon: Icons.save_rounded, + onPressed: controller.updateSubscription, + isLoading: controller.isLoading, + backgroundColor: baseColor, + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart new file mode 100644 index 0000000..1d84c5d --- /dev/null +++ b/lib/widgets/detail/detail_event_section.dart @@ -0,0 +1,221 @@ +import 'package:flutter/material.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../common/form_fields/currency_input_field.dart'; +import '../common/form_fields/date_picker_field.dart'; + +/// ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ ์„น์…˜ +/// ํ• ์ธ ์ด๋ฒคํŠธ ์ •๋ณด๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ์„น์…˜์ž…๋‹ˆ๋‹ค. +class DetailEventSection extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailEventSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ํ—ค๋” + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.local_offer_rounded, + color: baseColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + '์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™” ์Šค์œ„์น˜ + Switch.adaptive( + value: controller.isEventActive, + onChanged: (value) { + controller.isEventActive = value; + if (!value) { + // ์ด๋ฒคํŠธ ๋น„ํ™œ์„ฑํ™”์‹œ ๊ด€๋ จ ์ •๋ณด ์ดˆ๊ธฐํ™” + controller.eventStartDate = null; + controller.eventEndDate = null; + controller.eventPriceController.clear(); + } + }, + activeColor: baseColor, + ), + ], + ), + // ์ด๋ฒคํŠธ ํ™œ์„ฑํ™”์‹œ ํ‘œ์‹œ๋  ํ•„๋“œ๋“ค + if (controller.isEventActive) ...[ + const SizedBox(height: 20), + // ์ด๋ฒคํŠธ ์„ค๋ช… + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: Colors.blue[700], + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'ํ• ์ธ ๋˜๋Š” ํ”„๋กœ๋ชจ์…˜ ๊ฐ€๊ฒฉ์„ ์„ค์ •ํ•˜์„ธ์š”', + style: TextStyle( + fontSize: 14, + color: Colors.blue[700], + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), + // ์ด๋ฒคํŠธ ๊ธฐ๊ฐ„ + DateRangePickerField( + startDate: controller.eventStartDate, + endDate: controller.eventEndDate, + onStartDateSelected: (date) { + controller.eventStartDate = date; + }, + onEndDateSelected: (date) { + controller.eventEndDate = date; + }, + startLabel: '์‹œ์ž‘์ผ', + endLabel: '์ข…๋ฃŒ์ผ', + primaryColor: baseColor, + ), + const SizedBox(height: 20), + // ์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ + CurrencyInputField( + controller: controller.eventPriceController, + currency: controller.currency, + label: '์ด๋ฒคํŠธ ๊ฐ€๊ฒฉ', + hintText: 'ํ• ์ธ๋œ ๊ฐ€๊ฒฉ์„ ์ž…๋ ฅํ•˜์„ธ์š”', + ), + const SizedBox(height: 16), + // ํ• ์ธ์œจ ํ‘œ์‹œ + if (controller.eventPriceController.text.isNotEmpty) + _DiscountBadge( + originalPrice: controller.subscription.monthlyCost, + eventPrice: double.tryParse( + controller.eventPriceController.text.replaceAll(',', '') + ) ?? 0, + currency: controller.currency, + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +/// ํ• ์ธ์œจ ๋ฐฐ์ง€ +class _DiscountBadge extends StatelessWidget { + final double originalPrice; + final double eventPrice; + final String currency; + + const _DiscountBadge({ + required this.originalPrice, + required this.eventPrice, + required this.currency, + }); + + @override + Widget build(BuildContext context) { + if (eventPrice >= originalPrice || eventPrice <= 0) { + return const SizedBox.shrink(); + } + + final discountPercentage = ((originalPrice - eventPrice) / originalPrice * 100).round(); + final discountAmount = originalPrice - eventPrice; + + return Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.green.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.green, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '$discountPercentage% ํ• ์ธ', + style: const TextStyle( + color: Colors.white, + fontSize: 12, + fontWeight: FontWeight.w600, + ), + ), + ), + const SizedBox(width: 12), + Text( + currency == 'KRW' + ? 'โ‚ฉ${discountAmount.toInt().toString()}์› ์ ˆ์•ฝ' + : '\$${discountAmount.toStringAsFixed(2)} ์ ˆ์•ฝ', + style: TextStyle( + color: Colors.green[700], + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart new file mode 100644 index 0000000..8cab419 --- /dev/null +++ b/lib/widgets/detail/detail_form_section.dart @@ -0,0 +1,370 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../../providers/category_provider.dart'; +import '../common/form_fields/base_text_field.dart'; +import '../common/form_fields/currency_input_field.dart'; +import '../common/form_fields/date_picker_field.dart'; + +/// ์ƒ์„ธ ํ™”๋ฉด ํผ ์„น์…˜ +/// ๊ตฌ๋… ์ •๋ณด๋ฅผ ํŽธ์ง‘ํ•  ์ˆ˜ ์žˆ๋Š” ํผ ํ•„๋“œ๋“ค์„ ํฌํ•จํ•ฉ๋‹ˆ๋‹ค. +class DetailFormSection extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailFormSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.6), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // ์„œ๋น„์Šค๋ช… ํ•„๋“œ + BaseTextField( + controller: controller.serviceNameController, + focusNode: controller.serviceNameFocus, + label: '์„œ๋น„์Šค๋ช…', + hintText: '์˜ˆ: Netflix, Spotify', + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.monthlyCostFocus.requestFocus(); + }, + ), + const SizedBox(height: 20), + + // ์›” ์ง€์ถœ ๋ฐ ํ†ตํ™” ์„ ํƒ + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: CurrencyInputField( + controller: controller.monthlyCostController, + currency: controller.currency, + label: '์›” ์ง€์ถœ', + focusNode: controller.monthlyCostFocus, + textInputAction: TextInputAction.next, + onEditingComplete: () { + controller.billingCycleFocus.requestFocus(); + }, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'ํ†ตํ™”', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CurrencySelector( + currency: controller.currency, + onChanged: (value) { + controller.currency = value; + // ํ†ตํ™” ๋ณ€๊ฒฝ์‹œ ๊ธˆ์•ก ํฌ๋งท ์—…๋ฐ์ดํŠธ + if (value == 'KRW') { + final amount = double.tryParse( + controller.monthlyCostController.text.replaceAll(',', '') + ); + if (amount != null) { + controller.monthlyCostController.text = + amount.toInt().toString(); + } + } + }, + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + + // ๊ฒฐ์ œ ์ฃผ๊ธฐ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '๊ฒฐ์ œ ์ฃผ๊ธฐ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _BillingCycleSelector( + billingCycle: controller.billingCycle, + baseColor: baseColor, + onChanged: (value) { + controller.billingCycle = value; + }, + ), + ], + ), + const SizedBox(height: 20), + + // ๋‹ค์Œ ๊ฒฐ์ œ์ผ + DatePickerField( + selectedDate: controller.nextBillingDate, + onDateSelected: (date) { + controller.nextBillingDate = date; + }, + label: '๋‹ค์Œ ๊ฒฐ์ œ์ผ', + firstDate: DateTime.now(), + lastDate: DateTime.now().add(const Duration(days: 365 * 2)), + primaryColor: baseColor, + ), + const SizedBox(height: 20), + + // ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ + Consumer( + builder: (context, categoryProvider, child) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + '์นดํ…Œ๊ณ ๋ฆฌ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 8), + _CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: controller.selectedCategoryId, + baseColor: baseColor, + onChanged: (categoryId) { + controller.selectedCategoryId = categoryId; + }, + ), + ], + ); + }, + ), + ], + ), + ), + ), + ), + ); + } +} + +/// ํ†ตํ™” ์„ ํƒ๊ธฐ +class _CurrencySelector extends StatelessWidget { + final String currency; + final ValueChanged onChanged; + + const _CurrencySelector({ + required this.currency, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _CurrencyOption( + label: 'โ‚ฉ', + value: 'KRW', + isSelected: currency == 'KRW', + onTap: () => onChanged('KRW'), + ), + const SizedBox(width: 8), + _CurrencyOption( + label: '\$', + value: 'USD', + isSelected: currency == 'USD', + onTap: () => onChanged('USD'), + ), + ], + ); + } +} + +/// ํ†ตํ™” ์˜ต์…˜ +class _CurrencyOption extends StatelessWidget { + final String label; + final String value; + final bool isSelected; + final VoidCallback onTap; + + const _CurrencyOption({ + required this.label, + required this.value, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: isSelected + ? Theme.of(context).primaryColor + : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: isSelected ? Colors.white : Colors.grey[600], + ), + ), + ), + ), + ), + ); + } +} + +/// ๊ฒฐ์ œ ์ฃผ๊ธฐ ์„ ํƒ๊ธฐ +class _BillingCycleSelector extends StatelessWidget { + final String billingCycle; + final Color baseColor; + final ValueChanged onChanged; + + const _BillingCycleSelector({ + required this.billingCycle, + required this.baseColor, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final cycles = ['๋งค์›”', '๋ถ„๊ธฐ๋ณ„', '๋ฐ˜๊ธฐ๋ณ„', '๋งค๋…„']; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: cycles.map((cycle) { + final isSelected = billingCycle == cycle; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + onTap: () => onChanged(cycle), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + decoration: BoxDecoration( + color: isSelected ? baseColor : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Text( + cycle, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.grey[700], + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } +} + +/// ์นดํ…Œ๊ณ ๋ฆฌ ์„ ํƒ๊ธฐ +class _CategorySelector extends StatelessWidget { + final List categories; + final String? selectedCategoryId; + final Color baseColor; + final ValueChanged onChanged; + + const _CategorySelector({ + required this.categories, + required this.selectedCategoryId, + required this.baseColor, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: categories.map((category) { + final isSelected = selectedCategoryId == category.id; + return InkWell( + onTap: () => onChanged(category.id), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: isSelected ? baseColor : Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + category.emoji, + style: const TextStyle(fontSize: 16), + ), + const SizedBox(width: 6), + Text( + category.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: isSelected ? Colors.white : Colors.grey[700], + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart new file mode 100644 index 0000000..83527bb --- /dev/null +++ b/lib/widgets/detail/detail_header_section.dart @@ -0,0 +1,247 @@ +import 'package:flutter/material.dart'; +import 'package:intl/intl.dart'; +import '../../models/subscription_model.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../website_icon.dart'; + +/// ์ƒ์„ธ ํ™”๋ฉด ์ƒ๋‹จ ํ—ค๋” ์„น์…˜ +/// ์„œ๋น„์Šค ์•„์ด์ฝ˜, ์ด๋ฆ„, ๊ฒฐ์ œ ์ •๋ณด๋ฅผ ํ‘œ์‹œํ•ฉ๋‹ˆ๋‹ค. +class DetailHeaderSection extends StatelessWidget { + final SubscriptionModel subscription; + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + final Animation rotateAnimation; + + const DetailHeaderSection({ + super.key, + required this.subscription, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + required this.rotateAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + final gradient = controller.getGradient(baseColor); + + return Container( + height: 320, + decoration: BoxDecoration(gradient: gradient), + child: Stack( + children: [ + // ๋ฐฐ๊ฒฝ ํŒจํ„ด + Positioned( + top: -50, + right: -50, + child: RotationTransition( + turns: rotateAnimation, + child: Container( + width: 200, + height: 200, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.1), + ), + ), + ), + ), + Positioned( + bottom: -30, + left: -30, + child: Container( + width: 150, + height: 150, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Colors.white.withValues(alpha: 0.08), + ), + ), + ), + // ์ฝ˜ํ…์ธ  + SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ๋’ค๋กœ๊ฐ€๊ธฐ ๋ฒ„ํŠผ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + IconButton( + icon: const Icon( + Icons.arrow_back_ios_new_rounded, + color: Colors.white, + ), + onPressed: () => Navigator.of(context).pop(), + ), + IconButton( + icon: const Icon( + Icons.delete_outline_rounded, + color: Colors.white, + ), + onPressed: controller.deleteSubscription, + ), + ], + ), + const Spacer(), + // ์„œ๋น„์Šค ์ •๋ณด + FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: slideAnimation, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ์„œ๋น„์Šค ์•„์ด์ฝ˜๊ณผ ์ด๋ฆ„ + Row( + children: [ + Hero( + tag: 'icon_${subscription.id}', + child: Container( + width: 56, + height: 56, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.1), + blurRadius: 8, + offset: const Offset(0, 4), + ), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: WebsiteIcon( + url: subscription.websiteUrl, + serviceName: subscription.serviceName, + size: 48, + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + subscription.serviceName, + style: const TextStyle( + fontSize: 28, + fontWeight: FontWeight.w800, + color: Colors.white, + letterSpacing: -0.5, + shadows: [ + Shadow( + color: Colors.black26, + offset: Offset(0, 2), + blurRadius: 4, + ), + ], + ), + ), + const SizedBox(height: 4), + Text( + '${subscription.billingCycle} ๊ฒฐ์ œ', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 20), + // ๊ฒฐ์ œ ์ •๋ณด ์นด๋“œ + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(16), + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _InfoColumn( + label: '๋‹ค์Œ ๊ฒฐ์ œ์ผ', + value: DateFormat('yyyy๋…„ MM์›” dd์ผ') + .format(subscription.nextBillingDate), + ), + _InfoColumn( + label: '์›” ์ง€์ถœ', + value: NumberFormat.currency( + locale: subscription.currency == 'KRW' + ? 'ko_KR' + : 'en_US', + symbol: subscription.currency == 'KRW' + ? 'โ‚ฉ' + : '\$', + decimalDigits: + subscription.currency == 'KRW' ? 0 : 2, + ).format(subscription.monthlyCost), + alignment: CrossAxisAlignment.end, + ), + ], + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ], + ), + ); + } +} + +/// ์ •๋ณด ํ‘œ์‹œ ์ปฌ๋Ÿผ +class _InfoColumn extends StatelessWidget { + final String label; + final String value; + final CrossAxisAlignment alignment; + + const _InfoColumn({ + required this.label, + required this.value, + this.alignment = CrossAxisAlignment.start, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: alignment, + children: [ + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: 4), + Text( + value, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: Colors.white, + ), + ), + ], + ); + } +} \ No newline at end of file diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart new file mode 100644 index 0000000..426c62f --- /dev/null +++ b/lib/widgets/detail/detail_url_section.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import '../../controllers/detail_screen_controller.dart'; +import '../common/form_fields/base_text_field.dart'; +import '../common/buttons/secondary_button.dart'; + +/// ์›น์‚ฌ์ดํŠธ URL ์„น์…˜ +/// ์„œ๋น„์Šค ์›น์‚ฌ์ดํŠธ URL๊ณผ ํ•ด์ง€ ๊ด€๋ จ ์ •๋ณด๋ฅผ ๊ด€๋ฆฌํ•˜๋Š” ์„น์…˜์ž…๋‹ˆ๋‹ค. +class DetailUrlSection extends StatelessWidget { + final DetailScreenController controller; + final Animation fadeAnimation; + final Animation slideAnimation; + + const DetailUrlSection({ + super.key, + required this.controller, + required this.fadeAnimation, + required this.slideAnimation, + }); + + @override + Widget build(BuildContext context) { + final baseColor = controller.getCardColor(); + + return FadeTransition( + opacity: fadeAnimation, + child: SlideTransition( + position: Tween( + begin: const Offset(0.0, 0.8), + end: Offset.zero, + ).animate(CurvedAnimation( + parent: controller.animationController!, + curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), + )), + child: Card( + elevation: 1, + shadowColor: Colors.black12, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(20), + ), + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ํ—ค๋” + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: baseColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.language_rounded, + color: baseColor, + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + '์›น์‚ฌ์ดํŠธ ์ •๋ณด', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + const SizedBox(height: 20), + + // URL ์ž…๋ ฅ ํ•„๋“œ + BaseTextField( + controller: controller.websiteUrlController, + focusNode: controller.websiteUrlFocus, + label: '์›น์‚ฌ์ดํŠธ URL', + hintText: 'https://example.com', + keyboardType: TextInputType.url, + prefixIcon: Icon( + Icons.link_rounded, + color: Colors.grey[600], + ), + ), + + // ํ•ด์ง€ ์•ˆ๋‚ด ์„น์…˜ + if (controller.subscription.websiteUrl != null && + controller.subscription.websiteUrl!.isNotEmpty) ...[ + const SizedBox(height: 24), + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.orange.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: Colors.orange.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: Colors.orange[700], + size: 20, + ), + const SizedBox(width: 8), + Text( + 'ํ•ด์ง€ ์•ˆ๋‚ด', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: Colors.orange[700], + ), + ), + ], + ), + const SizedBox(height: 8), + Text( + '์ด ์„œ๋น„์Šค๋ฅผ ํ•ด์ง€ํ•˜๋ ค๋ฉด ์•„๋ž˜ ๋งํฌ๋ฅผ ํ†ตํ•ด ํ•ด์ง€ ํŽ˜์ด์ง€๋กœ ์ด๋™ํ•˜์„ธ์š”.', + style: TextStyle( + fontSize: 14, + color: Colors.grey[700], + height: 1.5, + ), + ), + const SizedBox(height: 12), + TextLinkButton( + text: 'ํ•ด์ง€ ํŽ˜์ด์ง€๋กœ ์ด๋™', + icon: Icons.open_in_new_rounded, + onPressed: controller.openCancellationPage, + color: Colors.orange[700], + ), + ], + ), + ), + ], + + // URL ์ž๋™ ๋งค์นญ ์ •๋ณด + if (controller.websiteUrlController.text.isEmpty) ...[ + const SizedBox(height: 16), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.blue.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + children: [ + Icon( + Icons.auto_fix_high_rounded, + color: Colors.blue[700], + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + 'URL์ด ๋น„์–ด์žˆ์œผ๋ฉด ์„œ๋น„์Šค๋ช…์„ ๊ธฐ๋ฐ˜์œผ๋กœ ์ž๋™ ๋งค์นญ๋ฉ๋‹ˆ๋‹ค', + style: TextStyle( + fontSize: 14, + color: Colors.blue[700], + ), + ), + ), + ], + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} \ No newline at end of file diff --git a/lib/widgets/exchange_rate_widget.dart b/lib/widgets/exchange_rate_widget.dart index bd7efe4..9448a34 100644 --- a/lib/widgets/exchange_rate_widget.dart +++ b/lib/widgets/exchange_rate_widget.dart @@ -137,7 +137,7 @@ class _ExchangeRateWidgetState extends State { return const SizedBox.shrink(); // ํ‘œ์‹œํ•  ํ•„์š”๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ์œ„์ ฏ ๋ฐ˜ํ™˜ } - return Column( + return const Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ // ์ด ์œ„์ ฏ์€ ์ด์ œ ํ™˜์œจ ์ •๋ณด๋งŒ ์ œ๊ณตํ•˜๊ณ , ์‹ค์ œ UI๋Š” ์Šคํฌ๋ฆฐ์—์„œ ๊ตฌ์„ฑ diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index 52ab9af..9771cde 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'dart:ui'; import '../theme/app_colors.dart'; import 'glassmorphism_card.dart'; @@ -62,8 +61,6 @@ class _FloatingNavigationBarState extends State @override Widget build(BuildContext context) { - final isDarkMode = Theme.of(context).brightness == Brightness.dark; - return AnimatedBuilder( animation: _animation, builder: (context, child) { diff --git a/lib/widgets/glassmorphic_scaffold.dart b/lib/widgets/glassmorphic_scaffold.dart index 13c9d9b..4f88297 100644 --- a/lib/widgets/glassmorphic_scaffold.dart +++ b/lib/widgets/glassmorphic_scaffold.dart @@ -2,7 +2,6 @@ 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'; /// ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ๋””์ž์ธ์ด ์ ์šฉ๋œ ํ†ต์ผ๋œ ์Šค์บํด๋“œ @@ -79,7 +78,6 @@ class _GlassmorphicScaffoldState extends State void _setupScrollListener() { _scrollController?.addListener(() { final currentScroll = _scrollController!.position.pixels; - final maxScroll = _scrollController!.position.maxScrollExtent; // ์Šคํฌ๋กค ๋ฐฉํ–ฅ์— ๋”ฐ๋ผ ํ”Œ๋กœํŒ… ๋„ค๋น„๊ฒŒ์ด์…˜ ๋ฐ” ํ‘œ์‹œ/์ˆจ๊น€ if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) { diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 1a12f27..21658f8 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -126,7 +126,6 @@ class _AnimatedGlassmorphismCardState extends State late AnimationController _controller; late Animation _scaleAnimation; late Animation _blurAnimation; - bool _isPressed = false; @override void initState() { @@ -160,23 +159,14 @@ class _AnimatedGlassmorphismCardState extends State } 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(); } diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index 971d68d..60ae091 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -9,7 +9,6 @@ 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; @@ -73,8 +72,8 @@ class HomeContent extends StatelessWidget { pinned: true, expandedHeight: kToolbarHeight, ), - SliverToBoxAdapter( - child: NativeAdWidget(key: const ValueKey('home_ad')), + const SliverToBoxAdapter( + child: NativeAdWidget(key: ValueKey('home_ad')), ), SliverToBoxAdapter( child: SlideTransition( @@ -119,14 +118,14 @@ class HomeContent extends StatelessWidget { children: [ Text( '${provider.subscriptions.length}๊ฐœ', - style: TextStyle( + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primaryColor, ), ), const SizedBox(width: 4), - Icon( + const Icon( Icons.arrow_forward_ios, size: 14, color: AppColors.primaryColor, diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index 4f1f1a3..3690cba 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -1,9 +1,7 @@ import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:intl/intl.dart'; import '../providers/subscription_provider.dart'; import '../theme/app_colors.dart'; -import '../utils/format_helper.dart'; import 'animated_wave_background.dart'; import 'glassmorphism_card.dart'; diff --git a/lib/widgets/spring_animation_widget.dart b/lib/widgets/spring_animation_widget.dart index d42483a..f3a13b6 100644 --- a/lib/widgets/spring_animation_widget.dart +++ b/lib/widgets/spring_animation_widget.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; import 'package:flutter/physics.dart'; -import 'dart:math' as math; /// ๋ฌผ๋ฆฌ ๊ธฐ๋ฐ˜ ์Šคํ”„๋ง ์• ๋‹ˆ๋ฉ”์ด์…˜์„ ์ ์šฉํ•˜๋Š” ์œ„์ ฏ class SpringAnimationWidget extends StatefulWidget { @@ -44,14 +43,6 @@ class _SpringAnimationWidgetState extends State 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), diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index d781aec..4ab204b 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -1,14 +1,9 @@ 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 { @@ -27,9 +22,6 @@ class _SubscriptionCardState extends State with SingleTickerProviderStateMixin { late AnimationController _hoverController; bool _isHovering = false; - final double _initialElevation = 1.0; - final double _hoveredElevation = 3.0; - late SubscriptionProvider _subscriptionProvider; @override void initState() { @@ -40,12 +32,6 @@ class _SubscriptionCardState extends State ); } - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _subscriptionProvider = - Provider.of(context, listen: false); - } @override void dispose() { @@ -221,10 +207,6 @@ class _SubscriptionCardState extends State child: AnimatedBuilder( animation: _hoverController, builder: (context, child) { - final elevation = _initialElevation + - (_hoveredElevation - _initialElevation) * - _hoverController.value; - final scale = 1.0 + (0.02 * _hoverController.value); return Transform.scale( @@ -337,10 +319,10 @@ class _SubscriptionCardState extends State vertical: 3, ), decoration: BoxDecoration( - gradient: LinearGradient( + gradient: const LinearGradient( colors: [ - const Color(0xFFFF6B6B), - const Color(0xFFFF8787), + Color(0xFFFF6B6B), + Color(0xFFFF8787), ], ), borderRadius: @@ -349,12 +331,12 @@ class _SubscriptionCardState extends State child: Row( mainAxisSize: MainAxisSize.min, children: [ - const Icon( + Icon( Icons.local_offer_rounded, size: 11, color: Colors.white, ), - const SizedBox(width: 3), + SizedBox(width: 3), Text( '์ด๋ฒคํŠธ', style: TextStyle( @@ -386,7 +368,7 @@ class _SubscriptionCardState extends State ), child: Text( widget.subscription.billingCycle, - style: TextStyle( + style: const TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: AppColors.textSecondary, @@ -424,7 +406,7 @@ class _SubscriptionCardState extends State decimalDigits: 0, ).format(widget .subscription.monthlyCost), - style: TextStyle( + style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w500, color: AppColors.textSecondary, @@ -555,7 +537,7 @@ class _SubscriptionCardState extends State if (widget.subscription.eventEndDate != null) ...[ Text( '${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}์ผ ๋‚จ์Œ', - style: TextStyle( + style: const TextStyle( fontSize: 11, color: AppColors.textSecondary, ), diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index fb53561..46fe98f 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -1,11 +1,8 @@ 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'; @@ -62,8 +59,8 @@ class SubscriptionListWidget extends StatelessWidget { itemBuilder: (context, subIndex) { // ๊ฐ ๊ตฌ๋…์˜ ์ง€์—ฐ๊ฐ’ ๊ณ„์‚ฐ (์ˆœ์ฐจ์ ์œผ๋กœ ๋‚˜ํƒ€๋‚˜๋„๋ก) final delay = 0.05 * subIndex; - final animationBegin = 0.2; - final animationEnd = 1.0; + const animationBegin = 0.2; + const animationEnd = 1.0; final intervalStart = delay; final intervalEnd = intervalStart + 0.4; diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart index 3cccc6d..dd32d25 100644 --- a/lib/widgets/swipeable_subscription_card.dart +++ b/lib/widgets/swipeable_subscription_card.dart @@ -1,6 +1,4 @@ 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'; diff --git a/lib/widgets/themed_text.dart b/lib/widgets/themed_text.dart index 29367d5..33822f4 100644 --- a/lib/widgets/themed_text.dart +++ b/lib/widgets/themed_text.dart @@ -43,7 +43,6 @@ class ThemedText extends StatelessWidget { if (forceDark) return AppColors.textPrimary; final brightness = Theme.of(context).brightness; - final backgroundColor = Theme.of(context).scaffoldBackgroundColor; // ๊ธ€๋ž˜์Šค๋ชจํ”ผ์ฆ˜ ํ™˜๊ฒฝ์—์„œ๋Š” ๋ณดํ†ต ์–ด๋‘์šด ๋ฐฐ๊ฒฝ ์œ„์— ๋ฐ์€ ํ…์ŠคํŠธ if (_isGlassmorphicContext(context)) { diff --git a/lib/widgets/website_icon.dart b/lib/widgets/website_icon.dart index e406fc4..0dc2bcf 100644 --- a/lib/widgets/website_icon.dart +++ b/lib/widgets/website_icon.dart @@ -1,16 +1,11 @@ import 'package:flutter/material.dart'; import 'package:http/http.dart' as http; import 'package:cached_network_image/cached_network_image.dart'; -import 'package:font_awesome_flutter/font_awesome_flutter.dart'; -import 'package:octo_image/octo_image.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; -import 'package:html/parser.dart' as html_parser; -import 'package:html/dom.dart' as html_dom; import '../theme/app_colors.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:path_provider/path_provider.dart'; import 'package:crypto/crypto.dart'; @@ -57,7 +52,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); return prefs.getString('favicon_$serviceKey'); } catch (e) { - print('ํŒŒ๋น„์ฝ˜ ์บ์‹œ ๋กœ๋“œ ์˜ค๋ฅ˜: $e'); + // ํŒŒ๋น„์ฝ˜ ์บ์‹œ ๋กœ๋“œ ์˜ค๋ฅ˜ return null; } } @@ -68,7 +63,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); await prefs.setString('favicon_$serviceKey', logoUrl); } catch (e) { - print('ํŒŒ๋น„์ฝ˜ ์บ์‹œ ์ €์žฅ ์˜ค๋ฅ˜: $e'); + // ํŒŒ๋น„์ฝ˜ ์บ์‹œ ์ €์žฅ ์˜ค๋ฅ˜ } } @@ -80,7 +75,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); await prefs.remove('favicon_$serviceKey'); } catch (e) { - print('ํŒŒ๋น„์ฝ˜ ์บ์‹œ ์‚ญ์ œ ์˜ค๋ฅ˜: $e'); + // ํŒŒ๋น„์ฝ˜ ์บ์‹œ ์‚ญ์ œ ์˜ค๋ฅ˜ } } @@ -90,7 +85,7 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); return prefs.getString('local_favicon_$serviceKey'); } catch (e) { - print('๋กœ์ปฌ ํŒŒ๋น„์ฝ˜ ๊ฒฝ๋กœ ๋กœ๋“œ ์˜ค๋ฅ˜: $e'); + // ๋กœ์ปฌ ํŒŒ๋น„์ฝ˜ ๊ฒฝ๋กœ ๋กœ๋“œ ์˜ค๋ฅ˜ return null; } } @@ -102,39 +97,14 @@ class FaviconCache { final prefs = await SharedPreferences.getInstance(); await prefs.setString('local_favicon_$serviceKey', filePath); } catch (e) { - print('๋กœ์ปฌ ํŒŒ๋น„์ฝ˜ ๊ฒฝ๋กœ ์ €์žฅ ์˜ค๋ฅ˜: $e'); + // ๋กœ์ปฌ ํŒŒ๋น„์ฝ˜ ๊ฒฝ๋กœ ์ €์žฅ ์˜ค๋ฅ˜ } } } // ๊ตฌ๊ธ€ ํŒŒ๋น„์ฝ˜ API ์„œ๋น„์Šค class GoogleFaviconService { - // CORS ํ”„๋ก์‹œ ์„œ๋ฒ„ ๋ชฉ๋ก - static final List _corsProxies = [ - 'https://corsproxy.io/?', - 'https://api.allorigins.win/raw?url=', - 'https://cors-anywhere.herokuapp.com/', - ]; - // ํ˜„์žฌ ์‚ฌ์šฉ ์ค‘์ธ ํ”„๋ก์‹œ ์ธ๋ฑ์Šค - static int _currentProxyIndex = 0; - - // ํ”„๋ก์‹œ๋ฅผ ์‚ฌ์šฉํ•˜์—ฌ URL ์ƒ์„ฑ - static String _getProxiedUrl(String url) { - // ์•ฑ ํ™˜๊ฒฝ์—์„œ๋Š” ํ”„๋ก์‹œ ์—†์ด ์ง์ ‘ URL ๋ฐ˜ํ™˜ - if (!kIsWeb) { - return url; - } - - // ์›น ํ™˜๊ฒฝ์—์„œ๋Š” CORS ํ”„๋ก์‹œ ์‚ฌ์šฉ - final proxy = _corsProxies[_currentProxyIndex]; - _currentProxyIndex = - (_currentProxyIndex + 1) % _corsProxies.length; // ๋‹ค์Œ ์š”์ฒญ์€ ๋‹ค๋ฅธ ํ”„๋ก์‹œ ์‚ฌ์šฉ - - // URL ์ธ์ฝ”๋”ฉ - final encodedUrl = Uri.encodeComponent(url); - return '$proxy$encodedUrl'; - } // ๊ตฌ๊ธ€ ํŒŒ๋น„์ฝ˜ API URL ์ƒ์„ฑ static String getFaviconUrl(String domain, int size) { @@ -167,7 +137,7 @@ class GoogleFaviconService { static String getBase64PlaceholderIcon(String serviceName, Color color) { // ๊ฐ„๋‹จํ•œ SVG ์ƒ์„ฑ (์„œ๋น„์Šค ์ด๋ฆ„์˜ ์ฒซ ๊ธ€์ž๋ฅผ ์› ์•ˆ์— ํ‘œ์‹œ) final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?'; - final colorHex = color.value.toRadixString(16).padLeft(8, '0').substring(2); + final colorHex = color.toARGB32().toRadixString(16).padLeft(8, '0').substring(2); // ๊ณต๋ฐฑ ์—†์ด SVG ์ƒ์„ฑ (๊ณต๋ฐฑ์ด ์žˆ์œผ๋ฉด Base64 ๋””์ฝ”๋”ฉ ํ›„ ์ด๋ฏธ์ง€ ๋กœ๋“œ ์‹œ ๋ฌธ์ œ ๋ฐœ์ƒ) final svgContent = @@ -207,15 +177,12 @@ class _WebsiteIconState extends State bool _isLoading = true; late AnimationController _animationController; late Animation _scaleAnimation; - late Animation _opacityAnimation; // ๊ฐ ์ธ์Šคํ„ด์Šค์— ๋Œ€ํ•œ ๊ณ ์œ  ์‹๋ณ„์ž ์ถ”๊ฐ€ final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString(); // ์„œ๋น„์Šค์™€ URL ์กฐํ•ฉ์œผ๋กœ ์บ์‹œ ํ‚ค ์ƒ์„ฑ String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}'; // ์ด์ „์— ์‚ฌ์šฉํ•œ ์„œ๋น„์Šค ํ‚ค (URL์ด ๋ณ€๊ฒฝ๋๋Š”์ง€ ํ™•์ธ์šฉ) String? _previousServiceKey; - // ๋กœ๋“œ ์‹œ์ž‘๋œ ์‹œ์  - DateTime? _loadStartTime; @override void initState() { @@ -231,15 +198,9 @@ class _WebsiteIconState extends State CurvedAnimation( parent: _animationController, curve: Curves.easeOutCubic)); - _opacityAnimation = Tween(begin: 0.0, end: 1.0).animate( - CurvedAnimation(parent: _animationController, curve: Curves.easeOut)); - // ์ดˆ๊ธฐ _previousServiceKey ์„ค์ • _previousServiceKey = _serviceKey; - // ๋กœ๋“œ ์‹œ์ž‘ ์‹œ๊ฐ„ ๊ธฐ๋ก - _loadStartTime = DateTime.now(); - // ์ตœ์ดˆ ๋กœ๋”ฉ _loadFaviconWithCache(); } @@ -263,7 +224,7 @@ class _WebsiteIconState extends State // ์ด๋ฏธ ๋กœ๋”ฉ ์ค‘์ธ์ง€ ํ™•์ธ if (!FaviconCache.markAsLoading(_serviceKey)) { - await Future.delayed(Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); cachedLogo = FaviconCache.getFromMemory(_serviceKey); if (cachedLogo != null) { setState(() { @@ -312,7 +273,7 @@ class _WebsiteIconState extends State // 2. ์ด๋ฏธ ๋กœ๋”ฉ ์ค‘์ธ์ง€ ํ™•์ธ if (!FaviconCache.markAsLoading(_serviceKey)) { - await Future.delayed(Duration(milliseconds: 500)); + await Future.delayed(const Duration(milliseconds: 500)); localPath = await FaviconCache.getLocalFaviconPath(_serviceKey); if (localPath != null) { final file = File(localPath); @@ -344,12 +305,9 @@ class _WebsiteIconState extends State // ์„œ๋น„์Šค๋ช…์ด๋‚˜ URL์ด ๋ณ€๊ฒฝ๋œ ๊ฒฝ์šฐ์—๋งŒ ๋‹ค์‹œ ๋กœ๋“œ final currentServiceKey = _serviceKey; if (_previousServiceKey != currentServiceKey) { - print('์„œ๋น„์Šค ํ‚ค ๋ณ€๊ฒฝ ๊ฐ์ง€: $_previousServiceKey -> $currentServiceKey'); + // ์„œ๋น„์Šค ํ‚ค ๋ณ€๊ฒฝ ๊ฐ์ง€: $_previousServiceKey -> $currentServiceKey _previousServiceKey = currentServiceKey; - // ๋กœ๋“œ ์‹œ์ž‘ ์‹œ๊ฐ„ ๊ธฐ๋ก - _loadStartTime = DateTime.now(); - // ๋ณ€๊ฒฝ๋œ ์„œ๋น„์Šค ์ •๋ณด๋กœ ํŒŒ๋น„์ฝ˜ ๋กœ๋“œ _loadFaviconWithCache(); } @@ -472,7 +430,7 @@ class _WebsiteIconState extends State return; } } catch (e) { - print('DuckDuckGo ํŒŒ๋น„์ฝ˜ API ์š”์ฒญ ์‹คํŒจ: $e'); + // DuckDuckGo ํŒŒ๋น„์ฝ˜ API ์š”์ฒญ ์‹คํŒจ // ์‹คํŒจ ์‹œ ๋ฐฑ์—… ๋ฐฉ๋ฒ•์œผ๋กœ ์ง„ํ–‰ } @@ -501,7 +459,7 @@ class _WebsiteIconState extends State FaviconCache.cancelLoading(_serviceKey); } catch (e) { - print('์›น์šฉ ํŒŒ๋น„์ฝ˜ ๊ฐ€์ ธ์˜ค๊ธฐ ์˜ค๋ฅ˜: $e'); + // ์›น์šฉ ํŒŒ๋น„์ฝ˜ ๊ฐ€์ ธ์˜ค๊ธฐ ์˜ค๋ฅ˜ if (mounted) { setState(() { _isLoading = false; @@ -579,7 +537,7 @@ class _WebsiteIconState extends State FaviconCache.cancelLoading(_serviceKey); } catch (e) { - print('์•ฑ์šฉ ํŒŒ๋น„์ฝ˜ ๋‹ค์šด๋กœ๋“œ ์˜ค๋ฅ˜: $e'); + // ์•ฑ์šฉ ํŒŒ๋น„์ฝ˜ ๋‹ค์šด๋กœ๋“œ ์˜ค๋ฅ˜ if (mounted) { setState(() { _isLoading = false; @@ -610,7 +568,7 @@ class _WebsiteIconState extends State boxShadow: widget.isHovered ? [ BoxShadow( - color: _getColorFromName().withAlpha(76), // ์•ฝ 0.3 ์•ŒํŒŒ๊ฐ’ + color: _getColorFromName().withValues(alpha: 0.3), // ์•ฝ 0.3 ์•ŒํŒŒ๊ฐ’ blurRadius: 12, spreadRadius: 0, offset: const Offset(0, 4), @@ -643,7 +601,7 @@ class _WebsiteIconState extends State child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withAlpha(179)), + AppColors.primaryColor.withValues(alpha: 0.7)), ), ), ), @@ -684,7 +642,7 @@ class _WebsiteIconState extends State child: CircularProgressIndicator( strokeWidth: 2, valueColor: AlwaysStoppedAnimation( - AppColors.primaryColor.withAlpha(179)), + AppColors.primaryColor.withValues(alpha: 0.7)), ), ), ), @@ -726,7 +684,7 @@ class _WebsiteIconState extends State gradient: LinearGradient( colors: [ color, - color.withAlpha(204), // ์•ฝ 0.8 ์•ŒํŒŒ๊ฐ’ + color.withValues(alpha: 0.8), // ์•ฝ 0.8 ์•ŒํŒŒ๊ฐ’ ], begin: Alignment.topLeft, end: Alignment.bottomRight,