Major UI/UX and architecture improvements
- Implemented new navigation system with NavigationProvider and route management - Added adaptive theme system with ThemeProvider for better theme handling - Introduced glassmorphism design elements (app bars, scaffolds, cards) - Added advanced animations (spring animations, page transitions, staggered lists) - Implemented performance optimizations (memory manager, lazy loading) - Refactored Analysis screen into modular components - Added floating navigation bar with haptic feedback - Improved subscription cards with swipe actions - Enhanced skeleton loading with better animations - Added cached network image support - Improved overall app architecture and code organization 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
58
doc/color.md
Normal file
58
doc/color.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
## 구독관리 앱 글래스모피어즘 색상 가이드
|
||||||
|
**신뢰성, 편안함, 트렌드함**을 모두 잡는 컬러 조합 추천
|
||||||
|
|
||||||
|
### 1. 컬러 선정 원칙
|
||||||
|
|
||||||
|
- **신뢰성:** 블루 계열, 그레이, 화이트 등 안정적이고 전문적인 느낌의 색상
|
||||||
|
- **편안함:** 저채도 파스텔, 연한 블루·민트, 따뜻한 베이지 등 눈에 부담 없는 색상
|
||||||
|
- **트렌드함:** 그라디언트, 반투명 레이어, 약간의 네온 포인트 등 현대적 감각
|
||||||
|
|
||||||
|
### 2. 추천 컬러 팔레트
|
||||||
|
|
||||||
|
| 용도 | 추천 색상 예시 (Hex) | 설명 |
|
||||||
|
|--------------|-------------------------------|---------------------------------------|
|
||||||
|
| 메인 | #2563eb, #60a5fa, #e0e7ef | 신뢰감 주는 블루 계열 그라디언트 |
|
||||||
|
| 서브 | #f9fafb, #f1f5f9, #f3f4f6 | 밝은 화이트·그레이, 편안한 배경 |
|
||||||
|
| 포인트 | #38bdf8, #7dd3fc, #f472b6 | 트렌디한 민트, 연핑크, 밝은 블루 |
|
||||||
|
| 테두리/블러 | rgba(255,255,255,0.3) | 글래스 효과용 반투명 화이트 |
|
||||||
|
| 그림자 | rgba(0,0,0,0.08) | 부드러운 깊이감 부여 |
|
||||||
|
|
||||||
|
### 3. 실전 적용 예시
|
||||||
|
|
||||||
|
- **배경:**
|
||||||
|
연한 블루(#e0e7ef) 또는 밝은 그레이(#f9fafb)
|
||||||
|
- **글래스 카드:**
|
||||||
|
반투명 화이트(예: rgba(255,255,255,0.2)), 블루 그라디언트 테두리
|
||||||
|
- **포인트 버튼:**
|
||||||
|
밝은 민트(#38bdf8) 또는 연핑크(#f472b6)
|
||||||
|
- **아이콘/텍스트:**
|
||||||
|
진한 블루(#2563eb), 다크 그레이(#334155)
|
||||||
|
- **그라디언트 예시:**
|
||||||
|
LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFF2563eb), Color(0xFF60a5fa), Color(0xFFe0e7ef)],
|
||||||
|
)
|
||||||
|
|
||||||
|
### 4. 참고 팁
|
||||||
|
|
||||||
|
- 글래스모피어즘은 **투명도·블러**와 함께 **밝고 깨끗한 색상**을 조합하면 신뢰감과 트렌디함을 동시에 줄 수 있습니다.
|
||||||
|
- 포인트 컬러를 너무 강하게 쓰기보다는, 전체적으로 **밝고 부드러운 톤**에 약간의 컬러만 더하는 것이 편안함을 극대화합니다.
|
||||||
|
- 실제 인기 앱(Reflect, T.RICKS, Coffee 등)도 블루·화이트·민트 계열을 주로 활용합니다.
|
||||||
|
|
||||||
|
### 5. 컬러 팔레트 예시
|
||||||
|
|
||||||
|
| 이름 | Hex 코드 | 용도/느낌 |
|
||||||
|
|-------------|------------|-------------------|
|
||||||
|
| Deep Blue | #2563eb | 신뢰, 메인 |
|
||||||
|
| Sky Blue | #60a5fa | 트렌드, 그라디언트|
|
||||||
|
| Soft Mint | #38bdf8 | 포인트, 상쾌함 |
|
||||||
|
| Light Gray | #f1f5f9 | 배경, 편안함 |
|
||||||
|
| White Glass | #ffffff(투명도) | 글래스 효과 |
|
||||||
|
| Pink Accent | #f472b6 | 포인트, 트렌디 |
|
||||||
|
|
||||||
|
### 6. 마무리
|
||||||
|
|
||||||
|
- **블루+화이트+민트** 조합은 신뢰성, 편안함, 트렌드함을 모두 만족시킵니다.
|
||||||
|
- 글래스모피어즘 효과와 함께라면, 위 팔레트로 세련되고 현대적인 구독관리 앱 UI를 완성할 수 있습니다.
|
||||||
|
- 실제 적용 시, 밝은 배경과 부드러운 그라디언트, 포인트 컬러를 적절히 조합해보세요.
|
||||||
177
ios/Podfile.lock
Normal file
177
ios/Podfile.lock
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
PODS:
|
||||||
|
- Flutter (1.0.0)
|
||||||
|
- flutter_local_notifications (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- flutter_native_splash (2.4.3):
|
||||||
|
- Flutter
|
||||||
|
- flutter_secure_storage (6.0.0):
|
||||||
|
- Flutter
|
||||||
|
- flutter_sms (1.1.0):
|
||||||
|
- Flutter
|
||||||
|
- Google-Mobile-Ads-SDK (10.11.0):
|
||||||
|
- GoogleAppMeasurement (< 11.0, >= 7.0)
|
||||||
|
- GoogleUserMessagingPlatform (>= 1.1)
|
||||||
|
- google_mobile_ads (1.0.0):
|
||||||
|
- Flutter
|
||||||
|
- Google-Mobile-Ads-SDK (~> 10.11.0)
|
||||||
|
- webview_flutter_wkwebview
|
||||||
|
- GoogleAppMeasurement (10.29.0):
|
||||||
|
- GoogleAppMeasurement/AdIdSupport (= 10.29.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 7.11)
|
||||||
|
- GoogleUtilities/Network (~> 7.11)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||||
|
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||||
|
- GoogleAppMeasurement/AdIdSupport (10.29.0):
|
||||||
|
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.29.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 7.11)
|
||||||
|
- GoogleUtilities/Network (~> 7.11)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||||
|
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||||
|
- GoogleAppMeasurement/WithoutAdIdSupport (10.29.0):
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 7.11)
|
||||||
|
- GoogleUtilities/Network (~> 7.11)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 7.11)"
|
||||||
|
- nanopb (< 2.30911.0, >= 2.30908.0)
|
||||||
|
- GoogleUserMessagingPlatform (3.0.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (7.13.3):
|
||||||
|
- GoogleUtilities/Environment
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Network
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Environment (7.13.3):
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- PromisesObjC (< 3.0, >= 1.2)
|
||||||
|
- GoogleUtilities/Logger (7.13.3):
|
||||||
|
- GoogleUtilities/Environment
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/MethodSwizzler (7.13.3):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Network (7.13.3):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- "GoogleUtilities/NSData+zlib"
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Reachability
|
||||||
|
- "GoogleUtilities/NSData+zlib (7.13.3)":
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Privacy (7.13.3)
|
||||||
|
- GoogleUtilities/Reachability (7.13.3):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- local_auth_darwin (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- nanopb (2.30910.0):
|
||||||
|
- nanopb/decode (= 2.30910.0)
|
||||||
|
- nanopb/encode (= 2.30910.0)
|
||||||
|
- nanopb/decode (2.30910.0)
|
||||||
|
- nanopb/encode (2.30910.0)
|
||||||
|
- path_provider_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- permission_handler_apple (9.3.0):
|
||||||
|
- Flutter
|
||||||
|
- PromisesObjC (2.4.0)
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- shared_preferences_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- sqflite_darwin (0.0.4):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- telephony (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- url_launcher_ios (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- Flutter (from `Flutter`)
|
||||||
|
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||||
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
|
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
|
||||||
|
- flutter_sms (from `.symlinks/plugins/flutter_sms/ios`)
|
||||||
|
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
|
||||||
|
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
|
||||||
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
|
||||||
|
- share_plus (from `.symlinks/plugins/share_plus/ios`)
|
||||||
|
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
- telephony (from `.symlinks/plugins/telephony/ios`)
|
||||||
|
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
|
||||||
|
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- Google-Mobile-Ads-SDK
|
||||||
|
- GoogleAppMeasurement
|
||||||
|
- GoogleUserMessagingPlatform
|
||||||
|
- GoogleUtilities
|
||||||
|
- nanopb
|
||||||
|
- PromisesObjC
|
||||||
|
|
||||||
|
EXTERNAL SOURCES:
|
||||||
|
Flutter:
|
||||||
|
:path: Flutter
|
||||||
|
flutter_local_notifications:
|
||||||
|
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||||
|
flutter_native_splash:
|
||||||
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
|
flutter_secure_storage:
|
||||||
|
:path: ".symlinks/plugins/flutter_secure_storage/ios"
|
||||||
|
flutter_sms:
|
||||||
|
:path: ".symlinks/plugins/flutter_sms/ios"
|
||||||
|
google_mobile_ads:
|
||||||
|
:path: ".symlinks/plugins/google_mobile_ads/ios"
|
||||||
|
local_auth_darwin:
|
||||||
|
:path: ".symlinks/plugins/local_auth_darwin/darwin"
|
||||||
|
path_provider_foundation:
|
||||||
|
:path: ".symlinks/plugins/path_provider_foundation/darwin"
|
||||||
|
permission_handler_apple:
|
||||||
|
:path: ".symlinks/plugins/permission_handler_apple/ios"
|
||||||
|
share_plus:
|
||||||
|
:path: ".symlinks/plugins/share_plus/ios"
|
||||||
|
shared_preferences_foundation:
|
||||||
|
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
|
||||||
|
sqflite_darwin:
|
||||||
|
:path: ".symlinks/plugins/sqflite_darwin/darwin"
|
||||||
|
telephony:
|
||||||
|
:path: ".symlinks/plugins/telephony/ios"
|
||||||
|
url_launcher_ios:
|
||||||
|
:path: ".symlinks/plugins/url_launcher_ios/ios"
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
|
||||||
|
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
|
||||||
|
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
|
||||||
|
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
|
||||||
|
flutter_sms: 91ce41530f55c85d6524d82307a5d555844c086a
|
||||||
|
Google-Mobile-Ads-SDK: 58b4fda3f9758fc1ed210aa5cf7777b5eb55d47e
|
||||||
|
google_mobile_ads: 511febb4768edc860ee455a9e201ff52de385908
|
||||||
|
GoogleAppMeasurement: f9de05ee17401e3355f68e8fc8b5064d429f5918
|
||||||
|
GoogleUserMessagingPlatform: f8d0cdad3ca835406755d0a69aa634f00e76d576
|
||||||
|
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
|
||||||
|
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
|
||||||
|
nanopb: 438bc412db1928dac798aa6fd75726007be04262
|
||||||
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
|
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
|
||||||
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
|
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
|
||||||
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
|
telephony: c41768fae9fb5495781b05a72004106ca33ec777
|
||||||
|
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
|
||||||
|
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5
|
||||||
|
|
||||||
|
COCOAPODS: 1.16.2
|
||||||
@@ -10,6 +10,8 @@
|
|||||||
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
|
||||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||||
|
3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */; };
|
||||||
|
73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */; };
|
||||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||||
@@ -40,6 +42,8 @@
|
|||||||
/* End PBXCopyFilesBuildPhase section */
|
/* End PBXCopyFilesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
|
0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
|
||||||
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
|
||||||
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -55,19 +59,43 @@
|
|||||||
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||||
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
|
||||||
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
|
C8B5AAC4245FB9238AB6F925 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
DDC48A61CC3887158D51699F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
DEDD176B80E79E5674C841B0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
|
27474C77F8EBFE4B5468329B /* Frameworks */ = {
|
||||||
|
isa = PBXFrameworksBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
104722E6173DA3E706B6AF13 /* Frameworks */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */,
|
||||||
|
13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */,
|
||||||
|
);
|
||||||
|
name = Frameworks;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -94,6 +122,8 @@
|
|||||||
97C146F01CF9000F007C117D /* Runner */,
|
97C146F01CF9000F007C117D /* Runner */,
|
||||||
97C146EF1CF9000F007C117D /* Products */,
|
97C146EF1CF9000F007C117D /* Products */,
|
||||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||||
|
F0813F149E71664270D649A1 /* Pods */,
|
||||||
|
104722E6173DA3E706B6AF13 /* Frameworks */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -121,6 +151,20 @@
|
|||||||
path = Runner;
|
path = Runner;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
F0813F149E71664270D649A1 /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
C8B5AAC4245FB9238AB6F925 /* Pods-Runner.debug.xcconfig */,
|
||||||
|
DDC48A61CC3887158D51699F /* Pods-Runner.release.xcconfig */,
|
||||||
|
DEDD176B80E79E5674C841B0 /* Pods-Runner.profile.xcconfig */,
|
||||||
|
A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */,
|
||||||
|
0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
|
EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Pods;
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
/* End PBXGroup section */
|
/* End PBXGroup section */
|
||||||
|
|
||||||
/* Begin PBXNativeTarget section */
|
/* Begin PBXNativeTarget section */
|
||||||
@@ -128,8 +172,10 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */,
|
||||||
331C807D294A63A400263BE5 /* Sources */,
|
331C807D294A63A400263BE5 /* Sources */,
|
||||||
331C807F294A63A400263BE5 /* Resources */,
|
331C807F294A63A400263BE5 /* Resources */,
|
||||||
|
27474C77F8EBFE4B5468329B /* Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -145,12 +191,15 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */,
|
||||||
9740EEB61CF901F6004384FC /* Run Script */,
|
9740EEB61CF901F6004384FC /* Run Script */,
|
||||||
97C146EA1CF9000F007C117D /* Sources */,
|
97C146EA1CF9000F007C117D /* Sources */,
|
||||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||||
97C146EC1CF9000F007C117D /* Resources */,
|
97C146EC1CF9000F007C117D /* Resources */,
|
||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
|
45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */,
|
||||||
|
05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -222,6 +271,45 @@
|
|||||||
/* End PBXResourcesBuildPhase section */
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXShellScriptBuildPhase section */
|
/* Begin PBXShellScriptBuildPhase section */
|
||||||
|
05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Copy Pods Resources";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -238,6 +326,23 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||||
};
|
};
|
||||||
|
45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
@@ -253,6 +358,28 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||||
};
|
};
|
||||||
|
EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -378,6 +505,7 @@
|
|||||||
};
|
};
|
||||||
331C8088294A63A400263BE5 /* Debug */ = {
|
331C8088294A63A400263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -395,6 +523,7 @@
|
|||||||
};
|
};
|
||||||
331C8089294A63A400263BE5 /* Release */ = {
|
331C8089294A63A400263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
@@ -410,6 +539,7 @@
|
|||||||
};
|
};
|
||||||
331C808A294A63A400263BE5 /* Profile */ = {
|
331C808A294A63A400263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
|
|||||||
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
@@ -4,4 +4,7 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
@@ -1,27 +1,28 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:hive_flutter/hive_flutter.dart';
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'models/subscription_model.dart';
|
import 'models/subscription_model.dart';
|
||||||
import 'models/category_model.dart';
|
import 'models/category_model.dart';
|
||||||
import 'providers/subscription_provider.dart';
|
import 'providers/subscription_provider.dart';
|
||||||
import 'providers/app_lock_provider.dart';
|
import 'providers/app_lock_provider.dart';
|
||||||
import 'providers/notification_provider.dart';
|
import 'providers/notification_provider.dart';
|
||||||
import 'screens/main_screen.dart';
|
import 'providers/navigation_provider.dart';
|
||||||
import 'screens/app_lock_screen.dart';
|
|
||||||
import 'services/notification_service.dart';
|
import 'services/notification_service.dart';
|
||||||
import 'providers/category_provider.dart';
|
import 'providers/category_provider.dart';
|
||||||
import 'providers/locale_provider.dart';
|
import 'providers/locale_provider.dart';
|
||||||
|
import 'providers/theme_provider.dart';
|
||||||
import 'l10n/app_localizations.dart';
|
import 'l10n/app_localizations.dart';
|
||||||
import 'theme/app_theme.dart';
|
import 'theme/adaptive_theme.dart';
|
||||||
import 'screens/splash_screen.dart';
|
import 'routes/app_routes.dart';
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'navigation/app_navigation_observer.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
import 'utils/memory_manager.dart';
|
||||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
import 'utils/performance_optimizer.dart';
|
||||||
|
import 'navigator_key.dart';
|
||||||
|
|
||||||
Future<void> main() async {
|
Future<void> main() async {
|
||||||
WidgetsFlutterBinding.ensureInitialized();
|
WidgetsFlutterBinding.ensureInitialized();
|
||||||
@@ -31,17 +32,25 @@ Future<void> main() async {
|
|||||||
await MobileAds.instance.initialize();
|
await MobileAds.instance.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 성능 최적화 설정
|
||||||
|
MemoryManager.optimizeImageCache();
|
||||||
|
MemoryManager().startAutoCleanup();
|
||||||
|
|
||||||
// 앱 시작 시 이미지 캐시 관리
|
// 앱 시작 시 이미지 캐시 관리
|
||||||
try {
|
try {
|
||||||
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
||||||
final cache = PaintingBinding.instance.imageCache;
|
|
||||||
|
|
||||||
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
|
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
|
||||||
await DefaultCacheManager().emptyCache();
|
await DefaultCacheManager().emptyCache();
|
||||||
|
|
||||||
print('이미지 캐시 관리 초기화 완료');
|
if (kDebugMode) {
|
||||||
|
print('이미지 캐시 관리 초기화 완료');
|
||||||
|
PerformanceOptimizer.checkConstOptimization();
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
print('캐시 초기화 오류: $e');
|
if (kDebugMode) {
|
||||||
|
print('캐시 초기화 오류: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hive 초기화
|
// Hive 초기화
|
||||||
@@ -58,11 +67,14 @@ Future<void> main() async {
|
|||||||
final categoryProvider = CategoryProvider();
|
final categoryProvider = CategoryProvider();
|
||||||
final localeProvider = LocaleProvider();
|
final localeProvider = LocaleProvider();
|
||||||
final notificationProvider = NotificationProvider();
|
final notificationProvider = NotificationProvider();
|
||||||
|
final themeProvider = ThemeProvider();
|
||||||
|
final navigationProvider = NavigationProvider();
|
||||||
|
|
||||||
await subscriptionProvider.init();
|
await subscriptionProvider.init();
|
||||||
await categoryProvider.init();
|
await categoryProvider.init();
|
||||||
await localeProvider.init();
|
await localeProvider.init();
|
||||||
await notificationProvider.init();
|
await notificationProvider.init();
|
||||||
|
await themeProvider.initialize();
|
||||||
|
|
||||||
// NotificationProvider에 SubscriptionProvider 연결 (알림 재예약용)
|
// NotificationProvider에 SubscriptionProvider 연결 (알림 재예약용)
|
||||||
// SRP 원칙에 따라 다른 Provider 객체를 명시적으로 주입
|
// SRP 원칙에 따라 다른 Provider 객체를 명시적으로 주입
|
||||||
@@ -89,6 +101,8 @@ Future<void> main() async {
|
|||||||
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
|
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
|
||||||
ChangeNotifierProvider(create: (_) => notificationProvider),
|
ChangeNotifierProvider(create: (_) => notificationProvider),
|
||||||
ChangeNotifierProvider(create: (_) => localeProvider),
|
ChangeNotifierProvider(create: (_) => localeProvider),
|
||||||
|
ChangeNotifierProvider(create: (_) => themeProvider),
|
||||||
|
ChangeNotifierProvider(create: (_) => navigationProvider),
|
||||||
],
|
],
|
||||||
child: const SubManagerApp(),
|
child: const SubManagerApp(),
|
||||||
),
|
),
|
||||||
@@ -100,12 +114,15 @@ class SubManagerApp extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Consumer<LocaleProvider>(
|
return Consumer2<LocaleProvider, ThemeProvider>(
|
||||||
builder: (context, localeProvider, child) {
|
builder: (context, localeProvider, themeProvider, child) {
|
||||||
|
// 시스템 UI 오버레이 스타일 적용
|
||||||
|
AdaptiveTheme.applySystemUIOverlay(context);
|
||||||
|
|
||||||
return MaterialApp(
|
return MaterialApp(
|
||||||
title: 'SubManager',
|
title: 'SubManager',
|
||||||
debugShowCheckedModeBanner: false,
|
debugShowCheckedModeBanner: false,
|
||||||
theme: AppTheme.lightTheme,
|
theme: themeProvider.getTheme(context),
|
||||||
locale: localeProvider.locale,
|
locale: localeProvider.locale,
|
||||||
localizationsDelegates: const [
|
localizationsDelegates: const [
|
||||||
AppLocalizationsDelegate(),
|
AppLocalizationsDelegate(),
|
||||||
@@ -118,7 +135,24 @@ class SubManagerApp extends StatelessWidget {
|
|||||||
Locale('ko'),
|
Locale('ko'),
|
||||||
],
|
],
|
||||||
navigatorKey: navigatorKey,
|
navigatorKey: navigatorKey,
|
||||||
home: const SplashScreen(),
|
navigatorObservers: [AppNavigationObserver()],
|
||||||
|
initialRoute: AppRoutes.splash,
|
||||||
|
routes: AppRoutes.getRoutes(),
|
||||||
|
onGenerateRoute: AppRoutes.generateRoute,
|
||||||
|
builder: (context, child) {
|
||||||
|
// 성능 최적화 및 메모리 관리
|
||||||
|
if (kDebugMode) {
|
||||||
|
PerformanceOptimizer().startFrameMonitoring();
|
||||||
|
}
|
||||||
|
|
||||||
|
return MediaQuery(
|
||||||
|
data: MediaQuery.of(context).copyWith(
|
||||||
|
textScaler: TextScaler.linear(themeProvider.largeText ? 1.2 : 1.0),
|
||||||
|
disableAnimations: themeProvider.reduceMotion,
|
||||||
|
),
|
||||||
|
child: child!,
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -95,6 +95,9 @@ class SubscriptionModel extends HiveObject {
|
|||||||
}
|
}
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 원래 가격 (이벤트와 관계없이 항상 정상 가격)
|
||||||
|
double get originalPrice => monthlyCost;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hive TypeAdapter 생성을 위한 명령어
|
// Hive TypeAdapter 생성을 위한 명령어
|
||||||
|
|||||||
79
lib/navigation/app_navigation_observer.dart
Normal file
79
lib/navigation/app_navigation_observer.dart
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
|
|
||||||
|
class AppNavigationObserver extends NavigatorObserver {
|
||||||
|
@override
|
||||||
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
super.didPush(route, previousRoute);
|
||||||
|
_updateNavigationState(route);
|
||||||
|
debugPrint('Navigation: Push ${route.settings.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
super.didPop(route, previousRoute);
|
||||||
|
if (previousRoute != null) {
|
||||||
|
_updateNavigationState(previousRoute);
|
||||||
|
} else {
|
||||||
|
// 이전 라우트가 없으면 Provider의 히스토리를 사용
|
||||||
|
_handlePopWithProvider();
|
||||||
|
}
|
||||||
|
debugPrint('Navigation: Pop ${route.settings.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
super.didRemove(route, previousRoute);
|
||||||
|
if (previousRoute != null) {
|
||||||
|
_updateNavigationState(previousRoute);
|
||||||
|
}
|
||||||
|
debugPrint('Navigation: Remove ${route.settings.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||||
|
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||||
|
if (newRoute != null) {
|
||||||
|
_updateNavigationState(newRoute);
|
||||||
|
}
|
||||||
|
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateNavigationState(Route<dynamic> route) {
|
||||||
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
|
final routeName = route.settings.name;
|
||||||
|
if (routeName == null) return;
|
||||||
|
|
||||||
|
// build 완료 후 업데이트하도록 변경
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final context = navigator!.context;
|
||||||
|
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||||
|
navigationProvider.updateByRoute(routeName);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to update navigation state: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handlePopWithProvider() {
|
||||||
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
|
// build 완료 후 업데이트하도록 변경
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (navigator?.context == null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final context = navigator!.context;
|
||||||
|
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||||
|
navigationProvider.pop();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('Failed to handle pop with provider: $e');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/providers/navigation_provider.dart
Normal file
106
lib/providers/navigation_provider.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class NavigationProvider extends ChangeNotifier {
|
||||||
|
int _currentIndex = 0;
|
||||||
|
final List<int> _navigationHistory = [0];
|
||||||
|
String _currentRoute = '/';
|
||||||
|
String _currentTitle = '홈';
|
||||||
|
|
||||||
|
int get currentIndex => _currentIndex;
|
||||||
|
List<int> get navigationHistory => List.unmodifiable(_navigationHistory);
|
||||||
|
String get currentRoute => _currentRoute;
|
||||||
|
String get currentTitle => _currentTitle;
|
||||||
|
|
||||||
|
static const Map<String, int> routeToIndex = {
|
||||||
|
'/': 0,
|
||||||
|
'/add-subscription': -1,
|
||||||
|
'/sms-scanner': 3,
|
||||||
|
'/analysis': 1,
|
||||||
|
'/settings': 4,
|
||||||
|
'/subscription-detail': -1,
|
||||||
|
};
|
||||||
|
|
||||||
|
static const Map<int, String> indexToRoute = {
|
||||||
|
0: '/',
|
||||||
|
1: '/analysis',
|
||||||
|
3: '/sms-scanner',
|
||||||
|
4: '/settings',
|
||||||
|
};
|
||||||
|
|
||||||
|
static const Map<int, String> indexToTitle = {
|
||||||
|
0: '홈',
|
||||||
|
1: '분석',
|
||||||
|
3: 'SMS 스캔',
|
||||||
|
4: '설정',
|
||||||
|
};
|
||||||
|
|
||||||
|
void updateCurrentIndex(int index, {bool addToHistory = true}) {
|
||||||
|
if (_currentIndex == index) return;
|
||||||
|
|
||||||
|
_currentIndex = index;
|
||||||
|
_currentRoute = indexToRoute[index] ?? '/';
|
||||||
|
_currentTitle = indexToTitle[index] ?? '홈';
|
||||||
|
|
||||||
|
if (addToHistory && index >= 0) {
|
||||||
|
_navigationHistory.add(index);
|
||||||
|
if (_navigationHistory.length > 10) {
|
||||||
|
_navigationHistory.removeAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateByRoute(String route) {
|
||||||
|
final index = routeToIndex[route] ?? 0;
|
||||||
|
_currentRoute = route;
|
||||||
|
|
||||||
|
if (index >= 0) {
|
||||||
|
_currentIndex = index;
|
||||||
|
_currentTitle = indexToTitle[index] ?? '홈';
|
||||||
|
} else {
|
||||||
|
switch (route) {
|
||||||
|
case '/add-subscription':
|
||||||
|
_currentTitle = '구독 추가';
|
||||||
|
break;
|
||||||
|
case '/subscription-detail':
|
||||||
|
_currentTitle = '구독 상세';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
_currentTitle = '홈';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool canPop() {
|
||||||
|
return _navigationHistory.length > 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
void pop() {
|
||||||
|
if (_navigationHistory.length > 1) {
|
||||||
|
_navigationHistory.removeLast();
|
||||||
|
final previousIndex = _navigationHistory.last;
|
||||||
|
updateCurrentIndex(previousIndex, addToHistory: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void reset() {
|
||||||
|
_currentIndex = 0;
|
||||||
|
_currentRoute = '/';
|
||||||
|
_currentTitle = '홈';
|
||||||
|
_navigationHistory.clear();
|
||||||
|
_navigationHistory.add(0);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void clearHistoryAndGoHome() {
|
||||||
|
_currentIndex = 0;
|
||||||
|
_currentRoute = '/';
|
||||||
|
_currentTitle = '홈';
|
||||||
|
_navigationHistory.clear();
|
||||||
|
_navigationHistory.add(0);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -243,4 +243,83 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
await refreshSubscriptions();
|
await refreshSubscriptions();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 총 월간 지출을 계산합니다.
|
||||||
|
Future<double> calculateTotalExpense() async {
|
||||||
|
// 이미 존재하는 totalMonthlyExpense getter를 사용
|
||||||
|
return totalMonthlyExpense;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 최근 6개월의 월별 지출 데이터를 반환합니다.
|
||||||
|
Future<List<Map<String, dynamic>>> getMonthlyExpenseData() async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final List<Map<String, dynamic>> monthlyData = [];
|
||||||
|
|
||||||
|
// 최근 6개월 데이터 생성
|
||||||
|
for (int i = 5; i >= 0; i--) {
|
||||||
|
final month = DateTime(now.year, now.month - i, 1);
|
||||||
|
double monthTotal = 0.0;
|
||||||
|
|
||||||
|
// 해당 월에 활성화된 구독 계산
|
||||||
|
for (final subscription in _subscriptions) {
|
||||||
|
// 구독이 해당 월에 활성화되어 있었는지 확인
|
||||||
|
final subscriptionStartDate = subscription.nextBillingDate.subtract(
|
||||||
|
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
|
||||||
|
subscription.nextBillingDate.isAfter(month)) {
|
||||||
|
// 해당 월의 비용 계산 (이벤트 가격 고려)
|
||||||
|
if (subscription.isEventActive &&
|
||||||
|
subscription.eventStartDate != null &&
|
||||||
|
subscription.eventEndDate != null &&
|
||||||
|
month.isAfter(subscription.eventStartDate!) &&
|
||||||
|
month.isBefore(subscription.eventEndDate!)) {
|
||||||
|
monthTotal += subscription.eventPrice ?? subscription.monthlyCost;
|
||||||
|
} else {
|
||||||
|
monthTotal += subscription.monthlyCost;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
monthlyData.add({
|
||||||
|
'month': month,
|
||||||
|
'totalExpense': monthTotal,
|
||||||
|
'monthName': _getMonthLabel(month),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return monthlyData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이벤트로 인한 총 절약액을 계산합니다.
|
||||||
|
double calculateTotalSavings() {
|
||||||
|
// 이미 존재하는 totalEventSavings getter를 사용
|
||||||
|
return totalEventSavings;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 결제 주기를 일 단위로 변환합니다.
|
||||||
|
int _getBillingCycleDays(String billingCycle) {
|
||||||
|
switch (billingCycle) {
|
||||||
|
case 'monthly':
|
||||||
|
return 30;
|
||||||
|
case 'yearly':
|
||||||
|
return 365;
|
||||||
|
case 'weekly':
|
||||||
|
return 7;
|
||||||
|
case 'quarterly':
|
||||||
|
return 90;
|
||||||
|
default:
|
||||||
|
return 30;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 월 라벨을 생성합니다.
|
||||||
|
String _getMonthLabel(DateTime month) {
|
||||||
|
final months = [
|
||||||
|
'1월', '2월', '3월', '4월', '5월', '6월',
|
||||||
|
'7월', '8월', '9월', '10월', '11월', '12월'
|
||||||
|
];
|
||||||
|
return months[month.month - 1];
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
186
lib/providers/theme_provider.dart
Normal file
186
lib/providers/theme_provider.dart
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../theme/adaptive_theme.dart';
|
||||||
|
|
||||||
|
/// 테마 관리 Provider
|
||||||
|
class ThemeProvider extends ChangeNotifier {
|
||||||
|
static const String _themeBoxName = 'theme_settings';
|
||||||
|
static const String _themeKey = 'theme_settings';
|
||||||
|
|
||||||
|
late Box<Map> _themeBox;
|
||||||
|
ThemeSettings _themeSettings = const ThemeSettings();
|
||||||
|
|
||||||
|
ThemeSettings get themeSettings => _themeSettings;
|
||||||
|
|
||||||
|
AppThemeMode get themeMode => _themeSettings.mode;
|
||||||
|
bool get useSystemColors => _themeSettings.useSystemColors;
|
||||||
|
bool get largeText => _themeSettings.largeText;
|
||||||
|
bool get reduceMotion => _themeSettings.reduceMotion;
|
||||||
|
bool get highContrast => _themeSettings.highContrast;
|
||||||
|
|
||||||
|
/// Provider 초기화
|
||||||
|
Future<void> initialize() async {
|
||||||
|
_themeBox = await Hive.openBox<Map>(_themeBoxName);
|
||||||
|
await _loadThemeSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 저장된 테마 설정 로드
|
||||||
|
Future<void> _loadThemeSettings() async {
|
||||||
|
final savedSettings = _themeBox.get(_themeKey);
|
||||||
|
if (savedSettings != null) {
|
||||||
|
_themeSettings = ThemeSettings.fromJson(
|
||||||
|
Map<String, dynamic>.from(savedSettings),
|
||||||
|
);
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 설정 저장
|
||||||
|
Future<void> _saveThemeSettings() async {
|
||||||
|
await _themeBox.put(_themeKey, _themeSettings.toJson());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 모드 변경
|
||||||
|
Future<void> setThemeMode(AppThemeMode mode) async {
|
||||||
|
_themeSettings = _themeSettings.copyWith(mode: mode);
|
||||||
|
await _saveThemeSettings();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시스템 색상 사용 설정
|
||||||
|
Future<void> setUseSystemColors(bool value) async {
|
||||||
|
_themeSettings = _themeSettings.copyWith(useSystemColors: value);
|
||||||
|
await _saveThemeSettings();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 큰 텍스트 설정
|
||||||
|
Future<void> setLargeText(bool value) async {
|
||||||
|
_themeSettings = _themeSettings.copyWith(largeText: value);
|
||||||
|
await _saveThemeSettings();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 모션 감소 설정
|
||||||
|
Future<void> setReduceMotion(bool value) async {
|
||||||
|
_themeSettings = _themeSettings.copyWith(reduceMotion: value);
|
||||||
|
await _saveThemeSettings();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 고대비 설정
|
||||||
|
Future<void> setHighContrast(bool value) async {
|
||||||
|
_themeSettings = _themeSettings.copyWith(highContrast: value);
|
||||||
|
await _saveThemeSettings();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 현재 설정에 따른 테마 가져오기
|
||||||
|
ThemeData getTheme(BuildContext context) {
|
||||||
|
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
||||||
|
|
||||||
|
ThemeData baseTheme;
|
||||||
|
|
||||||
|
switch (_themeSettings.mode) {
|
||||||
|
case AppThemeMode.light:
|
||||||
|
baseTheme = AdaptiveTheme.lightTheme;
|
||||||
|
break;
|
||||||
|
case AppThemeMode.dark:
|
||||||
|
baseTheme = AdaptiveTheme.darkTheme;
|
||||||
|
break;
|
||||||
|
case AppThemeMode.oled:
|
||||||
|
baseTheme = AdaptiveTheme.oledTheme;
|
||||||
|
break;
|
||||||
|
case AppThemeMode.system:
|
||||||
|
baseTheme = platformBrightness == Brightness.dark
|
||||||
|
? AdaptiveTheme.darkTheme
|
||||||
|
: AdaptiveTheme.lightTheme;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 접근성 설정 적용
|
||||||
|
return AdaptiveTheme.getAccessibleTheme(
|
||||||
|
baseTheme,
|
||||||
|
largeText: _themeSettings.largeText,
|
||||||
|
reduceMotion: _themeSettings.reduceMotion,
|
||||||
|
highContrast: _themeSettings.highContrast,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 현재 테마가 다크 모드인지 확인
|
||||||
|
bool isDarkMode(BuildContext context) {
|
||||||
|
final platformBrightness = MediaQuery.of(context).platformBrightness;
|
||||||
|
|
||||||
|
switch (_themeSettings.mode) {
|
||||||
|
case AppThemeMode.light:
|
||||||
|
return false;
|
||||||
|
case AppThemeMode.dark:
|
||||||
|
case AppThemeMode.oled:
|
||||||
|
return true;
|
||||||
|
case AppThemeMode.system:
|
||||||
|
return platformBrightness == Brightness.dark;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 토글 (라이트/다크)
|
||||||
|
Future<void> toggleTheme() async {
|
||||||
|
if (_themeSettings.mode == AppThemeMode.light) {
|
||||||
|
await setThemeMode(AppThemeMode.dark);
|
||||||
|
} else {
|
||||||
|
await setThemeMode(AppThemeMode.light);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 전환 애니메이션 위젯
|
||||||
|
class AnimatedThemeBuilder extends StatelessWidget {
|
||||||
|
final Widget Function(BuildContext, ThemeData) builder;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const AnimatedThemeBuilder({
|
||||||
|
super.key,
|
||||||
|
required this.builder,
|
||||||
|
this.duration = const Duration(milliseconds: 300),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final themeProvider = context.watch<ThemeProvider>();
|
||||||
|
final theme = themeProvider.getTheme(context);
|
||||||
|
|
||||||
|
return AnimatedTheme(
|
||||||
|
data: theme,
|
||||||
|
duration: duration,
|
||||||
|
child: Builder(
|
||||||
|
builder: (context) => builder(context, theme),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마별 색상 위젯
|
||||||
|
class ThemedColor extends StatelessWidget {
|
||||||
|
final Color lightColor;
|
||||||
|
final Color darkColor;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ThemedColor({
|
||||||
|
super.key,
|
||||||
|
required this.lightColor,
|
||||||
|
required this.darkColor,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDark = context.read<ThemeProvider>().isDarkMode(context);
|
||||||
|
|
||||||
|
return Theme(
|
||||||
|
data: Theme.of(context).copyWith(
|
||||||
|
primaryColor: isDark ? darkColor : lightColor,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
106
lib/routes/app_routes.dart
Normal file
106
lib/routes/app_routes.dart
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:submanager/screens/main_screen.dart';
|
||||||
|
import 'package:submanager/screens/add_subscription_screen.dart';
|
||||||
|
import 'package:submanager/screens/detail_screen.dart';
|
||||||
|
import 'package:submanager/screens/sms_scan_screen.dart';
|
||||||
|
import 'package:submanager/screens/analysis_screen.dart';
|
||||||
|
import 'package:submanager/screens/settings_screen.dart';
|
||||||
|
import 'package:submanager/screens/splash_screen.dart';
|
||||||
|
import 'package:submanager/models/subscription_model.dart';
|
||||||
|
|
||||||
|
class AppRoutes {
|
||||||
|
static const String splash = '/splash';
|
||||||
|
static const String main = '/';
|
||||||
|
static const String addSubscription = '/add-subscription';
|
||||||
|
static const String subscriptionDetail = '/subscription-detail';
|
||||||
|
static const String smsScanner = '/sms-scanner';
|
||||||
|
static const String analysis = '/analysis';
|
||||||
|
static const String settings = '/settings';
|
||||||
|
|
||||||
|
static Map<String, WidgetBuilder> getRoutes() {
|
||||||
|
return {
|
||||||
|
splash: (context) => const SplashScreen(),
|
||||||
|
main: (context) => const MainScreen(),
|
||||||
|
addSubscription: (context) => const AddSubscriptionScreen(),
|
||||||
|
smsScanner: (context) => const SmsScanScreen(),
|
||||||
|
analysis: (context) => const AnalysisScreen(),
|
||||||
|
settings: (context) => const SettingsScreen(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
static Route<dynamic> generateRoute(RouteSettings routeSettings) {
|
||||||
|
switch (routeSettings.name) {
|
||||||
|
case splash:
|
||||||
|
return _buildRoute(const SplashScreen(), routeSettings);
|
||||||
|
|
||||||
|
case main:
|
||||||
|
return _buildRoute(const MainScreen(), routeSettings);
|
||||||
|
|
||||||
|
case addSubscription:
|
||||||
|
return _buildRoute(const AddSubscriptionScreen(), routeSettings);
|
||||||
|
|
||||||
|
case subscriptionDetail:
|
||||||
|
final subscription = routeSettings.arguments as SubscriptionModel?;
|
||||||
|
if (subscription != null) {
|
||||||
|
return _buildRoute(DetailScreen(subscription: subscription), routeSettings);
|
||||||
|
}
|
||||||
|
return _errorRoute();
|
||||||
|
|
||||||
|
case smsScanner:
|
||||||
|
return _buildRoute(const SmsScanScreen(), routeSettings);
|
||||||
|
|
||||||
|
case analysis:
|
||||||
|
return _buildRoute(const AnalysisScreen(), routeSettings);
|
||||||
|
|
||||||
|
case settings:
|
||||||
|
return _buildRoute(const SettingsScreen(), routeSettings);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return _errorRoute();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static Route<dynamic> _buildRoute(Widget page, RouteSettings settings) {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (_) => page,
|
||||||
|
settings: settings,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static Route<dynamic> _errorRoute() {
|
||||||
|
return MaterialPageRoute(
|
||||||
|
builder: (_) => const Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: Text('페이지를 찾을 수 없습니다'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void navigateTo(BuildContext context, String routeName, {Object? arguments}) {
|
||||||
|
Navigator.pushNamed(context, routeName, arguments: arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) {
|
||||||
|
Navigator.pushReplacementNamed(context, routeName, arguments: arguments);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) {
|
||||||
|
Navigator.pushNamedAndRemoveUntil(
|
||||||
|
context,
|
||||||
|
routeName,
|
||||||
|
(route) => false,
|
||||||
|
arguments: arguments,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static void pop(BuildContext context, {dynamic result}) {
|
||||||
|
if (Navigator.canPop(context)) {
|
||||||
|
Navigator.pop(context, result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool canPop(BuildContext context) {
|
||||||
|
return Navigator.canPop(context);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,6 @@ import 'dart:math' as math;
|
|||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import '../providers/category_provider.dart';
|
import '../providers/category_provider.dart';
|
||||||
import '../models/category_model.dart';
|
|
||||||
import '../services/sms_service.dart';
|
import '../services/sms_service.dart';
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../services/subscription_url_matcher.dart';
|
||||||
import '../services/exchange_rate_service.dart';
|
import '../services/exchange_rate_service.dart';
|
||||||
@@ -495,7 +494,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context, true); // 성공 여부 반환
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setState(() {
|
setState(() {
|
||||||
@@ -536,11 +535,11 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(appBarOpacity),
|
color: Colors.white.withValues(alpha: appBarOpacity),
|
||||||
boxShadow: appBarOpacity > 0.6
|
boxShadow: appBarOpacity > 0.6
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.1 * appBarOpacity),
|
color: Colors.black.withValues(alpha: 0.1 * appBarOpacity),
|
||||||
spreadRadius: 1,
|
spreadRadius: 1,
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
@@ -561,7 +560,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
shadows: appBarOpacity > 0.6
|
shadows: appBarOpacity > 0.6
|
||||||
? [
|
? [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: Colors.black.withOpacity(0.2),
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
offset: const Offset(0, 1),
|
offset: const Offset(0, 1),
|
||||||
blurRadius: 2,
|
blurRadius: 2,
|
||||||
)
|
)
|
||||||
@@ -626,7 +625,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: _gradientColors[0].withOpacity(0.3),
|
color: _gradientColors[0].withValues(alpha: 0.3),
|
||||||
blurRadius: 20,
|
blurRadius: 20,
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
@@ -638,7 +637,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.2),
|
color: Colors.white.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -741,7 +740,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 0
|
color: _currentEditingField == 0
|
||||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -786,7 +785,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -821,7 +820,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 1
|
color: _currentEditingField == 1
|
||||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -922,7 +921,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color:
|
color:
|
||||||
Colors.grey.withOpacity(0.2),
|
Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -979,7 +978,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _currentEditingField == 1
|
color: _currentEditingField == 1
|
||||||
? const Color(0xFF3B82F6)
|
? const Color(0xFF3B82F6)
|
||||||
: Colors.grey.withOpacity(
|
: Colors.grey.withValues(alpha:
|
||||||
0.4), // 포커스 없을 때 더 진한 회색
|
0.4), // 포커스 없을 때 더 진한 회색
|
||||||
width: _currentEditingField == 1
|
width: _currentEditingField == 1
|
||||||
? 2
|
? 2
|
||||||
@@ -997,7 +996,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: Border(
|
border: Border(
|
||||||
right: BorderSide(
|
right: BorderSide(
|
||||||
color: Colors.grey
|
color: Colors.grey
|
||||||
.withOpacity(0.2),
|
.withValues(alpha: 0.2),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1248,7 +1247,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 2
|
color: _currentEditingField == 2
|
||||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1285,7 +1284,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1336,7 +1335,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 3
|
color: _currentEditingField == 3
|
||||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1397,7 +1396,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _nextBillingDate == null
|
color: _nextBillingDate == null
|
||||||
? Colors.red
|
? Colors.red
|
||||||
: Colors.grey.withOpacity(0.2),
|
: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -1437,7 +1436,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 4
|
color: _currentEditingField == 4
|
||||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1476,7 +1475,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1504,7 +1503,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 5
|
color: _currentEditingField == 5
|
||||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1538,7 +1537,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color:
|
color:
|
||||||
Colors.grey.withOpacity(0.2),
|
Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
@@ -1598,7 +1597,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1667,7 +1666,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _isEventActive
|
color: _isEventActive
|
||||||
? const Color(0xFF3B82F6)
|
? const Color(0xFF3B82F6)
|
||||||
: Colors.grey.withOpacity(0.2),
|
: Colors.grey.withValues(alpha: 0.2),
|
||||||
width: _isEventActive ? 2 : 1,
|
width: _isEventActive ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1761,7 +1760,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1825,7 +1824,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -1889,7 +1888,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1967,15 +1966,15 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
|||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
backgroundColor: const Color(0xFF3B82F6),
|
backgroundColor: const Color(0xFF3B82F6),
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
disabledBackgroundColor: Colors.grey.withOpacity(0.3),
|
disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||||
disabledForegroundColor:
|
disabledForegroundColor:
|
||||||
Colors.white.withOpacity(0.5),
|
Colors.white.withValues(alpha: 0.5),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
elevation: _isSaveHovered ? 8 : 4,
|
elevation: _isSaveHovered ? 8 : 4,
|
||||||
shadowColor: const Color(0xFF3B82F6).withOpacity(0.5),
|
shadowColor: const Color(0xFF3B82F6).withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
child: _isLoading
|
child: _isLoading
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -385,7 +385,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
return LinearGradient(
|
return LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
baseColor,
|
baseColor,
|
||||||
baseColor.withOpacity(0.7),
|
baseColor.withValues(alpha: 0.7),
|
||||||
],
|
],
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
@@ -628,11 +628,11 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
preferredSize: const Size.fromHeight(60),
|
preferredSize: const Size.fromHeight(60),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(appBarOpacity),
|
color: Colors.white.withValues(alpha: appBarOpacity),
|
||||||
boxShadow: appBarOpacity > 0.6
|
boxShadow: appBarOpacity > 0.6
|
||||||
? [
|
? [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.1 * appBarOpacity),
|
color: Colors.black.withValues(alpha: 0.1 * appBarOpacity),
|
||||||
spreadRadius: 1,
|
spreadRadius: 1,
|
||||||
blurRadius: 8,
|
blurRadius: 8,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
@@ -653,7 +653,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
shadows: appBarOpacity > 0.6
|
shadows: appBarOpacity > 0.6
|
||||||
? [
|
? [
|
||||||
Shadow(
|
Shadow(
|
||||||
color: Colors.black.withOpacity(0.2),
|
color: Colors.black.withValues(alpha: 0.2),
|
||||||
offset: const Offset(0, 1),
|
offset: const Offset(0, 1),
|
||||||
blurRadius: 2,
|
blurRadius: 2,
|
||||||
)
|
)
|
||||||
@@ -746,7 +746,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
tag: 'subscription_${widget.subscription.id}',
|
tag: 'subscription_${widget.subscription.id}',
|
||||||
child: Card(
|
child: Card(
|
||||||
elevation: 8,
|
elevation: 8,
|
||||||
shadowColor: baseColor.withOpacity(0.4),
|
shadowColor: baseColor.withValues(alpha: 0.4),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
),
|
),
|
||||||
@@ -760,7 +760,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [
|
colors: [
|
||||||
baseColor.withOpacity(0.8),
|
baseColor.withValues(alpha: 0.8),
|
||||||
baseColor,
|
baseColor,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -787,7 +787,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black
|
color: Colors.black
|
||||||
.withOpacity(0.1),
|
.withValues(alpha: 0.1),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
),
|
),
|
||||||
@@ -834,7 +834,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color:
|
color:
|
||||||
Colors.white.withOpacity(0.8),
|
Colors.white.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -846,7 +846,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.15),
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -863,7 +863,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color:
|
color:
|
||||||
Colors.white.withOpacity(0.8),
|
Colors.white.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -889,7 +889,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color:
|
color:
|
||||||
Colors.white.withOpacity(0.8),
|
Colors.white.withValues(alpha: 0.8),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -924,10 +924,10 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFDC2626)
|
color: const Color(0xFFDC2626)
|
||||||
.withOpacity(0.2),
|
.withValues(alpha: 0.2),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.white.withOpacity(0.3),
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1015,7 +1015,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 0
|
color: _currentEditingField == 0
|
||||||
? baseColor.withOpacity(0.1)
|
? baseColor.withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1053,7 +1053,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1080,7 +1080,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 1
|
color: _currentEditingField == 1
|
||||||
? baseColor.withOpacity(0.1)
|
? baseColor.withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1181,7 +1181,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color:
|
color:
|
||||||
Colors.grey.withOpacity(0.2),
|
Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1238,7 +1238,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _currentEditingField == 1
|
color: _currentEditingField == 1
|
||||||
? baseColor
|
? baseColor
|
||||||
: Colors.grey.withOpacity(
|
: Colors.grey.withValues(alpha:
|
||||||
0.4), // 포커스 없을 때 더 진한 회색
|
0.4), // 포커스 없을 때 더 진한 회색
|
||||||
width: _currentEditingField == 1
|
width: _currentEditingField == 1
|
||||||
? 2
|
? 2
|
||||||
@@ -1256,7 +1256,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
border: Border(
|
border: Border(
|
||||||
right: BorderSide(
|
right: BorderSide(
|
||||||
color: Colors.grey
|
color: Colors.grey
|
||||||
.withOpacity(0.2),
|
.withValues(alpha: 0.2),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1508,7 +1508,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 2
|
color: _currentEditingField == 2
|
||||||
? baseColor.withOpacity(0.1)
|
? baseColor.withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1545,7 +1545,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1584,7 +1584,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 3
|
color: _currentEditingField == 3
|
||||||
? baseColor.withOpacity(0.1)
|
? baseColor.withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1642,7 +1642,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -1678,7 +1678,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 4
|
color: _currentEditingField == 4
|
||||||
? baseColor.withOpacity(0.1)
|
? baseColor.withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1716,7 +1716,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1748,7 +1748,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
color: _currentEditingField == 5
|
color: _currentEditingField == 5
|
||||||
? baseColor.withOpacity(0.1)
|
? baseColor.withValues(alpha: 0.1)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
padding: const EdgeInsets.all(8),
|
||||||
@@ -1776,7 +1776,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
@@ -1827,7 +1827,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -1900,7 +1900,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _isEventActive
|
color: _isEventActive
|
||||||
? baseColor
|
? baseColor
|
||||||
: Colors.grey.withOpacity(0.2),
|
: Colors.grey.withValues(alpha: 0.2),
|
||||||
width: _isEventActive ? 2 : 1,
|
width: _isEventActive ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1990,7 +1990,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -2054,7 +2054,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -2118,7 +2118,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: Colors.grey.withOpacity(0.2),
|
color: Colors.grey.withValues(alpha: 0.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
@@ -2196,7 +2196,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
elevation: _isSaveHovered ? 8 : 4,
|
elevation: _isSaveHovered ? 8 : 4,
|
||||||
shadowColor: baseColor.withOpacity(0.5),
|
shadowColor: baseColor.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
|||||||
@@ -1,28 +1,20 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
import 'package:intl/intl.dart';
|
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import '../providers/app_lock_provider.dart';
|
import '../providers/app_lock_provider.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../routes/app_routes.dart';
|
||||||
import '../models/subscription_model.dart';
|
|
||||||
import 'add_subscription_screen.dart';
|
|
||||||
import 'analysis_screen.dart';
|
import 'analysis_screen.dart';
|
||||||
import 'app_lock_screen.dart';
|
import 'app_lock_screen.dart';
|
||||||
import 'settings_screen.dart';
|
import 'settings_screen.dart';
|
||||||
import '../widgets/subscription_card.dart';
|
|
||||||
import '../widgets/skeleton_loading.dart';
|
|
||||||
import 'sms_scan_screen.dart';
|
import 'sms_scan_screen.dart';
|
||||||
import '../providers/category_provider.dart';
|
|
||||||
import '../utils/subscription_category_helper.dart';
|
|
||||||
import '../utils/animation_controller_helper.dart';
|
import '../utils/animation_controller_helper.dart';
|
||||||
import '../widgets/subscription_list_widget.dart';
|
import '../widgets/floating_navigation_bar.dart';
|
||||||
import '../widgets/main_summary_card.dart';
|
import '../widgets/glassmorphic_scaffold.dart';
|
||||||
import '../widgets/empty_state_widget.dart';
|
import '../widgets/glassmorphic_app_bar.dart';
|
||||||
import '../widgets/native_ad_widget.dart';
|
import '../widgets/home_content.dart';
|
||||||
|
|
||||||
class MainScreen extends StatefulWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
const MainScreen({super.key});
|
const MainScreen({super.key});
|
||||||
@@ -40,7 +32,11 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
late AnimationController _pulseController;
|
late AnimationController _pulseController;
|
||||||
late AnimationController _waveController;
|
late AnimationController _waveController;
|
||||||
late ScrollController _scrollController;
|
late ScrollController _scrollController;
|
||||||
double _scrollOffset = 0;
|
late FloatingNavBarScrollController _navBarScrollController;
|
||||||
|
bool _isNavBarVisible = true;
|
||||||
|
|
||||||
|
// 화면 목록
|
||||||
|
late final List<Widget> _screens;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -67,12 +63,30 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
waveController: _waveController,
|
waveController: _waveController,
|
||||||
);
|
);
|
||||||
|
|
||||||
_scrollController = ScrollController()
|
_scrollController = ScrollController();
|
||||||
..addListener(() {
|
|
||||||
setState(() {
|
_navBarScrollController = FloatingNavBarScrollController(
|
||||||
_scrollOffset = _scrollController.offset;
|
scrollController: _scrollController,
|
||||||
});
|
onHide: () => setState(() => _isNavBarVisible = false),
|
||||||
});
|
onShow: () => setState(() => _isNavBarVisible = true),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 화면 목록 초기화
|
||||||
|
_screens = [
|
||||||
|
HomeContent(
|
||||||
|
fadeController: _fadeController,
|
||||||
|
rotateController: _rotateController,
|
||||||
|
slideController: _slideController,
|
||||||
|
pulseController: _pulseController,
|
||||||
|
waveController: _waveController,
|
||||||
|
scrollController: _scrollController,
|
||||||
|
onAddPressed: () => _navigateToAddSubscription(context),
|
||||||
|
),
|
||||||
|
const AnalysisScreen(),
|
||||||
|
Container(), // 추가 버튼은 별도 처리
|
||||||
|
const SmsScanScreen(),
|
||||||
|
const SettingsScreen(),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -90,6 +104,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
);
|
);
|
||||||
|
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
|
_navBarScrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,307 +151,109 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToSmsScan(BuildContext context) async {
|
|
||||||
final added = await Navigator.push<bool>(
|
|
||||||
context,
|
|
||||||
PageRouteBuilder(
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
const SmsScanScreen(),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
||||||
return SlideTransition(
|
|
||||||
position: Tween<Offset>(
|
|
||||||
begin: const Offset(1, 0),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(animation),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (added == true && context.mounted) {
|
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
|
||||||
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
_resetAnimations();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateToAnalysis(BuildContext context) {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
PageRouteBuilder(
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
const AnalysisScreen(),
|
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
||||||
return SlideTransition(
|
|
||||||
position: Tween<Offset>(
|
|
||||||
begin: const Offset(1, 0),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(animation),
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _navigateToAddSubscription(BuildContext context) {
|
void _navigateToAddSubscription(BuildContext context) {
|
||||||
HapticFeedback.mediumImpact();
|
HapticFeedback.mediumImpact();
|
||||||
Navigator.of(context)
|
Navigator.pushNamed(
|
||||||
.push(
|
context,
|
||||||
PageRouteBuilder(
|
AppRoutes.addSubscription,
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
).then((result) {
|
||||||
const AddSubscriptionScreen(),
|
_resetAnimations();
|
||||||
transitionsBuilder:
|
|
||||||
(context, animation, secondaryAnimation, child) {
|
// 구독이 성공적으로 추가된 경우
|
||||||
return FadeTransition(
|
if (result == true) {
|
||||||
opacity: animation,
|
// 상단에 스낵바 표시
|
||||||
child: ScaleTransition(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
scale: Tween<double>(begin: 0.8, end: 1.0).animate(animation),
|
SnackBar(
|
||||||
child: child,
|
content: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
),
|
),
|
||||||
);
|
const SizedBox(width: 12),
|
||||||
},
|
const Text(
|
||||||
|
'구독이 추가되었습니다',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: const Color(0xFF10B981), // 초록색
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: EdgeInsets.only(
|
||||||
|
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
dismissDirection: DismissDirection.horizontal,
|
||||||
),
|
),
|
||||||
)
|
);
|
||||||
.then((_) => _resetAnimations());
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _navigateToSettings(BuildContext context) {
|
void _handleNavigation(int index, BuildContext context) {
|
||||||
Navigator.of(context).push(
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
PageRouteBuilder(
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
// 이미 같은 인덱스면 무시
|
||||||
const SettingsScreen(),
|
if (navigationProvider.currentIndex == index) {
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
return;
|
||||||
return SlideTransition(
|
}
|
||||||
position: Tween<Offset>(
|
|
||||||
begin: const Offset(1, 0),
|
// 추가 버튼은 별도 처리
|
||||||
end: Offset.zero,
|
if (index == 2) {
|
||||||
).animate(animation),
|
_navigateToAddSubscription(context);
|
||||||
child: child,
|
return;
|
||||||
);
|
}
|
||||||
},
|
|
||||||
),
|
// 인덱스 업데이트
|
||||||
);
|
navigationProvider.updateCurrentIndex(index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100));
|
final navigationProvider = context.watch<NavigationProvider>();
|
||||||
|
final hour = DateTime.now().hour;
|
||||||
|
List<Color> backgroundGradient;
|
||||||
|
|
||||||
return Scaffold(
|
// 시간대별 배경 그라디언트 설정
|
||||||
backgroundColor: AppColors.backgroundColor,
|
if (hour >= 6 && hour < 10) {
|
||||||
extendBodyBehindAppBar: true,
|
backgroundGradient = AppColors.morningGradient;
|
||||||
appBar: _buildAppBar(appBarOpacity),
|
} else if (hour >= 10 && hour < 17) {
|
||||||
body: _buildBody(context, context.watch<SubscriptionProvider>()),
|
backgroundGradient = AppColors.dayGradient;
|
||||||
floatingActionButton: _buildFloatingActionButton(context),
|
} else if (hour >= 17 && hour < 20) {
|
||||||
);
|
backgroundGradient = AppColors.eveningGradient;
|
||||||
}
|
} else {
|
||||||
|
backgroundGradient = AppColors.nightGradient;
|
||||||
PreferredSize _buildAppBar(double appBarOpacity) {
|
|
||||||
return PreferredSize(
|
|
||||||
preferredSize: const Size.fromHeight(60),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.surfaceColor.withOpacity(appBarOpacity),
|
|
||||||
boxShadow: appBarOpacity > 0.6
|
|
||||||
? [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.06 * appBarOpacity),
|
|
||||||
spreadRadius: 0,
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
child: AppBar(
|
|
||||||
title: FadeTransition(
|
|
||||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
|
||||||
CurvedAnimation(
|
|
||||||
parent: _fadeController, curve: Curves.easeInOut)),
|
|
||||||
child: const Text(
|
|
||||||
'SubManager',
|
|
||||||
style: TextStyle(
|
|
||||||
fontFamily: 'Montserrat',
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
color: Color(0xFF1E293B),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
elevation: 0,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
actions: [
|
|
||||||
IconButton(
|
|
||||||
icon: const FaIcon(FontAwesomeIcons.chartPie,
|
|
||||||
size: 20, color: Color(0xFF64748B)),
|
|
||||||
tooltip: '분석',
|
|
||||||
onPressed: () => _navigateToAnalysis(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const FaIcon(FontAwesomeIcons.sms,
|
|
||||||
size: 20, color: Color(0xFF64748B)),
|
|
||||||
tooltip: 'SMS 스캔',
|
|
||||||
onPressed: () => _navigateToSmsScan(context),
|
|
||||||
),
|
|
||||||
IconButton(
|
|
||||||
icon: const FaIcon(FontAwesomeIcons.gear,
|
|
||||||
size: 20, color: Color(0xFF64748B)),
|
|
||||||
tooltip: '설정',
|
|
||||||
onPressed: () => _navigateToSettings(context),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildFloatingActionButton(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _scaleController,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.scale(
|
|
||||||
scale: Tween<double>(begin: 0.95, end: 1.0)
|
|
||||||
.animate(CurvedAnimation(
|
|
||||||
parent: _scaleController, curve: Curves.easeOutBack))
|
|
||||||
.value,
|
|
||||||
child: FloatingActionButton.extended(
|
|
||||||
onPressed: () => _navigateToAddSubscription(context),
|
|
||||||
icon: const Icon(Icons.add_rounded),
|
|
||||||
label: const Text(
|
|
||||||
'구독 추가',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
elevation: 4,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBody(BuildContext context, SubscriptionProvider provider) {
|
|
||||||
if (provider.isLoading) {
|
|
||||||
return const Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (provider.subscriptions.isEmpty) {
|
// 현재 인덱스가 유효한지 확인
|
||||||
return EmptyStateWidget(
|
int currentIndex = navigationProvider.currentIndex;
|
||||||
fadeController: _fadeController,
|
if (currentIndex == 2) {
|
||||||
rotateController: _rotateController,
|
currentIndex = 0; // 추가 버튼은 홈으로 표시
|
||||||
slideController: _slideController,
|
|
||||||
onAddPressed: () => _navigateToAddSubscription(context),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리별 구독 구분
|
return GlassmorphicScaffold(
|
||||||
final categoryProvider =
|
body: IndexedStack(
|
||||||
Provider.of<CategoryProvider>(context, listen: false);
|
index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex,
|
||||||
final categorizedSubscriptions =
|
children: _screens,
|
||||||
SubscriptionCategoryHelper.categorizeSubscriptions(
|
|
||||||
provider.subscriptions,
|
|
||||||
categoryProvider,
|
|
||||||
);
|
|
||||||
|
|
||||||
return RefreshIndicator(
|
|
||||||
onRefresh: () async {
|
|
||||||
await provider.refreshSubscriptions();
|
|
||||||
_resetAnimations();
|
|
||||||
},
|
|
||||||
color: const Color(0xFF3B82F6),
|
|
||||||
child: CustomScrollView(
|
|
||||||
controller: _scrollController,
|
|
||||||
physics: const BouncingScrollPhysics(),
|
|
||||||
slivers: [
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SizedBox(height: MediaQuery.of(context).padding.top + 60),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: NativeAdWidget(key: UniqueKey()),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SlideTransition(
|
|
||||||
position: Tween<Offset>(
|
|
||||||
begin: const Offset(0, 0.2),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _slideController, curve: Curves.easeOutCubic)),
|
|
||||||
child: MainScreenSummaryCard(
|
|
||||||
provider: provider,
|
|
||||||
fadeController: _fadeController,
|
|
||||||
pulseController: _pulseController,
|
|
||||||
waveController: _waveController,
|
|
||||||
slideController: _slideController,
|
|
||||||
onTap: () => _navigateToAnalysis(context),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
SlideTransition(
|
|
||||||
position: Tween<Offset>(
|
|
||||||
begin: const Offset(-0.2, 0),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _slideController, curve: Curves.easeOutCubic)),
|
|
||||||
child: Text(
|
|
||||||
'나의 구독 서비스',
|
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SlideTransition(
|
|
||||||
position: Tween<Offset>(
|
|
||||||
begin: const Offset(0.2, 0),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _slideController, curve: Curves.easeOutCubic)),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${provider.subscriptions.length}개',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Icon(
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
size: 14,
|
|
||||||
color: AppColors.primaryColor,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SubscriptionListWidget(
|
|
||||||
categorizedSubscriptions: categorizedSubscriptions,
|
|
||||||
fadeController: _fadeController,
|
|
||||||
),
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SizedBox(height: 100),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
backgroundGradient: backgroundGradient,
|
||||||
|
useFloatingNavBar: true,
|
||||||
|
floatingNavBarIndex: navigationProvider.currentIndex,
|
||||||
|
onFloatingNavBarTapped: (index) {
|
||||||
|
_handleNavigation(index, context);
|
||||||
|
},
|
||||||
|
enableParticles: false,
|
||||||
|
enableWaveAnimation: false,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../providers/app_lock_provider.dart';
|
import '../providers/app_lock_provider.dart';
|
||||||
import '../providers/notification_provider.dart';
|
import '../providers/notification_provider.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
@@ -11,6 +12,13 @@ import 'package:path/path.dart' as path;
|
|||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
import '../screens/sms_scan_screen.dart';
|
import '../screens/sms_scan_screen.dart';
|
||||||
import 'package:url_launcher/url_launcher.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 {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -27,13 +35,13 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? Theme.of(context).colorScheme.primary.withOpacity(0.2)
|
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)
|
||||||
: Colors.transparent,
|
: Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? Theme.of(context).colorScheme.primary
|
? Theme.of(context).colorScheme.primary
|
||||||
: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||||
width: isSelected ? 2 : 1,
|
width: isSelected ? 2 : 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -130,12 +138,81 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return ListView(
|
||||||
appBar: AppBar(
|
padding: const EdgeInsets.only(top: 20),
|
||||||
title: const Text('설정'),
|
|
||||||
),
|
|
||||||
body: ListView(
|
|
||||||
children: [
|
children: [
|
||||||
|
// 테마 설정
|
||||||
|
GlassmorphismCard(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Consumer<ThemeProvider>(
|
||||||
|
builder: (context, themeProvider, child) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.all(16),
|
||||||
|
child: Text(
|
||||||
|
'테마 설정',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 테마 모드 선택
|
||||||
|
ListTile(
|
||||||
|
title: const Text('테마 모드'),
|
||||||
|
subtitle: Text(_getThemeModeText(themeProvider.themeMode)),
|
||||||
|
leading: Icon(
|
||||||
|
_getThemeModeIcon(themeProvider.themeMode),
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
trailing: DropdownButton<AppThemeMode>(
|
||||||
|
value: themeProvider.themeMode,
|
||||||
|
underline: Container(),
|
||||||
|
onChanged: (mode) {
|
||||||
|
if (mode != null) {
|
||||||
|
themeProvider.setThemeMode(mode);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
items: AppThemeMode.values.map((mode) =>
|
||||||
|
DropdownMenuItem(
|
||||||
|
value: mode,
|
||||||
|
child: Text(_getThemeModeText(mode)),
|
||||||
|
),
|
||||||
|
).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
|
|
||||||
|
// 접근성 설정
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('큰 텍스트'),
|
||||||
|
subtitle: const Text('텍스트 크기를 크게 표시합니다'),
|
||||||
|
secondary: const Icon(Icons.text_fields),
|
||||||
|
value: themeProvider.largeText,
|
||||||
|
onChanged: themeProvider.setLargeText,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('모션 감소'),
|
||||||
|
subtitle: const Text('애니메이션 효과를 줄입니다'),
|
||||||
|
secondary: const Icon(Icons.slow_motion_video),
|
||||||
|
value: themeProvider.reduceMotion,
|
||||||
|
onChanged: themeProvider.setReduceMotion,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
title: const Text('고대비 모드'),
|
||||||
|
subtitle: const Text('더 선명한 색상으로 표시합니다'),
|
||||||
|
secondary: const Icon(Icons.contrast),
|
||||||
|
value: themeProvider.highContrast,
|
||||||
|
onChanged: themeProvider.setHighContrast,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
// 앱 잠금 설정 UI 숨김
|
// 앱 잠금 설정 UI 숨김
|
||||||
// Card(
|
// Card(
|
||||||
// margin: const EdgeInsets.all(16),
|
// margin: const EdgeInsets.all(16),
|
||||||
@@ -161,8 +238,9 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
// ),
|
// ),
|
||||||
|
|
||||||
// 알림 설정
|
// 알림 설정
|
||||||
Card(
|
GlassmorphismCard(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
child: Consumer<NotificationProvider>(
|
child: Consumer<NotificationProvider>(
|
||||||
builder: (context, provider, child) {
|
builder: (context, provider, child) {
|
||||||
return Column(
|
return Column(
|
||||||
@@ -211,7 +289,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.surfaceVariant
|
.surfaceVariant
|
||||||
.withOpacity(0.3),
|
.withValues(alpha: 0.3),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -273,7 +351,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.outline
|
.outline
|
||||||
.withOpacity(0.5),
|
.withValues(alpha: 0.5),
|
||||||
),
|
),
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(8),
|
BorderRadius.circular(8),
|
||||||
@@ -329,7 +407,7 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
color: Theme.of(context)
|
color: Theme.of(context)
|
||||||
.colorScheme
|
.colorScheme
|
||||||
.surfaceVariant
|
.surfaceVariant
|
||||||
.withOpacity(0.3),
|
.withValues(alpha: 0.3),
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(8),
|
BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
@@ -377,8 +455,9 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// 데이터 관리
|
// 데이터 관리
|
||||||
Card(
|
GlassmorphismCard(
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 데이터 백업 기능 비활성화
|
// 데이터 백업 기능 비활성화
|
||||||
@@ -389,108 +468,14 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
// onTap: () => _backupData(context),
|
// onTap: () => _backupData(context),
|
||||||
// ),
|
// ),
|
||||||
// const Divider(),
|
// const Divider(),
|
||||||
// SMS 스캔 - 시각적으로 강조된 UI
|
|
||||||
InkWell(
|
|
||||||
onTap: () => _navigateToSmsScan(context),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Theme.of(context).primaryColor.withOpacity(0.1),
|
|
||||||
Theme.of(context).primaryColor.withOpacity(0.2),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
borderRadius: const BorderRadius.only(
|
|
||||||
bottomLeft: Radius.circular(12),
|
|
||||||
bottomRight: Radius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 16.0, horizontal: 8.0),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
margin: const EdgeInsets.only(left: 8, right: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context)
|
|
||||||
.primaryColor
|
|
||||||
.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
Icons.sms_rounded,
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'SMS 스캔으로 구독 자동 찾기',
|
|
||||||
style: TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 16,
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 3,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: const Text(
|
|
||||||
'추천',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 10,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 6),
|
|
||||||
const Text(
|
|
||||||
'2회 이상 반복 결제된 구독 서비스를 자동으로 찾아 추가합니다',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.black54,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Icon(
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
size: 16,
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 앱 정보
|
// 앱 정보
|
||||||
Card(
|
GlassmorphismCard(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
title: const Text('앱 정보'),
|
title: const Text('앱 정보'),
|
||||||
subtitle: const Text('버전 1.0.0'),
|
subtitle: const Text('버전 1.0.0'),
|
||||||
@@ -554,8 +539,36 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
SizedBox(
|
||||||
|
height: 20 + MediaQuery.of(context).padding.bottom, // 하단 여백
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
);
|
}
|
||||||
|
|
||||||
|
String _getThemeModeText(AppThemeMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case AppThemeMode.light:
|
||||||
|
return '라이트';
|
||||||
|
case AppThemeMode.dark:
|
||||||
|
return '다크';
|
||||||
|
case AppThemeMode.oled:
|
||||||
|
return 'OLED 블랙';
|
||||||
|
case AppThemeMode.system:
|
||||||
|
return '시스템 설정';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
IconData _getThemeModeIcon(AppThemeMode mode) {
|
||||||
|
switch (mode) {
|
||||||
|
case AppThemeMode.light:
|
||||||
|
return Icons.light_mode;
|
||||||
|
case AppThemeMode.dark:
|
||||||
|
return Icons.dark_mode;
|
||||||
|
case AppThemeMode.oled:
|
||||||
|
return Icons.phonelink_lock;
|
||||||
|
case AppThemeMode.system:
|
||||||
|
return Icons.settings_brightness;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/sms_scanner.dart';
|
import '../services/sms_scanner.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../models/subscription.dart';
|
import '../models/subscription.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../services/subscription_url_matcher.dart';
|
import '../services/subscription_url_matcher.dart';
|
||||||
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
|
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 {
|
class SmsScanScreen extends StatefulWidget {
|
||||||
const SmsScanScreen({super.key});
|
const SmsScanScreen({super.key});
|
||||||
@@ -100,8 +106,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
||||||
print('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
print('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
||||||
|
|
||||||
if (filteredSubscriptions.isNotEmpty &&
|
if (filteredSubscriptions.isNotEmpty) {
|
||||||
filteredSubscriptions[0] != null) {
|
|
||||||
print(
|
print(
|
||||||
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
||||||
}
|
}
|
||||||
@@ -163,10 +168,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
|
|
||||||
// 중복되지 않은 구독만 필터링
|
// 중복되지 않은 구독만 필터링
|
||||||
final nonDuplicates = scanned.where((scannedSub) {
|
final nonDuplicates = scanned.where((scannedSub) {
|
||||||
if (scannedSub == null) {
|
|
||||||
print('_filterDuplicates: null 구독 객체 발견');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서비스명과 금액이 동일한 기존 구독 찾기
|
// 서비스명과 금액이 동일한 기존 구독 찾기
|
||||||
final hasDuplicate = existing.any((existingSub) =>
|
final hasDuplicate = existing.any((existingSub) =>
|
||||||
@@ -189,10 +190,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
|
|
||||||
for (int i = 0; i < nonDuplicates.length; i++) {
|
for (int i = 0; i < nonDuplicates.length; i++) {
|
||||||
final subscription = nonDuplicates[i];
|
final subscription = nonDuplicates[i];
|
||||||
if (subscription == null) {
|
|
||||||
print('_filterDuplicates: null 구독 객체 무시');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
String? websiteUrl = subscription.websiteUrl;
|
String? websiteUrl = subscription.websiteUrl;
|
||||||
|
|
||||||
@@ -252,11 +249,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final subscription = _scannedSubscriptions[_currentIndex];
|
final subscription = _scannedSubscriptions[_currentIndex];
|
||||||
if (subscription == null) {
|
|
||||||
print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)');
|
|
||||||
_moveToNextSubscription();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||||
|
|
||||||
@@ -365,9 +357,38 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text('${subscription.serviceName} 구독이 추가되었습니다.'),
|
content: Row(
|
||||||
backgroundColor: Colors.green,
|
children: [
|
||||||
duration: const Duration(seconds: 2),
|
const Icon(
|
||||||
|
Icons.check_circle,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'${subscription.serviceName} 구독이 추가되었습니다.',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
backgroundColor: const Color(0xFF10B981), // 초록색
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
margin: EdgeInsets.only(
|
||||||
|
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
|
||||||
|
),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
duration: const Duration(seconds: 3),
|
||||||
|
dismissDirection: DismissDirection.horizontal,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -402,21 +423,36 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
_currentIndex++;
|
_currentIndex++;
|
||||||
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||||
|
|
||||||
// 모든 구독을 처리했으면 화면 종료
|
// 모든 구독을 처리했으면 홈 화면으로 이동
|
||||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||||
Navigator.of(context).pop(true);
|
_navigateToHome();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 홈 화면으로 이동
|
||||||
|
void _navigateToHome() {
|
||||||
|
// NavigationProvider를 사용하여 홈 화면으로 이동
|
||||||
|
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
|
||||||
|
navigationProvider.updateCurrentIndex(0);
|
||||||
|
|
||||||
|
// 완료 메시지 표시
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('모든 구독이 처리되었습니다.'),
|
||||||
|
backgroundColor: Colors.green,
|
||||||
|
duration: Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// 날짜 상태 텍스트 가져오기
|
// 날짜 상태 텍스트 가져오기
|
||||||
String _getNextBillingText(DateTime date) {
|
String _getNextBillingText(DateTime date) {
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
|
|
||||||
if (date.isBefore(now)) {
|
if (date.isBefore(now)) {
|
||||||
// 주기에 따라 다음 결제일 예측
|
// 주기에 따라 다음 결제일 예측
|
||||||
if (_currentIndex >= _scannedSubscriptions.length ||
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||||
_scannedSubscriptions[_currentIndex] == null) {
|
|
||||||
return '다음 결제일 확인 필요';
|
return '다음 결제일 확인 필요';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -485,17 +521,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Padding(
|
||||||
appBar: AppBar(
|
padding: const EdgeInsets.all(16.0),
|
||||||
title: const Text('SMS 스캔'),
|
child: _isLoading
|
||||||
),
|
? _buildLoadingState()
|
||||||
body: Padding(
|
: (_scannedSubscriptions.isEmpty
|
||||||
padding: const EdgeInsets.all(16.0),
|
? _buildInitialState()
|
||||||
child: _isLoading
|
: _buildSubscriptionState()),
|
||||||
? _buildLoadingState()
|
|
||||||
: (_scannedSubscriptions.isEmpty
|
|
||||||
? _buildInitialState()
|
|
||||||
: _buildSubscriptionState())),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -507,9 +539,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
children: [
|
children: [
|
||||||
CircularProgressIndicator(),
|
CircularProgressIndicator(),
|
||||||
SizedBox(height: 16),
|
SizedBox(height: 16),
|
||||||
Text('SMS 메시지를 스캔 중입니다...'),
|
ThemedText('SMS 메시지를 스캔 중입니다...'),
|
||||||
SizedBox(height: 8),
|
SizedBox(height: 8),
|
||||||
Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)),
|
ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -524,24 +556,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
if (_errorMessage != null)
|
if (_errorMessage != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Text(
|
child: ThemedText(
|
||||||
_errorMessage!,
|
_errorMessage!,
|
||||||
style: const TextStyle(color: Colors.red),
|
color: Colors.red,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
const Text(
|
const ThemedText(
|
||||||
'2회 이상 결제된 구독 서비스 찾기',
|
'2회 이상 결제된 구독 서비스 찾기',
|
||||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 32.0),
|
padding: EdgeInsets.symmetric(horizontal: 32.0),
|
||||||
child: Text(
|
child: ThemedText(
|
||||||
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
|
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(color: Colors.grey),
|
opacity: 0.7,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
@@ -562,26 +595,11 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
Widget _buildSubscriptionState() {
|
Widget _buildSubscriptionState() {
|
||||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||||
return const Center(
|
return const Center(
|
||||||
child: Text('모든 구독 처리 완료'),
|
child: ThemedText('모든 구독 처리 완료'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final subscription = _scannedSubscriptions[_currentIndex];
|
final subscription = _scannedSubscriptions[_currentIndex];
|
||||||
if (subscription == null) {
|
|
||||||
return Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
Text('오류: 구독 정보를 불러올 수 없습니다.'),
|
|
||||||
SizedBox(height: 16),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: _moveToNextSubscription,
|
|
||||||
child: Text('건너뛰기'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
||||||
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
||||||
@@ -594,54 +612,42 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
// 진행 상태 표시
|
// 진행 상태 표시
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: (_currentIndex + 1) / _scannedSubscriptions.length,
|
value: (_currentIndex + 1) / _scannedSubscriptions.length,
|
||||||
backgroundColor: Colors.grey.withOpacity(0.2),
|
backgroundColor: Colors.grey.withValues(alpha: 0.2),
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
Theme.of(context).colorScheme.primary),
|
Theme.of(context).colorScheme.primary),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
ThemedText(
|
||||||
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
|
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.grey.shade600,
|
opacity: 0.7,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 구독 정보 카드
|
// 구독 정보 카드
|
||||||
Card(
|
GlassmorphismCard(
|
||||||
elevation: 4,
|
width: double.infinity,
|
||||||
shape: RoundedRectangleBorder(
|
padding: const EdgeInsets.all(16.0),
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const ThemedText(
|
||||||
'다음 구독을 찾았습니다',
|
'다음 구독을 찾았습니다',
|
||||||
style: TextStyle(
|
fontSize: 18,
|
||||||
fontSize: 18,
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
// 서비스명
|
// 서비스명
|
||||||
const Text(
|
const ThemedText(
|
||||||
'서비스명',
|
'서비스명',
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.grey,
|
opacity: 0.7,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
ThemedText(
|
||||||
subscription.serviceName,
|
subscription.serviceName,
|
||||||
style: const TextStyle(
|
fontSize: 22,
|
||||||
fontSize: 22,
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
@@ -652,15 +658,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const ThemedText(
|
||||||
'월 비용',
|
'월 비용',
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.grey,
|
opacity: 0.7,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
ThemedText(
|
||||||
subscription.currency == 'USD'
|
subscription.currency == 'USD'
|
||||||
? NumberFormat.currency(
|
? NumberFormat.currency(
|
||||||
locale: 'en_US',
|
locale: 'en_US',
|
||||||
@@ -672,10 +676,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
symbol: '₩',
|
symbol: '₩',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
).format(subscription.monthlyCost),
|
).format(subscription.monthlyCost),
|
||||||
style: const TextStyle(
|
fontSize: 18,
|
||||||
fontSize: 18,
|
fontWeight: FontWeight.bold,
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -684,21 +686,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const ThemedText(
|
||||||
'반복 횟수',
|
'반복 횟수',
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.grey,
|
opacity: 0.7,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
ThemedText(
|
||||||
_getRepeatCountText(subscription.repeatCount),
|
_getRepeatCountText(subscription.repeatCount),
|
||||||
style: TextStyle(
|
fontSize: 16,
|
||||||
fontSize: 16,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.w500,
|
color: Theme.of(context).colorScheme.secondary,
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -714,20 +712,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const ThemedText(
|
||||||
'결제 주기',
|
'결제 주기',
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.grey,
|
opacity: 0.7,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
ThemedText(
|
||||||
subscription.billingCycle,
|
subscription.billingCycle,
|
||||||
style: const TextStyle(
|
fontSize: 16,
|
||||||
fontSize: 16,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -736,20 +730,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const ThemedText(
|
||||||
'결제일',
|
'결제일',
|
||||||
style: TextStyle(
|
fontWeight: FontWeight.w500,
|
||||||
color: Colors.grey,
|
opacity: 0.7,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
ThemedText(
|
||||||
_getNextBillingText(subscription.nextBillingDate),
|
_getNextBillingText(subscription.nextBillingDate),
|
||||||
style: const TextStyle(
|
fontSize: 14,
|
||||||
fontSize: 14,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -800,7 +790,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -809,8 +798,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
if (_scannedSubscriptions.isNotEmpty &&
|
if (_scannedSubscriptions.isNotEmpty &&
|
||||||
_currentIndex < _scannedSubscriptions.length &&
|
_currentIndex < _scannedSubscriptions.length) {
|
||||||
_scannedSubscriptions[_currentIndex] != null) {
|
|
||||||
final currentSub = _scannedSubscriptions[_currentIndex];
|
final currentSub = _scannedSubscriptions[_currentIndex];
|
||||||
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
||||||
_websiteUrlController.text = currentSub.websiteUrl!;
|
_websiteUrlController.text = currentSub.websiteUrl!;
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
import 'dart:ui';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/app_lock_provider.dart';
|
import '../providers/app_lock_provider.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/glassmorphism_card.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
import 'app_lock_screen.dart';
|
import 'app_lock_screen.dart';
|
||||||
import 'main_screen.dart';
|
import 'main_screen.dart';
|
||||||
|
|
||||||
@@ -101,18 +105,10 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
|
|
||||||
void navigateToNextScreen() {
|
void navigateToNextScreen() {
|
||||||
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
|
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
|
||||||
Navigator.of(context).pushReplacement(
|
// 모든 이전 라우트를 제거하고 홈으로 이동
|
||||||
PageRouteBuilder(
|
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
AppRoutes.main,
|
||||||
const MainScreen(),
|
(route) => false,
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
||||||
return FadeTransition(
|
|
||||||
opacity: animation,
|
|
||||||
child: child,
|
|
||||||
);
|
|
||||||
},
|
|
||||||
transitionDuration: const Duration(milliseconds: 500),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,244 +123,305 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
final size = MediaQuery.of(context).size;
|
final size = MediaQuery.of(context).size;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: Container(
|
body: Stack(
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
gradient: LinearGradient(
|
// 배경 그라디언트
|
||||||
begin: Alignment.topCenter,
|
Container(
|
||||||
end: Alignment.bottomCenter,
|
decoration: BoxDecoration(
|
||||||
colors: AppColors.blueGradient,
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
AppColors.dayGradient[0],
|
||||||
|
AppColors.dayGradient[1],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
// 글래스모피즘 오버레이
|
||||||
child: Stack(
|
Container(
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
// 배경 파티클
|
color: Colors.white.withValues(alpha: 0.05),
|
||||||
..._particles.map((particle) {
|
),
|
||||||
return AnimatedPositioned(
|
),
|
||||||
duration: Duration(milliseconds: particle['duration'].toInt()),
|
Stack(
|
||||||
curve: Curves.easeInOut,
|
children: [
|
||||||
left: particle['x'] - 50 + (size.width * 0.1),
|
// 배경 파티클
|
||||||
top: particle['y'] - 50 + (size.height * 0.1),
|
..._particles.map((particle) {
|
||||||
child: TweenAnimationBuilder<double>(
|
return AnimatedPositioned(
|
||||||
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
|
|
||||||
duration:
|
duration:
|
||||||
Duration(milliseconds: particle['duration'].toInt()),
|
Duration(milliseconds: particle['duration'].toInt()),
|
||||||
builder: (context, value, child) {
|
curve: Curves.easeInOut,
|
||||||
return Opacity(
|
left: particle['x'] - 50 + (size.width * 0.1),
|
||||||
opacity: value,
|
top: particle['y'] - 50 + (size.height * 0.1),
|
||||||
child: child,
|
child: TweenAnimationBuilder<double>(
|
||||||
);
|
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
|
||||||
},
|
duration:
|
||||||
child: Container(
|
Duration(milliseconds: particle['duration'].toInt()),
|
||||||
width: particle['size'],
|
builder: (context, value, child) {
|
||||||
height: particle['size'],
|
return Opacity(
|
||||||
decoration: BoxDecoration(
|
opacity: value,
|
||||||
color: particle['color'],
|
child: child,
|
||||||
shape: BoxShape.circle,
|
);
|
||||||
boxShadow: [
|
},
|
||||||
BoxShadow(
|
child: Container(
|
||||||
color: particle['color'].withOpacity(0.3),
|
width: particle['size'],
|
||||||
blurRadius: 10,
|
height: particle['size'],
|
||||||
spreadRadius: 1,
|
decoration: BoxDecoration(
|
||||||
),
|
color: particle['color'],
|
||||||
],
|
shape: BoxShape.circle,
|
||||||
),
|
boxShadow: [
|
||||||
),
|
BoxShadow(
|
||||||
),
|
color: particle['color'].withValues(alpha: 0.3),
|
||||||
);
|
blurRadius: 10,
|
||||||
}).toList(),
|
spreadRadius: 1,
|
||||||
|
|
||||||
// 상단 원형 그라데이션
|
|
||||||
Positioned(
|
|
||||||
top: -size.height * 0.2,
|
|
||||||
right: -size.width * 0.2,
|
|
||||||
child: Container(
|
|
||||||
width: size.width * 0.8,
|
|
||||||
height: size.width * 0.8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: RadialGradient(
|
|
||||||
colors: [
|
|
||||||
Colors.white.withOpacity(0.1),
|
|
||||||
Colors.white.withOpacity(0.0),
|
|
||||||
],
|
|
||||||
stops: const [0.2, 1.0],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 하단 원형 그라데이션
|
|
||||||
Positioned(
|
|
||||||
bottom: -size.height * 0.1,
|
|
||||||
left: -size.width * 0.3,
|
|
||||||
child: Container(
|
|
||||||
width: size.width * 0.9,
|
|
||||||
height: size.width * 0.9,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
gradient: RadialGradient(
|
|
||||||
colors: [
|
|
||||||
Colors.white.withOpacity(0.07),
|
|
||||||
Colors.white.withOpacity(0.0),
|
|
||||||
],
|
|
||||||
stops: const [0.4, 1.0],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 메인 콘텐츠
|
|
||||||
Column(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Center(
|
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
children: [
|
|
||||||
// 로고 애니메이션
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: _animationController,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.scale(
|
|
||||||
scale: _scaleAnimation.value,
|
|
||||||
child: Transform.rotate(
|
|
||||||
angle: _rotateAnimation.value,
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
width: 120,
|
|
||||||
height: 120,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(30),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.2),
|
|
||||||
spreadRadius: 0,
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, 10),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: AnimatedBuilder(
|
|
||||||
animation: _animationController,
|
|
||||||
builder: (context, _) {
|
|
||||||
return ShaderMask(
|
|
||||||
blendMode: BlendMode.srcIn,
|
|
||||||
shaderCallback: (bounds) =>
|
|
||||||
LinearGradient(
|
|
||||||
colors: AppColors.blueGradient,
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
).createShader(bounds),
|
|
||||||
child: Icon(
|
|
||||||
Icons.subscriptions_outlined,
|
|
||||||
size: 64,
|
|
||||||
color: Theme.of(context)
|
|
||||||
.primaryColor,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 40),
|
|
||||||
|
|
||||||
// 앱 이름 텍스트
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: _animationController,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Opacity(
|
|
||||||
opacity: _fadeAnimation.value,
|
|
||||||
child: Transform.translate(
|
|
||||||
offset: Offset(0, _slideAnimation.value),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
'SubManager',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 36,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.white,
|
|
||||||
letterSpacing: 1.2,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 부제목 텍스트
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: _animationController,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Opacity(
|
|
||||||
opacity: _fadeAnimation.value,
|
|
||||||
child: Transform.translate(
|
|
||||||
offset: Offset(0, _slideAnimation.value * 1.2),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Text(
|
|
||||||
'구독 서비스 관리를 더 쉽게',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.white70,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
const SizedBox(height: 60),
|
|
||||||
|
|
||||||
// 로딩 인디케이터
|
|
||||||
FadeTransition(
|
|
||||||
opacity: _fadeAnimation,
|
|
||||||
child: Container(
|
|
||||||
width: 60,
|
|
||||||
height: 60,
|
|
||||||
padding: const EdgeInsets.all(6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(50),
|
|
||||||
),
|
|
||||||
child: const CircularProgressIndicator(
|
|
||||||
valueColor:
|
|
||||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
|
||||||
strokeWidth: 3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 카피라이트 텍스트
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(bottom: 24.0),
|
|
||||||
child: FadeTransition(
|
|
||||||
opacity: _fadeAnimation,
|
|
||||||
child: const Text(
|
|
||||||
'© 2023 CClabs. All rights reserved.',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.white60,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
|
||||||
|
// 상단 원형 그라데이션
|
||||||
|
Positioned(
|
||||||
|
top: -size.height * 0.2,
|
||||||
|
right: -size.width * 0.2,
|
||||||
|
child: Container(
|
||||||
|
width: size.width * 0.8,
|
||||||
|
height: size.width * 0.8,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.white.withValues(alpha: 0.1),
|
||||||
|
Colors.white.withValues(alpha: 0.0),
|
||||||
|
],
|
||||||
|
stops: const [0.2, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
|
||||||
],
|
// 하단 원형 그라데이션
|
||||||
),
|
Positioned(
|
||||||
|
bottom: -size.height * 0.1,
|
||||||
|
left: -size.width * 0.3,
|
||||||
|
child: Container(
|
||||||
|
width: size.width * 0.9,
|
||||||
|
height: size.width * 0.9,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.white.withValues(alpha: 0.07),
|
||||||
|
Colors.white.withValues(alpha: 0.0),
|
||||||
|
],
|
||||||
|
stops: const [0.4, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 메인 콘텐츠
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// 로고 애니메이션
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _rotateAnimation.value,
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration:
|
||||||
|
const Duration(milliseconds: 200),
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(30),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: 20, sigmaY: 20),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
Colors.white
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
Colors.white
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(30),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
|
spreadRadius: 0,
|
||||||
|
blurRadius: 30,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation:
|
||||||
|
_animationController,
|
||||||
|
builder: (context, _) {
|
||||||
|
return ShaderMask(
|
||||||
|
blendMode:
|
||||||
|
BlendMode.srcIn,
|
||||||
|
shaderCallback:
|
||||||
|
(bounds) =>
|
||||||
|
LinearGradient(
|
||||||
|
colors: AppColors
|
||||||
|
.blueGradient,
|
||||||
|
begin:
|
||||||
|
Alignment.topLeft,
|
||||||
|
end: Alignment
|
||||||
|
.bottomRight,
|
||||||
|
).createShader(bounds),
|
||||||
|
child: Icon(
|
||||||
|
Icons
|
||||||
|
.subscriptions_outlined,
|
||||||
|
size: 64,
|
||||||
|
color:
|
||||||
|
Theme.of(context)
|
||||||
|
.primaryColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)),
|
||||||
|
));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
|
// 앱 이름 텍스트
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: _fadeAnimation.value,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, _slideAnimation.value),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'SubManager',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 36,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
letterSpacing: 1.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 부제목 텍스트
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _animationController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Opacity(
|
||||||
|
opacity: _fadeAnimation.value,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset:
|
||||||
|
Offset(0, _slideAnimation.value * 1.2),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: const Text(
|
||||||
|
'구독 서비스 관리를 더 쉽게',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.white70,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: 60),
|
||||||
|
|
||||||
|
// 로딩 인디케이터
|
||||||
|
FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter:
|
||||||
|
ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: Container(
|
||||||
|
width: 60,
|
||||||
|
height: 60,
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(50),
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
Colors.white.withValues(alpha: 0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
|
Colors.white),
|
||||||
|
strokeWidth: 3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 카피라이트 텍스트
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 24.0),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: const Text(
|
||||||
|
'© 2023 CClabs. All rights reserved.',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.white60,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,4 +131,29 @@ class CurrencyUtil {
|
|||||||
).format(savings);
|
).format(savings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 금액과 통화를 받아 포맷팅하여 반환
|
||||||
|
static Future<String> formatAmount(double amount, String currency) async {
|
||||||
|
if (currency == 'USD') {
|
||||||
|
// USD 표시 + 원화 환산 금액
|
||||||
|
final usdFormatted = NumberFormat.currency(
|
||||||
|
locale: 'en_US',
|
||||||
|
symbol: '\$',
|
||||||
|
decimalDigits: 2,
|
||||||
|
).format(amount);
|
||||||
|
|
||||||
|
// 원화 환산 금액
|
||||||
|
final krwAmount = await _exchangeRateService
|
||||||
|
.getFormattedKrwAmount(amount);
|
||||||
|
|
||||||
|
return '$usdFormatted $krwAmount';
|
||||||
|
} else {
|
||||||
|
// 원화 표시
|
||||||
|
return NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(amount);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
379
lib/theme/adaptive_theme.dart
Normal file
379
lib/theme/adaptive_theme.dart
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'app_colors.dart';
|
||||||
|
import 'app_theme.dart';
|
||||||
|
|
||||||
|
/// 적응형 테마 관리 클래스
|
||||||
|
class AdaptiveTheme {
|
||||||
|
/// 라이트 테마
|
||||||
|
static ThemeData get lightTheme => AppTheme.lightTheme;
|
||||||
|
|
||||||
|
/// 다크 테마
|
||||||
|
static ThemeData get darkTheme {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.dark,
|
||||||
|
colorScheme: ColorScheme.dark(
|
||||||
|
primary: AppColors.primaryColor,
|
||||||
|
onPrimary: Colors.white,
|
||||||
|
secondary: AppColors.secondaryColor,
|
||||||
|
tertiary: AppColors.infoColor,
|
||||||
|
error: AppColors.dangerColor,
|
||||||
|
background: const Color(0xFF121212),
|
||||||
|
surface: const Color(0xFF1E1E1E),
|
||||||
|
),
|
||||||
|
|
||||||
|
scaffoldBackgroundColor: const Color(0xFF121212),
|
||||||
|
|
||||||
|
cardTheme: CardTheme(
|
||||||
|
color: const Color(0xFF1E1E1E),
|
||||||
|
elevation: 2,
|
||||||
|
shadowColor: Colors.black.withValues(alpha: 0.3),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
|
||||||
|
),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
),
|
||||||
|
|
||||||
|
appBarTheme: AppBarTheme(
|
||||||
|
backgroundColor: const Color(0xFF1E1E1E),
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
centerTitle: false,
|
||||||
|
titleTextStyle: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
iconTheme: IconThemeData(
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
textTheme: TextTheme(
|
||||||
|
headlineLarge: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
headlineMedium: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
height: 1.2,
|
||||||
|
),
|
||||||
|
headlineSmall: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.25,
|
||||||
|
height: 1.3,
|
||||||
|
),
|
||||||
|
titleLarge: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
titleMedium: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.1,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
titleSmall: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
bodyLarge: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
bodyMedium: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.1,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
bodySmall: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
letterSpacing: 0.2,
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
inputDecorationTheme: InputDecorationTheme(
|
||||||
|
filled: true,
|
||||||
|
fillColor: const Color(0xFF2A2A2A),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide.none,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5),
|
||||||
|
),
|
||||||
|
errorBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: AppColors.dangerColor, width: 1),
|
||||||
|
),
|
||||||
|
labelStyle: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.7),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
hintStyle: TextStyle(
|
||||||
|
color: Colors.white.withValues(alpha: 0.5),
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.primaryColor,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(0, 48),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
elevation: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
dividerTheme: DividerThemeData(
|
||||||
|
color: Colors.white.withValues(alpha: 0.1),
|
||||||
|
thickness: 1,
|
||||||
|
space: 16,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// OLED 최적화 다크 테마
|
||||||
|
static ThemeData get oledTheme {
|
||||||
|
return darkTheme.copyWith(
|
||||||
|
scaffoldBackgroundColor: Colors.black,
|
||||||
|
colorScheme: darkTheme.colorScheme.copyWith(
|
||||||
|
background: Colors.black,
|
||||||
|
surface: const Color(0xFF0A0A0A),
|
||||||
|
),
|
||||||
|
cardTheme: darkTheme.cardTheme.copyWith(
|
||||||
|
color: const Color(0xFF0A0A0A),
|
||||||
|
),
|
||||||
|
appBarTheme: darkTheme.appBarTheme.copyWith(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
),
|
||||||
|
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
|
||||||
|
fillColor: const Color(0xFF0A0A0A),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 고대비 테마
|
||||||
|
static ThemeData get highContrastTheme {
|
||||||
|
return ThemeData(
|
||||||
|
useMaterial3: true,
|
||||||
|
brightness: Brightness.light,
|
||||||
|
colorScheme: const ColorScheme.highContrastLight(
|
||||||
|
primary: Colors.black,
|
||||||
|
secondary: Colors.black87,
|
||||||
|
tertiary: Colors.black54,
|
||||||
|
error: Colors.red,
|
||||||
|
background: Colors.white,
|
||||||
|
surface: Colors.white,
|
||||||
|
),
|
||||||
|
|
||||||
|
textTheme: const TextTheme(
|
||||||
|
headlineLarge: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
headlineMedium: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w900,
|
||||||
|
),
|
||||||
|
headlineSmall: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
),
|
||||||
|
bodyLarge: TextStyle(
|
||||||
|
color: Colors.black,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
bodyMedium: TextStyle(
|
||||||
|
color: Colors.black87,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
cardTheme: CardTheme(
|
||||||
|
elevation: 0,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
side: const BorderSide(color: Colors.black, width: 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.black,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: const BorderSide(color: Colors.black, width: 2),
|
||||||
|
textStyle: const TextStyle(
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 시스템 테마에 따른 상태바 스타일 적용
|
||||||
|
static void applySystemUIOverlay(BuildContext context) {
|
||||||
|
final brightness = Theme.of(context).brightness;
|
||||||
|
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
|
||||||
|
|
||||||
|
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
statusBarIconBrightness: brightness == Brightness.dark
|
||||||
|
? Brightness.light
|
||||||
|
: Brightness.dark,
|
||||||
|
statusBarBrightness: brightness == Brightness.dark
|
||||||
|
? Brightness.light
|
||||||
|
: Brightness.dark,
|
||||||
|
systemNavigationBarColor: isOled
|
||||||
|
? Colors.black
|
||||||
|
: (brightness == Brightness.dark
|
||||||
|
? const Color(0xFF121212)
|
||||||
|
: Colors.white),
|
||||||
|
systemNavigationBarIconBrightness: brightness == Brightness.dark
|
||||||
|
? Brightness.light
|
||||||
|
: Brightness.dark,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 접근성 설정에 따른 테마 조정
|
||||||
|
static ThemeData getAccessibleTheme(
|
||||||
|
ThemeData baseTheme, {
|
||||||
|
required bool largeText,
|
||||||
|
required bool reduceMotion,
|
||||||
|
required bool highContrast,
|
||||||
|
}) {
|
||||||
|
if (highContrast) {
|
||||||
|
return highContrastTheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
ThemeData theme = baseTheme;
|
||||||
|
|
||||||
|
if (largeText) {
|
||||||
|
theme = theme.copyWith(
|
||||||
|
textTheme: theme.textTheme.apply(
|
||||||
|
fontSizeFactor: 1.2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reduceMotion) {
|
||||||
|
theme = theme.copyWith(
|
||||||
|
pageTransitionsTheme: const PageTransitionsTheme(
|
||||||
|
builders: {
|
||||||
|
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
TargetPlatform.iOS: FadeUpwardsPageTransitionsBuilder(),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return theme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 모드 열거형
|
||||||
|
enum AppThemeMode {
|
||||||
|
light,
|
||||||
|
dark,
|
||||||
|
oled,
|
||||||
|
system,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 테마 설정 클래스
|
||||||
|
class ThemeSettings {
|
||||||
|
final AppThemeMode mode;
|
||||||
|
final bool useSystemColors;
|
||||||
|
final bool largeText;
|
||||||
|
final bool reduceMotion;
|
||||||
|
final bool highContrast;
|
||||||
|
|
||||||
|
const ThemeSettings({
|
||||||
|
this.mode = AppThemeMode.system,
|
||||||
|
this.useSystemColors = false,
|
||||||
|
this.largeText = false,
|
||||||
|
this.reduceMotion = false,
|
||||||
|
this.highContrast = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
ThemeSettings copyWith({
|
||||||
|
AppThemeMode? mode,
|
||||||
|
bool? useSystemColors,
|
||||||
|
bool? largeText,
|
||||||
|
bool? reduceMotion,
|
||||||
|
bool? highContrast,
|
||||||
|
}) {
|
||||||
|
return ThemeSettings(
|
||||||
|
mode: mode ?? this.mode,
|
||||||
|
useSystemColors: useSystemColors ?? this.useSystemColors,
|
||||||
|
largeText: largeText ?? this.largeText,
|
||||||
|
reduceMotion: reduceMotion ?? this.reduceMotion,
|
||||||
|
highContrast: highContrast ?? this.highContrast,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'mode': mode.name,
|
||||||
|
'useSystemColors': useSystemColors,
|
||||||
|
'largeText': largeText,
|
||||||
|
'reduceMotion': reduceMotion,
|
||||||
|
'highContrast': highContrast,
|
||||||
|
};
|
||||||
|
|
||||||
|
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
|
||||||
|
return ThemeSettings(
|
||||||
|
mode: AppThemeMode.values.firstWhere(
|
||||||
|
(mode) => mode.name == json['mode'],
|
||||||
|
orElse: () => AppThemeMode.system,
|
||||||
|
),
|
||||||
|
useSystemColors: json['useSystemColors'] ?? false,
|
||||||
|
largeText: json['largeText'] ?? false,
|
||||||
|
reduceMotion: json['reduceMotion'] ?? false,
|
||||||
|
highContrast: json['highContrast'] ?? false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -46,4 +46,49 @@ class AppColors {
|
|||||||
Color(0xFFF43F5E),
|
Color(0xFFF43F5E),
|
||||||
Color(0xFFE11D48)
|
Color(0xFFE11D48)
|
||||||
];
|
];
|
||||||
|
|
||||||
|
// Glassmorphism 효과를 위한 색상
|
||||||
|
static const glassSurface = Color(0x0FFFFFFF); // 매우 연한 흰색 (6% opacity)
|
||||||
|
static const glassBackground = Color(0x1AFFFFFF); // 연한 흰색 (10% opacity)
|
||||||
|
static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity)
|
||||||
|
static const glassBorder = Color(0x4DFFFFFF); // 반투명 테두리 (30% opacity)
|
||||||
|
static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity)
|
||||||
|
|
||||||
|
// 다크 모드용 Glassmorphism 색상
|
||||||
|
static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity)
|
||||||
|
static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity)
|
||||||
|
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
|
||||||
|
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
|
||||||
|
|
||||||
|
// 백드롭 블러 효과를 위한 그라디언트
|
||||||
|
static const List<Color> glassGradient = [
|
||||||
|
Color(0x1AFFFFFF), // 10% white
|
||||||
|
Color(0x0FFFFFFF), // 6% white
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<Color> glassGradientDark = [
|
||||||
|
Color(0x1A000000), // 10% black
|
||||||
|
Color(0x0F000000), // 6% black
|
||||||
|
];
|
||||||
|
|
||||||
|
// 시간대별 배경 그라디언트
|
||||||
|
static const List<Color> morningGradient = [
|
||||||
|
Color(0xFFFED7AA), // 따뜻한 오렌지
|
||||||
|
Color(0xFFFBBF24), // 부드러운 노랑
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<Color> dayGradient = [
|
||||||
|
Color(0xFFDDEAFC), // 연한 하늘색
|
||||||
|
Color(0xFFBFDBFE), // 맑은 파랑
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<Color> eveningGradient = [
|
||||||
|
Color(0xFFFCA5A5), // 부드러운 핑크
|
||||||
|
Color(0xFFC084FC), // 연한 보라
|
||||||
|
];
|
||||||
|
|
||||||
|
static const List<Color> nightGradient = [
|
||||||
|
Color(0xFF4338CA), // 깊은 인디고
|
||||||
|
Color(0xFF1E1B4B), // 다크 네이비
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class AppTheme {
|
|||||||
cardTheme: CardTheme(
|
cardTheme: CardTheme(
|
||||||
color: AppColors.cardColor,
|
color: AppColors.cardColor,
|
||||||
elevation: 1,
|
elevation: 1,
|
||||||
shadowColor: Colors.black.withOpacity(0.04),
|
shadowColor: Colors.black.withValues(alpha: 0.04),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: BorderSide(color: AppColors.borderColor, width: 0.5),
|
side: BorderSide(color: AppColors.borderColor, width: 0.5),
|
||||||
@@ -265,7 +265,7 @@ class AppTheme {
|
|||||||
}),
|
}),
|
||||||
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
|
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||||
if (states.contains(MaterialState.selected)) {
|
if (states.contains(MaterialState.selected)) {
|
||||||
return AppColors.primaryColor.withOpacity(0.5);
|
return AppColors.primaryColor.withValues(alpha: 0.5);
|
||||||
}
|
}
|
||||||
return AppColors.borderColor;
|
return AppColors.borderColor;
|
||||||
}),
|
}),
|
||||||
@@ -300,7 +300,7 @@ class AppTheme {
|
|||||||
activeTrackColor: AppColors.primaryColor,
|
activeTrackColor: AppColors.primaryColor,
|
||||||
inactiveTrackColor: AppColors.borderColor,
|
inactiveTrackColor: AppColors.borderColor,
|
||||||
thumbColor: AppColors.primaryColor,
|
thumbColor: AppColors.primaryColor,
|
||||||
overlayColor: AppColors.primaryColor.withOpacity(0.2),
|
overlayColor: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||||
trackHeight: 4,
|
trackHeight: 4,
|
||||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||||
|
|||||||
74
lib/utils/haptic_feedback_helper.dart
Normal file
74
lib/utils/haptic_feedback_helper.dart
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
/// 햅틱 피드백을 관리하는 헬퍼 클래스
|
||||||
|
class HapticFeedbackHelper {
|
||||||
|
static bool _isEnabled = true;
|
||||||
|
|
||||||
|
/// 햅틱 피드백 활성화 여부 설정
|
||||||
|
static void setEnabled(bool enabled) {
|
||||||
|
_isEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가벼운 햅틱 피드백
|
||||||
|
static Future<void> lightImpact() async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.lightImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 중간 강도 햅틱 피드백
|
||||||
|
static Future<void> mediumImpact() async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 강한 햅틱 피드백
|
||||||
|
static Future<void> heavyImpact() async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.heavyImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine)
|
||||||
|
static Future<void> selectionClick() async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.selectionClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 진동 패턴 (Android)
|
||||||
|
static Future<void> vibrate({int duration = 50}) async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.vibrate();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 성공 피드백 패턴
|
||||||
|
static Future<void> success() async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
await HapticFeedback.lightImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 에러 피드백 패턴
|
||||||
|
static Future<void> error() async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.heavyImpact();
|
||||||
|
await Future.delayed(const Duration(milliseconds: 100));
|
||||||
|
await HapticFeedback.heavyImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 경고 피드백 패턴
|
||||||
|
static Future<void> warning() async {
|
||||||
|
if (!_isEnabled || !_isPlatformSupported()) return;
|
||||||
|
await HapticFeedback.mediumImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 플랫폼이 햅틱 피드백을 지원하는지 확인
|
||||||
|
static bool _isPlatformSupported() {
|
||||||
|
try {
|
||||||
|
return Platform.isIOS || Platform.isAndroid;
|
||||||
|
} catch (e) {
|
||||||
|
// 웹이나 데스크톱에서는 Platform을 사용할 수 없음
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
287
lib/utils/memory_manager.dart
Normal file
287
lib/utils/memory_manager.dart
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'dart:collection';
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 메모리 관리를 위한 헬퍼 클래스
|
||||||
|
class MemoryManager {
|
||||||
|
static final MemoryManager _instance = MemoryManager._internal();
|
||||||
|
factory MemoryManager() => _instance;
|
||||||
|
MemoryManager._internal();
|
||||||
|
|
||||||
|
// 캐시 관리
|
||||||
|
final Map<String, _CacheEntry> _cache = {};
|
||||||
|
final int _maxCacheSize = 100;
|
||||||
|
final Duration _defaultTTL = const Duration(minutes: 5);
|
||||||
|
|
||||||
|
// 이미지 캐시 관리
|
||||||
|
static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB
|
||||||
|
static const int maxImageCacheCount = 100;
|
||||||
|
|
||||||
|
// 위젯 참조 추적
|
||||||
|
final Map<String, WeakReference<State>> _widgetReferences = {};
|
||||||
|
|
||||||
|
/// 캐시에 데이터 저장
|
||||||
|
void cacheData<T>({
|
||||||
|
required String key,
|
||||||
|
required T data,
|
||||||
|
Duration? ttl,
|
||||||
|
}) {
|
||||||
|
_cleanupExpiredCache();
|
||||||
|
|
||||||
|
if (_cache.length >= _maxCacheSize) {
|
||||||
|
_evictOldestEntry();
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache[key] = _CacheEntry(
|
||||||
|
data: data,
|
||||||
|
timestamp: DateTime.now(),
|
||||||
|
ttl: ttl ?? _defaultTTL,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 캐시에서 데이터 가져오기
|
||||||
|
T? getCachedData<T>(String key) {
|
||||||
|
final entry = _cache[key];
|
||||||
|
if (entry == null) return null;
|
||||||
|
|
||||||
|
if (entry.isExpired) {
|
||||||
|
_cache.remove(key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.lastAccess = DateTime.now();
|
||||||
|
return entry.data as T?;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 캐시 비우기
|
||||||
|
void clearCache() {
|
||||||
|
_cache.clear();
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('🧹 메모리 캐시가 비워졌습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 특정 패턴의 캐시 제거
|
||||||
|
void clearCacheByPattern(String pattern) {
|
||||||
|
final keysToRemove = _cache.keys
|
||||||
|
.where((key) => key.contains(pattern))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (final key in keysToRemove) {
|
||||||
|
_cache.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 만료된 캐시 정리
|
||||||
|
void _cleanupExpiredCache() {
|
||||||
|
final expiredKeys = _cache.entries
|
||||||
|
.where((entry) => entry.value.isExpired)
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (final key in expiredKeys) {
|
||||||
|
_cache.remove(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 가장 오래된 캐시 항목 제거
|
||||||
|
void _evictOldestEntry() {
|
||||||
|
if (_cache.isEmpty) return;
|
||||||
|
|
||||||
|
var oldestKey = _cache.keys.first;
|
||||||
|
var oldestTime = _cache[oldestKey]!.lastAccess;
|
||||||
|
|
||||||
|
for (final entry in _cache.entries) {
|
||||||
|
if (entry.value.lastAccess.isBefore(oldestTime)) {
|
||||||
|
oldestKey = entry.key;
|
||||||
|
oldestTime = entry.value.lastAccess;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_cache.remove(oldestKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미지 캐시 최적화
|
||||||
|
static void optimizeImageCache() {
|
||||||
|
PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount;
|
||||||
|
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미지 캐시 상태 확인
|
||||||
|
static ImageCacheStatus getImageCacheStatus() {
|
||||||
|
final cache = PaintingBinding.instance.imageCache;
|
||||||
|
return ImageCacheStatus(
|
||||||
|
currentSize: cache.currentSize,
|
||||||
|
maximumSize: cache.maximumSize,
|
||||||
|
currentSizeBytes: cache.currentSizeBytes,
|
||||||
|
maximumSizeBytes: cache.maximumSizeBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미지 캐시 비우기
|
||||||
|
static void clearImageCache() {
|
||||||
|
PaintingBinding.instance.imageCache.clear();
|
||||||
|
PaintingBinding.instance.imageCache.clearLiveImages();
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('🖼️ 이미지 캐시가 비워졌습니다.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 위젯 참조 추적
|
||||||
|
void trackWidget(String key, State widget) {
|
||||||
|
_widgetReferences[key] = WeakReference(widget);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 위젯 참조 제거
|
||||||
|
void untrackWidget(String key) {
|
||||||
|
_widgetReferences.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 살아있는 위젯 수 확인
|
||||||
|
int getAliveWidgetCount() {
|
||||||
|
return _widgetReferences.values
|
||||||
|
.where((ref) => ref.target != null)
|
||||||
|
.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 메모리 압박 시 대응
|
||||||
|
void handleMemoryPressure() {
|
||||||
|
// 캐시 50% 제거
|
||||||
|
final keysToRemove = _cache.keys.take(_cache.length ~/ 2).toList();
|
||||||
|
for (final key in keysToRemove) {
|
||||||
|
_cache.remove(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 이미지 캐시 축소
|
||||||
|
final imageCache = PaintingBinding.instance.imageCache;
|
||||||
|
imageCache.maximumSize = maxImageCacheCount ~/ 2;
|
||||||
|
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
|
||||||
|
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('⚠️ 메모리 압박 대응: 캐시 크기 감소');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 자동 메모리 정리 시작
|
||||||
|
Timer? _cleanupTimer;
|
||||||
|
|
||||||
|
void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) {
|
||||||
|
_cleanupTimer?.cancel();
|
||||||
|
_cleanupTimer = Timer.periodic(interval, (_) {
|
||||||
|
_cleanupExpiredCache();
|
||||||
|
|
||||||
|
// 죽은 위젯 참조 제거
|
||||||
|
final deadKeys = _widgetReferences.entries
|
||||||
|
.where((entry) => entry.value.target == null)
|
||||||
|
.map((entry) => entry.key)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
for (final key in deadKeys) {
|
||||||
|
_widgetReferences.remove(key);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 자동 메모리 정리 중지
|
||||||
|
void stopAutoCleanup() {
|
||||||
|
_cleanupTimer?.cancel();
|
||||||
|
_cleanupTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 메모리 사용량 리포트
|
||||||
|
Map<String, dynamic> getMemoryReport() {
|
||||||
|
return {
|
||||||
|
'cacheSize': _cache.length,
|
||||||
|
'maxCacheSize': _maxCacheSize,
|
||||||
|
'aliveWidgets': getAliveWidgetCount(),
|
||||||
|
'totalWidgetReferences': _widgetReferences.length,
|
||||||
|
'imageCacheStatus': getImageCacheStatus().toJson(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 캐시 항목 클래스
|
||||||
|
class _CacheEntry {
|
||||||
|
final dynamic data;
|
||||||
|
final DateTime timestamp;
|
||||||
|
final Duration ttl;
|
||||||
|
DateTime lastAccess;
|
||||||
|
|
||||||
|
_CacheEntry({
|
||||||
|
required this.data,
|
||||||
|
required this.timestamp,
|
||||||
|
required this.ttl,
|
||||||
|
}) : lastAccess = timestamp;
|
||||||
|
|
||||||
|
bool get isExpired => DateTime.now().difference(timestamp) > ttl;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미지 캐시 상태 클래스
|
||||||
|
class ImageCacheStatus {
|
||||||
|
final int currentSize;
|
||||||
|
final int maximumSize;
|
||||||
|
final int currentSizeBytes;
|
||||||
|
final int maximumSizeBytes;
|
||||||
|
|
||||||
|
ImageCacheStatus({
|
||||||
|
required this.currentSize,
|
||||||
|
required this.maximumSize,
|
||||||
|
required this.currentSizeBytes,
|
||||||
|
required this.maximumSizeBytes,
|
||||||
|
});
|
||||||
|
|
||||||
|
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
|
||||||
|
double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100;
|
||||||
|
|
||||||
|
Map<String, dynamic> toJson() => {
|
||||||
|
'currentSize': currentSize,
|
||||||
|
'maximumSize': maximumSize,
|
||||||
|
'currentSizeBytes': currentSizeBytes,
|
||||||
|
'maximumSizeBytes': maximumSizeBytes,
|
||||||
|
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
|
||||||
|
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 메모리 효율적인 리스트 뷰
|
||||||
|
class MemoryEfficientListView<T> extends StatefulWidget {
|
||||||
|
final List<T> items;
|
||||||
|
final Widget Function(BuildContext, T) itemBuilder;
|
||||||
|
final int cacheExtent;
|
||||||
|
final ScrollPhysics? physics;
|
||||||
|
|
||||||
|
const MemoryEfficientListView({
|
||||||
|
super.key,
|
||||||
|
required this.items,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.cacheExtent = 250,
|
||||||
|
this.physics,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MemoryEfficientListView<T>> createState() =>
|
||||||
|
_MemoryEfficientListViewState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MemoryEfficientListViewState<T>
|
||||||
|
extends State<MemoryEfficientListView<T>>
|
||||||
|
with AutomaticKeepAliveClientMixin {
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
|
|
||||||
|
return ListView.builder(
|
||||||
|
itemCount: widget.items.length,
|
||||||
|
cacheExtent: widget.cacheExtent.toDouble(),
|
||||||
|
physics: widget.physics ?? const BouncingScrollPhysics(),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return widget.itemBuilder(context, widget.items[index]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
204
lib/utils/performance_optimizer.dart
Normal file
204
lib/utils/performance_optimizer.dart
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/scheduler.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:developer' as developer;
|
||||||
|
|
||||||
|
/// 성능 최적화를 위한 유틸리티 클래스
|
||||||
|
class PerformanceOptimizer {
|
||||||
|
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal();
|
||||||
|
factory PerformanceOptimizer() => _instance;
|
||||||
|
PerformanceOptimizer._internal();
|
||||||
|
|
||||||
|
// 프레임 타이밍 정보
|
||||||
|
final List<FrameTiming> _frameTimings = [];
|
||||||
|
bool _isMonitoring = false;
|
||||||
|
|
||||||
|
/// 프레임 성능 모니터링 시작
|
||||||
|
void startFrameMonitoring() {
|
||||||
|
if (_isMonitoring) return;
|
||||||
|
_isMonitoring = true;
|
||||||
|
|
||||||
|
SchedulerBinding.instance.addTimingsCallback((timings) {
|
||||||
|
_frameTimings.addAll(timings);
|
||||||
|
// 최근 100개 프레임만 유지
|
||||||
|
if (_frameTimings.length > 100) {
|
||||||
|
_frameTimings.removeRange(0, _frameTimings.length - 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 프레임 성능 모니터링 중지
|
||||||
|
void stopFrameMonitoring() {
|
||||||
|
if (!_isMonitoring) return;
|
||||||
|
_isMonitoring = false;
|
||||||
|
SchedulerBinding.instance.addTimingsCallback((_) {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 평균 FPS 계산
|
||||||
|
double getAverageFPS() {
|
||||||
|
if (_frameTimings.isEmpty) return 0.0;
|
||||||
|
|
||||||
|
double totalDuration = 0;
|
||||||
|
for (final timing in _frameTimings) {
|
||||||
|
totalDuration += timing.totalSpan.inMicroseconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
final averageDuration = totalDuration / _frameTimings.length;
|
||||||
|
return 1000000 / averageDuration; // microseconds to FPS
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 메모리 사용량 모니터링
|
||||||
|
static Future<MemoryInfo> getMemoryInfo() async {
|
||||||
|
// Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로
|
||||||
|
// 이미지 캐시 사용량을 기준으로 측정
|
||||||
|
final imageCache = PaintingBinding.instance.imageCache;
|
||||||
|
return MemoryInfo(
|
||||||
|
currentUsage: imageCache.currentSizeBytes,
|
||||||
|
capacity: imageCache.maximumSizeBytes,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 위젯 재빌드 최적화를 위한 데바운서
|
||||||
|
static Timer? _debounceTimer;
|
||||||
|
static void debounce(
|
||||||
|
VoidCallback callback, {
|
||||||
|
Duration delay = const Duration(milliseconds: 300),
|
||||||
|
}) {
|
||||||
|
_debounceTimer?.cancel();
|
||||||
|
_debounceTimer = Timer(delay, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스로틀링 - 지정된 시간 간격으로만 실행
|
||||||
|
static DateTime? _lastThrottleTime;
|
||||||
|
static void throttle(
|
||||||
|
VoidCallback callback, {
|
||||||
|
Duration interval = const Duration(milliseconds: 300),
|
||||||
|
}) {
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastThrottleTime == null ||
|
||||||
|
now.difference(_lastThrottleTime!) > interval) {
|
||||||
|
_lastThrottleTime = now;
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 무거운 연산을 별도 Isolate에서 실행
|
||||||
|
static Future<T> runInIsolate<T>(
|
||||||
|
ComputeCallback<dynamic, T> callback,
|
||||||
|
dynamic parameter,
|
||||||
|
) async {
|
||||||
|
return await compute(callback, parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 레이지 로딩을 위한 페이지네이션 헬퍼
|
||||||
|
static List<T> paginate<T>({
|
||||||
|
required List<T> items,
|
||||||
|
required int page,
|
||||||
|
required int pageSize,
|
||||||
|
}) {
|
||||||
|
final startIndex = page * pageSize;
|
||||||
|
final endIndex = (startIndex + pageSize).clamp(0, items.length);
|
||||||
|
|
||||||
|
if (startIndex >= items.length) return [];
|
||||||
|
return items.sublist(startIndex, endIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미지 최적화 - 메모리 효율적인 크기로 조정
|
||||||
|
static double getOptimalImageSize(BuildContext context, {
|
||||||
|
required double originalSize,
|
||||||
|
double maxSize = 1000,
|
||||||
|
}) {
|
||||||
|
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
|
final screenSize = MediaQuery.of(context).size;
|
||||||
|
final maxDimension = screenSize.width > screenSize.height
|
||||||
|
? screenSize.width
|
||||||
|
: screenSize.height;
|
||||||
|
|
||||||
|
final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize);
|
||||||
|
return optimalSize < originalSize ? optimalSize : originalSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 위젯 키 최적화
|
||||||
|
static Key generateOptimizedKey(String prefix, dynamic identifier) {
|
||||||
|
return ValueKey('${prefix}_$identifier');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 애니메이션 최적화 - 보이지 않는 애니메이션 중지
|
||||||
|
static bool shouldAnimateWidget(BuildContext context) {
|
||||||
|
final mediaQuery = MediaQuery.of(context);
|
||||||
|
return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스크롤 성능 최적화
|
||||||
|
static ScrollPhysics getOptimizedScrollPhysics() {
|
||||||
|
return const BouncingScrollPhysics(
|
||||||
|
parent: AlwaysScrollableScrollPhysics(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 빌드 최적화를 위한 const 위젯 권장사항 체크
|
||||||
|
static void checkConstOptimization() {
|
||||||
|
if (kDebugMode) {
|
||||||
|
print('💡 성능 최적화 팁:');
|
||||||
|
print('1. 가능한 모든 위젯에 const 사용');
|
||||||
|
print('2. StatelessWidget 대신 const 생성자 사용');
|
||||||
|
print('3. 큰 리스트는 ListView.builder 사용');
|
||||||
|
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드');
|
||||||
|
print('5. 애니메이션은 AnimatedBuilder 사용');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 메모리 누수 감지 헬퍼
|
||||||
|
static final Map<String, int> _widgetCounts = {};
|
||||||
|
|
||||||
|
static void trackWidget(String widgetName, bool isCreated) {
|
||||||
|
if (!kDebugMode) return;
|
||||||
|
|
||||||
|
_widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) +
|
||||||
|
(isCreated ? 1 : -1);
|
||||||
|
|
||||||
|
// 위젯이 비정상적으로 많이 생성되면 경고
|
||||||
|
if ((_widgetCounts[widgetName] ?? 0) > 100) {
|
||||||
|
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 메모리 정보 클래스
|
||||||
|
class MemoryInfo {
|
||||||
|
final int currentUsage;
|
||||||
|
final int capacity;
|
||||||
|
|
||||||
|
MemoryInfo({
|
||||||
|
required this.currentUsage,
|
||||||
|
required this.capacity,
|
||||||
|
});
|
||||||
|
|
||||||
|
double get usagePercentage => (currentUsage / capacity) * 100;
|
||||||
|
|
||||||
|
String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||||
|
String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 성능 측정 데코레이터
|
||||||
|
class PerformanceMeasure {
|
||||||
|
static Future<T> measure<T>({
|
||||||
|
required String name,
|
||||||
|
required Future<T> Function() operation,
|
||||||
|
}) async {
|
||||||
|
if (!kDebugMode) return await operation();
|
||||||
|
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
try {
|
||||||
|
final result = await operation();
|
||||||
|
stopwatch.stop();
|
||||||
|
print('✅ $name 완료: ${stopwatch.elapsedMilliseconds}ms');
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
stopwatch.stop();
|
||||||
|
print('❌ $name 실패: ${stopwatch.elapsedMilliseconds}ms - $e');
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
83
lib/widgets/analysis/analysis_badge.dart
Normal file
83
lib/widgets/analysis/analysis_badge.dart
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import '../../models/subscription_model.dart';
|
||||||
|
import '../../services/currency_util.dart';
|
||||||
|
|
||||||
|
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
|
||||||
|
class AnalysisBadge extends StatelessWidget {
|
||||||
|
final double size;
|
||||||
|
final Color borderColor;
|
||||||
|
final SubscriptionModel subscription;
|
||||||
|
|
||||||
|
const AnalysisBadge({
|
||||||
|
super.key,
|
||||||
|
required this.size,
|
||||||
|
required this.borderColor,
|
||||||
|
required this.subscription,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedContainer(
|
||||||
|
duration: PieChart.defaultDuration,
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(
|
||||||
|
color: borderColor,
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.5),
|
||||||
|
blurRadius: 10,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
subscription.serviceName.length > 5
|
||||||
|
? '${subscription.serviceName.substring(0, 5)}...'
|
||||||
|
: subscription.serviceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 8,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: CurrencyUtil.formatAmount(
|
||||||
|
subscription.monthlyCost,
|
||||||
|
subscription.currency,
|
||||||
|
),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
final amountText = snapshot.data!;
|
||||||
|
// 금액이 너무 길면 축약
|
||||||
|
final displayText = amountText.length > 8
|
||||||
|
? amountText.replaceAll('원', '').trim()
|
||||||
|
: amountText;
|
||||||
|
return Text(
|
||||||
|
displayText,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 7,
|
||||||
|
color: Colors.black54,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
19
lib/widgets/analysis/analysis_screen_spacer.dart
Normal file
19
lib/widgets/analysis/analysis_screen_spacer.dart
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
/// 분석 화면에서 사용하는 간격 위젯
|
||||||
|
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
|
||||||
|
class AnalysisScreenSpacer extends StatelessWidget {
|
||||||
|
final double height;
|
||||||
|
|
||||||
|
const AnalysisScreenSpacer({
|
||||||
|
super.key,
|
||||||
|
this.height = 24,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: SizedBox(height: height),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
272
lib/widgets/analysis/event_analysis_card.dart
Normal file
272
lib/widgets/analysis/event_analysis_card.dart
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../../providers/subscription_provider.dart';
|
||||||
|
import '../../services/currency_util.dart';
|
||||||
|
import '../glassmorphism_card.dart';
|
||||||
|
import '../themed_text.dart';
|
||||||
|
|
||||||
|
/// 이벤트 할인 현황을 보여주는 카드 위젯
|
||||||
|
class EventAnalysisCard extends StatelessWidget {
|
||||||
|
final AnimationController animationController;
|
||||||
|
|
||||||
|
const EventAnalysisCard({
|
||||||
|
super.key,
|
||||||
|
required this.animationController,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Consumer<SubscriptionProvider>(
|
||||||
|
builder: (context, provider, child) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: provider.activeEventSubscriptions.isNotEmpty
|
||||||
|
? FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.2),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
|
||||||
|
)),
|
||||||
|
child: GlassmorphismCard(
|
||||||
|
blur: 10,
|
||||||
|
opacity: 0.1,
|
||||||
|
borderRadius: 16,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
ThemedText.headline(
|
||||||
|
text: '이벤트 할인 현황',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(0xFFFF6B6B),
|
||||||
|
Color(0xFFFE7E7E),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const FaIcon(
|
||||||
|
FontAwesomeIcons.fire,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
'${provider.activeEventSubscriptions.length}개 진행중',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
||||||
|
const Color(0xFFFF8787).withValues(alpha: 0.1),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.savings,
|
||||||
|
color: Color(0xFFFF6B6B),
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const ThemedText(
|
||||||
|
'월간 절약 금액',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ThemedText(
|
||||||
|
CurrencyUtil.formatTotalAmount(
|
||||||
|
provider.calculateTotalSavings(),
|
||||||
|
),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 20,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFFFF6B6B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
const ThemedText(
|
||||||
|
'진행중인 이벤트',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
...provider.activeEventSubscriptions.map((sub) {
|
||||||
|
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice);
|
||||||
|
final discountRate =
|
||||||
|
((savings / sub.originalPrice) * 100).round();
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.only(bottom: 8),
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.05),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.white.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText(
|
||||||
|
sub.serviceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: CurrencyUtil
|
||||||
|
.formatAmount(
|
||||||
|
sub.originalPrice,
|
||||||
|
sub.currency),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return ThemedText(
|
||||||
|
snapshot.data!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
decoration: TextDecoration
|
||||||
|
.lineThrough,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Icon(
|
||||||
|
Icons.arrow_forward,
|
||||||
|
size: 12,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: CurrencyUtil
|
||||||
|
.formatAmount(
|
||||||
|
sub.eventPrice ?? sub.originalPrice,
|
||||||
|
sub.currency),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return ThemedText(
|
||||||
|
snapshot.data!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight:
|
||||||
|
FontWeight.bold,
|
||||||
|
color:
|
||||||
|
Color(0xFF10B981),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFFF6B6B)
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'$discountRate% 할인',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Color(0xFFFF6B6B),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
214
lib/widgets/analysis/monthly_expense_chart_card.dart
Normal file
214
lib/widgets/analysis/monthly_expense_chart_card.dart
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import '../../services/currency_util.dart';
|
||||||
|
import '../glassmorphism_card.dart';
|
||||||
|
import '../themed_text.dart';
|
||||||
|
|
||||||
|
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
||||||
|
class MonthlyExpenseChartCard extends StatelessWidget {
|
||||||
|
final List<Map<String, dynamic>> monthlyData;
|
||||||
|
final AnimationController animationController;
|
||||||
|
|
||||||
|
const MonthlyExpenseChartCard({
|
||||||
|
super.key,
|
||||||
|
required this.monthlyData,
|
||||||
|
required this.animationController,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 월간 지출 차트 데이터
|
||||||
|
List<BarChartGroupData> _getMonthlyBarGroups() {
|
||||||
|
final List<BarChartGroupData> barGroups = [];
|
||||||
|
final calculatedMax = monthlyData.fold<double>(
|
||||||
|
0, (max, data) => math.max(max, data['totalExpense'] as double));
|
||||||
|
final maxAmount = calculatedMax > 0 ? calculatedMax : 100000.0; // 기본값 10만원
|
||||||
|
|
||||||
|
for (int i = 0; i < monthlyData.length; i++) {
|
||||||
|
final data = monthlyData[i];
|
||||||
|
barGroups.add(
|
||||||
|
BarChartGroupData(
|
||||||
|
x: i,
|
||||||
|
barRods: [
|
||||||
|
BarChartRodData(
|
||||||
|
toY: data['totalExpense'],
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
const Color(0xFF3B82F6).withValues(alpha: 0.7),
|
||||||
|
const Color(0xFF60A5FA),
|
||||||
|
],
|
||||||
|
begin: Alignment.bottomCenter,
|
||||||
|
end: Alignment.topCenter,
|
||||||
|
),
|
||||||
|
width: 18,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
backDrawRodData: BackgroundBarChartRodData(
|
||||||
|
show: true,
|
||||||
|
toY: maxAmount + (maxAmount * 0.1),
|
||||||
|
color: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return barGroups;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.2),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
|
||||||
|
)),
|
||||||
|
child: GlassmorphismCard(
|
||||||
|
blur: 10,
|
||||||
|
opacity: 0.1,
|
||||||
|
borderRadius: 16,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.headline(
|
||||||
|
text: '월별 지출 현황',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ThemedText.subtitle(
|
||||||
|
text: '최근 6개월간 추이',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
// 바 차트
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1.6,
|
||||||
|
child: BarChart(
|
||||||
|
BarChartData(
|
||||||
|
alignment: BarChartAlignment.spaceAround,
|
||||||
|
maxY: math.max(
|
||||||
|
monthlyData.fold<double>(
|
||||||
|
0,
|
||||||
|
(max, data) => math.max(
|
||||||
|
max, data['totalExpense'] as double)) *
|
||||||
|
1.2,
|
||||||
|
100000.0 // 최소값 10만원
|
||||||
|
),
|
||||||
|
barGroups: _getMonthlyBarGroups(),
|
||||||
|
gridData: FlGridData(
|
||||||
|
show: true,
|
||||||
|
drawVerticalLine: false,
|
||||||
|
horizontalInterval: math.max(
|
||||||
|
monthlyData.fold<double>(
|
||||||
|
0,
|
||||||
|
(max, data) => math.max(max,
|
||||||
|
data['totalExpense'] as double)) /
|
||||||
|
4,
|
||||||
|
25000.0 // 최소 간격 2.5만원
|
||||||
|
),
|
||||||
|
getDrawingHorizontalLine: (value) {
|
||||||
|
return FlLine(
|
||||||
|
color: Colors.grey.withValues(alpha: 0.1),
|
||||||
|
strokeWidth: 1,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
titlesData: FlTitlesData(
|
||||||
|
show: true,
|
||||||
|
topTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
bottomTitles: AxisTitles(
|
||||||
|
sideTitles: SideTitles(
|
||||||
|
showTitles: true,
|
||||||
|
getTitlesWidget: (value, meta) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 8),
|
||||||
|
child: ThemedText.caption(
|
||||||
|
text: monthlyData[value.toInt()]
|
||||||
|
['monthName'],
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
leftTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
rightTitles: const AxisTitles(
|
||||||
|
sideTitles: SideTitles(showTitles: false),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
barTouchData: BarTouchData(
|
||||||
|
enabled: true,
|
||||||
|
touchTooltipData: BarTouchTooltipData(
|
||||||
|
tooltipBgColor: Colors.blueGrey.shade800,
|
||||||
|
tooltipRoundedRadius: 8,
|
||||||
|
getTooltipItem:
|
||||||
|
(group, groupIndex, rod, rodIndex) {
|
||||||
|
return BarTooltipItem(
|
||||||
|
'${monthlyData[group.x]['monthName']}\n',
|
||||||
|
const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: CurrencyUtil.formatTotalAmount(
|
||||||
|
monthlyData[group.x]['totalExpense']
|
||||||
|
as double),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.yellow,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: ThemedText.caption(
|
||||||
|
text: '월 구독 지출',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
294
lib/widgets/analysis/subscription_pie_chart_card.dart
Normal file
294
lib/widgets/analysis/subscription_pie_chart_card.dart
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:fl_chart/fl_chart.dart';
|
||||||
|
import '../../models/subscription_model.dart';
|
||||||
|
import '../../services/currency_util.dart';
|
||||||
|
import '../glassmorphism_card.dart';
|
||||||
|
import '../themed_text.dart';
|
||||||
|
import 'analysis_badge.dart';
|
||||||
|
|
||||||
|
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
||||||
|
class SubscriptionPieChartCard extends StatelessWidget {
|
||||||
|
final List<SubscriptionModel> subscriptions;
|
||||||
|
final AnimationController animationController;
|
||||||
|
final int touchedIndex;
|
||||||
|
final Function(int) onPieTouch;
|
||||||
|
|
||||||
|
const SubscriptionPieChartCard({
|
||||||
|
super.key,
|
||||||
|
required this.subscriptions,
|
||||||
|
required this.animationController,
|
||||||
|
required this.touchedIndex,
|
||||||
|
required this.onPieTouch,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 파이 차트 섹션 데이터
|
||||||
|
List<PieChartSectionData> _getPieSections() {
|
||||||
|
if (subscriptions.isEmpty) return [];
|
||||||
|
|
||||||
|
final colors = [
|
||||||
|
const Color(0xFF3B82F6),
|
||||||
|
const Color(0xFF10B981),
|
||||||
|
const Color(0xFFF59E0B),
|
||||||
|
const Color(0xFFEF4444),
|
||||||
|
const Color(0xFF8B5CF6),
|
||||||
|
const Color(0xFF0EA5E9),
|
||||||
|
const Color(0xFFEC4899),
|
||||||
|
];
|
||||||
|
|
||||||
|
// 개별 구독의 비율 계산을 위한 값들
|
||||||
|
List<double> sectionValues = [];
|
||||||
|
|
||||||
|
// 각 구독의 원화 환산 금액 또는 원화 금액을 계산
|
||||||
|
for (var subscription in subscriptions) {
|
||||||
|
double value = subscription.monthlyCost;
|
||||||
|
if (subscription.currency == 'USD') {
|
||||||
|
// USD의 경우 마지막으로 조회된 환율로 대략적인 계산
|
||||||
|
// (정확한 계산은 비동기로 이루어지므로 UI 표시용으로만 사용)
|
||||||
|
const rate = 1350.0; // 기본 환율 (실제 값은 API로 별도로 가져옴)
|
||||||
|
value = value * rate;
|
||||||
|
}
|
||||||
|
sectionValues.add(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 총합 계산
|
||||||
|
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
|
||||||
|
|
||||||
|
// 섹션 데이터 생성
|
||||||
|
return List.generate(subscriptions.length, (i) {
|
||||||
|
final subscription = subscriptions[i];
|
||||||
|
final percentage = (sectionValues[i] / sectionsTotal) * 100;
|
||||||
|
final index = i % colors.length;
|
||||||
|
final isTouched = touchedIndex == i;
|
||||||
|
final fontSize = isTouched ? 16.0 : 12.0;
|
||||||
|
final radius = isTouched ? 105.0 : 100.0;
|
||||||
|
|
||||||
|
return PieChartSectionData(
|
||||||
|
value: sectionValues[i],
|
||||||
|
title: '${percentage.toStringAsFixed(1)}%',
|
||||||
|
titleStyle: TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: const [
|
||||||
|
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
|
||||||
|
],
|
||||||
|
),
|
||||||
|
color: colors[index],
|
||||||
|
radius: radius,
|
||||||
|
titlePositionPercentageOffset: 0.6,
|
||||||
|
badgeWidget: isTouched
|
||||||
|
? AnalysisBadge(
|
||||||
|
size: 40,
|
||||||
|
borderColor: colors[index],
|
||||||
|
subscription: subscription,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
badgePositionPercentageOffset: .98,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.2),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
|
||||||
|
)),
|
||||||
|
child: GlassmorphismCard(
|
||||||
|
blur: 10,
|
||||||
|
opacity: 0.1,
|
||||||
|
borderRadius: 16,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
ThemedText.headline(
|
||||||
|
text: '구독 서비스 비율',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: CurrencyUtil.getExchangeRateInfo(),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData &&
|
||||||
|
snapshot.data!.isNotEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE5F2FF),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFBFDBFE),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
snapshot.data!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF3B82F6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ThemedText.subtitle(
|
||||||
|
text: '월 지출 기준',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Center(
|
||||||
|
child: subscriptions.isEmpty
|
||||||
|
? const SizedBox(
|
||||||
|
height: 250,
|
||||||
|
child: Center(
|
||||||
|
child: ThemedText(
|
||||||
|
'구독중인 서비스가 없습니다',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: SizedBox(
|
||||||
|
height: 250,
|
||||||
|
child: PieChart(
|
||||||
|
PieChartData(
|
||||||
|
borderData: FlBorderData(show: false),
|
||||||
|
sectionsSpace: 2,
|
||||||
|
centerSpaceRadius: 60,
|
||||||
|
sections: _getPieSections(),
|
||||||
|
pieTouchData: PieTouchData(
|
||||||
|
touchCallback: (FlTouchEvent event,
|
||||||
|
pieTouchResponse) {
|
||||||
|
if (!event
|
||||||
|
.isInterestedForInteractions ||
|
||||||
|
pieTouchResponse == null ||
|
||||||
|
pieTouchResponse
|
||||||
|
.touchedSection ==
|
||||||
|
null) {
|
||||||
|
onPieTouch(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPieTouch(pieTouchResponse
|
||||||
|
.touchedSection!
|
||||||
|
.touchedSectionIndex);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
// 서비스 목록
|
||||||
|
Column(
|
||||||
|
children: subscriptions.isEmpty
|
||||||
|
? []
|
||||||
|
: List.generate(
|
||||||
|
subscriptions.length,
|
||||||
|
(index) {
|
||||||
|
final subscription =
|
||||||
|
subscriptions[index];
|
||||||
|
final color = [
|
||||||
|
const Color(0xFF3B82F6),
|
||||||
|
const Color(0xFF10B981),
|
||||||
|
const Color(0xFFF59E0B),
|
||||||
|
const Color(0xFFEF4444),
|
||||||
|
const Color(0xFF8B5CF6),
|
||||||
|
const Color(0xFF0EA5E9),
|
||||||
|
const Color(0xFFEC4899),
|
||||||
|
][index % 7];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
bottom: 4.0),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 12,
|
||||||
|
height: 12,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: color,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: ThemedText(
|
||||||
|
subscription.serviceName,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
overflow:
|
||||||
|
TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future: CurrencyUtil
|
||||||
|
.formatSubscriptionAmount(
|
||||||
|
subscription),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData) {
|
||||||
|
return ThemedText(
|
||||||
|
snapshot.data!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight:
|
||||||
|
FontWeight.bold,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child:
|
||||||
|
CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
228
lib/widgets/analysis/total_expense_summary_card.dart
Normal file
228
lib/widgets/analysis/total_expense_summary_card.dart
Normal file
@@ -0,0 +1,228 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||||
|
import '../../models/subscription_model.dart';
|
||||||
|
import '../../services/currency_util.dart';
|
||||||
|
import '../../utils/haptic_feedback_helper.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
import '../glassmorphism_card.dart';
|
||||||
|
import '../themed_text.dart';
|
||||||
|
|
||||||
|
/// 총 지출 요약을 보여주는 카드 위젯
|
||||||
|
class TotalExpenseSummaryCard extends StatelessWidget {
|
||||||
|
final List<SubscriptionModel> subscriptions;
|
||||||
|
final double totalExpense;
|
||||||
|
final AnimationController animationController;
|
||||||
|
|
||||||
|
const TotalExpenseSummaryCard({
|
||||||
|
super.key,
|
||||||
|
required this.subscriptions,
|
||||||
|
required this.totalExpense,
|
||||||
|
required this.animationController,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||||
|
),
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.2),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animationController,
|
||||||
|
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||||
|
)),
|
||||||
|
child: GlassmorphismCard(
|
||||||
|
blur: 10,
|
||||||
|
opacity: 0.1,
|
||||||
|
borderRadius: 16,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
ThemedText.headline(
|
||||||
|
text: '총 지출 요약',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.content_copy),
|
||||||
|
iconSize: 20,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
constraints: const BoxConstraints(),
|
||||||
|
onPressed: () async {
|
||||||
|
final totalExpenseText = CurrencyUtil.formatTotalAmount(totalExpense);
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: totalExpenseText));
|
||||||
|
HapticFeedbackHelper.lightImpact();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text('총 지출액이 복사되었습니다: $totalExpenseText'),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
behavior: SnackBarBehavior.floating,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ThemedText.subtitle(
|
||||||
|
text: '월 단위 총액',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.caption(
|
||||||
|
text: '총 지출',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ThemedText(
|
||||||
|
CurrencyUtil.formatTotalAmount(totalExpense),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const FaIcon(
|
||||||
|
FontAwesomeIcons.listCheck,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.blue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.caption(
|
||||||
|
text: '총 서비스',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ThemedText(
|
||||||
|
'${subscriptions.length}개',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.glassBackground.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const FaIcon(
|
||||||
|
FontAwesomeIcons.chartLine,
|
||||||
|
size: 16,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.caption(
|
||||||
|
text: '평균 요금',
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ThemedText(
|
||||||
|
CurrencyUtil.formatTotalAmount(
|
||||||
|
subscriptions.isEmpty
|
||||||
|
? 0
|
||||||
|
: totalExpense / subscriptions.length),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
311
lib/widgets/animated_page_transitions.dart
Normal file
311
lib/widgets/animated_page_transitions.dart
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
/// 슬라이드 + 페이드 전환
|
||||||
|
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget page;
|
||||||
|
final AxisDirection direction;
|
||||||
|
|
||||||
|
SlidePageRoute({
|
||||||
|
required this.page,
|
||||||
|
this.direction = AxisDirection.right,
|
||||||
|
}) : super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
Offset begin;
|
||||||
|
switch (direction) {
|
||||||
|
case AxisDirection.right:
|
||||||
|
begin = const Offset(-1.0, 0.0);
|
||||||
|
break;
|
||||||
|
case AxisDirection.left:
|
||||||
|
begin = const Offset(1.0, 0.0);
|
||||||
|
break;
|
||||||
|
case AxisDirection.up:
|
||||||
|
begin = const Offset(0.0, 1.0);
|
||||||
|
break;
|
||||||
|
case AxisDirection.down:
|
||||||
|
begin = const Offset(0.0, -1.0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = Offset.zero;
|
||||||
|
const curve = Curves.easeOutCubic;
|
||||||
|
|
||||||
|
var tween = Tween(begin: begin, end: end).chain(
|
||||||
|
CurveTween(curve: curve),
|
||||||
|
);
|
||||||
|
var offsetAnimation = animation.drive(tween);
|
||||||
|
|
||||||
|
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
|
CurveTween(curve: curve),
|
||||||
|
);
|
||||||
|
var fadeAnimation = animation.drive(fadeTween);
|
||||||
|
|
||||||
|
return SlideTransition(
|
||||||
|
position: offsetAnimation,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: fadeAnimation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 스케일 + 페이드 전환
|
||||||
|
class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget page;
|
||||||
|
final Alignment alignment;
|
||||||
|
|
||||||
|
ScalePageRoute({
|
||||||
|
required this.page,
|
||||||
|
this.alignment = Alignment.center,
|
||||||
|
}) : super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 400),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 400),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
const curve = Curves.elasticOut;
|
||||||
|
|
||||||
|
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
|
CurveTween(curve: curve),
|
||||||
|
);
|
||||||
|
var scaleAnimation = animation.drive(scaleTween);
|
||||||
|
|
||||||
|
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
|
CurveTween(curve: Curves.easeIn),
|
||||||
|
);
|
||||||
|
var fadeAnimation = animation.drive(fadeTween);
|
||||||
|
|
||||||
|
return ScaleTransition(
|
||||||
|
scale: scaleAnimation,
|
||||||
|
alignment: alignment,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: fadeAnimation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 회전 + 스케일 전환
|
||||||
|
class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget page;
|
||||||
|
|
||||||
|
RotatePageRoute({required this.page})
|
||||||
|
: super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
const curve = Curves.easeInOut;
|
||||||
|
|
||||||
|
var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
|
||||||
|
CurveTween(curve: curve),
|
||||||
|
);
|
||||||
|
var rotateAnimation = animation.drive(rotateTween);
|
||||||
|
|
||||||
|
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
|
||||||
|
CurveTween(curve: curve),
|
||||||
|
);
|
||||||
|
var scaleAnimation = animation.drive(scaleTween);
|
||||||
|
|
||||||
|
return Transform(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..setEntry(3, 2, 0.001)
|
||||||
|
..rotateZ(rotateAnimation.value)
|
||||||
|
..scale(scaleAnimation.value),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 3D 플립 전환
|
||||||
|
class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget page;
|
||||||
|
final bool horizontal;
|
||||||
|
|
||||||
|
FlipPageRoute({
|
||||||
|
required this.page,
|
||||||
|
this.horizontal = true,
|
||||||
|
}) : super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 800),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 800),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
final isAnimatingForward = animation.status == AnimationStatus.forward;
|
||||||
|
|
||||||
|
final flipAnimation = Tween(
|
||||||
|
begin: 0.0,
|
||||||
|
end: isAnimatingForward ? -math.pi : math.pi,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
));
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: flipAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
|
||||||
|
|
||||||
|
return Transform(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..setEntry(3, 2, 0.001)
|
||||||
|
..rotateY(horizontal ? flipAnimation.value : 0)
|
||||||
|
..rotateX(horizontal ? 0 : flipAnimation.value),
|
||||||
|
child: isShowingFront
|
||||||
|
? Container()
|
||||||
|
: Transform(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..rotateY(horizontal ? math.pi : 0)
|
||||||
|
..rotateX(horizontal ? 0 : math.pi),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 컨테이너 트랜스폼 (Material Design)
|
||||||
|
class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget page;
|
||||||
|
final Widget startWidget;
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
|
ContainerTransformPageRoute({
|
||||||
|
required this.page,
|
||||||
|
required this.startWidget,
|
||||||
|
this.borderRadius,
|
||||||
|
}) : super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// 배경 페이드
|
||||||
|
FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 컨테이너 확장 애니메이션
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: animation,
|
||||||
|
builder: (context, _) {
|
||||||
|
final progress = animation.value;
|
||||||
|
final scale = 0.5 + (0.5 * progress);
|
||||||
|
final radius = borderRadius?.topLeft.x ?? 0;
|
||||||
|
final currentRadius = radius * (1 - progress);
|
||||||
|
|
||||||
|
return Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(currentRadius),
|
||||||
|
child: progress < 0.5 ? startWidget : child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 커스텀 Hero 애니메이션
|
||||||
|
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget page;
|
||||||
|
final String heroTag;
|
||||||
|
|
||||||
|
CustomHeroPageRoute({
|
||||||
|
required this.page,
|
||||||
|
required this.heroTag,
|
||||||
|
}) : super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 500),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: CurvedAnimation(
|
||||||
|
parent: animation,
|
||||||
|
curve: const Interval(0.5, 1.0),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 공유 축 전환 (Material Design)
|
||||||
|
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
||||||
|
final Widget page;
|
||||||
|
final SharedAxisTransitionType transitionType;
|
||||||
|
|
||||||
|
SharedAxisPageRoute({
|
||||||
|
required this.page,
|
||||||
|
required this.transitionType,
|
||||||
|
}) : super(
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
|
transitionDuration: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 300),
|
||||||
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
|
late final Offset begin;
|
||||||
|
late final Offset end;
|
||||||
|
|
||||||
|
switch (transitionType) {
|
||||||
|
case SharedAxisTransitionType.horizontal:
|
||||||
|
begin = const Offset(1.0, 0.0);
|
||||||
|
end = Offset.zero;
|
||||||
|
break;
|
||||||
|
case SharedAxisTransitionType.vertical:
|
||||||
|
begin = const Offset(0.0, 1.0);
|
||||||
|
end = Offset.zero;
|
||||||
|
break;
|
||||||
|
case SharedAxisTransitionType.scaled:
|
||||||
|
begin = Offset.zero;
|
||||||
|
end = Offset.zero;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
final slideTween = Tween(begin: begin, end: end);
|
||||||
|
final fadeTween = Tween(begin: 0.0, end: 1.0);
|
||||||
|
final scaleTween = transitionType == SharedAxisTransitionType.scaled
|
||||||
|
? Tween(begin: 0.8, end: 1.0)
|
||||||
|
: Tween(begin: 1.0, end: 1.0);
|
||||||
|
|
||||||
|
final slideAnimation = animation.drive(slideTween);
|
||||||
|
final fadeAnimation = animation.drive(fadeTween);
|
||||||
|
final scaleAnimation = animation.drive(scaleTween);
|
||||||
|
|
||||||
|
return SlideTransition(
|
||||||
|
position: slideAnimation,
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: fadeAnimation,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: scaleAnimation,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
enum SharedAxisTransitionType {
|
||||||
|
horizontal,
|
||||||
|
vertical,
|
||||||
|
scaled,
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.1),
|
color: Colors.white.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(100),
|
borderRadius: BorderRadius.circular(100),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -64,7 +64,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
width: 220,
|
width: 220,
|
||||||
height: 220,
|
height: 220,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.05),
|
color: Colors.white.withValues(alpha: 0.05),
|
||||||
borderRadius: BorderRadius.circular(110),
|
borderRadius: BorderRadius.circular(110),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -90,7 +90,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.08),
|
color: Colors.white.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(60),
|
borderRadius: BorderRadius.circular(60),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -109,7 +109,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(
|
color: Colors.white.withValues(alpha:
|
||||||
0.1 + 0.1 * pulseController.value,
|
0.1 + 0.1 * pulseController.value,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(15),
|
borderRadius: BorderRadius.circular(15),
|
||||||
|
|||||||
200
lib/widgets/app_navigator.dart
Normal file
200
lib/widgets/app_navigator.dart
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../screens/main_screen.dart';
|
||||||
|
import '../screens/analysis_screen.dart';
|
||||||
|
import '../screens/add_subscription_screen.dart';
|
||||||
|
import '../screens/detail_screen.dart';
|
||||||
|
import '../screens/settings_screen.dart';
|
||||||
|
import '../screens/sms_scan_screen.dart';
|
||||||
|
import '../screens/category_management_screen.dart';
|
||||||
|
import '../screens/app_lock_screen.dart';
|
||||||
|
import '../models/subscription_model.dart';
|
||||||
|
import '../providers/navigation_provider.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
|
import 'animated_page_transitions.dart';
|
||||||
|
|
||||||
|
/// 앱 전체의 네비게이션을 관리하는 클래스
|
||||||
|
class AppNavigator {
|
||||||
|
// NavigationProvider를 사용하여 상태를 관리하므로 더 이상 싱글톤 패턴이 필요하지 않음
|
||||||
|
|
||||||
|
/// 홈으로 네비게이션
|
||||||
|
static Future<void> toHome(BuildContext context) async {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
navigationProvider.clearHistoryAndGoHome();
|
||||||
|
|
||||||
|
await Navigator.of(context).pushNamedAndRemoveUntil(
|
||||||
|
AppRoutes.main,
|
||||||
|
(route) => false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 분석 화면으로 네비게이션
|
||||||
|
static Future<void> toAnalysis(BuildContext context) async {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
navigationProvider.updateCurrentIndex(1);
|
||||||
|
|
||||||
|
await Navigator.of(context).pushNamed(AppRoutes.analysis);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구독 추가 화면으로 네비게이션
|
||||||
|
static Future<void> toAddSubscription(BuildContext context) async {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 구독 상세 화면으로 네비게이션
|
||||||
|
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
|
await Navigator.of(context).pushNamed(
|
||||||
|
AppRoutes.subscriptionDetail,
|
||||||
|
arguments: subscription,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// SMS 스캔 화면으로 네비게이션
|
||||||
|
static Future<void> toSmsScan(BuildContext context) async {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
navigationProvider.updateCurrentIndex(3);
|
||||||
|
|
||||||
|
await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 설정 화면으로 네비게이션
|
||||||
|
static Future<void> toSettings(BuildContext context) async {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
navigationProvider.updateCurrentIndex(4);
|
||||||
|
|
||||||
|
await Navigator.of(context).pushNamed(AppRoutes.settings);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 카테고리 관리 화면으로 네비게이션
|
||||||
|
static Future<void> toCategoryManagement(BuildContext context) async {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
SlidePageRoute(
|
||||||
|
page: const CategoryManagementScreen(),
|
||||||
|
direction: AxisDirection.up,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 앱 잠금 화면으로 네비게이션
|
||||||
|
static Future<void> toAppLock(BuildContext context) async {
|
||||||
|
await Navigator.of(context).push(
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (context) => const AppLockScreen(),
|
||||||
|
fullscreenDialog: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 뒤로가기 처리
|
||||||
|
static Future<bool> handleBackButton(BuildContext context) async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
|
||||||
|
// 네비게이션 스택이 있으면 팝
|
||||||
|
if (navigator.canPop()) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
|
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
|
||||||
|
if (navigationProvider.canPop()) {
|
||||||
|
navigationProvider.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
navigator.pop();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 앱 종료 확인
|
||||||
|
final shouldExit = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('앱 종료'),
|
||||||
|
content: const Text('SubManager를 종료하시겠습니까?'),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
|
child: const Text('취소'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.of(context).pop(true),
|
||||||
|
child: const Text('종료'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return shouldExit ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 플로팅 네비게이션 바 탭 처리
|
||||||
|
static void handleFloatingNavTap(BuildContext context, int index) {
|
||||||
|
final navigationProvider = context.read<NavigationProvider>();
|
||||||
|
final currentIndex = navigationProvider.currentIndex;
|
||||||
|
|
||||||
|
// 같은 탭을 다시 탭하면 아무 동작 안 함
|
||||||
|
if (currentIndex == index) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
|
||||||
|
if (Navigator.of(context).canPop()) {
|
||||||
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 인덱스에 따라 네비게이션
|
||||||
|
switch (index) {
|
||||||
|
case 0: // 홈
|
||||||
|
navigationProvider.updateCurrentIndex(0);
|
||||||
|
break;
|
||||||
|
case 1: // 분석
|
||||||
|
toAnalysis(context);
|
||||||
|
break;
|
||||||
|
case 2: // 추가
|
||||||
|
toAddSubscription(context);
|
||||||
|
break;
|
||||||
|
case 3: // SMS
|
||||||
|
toSmsScan(context);
|
||||||
|
break;
|
||||||
|
case 4: // 설정
|
||||||
|
toSettings(context);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 네비게이션 관찰자 (디버깅용)
|
||||||
|
class AppNavigationObserver extends NavigatorObserver {
|
||||||
|
@override
|
||||||
|
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
super.didPush(route, previousRoute);
|
||||||
|
debugPrint('Navigation: Push ${route.settings.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
super.didPop(route, previousRoute);
|
||||||
|
debugPrint('Navigation: Pop ${route.settings.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
|
||||||
|
super.didRemove(route, previousRoute);
|
||||||
|
debugPrint('Navigation: Remove ${route.settings.name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
|
||||||
|
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
|
||||||
|
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
|
||||||
|
}
|
||||||
|
}
|
||||||
315
lib/widgets/cached_network_image_widget.dart
Normal file
315
lib/widgets/cached_network_image_widget.dart
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import 'skeleton_loading.dart';
|
||||||
|
|
||||||
|
/// 최적화된 캐시 네트워크 이미지 위젯
|
||||||
|
class OptimizedCachedNetworkImage extends StatelessWidget {
|
||||||
|
final String imageUrl;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final BoxFit fit;
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
final Duration fadeInDuration;
|
||||||
|
final Duration fadeOutDuration;
|
||||||
|
final Widget? placeholder;
|
||||||
|
final Widget? errorWidget;
|
||||||
|
final Map<String, String>? httpHeaders;
|
||||||
|
final bool enableMemoryCache;
|
||||||
|
final bool enableDiskCache;
|
||||||
|
final int? maxWidth;
|
||||||
|
final int? maxHeight;
|
||||||
|
|
||||||
|
const OptimizedCachedNetworkImage({
|
||||||
|
super.key,
|
||||||
|
required this.imageUrl,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.borderRadius,
|
||||||
|
this.fadeInDuration = const Duration(milliseconds: 300),
|
||||||
|
this.fadeOutDuration = const Duration(milliseconds: 300),
|
||||||
|
this.placeholder,
|
||||||
|
this.errorWidget,
|
||||||
|
this.httpHeaders,
|
||||||
|
this.enableMemoryCache = true,
|
||||||
|
this.enableDiskCache = true,
|
||||||
|
this.maxWidth,
|
||||||
|
this.maxHeight,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// 성능 최적화를 위한 이미지 크기 계산
|
||||||
|
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
|
||||||
|
final optimalWidth = maxWidth ??
|
||||||
|
(width != null ? (width! * devicePixelRatio).round() : null);
|
||||||
|
final optimalHeight = maxHeight ??
|
||||||
|
(height != null ? (height! * devicePixelRatio).round() : null);
|
||||||
|
|
||||||
|
Widget image = CachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
|
fadeInDuration: fadeInDuration,
|
||||||
|
fadeOutDuration: fadeOutDuration,
|
||||||
|
httpHeaders: httpHeaders,
|
||||||
|
memCacheWidth: optimalWidth,
|
||||||
|
memCacheHeight: optimalHeight,
|
||||||
|
maxWidthDiskCache: optimalWidth,
|
||||||
|
maxHeightDiskCache: optimalHeight,
|
||||||
|
placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(),
|
||||||
|
errorWidget: (context, url, error) =>
|
||||||
|
errorWidget ?? _buildDefaultErrorWidget(),
|
||||||
|
imageBuilder: (context, imageProvider) {
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
image: DecorationImage(
|
||||||
|
image: imageProvider,
|
||||||
|
fit: fit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (borderRadius != null) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: borderRadius!,
|
||||||
|
child: image,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDefaultPlaceholder() {
|
||||||
|
return SkeletonLoading(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
borderRadius: borderRadius?.topLeft.x ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDefaultErrorWidget() {
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceColorAlt,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.broken_image_outlined,
|
||||||
|
color: AppColors.textMuted,
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 프로그레시브 이미지 로더 (저화질 → 고화질)
|
||||||
|
class ProgressiveNetworkImage extends StatelessWidget {
|
||||||
|
final String thumbnailUrl;
|
||||||
|
final String imageUrl;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final BoxFit fit;
|
||||||
|
final BorderRadius? borderRadius;
|
||||||
|
|
||||||
|
const ProgressiveNetworkImage({
|
||||||
|
super.key,
|
||||||
|
required this.thumbnailUrl,
|
||||||
|
required this.imageUrl,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.borderRadius,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.passthrough,
|
||||||
|
children: [
|
||||||
|
// 썸네일 (저화질)
|
||||||
|
OptimizedCachedNetworkImage(
|
||||||
|
imageUrl: thumbnailUrl,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
fadeInDuration: Duration.zero,
|
||||||
|
),
|
||||||
|
// 원본 이미지 (고화질)
|
||||||
|
OptimizedCachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
|
borderRadius: borderRadius,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 이미지 갤러리 위젯 (메모리 효율적)
|
||||||
|
class OptimizedImageGallery extends StatefulWidget {
|
||||||
|
final List<String> imageUrls;
|
||||||
|
final double itemHeight;
|
||||||
|
final double spacing;
|
||||||
|
final int crossAxisCount;
|
||||||
|
final void Function(int)? onImageTap;
|
||||||
|
|
||||||
|
const OptimizedImageGallery({
|
||||||
|
super.key,
|
||||||
|
required this.imageUrls,
|
||||||
|
this.itemHeight = 120,
|
||||||
|
this.spacing = 8,
|
||||||
|
this.crossAxisCount = 3,
|
||||||
|
this.onImageTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<OptimizedImageGallery> createState() => _OptimizedImageGalleryState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _OptimizedImageGalleryState extends State<OptimizedImageGallery> {
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
final Set<int> _visibleIndices = {};
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
// 초기 보이는 아이템 계산
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
_calculateVisibleIndices();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
_calculateVisibleIndices();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _calculateVisibleIndices() {
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
final viewportHeight = context.size?.height ?? 0;
|
||||||
|
final scrollOffset = _scrollController.offset;
|
||||||
|
final itemHeight = widget.itemHeight + widget.spacing;
|
||||||
|
final itemsPerRow = widget.crossAxisCount;
|
||||||
|
|
||||||
|
final firstVisibleRow = (scrollOffset / itemHeight).floor();
|
||||||
|
final lastVisibleRow = ((scrollOffset + viewportHeight) / itemHeight).ceil();
|
||||||
|
|
||||||
|
final newVisibleIndices = <int>{};
|
||||||
|
for (int row = firstVisibleRow; row <= lastVisibleRow; row++) {
|
||||||
|
for (int col = 0; col < itemsPerRow; col++) {
|
||||||
|
final index = row * itemsPerRow + col;
|
||||||
|
if (index < widget.imageUrls.length) {
|
||||||
|
newVisibleIndices.add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!setEquals(_visibleIndices, newVisibleIndices)) {
|
||||||
|
setState(() {
|
||||||
|
_visibleIndices.clear();
|
||||||
|
_visibleIndices.addAll(newVisibleIndices);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: widget.crossAxisCount,
|
||||||
|
childAspectRatio: 1.0,
|
||||||
|
crossAxisSpacing: widget.spacing,
|
||||||
|
mainAxisSpacing: widget.spacing,
|
||||||
|
),
|
||||||
|
itemCount: widget.imageUrls.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
// 보이는 영역의 이미지만 로드
|
||||||
|
if (_visibleIndices.contains(index) ||
|
||||||
|
(index >= _visibleIndices.first - widget.crossAxisCount &&
|
||||||
|
index <= _visibleIndices.last + widget.crossAxisCount)) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () => widget.onImageTap?.call(index),
|
||||||
|
child: OptimizedCachedNetworkImage(
|
||||||
|
imageUrl: widget.imageUrls[index],
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 보이지 않는 영역은 플레이스홀더
|
||||||
|
return Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.surfaceColorAlt,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool setEquals(Set<int> a, Set<int> b) {
|
||||||
|
if (a.length != b.length) return false;
|
||||||
|
for (final item in a) {
|
||||||
|
if (!b.contains(item)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 히어로 애니메이션이 적용된 이미지
|
||||||
|
class HeroNetworkImage extends StatelessWidget {
|
||||||
|
final String imageUrl;
|
||||||
|
final String heroTag;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final BoxFit fit;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const HeroNetworkImage({
|
||||||
|
super.key,
|
||||||
|
required this.imageUrl,
|
||||||
|
required this.heroTag,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.fit = BoxFit.cover,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Hero(
|
||||||
|
tag: heroTag,
|
||||||
|
child: OptimizedCachedNetworkImage(
|
||||||
|
imageUrl: imageUrl,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
fit: fit,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import 'glassmorphism_card.dart';
|
||||||
|
import 'themed_text.dart';
|
||||||
|
|
||||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||||
///
|
///
|
||||||
@@ -31,21 +33,10 @@ class EmptyStateWidget extends StatelessWidget {
|
|||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(CurvedAnimation(
|
).animate(CurvedAnimation(
|
||||||
parent: slideController, curve: Curves.easeOutBack)),
|
parent: slideController, curve: Curves.easeOutBack)),
|
||||||
child: Container(
|
child: GlassmorphismCard(
|
||||||
|
width: null,
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
padding: const EdgeInsets.all(32),
|
padding: const EdgeInsets.all(32),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.grey.withOpacity(0.08),
|
|
||||||
spreadRadius: 0,
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -65,7 +56,7 @@ class EmptyStateWidget extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF3B82F6).withOpacity(0.3),
|
color: const Color(0xFF3B82F6).withValues(alpha: 0.3),
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
@@ -82,29 +73,17 @@ class EmptyStateWidget extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
ShaderMask(
|
const ThemedText(
|
||||||
shaderCallback: (bounds) => const LinearGradient(
|
'등록된 구독이 없습니다',
|
||||||
colors: [Color(0xFF3B82F6), Color(0xFF0EA5E9)],
|
fontSize: 22,
|
||||||
begin: Alignment.topLeft,
|
fontWeight: FontWeight.w800,
|
||||||
end: Alignment.bottomRight,
|
letterSpacing: -0.5,
|
||||||
).createShader(bounds),
|
|
||||||
child: const Text(
|
|
||||||
'등록된 구독이 없습니다',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: Colors.white,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
const Text(
|
const ThemedText(
|
||||||
'새로운 구독을 추가해보세요',
|
'새로운 구독을 추가해보세요',
|
||||||
style: TextStyle(
|
fontSize: 16,
|
||||||
fontSize: 16,
|
opacity: 0.7,
|
||||||
color: Color(0xFF64748B),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
MouseRegion(
|
MouseRegion(
|
||||||
@@ -133,6 +112,7 @@ class EmptyStateWidget extends StatelessWidget {
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
268
lib/widgets/expandable_fab.dart
Normal file
268
lib/widgets/expandable_fab.dart
Normal file
@@ -0,0 +1,268 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/haptic_feedback_helper.dart';
|
||||||
|
import 'glassmorphism_card.dart';
|
||||||
|
|
||||||
|
class ExpandableFab extends StatefulWidget {
|
||||||
|
final List<FabAction> actions;
|
||||||
|
final double distance;
|
||||||
|
|
||||||
|
const ExpandableFab({
|
||||||
|
super.key,
|
||||||
|
required this.actions,
|
||||||
|
this.distance = 100.0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ExpandableFab> createState() => _ExpandableFabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ExpandableFabState extends State<ExpandableFab>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _expandAnimation;
|
||||||
|
late Animation<double> _rotateAnimation;
|
||||||
|
bool _isExpanded = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_expandAnimation = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOutBack,
|
||||||
|
reverseCurve: Curves.easeInBack,
|
||||||
|
);
|
||||||
|
|
||||||
|
_rotateAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: math.pi / 4,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _toggle() {
|
||||||
|
setState(() {
|
||||||
|
_isExpanded = !_isExpanded;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (_isExpanded) {
|
||||||
|
HapticFeedbackHelper.mediumImpact();
|
||||||
|
_controller.forward();
|
||||||
|
} else {
|
||||||
|
HapticFeedbackHelper.lightImpact();
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
alignment: Alignment.bottomRight,
|
||||||
|
children: [
|
||||||
|
// 배경 오버레이 (확장 시)
|
||||||
|
if (_isExpanded)
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _toggle,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _expandAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.black.withValues(alpha: 0.3 * _expandAnimation.value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 액션 버튼들
|
||||||
|
...widget.actions.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final action = entry.value;
|
||||||
|
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _expandAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final distance = widget.distance * _expandAnimation.value;
|
||||||
|
final x = distance * math.cos(angle);
|
||||||
|
final y = distance * math.sin(angle);
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(-x, -y),
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: _expandAnimation,
|
||||||
|
child: FloatingActionButton.small(
|
||||||
|
heroTag: 'fab_action_$index',
|
||||||
|
onPressed: _isExpanded
|
||||||
|
? () {
|
||||||
|
HapticFeedbackHelper.lightImpact();
|
||||||
|
_toggle();
|
||||||
|
action.onPressed();
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
backgroundColor: action.color ?? AppColors.primaryColor,
|
||||||
|
child: Icon(
|
||||||
|
action.icon,
|
||||||
|
size: 20,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
|
||||||
|
// 메인 FAB
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _rotateAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.rotate(
|
||||||
|
angle: _rotateAnimation.value,
|
||||||
|
child: FloatingActionButton(
|
||||||
|
onPressed: _toggle,
|
||||||
|
backgroundColor: AppColors.primaryColor,
|
||||||
|
child: Icon(
|
||||||
|
_isExpanded ? Icons.close : Icons.add,
|
||||||
|
size: 28,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
|
||||||
|
// 라벨 표시
|
||||||
|
if (_isExpanded)
|
||||||
|
...widget.actions.asMap().entries.map((entry) {
|
||||||
|
final index = entry.key;
|
||||||
|
final action = entry.value;
|
||||||
|
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _expandAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final distance = widget.distance * _expandAnimation.value;
|
||||||
|
final x = distance * math.cos(angle);
|
||||||
|
final y = distance * math.sin(angle);
|
||||||
|
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(-x - 80, -y),
|
||||||
|
child: FadeTransition(
|
||||||
|
opacity: _expandAnimation,
|
||||||
|
child: GlassmorphismCard(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 12,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
borderRadius: 8,
|
||||||
|
blur: 10,
|
||||||
|
child: Text(
|
||||||
|
action.label,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class FabAction {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onPressed;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const FabAction({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onPressed,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 드래그 가능한 FAB
|
||||||
|
class DraggableFab extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsets? padding;
|
||||||
|
|
||||||
|
const DraggableFab({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<DraggableFab> createState() => _DraggableFabState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DraggableFabState extends State<DraggableFab> {
|
||||||
|
Offset _position = const Offset(20, 20);
|
||||||
|
bool _isDragging = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final screenSize = MediaQuery.of(context).size;
|
||||||
|
final padding = widget.padding ?? const EdgeInsets.all(20);
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
Positioned(
|
||||||
|
right: _position.dx,
|
||||||
|
bottom: _position.dy,
|
||||||
|
child: GestureDetector(
|
||||||
|
onPanStart: (_) {
|
||||||
|
setState(() => _isDragging = true);
|
||||||
|
HapticFeedbackHelper.lightImpact();
|
||||||
|
},
|
||||||
|
onPanUpdate: (details) {
|
||||||
|
setState(() {
|
||||||
|
_position = Offset(
|
||||||
|
(_position.dx - details.delta.dx).clamp(
|
||||||
|
padding.right,
|
||||||
|
screenSize.width - 100 - padding.left,
|
||||||
|
),
|
||||||
|
(_position.dy - details.delta.dy).clamp(
|
||||||
|
padding.bottom,
|
||||||
|
screenSize.height - 200 - padding.top,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onPanEnd: (_) {
|
||||||
|
setState(() => _isDragging = false);
|
||||||
|
HapticFeedbackHelper.lightImpact();
|
||||||
|
},
|
||||||
|
child: AnimatedScale(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
scale: _isDragging ? 0.9 : 1.0,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
310
lib/widgets/floating_navigation_bar.dart
Normal file
310
lib/widgets/floating_navigation_bar.dart
Normal file
@@ -0,0 +1,310 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'dart:ui';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import 'glassmorphism_card.dart';
|
||||||
|
|
||||||
|
class FloatingNavigationBar extends StatefulWidget {
|
||||||
|
final int selectedIndex;
|
||||||
|
final Function(int) onItemTapped;
|
||||||
|
final bool isVisible;
|
||||||
|
|
||||||
|
const FloatingNavigationBar({
|
||||||
|
super.key,
|
||||||
|
required this.selectedIndex,
|
||||||
|
required this.onItemTapped,
|
||||||
|
this.isVisible = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FloatingNavigationBar> createState() => _FloatingNavigationBarState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_animation = CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.isVisible) {
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(FloatingNavigationBar oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (widget.isVisible != oldWidget.isVisible) {
|
||||||
|
if (widget.isVisible) {
|
||||||
|
_controller.forward();
|
||||||
|
} else {
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Positioned(
|
||||||
|
bottom: 20,
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(0, 100 * (1 - _animation.value)),
|
||||||
|
child: Opacity(
|
||||||
|
opacity: _animation.value,
|
||||||
|
child: GlassmorphismCard(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||||
|
borderRadius: 24,
|
||||||
|
blur: 10.0,
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: [
|
||||||
|
_NavigationItem(
|
||||||
|
icon: Icons.home_rounded,
|
||||||
|
label: '홈',
|
||||||
|
isSelected: widget.selectedIndex == 0,
|
||||||
|
onTap: () => _onItemTapped(0),
|
||||||
|
),
|
||||||
|
_NavigationItem(
|
||||||
|
icon: Icons.analytics_rounded,
|
||||||
|
label: '분석',
|
||||||
|
isSelected: widget.selectedIndex == 1,
|
||||||
|
onTap: () => _onItemTapped(1),
|
||||||
|
),
|
||||||
|
_AddButton(
|
||||||
|
onTap: () => _onItemTapped(2),
|
||||||
|
),
|
||||||
|
_NavigationItem(
|
||||||
|
icon: Icons.qr_code_scanner_rounded,
|
||||||
|
label: 'SMS',
|
||||||
|
isSelected: widget.selectedIndex == 3,
|
||||||
|
onTap: () => _onItemTapped(3),
|
||||||
|
),
|
||||||
|
_NavigationItem(
|
||||||
|
icon: Icons.settings_rounded,
|
||||||
|
label: '설정',
|
||||||
|
isSelected: widget.selectedIndex == 4,
|
||||||
|
onTap: () => _onItemTapped(4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onItemTapped(int index) {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
widget.onItemTapped(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _NavigationItem extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final bool isSelected;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _NavigationItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.isSelected,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF14B8A6).withValues(alpha: 0.1)
|
||||||
|
: Colors.transparent,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF14B8A6)
|
||||||
|
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
|
||||||
|
size: isSelected ? 26 : 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
AnimatedDefaultTextStyle(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
color: isSelected
|
||||||
|
? const Color(0xFF14B8A6)
|
||||||
|
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
|
||||||
|
),
|
||||||
|
child: Text(label),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddButton extends StatefulWidget {
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _AddButton({required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<_AddButton> createState() => _AddButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AddButtonState extends State<_AddButton>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 150),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: 0.9,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: (_) => _controller.forward(),
|
||||||
|
onTapUp: (_) {
|
||||||
|
_controller.reverse();
|
||||||
|
widget.onTap();
|
||||||
|
},
|
||||||
|
onTapCancel: () => _controller.reverse(),
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: AppColors.blueGradient,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.primaryColor.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.add_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 스크롤 감지를 위한 유틸리티 클래스
|
||||||
|
class FloatingNavBarScrollController {
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final VoidCallback onHide;
|
||||||
|
final VoidCallback onShow;
|
||||||
|
|
||||||
|
double _lastScrollPosition = 0;
|
||||||
|
bool _isVisible = true;
|
||||||
|
|
||||||
|
FloatingNavBarScrollController({
|
||||||
|
required this.scrollController,
|
||||||
|
required this.onHide,
|
||||||
|
required this.onShow,
|
||||||
|
}) {
|
||||||
|
scrollController.addListener(_handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleScroll() {
|
||||||
|
final currentScroll = scrollController.position.pixels;
|
||||||
|
|
||||||
|
if (currentScroll > _lastScrollPosition && currentScroll > 50) {
|
||||||
|
// 스크롤 다운
|
||||||
|
if (_isVisible) {
|
||||||
|
_isVisible = false;
|
||||||
|
onHide();
|
||||||
|
}
|
||||||
|
} else if (currentScroll < _lastScrollPosition - 5) {
|
||||||
|
// 스크롤 업
|
||||||
|
if (!_isVisible) {
|
||||||
|
_isVisible = true;
|
||||||
|
onShow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_lastScrollPosition = currentScroll;
|
||||||
|
}
|
||||||
|
|
||||||
|
void dispose() {
|
||||||
|
scrollController.removeListener(_handleScroll);
|
||||||
|
}
|
||||||
|
}
|
||||||
304
lib/widgets/glassmorphic_app_bar.dart
Normal file
304
lib/widgets/glassmorphic_app_bar.dart
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'dart:ui';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import 'themed_text.dart';
|
||||||
|
|
||||||
|
/// 글래스모피즘 효과가 적용된 통일된 앱바
|
||||||
|
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final Widget? leading;
|
||||||
|
final bool automaticallyImplyLeading;
|
||||||
|
final double elevation;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final double blur;
|
||||||
|
final double opacity;
|
||||||
|
final PreferredSizeWidget? bottom;
|
||||||
|
final bool centerTitle;
|
||||||
|
final double? titleSpacing;
|
||||||
|
final VoidCallback? onBackPressed;
|
||||||
|
|
||||||
|
const GlassmorphicAppBar({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.actions,
|
||||||
|
this.leading,
|
||||||
|
this.automaticallyImplyLeading = true,
|
||||||
|
this.elevation = 0,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.blur = 20,
|
||||||
|
this.opacity = 0.1,
|
||||||
|
this.bottom,
|
||||||
|
this.centerTitle = false,
|
||||||
|
this.titleSpacing,
|
||||||
|
this.onBackPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => Size.fromHeight(
|
||||||
|
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final canPop = Navigator.of(context).canPop();
|
||||||
|
|
||||||
|
return ClipRRect(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
(backgroundColor ?? (isDarkMode
|
||||||
|
? AppColors.glassBackgroundDark
|
||||||
|
: AppColors.glassBackground)).withValues(alpha: opacity),
|
||||||
|
(backgroundColor ?? (isDarkMode
|
||||||
|
? AppColors.glassSurfaceDark
|
||||||
|
: AppColors.glassSurface)).withValues(alpha: opacity * 0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: isDarkMode
|
||||||
|
? AppColors.glassBorderDark.withValues(alpha: 0.3)
|
||||||
|
: AppColors.glassBorder.withValues(alpha: 0.3),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: ClipRect(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Flexible(
|
||||||
|
child: SizedBox(
|
||||||
|
height: kToolbarHeight,
|
||||||
|
child: NavigationToolbar(
|
||||||
|
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
|
||||||
|
? _buildBackButton(context)
|
||||||
|
: null),
|
||||||
|
middle: _buildTitle(context),
|
||||||
|
trailing: actions != null
|
||||||
|
? Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: actions!,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
centerMiddle: centerTitle,
|
||||||
|
middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (bottom != null) bottom!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBackButton(BuildContext context) {
|
||||||
|
return IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
onPressed: onBackPressed ?? () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
splashRadius: 24,
|
||||||
|
tooltip: '뒤로가기',
|
||||||
|
color: ThemedText.getContrastColor(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTitle(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
child: ThemedText.headline(
|
||||||
|
text: title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 투명 스타일 팩토리
|
||||||
|
static GlassmorphicAppBar transparent({
|
||||||
|
required String title,
|
||||||
|
List<Widget>? actions,
|
||||||
|
Widget? leading,
|
||||||
|
VoidCallback? onBackPressed,
|
||||||
|
}) {
|
||||||
|
return GlassmorphicAppBar(
|
||||||
|
title: title,
|
||||||
|
actions: actions,
|
||||||
|
leading: leading,
|
||||||
|
blur: 30,
|
||||||
|
opacity: 0.05,
|
||||||
|
onBackPressed: onBackPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 반투명 스타일 팩토리
|
||||||
|
static GlassmorphicAppBar translucent({
|
||||||
|
required String title,
|
||||||
|
List<Widget>? actions,
|
||||||
|
Widget? leading,
|
||||||
|
VoidCallback? onBackPressed,
|
||||||
|
}) {
|
||||||
|
return GlassmorphicAppBar(
|
||||||
|
title: title,
|
||||||
|
actions: actions,
|
||||||
|
leading: leading,
|
||||||
|
blur: 20,
|
||||||
|
opacity: 0.15,
|
||||||
|
onBackPressed: onBackPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함)
|
||||||
|
class GlassmorphicSliverAppBar extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final List<Widget>? actions;
|
||||||
|
final Widget? leading;
|
||||||
|
final double expandedHeight;
|
||||||
|
final bool floating;
|
||||||
|
final bool pinned;
|
||||||
|
final bool snap;
|
||||||
|
final Widget? flexibleSpace;
|
||||||
|
final double blur;
|
||||||
|
final double opacity;
|
||||||
|
final bool automaticallyImplyLeading;
|
||||||
|
final VoidCallback? onBackPressed;
|
||||||
|
final bool centerTitle;
|
||||||
|
|
||||||
|
const GlassmorphicSliverAppBar({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
this.actions,
|
||||||
|
this.leading,
|
||||||
|
this.expandedHeight = kToolbarHeight,
|
||||||
|
this.floating = false,
|
||||||
|
this.pinned = true,
|
||||||
|
this.snap = false,
|
||||||
|
this.flexibleSpace,
|
||||||
|
this.blur = 20,
|
||||||
|
this.opacity = 0.1,
|
||||||
|
this.automaticallyImplyLeading = true,
|
||||||
|
this.onBackPressed,
|
||||||
|
this.centerTitle = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
final canPop = Navigator.of(context).canPop();
|
||||||
|
|
||||||
|
return SliverAppBar(
|
||||||
|
expandedHeight: expandedHeight,
|
||||||
|
floating: floating,
|
||||||
|
pinned: pinned,
|
||||||
|
snap: snap,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
|
||||||
|
? IconButton(
|
||||||
|
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||||
|
onPressed: onBackPressed ?? () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
|
splashRadius: 24,
|
||||||
|
tooltip: '뒤로가기',
|
||||||
|
)
|
||||||
|
: null),
|
||||||
|
actions: actions,
|
||||||
|
centerTitle: centerTitle,
|
||||||
|
flexibleSpace: LayoutBuilder(
|
||||||
|
builder: (BuildContext context, BoxConstraints constraints) {
|
||||||
|
final top = constraints.biggest.height;
|
||||||
|
final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top;
|
||||||
|
|
||||||
|
return FlexibleSpaceBar(
|
||||||
|
title: isCollapsed
|
||||||
|
? ThemedText.headline(
|
||||||
|
text: title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: -0.2,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
centerTitle: centerTitle,
|
||||||
|
titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16),
|
||||||
|
background: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
// 글래스모피즘 배경
|
||||||
|
ClipRRect(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [
|
||||||
|
(isDarkMode
|
||||||
|
? AppColors.glassBackgroundDark
|
||||||
|
: AppColors.glassBackground).withValues(alpha: opacity),
|
||||||
|
(isDarkMode
|
||||||
|
? AppColors.glassSurfaceDark
|
||||||
|
: AppColors.glassSurface).withValues(alpha: opacity * 0.8),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
border: Border(
|
||||||
|
bottom: BorderSide(
|
||||||
|
color: isDarkMode
|
||||||
|
? AppColors.glassBorderDark.withValues(alpha: 0.3)
|
||||||
|
: AppColors.glassBorder.withValues(alpha: 0.3),
|
||||||
|
width: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 확장 상태에서만 보이는 타이틀
|
||||||
|
if (!isCollapsed)
|
||||||
|
Positioned(
|
||||||
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
bottom: 16,
|
||||||
|
child: ThemedText.headline(
|
||||||
|
text: title,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 28,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 커스텀 flexibleSpace가 있으면 추가
|
||||||
|
if (flexibleSpace != null) flexibleSpace!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
314
lib/widgets/glassmorphic_scaffold.dart
Normal file
314
lib/widgets/glassmorphic_scaffold.dart
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/rendering.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import 'glassmorphic_app_bar.dart';
|
||||||
|
import 'floating_navigation_bar.dart';
|
||||||
|
|
||||||
|
/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드
|
||||||
|
class GlassmorphicScaffold extends StatefulWidget {
|
||||||
|
final PreferredSizeWidget? appBar;
|
||||||
|
final Widget body;
|
||||||
|
final Widget? floatingActionButton;
|
||||||
|
final FloatingActionButtonLocation? floatingActionButtonLocation;
|
||||||
|
final List<Color>? backgroundGradient;
|
||||||
|
final bool extendBodyBehindAppBar;
|
||||||
|
final bool extendBody;
|
||||||
|
final Widget? bottomNavigationBar;
|
||||||
|
final bool useFloatingNavBar;
|
||||||
|
final int? floatingNavBarIndex;
|
||||||
|
final Function(int)? onFloatingNavBarTapped;
|
||||||
|
final bool resizeToAvoidBottomInset;
|
||||||
|
final Widget? drawer;
|
||||||
|
final Widget? endDrawer;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final bool enableParticles;
|
||||||
|
final bool enableWaveAnimation;
|
||||||
|
|
||||||
|
const GlassmorphicScaffold({
|
||||||
|
super.key,
|
||||||
|
this.appBar,
|
||||||
|
required this.body,
|
||||||
|
this.floatingActionButton,
|
||||||
|
this.floatingActionButtonLocation,
|
||||||
|
this.backgroundGradient,
|
||||||
|
this.extendBodyBehindAppBar = true,
|
||||||
|
this.extendBody = true,
|
||||||
|
this.bottomNavigationBar,
|
||||||
|
this.useFloatingNavBar = false,
|
||||||
|
this.floatingNavBarIndex,
|
||||||
|
this.onFloatingNavBarTapped,
|
||||||
|
this.resizeToAvoidBottomInset = true,
|
||||||
|
this.drawer,
|
||||||
|
this.endDrawer,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.enableParticles = false,
|
||||||
|
this.enableWaveAnimation = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GlassmorphicScaffold> createState() => _GlassmorphicScaffoldState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _particleController;
|
||||||
|
late AnimationController _waveController;
|
||||||
|
ScrollController? _scrollController;
|
||||||
|
bool _isFloatingNavBarVisible = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_particleController = AnimationController(
|
||||||
|
duration: const Duration(seconds: 20),
|
||||||
|
vsync: this,
|
||||||
|
)..repeat();
|
||||||
|
|
||||||
|
_waveController = AnimationController(
|
||||||
|
duration: const Duration(seconds: 10),
|
||||||
|
vsync: this,
|
||||||
|
)..repeat();
|
||||||
|
|
||||||
|
if (widget.useFloatingNavBar) {
|
||||||
|
_scrollController = ScrollController();
|
||||||
|
_setupScrollListener();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setupScrollListener() {
|
||||||
|
_scrollController?.addListener(() {
|
||||||
|
final currentScroll = _scrollController!.position.pixels;
|
||||||
|
final maxScroll = _scrollController!.position.maxScrollExtent;
|
||||||
|
|
||||||
|
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
|
||||||
|
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {
|
||||||
|
if (_isFloatingNavBarVisible) {
|
||||||
|
setState(() => _isFloatingNavBarVisible = false);
|
||||||
|
}
|
||||||
|
} else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) {
|
||||||
|
if (!_isFloatingNavBarVisible) {
|
||||||
|
setState(() => _isFloatingNavBarVisible = true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_particleController.dispose();
|
||||||
|
_waveController.dispose();
|
||||||
|
_scrollController?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Color> _getBackgroundGradient() {
|
||||||
|
if (widget.backgroundGradient != null) {
|
||||||
|
return widget.backgroundGradient!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 시간대별 기본 그라디언트
|
||||||
|
final hour = DateTime.now().hour;
|
||||||
|
if (hour >= 6 && hour < 10) {
|
||||||
|
return AppColors.morningGradient;
|
||||||
|
} else if (hour >= 10 && hour < 17) {
|
||||||
|
return AppColors.dayGradient;
|
||||||
|
} else if (hour >= 17 && hour < 20) {
|
||||||
|
return AppColors.eveningGradient;
|
||||||
|
} else {
|
||||||
|
return AppColors.nightGradient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final backgroundGradient = _getBackgroundGradient();
|
||||||
|
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// 배경 그라디언트
|
||||||
|
_buildBackground(backgroundGradient),
|
||||||
|
|
||||||
|
// 파티클 효과 (선택적)
|
||||||
|
if (widget.enableParticles) _buildParticles(),
|
||||||
|
|
||||||
|
// 웨이브 애니메이션 (선택적)
|
||||||
|
if (widget.enableWaveAnimation) _buildWaveAnimation(),
|
||||||
|
|
||||||
|
// 메인 스캐폴드
|
||||||
|
Scaffold(
|
||||||
|
backgroundColor: widget.backgroundColor ?? Colors.transparent,
|
||||||
|
appBar: widget.appBar,
|
||||||
|
body: widget.body,
|
||||||
|
floatingActionButton: widget.floatingActionButton,
|
||||||
|
floatingActionButtonLocation: widget.floatingActionButtonLocation,
|
||||||
|
bottomNavigationBar: widget.bottomNavigationBar,
|
||||||
|
extendBodyBehindAppBar: widget.extendBodyBehindAppBar,
|
||||||
|
extendBody: widget.extendBody,
|
||||||
|
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
|
||||||
|
drawer: widget.drawer,
|
||||||
|
endDrawer: widget.endDrawer,
|
||||||
|
),
|
||||||
|
|
||||||
|
// 플로팅 네비게이션 바 (선택적)
|
||||||
|
if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null)
|
||||||
|
FloatingNavigationBar(
|
||||||
|
selectedIndex: widget.floatingNavBarIndex!,
|
||||||
|
isVisible: _isFloatingNavBarVisible,
|
||||||
|
onItemTapped: widget.onFloatingNavBarTapped ?? (_) {},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBackground(List<Color> gradientColors) {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: gradientColors.map((color) => color.withValues(alpha: 0.1)).toList(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildParticles() {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _particleController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: ParticlePainter(
|
||||||
|
animation: _particleController,
|
||||||
|
particleCount: 30,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildWaveAnimation() {
|
||||||
|
return Positioned(
|
||||||
|
bottom: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
height: 200,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _waveController,
|
||||||
|
builder: (context, child) {
|
||||||
|
return CustomPaint(
|
||||||
|
painter: WavePainter(
|
||||||
|
animation: _waveController,
|
||||||
|
waveColor: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 파티클 페인터
|
||||||
|
class ParticlePainter extends CustomPainter {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final int particleCount;
|
||||||
|
final List<Particle> particles = [];
|
||||||
|
|
||||||
|
ParticlePainter({
|
||||||
|
required this.animation,
|
||||||
|
this.particleCount = 50,
|
||||||
|
}) : super(repaint: animation) {
|
||||||
|
_initParticles();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _initParticles() {
|
||||||
|
final random = math.Random();
|
||||||
|
for (int i = 0; i < particleCount; i++) {
|
||||||
|
particles.add(Particle(
|
||||||
|
x: random.nextDouble(),
|
||||||
|
y: random.nextDouble(),
|
||||||
|
size: random.nextDouble() * 3 + 1,
|
||||||
|
speed: random.nextDouble() * 0.5 + 0.1,
|
||||||
|
opacity: random.nextDouble() * 0.5 + 0.1,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
for (final particle in particles) {
|
||||||
|
final progress = animation.value;
|
||||||
|
final y = (particle.y + progress * particle.speed) % 1.0;
|
||||||
|
|
||||||
|
paint.color = Colors.white.withValues(alpha: particle.opacity);
|
||||||
|
canvas.drawCircle(
|
||||||
|
Offset(particle.x * size.width, y * size.height),
|
||||||
|
particle.size,
|
||||||
|
paint,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 웨이브 페인터
|
||||||
|
class WavePainter extends CustomPainter {
|
||||||
|
final Animation<double> animation;
|
||||||
|
final Color waveColor;
|
||||||
|
|
||||||
|
WavePainter({
|
||||||
|
required this.animation,
|
||||||
|
required this.waveColor,
|
||||||
|
}) : super(repaint: animation);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()
|
||||||
|
..color = waveColor
|
||||||
|
..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final path = Path();
|
||||||
|
final progress = animation.value;
|
||||||
|
|
||||||
|
path.moveTo(0, size.height);
|
||||||
|
|
||||||
|
for (double x = 0; x <= size.width; x++) {
|
||||||
|
final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 +
|
||||||
|
size.height * 0.5;
|
||||||
|
path.lineTo(x, y);
|
||||||
|
}
|
||||||
|
|
||||||
|
path.lineTo(size.width, size.height);
|
||||||
|
path.close();
|
||||||
|
|
||||||
|
canvas.drawPath(path, paint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 파티클 데이터 클래스
|
||||||
|
class Particle {
|
||||||
|
final double x;
|
||||||
|
final double y;
|
||||||
|
final double size;
|
||||||
|
final double speed;
|
||||||
|
final double opacity;
|
||||||
|
|
||||||
|
Particle({
|
||||||
|
required this.x,
|
||||||
|
required this.y,
|
||||||
|
required this.size,
|
||||||
|
required this.speed,
|
||||||
|
required this.opacity,
|
||||||
|
});
|
||||||
|
}
|
||||||
210
lib/widgets/glassmorphism_card.dart
Normal file
210
lib/widgets/glassmorphism_card.dart
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:ui';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class GlassmorphismCard extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final double borderRadius;
|
||||||
|
final double blur;
|
||||||
|
final double opacity;
|
||||||
|
final Color? backgroundColor;
|
||||||
|
final Gradient? gradient;
|
||||||
|
final Border? border;
|
||||||
|
final List<BoxShadow>? boxShadow;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const GlassmorphismCard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding,
|
||||||
|
this.margin,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.borderRadius = 16.0,
|
||||||
|
this.blur = 10.0,
|
||||||
|
this.opacity = 0.1,
|
||||||
|
this.backgroundColor,
|
||||||
|
this.gradient,
|
||||||
|
this.border,
|
||||||
|
this.boxShadow,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
margin: margin,
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||||
|
child: Container(
|
||||||
|
padding: padding,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor ?? (isDarkMode
|
||||||
|
? AppColors.glassCardDark
|
||||||
|
: AppColors.glassCard),
|
||||||
|
gradient: gradient ?? LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: isDarkMode
|
||||||
|
? AppColors.glassGradientDark
|
||||||
|
: AppColors.glassGradient,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
border: border ?? Border.all(
|
||||||
|
color: isDarkMode
|
||||||
|
? AppColors.glassBorderDark
|
||||||
|
: AppColors.glassBorder,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
boxShadow: boxShadow ?? [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withValues(alpha: 0.1),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: -5,
|
||||||
|
offset: const Offset(0, 10),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 애니메이션이 적용된 글래스모피즘 카드
|
||||||
|
class AnimatedGlassmorphismCard extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
final EdgeInsetsGeometry? margin;
|
||||||
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final double borderRadius;
|
||||||
|
final double blur;
|
||||||
|
final double opacity;
|
||||||
|
final Duration animationDuration;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const AnimatedGlassmorphismCard({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.padding,
|
||||||
|
this.margin,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.borderRadius = 16.0,
|
||||||
|
this.blur = 10.0,
|
||||||
|
this.opacity = 0.1,
|
||||||
|
this.animationDuration = const Duration(milliseconds: 200),
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AnimatedGlassmorphismCard> createState() => _AnimatedGlassmorphismCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
late Animation<double> _blurAnimation;
|
||||||
|
bool _isPressed = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: widget.animationDuration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: 0.98,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
));
|
||||||
|
|
||||||
|
_blurAnimation = Tween<double>(
|
||||||
|
begin: widget.blur,
|
||||||
|
end: widget.blur * 1.5,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapDown(TapDownDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_isPressed = true;
|
||||||
|
});
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapUp(TapUpDetails details) {
|
||||||
|
setState(() {
|
||||||
|
_isPressed = false;
|
||||||
|
});
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapCancel() {
|
||||||
|
setState(() {
|
||||||
|
_isPressed = false;
|
||||||
|
});
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: _handleTapDown,
|
||||||
|
onTapUp: _handleTapUp,
|
||||||
|
onTapCancel: _handleTapCancel,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: GlassmorphismCard(
|
||||||
|
padding: widget.padding,
|
||||||
|
margin: widget.margin,
|
||||||
|
width: widget.width,
|
||||||
|
height: widget.height,
|
||||||
|
borderRadius: widget.borderRadius,
|
||||||
|
blur: _blurAnimation.value,
|
||||||
|
opacity: widget.opacity,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
lib/widgets/home_content.dart
Normal file
154
lib/widgets/home_content.dart
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/subscription_provider.dart';
|
||||||
|
import '../providers/category_provider.dart';
|
||||||
|
import '../utils/subscription_category_helper.dart';
|
||||||
|
import '../widgets/native_ad_widget.dart';
|
||||||
|
import '../widgets/main_summary_card.dart';
|
||||||
|
import '../widgets/subscription_list_widget.dart';
|
||||||
|
import '../widgets/empty_state_widget.dart';
|
||||||
|
import '../widgets/glassmorphic_app_bar.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
import '../routes/app_routes.dart';
|
||||||
|
|
||||||
|
class HomeContent extends StatelessWidget {
|
||||||
|
final AnimationController fadeController;
|
||||||
|
final AnimationController rotateController;
|
||||||
|
final AnimationController slideController;
|
||||||
|
final AnimationController pulseController;
|
||||||
|
final AnimationController waveController;
|
||||||
|
final ScrollController scrollController;
|
||||||
|
final VoidCallback onAddPressed;
|
||||||
|
|
||||||
|
const HomeContent({
|
||||||
|
super.key,
|
||||||
|
required this.fadeController,
|
||||||
|
required this.rotateController,
|
||||||
|
required this.slideController,
|
||||||
|
required this.pulseController,
|
||||||
|
required this.waveController,
|
||||||
|
required this.scrollController,
|
||||||
|
required this.onAddPressed,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final provider = context.watch<SubscriptionProvider>();
|
||||||
|
|
||||||
|
if (provider.isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (provider.subscriptions.isEmpty) {
|
||||||
|
return EmptyStateWidget(
|
||||||
|
fadeController: fadeController,
|
||||||
|
rotateController: rotateController,
|
||||||
|
slideController: slideController,
|
||||||
|
onAddPressed: onAddPressed,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카테고리별 구독 구분
|
||||||
|
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||||
|
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions(
|
||||||
|
provider.subscriptions,
|
||||||
|
categoryProvider,
|
||||||
|
);
|
||||||
|
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: () async {
|
||||||
|
await provider.refreshSubscriptions();
|
||||||
|
},
|
||||||
|
color: const Color(0xFF3B82F6),
|
||||||
|
child: CustomScrollView(
|
||||||
|
controller: scrollController,
|
||||||
|
physics: const BouncingScrollPhysics(),
|
||||||
|
slivers: [
|
||||||
|
const GlassmorphicSliverAppBar(
|
||||||
|
title: '홈',
|
||||||
|
pinned: true,
|
||||||
|
expandedHeight: kToolbarHeight,
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: NativeAdWidget(key: const ValueKey('home_ad')),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.2),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: slideController, curve: Curves.easeOutCubic)),
|
||||||
|
child: MainScreenSummaryCard(
|
||||||
|
provider: provider,
|
||||||
|
fadeController: fadeController,
|
||||||
|
pulseController: pulseController,
|
||||||
|
waveController: waveController,
|
||||||
|
slideController: slideController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(-0.2, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: slideController, curve: Curves.easeOutCubic)),
|
||||||
|
child: Text(
|
||||||
|
'나의 구독 서비스',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SlideTransition(
|
||||||
|
position: Tween<Offset>(
|
||||||
|
begin: const Offset(0.2, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: slideController, curve: Curves.easeOutCubic)),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'${provider.subscriptions.length}개',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Icon(
|
||||||
|
Icons.arrow_forward_ios,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors.primaryColor,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
SubscriptionListWidget(
|
||||||
|
categorizedSubscriptions: categorizedSubscriptions,
|
||||||
|
fadeController: fadeController,
|
||||||
|
),
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 100 + MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
416
lib/widgets/lazy_loading_list.dart
Normal file
416
lib/widgets/lazy_loading_list.dart
Normal file
@@ -0,0 +1,416 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:async';
|
||||||
|
import '../utils/performance_optimizer.dart';
|
||||||
|
import '../widgets/skeleton_loading.dart';
|
||||||
|
|
||||||
|
/// 레이지 로딩이 적용된 리스트 위젯
|
||||||
|
class LazyLoadingList<T> extends StatefulWidget {
|
||||||
|
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
||||||
|
final Widget Function(BuildContext, T, int) itemBuilder;
|
||||||
|
final int pageSize;
|
||||||
|
final double scrollThreshold;
|
||||||
|
final Widget? loadingWidget;
|
||||||
|
final Widget? emptyWidget;
|
||||||
|
final Widget? errorWidget;
|
||||||
|
final bool enableRefresh;
|
||||||
|
final ScrollPhysics? physics;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
|
||||||
|
const LazyLoadingList({
|
||||||
|
super.key,
|
||||||
|
required this.loadMore,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.pageSize = 20,
|
||||||
|
this.scrollThreshold = 0.8,
|
||||||
|
this.loadingWidget,
|
||||||
|
this.emptyWidget,
|
||||||
|
this.errorWidget,
|
||||||
|
this.enableRefresh = true,
|
||||||
|
this.physics,
|
||||||
|
this.padding,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LazyLoadingList<T>> createState() => _LazyLoadingListState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LazyLoadingListState<T> extends State<LazyLoadingList<T>> {
|
||||||
|
final List<T> _items = [];
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
int _currentPage = 0;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _hasMore = true;
|
||||||
|
String? _error;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadInitialData();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_isLoading || !_hasMore) return;
|
||||||
|
|
||||||
|
final position = _scrollController.position;
|
||||||
|
final maxScroll = position.maxScrollExtent;
|
||||||
|
final currentScroll = position.pixels;
|
||||||
|
|
||||||
|
if (currentScroll >= maxScroll * widget.scrollThreshold) {
|
||||||
|
_loadMoreData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialData() async {
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
_error = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final newItems = await PerformanceMeasure.measure(
|
||||||
|
name: 'Initial data load',
|
||||||
|
operation: () => widget.loadMore(0, widget.pageSize),
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items.clear();
|
||||||
|
_items.addAll(newItems);
|
||||||
|
_currentPage = 0;
|
||||||
|
_hasMore = newItems.length >= widget.pageSize;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreData() async {
|
||||||
|
if (_isLoading || !_hasMore) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_isLoading = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final nextPage = _currentPage + 1;
|
||||||
|
final newItems = await PerformanceMeasure.measure(
|
||||||
|
name: 'Load more data (page $nextPage)',
|
||||||
|
operation: () => widget.loadMore(nextPage, widget.pageSize),
|
||||||
|
);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items.addAll(newItems);
|
||||||
|
_currentPage = nextPage;
|
||||||
|
_hasMore = newItems.length >= widget.pageSize;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
setState(() {
|
||||||
|
_error = e.toString();
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _refresh() async {
|
||||||
|
await _loadInitialData();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (_error != null && _items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: widget.errorWidget ?? _buildDefaultErrorWidget(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!_isLoading && _items.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: widget.emptyWidget ?? _buildDefaultEmptyWidget(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget listView = ListView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
physics: widget.physics ?? PerformanceOptimizer.getOptimizedScrollPhysics(),
|
||||||
|
padding: widget.padding,
|
||||||
|
itemCount: _items.length + (_isLoading || _hasMore ? 1 : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index < _items.length) {
|
||||||
|
return widget.itemBuilder(context, _items[index], index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 인디케이터
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: Center(
|
||||||
|
child: widget.loadingWidget ?? _buildDefaultLoadingWidget(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (widget.enableRefresh) {
|
||||||
|
return RefreshIndicator(
|
||||||
|
onRefresh: _refresh,
|
||||||
|
child: listView,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return listView;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDefaultLoadingWidget() {
|
||||||
|
return const CircularProgressIndicator();
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDefaultEmptyWidget() {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.inbox_outlined,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.grey[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'데이터가 없습니다',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDefaultErrorWidget() {
|
||||||
|
return Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
size: 64,
|
||||||
|
color: Colors.red[400],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'오류가 발생했습니다',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
_error ?? '',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
ElevatedButton(
|
||||||
|
onPressed: _loadInitialData,
|
||||||
|
child: const Text('다시 시도'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 캐시가 적용된 레이지 로딩 리스트
|
||||||
|
class CachedLazyLoadingList<T> extends StatefulWidget {
|
||||||
|
final String cacheKey;
|
||||||
|
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
||||||
|
final Widget Function(BuildContext, T, int) itemBuilder;
|
||||||
|
final int pageSize;
|
||||||
|
final Duration cacheDuration;
|
||||||
|
final Widget? loadingWidget;
|
||||||
|
final Widget? emptyWidget;
|
||||||
|
|
||||||
|
const CachedLazyLoadingList({
|
||||||
|
super.key,
|
||||||
|
required this.cacheKey,
|
||||||
|
required this.loadMore,
|
||||||
|
required this.itemBuilder,
|
||||||
|
this.pageSize = 20,
|
||||||
|
this.cacheDuration = const Duration(minutes: 5),
|
||||||
|
this.loadingWidget,
|
||||||
|
this.emptyWidget,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<CachedLazyLoadingList<T>> createState() => _CachedLazyLoadingListState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _CachedLazyLoadingListState<T> extends State<CachedLazyLoadingList<T>> {
|
||||||
|
final Map<int, List<T>> _pageCache = {};
|
||||||
|
|
||||||
|
Future<List<T>> _loadWithCache(int page, int pageSize) async {
|
||||||
|
// 캐시 확인
|
||||||
|
if (_pageCache.containsKey(page)) {
|
||||||
|
return _pageCache[page]!;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 데이터 로드
|
||||||
|
final items = await widget.loadMore(page, pageSize);
|
||||||
|
|
||||||
|
// 캐시 저장
|
||||||
|
_pageCache[page] = items;
|
||||||
|
|
||||||
|
// 일정 시간 후 캐시 제거
|
||||||
|
Timer(widget.cacheDuration, () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_pageCache.remove(page);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return LazyLoadingList<T>(
|
||||||
|
loadMore: _loadWithCache,
|
||||||
|
itemBuilder: widget.itemBuilder,
|
||||||
|
pageSize: widget.pageSize,
|
||||||
|
loadingWidget: widget.loadingWidget,
|
||||||
|
emptyWidget: widget.emptyWidget,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 무한 스크롤 그리드 뷰
|
||||||
|
class LazyLoadingGrid<T> extends StatefulWidget {
|
||||||
|
final Future<List<T>> Function(int page, int pageSize) loadMore;
|
||||||
|
final Widget Function(BuildContext, T, int) itemBuilder;
|
||||||
|
final int crossAxisCount;
|
||||||
|
final int pageSize;
|
||||||
|
final double scrollThreshold;
|
||||||
|
final double childAspectRatio;
|
||||||
|
final double crossAxisSpacing;
|
||||||
|
final double mainAxisSpacing;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
|
||||||
|
const LazyLoadingGrid({
|
||||||
|
super.key,
|
||||||
|
required this.loadMore,
|
||||||
|
required this.itemBuilder,
|
||||||
|
required this.crossAxisCount,
|
||||||
|
this.pageSize = 20,
|
||||||
|
this.scrollThreshold = 0.8,
|
||||||
|
this.childAspectRatio = 1.0,
|
||||||
|
this.crossAxisSpacing = 8.0,
|
||||||
|
this.mainAxisSpacing = 8.0,
|
||||||
|
this.padding,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<LazyLoadingGrid<T>> createState() => _LazyLoadingGridState<T>();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LazyLoadingGridState<T> extends State<LazyLoadingGrid<T>> {
|
||||||
|
final List<T> _items = [];
|
||||||
|
final ScrollController _scrollController = ScrollController();
|
||||||
|
|
||||||
|
int _currentPage = 0;
|
||||||
|
bool _isLoading = false;
|
||||||
|
bool _hasMore = true;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadInitialData();
|
||||||
|
_scrollController.addListener(_onScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_scrollController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onScroll() {
|
||||||
|
if (_isLoading || !_hasMore) return;
|
||||||
|
|
||||||
|
final position = _scrollController.position;
|
||||||
|
final maxScroll = position.maxScrollExtent;
|
||||||
|
final currentScroll = position.pixels;
|
||||||
|
|
||||||
|
if (currentScroll >= maxScroll * widget.scrollThreshold) {
|
||||||
|
_loadMoreData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadInitialData() async {
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
final newItems = await widget.loadMore(0, widget.pageSize);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items.clear();
|
||||||
|
_items.addAll(newItems);
|
||||||
|
_currentPage = 0;
|
||||||
|
_hasMore = newItems.length >= widget.pageSize;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMoreData() async {
|
||||||
|
if (_isLoading || !_hasMore) return;
|
||||||
|
|
||||||
|
setState(() => _isLoading = true);
|
||||||
|
|
||||||
|
final nextPage = _currentPage + 1;
|
||||||
|
final newItems = await widget.loadMore(nextPage, widget.pageSize);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_items.addAll(newItems);
|
||||||
|
_currentPage = nextPage;
|
||||||
|
_hasMore = newItems.length >= widget.pageSize;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GridView.builder(
|
||||||
|
controller: _scrollController,
|
||||||
|
padding: widget.padding,
|
||||||
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: widget.crossAxisCount,
|
||||||
|
childAspectRatio: widget.childAspectRatio,
|
||||||
|
crossAxisSpacing: widget.crossAxisSpacing,
|
||||||
|
mainAxisSpacing: widget.mainAxisSpacing,
|
||||||
|
),
|
||||||
|
itemCount: _items.length + (_isLoading ? widget.crossAxisCount : 0),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
if (index < _items.length) {
|
||||||
|
return widget.itemBuilder(context, _items[index], index);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 로딩 스켈레톤
|
||||||
|
return const SkeletonLoading(
|
||||||
|
height: 100,
|
||||||
|
borderRadius: 12,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,18 +5,17 @@ import '../providers/subscription_provider.dart';
|
|||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../utils/format_helper.dart';
|
import '../utils/format_helper.dart';
|
||||||
import 'animated_wave_background.dart';
|
import 'animated_wave_background.dart';
|
||||||
|
import 'glassmorphism_card.dart';
|
||||||
|
|
||||||
/// 메인 화면 상단에 표시되는 요약 카드 위젯
|
/// 메인 화면 상단에 표시되는 요약 카드 위젯
|
||||||
///
|
///
|
||||||
/// 총 구독 수와 월별 총 지출을 표시하며, 분석 화면으로 이동하는 기능을 제공합니다.
|
/// 총 구독 수와 월별 총 지출을 표시합니다.
|
||||||
class MainScreenSummaryCard extends StatelessWidget {
|
class MainScreenSummaryCard extends StatelessWidget {
|
||||||
final SubscriptionProvider provider;
|
final SubscriptionProvider provider;
|
||||||
final AnimationController fadeController;
|
final AnimationController fadeController;
|
||||||
final AnimationController pulseController;
|
final AnimationController pulseController;
|
||||||
final AnimationController waveController;
|
final AnimationController waveController;
|
||||||
final AnimationController slideController;
|
final AnimationController slideController;
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const MainScreenSummaryCard({
|
const MainScreenSummaryCard({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.provider,
|
required this.provider,
|
||||||
@@ -24,7 +23,6 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
required this.pulseController,
|
required this.pulseController,
|
||||||
required this.waveController,
|
required this.waveController,
|
||||||
required this.slideController,
|
required this.slideController,
|
||||||
required this.onTap,
|
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -40,16 +38,20 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||||
child: GestureDetector(
|
child: GlassmorphismCard(
|
||||||
onTap: () {
|
borderRadius: 24,
|
||||||
HapticFeedback.mediumImpact();
|
blur: 15,
|
||||||
onTap();
|
backgroundColor: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||||
},
|
gradient: LinearGradient(
|
||||||
child: Card(
|
begin: Alignment.topLeft,
|
||||||
elevation: 4,
|
end: Alignment.bottomRight,
|
||||||
shadowColor: Colors.black12,
|
colors: [
|
||||||
shape: RoundedRectangleBorder(
|
AppColors.primaryColor.withValues(alpha: 0.3),
|
||||||
borderRadius: BorderRadius.circular(24),
|
AppColors.primaryColor.withBlue(
|
||||||
|
(AppColors.primaryColor.blue * 1.3)
|
||||||
|
.clamp(0, 255)
|
||||||
|
.toInt()).withValues(alpha: 0.2),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
@@ -59,17 +61,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
gradient: LinearGradient(
|
color: Colors.transparent,
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
AppColors.primaryColor,
|
|
||||||
AppColors.primaryColor.withBlue(
|
|
||||||
(AppColors.primaryColor.blue * 1.3)
|
|
||||||
.clamp(0, 255)
|
|
||||||
.toInt()),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
@@ -91,7 +83,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'이번 달 총 구독 비용',
|
'이번 달 총 구독 비용',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.9),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -118,7 +110,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'원',
|
'원',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.9),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -153,13 +145,13 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
colors: [
|
colors: [
|
||||||
Colors.white.withOpacity(0.2),
|
Colors.white.withValues(alpha: 0.2),
|
||||||
Colors.white.withOpacity(0.15),
|
Colors.white.withValues(alpha: 0.15),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.white.withOpacity(0.3),
|
color: Colors.white.withValues(alpha: 0.3),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -169,7 +161,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.25),
|
color: Colors.white.withValues(alpha: 0.25),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -185,7 +177,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
'이벤트 할인 중',
|
'이벤트 할인 중',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.9),
|
color: Colors.white.withValues(alpha: 0.9),
|
||||||
fontSize: 11,
|
fontSize: 11,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -208,7 +200,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
' 절약 ($activeEvents개)',
|
' 절약 ($activeEvents개)',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.85),
|
color: Colors.white.withValues(alpha: 0.85),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -224,20 +216,10 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
|
||||||
right: 16,
|
|
||||||
top: 16,
|
|
||||||
child: Icon(
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
color: Colors.white.withOpacity(0.7),
|
|
||||||
size: 16,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -249,7 +231,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withOpacity(0.15),
|
color: Colors.white.withValues(alpha: 0.15),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -258,7 +240,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.white.withOpacity(0.85),
|
color: Colors.white.withValues(alpha: 0.85),
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
import 'glassmorphism_card.dart';
|
||||||
|
|
||||||
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
|
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
|
||||||
/// SRP에 따라 광고 전용 위젯으로 분리
|
/// SRP에 따라 광고 전용 위젯으로 분리
|
||||||
@@ -84,9 +85,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
|||||||
Widget _buildWebPlaceholder() {
|
Widget _buildWebPlaceholder() {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Card(
|
child: GlassmorphismCard(
|
||||||
elevation: 2,
|
borderRadius: 16,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
blur: 10,
|
||||||
|
opacity: 0.1,
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 80,
|
height: 80,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -186,9 +188,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
|||||||
// 광고 정상 노출
|
// 광고 정상 노출
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Card(
|
child: GlassmorphismCard(
|
||||||
elevation: 2,
|
borderRadius: 16,
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
blur: 10,
|
||||||
|
opacity: 0.1,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 80, // 네이티브 광고 높이 조정
|
height: 80, // 네이티브 광고 높이 조정
|
||||||
child: AdWidget(ad: _nativeAd!),
|
child: AdWidget(ad: _nativeAd!),
|
||||||
|
|||||||
@@ -1,24 +1,44 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'glassmorphism_card.dart';
|
||||||
|
|
||||||
class SkeletonLoading extends StatelessWidget {
|
class SkeletonLoading extends StatelessWidget {
|
||||||
const SkeletonLoading({Key? key}) : super(key: key);
|
final double? width;
|
||||||
|
final double? height;
|
||||||
|
final double borderRadius;
|
||||||
|
|
||||||
|
const SkeletonLoading({
|
||||||
|
Key? key,
|
||||||
|
this.width,
|
||||||
|
this.height,
|
||||||
|
this.borderRadius = 8.0,
|
||||||
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 단일 스켈레톤 아이템이 요청된 경우
|
||||||
|
if (width != null || height != null) {
|
||||||
|
return _buildSingleSkeleton();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 기본 전체 화면 스켈레톤
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
// 요약 카드 스켈레톤
|
// 요약 카드 스켈레톤
|
||||||
Card(
|
GlassmorphismCard(
|
||||||
margin: const EdgeInsets.all(16),
|
margin: const EdgeInsets.all(16),
|
||||||
child: Padding(
|
padding: const EdgeInsets.all(16.0),
|
||||||
padding: const EdgeInsets.all(16.0),
|
blur: 10,
|
||||||
child: Column(
|
opacity: 0.1,
|
||||||
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 24,
|
height: 24,
|
||||||
color: Colors.grey[300],
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Row(
|
Row(
|
||||||
@@ -29,7 +49,6 @@ class SkeletonLoading extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 구독 목록 스켈레톤
|
// 구독 목록 스켈레톤
|
||||||
@@ -37,32 +56,47 @@ class SkeletonLoading extends StatelessWidget {
|
|||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: 5,
|
itemCount: 5,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
return Card(
|
return GlassmorphismCard(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: ListTile(
|
padding: const EdgeInsets.all(16),
|
||||||
contentPadding: const EdgeInsets.all(16),
|
blur: 10,
|
||||||
title: Container(
|
opacity: 0.1,
|
||||||
width: 200,
|
child: Row(
|
||||||
height: 24,
|
children: [
|
||||||
color: Colors.grey[300],
|
Expanded(
|
||||||
),
|
child: Column(
|
||||||
subtitle: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
Container(
|
||||||
const SizedBox(height: 8),
|
width: 200,
|
||||||
Container(
|
height: 24,
|
||||||
width: 150,
|
decoration: BoxDecoration(
|
||||||
height: 16,
|
color: Colors.grey[300],
|
||||||
color: Colors.grey[300],
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Container(
|
||||||
|
width: 150,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Container(
|
||||||
|
width: 180,
|
||||||
|
height: 16,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
),
|
||||||
Container(
|
],
|
||||||
width: 180,
|
|
||||||
height: 16,
|
|
||||||
color: Colors.grey[300],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -72,6 +106,32 @@ class SkeletonLoading extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _buildSingleSkeleton() {
|
||||||
|
return Container(
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 1500),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
Colors.grey[300]!,
|
||||||
|
Colors.grey[100]!,
|
||||||
|
Colors.grey[300]!,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildSkeletonColumn() {
|
Widget _buildSkeletonColumn() {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -79,13 +139,19 @@ class SkeletonLoading extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: 80,
|
width: 80,
|
||||||
height: 16,
|
height: 16,
|
||||||
color: Colors.grey[300],
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Container(
|
Container(
|
||||||
width: 100,
|
width: 100,
|
||||||
height: 24,
|
height: 24,
|
||||||
color: Colors.grey[300],
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey[300],
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
350
lib/widgets/spring_animation_widget.dart
Normal file
350
lib/widgets/spring_animation_widget.dart
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/physics.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
|
||||||
|
class SpringAnimationWidget extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final Duration delay;
|
||||||
|
final SpringDescription spring;
|
||||||
|
final Offset? initialOffset;
|
||||||
|
final double? initialScale;
|
||||||
|
final double? initialRotation;
|
||||||
|
|
||||||
|
const SpringAnimationWidget({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.delay = Duration.zero,
|
||||||
|
this.spring = const SpringDescription(
|
||||||
|
mass: 1,
|
||||||
|
stiffness: 100,
|
||||||
|
damping: 10,
|
||||||
|
),
|
||||||
|
this.initialOffset,
|
||||||
|
this.initialScale,
|
||||||
|
this.initialRotation,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<Offset> _offsetAnimation;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
late Animation<double> _rotationAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 스프링 시뮬레이션
|
||||||
|
final simulation = SpringSimulation(
|
||||||
|
widget.spring,
|
||||||
|
0.0,
|
||||||
|
1.0,
|
||||||
|
0.0,
|
||||||
|
);
|
||||||
|
|
||||||
|
// 오프셋 애니메이션
|
||||||
|
_offsetAnimation = Tween<Offset>(
|
||||||
|
begin: widget.initialOffset ?? const Offset(0, 50),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.elasticOut,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 스케일 애니메이션
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: widget.initialScale ?? 0.5,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.elasticOut,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 회전 애니메이션
|
||||||
|
_rotationAnimation = Tween<double>(
|
||||||
|
begin: widget.initialRotation ?? 0.0,
|
||||||
|
end: 0.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.elasticOut,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 지연 후 애니메이션 시작
|
||||||
|
Future.delayed(widget.delay, () {
|
||||||
|
if (mounted) {
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: _offsetAnimation.value,
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _rotationAnimation.value,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 바운스 효과가 있는 버튼
|
||||||
|
class BouncyButton extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final EdgeInsetsGeometry? padding;
|
||||||
|
final BoxDecoration? decoration;
|
||||||
|
|
||||||
|
const BouncyButton({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.onPressed,
|
||||||
|
this.padding,
|
||||||
|
this.decoration,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<BouncyButton> createState() => _BouncyButtonState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BouncyButtonState extends State<BouncyButton>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 1.0,
|
||||||
|
end: 0.95,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapDown(TapDownDetails details) {
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapUp(TapUpDetails details) {
|
||||||
|
_controller.reverse();
|
||||||
|
widget.onPressed?.call();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTapCancel() {
|
||||||
|
_controller.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTapDown: _handleTapDown,
|
||||||
|
onTapUp: _handleTapUp,
|
||||||
|
onTapCancel: _handleTapCancel,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _scaleAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.scale(
|
||||||
|
scale: _scaleAnimation.value,
|
||||||
|
child: Container(
|
||||||
|
padding: widget.padding,
|
||||||
|
decoration: widget.decoration,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 중력 효과 애니메이션
|
||||||
|
class GravityAnimation extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final double gravity;
|
||||||
|
final double bounceFactor;
|
||||||
|
final double initialVelocity;
|
||||||
|
|
||||||
|
const GravityAnimation({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.gravity = 9.8,
|
||||||
|
this.bounceFactor = 0.8,
|
||||||
|
this.initialVelocity = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<GravityAnimation> createState() => _GravityAnimationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GravityAnimationState extends State<GravityAnimation>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
double _position = 0;
|
||||||
|
double _velocity = 0;
|
||||||
|
double _floor = 300;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_velocity = widget.initialVelocity;
|
||||||
|
_controller = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(seconds: 10),
|
||||||
|
)..addListener(_updatePhysics);
|
||||||
|
|
||||||
|
_controller.repeat();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updatePhysics() {
|
||||||
|
setState(() {
|
||||||
|
// 속도 업데이트 (중력 적용)
|
||||||
|
_velocity += widget.gravity * 0.016; // 60fps 가정
|
||||||
|
|
||||||
|
// 위치 업데이트
|
||||||
|
_position += _velocity;
|
||||||
|
|
||||||
|
// 바닥 충돌 감지
|
||||||
|
if (_position >= _floor) {
|
||||||
|
_position = _floor;
|
||||||
|
_velocity = -_velocity * widget.bounceFactor;
|
||||||
|
|
||||||
|
// 너무 작은 바운스는 멈춤
|
||||||
|
if (_velocity.abs() < 1) {
|
||||||
|
_velocity = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(0, _position),
|
||||||
|
child: widget.child,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 물결 효과 애니메이션
|
||||||
|
class RippleAnimation extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final Color rippleColor;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const RippleAnimation({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
this.rippleColor = Colors.blue,
|
||||||
|
this.duration = const Duration(milliseconds: 600),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<RippleAnimation> createState() => _RippleAnimationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RippleAnimationState extends State<RippleAnimation>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: widget.duration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap() {
|
||||||
|
_controller.forward(from: 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _handleTap,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Container(
|
||||||
|
width: 100 + 200 * _animation.value,
|
||||||
|
height: 100 + 200 * _animation.value,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: widget.rippleColor.withValues(alpha:
|
||||||
|
(1 - _animation.value) * 0.3,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
widget.child,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
302
lib/widgets/staggered_list_animation.dart
Normal file
302
lib/widgets/staggered_list_animation.dart
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
|
||||||
|
/// 스태거 애니메이션이 적용된 리스트 위젯
|
||||||
|
class StaggeredListAnimation extends StatefulWidget {
|
||||||
|
final List<Widget> children;
|
||||||
|
final Duration itemDelay;
|
||||||
|
final Duration animationDuration;
|
||||||
|
final Curve curve;
|
||||||
|
final Axis direction;
|
||||||
|
|
||||||
|
const StaggeredListAnimation({
|
||||||
|
super.key,
|
||||||
|
required this.children,
|
||||||
|
this.itemDelay = const Duration(milliseconds: 100),
|
||||||
|
this.animationDuration = const Duration(milliseconds: 500),
|
||||||
|
this.curve = Curves.easeOutBack,
|
||||||
|
this.direction = Axis.vertical,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
|
||||||
|
with TickerProviderStateMixin {
|
||||||
|
final List<AnimationController> _controllers = [];
|
||||||
|
final List<Animation<double>> _fadeAnimations = [];
|
||||||
|
final List<Animation<Offset>> _slideAnimations = [];
|
||||||
|
final List<Animation<double>> _scaleAnimations = [];
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_createAnimations();
|
||||||
|
_startAnimations();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _createAnimations() {
|
||||||
|
for (int i = 0; i < widget.children.length; i++) {
|
||||||
|
final controller = AnimationController(
|
||||||
|
duration: widget.animationDuration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
final fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: controller,
|
||||||
|
curve: widget.curve,
|
||||||
|
));
|
||||||
|
|
||||||
|
final slideAnimation = Tween<Offset>(
|
||||||
|
begin: widget.direction == Axis.vertical
|
||||||
|
? const Offset(0, 0.3)
|
||||||
|
: const Offset(0.3, 0),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: controller,
|
||||||
|
curve: widget.curve,
|
||||||
|
));
|
||||||
|
|
||||||
|
final scaleAnimation = Tween<double>(
|
||||||
|
begin: 0.8,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: controller,
|
||||||
|
curve: widget.curve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_controllers.add(controller);
|
||||||
|
_fadeAnimations.add(fadeAnimation);
|
||||||
|
_slideAnimations.add(slideAnimation);
|
||||||
|
_scaleAnimations.add(scaleAnimation);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startAnimations() async {
|
||||||
|
for (int i = 0; i < _controllers.length; i++) {
|
||||||
|
await Future.delayed(widget.itemDelay * i);
|
||||||
|
if (mounted) {
|
||||||
|
_controllers[i].forward();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
for (final controller in _controllers) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return widget.direction == Axis.vertical
|
||||||
|
? Column(
|
||||||
|
children: _buildAnimatedChildren(),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
|
children: _buildAnimatedChildren(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildAnimatedChildren() {
|
||||||
|
return List.generate(widget.children.length, (index) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controllers[index],
|
||||||
|
builder: (context, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _fadeAnimations[index],
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slideAnimations[index],
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: _scaleAnimations[index],
|
||||||
|
child: widget.children[index],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 개별 스태거 애니메이션 아이템
|
||||||
|
class StaggeredAnimationItem extends StatefulWidget {
|
||||||
|
final Widget child;
|
||||||
|
final int index;
|
||||||
|
final Duration delay;
|
||||||
|
final Duration duration;
|
||||||
|
final Curve curve;
|
||||||
|
|
||||||
|
const StaggeredAnimationItem({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
required this.index,
|
||||||
|
this.delay = const Duration(milliseconds: 100),
|
||||||
|
this.duration = const Duration(milliseconds: 500),
|
||||||
|
this.curve = Curves.easeOutBack,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<StaggeredAnimationItem> createState() => _StaggeredAnimationItemState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _fadeAnimation;
|
||||||
|
late Animation<Offset> _slideAnimation;
|
||||||
|
late Animation<double> _scaleAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: widget.duration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_fadeAnimation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: widget.curve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_slideAnimation = Tween<Offset>(
|
||||||
|
begin: const Offset(0, 0.3),
|
||||||
|
end: Offset.zero,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: widget.curve,
|
||||||
|
));
|
||||||
|
|
||||||
|
_scaleAnimation = Tween<double>(
|
||||||
|
begin: 0.8,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: widget.curve,
|
||||||
|
));
|
||||||
|
|
||||||
|
// 지연 후 애니메이션 시작
|
||||||
|
Future.delayed(widget.delay * widget.index, () {
|
||||||
|
if (mounted) {
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _controller,
|
||||||
|
builder: (context, child) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: _fadeAnimation,
|
||||||
|
child: SlideTransition(
|
||||||
|
position: _slideAnimation,
|
||||||
|
child: ScaleTransition(
|
||||||
|
scale: _scaleAnimation,
|
||||||
|
child: widget.child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 카드 플립 애니메이션
|
||||||
|
class FlipAnimationCard extends StatefulWidget {
|
||||||
|
final Widget front;
|
||||||
|
final Widget back;
|
||||||
|
final Duration duration;
|
||||||
|
|
||||||
|
const FlipAnimationCard({
|
||||||
|
super.key,
|
||||||
|
required this.front,
|
||||||
|
required this.back,
|
||||||
|
this.duration = const Duration(milliseconds: 800),
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<FlipAnimationCard> createState() => _FlipAnimationCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FlipAnimationCardState extends State<FlipAnimationCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
bool _isFlipped = false;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: widget.duration,
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 1.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeInOut,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _flip() {
|
||||||
|
if (_isFlipped) {
|
||||||
|
_controller.reverse();
|
||||||
|
} else {
|
||||||
|
_controller.forward();
|
||||||
|
}
|
||||||
|
_isFlipped = !_isFlipped;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: _flip,
|
||||||
|
child: AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final isShowingFront = _animation.value < 0.5;
|
||||||
|
return Transform(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
transform: Matrix4.identity()
|
||||||
|
..setEntry(3, 2, 0.001)
|
||||||
|
..rotateY(math.pi * _animation.value),
|
||||||
|
child: isShowingFront
|
||||||
|
? widget.front
|
||||||
|
: Transform(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
transform: Matrix4.identity()..rotateY(math.pi),
|
||||||
|
child: widget.back,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../screens/detail_screen.dart';
|
import '../screens/detail_screen.dart';
|
||||||
import 'website_icon.dart';
|
import 'website_icon.dart';
|
||||||
|
import 'app_navigator.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
|
import 'glassmorphism_card.dart';
|
||||||
|
|
||||||
class SubscriptionCard extends StatefulWidget {
|
class SubscriptionCard extends StatefulWidget {
|
||||||
final SubscriptionModel subscription;
|
final SubscriptionModel subscription;
|
||||||
@@ -230,67 +233,30 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final result = await Navigator.push(
|
await AppNavigator.toDetail(context, widget.subscription);
|
||||||
context,
|
|
||||||
PageRouteBuilder(
|
|
||||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
|
||||||
DetailScreen(subscription: widget.subscription),
|
|
||||||
transitionsBuilder:
|
|
||||||
(context, animation, secondaryAnimation, child) {
|
|
||||||
const begin = Offset(0.0, 0.05);
|
|
||||||
const end = Offset.zero;
|
|
||||||
const curve = Curves.easeOutCubic;
|
|
||||||
|
|
||||||
var tween = Tween(begin: begin, end: end)
|
|
||||||
.chain(CurveTween(curve: curve));
|
|
||||||
|
|
||||||
var fadeAnimation =
|
|
||||||
Tween<double>(begin: 0.6, end: 1.0)
|
|
||||||
.chain(CurveTween(curve: curve))
|
|
||||||
.animate(animation);
|
|
||||||
|
|
||||||
return FadeTransition(
|
|
||||||
opacity: fadeAnimation,
|
|
||||||
child: SlideTransition(
|
|
||||||
position: animation.drive(tween),
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result == true) {
|
|
||||||
// 변경 사항이 있을 경우 미리 저장된 Provider 참조를 사용하여 구독 목록 갱신
|
|
||||||
await _subscriptionProvider.refreshSubscriptions();
|
|
||||||
|
|
||||||
// 메인 화면의 State를 갱신하기 위해 미세한 지연 후 다시 한번 알림
|
|
||||||
// mounted 상태를 확인하여 dispose된 위젯에서 Provider를 참조하지 않도록 합니다.
|
|
||||||
Future.delayed(const Duration(milliseconds: 100), () {
|
|
||||||
// 위젯이 아직 마운트 상태인지 확인하고, 미리 저장된 Provider 참조 사용
|
|
||||||
if (mounted) {
|
|
||||||
_subscriptionProvider.notifyListeners();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
splashColor: AppColors.primaryColor.withOpacity(0.1),
|
splashColor: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||||
highlightColor: AppColors.primaryColor.withOpacity(0.05),
|
highlightColor: AppColors.primaryColor.withValues(alpha: 0.05),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: Container(
|
child: AnimatedGlassmorphismCard(
|
||||||
|
onTap: () {}, // onTap은 이미 InkWell에서 처리됨
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
borderRadius: 16,
|
||||||
|
blur: _isHovering ? 15 : 10,
|
||||||
|
child: Container(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: cardColor,
|
color: cardColor,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: _isHovering
|
color: _isHovering
|
||||||
? AppColors.primaryColor.withOpacity(0.3)
|
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||||
: AppColors.borderColor,
|
: AppColors.borderColor,
|
||||||
width: _isHovering ? 1.5 : 0.5,
|
width: _isHovering ? 1.5 : 0.5,
|
||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.primaryColor.withOpacity(
|
color: AppColors.primaryColor.withValues(alpha:
|
||||||
0.03 + (0.05 * _hoverController.value)),
|
0.03 + (0.05 * _hoverController.value)),
|
||||||
blurRadius: 8 + (8 * _hoverController.value),
|
blurRadius: 8 + (8 * _hoverController.value),
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
@@ -502,9 +468,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: isNearBilling
|
color: isNearBilling
|
||||||
? AppColors.warningColor
|
? AppColors.warningColor
|
||||||
.withOpacity(0.1)
|
.withValues(alpha: 0.1)
|
||||||
: AppColors.successColor
|
: AppColors.successColor
|
||||||
.withOpacity(0.1),
|
.withValues(alpha: 0.1),
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
@@ -551,7 +517,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
vertical: 2,
|
vertical: 2,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFF6B6B).withOpacity(0.1),
|
color: const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -607,6 +573,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,6 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../widgets/subscription_card.dart';
|
import '../widgets/subscription_card.dart';
|
||||||
import '../widgets/category_header_widget.dart';
|
import '../widgets/category_header_widget.dart';
|
||||||
|
import '../widgets/swipeable_subscription_card.dart';
|
||||||
|
import '../widgets/staggered_list_animation.dart';
|
||||||
|
import '../screens/detail_screen.dart';
|
||||||
|
import '../widgets/animated_page_transitions.dart';
|
||||||
|
import '../widgets/app_navigator.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
import '../providers/subscription_provider.dart';
|
||||||
|
|
||||||
/// 카테고리별로 구독 목록을 표시하는 위젯
|
/// 카테고리별로 구독 목록을 표시하는 위젯
|
||||||
class SubscriptionListWidget extends StatelessWidget {
|
class SubscriptionListWidget extends StatelessWidget {
|
||||||
@@ -75,8 +82,29 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
curve: Curves.easeOut))),
|
curve: Curves.easeOut))),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 12.0),
|
padding: const EdgeInsets.only(bottom: 12.0),
|
||||||
child: SubscriptionCard(
|
child: StaggeredAnimationItem(
|
||||||
subscription: subscriptions[subIndex],
|
index: subIndex,
|
||||||
|
delay: const Duration(milliseconds: 50),
|
||||||
|
child: SwipeableSubscriptionCard(
|
||||||
|
subscription: subscriptions[subIndex],
|
||||||
|
onTap: () {
|
||||||
|
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||||
|
},
|
||||||
|
onEdit: () {
|
||||||
|
// 편집 화면으로 이동
|
||||||
|
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||||
|
},
|
||||||
|
onDelete: () async {
|
||||||
|
// 삭제 확인 다이얼로그
|
||||||
|
final provider = Provider.of<SubscriptionProvider>(
|
||||||
|
context,
|
||||||
|
listen: false,
|
||||||
|
);
|
||||||
|
await provider.deleteSubscription(
|
||||||
|
subscriptions[subIndex].id,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
227
lib/widgets/swipeable_subscription_card.dart
Normal file
227
lib/widgets/swipeable_subscription_card.dart
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'dart:math' as math;
|
||||||
|
import '../models/subscription_model.dart';
|
||||||
|
import '../utils/haptic_feedback_helper.dart';
|
||||||
|
import 'subscription_card.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
class SwipeableSubscriptionCard extends StatefulWidget {
|
||||||
|
final SubscriptionModel subscription;
|
||||||
|
final VoidCallback? onEdit;
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
const SwipeableSubscriptionCard({
|
||||||
|
super.key,
|
||||||
|
required this.subscription,
|
||||||
|
this.onEdit,
|
||||||
|
this.onDelete,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
late AnimationController _controller;
|
||||||
|
late Animation<double> _animation;
|
||||||
|
double _dragStartX = 0;
|
||||||
|
double _dragExtent = 0;
|
||||||
|
bool _isSwipingLeft = false;
|
||||||
|
bool _hapticTriggered = false;
|
||||||
|
|
||||||
|
static const double _swipeThreshold = 80.0;
|
||||||
|
static const double _deleteThreshold = 150.0;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_controller = AnimationController(
|
||||||
|
duration: const Duration(milliseconds: 300),
|
||||||
|
vsync: this,
|
||||||
|
);
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: 0.0,
|
||||||
|
end: 0.0,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOutExpo,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragStart(DragStartDetails details) {
|
||||||
|
_dragStartX = details.localPosition.dx;
|
||||||
|
_hapticTriggered = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragUpdate(DragUpdateDetails details) {
|
||||||
|
final delta = details.localPosition.dx - _dragStartX;
|
||||||
|
setState(() {
|
||||||
|
_dragExtent = delta;
|
||||||
|
_isSwipingLeft = delta < 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 햅틱 피드백 트리거
|
||||||
|
if (!_hapticTriggered && _dragExtent.abs() > _swipeThreshold) {
|
||||||
|
_hapticTriggered = true;
|
||||||
|
HapticFeedbackHelper.mediumImpact();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 삭제 임계값에 도달했을 때 강한 햅틱
|
||||||
|
if (_dragExtent.abs() > _deleteThreshold && _hapticTriggered) {
|
||||||
|
HapticFeedbackHelper.heavyImpact();
|
||||||
|
_hapticTriggered = false; // 반복 방지
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleDragEnd(DragEndDetails details) {
|
||||||
|
final velocity = details.velocity.pixelsPerSecond.dx;
|
||||||
|
final extent = _dragExtent.abs();
|
||||||
|
|
||||||
|
if (extent > _deleteThreshold || velocity.abs() > 800) {
|
||||||
|
// 삭제 액션
|
||||||
|
if (_isSwipingLeft && widget.onDelete != null) {
|
||||||
|
HapticFeedbackHelper.success();
|
||||||
|
_animateToOffset(-MediaQuery.of(context).size.width);
|
||||||
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
|
widget.onDelete!();
|
||||||
|
});
|
||||||
|
} else if (!_isSwipingLeft && widget.onEdit != null) {
|
||||||
|
HapticFeedbackHelper.success();
|
||||||
|
_animateToOffset(MediaQuery.of(context).size.width);
|
||||||
|
Future.delayed(const Duration(milliseconds: 300), () {
|
||||||
|
widget.onEdit!();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else if (extent > _swipeThreshold) {
|
||||||
|
// 액션 버튼 표시
|
||||||
|
HapticFeedbackHelper.lightImpact();
|
||||||
|
_animateToOffset(_isSwipingLeft ? -_swipeThreshold : _swipeThreshold);
|
||||||
|
} else {
|
||||||
|
// 원위치로 복귀
|
||||||
|
_animateToOffset(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _animateToOffset(double offset) {
|
||||||
|
_animation = Tween<double>(
|
||||||
|
begin: _dragExtent,
|
||||||
|
end: offset,
|
||||||
|
).animate(CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: Curves.easeOutExpo,
|
||||||
|
));
|
||||||
|
_controller.forward(from: 0).then((_) {
|
||||||
|
setState(() {
|
||||||
|
_dragExtent = offset;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Stack(
|
||||||
|
children: [
|
||||||
|
// 배경 액션 버튼들
|
||||||
|
Positioned.fill(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: _isSwipingLeft
|
||||||
|
? AppColors.dangerColor
|
||||||
|
: AppColors.primaryColor,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
// 편집 버튼 (오른쪽 스와이프)
|
||||||
|
if (!_isSwipingLeft)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 24),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
opacity: _dragExtent > 40 ? 1.0 : 0.0,
|
||||||
|
child: AnimatedScale(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
scale: _dragExtent > 40 ? 1.0 : 0.5,
|
||||||
|
child: const Icon(
|
||||||
|
Icons.edit_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 삭제 버튼 (왼쪽 스와이프)
|
||||||
|
if (_isSwipingLeft)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 24),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
opacity: _dragExtent.abs() > 40 ? 1.0 : 0.0,
|
||||||
|
child: AnimatedScale(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
scale: _dragExtent.abs() > 40 ? 1.0 : 0.5,
|
||||||
|
child: Icon(
|
||||||
|
_dragExtent.abs() > _deleteThreshold
|
||||||
|
? Icons.delete_forever_rounded
|
||||||
|
: Icons.delete_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 스와이프 가능한 카드
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _animation,
|
||||||
|
builder: (context, child) {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(_animation.value, 0),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: GestureDetector(
|
||||||
|
onHorizontalDragStart: _handleDragStart,
|
||||||
|
onHorizontalDragUpdate: _handleDragUpdate,
|
||||||
|
onHorizontalDragEnd: _handleDragEnd,
|
||||||
|
child: Transform.translate(
|
||||||
|
offset: Offset(_dragExtent, 0),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: 1.0 - (_dragExtent.abs() / 2000),
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _dragExtent / 2000,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
if (_dragExtent.abs() < 10) {
|
||||||
|
widget.onTap?.call();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: SubscriptionCard(
|
||||||
|
subscription: widget.subscription,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
216
lib/widgets/themed_text.dart
Normal file
216
lib/widgets/themed_text.dart
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
|
/// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯
|
||||||
|
class ThemedText extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final TextStyle? style;
|
||||||
|
final TextAlign? textAlign;
|
||||||
|
final TextOverflow? overflow;
|
||||||
|
final int? maxLines;
|
||||||
|
final bool softWrap;
|
||||||
|
final bool forceLight;
|
||||||
|
final bool forceDark;
|
||||||
|
final double? opacity;
|
||||||
|
final double? fontSize;
|
||||||
|
final FontWeight? fontWeight;
|
||||||
|
final double? letterSpacing;
|
||||||
|
final Color? color;
|
||||||
|
|
||||||
|
const ThemedText(
|
||||||
|
this.text, {
|
||||||
|
super.key,
|
||||||
|
this.style,
|
||||||
|
this.textAlign,
|
||||||
|
this.overflow,
|
||||||
|
this.maxLines,
|
||||||
|
this.softWrap = true,
|
||||||
|
this.forceLight = false,
|
||||||
|
this.forceDark = false,
|
||||||
|
this.opacity,
|
||||||
|
this.fontSize,
|
||||||
|
this.fontWeight,
|
||||||
|
this.letterSpacing,
|
||||||
|
this.color,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 배경 밝기에 따른 텍스트 색상 결정
|
||||||
|
static Color getContrastColor(BuildContext context, {
|
||||||
|
bool forceLight = false,
|
||||||
|
bool forceDark = false,
|
||||||
|
}) {
|
||||||
|
if (forceLight) return Colors.white;
|
||||||
|
if (forceDark) return AppColors.textPrimary;
|
||||||
|
|
||||||
|
final brightness = Theme.of(context).brightness;
|
||||||
|
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
|
||||||
|
|
||||||
|
// 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트
|
||||||
|
if (_isGlassmorphicContext(context)) {
|
||||||
|
return brightness == Brightness.dark
|
||||||
|
? Colors.white.withValues(alpha: 0.95)
|
||||||
|
: AppColors.textPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 일반 환경
|
||||||
|
return brightness == Brightness.dark
|
||||||
|
? Colors.white
|
||||||
|
: AppColors.textPrimary;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 글래스모피즘 컨텍스트인지 확인
|
||||||
|
static bool _isGlassmorphicContext(BuildContext context) {
|
||||||
|
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
|
||||||
|
final glassmorphic = context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
|
||||||
|
return glassmorphic != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final textColor = color ?? getContrastColor(
|
||||||
|
context,
|
||||||
|
forceLight: forceLight,
|
||||||
|
forceDark: forceDark,
|
||||||
|
);
|
||||||
|
|
||||||
|
final finalColor = opacity != null
|
||||||
|
? textColor.withValues(alpha: opacity!)
|
||||||
|
: textColor;
|
||||||
|
|
||||||
|
final defaultStyle = DefaultTextStyle.of(context).style;
|
||||||
|
|
||||||
|
// 개별 스타일 속성들을 병합
|
||||||
|
final baseStyle = TextStyle(
|
||||||
|
fontSize: fontSize,
|
||||||
|
fontWeight: fontWeight,
|
||||||
|
letterSpacing: letterSpacing,
|
||||||
|
color: finalColor,
|
||||||
|
);
|
||||||
|
|
||||||
|
final effectiveStyle = defaultStyle.merge(baseStyle).merge(style);
|
||||||
|
|
||||||
|
return Text(
|
||||||
|
text,
|
||||||
|
style: effectiveStyle,
|
||||||
|
textAlign: textAlign,
|
||||||
|
overflow: overflow,
|
||||||
|
maxLines: maxLines,
|
||||||
|
softWrap: softWrap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 제목용 스타일 팩토리
|
||||||
|
static ThemedText headline({
|
||||||
|
required String text,
|
||||||
|
TextStyle? style,
|
||||||
|
bool forceLight = false,
|
||||||
|
bool forceDark = false,
|
||||||
|
}) {
|
||||||
|
return ThemedText(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
).merge(style),
|
||||||
|
forceLight: forceLight,
|
||||||
|
forceDark: forceDark,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 부제목용 스타일 팩토리
|
||||||
|
static ThemedText subtitle({
|
||||||
|
required String text,
|
||||||
|
TextStyle? style,
|
||||||
|
bool forceLight = false,
|
||||||
|
bool forceDark = false,
|
||||||
|
double opacity = 0.8,
|
||||||
|
}) {
|
||||||
|
return ThemedText(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
).merge(style),
|
||||||
|
forceLight: forceLight,
|
||||||
|
forceDark: forceDark,
|
||||||
|
opacity: opacity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 본문용 스타일 팩토리
|
||||||
|
static ThemedText body({
|
||||||
|
required String text,
|
||||||
|
TextStyle? style,
|
||||||
|
bool forceLight = false,
|
||||||
|
bool forceDark = false,
|
||||||
|
double opacity = 0.9,
|
||||||
|
}) {
|
||||||
|
return ThemedText(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
).merge(style),
|
||||||
|
forceLight: forceLight,
|
||||||
|
forceDark: forceDark,
|
||||||
|
opacity: opacity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 캡션용 스타일 팩토리
|
||||||
|
static ThemedText caption({
|
||||||
|
required String text,
|
||||||
|
TextStyle? style,
|
||||||
|
bool forceLight = false,
|
||||||
|
bool forceDark = false,
|
||||||
|
double opacity = 0.7,
|
||||||
|
}) {
|
||||||
|
return ThemedText(
|
||||||
|
text,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.normal,
|
||||||
|
).merge(style),
|
||||||
|
forceLight: forceLight,
|
||||||
|
forceDark: forceDark,
|
||||||
|
opacity: opacity,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 글래스모피즘 컨텍스트를 표시하는 마커 위젯
|
||||||
|
class GlassmorphicIndicator extends InheritedWidget {
|
||||||
|
const GlassmorphicIndicator({
|
||||||
|
super.key,
|
||||||
|
required super.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
static GlassmorphicIndicator? of(BuildContext context) {
|
||||||
|
return context.dependOnInheritedWidgetOfExactType<GlassmorphicIndicator>();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool updateShouldNotify(GlassmorphicIndicator oldWidget) => false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼
|
||||||
|
class GlassmorphicTextWrapper extends StatelessWidget {
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const GlassmorphicTextWrapper({
|
||||||
|
super.key,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GlassmorphicIndicator(
|
||||||
|
child: DefaultTextStyle(
|
||||||
|
style: DefaultTextStyle.of(context).style.copyWith(
|
||||||
|
color: ThemedText.getContrastColor(context),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
75
macos/Podfile.lock
Normal file
75
macos/Podfile.lock
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
PODS:
|
||||||
|
- flutter_local_notifications (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
|
- flutter_secure_storage_macos (6.1.3):
|
||||||
|
- FlutterMacOS
|
||||||
|
- FlutterMacOS (1.0.0)
|
||||||
|
- local_auth_darwin (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- path_provider_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- share_plus (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
|
- shared_preferences_foundation (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- sqflite_darwin (0.0.4):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
- url_launcher_macos (0.0.1):
|
||||||
|
- FlutterMacOS
|
||||||
|
- webview_flutter_wkwebview (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
|
|
||||||
|
DEPENDENCIES:
|
||||||
|
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
|
||||||
|
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
|
||||||
|
- FlutterMacOS (from `Flutter/ephemeral`)
|
||||||
|
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
|
||||||
|
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
|
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
|
||||||
|
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
|
||||||
|
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
|
||||||
|
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
|
||||||
|
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
|
||||||
|
|
||||||
|
EXTERNAL SOURCES:
|
||||||
|
flutter_local_notifications:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
|
||||||
|
flutter_secure_storage_macos:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
|
||||||
|
FlutterMacOS:
|
||||||
|
:path: Flutter/ephemeral
|
||||||
|
local_auth_darwin:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin
|
||||||
|
path_provider_foundation:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
|
||||||
|
share_plus:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
|
||||||
|
shared_preferences_foundation:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
|
||||||
|
sqflite_darwin:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
|
||||||
|
url_launcher_macos:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
|
||||||
|
webview_flutter_wkwebview:
|
||||||
|
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
|
||||||
|
|
||||||
|
SPEC CHECKSUMS:
|
||||||
|
flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4
|
||||||
|
flutter_secure_storage_macos: c2754d3483d20bb207bb9e5a14f1b8e771abcdb9
|
||||||
|
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
|
||||||
|
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
|
||||||
|
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
|
||||||
|
share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7
|
||||||
|
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
|
||||||
|
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
|
||||||
|
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
|
||||||
|
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
|
||||||
|
|
||||||
|
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82
|
||||||
|
|
||||||
|
COCOAPODS: 1.16.2
|
||||||
@@ -27,6 +27,8 @@
|
|||||||
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
|
||||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||||
|
72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4015C586B270010D4F62A7 /* Pods_Runner.framework */; };
|
||||||
|
EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXContainerItemProxy section */
|
/* Begin PBXContainerItemProxy section */
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
|
||||||
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
|
||||||
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
|
||||||
33CC10ED2044A3C60003C045 /* submanager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "submanager.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
33CC10ED2044A3C60003C045 /* submanager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = submanager.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
|
||||||
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
|
||||||
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
|
||||||
@@ -76,8 +78,16 @@
|
|||||||
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
|
||||||
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
|
||||||
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
|
||||||
|
449D7C4E91ECC2307A618B21 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
4C4015C586B270010D4F62A7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
|
||||||
|
9119DFDCC41763FA448B6987 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
|
||||||
|
98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
BC9E591D2F7240B7A962E51A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
/* Begin PBXFrameworksBuildPhase section */
|
/* Begin PBXFrameworksBuildPhase section */
|
||||||
@@ -85,6 +95,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -92,12 +103,27 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
/* End PBXFrameworksBuildPhase section */
|
/* End PBXFrameworksBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXGroup section */
|
/* Begin PBXGroup section */
|
||||||
|
2A845BBB3A2FF55EFF11D802 /* Pods */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
9119DFDCC41763FA448B6987 /* Pods-Runner.debug.xcconfig */,
|
||||||
|
BC9E591D2F7240B7A962E51A /* Pods-Runner.release.xcconfig */,
|
||||||
|
449D7C4E91ECC2307A618B21 /* Pods-Runner.profile.xcconfig */,
|
||||||
|
F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */,
|
||||||
|
98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */,
|
||||||
|
ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */,
|
||||||
|
);
|
||||||
|
name = Pods;
|
||||||
|
path = Pods;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -125,6 +151,7 @@
|
|||||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||||
33CC10EE2044A3C60003C045 /* Products */,
|
33CC10EE2044A3C60003C045 /* Products */,
|
||||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||||
|
2A845BBB3A2FF55EFF11D802 /* Pods */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
@@ -175,6 +202,8 @@
|
|||||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
4C4015C586B270010D4F62A7 /* Pods_Runner.framework */,
|
||||||
|
FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -186,6 +215,7 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */,
|
||||||
331C80D1294CF70F00263BE5 /* Sources */,
|
331C80D1294CF70F00263BE5 /* Sources */,
|
||||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||||
331C80D3294CF70F00263BE5 /* Resources */,
|
331C80D3294CF70F00263BE5 /* Resources */,
|
||||||
@@ -204,11 +234,13 @@
|
|||||||
isa = PBXNativeTarget;
|
isa = PBXNativeTarget;
|
||||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
|
7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */,
|
||||||
33CC10E92044A3C60003C045 /* Sources */,
|
33CC10E92044A3C60003C045 /* Sources */,
|
||||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||||
33CC10EB2044A3C60003C045 /* Resources */,
|
33CC10EB2044A3C60003C045 /* Resources */,
|
||||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||||
|
CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -329,6 +361,67 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||||
};
|
};
|
||||||
|
451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||||
|
"${PODS_ROOT}/Manifest.lock",
|
||||||
|
);
|
||||||
|
name = "[CP] Check Pods Manifest.lock";
|
||||||
|
outputFileListPaths = (
|
||||||
|
);
|
||||||
|
outputPaths = (
|
||||||
|
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
|
CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||||
|
);
|
||||||
|
name = "[CP] Embed Pods Frameworks";
|
||||||
|
outputFileListPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
@@ -380,6 +473,7 @@
|
|||||||
/* Begin XCBuildConfiguration section */
|
/* Begin XCBuildConfiguration section */
|
||||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -394,6 +488,7 @@
|
|||||||
};
|
};
|
||||||
331C80DC294CF71000263BE5 /* Release */ = {
|
331C80DC294CF71000263BE5 /* Release */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = 98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
@@ -408,6 +503,7 @@
|
|||||||
};
|
};
|
||||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||||
isa = XCBuildConfiguration;
|
isa = XCBuildConfiguration;
|
||||||
|
baseConfigurationReference = ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */;
|
||||||
buildSettings = {
|
buildSettings = {
|
||||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
|
|||||||
@@ -4,4 +4,7 @@
|
|||||||
<FileRef
|
<FileRef
|
||||||
location = "group:Runner.xcodeproj">
|
location = "group:Runner.xcodeproj">
|
||||||
</FileRef>
|
</FileRef>
|
||||||
|
<FileRef
|
||||||
|
location = "group:Pods/Pods.xcodeproj">
|
||||||
|
</FileRef>
|
||||||
</Workspace>
|
</Workspace>
|
||||||
|
|||||||
Reference in New Issue
Block a user