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 */; };
|
||||
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
|
||||
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
|
||||
3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */; };
|
||||
73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */; };
|
||||
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
|
||||
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
|
||||
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
|
||||
@@ -40,6 +42,8 @@
|
||||
/* End PBXCopyFilesBuildPhase section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
@@ -55,19 +59,43 @@
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
27474C77F8EBFE4B5468329B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
97C146EB1CF9000F007C117D /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
104722E6173DA3E706B6AF13 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */,
|
||||
13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C8082294A63A400263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -94,6 +122,8 @@
|
||||
97C146F01CF9000F007C117D /* Runner */,
|
||||
97C146EF1CF9000F007C117D /* Products */,
|
||||
331C8082294A63A400263BE5 /* RunnerTests */,
|
||||
F0813F149E71664270D649A1 /* Pods */,
|
||||
104722E6173DA3E706B6AF13 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -121,6 +151,20 @@
|
||||
path = Runner;
|
||||
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 */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
@@ -128,8 +172,10 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */,
|
||||
331C807D294A63A400263BE5 /* Sources */,
|
||||
331C807F294A63A400263BE5 /* Resources */,
|
||||
27474C77F8EBFE4B5468329B /* Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -145,12 +191,15 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */,
|
||||
9740EEB61CF901F6004384FC /* Run Script */,
|
||||
97C146EA1CF9000F007C117D /* Sources */,
|
||||
97C146EB1CF9000F007C117D /* Frameworks */,
|
||||
97C146EC1CF9000F007C117D /* Resources */,
|
||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||
45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */,
|
||||
05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -222,6 +271,45 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXShellScriptBuildPhase section */
|
||||
05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Copy Pods Resources";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -238,6 +326,23 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
|
||||
};
|
||||
45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
9740EEB61CF901F6004384FC /* Run Script */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
alwaysOutOfDate = 1;
|
||||
@@ -253,6 +358,28 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
||||
};
|
||||
EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -378,6 +505,7 @@
|
||||
};
|
||||
331C8088294A63A400263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -395,6 +523,7 @@
|
||||
};
|
||||
331C8089294A63A400263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
@@ -410,6 +539,7 @@
|
||||
};
|
||||
331C808A294A63A400263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
|
||||
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
3
ios/Runner.xcworkspace/contents.xcworkspacedata
generated
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'models/subscription_model.dart';
|
||||
import 'models/category_model.dart';
|
||||
import 'providers/subscription_provider.dart';
|
||||
import 'providers/app_lock_provider.dart';
|
||||
import 'providers/notification_provider.dart';
|
||||
import 'screens/main_screen.dart';
|
||||
import 'screens/app_lock_screen.dart';
|
||||
import 'providers/navigation_provider.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'providers/category_provider.dart';
|
||||
import 'providers/locale_provider.dart';
|
||||
import 'providers/theme_provider.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'theme/adaptive_theme.dart';
|
||||
import 'routes/app_routes.dart';
|
||||
import 'navigation/app_navigation_observer.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
import 'utils/memory_manager.dart';
|
||||
import 'utils/performance_optimizer.dart';
|
||||
import 'navigator_key.dart';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
@@ -31,17 +32,25 @@ Future<void> main() async {
|
||||
await MobileAds.instance.initialize();
|
||||
}
|
||||
|
||||
// 성능 최적화 설정
|
||||
MemoryManager.optimizeImageCache();
|
||||
MemoryManager().startAutoCleanup();
|
||||
|
||||
// 앱 시작 시 이미지 캐시 관리
|
||||
try {
|
||||
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
|
||||
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
|
||||
await DefaultCacheManager().emptyCache();
|
||||
|
||||
print('이미지 캐시 관리 초기화 완료');
|
||||
if (kDebugMode) {
|
||||
print('이미지 캐시 관리 초기화 완료');
|
||||
PerformanceOptimizer.checkConstOptimization();
|
||||
}
|
||||
} catch (e) {
|
||||
print('캐시 초기화 오류: $e');
|
||||
if (kDebugMode) {
|
||||
print('캐시 초기화 오류: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Hive 초기화
|
||||
@@ -58,11 +67,14 @@ Future<void> main() async {
|
||||
final categoryProvider = CategoryProvider();
|
||||
final localeProvider = LocaleProvider();
|
||||
final notificationProvider = NotificationProvider();
|
||||
final themeProvider = ThemeProvider();
|
||||
final navigationProvider = NavigationProvider();
|
||||
|
||||
await subscriptionProvider.init();
|
||||
await categoryProvider.init();
|
||||
await localeProvider.init();
|
||||
await notificationProvider.init();
|
||||
await themeProvider.initialize();
|
||||
|
||||
// NotificationProvider에 SubscriptionProvider 연결 (알림 재예약용)
|
||||
// SRP 원칙에 따라 다른 Provider 객체를 명시적으로 주입
|
||||
@@ -89,6 +101,8 @@ Future<void> main() async {
|
||||
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
|
||||
ChangeNotifierProvider(create: (_) => notificationProvider),
|
||||
ChangeNotifierProvider(create: (_) => localeProvider),
|
||||
ChangeNotifierProvider(create: (_) => themeProvider),
|
||||
ChangeNotifierProvider(create: (_) => navigationProvider),
|
||||
],
|
||||
child: const SubManagerApp(),
|
||||
),
|
||||
@@ -100,12 +114,15 @@ class SubManagerApp extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<LocaleProvider>(
|
||||
builder: (context, localeProvider, child) {
|
||||
return Consumer2<LocaleProvider, ThemeProvider>(
|
||||
builder: (context, localeProvider, themeProvider, child) {
|
||||
// 시스템 UI 오버레이 스타일 적용
|
||||
AdaptiveTheme.applySystemUIOverlay(context);
|
||||
|
||||
return MaterialApp(
|
||||
title: 'SubManager',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
theme: themeProvider.getTheme(context),
|
||||
locale: localeProvider.locale,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizationsDelegate(),
|
||||
@@ -118,7 +135,24 @@ class SubManagerApp extends StatelessWidget {
|
||||
Locale('ko'),
|
||||
],
|
||||
navigatorKey: navigatorKey,
|
||||
home: const SplashScreen(),
|
||||
navigatorObservers: [AppNavigationObserver()],
|
||||
initialRoute: AppRoutes.splash,
|
||||
routes: AppRoutes.getRoutes(),
|
||||
onGenerateRoute: AppRoutes.generateRoute,
|
||||
builder: (context, child) {
|
||||
// 성능 최적화 및 메모리 관리
|
||||
if (kDebugMode) {
|
||||
PerformanceOptimizer().startFrameMonitoring();
|
||||
}
|
||||
|
||||
return MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
textScaler: TextScaler.linear(themeProvider.largeText ? 1.2 : 1.0),
|
||||
disableAnimations: themeProvider.reduceMotion,
|
||||
),
|
||||
child: child!,
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -95,6 +95,9 @@ class SubscriptionModel extends HiveObject {
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// 원래 가격 (이벤트와 관계없이 항상 정상 가격)
|
||||
double get originalPrice => monthlyCost;
|
||||
}
|
||||
|
||||
// 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();
|
||||
}
|
||||
}
|
||||
|
||||
/// 총 월간 지출을 계산합니다.
|
||||
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 '../providers/subscription_provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../models/category_model.dart';
|
||||
import '../services/sms_service.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../services/exchange_rate_service.dart';
|
||||
@@ -495,7 +494,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context);
|
||||
Navigator.pop(context, true); // 성공 여부 반환
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
@@ -536,11 +535,11 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(appBarOpacity),
|
||||
color: Colors.white.withValues(alpha: appBarOpacity),
|
||||
boxShadow: appBarOpacity > 0.6
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1 * appBarOpacity),
|
||||
color: Colors.black.withValues(alpha: 0.1 * appBarOpacity),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
@@ -561,7 +560,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
shadows: appBarOpacity > 0.6
|
||||
? [
|
||||
Shadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
)
|
||||
@@ -626,7 +625,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _gradientColors[0].withOpacity(0.3),
|
||||
color: _gradientColors[0].withValues(alpha: 0.3),
|
||||
blurRadius: 20,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 8),
|
||||
@@ -638,7 +637,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
color: Colors.white.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
@@ -741,7 +740,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 0
|
||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
||||
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -786,7 +785,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -821,7 +820,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 1
|
||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
||||
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -922,7 +921,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
Colors.grey.withOpacity(0.2),
|
||||
Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -979,7 +978,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: Border.all(
|
||||
color: _currentEditingField == 1
|
||||
? const Color(0xFF3B82F6)
|
||||
: Colors.grey.withOpacity(
|
||||
: Colors.grey.withValues(alpha:
|
||||
0.4), // 포커스 없을 때 더 진한 회색
|
||||
width: _currentEditingField == 1
|
||||
? 2
|
||||
@@ -997,7 +996,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: Colors.grey
|
||||
.withOpacity(0.2),
|
||||
.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -1248,7 +1247,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 2
|
||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
||||
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1285,7 +1284,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1336,7 +1335,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 3
|
||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
||||
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1397,7 +1396,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: Border.all(
|
||||
color: _nextBillingDate == null
|
||||
? Colors.red
|
||||
: Colors.grey.withOpacity(0.2),
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
@@ -1437,7 +1436,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 4
|
||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
||||
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1476,7 +1475,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1504,7 +1503,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 5
|
||||
? const Color(0xFF3B82F6).withOpacity(0.1)
|
||||
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1538,7 +1537,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color:
|
||||
Colors.grey.withOpacity(0.2),
|
||||
Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
@@ -1598,7 +1597,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1667,7 +1666,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: Border.all(
|
||||
color: _isEventActive
|
||||
? const Color(0xFF3B82F6)
|
||||
: Colors.grey.withOpacity(0.2),
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
width: _isEventActive ? 2 : 1,
|
||||
),
|
||||
),
|
||||
@@ -1761,7 +1760,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
@@ -1825,7 +1824,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
@@ -1889,7 +1888,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1967,15 +1966,15 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF3B82F6),
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey.withOpacity(0.3),
|
||||
disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3),
|
||||
disabledForegroundColor:
|
||||
Colors.white.withOpacity(0.5),
|
||||
Colors.white.withValues(alpha: 0.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: _isSaveHovered ? 8 : 4,
|
||||
shadowColor: const Color(0xFF3B82F6).withOpacity(0.5),
|
||||
shadowColor: const Color(0xFF3B82F6).withValues(alpha: 0.5),
|
||||
),
|
||||
child: _isLoading
|
||||
? const SizedBox(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -385,7 +385,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
return LinearGradient(
|
||||
colors: [
|
||||
baseColor,
|
||||
baseColor.withOpacity(0.7),
|
||||
baseColor.withValues(alpha: 0.7),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
@@ -628,11 +628,11 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(appBarOpacity),
|
||||
color: Colors.white.withValues(alpha: appBarOpacity),
|
||||
boxShadow: appBarOpacity > 0.6
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.1 * appBarOpacity),
|
||||
color: Colors.black.withValues(alpha: 0.1 * appBarOpacity),
|
||||
spreadRadius: 1,
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 4),
|
||||
@@ -653,7 +653,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
shadows: appBarOpacity > 0.6
|
||||
? [
|
||||
Shadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
color: Colors.black.withValues(alpha: 0.2),
|
||||
offset: const Offset(0, 1),
|
||||
blurRadius: 2,
|
||||
)
|
||||
@@ -746,7 +746,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
tag: 'subscription_${widget.subscription.id}',
|
||||
child: Card(
|
||||
elevation: 8,
|
||||
shadowColor: baseColor.withOpacity(0.4),
|
||||
shadowColor: baseColor.withValues(alpha: 0.4),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
@@ -760,7 +760,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
baseColor.withOpacity(0.8),
|
||||
baseColor.withValues(alpha: 0.8),
|
||||
baseColor,
|
||||
],
|
||||
),
|
||||
@@ -787,7 +787,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black
|
||||
.withOpacity(0.1),
|
||||
.withValues(alpha: 0.1),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 0,
|
||||
),
|
||||
@@ -834,7 +834,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
Colors.white.withOpacity(0.8),
|
||||
Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -846,7 +846,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
@@ -863,7 +863,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
Colors.white.withOpacity(0.8),
|
||||
Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -889,7 +889,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color:
|
||||
Colors.white.withOpacity(0.8),
|
||||
Colors.white.withValues(alpha: 0.8),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
@@ -924,10 +924,10 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFDC2626)
|
||||
.withOpacity(0.2),
|
||||
.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -1015,7 +1015,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 0
|
||||
? baseColor.withOpacity(0.1)
|
||||
? baseColor.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1053,7 +1053,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1080,7 +1080,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 1
|
||||
? baseColor.withOpacity(0.1)
|
||||
? baseColor.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1181,7 +1181,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color:
|
||||
Colors.grey.withOpacity(0.2),
|
||||
Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1238,7 +1238,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
border: Border.all(
|
||||
color: _currentEditingField == 1
|
||||
? baseColor
|
||||
: Colors.grey.withOpacity(
|
||||
: Colors.grey.withValues(alpha:
|
||||
0.4), // 포커스 없을 때 더 진한 회색
|
||||
width: _currentEditingField == 1
|
||||
? 2
|
||||
@@ -1256,7 +1256,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
border: Border(
|
||||
right: BorderSide(
|
||||
color: Colors.grey
|
||||
.withOpacity(0.2),
|
||||
.withValues(alpha: 0.2),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -1508,7 +1508,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 2
|
||||
? baseColor.withOpacity(0.1)
|
||||
? baseColor.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1545,7 +1545,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1584,7 +1584,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 3
|
||||
? baseColor.withOpacity(0.1)
|
||||
? baseColor.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1642,7 +1642,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
color: Colors.white,
|
||||
@@ -1678,7 +1678,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 4
|
||||
? baseColor.withOpacity(0.1)
|
||||
? baseColor.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1716,7 +1716,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1748,7 +1748,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: _currentEditingField == 5
|
||||
? baseColor.withOpacity(0.1)
|
||||
? baseColor.withValues(alpha: 0.1)
|
||||
: Colors.transparent,
|
||||
),
|
||||
padding: const EdgeInsets.all(8),
|
||||
@@ -1776,7 +1776,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
@@ -1827,7 +1827,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -1900,7 +1900,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
border: Border.all(
|
||||
color: _isEventActive
|
||||
? baseColor
|
||||
: Colors.grey.withOpacity(0.2),
|
||||
: Colors.grey.withValues(alpha: 0.2),
|
||||
width: _isEventActive ? 2 : 1,
|
||||
),
|
||||
),
|
||||
@@ -1990,7 +1990,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
@@ -2054,7 +2054,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: Colors.grey.withOpacity(0.3)),
|
||||
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
@@ -2118,7 +2118,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: Colors.grey.withOpacity(0.2),
|
||||
color: Colors.grey.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
@@ -2196,7 +2196,7 @@ class _DetailScreenState extends State<DetailScreen>
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: _isSaveHovered ? 8 : 4,
|
||||
shadowColor: baseColor.withOpacity(0.5),
|
||||
shadowColor: baseColor.withValues(alpha: 0.5),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
|
||||
@@ -1,28 +1,20 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'add_subscription_screen.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import 'analysis_screen.dart';
|
||||
import 'app_lock_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import '../widgets/subscription_card.dart';
|
||||
import '../widgets/skeleton_loading.dart';
|
||||
import 'sms_scan_screen.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../utils/subscription_category_helper.dart';
|
||||
import '../utils/animation_controller_helper.dart';
|
||||
import '../widgets/subscription_list_widget.dart';
|
||||
import '../widgets/main_summary_card.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/native_ad_widget.dart';
|
||||
import '../widgets/floating_navigation_bar.dart';
|
||||
import '../widgets/glassmorphic_scaffold.dart';
|
||||
import '../widgets/glassmorphic_app_bar.dart';
|
||||
import '../widgets/home_content.dart';
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
@@ -40,7 +32,11 @@ class _MainScreenState extends State<MainScreen>
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _waveController;
|
||||
late ScrollController _scrollController;
|
||||
double _scrollOffset = 0;
|
||||
late FloatingNavBarScrollController _navBarScrollController;
|
||||
bool _isNavBarVisible = true;
|
||||
|
||||
// 화면 목록
|
||||
late final List<Widget> _screens;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -67,12 +63,30 @@ class _MainScreenState extends State<MainScreen>
|
||||
waveController: _waveController,
|
||||
);
|
||||
|
||||
_scrollController = ScrollController()
|
||||
..addListener(() {
|
||||
setState(() {
|
||||
_scrollOffset = _scrollController.offset;
|
||||
});
|
||||
});
|
||||
_scrollController = ScrollController();
|
||||
|
||||
_navBarScrollController = FloatingNavBarScrollController(
|
||||
scrollController: _scrollController,
|
||||
onHide: () => setState(() => _isNavBarVisible = false),
|
||||
onShow: () => setState(() => _isNavBarVisible = true),
|
||||
);
|
||||
|
||||
// 화면 목록 초기화
|
||||
_screens = [
|
||||
HomeContent(
|
||||
fadeController: _fadeController,
|
||||
rotateController: _rotateController,
|
||||
slideController: _slideController,
|
||||
pulseController: _pulseController,
|
||||
waveController: _waveController,
|
||||
scrollController: _scrollController,
|
||||
onAddPressed: () => _navigateToAddSubscription(context),
|
||||
),
|
||||
const AnalysisScreen(),
|
||||
Container(), // 추가 버튼은 별도 처리
|
||||
const SmsScanScreen(),
|
||||
const SettingsScreen(),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -90,6 +104,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
);
|
||||
|
||||
_scrollController.dispose();
|
||||
_navBarScrollController.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) {
|
||||
HapticFeedback.mediumImpact();
|
||||
Navigator.of(context)
|
||||
.push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const AddSubscriptionScreen(),
|
||||
transitionsBuilder:
|
||||
(context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.8, end: 1.0).animate(animation),
|
||||
child: child,
|
||||
Navigator.pushNamed(
|
||||
context,
|
||||
AppRoutes.addSubscription,
|
||||
).then((result) {
|
||||
_resetAnimations();
|
||||
|
||||
// 구독이 성공적으로 추가된 경우
|
||||
if (result == true) {
|
||||
// 상단에 스낵바 표시
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
);
|
||||
},
|
||||
const SizedBox(width: 12),
|
||||
const Text(
|
||||
'구독이 추가되었습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: const Color(0xFF10B981), // 초록색
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
dismissDirection: DismissDirection.horizontal,
|
||||
),
|
||||
)
|
||||
.then((_) => _resetAnimations());
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _navigateToSettings(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const SettingsScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
void _handleNavigation(int index, BuildContext context) {
|
||||
final navigationProvider = context.read<NavigationProvider>();
|
||||
|
||||
// 이미 같은 인덱스면 무시
|
||||
if (navigationProvider.currentIndex == index) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 추가 버튼은 별도 처리
|
||||
if (index == 2) {
|
||||
_navigateToAddSubscription(context);
|
||||
return;
|
||||
}
|
||||
|
||||
// 인덱스 업데이트
|
||||
navigationProvider.updateCurrentIndex(index);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100));
|
||||
final navigationProvider = context.watch<NavigationProvider>();
|
||||
final hour = DateTime.now().hour;
|
||||
List<Color> backgroundGradient;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.backgroundColor,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: _buildAppBar(appBarOpacity),
|
||||
body: _buildBody(context, context.watch<SubscriptionProvider>()),
|
||||
floatingActionButton: _buildFloatingActionButton(context),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSize _buildAppBar(double appBarOpacity) {
|
||||
return PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColor.withOpacity(appBarOpacity),
|
||||
boxShadow: appBarOpacity > 0.6
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06 * appBarOpacity),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: AppBar(
|
||||
title: FadeTransition(
|
||||
opacity: Tween<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 (hour >= 6 && hour < 10) {
|
||||
backgroundGradient = AppColors.morningGradient;
|
||||
} else if (hour >= 10 && hour < 17) {
|
||||
backgroundGradient = AppColors.dayGradient;
|
||||
} else if (hour >= 17 && hour < 20) {
|
||||
backgroundGradient = AppColors.eveningGradient;
|
||||
} else {
|
||||
backgroundGradient = AppColors.nightGradient;
|
||||
}
|
||||
|
||||
if (provider.subscriptions.isEmpty) {
|
||||
return EmptyStateWidget(
|
||||
fadeController: _fadeController,
|
||||
rotateController: _rotateController,
|
||||
slideController: _slideController,
|
||||
onAddPressed: () => _navigateToAddSubscription(context),
|
||||
);
|
||||
// 현재 인덱스가 유효한지 확인
|
||||
int currentIndex = navigationProvider.currentIndex;
|
||||
if (currentIndex == 2) {
|
||||
currentIndex = 0; // 추가 버튼은 홈으로 표시
|
||||
}
|
||||
|
||||
// 카테고리별 구독 구분
|
||||
final categoryProvider =
|
||||
Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categorizedSubscriptions =
|
||||
SubscriptionCategoryHelper.categorizeSubscriptions(
|
||||
provider.subscriptions,
|
||||
categoryProvider,
|
||||
);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await provider.refreshSubscriptions();
|
||||
_resetAnimations();
|
||||
},
|
||||
color: const Color(0xFF3B82F6),
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: MediaQuery.of(context).padding.top + 60),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: NativeAdWidget(key: UniqueKey()),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SlideTransition(
|
||||
position: Tween<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),
|
||||
),
|
||||
],
|
||||
return GlassmorphicScaffold(
|
||||
body: IndexedStack(
|
||||
index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex,
|
||||
children: _screens,
|
||||
),
|
||||
backgroundGradient: backgroundGradient,
|
||||
useFloatingNavBar: true,
|
||||
floatingNavBarIndex: navigationProvider.currentIndex,
|
||||
onFloatingNavBarTapped: (index) {
|
||||
_handleNavigation(index, context);
|
||||
},
|
||||
enableParticles: false,
|
||||
enableWaveAnimation: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
import '../providers/notification_provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
@@ -11,6 +12,13 @@ import 'package:path/path.dart' as path;
|
||||
import '../services/notification_service.dart';
|
||||
import '../screens/sms_scan_screen.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../providers/theme_provider.dart';
|
||||
import '../theme/adaptive_theme.dart';
|
||||
import '../widgets/glassmorphic_scaffold.dart';
|
||||
import '../widgets/glassmorphic_app_bar.dart';
|
||||
import '../widgets/glassmorphism_card.dart';
|
||||
import '../widgets/app_navigator.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
@@ -27,13 +35,13 @@ class SettingsScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.2)
|
||||
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
@@ -130,12 +138,81 @@ class SettingsScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('설정'),
|
||||
),
|
||||
body: ListView(
|
||||
return ListView(
|
||||
padding: const EdgeInsets.only(top: 20),
|
||||
children: [
|
||||
// 테마 설정
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Consumer<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 숨김
|
||||
// Card(
|
||||
// margin: const EdgeInsets.all(16),
|
||||
@@ -161,8 +238,9 @@ class SettingsScreen extends StatelessWidget {
|
||||
// ),
|
||||
|
||||
// 알림 설정
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Consumer<NotificationProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return Column(
|
||||
@@ -211,7 +289,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant
|
||||
.withOpacity(0.3),
|
||||
.withValues(alpha: 0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
@@ -273,7 +351,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withOpacity(0.5),
|
||||
.withValues(alpha: 0.5),
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
@@ -329,7 +407,7 @@ class SettingsScreen extends StatelessWidget {
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant
|
||||
.withOpacity(0.3),
|
||||
.withValues(alpha: 0.3),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
),
|
||||
@@ -377,8 +455,9 @@ class SettingsScreen extends StatelessWidget {
|
||||
),
|
||||
|
||||
// 데이터 관리
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Column(
|
||||
children: [
|
||||
// 데이터 백업 기능 비활성화
|
||||
@@ -389,108 +468,14 @@ class SettingsScreen extends StatelessWidget {
|
||||
// onTap: () => _backupData(context),
|
||||
// ),
|
||||
// const Divider(),
|
||||
// SMS 스캔 - 시각적으로 강조된 UI
|
||||
InkWell(
|
||||
onTap: () => _navigateToSmsScan(context),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
Theme.of(context).primaryColor.withOpacity(0.2),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0, horizontal: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(left: 8, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.primaryColor
|
||||
.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.sms_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'SMS 스캔으로 구독 자동 찾기',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'추천',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'2회 이상 반복 결제된 구독 서비스를 자동으로 찾아 추가합니다',
|
||||
style: TextStyle(
|
||||
color: Colors.black54,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 앱 정보
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: ListTile(
|
||||
title: const Text('앱 정보'),
|
||||
subtitle: const Text('버전 1.0.0'),
|
||||
@@ -554,8 +539,36 @@ class SettingsScreen extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: 20 + MediaQuery.of(context).padding.bottom, // 하단 여백
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
String _getThemeModeText(AppThemeMode mode) {
|
||||
switch (mode) {
|
||||
case AppThemeMode.light:
|
||||
return '라이트';
|
||||
case AppThemeMode.dark:
|
||||
return '다크';
|
||||
case AppThemeMode.oled:
|
||||
return 'OLED 블랙';
|
||||
case AppThemeMode.system:
|
||||
return '시스템 설정';
|
||||
}
|
||||
}
|
||||
|
||||
IconData _getThemeModeIcon(AppThemeMode mode) {
|
||||
switch (mode) {
|
||||
case AppThemeMode.light:
|
||||
return Icons.light_mode;
|
||||
case AppThemeMode.dark:
|
||||
return Icons.dark_mode;
|
||||
case AppThemeMode.oled:
|
||||
return Icons.phonelink_lock;
|
||||
case AppThemeMode.system:
|
||||
return Icons.settings_brightness;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/sms_scanner.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/subscription.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
|
||||
import '../widgets/glassmorphic_scaffold.dart';
|
||||
import '../widgets/glassmorphic_app_bar.dart';
|
||||
import '../widgets/glassmorphism_card.dart';
|
||||
import '../widgets/themed_text.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
|
||||
class SmsScanScreen extends StatefulWidget {
|
||||
const SmsScanScreen({super.key});
|
||||
@@ -100,8 +106,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
||||
print('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
||||
|
||||
if (filteredSubscriptions.isNotEmpty &&
|
||||
filteredSubscriptions[0] != null) {
|
||||
if (filteredSubscriptions.isNotEmpty) {
|
||||
print(
|
||||
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
||||
}
|
||||
@@ -163,10 +168,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
|
||||
// 중복되지 않은 구독만 필터링
|
||||
final nonDuplicates = scanned.where((scannedSub) {
|
||||
if (scannedSub == null) {
|
||||
print('_filterDuplicates: null 구독 객체 발견');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 서비스명과 금액이 동일한 기존 구독 찾기
|
||||
final hasDuplicate = existing.any((existingSub) =>
|
||||
@@ -189,10 +190,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
|
||||
for (int i = 0; i < nonDuplicates.length; i++) {
|
||||
final subscription = nonDuplicates[i];
|
||||
if (subscription == null) {
|
||||
print('_filterDuplicates: null 구독 객체 무시');
|
||||
continue;
|
||||
}
|
||||
|
||||
String? websiteUrl = subscription.websiteUrl;
|
||||
|
||||
@@ -252,11 +249,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
}
|
||||
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
if (subscription == null) {
|
||||
print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)');
|
||||
_moveToNextSubscription();
|
||||
return;
|
||||
}
|
||||
|
||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
|
||||
@@ -365,9 +357,38 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${subscription.serviceName} 구독이 추가되었습니다.'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
content: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${subscription.serviceName} 구독이 추가되었습니다.',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
backgroundColor: const Color(0xFF10B981), // 초록색
|
||||
behavior: SnackBarBehavior.floating,
|
||||
margin: EdgeInsets.only(
|
||||
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
duration: const Duration(seconds: 3),
|
||||
dismissDirection: DismissDirection.horizontal,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -402,21 +423,36 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
_currentIndex++;
|
||||
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||
|
||||
// 모든 구독을 처리했으면 화면 종료
|
||||
// 모든 구독을 처리했으면 홈 화면으로 이동
|
||||
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) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (date.isBefore(now)) {
|
||||
// 주기에 따라 다음 결제일 예측
|
||||
if (_currentIndex >= _scannedSubscriptions.length ||
|
||||
_scannedSubscriptions[_currentIndex] == null) {
|
||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||
return '다음 결제일 확인 필요';
|
||||
}
|
||||
|
||||
@@ -485,17 +521,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('SMS 스캔'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _isLoading
|
||||
? _buildLoadingState()
|
||||
: (_scannedSubscriptions.isEmpty
|
||||
? _buildInitialState()
|
||||
: _buildSubscriptionState())),
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _isLoading
|
||||
? _buildLoadingState()
|
||||
: (_scannedSubscriptions.isEmpty
|
||||
? _buildInitialState()
|
||||
: _buildSubscriptionState()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -507,9 +539,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('SMS 메시지를 스캔 중입니다...'),
|
||||
ThemedText('SMS 메시지를 스캔 중입니다...'),
|
||||
SizedBox(height: 8),
|
||||
Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)),
|
||||
ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -524,24 +556,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
if (_errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
child: ThemedText(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
color: Colors.red,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'2회 이상 결제된 구독 서비스 찾기',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: Text(
|
||||
child: ThemedText(
|
||||
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
opacity: 0.7,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
@@ -562,26 +595,11 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
Widget _buildSubscriptionState() {
|
||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||
return const Center(
|
||||
child: Text('모든 구독 처리 완료'),
|
||||
child: ThemedText('모든 구독 처리 완료'),
|
||||
);
|
||||
}
|
||||
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
if (subscription == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('오류: 구독 정보를 불러올 수 없습니다.'),
|
||||
SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _moveToNextSubscription,
|
||||
child: Text('건너뛰기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
||||
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
||||
@@ -594,54 +612,42 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
// 진행 상태 표시
|
||||
LinearProgressIndicator(
|
||||
value: (_currentIndex + 1) / _scannedSubscriptions.length,
|
||||
backgroundColor: Colors.grey.withOpacity(0.2),
|
||||
backgroundColor: Colors.grey.withValues(alpha: 0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
ThemedText(
|
||||
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 구독 정보 카드
|
||||
Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
GlassmorphismCard(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'다음 구독을 찾았습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 서비스명
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'서비스명',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
ThemedText(
|
||||
subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
@@ -652,15 +658,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'월 비용',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
ThemedText(
|
||||
subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
@@ -672,10 +676,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(subscription.monthlyCost),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -684,21 +686,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'반복 횟수',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
ThemedText(
|
||||
_getRepeatCountText(subscription.repeatCount),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -714,20 +712,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'결제 주기',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
ThemedText(
|
||||
subscription.billingCycle,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -736,20 +730,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'결제일',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontWeight: FontWeight.w500,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
ThemedText(
|
||||
_getNextBillingText(subscription.nextBillingDate),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -800,7 +790,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -809,8 +798,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_scannedSubscriptions.isNotEmpty &&
|
||||
_currentIndex < _scannedSubscriptions.length &&
|
||||
_scannedSubscriptions[_currentIndex] != null) {
|
||||
_currentIndex < _scannedSubscriptions.length) {
|
||||
final currentSub = _scannedSubscriptions[_currentIndex];
|
||||
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
||||
_websiteUrlController.text = currentSub.websiteUrl!;
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
import '../providers/navigation_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../widgets/glassmorphism_card.dart';
|
||||
import '../routes/app_routes.dart';
|
||||
import 'app_lock_screen.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
@@ -101,18 +105,10 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
|
||||
void navigateToNextScreen() {
|
||||
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const MainScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
),
|
||||
// 모든 이전 라우트를 제거하고 홈으로 이동
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
AppRoutes.main,
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,244 +123,305 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: AppColors.blueGradient,
|
||||
body: Stack(
|
||||
children: [
|
||||
// 배경 그라디언트
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
AppColors.dayGradient[0],
|
||||
AppColors.dayGradient[1],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 배경 파티클
|
||||
..._particles.map((particle) {
|
||||
return AnimatedPositioned(
|
||||
duration: Duration(milliseconds: particle['duration'].toInt()),
|
||||
curve: Curves.easeInOut,
|
||||
left: particle['x'] - 50 + (size.width * 0.1),
|
||||
top: particle['y'] - 50 + (size.height * 0.1),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
|
||||
// 글래스모피즘 오버레이
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
),
|
||||
),
|
||||
Stack(
|
||||
children: [
|
||||
// 배경 파티클
|
||||
..._particles.map((particle) {
|
||||
return AnimatedPositioned(
|
||||
duration:
|
||||
Duration(milliseconds: particle['duration'].toInt()),
|
||||
builder: (context, value, child) {
|
||||
return Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: particle['size'],
|
||||
height: particle['size'],
|
||||
decoration: BoxDecoration(
|
||||
color: particle['color'],
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: particle['color'].withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
// 상단 원형 그라데이션
|
||||
Positioned(
|
||||
top: -size.height * 0.2,
|
||||
right: -size.width * 0.2,
|
||||
child: Container(
|
||||
width: size.width * 0.8,
|
||||
height: size.width * 0.8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.1),
|
||||
Colors.white.withOpacity(0.0),
|
||||
],
|
||||
stops: const [0.2, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 원형 그라데이션
|
||||
Positioned(
|
||||
bottom: -size.height * 0.1,
|
||||
left: -size.width * 0.3,
|
||||
child: Container(
|
||||
width: size.width * 0.9,
|
||||
height: size.width * 0.9,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.07),
|
||||
Colors.white.withOpacity(0.0),
|
||||
],
|
||||
stops: const [0.4, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 메인 콘텐츠
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 로고 애니메이션
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Transform.rotate(
|
||||
angle: _rotateAnimation.value,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, _) {
|
||||
return ShaderMask(
|
||||
blendMode: BlendMode.srcIn,
|
||||
shaderCallback: (bounds) =>
|
||||
LinearGradient(
|
||||
colors: AppColors.blueGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(bounds),
|
||||
child: Icon(
|
||||
Icons.subscriptions_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context)
|
||||
.primaryColor,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 앱 이름 텍스트
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _slideAnimation.value),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'SubManager',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
curve: Curves.easeInOut,
|
||||
left: particle['x'] - 50 + (size.width * 0.1),
|
||||
top: particle['y'] - 50 + (size.height * 0.1),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
|
||||
duration:
|
||||
Duration(milliseconds: particle['duration'].toInt()),
|
||||
builder: (context, value, child) {
|
||||
return Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: particle['size'],
|
||||
height: particle['size'],
|
||||
decoration: BoxDecoration(
|
||||
color: particle['color'],
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: particle['color'].withValues(alpha: 0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 부제목 텍스트
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _slideAnimation.value * 1.2),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'구독 서비스 관리를 더 쉽게',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white70,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// 로딩 인디케이터
|
||||
FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<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);
|
||||
}
|
||||
}
|
||||
|
||||
/// 금액과 통화를 받아 포맷팅하여 반환
|
||||
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(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(
|
||||
color: AppColors.cardColor,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black.withOpacity(0.04),
|
||||
shadowColor: Colors.black.withValues(alpha: 0.04),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppColors.borderColor, width: 0.5),
|
||||
@@ -265,7 +265,7 @@ class AppTheme {
|
||||
}),
|
||||
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return AppColors.primaryColor.withOpacity(0.5);
|
||||
return AppColors.primaryColor.withValues(alpha: 0.5);
|
||||
}
|
||||
return AppColors.borderColor;
|
||||
}),
|
||||
@@ -300,7 +300,7 @@ class AppTheme {
|
||||
activeTrackColor: AppColors.primaryColor,
|
||||
inactiveTrackColor: AppColors.borderColor,
|
||||
thumbColor: AppColors.primaryColor,
|
||||
overlayColor: AppColors.primaryColor.withOpacity(0.2),
|
||||
overlayColor: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
trackHeight: 4,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
|
||||
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,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
color: Colors.white.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
),
|
||||
@@ -64,7 +64,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
color: Colors.white.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(110),
|
||||
),
|
||||
),
|
||||
@@ -90,7 +90,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.08),
|
||||
color: Colors.white.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
),
|
||||
@@ -109,7 +109,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(
|
||||
color: Colors.white.withValues(alpha:
|
||||
0.1 + 0.1 * pulseController.value,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
|
||||
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/services.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'glassmorphism_card.dart';
|
||||
import 'themed_text.dart';
|
||||
|
||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||
///
|
||||
@@ -31,21 +33,10 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutBack)),
|
||||
child: Container(
|
||||
child: GlassmorphismCard(
|
||||
width: null,
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.08),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -65,7 +56,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF3B82F6).withOpacity(0.3),
|
||||
color: const Color(0xFF3B82F6).withValues(alpha: 0.3),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
@@ -82,29 +73,17 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: [Color(0xFF3B82F6), Color(0xFF0EA5E9)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(bounds),
|
||||
child: const Text(
|
||||
'등록된 구독이 없습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
const ThemedText(
|
||||
'등록된 구독이 없습니다',
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
const ThemedText(
|
||||
'새로운 구독을 추가해보세요',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
fontSize: 16,
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
MouseRegion(
|
||||
@@ -133,6 +112,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
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 '../utils/format_helper.dart';
|
||||
import 'animated_wave_background.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
/// 메인 화면 상단에 표시되는 요약 카드 위젯
|
||||
///
|
||||
/// 총 구독 수와 월별 총 지출을 표시하며, 분석 화면으로 이동하는 기능을 제공합니다.
|
||||
/// 총 구독 수와 월별 총 지출을 표시합니다.
|
||||
class MainScreenSummaryCard extends StatelessWidget {
|
||||
final SubscriptionProvider provider;
|
||||
final AnimationController fadeController;
|
||||
final AnimationController pulseController;
|
||||
final AnimationController waveController;
|
||||
final AnimationController slideController;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const MainScreenSummaryCard({
|
||||
Key? key,
|
||||
required this.provider,
|
||||
@@ -24,7 +23,6 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
required this.pulseController,
|
||||
required this.waveController,
|
||||
required this.slideController,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
@@ -40,16 +38,20 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
onTap();
|
||||
},
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 24,
|
||||
blur: 15,
|
||||
backgroundColor: AppColors.primaryColor.withValues(alpha: 0.2),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primaryColor.withValues(alpha: 0.3),
|
||||
AppColors.primaryColor.withBlue(
|
||||
(AppColors.primaryColor.blue * 1.3)
|
||||
.clamp(0, 255)
|
||||
.toInt()).withValues(alpha: 0.2),
|
||||
],
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
@@ -59,17 +61,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primaryColor,
|
||||
AppColors.primaryColor.withBlue(
|
||||
(AppColors.primaryColor.blue * 1.3)
|
||||
.clamp(0, 255)
|
||||
.toInt()),
|
||||
],
|
||||
),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
@@ -91,7 +83,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
'이번 달 총 구독 비용',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -118,7 +110,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
'원',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -153,13 +145,13 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.2),
|
||||
Colors.white.withOpacity(0.15),
|
||||
Colors.white.withValues(alpha: 0.2),
|
||||
Colors.white.withValues(alpha: 0.15),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
color: Colors.white.withValues(alpha: 0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
@@ -169,7 +161,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
color: Colors.white.withValues(alpha: 0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
@@ -185,7 +177,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
'이벤트 할인 중',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -208,7 +200,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
' 절약 ($activeEvents개)',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
@@ -224,20 +216,10 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -249,7 +231,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
color: Colors.white.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
@@ -258,7 +240,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
color: Colors.white.withValues(alpha: 0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:io' show Platform;
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
|
||||
/// SRP에 따라 광고 전용 위젯으로 분리
|
||||
@@ -84,9 +85,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
Widget _buildWebPlaceholder() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 16,
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Container(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -186,9 +188,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
// 광고 정상 노출
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: GlassmorphismCard(
|
||||
borderRadius: 16,
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: SizedBox(
|
||||
height: 80, // 네이티브 광고 높이 조정
|
||||
child: AdWidget(ad: _nativeAd!),
|
||||
|
||||
@@ -1,24 +1,44 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class SkeletonLoading extends StatelessWidget {
|
||||
const SkeletonLoading({Key? key}) : super(key: key);
|
||||
final double? width;
|
||||
final double? height;
|
||||
final double borderRadius;
|
||||
|
||||
const SkeletonLoading({
|
||||
Key? key,
|
||||
this.width,
|
||||
this.height,
|
||||
this.borderRadius = 8.0,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 단일 스켈레톤 아이템이 요청된 경우
|
||||
if (width != null || height != null) {
|
||||
return _buildSingleSkeleton();
|
||||
}
|
||||
|
||||
// 기본 전체 화면 스켈레톤
|
||||
return Column(
|
||||
children: [
|
||||
// 요약 카드 스켈레톤
|
||||
Card(
|
||||
GlassmorphismCard(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
@@ -29,7 +49,6 @@ class SkeletonLoading extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 구독 목록 스켈레톤
|
||||
@@ -37,32 +56,47 @@ class SkeletonLoading extends StatelessWidget {
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Card(
|
||||
return GlassmorphismCard(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
title: Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
padding: const EdgeInsets.all(16),
|
||||
blur: 10,
|
||||
opacity: 0.1,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -72,6 +106,32 @@ class SkeletonLoading extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSingleSkeleton() {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 1500),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(borderRadius),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
Colors.grey[300]!,
|
||||
Colors.grey[100]!,
|
||||
Colors.grey[300]!,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonColumn() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -79,13 +139,19 @@ class SkeletonLoading extends StatelessWidget {
|
||||
Container(
|
||||
width: 80,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
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/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../models/subscription_model.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import 'website_icon.dart';
|
||||
import 'app_navigator.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'glassmorphism_card.dart';
|
||||
|
||||
class SubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
@@ -230,67 +233,30 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
DetailScreen(subscription: widget.subscription),
|
||||
transitionsBuilder:
|
||||
(context, animation, secondaryAnimation, child) {
|
||||
const begin = Offset(0.0, 0.05);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
var tween = Tween(begin: begin, end: end)
|
||||
.chain(CurveTween(curve: curve));
|
||||
|
||||
var fadeAnimation =
|
||||
Tween<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();
|
||||
}
|
||||
});
|
||||
}
|
||||
await AppNavigator.toDetail(context, widget.subscription);
|
||||
},
|
||||
splashColor: AppColors.primaryColor.withOpacity(0.1),
|
||||
highlightColor: AppColors.primaryColor.withOpacity(0.05),
|
||||
splashColor: AppColors.primaryColor.withValues(alpha: 0.1),
|
||||
highlightColor: AppColors.primaryColor.withValues(alpha: 0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
child: AnimatedGlassmorphismCard(
|
||||
onTap: () {}, // onTap은 이미 InkWell에서 처리됨
|
||||
padding: EdgeInsets.zero,
|
||||
borderRadius: 16,
|
||||
blur: _isHovering ? 15 : 10,
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _isHovering
|
||||
? AppColors.primaryColor.withOpacity(0.3)
|
||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
||||
: AppColors.borderColor,
|
||||
width: _isHovering ? 1.5 : 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryColor.withOpacity(
|
||||
color: AppColors.primaryColor.withValues(alpha:
|
||||
0.03 + (0.05 * _hoverController.value)),
|
||||
blurRadius: 8 + (8 * _hoverController.value),
|
||||
spreadRadius: 0,
|
||||
@@ -502,9 +468,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
decoration: BoxDecoration(
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
.withOpacity(0.1)
|
||||
.withValues(alpha: 0.1)
|
||||
: AppColors.successColor
|
||||
.withOpacity(0.1),
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
@@ -551,7 +517,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B6B).withOpacity(0.1),
|
||||
color: const Color(0xFFFF6B6B).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
@@ -607,6 +573,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -2,6 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../widgets/subscription_card.dart';
|
||||
import '../widgets/category_header_widget.dart';
|
||||
import '../widgets/swipeable_subscription_card.dart';
|
||||
import '../widgets/staggered_list_animation.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import '../widgets/animated_page_transitions.dart';
|
||||
import '../widgets/app_navigator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
|
||||
/// 카테고리별로 구독 목록을 표시하는 위젯
|
||||
class SubscriptionListWidget extends StatelessWidget {
|
||||
@@ -75,8 +82,29 @@ class SubscriptionListWidget extends StatelessWidget {
|
||||
curve: Curves.easeOut))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: SubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
child: StaggeredAnimationItem(
|
||||
index: subIndex,
|
||||
delay: const Duration(milliseconds: 50),
|
||||
child: SwipeableSubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
onTap: () {
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onEdit: () {
|
||||
// 편집 화면으로 이동
|
||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||
},
|
||||
onDelete: () async {
|
||||
// 삭제 확인 다이얼로그
|
||||
final provider = Provider.of<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 */; };
|
||||
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
|
||||
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
|
||||
72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4015C586B270010D4F62A7 /* Pods_Runner.framework */; };
|
||||
EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
@@ -64,7 +66,7 @@
|
||||
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
@@ -76,8 +78,16 @@
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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 */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
@@ -85,6 +95,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -92,12 +103,27 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
2A845BBB3A2FF55EFF11D802 /* Pods */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9119DFDCC41763FA448B6987 /* Pods-Runner.debug.xcconfig */,
|
||||
BC9E591D2F7240B7A962E51A /* Pods-Runner.release.xcconfig */,
|
||||
449D7C4E91ECC2307A618B21 /* Pods-Runner.profile.xcconfig */,
|
||||
F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */,
|
||||
98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */,
|
||||
ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */,
|
||||
);
|
||||
name = Pods;
|
||||
path = Pods;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -125,6 +151,7 @@
|
||||
331C80D6294CF71000263BE5 /* RunnerTests */,
|
||||
33CC10EE2044A3C60003C045 /* Products */,
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */,
|
||||
2A845BBB3A2FF55EFF11D802 /* Pods */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -175,6 +202,8 @@
|
||||
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
4C4015C586B270010D4F62A7 /* Pods_Runner.framework */,
|
||||
FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
@@ -186,6 +215,7 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
|
||||
buildPhases = (
|
||||
451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */,
|
||||
331C80D1294CF70F00263BE5 /* Sources */,
|
||||
331C80D2294CF70F00263BE5 /* Frameworks */,
|
||||
331C80D3294CF70F00263BE5 /* Resources */,
|
||||
@@ -204,11 +234,13 @@
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
|
||||
buildPhases = (
|
||||
7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */,
|
||||
33CC10E92044A3C60003C045 /* Sources */,
|
||||
33CC10EA2044A3C60003C045 /* Frameworks */,
|
||||
33CC10EB2044A3C60003C045 /* Resources */,
|
||||
33CC110E2044A8840003C045 /* Bundle Framework */,
|
||||
3399D490228B24CF009A79C7 /* ShellScript */,
|
||||
CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
@@ -329,6 +361,67 @@
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
|
||||
};
|
||||
451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
);
|
||||
inputPaths = (
|
||||
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
|
||||
"${PODS_ROOT}/Manifest.lock",
|
||||
);
|
||||
name = "[CP] Check Pods Manifest.lock";
|
||||
outputFileListPaths = (
|
||||
);
|
||||
outputPaths = (
|
||||
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */ = {
|
||||
isa = PBXShellScriptBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
inputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
|
||||
);
|
||||
name = "[CP] Embed Pods Frameworks";
|
||||
outputFileListPaths = (
|
||||
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
shellPath = /bin/sh;
|
||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
|
||||
showEnvVarsInLog = 0;
|
||||
};
|
||||
/* End PBXShellScriptBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
@@ -380,6 +473,7 @@
|
||||
/* Begin XCBuildConfiguration section */
|
||||
331C80DB294CF71000263BE5 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -394,6 +488,7 @@
|
||||
};
|
||||
331C80DC294CF71000263BE5 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = 98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
@@ -408,6 +503,7 @@
|
||||
};
|
||||
331C80DD294CF71000263BE5 /* Profile */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
baseConfigurationReference = ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
|
||||
@@ -4,4 +4,7 @@
|
||||
<FileRef
|
||||
location = "group:Runner.xcodeproj">
|
||||
</FileRef>
|
||||
<FileRef
|
||||
location = "group:Pods/Pods.xcodeproj">
|
||||
</FileRef>
|
||||
</Workspace>
|
||||
|
||||
Reference in New Issue
Block a user