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