Major UI/UX and architecture improvements

- Implemented new navigation system with NavigationProvider and route management
- Added adaptive theme system with ThemeProvider for better theme handling
- Introduced glassmorphism design elements (app bars, scaffolds, cards)
- Added advanced animations (spring animations, page transitions, staggered lists)
- Implemented performance optimizations (memory manager, lazy loading)
- Refactored Analysis screen into modular components
- Added floating navigation bar with haptic feedback
- Improved subscription cards with swipe actions
- Enhanced skeleton loading with better animations
- Added cached network image support
- Improved overall app architecture and code organization

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-10 18:36:57 +09:00
parent 8619e96739
commit 4731288622
55 changed files with 8219 additions and 2149 deletions

58
doc/color.md Normal file
View File

@@ -0,0 +1,58 @@
## 구독관리 앱 글래스모피어즘 색상 가이드
**신뢰성, 편안함, 트렌드함**을 모두 잡는 컬러 조합 추천
### 1. 컬러 선정 원칙
- **신뢰성:** 블루 계열, 그레이, 화이트 등 안정적이고 전문적인 느낌의 색상
- **편안함:** 저채도 파스텔, 연한 블루·민트, 따뜻한 베이지 등 눈에 부담 없는 색상
- **트렌드함:** 그라디언트, 반투명 레이어, 약간의 네온 포인트 등 현대적 감각
### 2. 추천 컬러 팔레트
| 용도 | 추천 색상 예시 (Hex) | 설명 |
|--------------|-------------------------------|---------------------------------------|
| 메인 | #2563eb, #60a5fa, #e0e7ef | 신뢰감 주는 블루 계열 그라디언트 |
| 서브 | #f9fafb, #f1f5f9, #f3f4f6 | 밝은 화이트·그레이, 편안한 배경 |
| 포인트 | #38bdf8, #7dd3fc, #f472b6 | 트렌디한 민트, 연핑크, 밝은 블루 |
| 테두리/블러 | rgba(255,255,255,0.3) | 글래스 효과용 반투명 화이트 |
| 그림자 | rgba(0,0,0,0.08) | 부드러운 깊이감 부여 |
### 3. 실전 적용 예시
- **배경:**
연한 블루(#e0e7ef) 또는 밝은 그레이(#f9fafb)
- **글래스 카드:**
반투명 화이트(예: rgba(255,255,255,0.2)), 블루 그라디언트 테두리
- **포인트 버튼:**
밝은 민트(#38bdf8) 또는 연핑크(#f472b6)
- **아이콘/텍스트:**
진한 블루(#2563eb), 다크 그레이(#334155)
- **그라디언트 예시:**
LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF2563eb), Color(0xFF60a5fa), Color(0xFFe0e7ef)],
)
### 4. 참고 팁
- 글래스모피어즘은 **투명도·블러**와 함께 **밝고 깨끗한 색상**을 조합하면 신뢰감과 트렌디함을 동시에 줄 수 있습니다.
- 포인트 컬러를 너무 강하게 쓰기보다는, 전체적으로 **밝고 부드러운 톤**에 약간의 컬러만 더하는 것이 편안함을 극대화합니다.
- 실제 인기 앱(Reflect, T.RICKS, Coffee 등)도 블루·화이트·민트 계열을 주로 활용합니다.
### 5. 컬러 팔레트 예시
| 이름 | Hex 코드 | 용도/느낌 |
|-------------|------------|-------------------|
| Deep Blue | #2563eb | 신뢰, 메인 |
| Sky Blue | #60a5fa | 트렌드, 그라디언트|
| Soft Mint | #38bdf8 | 포인트, 상쾌함 |
| Light Gray | #f1f5f9 | 배경, 편안함 |
| White Glass | #ffffff(투명도) | 글래스 효과 |
| Pink Accent | #f472b6 | 포인트, 트렌디 |
### 6. 마무리
- **블루+화이트+민트** 조합은 신뢰성, 편안함, 트렌드함을 모두 만족시킵니다.
- 글래스모피어즘 효과와 함께라면, 위 팔레트로 세련되고 현대적인 구독관리 앱 UI를 완성할 수 있습니다.
- 실제 적용 시, 밝은 배경과 부드러운 그라디언트, 포인트 컬러를 적절히 조합해보세요.

177
ios/Podfile.lock Normal file
View File

@@ -0,0 +1,177 @@
PODS:
- Flutter (1.0.0)
- flutter_local_notifications (0.0.1):
- Flutter
- flutter_native_splash (2.4.3):
- Flutter
- flutter_secure_storage (6.0.0):
- Flutter
- flutter_sms (1.1.0):
- Flutter
- Google-Mobile-Ads-SDK (10.11.0):
- GoogleAppMeasurement (< 11.0, >= 7.0)
- GoogleUserMessagingPlatform (>= 1.1)
- google_mobile_ads (1.0.0):
- Flutter
- Google-Mobile-Ads-SDK (~> 10.11.0)
- webview_flutter_wkwebview
- GoogleAppMeasurement (10.29.0):
- GoogleAppMeasurement/AdIdSupport (= 10.29.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- GoogleAppMeasurement/AdIdSupport (10.29.0):
- GoogleAppMeasurement/WithoutAdIdSupport (= 10.29.0)
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- GoogleAppMeasurement/WithoutAdIdSupport (10.29.0):
- GoogleUtilities/AppDelegateSwizzler (~> 7.11)
- GoogleUtilities/MethodSwizzler (~> 7.11)
- GoogleUtilities/Network (~> 7.11)
- "GoogleUtilities/NSData+zlib (~> 7.11)"
- nanopb (< 2.30911.0, >= 2.30908.0)
- GoogleUserMessagingPlatform (3.0.0)
- GoogleUtilities/AppDelegateSwizzler (7.13.3):
- GoogleUtilities/Environment
- GoogleUtilities/Logger
- GoogleUtilities/Network
- GoogleUtilities/Privacy
- GoogleUtilities/Environment (7.13.3):
- GoogleUtilities/Privacy
- PromisesObjC (< 3.0, >= 1.2)
- GoogleUtilities/Logger (7.13.3):
- GoogleUtilities/Environment
- GoogleUtilities/Privacy
- GoogleUtilities/MethodSwizzler (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- GoogleUtilities/Network (7.13.3):
- GoogleUtilities/Logger
- "GoogleUtilities/NSData+zlib"
- GoogleUtilities/Privacy
- GoogleUtilities/Reachability
- "GoogleUtilities/NSData+zlib (7.13.3)":
- GoogleUtilities/Privacy
- GoogleUtilities/Privacy (7.13.3)
- GoogleUtilities/Reachability (7.13.3):
- GoogleUtilities/Logger
- GoogleUtilities/Privacy
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- nanopb (2.30910.0):
- nanopb/decode (= 2.30910.0)
- nanopb/encode (= 2.30910.0)
- nanopb/decode (2.30910.0)
- nanopb/encode (2.30910.0)
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- permission_handler_apple (9.3.0):
- Flutter
- PromisesObjC (2.4.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- telephony (0.0.1):
- Flutter
- url_launcher_ios (0.0.1):
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- Flutter (from `Flutter`)
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
- flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
- flutter_sms (from `.symlinks/plugins/flutter_sms/ios`)
- google_mobile_ads (from `.symlinks/plugins/google_mobile_ads/ios`)
- local_auth_darwin (from `.symlinks/plugins/local_auth_darwin/darwin`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- telephony (from `.symlinks/plugins/telephony/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
- Google-Mobile-Ads-SDK
- GoogleAppMeasurement
- GoogleUserMessagingPlatform
- GoogleUtilities
- nanopb
- PromisesObjC
EXTERNAL SOURCES:
Flutter:
:path: Flutter
flutter_local_notifications:
:path: ".symlinks/plugins/flutter_local_notifications/ios"
flutter_native_splash:
:path: ".symlinks/plugins/flutter_native_splash/ios"
flutter_secure_storage:
:path: ".symlinks/plugins/flutter_secure_storage/ios"
flutter_sms:
:path: ".symlinks/plugins/flutter_sms/ios"
google_mobile_ads:
:path: ".symlinks/plugins/google_mobile_ads/ios"
local_auth_darwin:
:path: ".symlinks/plugins/local_auth_darwin/darwin"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
telephony:
:path: ".symlinks/plugins/telephony/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
flutter_local_notifications: 0c0b1ae97e741e1521e4c1629a459d04b9aec743
flutter_native_splash: df59bb2e1421aa0282cb2e95618af4dcb0c56c29
flutter_secure_storage: d33dac7ae2ea08509be337e775f6b59f1ff45f12
flutter_sms: 91ce41530f55c85d6524d82307a5d555844c086a
Google-Mobile-Ads-SDK: 58b4fda3f9758fc1ed210aa5cf7777b5eb55d47e
google_mobile_ads: 511febb4768edc860ee455a9e201ff52de385908
GoogleAppMeasurement: f9de05ee17401e3355f68e8fc8b5064d429f5918
GoogleUserMessagingPlatform: f8d0cdad3ca835406755d0a69aa634f00e76d576
GoogleUtilities: ea963c370a38a8069cc5f7ba4ca849a60b6d7d15
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
nanopb: 438bc412db1928dac798aa6fd75726007be04262
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
telephony: c41768fae9fb5495781b05a72004106ca33ec777
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
PODFILE CHECKSUM: 4305caec6b40dde0ae97be1573c53de1882a07e5
COCOAPODS: 1.16.2

View File

@@ -10,6 +10,8 @@
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */; };
73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; };
@@ -40,6 +42,8 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = "<group>"; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = "<group>"; };
331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
@@ -55,19 +59,43 @@
97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C8B5AAC4245FB9238AB6F925 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
DDC48A61CC3887158D51699F /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
DEDD176B80E79E5674C841B0 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
27474C77F8EBFE4B5468329B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
73973B1966E7B3CA28C40C38 /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
97C146EB1CF9000F007C117D /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
3C9059DCFED61A64AFD8056F /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
104722E6173DA3E706B6AF13 /* Frameworks */ = {
isa = PBXGroup;
children = (
C86A4AE56B0F4535DE1410AB /* Pods_Runner.framework */,
13D7C070F88BEB1816847568 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
331C8082294A63A400263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@@ -94,6 +122,8 @@
97C146F01CF9000F007C117D /* Runner */,
97C146EF1CF9000F007C117D /* Products */,
331C8082294A63A400263BE5 /* RunnerTests */,
F0813F149E71664270D649A1 /* Pods */,
104722E6173DA3E706B6AF13 /* Frameworks */,
);
sourceTree = "<group>";
};
@@ -121,6 +151,20 @@
path = Runner;
sourceTree = "<group>";
};
F0813F149E71664270D649A1 /* Pods */ = {
isa = PBXGroup;
children = (
C8B5AAC4245FB9238AB6F925 /* Pods-Runner.debug.xcconfig */,
DDC48A61CC3887158D51699F /* Pods-Runner.release.xcconfig */,
DEDD176B80E79E5674C841B0 /* Pods-Runner.profile.xcconfig */,
A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */,
0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */,
EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@@ -128,8 +172,10 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */,
331C807D294A63A400263BE5 /* Sources */,
331C807F294A63A400263BE5 /* Resources */,
27474C77F8EBFE4B5468329B /* Frameworks */,
);
buildRules = (
);
@@ -145,12 +191,15 @@
isa = PBXNativeTarget;
buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */,
9740EEB61CF901F6004384FC /* Run Script */,
97C146EA1CF9000F007C117D /* Sources */,
97C146EB1CF9000F007C117D /* Frameworks */,
97C146EC1CF9000F007C117D /* Resources */,
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */,
05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -222,6 +271,45 @@
/* End PBXResourcesBuildPhase section */
/* Begin PBXShellScriptBuildPhase section */
05C65D80AD05ED5D71DB6EC5 /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
0801BE1F6FCD7AB456439887 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
3B06AD1E1E4923F5004D2608 /* Thin Binary */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -238,6 +326,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin";
};
45C48CE61626E0B8411CA684 /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
9740EEB61CF901F6004384FC /* Run Script */ = {
isa = PBXShellScriptBuildPhase;
alwaysOutOfDate = 1;
@@ -253,6 +358,28 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
EBA89E2B1C50E4AA1D056F75 /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -378,6 +505,7 @@
};
331C8088294A63A400263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = A2DE53EFB52D5A7B247F277C /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -395,6 +523,7 @@
};
331C8089294A63A400263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 0BADE7C661838AA20E419C81 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
@@ -410,6 +539,7 @@
};
331C808A294A63A400263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = EDF4A9E08C06B7A4E0AA32CB /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;

View File

@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>

View File

@@ -1,27 +1,28 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/foundation.dart' show kIsWeb, kDebugMode;
import 'package:flutter_localizations/flutter_localizations.dart';
import 'models/subscription_model.dart';
import 'models/category_model.dart';
import 'providers/subscription_provider.dart';
import 'providers/app_lock_provider.dart';
import 'providers/notification_provider.dart';
import 'screens/main_screen.dart';
import 'screens/app_lock_screen.dart';
import 'providers/navigation_provider.dart';
import 'services/notification_service.dart';
import 'providers/category_provider.dart';
import 'providers/locale_provider.dart';
import 'providers/theme_provider.dart';
import 'l10n/app_localizations.dart';
import 'theme/app_theme.dart';
import 'screens/splash_screen.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'theme/adaptive_theme.dart';
import 'routes/app_routes.dart';
import 'navigation/app_navigation_observer.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform;
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
import 'utils/memory_manager.dart';
import 'utils/performance_optimizer.dart';
import 'navigator_key.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
@@ -31,17 +32,25 @@ Future<void> main() async {
await MobileAds.instance.initialize();
}
// 성능 최적화 설정
MemoryManager.optimizeImageCache();
MemoryManager().startAutoCleanup();
// 앱 시작 시 이미지 캐시 관리
try {
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
final cache = PaintingBinding.instance.imageCache;
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
await DefaultCacheManager().emptyCache();
print('이미지 캐시 관리 초기화 완료');
if (kDebugMode) {
print('이미지 캐시 관리 초기화 완료');
PerformanceOptimizer.checkConstOptimization();
}
} catch (e) {
print('캐시 초기화 오류: $e');
if (kDebugMode) {
print('캐시 초기화 오류: $e');
}
}
// Hive 초기화
@@ -58,11 +67,14 @@ Future<void> main() async {
final categoryProvider = CategoryProvider();
final localeProvider = LocaleProvider();
final notificationProvider = NotificationProvider();
final themeProvider = ThemeProvider();
final navigationProvider = NavigationProvider();
await subscriptionProvider.init();
await categoryProvider.init();
await localeProvider.init();
await notificationProvider.init();
await themeProvider.initialize();
// NotificationProvider에 SubscriptionProvider 연결 (알림 재예약용)
// SRP 원칙에 따라 다른 Provider 객체를 명시적으로 주입
@@ -89,6 +101,8 @@ Future<void> main() async {
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
ChangeNotifierProvider(create: (_) => notificationProvider),
ChangeNotifierProvider(create: (_) => localeProvider),
ChangeNotifierProvider(create: (_) => themeProvider),
ChangeNotifierProvider(create: (_) => navigationProvider),
],
child: const SubManagerApp(),
),
@@ -100,12 +114,15 @@ class SubManagerApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<LocaleProvider>(
builder: (context, localeProvider, child) {
return Consumer2<LocaleProvider, ThemeProvider>(
builder: (context, localeProvider, themeProvider, child) {
// 시스템 UI 오버레이 스타일 적용
AdaptiveTheme.applySystemUIOverlay(context);
return MaterialApp(
title: 'SubManager',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
theme: themeProvider.getTheme(context),
locale: localeProvider.locale,
localizationsDelegates: const [
AppLocalizationsDelegate(),
@@ -118,7 +135,24 @@ class SubManagerApp extends StatelessWidget {
Locale('ko'),
],
navigatorKey: navigatorKey,
home: const SplashScreen(),
navigatorObservers: [AppNavigationObserver()],
initialRoute: AppRoutes.splash,
routes: AppRoutes.getRoutes(),
onGenerateRoute: AppRoutes.generateRoute,
builder: (context, child) {
// 성능 최적화 및 메모리 관리
if (kDebugMode) {
PerformanceOptimizer().startFrameMonitoring();
}
return MediaQuery(
data: MediaQuery.of(context).copyWith(
textScaler: TextScaler.linear(themeProvider.largeText ? 1.2 : 1.0),
disableAnimations: themeProvider.reduceMotion,
),
child: child!,
);
},
);
},
);

View File

@@ -95,6 +95,9 @@ class SubscriptionModel extends HiveObject {
}
return 0;
}
// 원래 가격 (이벤트와 관계없이 항상 정상 가격)
double get originalPrice => monthlyCost;
}
// Hive TypeAdapter 생성을 위한 명령어

View File

@@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/navigation_provider.dart';
class AppNavigationObserver extends NavigatorObserver {
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
_updateNavigationState(route);
debugPrint('Navigation: Push ${route.settings.name}');
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
if (previousRoute != null) {
_updateNavigationState(previousRoute);
} else {
// 이전 라우트가 없으면 Provider의 히스토리를 사용
_handlePopWithProvider();
}
debugPrint('Navigation: Pop ${route.settings.name}');
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didRemove(route, previousRoute);
if (previousRoute != null) {
_updateNavigationState(previousRoute);
}
debugPrint('Navigation: Remove ${route.settings.name}');
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
if (newRoute != null) {
_updateNavigationState(newRoute);
}
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
}
void _updateNavigationState(Route<dynamic> route) {
if (navigator?.context == null) return;
final routeName = route.settings.name;
if (routeName == null) return;
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;
try {
final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateByRoute(routeName);
} catch (e) {
debugPrint('Failed to update navigation state: $e');
}
});
}
void _handlePopWithProvider() {
if (navigator?.context == null) return;
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;
try {
final context = navigator!.context;
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.pop();
} catch (e) {
debugPrint('Failed to handle pop with provider: $e');
}
});
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
class NavigationProvider extends ChangeNotifier {
int _currentIndex = 0;
final List<int> _navigationHistory = [0];
String _currentRoute = '/';
String _currentTitle = '';
int get currentIndex => _currentIndex;
List<int> get navigationHistory => List.unmodifiable(_navigationHistory);
String get currentRoute => _currentRoute;
String get currentTitle => _currentTitle;
static const Map<String, int> routeToIndex = {
'/': 0,
'/add-subscription': -1,
'/sms-scanner': 3,
'/analysis': 1,
'/settings': 4,
'/subscription-detail': -1,
};
static const Map<int, String> indexToRoute = {
0: '/',
1: '/analysis',
3: '/sms-scanner',
4: '/settings',
};
static const Map<int, String> indexToTitle = {
0: '',
1: '분석',
3: 'SMS 스캔',
4: '설정',
};
void updateCurrentIndex(int index, {bool addToHistory = true}) {
if (_currentIndex == index) return;
_currentIndex = index;
_currentRoute = indexToRoute[index] ?? '/';
_currentTitle = indexToTitle[index] ?? '';
if (addToHistory && index >= 0) {
_navigationHistory.add(index);
if (_navigationHistory.length > 10) {
_navigationHistory.removeAt(0);
}
}
notifyListeners();
}
void updateByRoute(String route) {
final index = routeToIndex[route] ?? 0;
_currentRoute = route;
if (index >= 0) {
_currentIndex = index;
_currentTitle = indexToTitle[index] ?? '';
} else {
switch (route) {
case '/add-subscription':
_currentTitle = '구독 추가';
break;
case '/subscription-detail':
_currentTitle = '구독 상세';
break;
default:
_currentTitle = '';
}
}
notifyListeners();
}
bool canPop() {
return _navigationHistory.length > 1;
}
void pop() {
if (_navigationHistory.length > 1) {
_navigationHistory.removeLast();
final previousIndex = _navigationHistory.last;
updateCurrentIndex(previousIndex, addToHistory: false);
}
}
void reset() {
_currentIndex = 0;
_currentRoute = '/';
_currentTitle = '';
_navigationHistory.clear();
_navigationHistory.add(0);
notifyListeners();
}
void clearHistoryAndGoHome() {
_currentIndex = 0;
_currentRoute = '/';
_currentTitle = '';
_navigationHistory.clear();
_navigationHistory.add(0);
notifyListeners();
}
}

View File

@@ -243,4 +243,83 @@ class SubscriptionProvider extends ChangeNotifier {
await refreshSubscriptions();
}
}
/// 총 월간 지출을 계산합니다.
Future<double> calculateTotalExpense() async {
// 이미 존재하는 totalMonthlyExpense getter를 사용
return totalMonthlyExpense;
}
/// 최근 6개월의 월별 지출 데이터를 반환합니다.
Future<List<Map<String, dynamic>>> getMonthlyExpenseData() async {
final now = DateTime.now();
final List<Map<String, dynamic>> monthlyData = [];
// 최근 6개월 데이터 생성
for (int i = 5; i >= 0; i--) {
final month = DateTime(now.year, now.month - i, 1);
double monthTotal = 0.0;
// 해당 월에 활성화된 구독 계산
for (final subscription in _subscriptions) {
// 구독이 해당 월에 활성화되어 있었는지 확인
final subscriptionStartDate = subscription.nextBillingDate.subtract(
Duration(days: _getBillingCycleDays(subscription.billingCycle)),
);
if (subscriptionStartDate.isBefore(DateTime(month.year, month.month + 1, 1)) &&
subscription.nextBillingDate.isAfter(month)) {
// 해당 월의 비용 계산 (이벤트 가격 고려)
if (subscription.isEventActive &&
subscription.eventStartDate != null &&
subscription.eventEndDate != null &&
month.isAfter(subscription.eventStartDate!) &&
month.isBefore(subscription.eventEndDate!)) {
monthTotal += subscription.eventPrice ?? subscription.monthlyCost;
} else {
monthTotal += subscription.monthlyCost;
}
}
}
monthlyData.add({
'month': month,
'totalExpense': monthTotal,
'monthName': _getMonthLabel(month),
});
}
return monthlyData;
}
/// 이벤트로 인한 총 절약액을 계산합니다.
double calculateTotalSavings() {
// 이미 존재하는 totalEventSavings getter를 사용
return totalEventSavings;
}
/// 결제 주기를 일 단위로 변환합니다.
int _getBillingCycleDays(String billingCycle) {
switch (billingCycle) {
case 'monthly':
return 30;
case 'yearly':
return 365;
case 'weekly':
return 7;
case 'quarterly':
return 90;
default:
return 30;
}
}
/// 월 라벨을 생성합니다.
String _getMonthLabel(DateTime month) {
final months = [
'1월', '2월', '3월', '4월', '5월', '6월',
'7월', '8월', '9월', '10월', '11월', '12월'
];
return months[month.month - 1];
}
}

View File

@@ -0,0 +1,186 @@
import 'package:flutter/material.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:provider/provider.dart';
import '../theme/adaptive_theme.dart';
/// 테마 관리 Provider
class ThemeProvider extends ChangeNotifier {
static const String _themeBoxName = 'theme_settings';
static const String _themeKey = 'theme_settings';
late Box<Map> _themeBox;
ThemeSettings _themeSettings = const ThemeSettings();
ThemeSettings get themeSettings => _themeSettings;
AppThemeMode get themeMode => _themeSettings.mode;
bool get useSystemColors => _themeSettings.useSystemColors;
bool get largeText => _themeSettings.largeText;
bool get reduceMotion => _themeSettings.reduceMotion;
bool get highContrast => _themeSettings.highContrast;
/// Provider 초기화
Future<void> initialize() async {
_themeBox = await Hive.openBox<Map>(_themeBoxName);
await _loadThemeSettings();
}
/// 저장된 테마 설정 로드
Future<void> _loadThemeSettings() async {
final savedSettings = _themeBox.get(_themeKey);
if (savedSettings != null) {
_themeSettings = ThemeSettings.fromJson(
Map<String, dynamic>.from(savedSettings),
);
notifyListeners();
}
}
/// 테마 설정 저장
Future<void> _saveThemeSettings() async {
await _themeBox.put(_themeKey, _themeSettings.toJson());
}
/// 테마 모드 변경
Future<void> setThemeMode(AppThemeMode mode) async {
_themeSettings = _themeSettings.copyWith(mode: mode);
await _saveThemeSettings();
notifyListeners();
}
/// 시스템 색상 사용 설정
Future<void> setUseSystemColors(bool value) async {
_themeSettings = _themeSettings.copyWith(useSystemColors: value);
await _saveThemeSettings();
notifyListeners();
}
/// 큰 텍스트 설정
Future<void> setLargeText(bool value) async {
_themeSettings = _themeSettings.copyWith(largeText: value);
await _saveThemeSettings();
notifyListeners();
}
/// 모션 감소 설정
Future<void> setReduceMotion(bool value) async {
_themeSettings = _themeSettings.copyWith(reduceMotion: value);
await _saveThemeSettings();
notifyListeners();
}
/// 고대비 설정
Future<void> setHighContrast(bool value) async {
_themeSettings = _themeSettings.copyWith(highContrast: value);
await _saveThemeSettings();
notifyListeners();
}
/// 현재 설정에 따른 테마 가져오기
ThemeData getTheme(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness;
ThemeData baseTheme;
switch (_themeSettings.mode) {
case AppThemeMode.light:
baseTheme = AdaptiveTheme.lightTheme;
break;
case AppThemeMode.dark:
baseTheme = AdaptiveTheme.darkTheme;
break;
case AppThemeMode.oled:
baseTheme = AdaptiveTheme.oledTheme;
break;
case AppThemeMode.system:
baseTheme = platformBrightness == Brightness.dark
? AdaptiveTheme.darkTheme
: AdaptiveTheme.lightTheme;
break;
}
// 접근성 설정 적용
return AdaptiveTheme.getAccessibleTheme(
baseTheme,
largeText: _themeSettings.largeText,
reduceMotion: _themeSettings.reduceMotion,
highContrast: _themeSettings.highContrast,
);
}
/// 현재 테마가 다크 모드인지 확인
bool isDarkMode(BuildContext context) {
final platformBrightness = MediaQuery.of(context).platformBrightness;
switch (_themeSettings.mode) {
case AppThemeMode.light:
return false;
case AppThemeMode.dark:
case AppThemeMode.oled:
return true;
case AppThemeMode.system:
return platformBrightness == Brightness.dark;
}
}
/// 테마 토글 (라이트/다크)
Future<void> toggleTheme() async {
if (_themeSettings.mode == AppThemeMode.light) {
await setThemeMode(AppThemeMode.dark);
} else {
await setThemeMode(AppThemeMode.light);
}
}
}
/// 테마 전환 애니메이션 위젯
class AnimatedThemeBuilder extends StatelessWidget {
final Widget Function(BuildContext, ThemeData) builder;
final Duration duration;
const AnimatedThemeBuilder({
super.key,
required this.builder,
this.duration = const Duration(milliseconds: 300),
});
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
final theme = themeProvider.getTheme(context);
return AnimatedTheme(
data: theme,
duration: duration,
child: Builder(
builder: (context) => builder(context, theme),
),
);
}
}
/// 테마별 색상 위젯
class ThemedColor extends StatelessWidget {
final Color lightColor;
final Color darkColor;
final Widget child;
const ThemedColor({
super.key,
required this.lightColor,
required this.darkColor,
required this.child,
});
@override
Widget build(BuildContext context) {
final isDark = context.read<ThemeProvider>().isDarkMode(context);
return Theme(
data: Theme.of(context).copyWith(
primaryColor: isDark ? darkColor : lightColor,
),
child: child,
);
}
}

106
lib/routes/app_routes.dart Normal file
View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:submanager/screens/main_screen.dart';
import 'package:submanager/screens/add_subscription_screen.dart';
import 'package:submanager/screens/detail_screen.dart';
import 'package:submanager/screens/sms_scan_screen.dart';
import 'package:submanager/screens/analysis_screen.dart';
import 'package:submanager/screens/settings_screen.dart';
import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/models/subscription_model.dart';
class AppRoutes {
static const String splash = '/splash';
static const String main = '/';
static const String addSubscription = '/add-subscription';
static const String subscriptionDetail = '/subscription-detail';
static const String smsScanner = '/sms-scanner';
static const String analysis = '/analysis';
static const String settings = '/settings';
static Map<String, WidgetBuilder> getRoutes() {
return {
splash: (context) => const SplashScreen(),
main: (context) => const MainScreen(),
addSubscription: (context) => const AddSubscriptionScreen(),
smsScanner: (context) => const SmsScanScreen(),
analysis: (context) => const AnalysisScreen(),
settings: (context) => const SettingsScreen(),
};
}
static Route<dynamic> generateRoute(RouteSettings routeSettings) {
switch (routeSettings.name) {
case splash:
return _buildRoute(const SplashScreen(), routeSettings);
case main:
return _buildRoute(const MainScreen(), routeSettings);
case addSubscription:
return _buildRoute(const AddSubscriptionScreen(), routeSettings);
case subscriptionDetail:
final subscription = routeSettings.arguments as SubscriptionModel?;
if (subscription != null) {
return _buildRoute(DetailScreen(subscription: subscription), routeSettings);
}
return _errorRoute();
case smsScanner:
return _buildRoute(const SmsScanScreen(), routeSettings);
case analysis:
return _buildRoute(const AnalysisScreen(), routeSettings);
case settings:
return _buildRoute(const SettingsScreen(), routeSettings);
default:
return _errorRoute();
}
}
static Route<dynamic> _buildRoute(Widget page, RouteSettings settings) {
return MaterialPageRoute(
builder: (_) => page,
settings: settings,
);
}
static Route<dynamic> _errorRoute() {
return MaterialPageRoute(
builder: (_) => const Scaffold(
body: Center(
child: Text('페이지를 찾을 수 없습니다'),
),
),
);
}
static void navigateTo(BuildContext context, String routeName, {Object? arguments}) {
Navigator.pushNamed(context, routeName, arguments: arguments);
}
static void navigateAndReplace(BuildContext context, String routeName, {Object? arguments}) {
Navigator.pushReplacementNamed(context, routeName, arguments: arguments);
}
static void navigateAndRemoveUntil(BuildContext context, String routeName, {Object? arguments}) {
Navigator.pushNamedAndRemoveUntil(
context,
routeName,
(route) => false,
arguments: arguments,
);
}
static void pop(BuildContext context, {dynamic result}) {
if (Navigator.canPop(context)) {
Navigator.pop(context, result);
}
}
static bool canPop(BuildContext context) {
return Navigator.canPop(context);
}
}

View File

@@ -7,7 +7,6 @@ import 'dart:math' as math;
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../models/category_model.dart';
import '../services/sms_service.dart';
import '../services/subscription_url_matcher.dart';
import '../services/exchange_rate_service.dart';
@@ -495,7 +494,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
);
if (mounted) {
Navigator.pop(context);
Navigator.pop(context, true); // 성공 여부 반환
}
} catch (e) {
setState(() {
@@ -536,11 +535,11 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
preferredSize: const Size.fromHeight(60),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(appBarOpacity),
color: Colors.white.withValues(alpha: appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.1 * appBarOpacity),
color: Colors.black.withValues(alpha: 0.1 * appBarOpacity),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 4),
@@ -561,7 +560,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
shadows: appBarOpacity > 0.6
? [
Shadow(
color: Colors.black.withOpacity(0.2),
color: Colors.black.withValues(alpha: 0.2),
offset: const Offset(0, 1),
blurRadius: 2,
)
@@ -626,7 +625,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
),
boxShadow: [
BoxShadow(
color: _gradientColors[0].withOpacity(0.3),
color: _gradientColors[0].withValues(alpha: 0.3),
blurRadius: 20,
spreadRadius: 0,
offset: const Offset(0, 8),
@@ -638,7 +637,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
color: Colors.white.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(
@@ -741,7 +740,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 0
? const Color(0xFF3B82F6).withOpacity(0.1)
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -786,7 +785,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -821,7 +820,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 1
? const Color(0xFF3B82F6).withOpacity(0.1)
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -922,7 +921,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
BorderRadius.circular(12),
borderSide: BorderSide(
color:
Colors.grey.withOpacity(0.2),
Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -979,7 +978,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: Border.all(
color: _currentEditingField == 1
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(
: Colors.grey.withValues(alpha:
0.4), // 포커스 없을 때 더 진한 회색
width: _currentEditingField == 1
? 2
@@ -997,7 +996,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: Border(
right: BorderSide(
color: Colors.grey
.withOpacity(0.2),
.withValues(alpha: 0.2),
width: 1,
),
),
@@ -1248,7 +1247,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 2
? const Color(0xFF3B82F6).withOpacity(0.1)
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1285,7 +1284,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1336,7 +1335,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 3
? const Color(0xFF3B82F6).withOpacity(0.1)
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1397,7 +1396,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: Border.all(
color: _nextBillingDate == null
? Colors.red
: Colors.grey.withOpacity(0.2),
: Colors.grey.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(12),
color: Colors.white,
@@ -1437,7 +1436,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 4
? const Color(0xFF3B82F6).withOpacity(0.1)
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1476,7 +1475,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1504,7 +1503,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 5
? const Color(0xFF3B82F6).withOpacity(0.1)
? const Color(0xFF3B82F6).withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1538,7 +1537,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
decoration: BoxDecoration(
border: Border.all(
color:
Colors.grey.withOpacity(0.2),
Colors.grey.withValues(alpha: 0.2),
),
borderRadius:
BorderRadius.circular(12),
@@ -1598,7 +1597,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
borderRadius:
BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1667,7 +1666,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: Border.all(
color: _isEventActive
? const Color(0xFF3B82F6)
: Colors.grey.withOpacity(0.2),
: Colors.grey.withValues(alpha: 0.2),
width: _isEventActive ? 2 : 1,
),
),
@@ -1761,7 +1760,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -1825,7 +1824,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -1889,7 +1888,7 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1967,15 +1966,15 @@ class _AddSubscriptionScreenState extends State<AddSubscriptionScreen>
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF3B82F6),
foregroundColor: Colors.white,
disabledBackgroundColor: Colors.grey.withOpacity(0.3),
disabledBackgroundColor: Colors.grey.withValues(alpha: 0.3),
disabledForegroundColor:
Colors.white.withOpacity(0.5),
Colors.white.withValues(alpha: 0.5),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: _isSaveHovered ? 8 : 4,
shadowColor: const Color(0xFF3B82F6).withOpacity(0.5),
shadowColor: const Color(0xFF3B82F6).withValues(alpha: 0.5),
),
child: _isLoading
? const SizedBox(

File diff suppressed because it is too large Load Diff

View File

@@ -385,7 +385,7 @@ class _DetailScreenState extends State<DetailScreen>
return LinearGradient(
colors: [
baseColor,
baseColor.withOpacity(0.7),
baseColor.withValues(alpha: 0.7),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
@@ -628,11 +628,11 @@ class _DetailScreenState extends State<DetailScreen>
preferredSize: const Size.fromHeight(60),
child: Container(
decoration: BoxDecoration(
color: Colors.white.withOpacity(appBarOpacity),
color: Colors.white.withValues(alpha: appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.1 * appBarOpacity),
color: Colors.black.withValues(alpha: 0.1 * appBarOpacity),
spreadRadius: 1,
blurRadius: 8,
offset: const Offset(0, 4),
@@ -653,7 +653,7 @@ class _DetailScreenState extends State<DetailScreen>
shadows: appBarOpacity > 0.6
? [
Shadow(
color: Colors.black.withOpacity(0.2),
color: Colors.black.withValues(alpha: 0.2),
offset: const Offset(0, 1),
blurRadius: 2,
)
@@ -746,7 +746,7 @@ class _DetailScreenState extends State<DetailScreen>
tag: 'subscription_${widget.subscription.id}',
child: Card(
elevation: 8,
shadowColor: baseColor.withOpacity(0.4),
shadowColor: baseColor.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
@@ -760,7 +760,7 @@ class _DetailScreenState extends State<DetailScreen>
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
baseColor.withOpacity(0.8),
baseColor.withValues(alpha: 0.8),
baseColor,
],
),
@@ -787,7 +787,7 @@ class _DetailScreenState extends State<DetailScreen>
boxShadow: [
BoxShadow(
color: Colors.black
.withOpacity(0.1),
.withValues(alpha: 0.1),
blurRadius: 10,
spreadRadius: 0,
),
@@ -834,7 +834,7 @@ class _DetailScreenState extends State<DetailScreen>
fontSize: 16,
fontWeight: FontWeight.w500,
color:
Colors.white.withOpacity(0.8),
Colors.white.withValues(alpha: 0.8),
),
),
],
@@ -846,7 +846,7 @@ class _DetailScreenState extends State<DetailScreen>
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
color: Colors.white.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(16),
),
child: Row(
@@ -863,7 +863,7 @@ class _DetailScreenState extends State<DetailScreen>
fontSize: 14,
fontWeight: FontWeight.w500,
color:
Colors.white.withOpacity(0.8),
Colors.white.withValues(alpha: 0.8),
),
),
const SizedBox(height: 4),
@@ -889,7 +889,7 @@ class _DetailScreenState extends State<DetailScreen>
fontSize: 14,
fontWeight: FontWeight.w500,
color:
Colors.white.withOpacity(0.8),
Colors.white.withValues(alpha: 0.8),
),
),
const SizedBox(height: 4),
@@ -924,10 +924,10 @@ class _DetailScreenState extends State<DetailScreen>
),
decoration: BoxDecoration(
color: const Color(0xFFDC2626)
.withOpacity(0.2),
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Colors.white.withOpacity(0.3),
color: Colors.white.withValues(alpha: 0.3),
width: 1,
),
),
@@ -1015,7 +1015,7 @@ class _DetailScreenState extends State<DetailScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 0
? baseColor.withOpacity(0.1)
? baseColor.withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1053,7 +1053,7 @@ class _DetailScreenState extends State<DetailScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1080,7 +1080,7 @@ class _DetailScreenState extends State<DetailScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 1
? baseColor.withOpacity(0.1)
? baseColor.withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1181,7 +1181,7 @@ class _DetailScreenState extends State<DetailScreen>
BorderRadius.circular(12),
borderSide: BorderSide(
color:
Colors.grey.withOpacity(0.2),
Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1238,7 +1238,7 @@ class _DetailScreenState extends State<DetailScreen>
border: Border.all(
color: _currentEditingField == 1
? baseColor
: Colors.grey.withOpacity(
: Colors.grey.withValues(alpha:
0.4), // 포커스 없을 때 더 진한 회색
width: _currentEditingField == 1
? 2
@@ -1256,7 +1256,7 @@ class _DetailScreenState extends State<DetailScreen>
border: Border(
right: BorderSide(
color: Colors.grey
.withOpacity(0.2),
.withValues(alpha: 0.2),
width: 1,
),
),
@@ -1508,7 +1508,7 @@ class _DetailScreenState extends State<DetailScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 2
? baseColor.withOpacity(0.1)
? baseColor.withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1545,7 +1545,7 @@ class _DetailScreenState extends State<DetailScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1584,7 +1584,7 @@ class _DetailScreenState extends State<DetailScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 3
? baseColor.withOpacity(0.1)
? baseColor.withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1642,7 +1642,7 @@ class _DetailScreenState extends State<DetailScreen>
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
borderRadius: BorderRadius.circular(12),
color: Colors.white,
@@ -1678,7 +1678,7 @@ class _DetailScreenState extends State<DetailScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 4
? baseColor.withOpacity(0.1)
? baseColor.withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1716,7 +1716,7 @@ class _DetailScreenState extends State<DetailScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1748,7 +1748,7 @@ class _DetailScreenState extends State<DetailScreen>
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _currentEditingField == 5
? baseColor.withOpacity(0.1)
? baseColor.withValues(alpha: 0.1)
: Colors.transparent,
),
padding: const EdgeInsets.all(8),
@@ -1776,7 +1776,7 @@ class _DetailScreenState extends State<DetailScreen>
),
decoration: BoxDecoration(
border: Border.all(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
borderRadius:
BorderRadius.circular(12),
@@ -1827,7 +1827,7 @@ class _DetailScreenState extends State<DetailScreen>
borderRadius:
BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -1900,7 +1900,7 @@ class _DetailScreenState extends State<DetailScreen>
border: Border.all(
color: _isEventActive
? baseColor
: Colors.grey.withOpacity(0.2),
: Colors.grey.withValues(alpha: 0.2),
width: _isEventActive ? 2 : 1,
),
),
@@ -1990,7 +1990,7 @@ class _DetailScreenState extends State<DetailScreen>
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -2054,7 +2054,7 @@ class _DetailScreenState extends State<DetailScreen>
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.withOpacity(0.3)),
border: Border.all(color: Colors.grey.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(12),
),
child: Column(
@@ -2118,7 +2118,7 @@ class _DetailScreenState extends State<DetailScreen>
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: Colors.grey.withOpacity(0.2),
color: Colors.grey.withValues(alpha: 0.2),
),
),
focusedBorder: OutlineInputBorder(
@@ -2196,7 +2196,7 @@ class _DetailScreenState extends State<DetailScreen>
),
padding: const EdgeInsets.symmetric(vertical: 16),
elevation: _isSaveHovered ? 8 : 4,
shadowColor: baseColor.withOpacity(0.5),
shadowColor: baseColor.withValues(alpha: 0.5),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,

View File

@@ -1,28 +1,20 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'dart:math' as math;
import 'package:intl/intl.dart';
import '../providers/subscription_provider.dart';
import '../providers/app_lock_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/app_colors.dart';
import '../services/subscription_url_matcher.dart';
import '../models/subscription_model.dart';
import 'add_subscription_screen.dart';
import '../routes/app_routes.dart';
import 'analysis_screen.dart';
import 'app_lock_screen.dart';
import 'settings_screen.dart';
import '../widgets/subscription_card.dart';
import '../widgets/skeleton_loading.dart';
import 'sms_scan_screen.dart';
import '../providers/category_provider.dart';
import '../utils/subscription_category_helper.dart';
import '../utils/animation_controller_helper.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/floating_navigation_bar.dart';
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/home_content.dart';
class MainScreen extends StatefulWidget {
const MainScreen({super.key});
@@ -40,7 +32,11 @@ class _MainScreenState extends State<MainScreen>
late AnimationController _pulseController;
late AnimationController _waveController;
late ScrollController _scrollController;
double _scrollOffset = 0;
late FloatingNavBarScrollController _navBarScrollController;
bool _isNavBarVisible = true;
// 화면 목록
late final List<Widget> _screens;
@override
void initState() {
@@ -67,12 +63,30 @@ class _MainScreenState extends State<MainScreen>
waveController: _waveController,
);
_scrollController = ScrollController()
..addListener(() {
setState(() {
_scrollOffset = _scrollController.offset;
});
});
_scrollController = ScrollController();
_navBarScrollController = FloatingNavBarScrollController(
scrollController: _scrollController,
onHide: () => setState(() => _isNavBarVisible = false),
onShow: () => setState(() => _isNavBarVisible = true),
);
// 화면 목록 초기화
_screens = [
HomeContent(
fadeController: _fadeController,
rotateController: _rotateController,
slideController: _slideController,
pulseController: _pulseController,
waveController: _waveController,
scrollController: _scrollController,
onAddPressed: () => _navigateToAddSubscription(context),
),
const AnalysisScreen(),
Container(), // 추가 버튼은 별도 처리
const SmsScanScreen(),
const SettingsScreen(),
];
}
@override
@@ -90,6 +104,7 @@ class _MainScreenState extends State<MainScreen>
);
_scrollController.dispose();
_navBarScrollController.dispose();
super.dispose();
}
@@ -136,307 +151,109 @@ class _MainScreenState extends State<MainScreen>
}
}
void _navigateToSmsScan(BuildContext context) async {
final added = await Navigator.push<bool>(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SmsScanScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
if (added == true && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
);
}
_resetAnimations();
}
void _navigateToAnalysis(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AnalysisScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
}
void _navigateToAddSubscription(BuildContext context) {
HapticFeedback.mediumImpact();
Navigator.of(context)
.push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const AddSubscriptionScreen(),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: ScaleTransition(
scale: Tween<double>(begin: 0.8, end: 1.0).animate(animation),
child: child,
Navigator.pushNamed(
context,
AppRoutes.addSubscription,
).then((result) {
_resetAnimations();
// 구독이 성공적으로 추가된 경우
if (result == true) {
// 상단에 스낵바 표시
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
);
},
const SizedBox(width: 12),
const Text(
'구독이 추가되었습니다',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
],
),
backgroundColor: const Color(0xFF10B981), // 초록색
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
dismissDirection: DismissDirection.horizontal,
),
)
.then((_) => _resetAnimations());
);
}
});
}
void _navigateToSettings(BuildContext context) {
Navigator.of(context).push(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const SettingsScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return SlideTransition(
position: Tween<Offset>(
begin: const Offset(1, 0),
end: Offset.zero,
).animate(animation),
child: child,
);
},
),
);
void _handleNavigation(int index, BuildContext context) {
final navigationProvider = context.read<NavigationProvider>();
// 이미 같은 인덱스면 무시
if (navigationProvider.currentIndex == index) {
return;
}
// 추가 버튼은 별도 처리
if (index == 2) {
_navigateToAddSubscription(context);
return;
}
// 인덱스 업데이트
navigationProvider.updateCurrentIndex(index);
}
@override
Widget build(BuildContext context) {
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100));
return Scaffold(
backgroundColor: AppColors.backgroundColor,
extendBodyBehindAppBar: true,
appBar: _buildAppBar(appBarOpacity),
body: _buildBody(context, context.watch<SubscriptionProvider>()),
floatingActionButton: _buildFloatingActionButton(context),
);
}
PreferredSize _buildAppBar(double appBarOpacity) {
return PreferredSize(
preferredSize: const Size.fromHeight(60),
child: Container(
decoration: BoxDecoration(
color: AppColors.surfaceColor.withOpacity(appBarOpacity),
boxShadow: appBarOpacity > 0.6
? [
BoxShadow(
color: Colors.black.withOpacity(0.06 * appBarOpacity),
spreadRadius: 0,
blurRadius: 12,
offset: const Offset(0, 4),
)
]
: null,
),
child: SafeArea(
child: AppBar(
title: FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: _fadeController, curve: Curves.easeInOut)),
child: const Text(
'SubManager',
style: TextStyle(
fontFamily: 'Montserrat',
fontSize: 26,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: Color(0xFF1E293B),
),
),
),
elevation: 0,
backgroundColor: Colors.transparent,
actions: [
IconButton(
icon: const FaIcon(FontAwesomeIcons.chartPie,
size: 20, color: Color(0xFF64748B)),
tooltip: '분석',
onPressed: () => _navigateToAnalysis(context),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.sms,
size: 20, color: Color(0xFF64748B)),
tooltip: 'SMS 스캔',
onPressed: () => _navigateToSmsScan(context),
),
IconButton(
icon: const FaIcon(FontAwesomeIcons.gear,
size: 20, color: Color(0xFF64748B)),
tooltip: '설정',
onPressed: () => _navigateToSettings(context),
),
],
),
),
),
);
}
Widget _buildFloatingActionButton(BuildContext context) {
return AnimatedBuilder(
animation: _scaleController,
builder: (context, child) {
return Transform.scale(
scale: Tween<double>(begin: 0.95, end: 1.0)
.animate(CurvedAnimation(
parent: _scaleController, curve: Curves.easeOutBack))
.value,
child: FloatingActionButton.extended(
onPressed: () => _navigateToAddSubscription(context),
icon: const Icon(Icons.add_rounded),
label: const Text(
'구독 추가',
style: TextStyle(
fontWeight: FontWeight.w600,
letterSpacing: 0.5,
),
),
elevation: 4,
),
);
},
);
}
Widget _buildBody(BuildContext context, SubscriptionProvider provider) {
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
),
);
final navigationProvider = context.watch<NavigationProvider>();
final hour = DateTime.now().hour;
List<Color> 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<CategoryProvider>(context, listen: false);
final categorizedSubscriptions =
SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions,
categoryProvider,
);
return RefreshIndicator(
onRefresh: () async {
await provider.refreshSubscriptions();
_resetAnimations();
},
color: const Color(0xFF3B82F6),
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: MediaQuery.of(context).padding.top + 60),
),
SliverToBoxAdapter(
child: NativeAdWidget(key: UniqueKey()),
),
SliverToBoxAdapter(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: MainScreenSummaryCard(
provider: provider,
fadeController: _fadeController,
pulseController: _pulseController,
waveController: _waveController,
slideController: _slideController,
onTap: () => _navigateToAnalysis(context),
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SlideTransition(
position: Tween<Offset>(
begin: const Offset(-0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: Text(
'나의 구독 서비스',
style: Theme.of(context).textTheme.titleLarge,
),
),
SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _slideController, curve: Curves.easeOutCubic)),
child: Row(
children: [
Text(
'${provider.subscriptions.length}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryColor,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: AppColors.primaryColor,
),
],
),
),
],
),
),
),
SubscriptionListWidget(
categorizedSubscriptions: categorizedSubscriptions,
fadeController: _fadeController,
),
SliverToBoxAdapter(
child: SizedBox(height: 100),
),
],
return GlassmorphicScaffold(
body: IndexedStack(
index: currentIndex == 3 ? 3 : currentIndex == 4 ? 4 : currentIndex,
children: _screens,
),
backgroundGradient: backgroundGradient,
useFloatingNavBar: true,
floatingNavBarIndex: navigationProvider.currentIndex,
onFloatingNavBarTapped: (index) {
_handleNavigation(index, context);
},
enableParticles: false,
enableWaveAnimation: false,
);
}
}
}

View File

@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../providers/notification_provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/navigation_provider.dart';
import 'package:share_plus/share_plus.dart';
import 'package:path_provider/path_provider.dart';
import 'dart:io';
@@ -11,6 +12,13 @@ import 'package:path/path.dart' as path;
import '../services/notification_service.dart';
import '../screens/sms_scan_screen.dart';
import 'package:url_launcher/url_launcher.dart';
import '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart';
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/glassmorphism_card.dart';
import '../widgets/app_navigator.dart';
import '../theme/app_colors.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -27,13 +35,13 @@ class SettingsScreen extends StatelessWidget {
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: isSelected
? Theme.of(context).colorScheme.primary.withOpacity(0.2)
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.2)
: Colors.transparent,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: isSelected
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline.withOpacity(0.5),
: Theme.of(context).colorScheme.outline.withValues(alpha: 0.5),
width: isSelected ? 2 : 1,
),
),
@@ -130,12 +138,81 @@ class SettingsScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('설정'),
),
body: ListView(
return ListView(
padding: const EdgeInsets.only(top: 20),
children: [
// 테마 설정
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
child: Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'테마 설정',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
// 테마 모드 선택
ListTile(
title: const Text('테마 모드'),
subtitle: Text(_getThemeModeText(themeProvider.themeMode)),
leading: Icon(
_getThemeModeIcon(themeProvider.themeMode),
color: Theme.of(context).colorScheme.primary,
),
trailing: DropdownButton<AppThemeMode>(
value: themeProvider.themeMode,
underline: Container(),
onChanged: (mode) {
if (mode != null) {
themeProvider.setThemeMode(mode);
}
},
items: AppThemeMode.values.map((mode) =>
DropdownMenuItem(
value: mode,
child: Text(_getThemeModeText(mode)),
),
).toList(),
),
),
const Divider(height: 1),
// 접근성 설정
SwitchListTile(
title: const Text('큰 텍스트'),
subtitle: const Text('텍스트 크기를 크게 표시합니다'),
secondary: const Icon(Icons.text_fields),
value: themeProvider.largeText,
onChanged: themeProvider.setLargeText,
),
SwitchListTile(
title: const Text('모션 감소'),
subtitle: const Text('애니메이션 효과를 줄입니다'),
secondary: const Icon(Icons.slow_motion_video),
value: themeProvider.reduceMotion,
onChanged: themeProvider.setReduceMotion,
),
SwitchListTile(
title: const Text('고대비 모드'),
subtitle: const Text('더 선명한 색상으로 표시합니다'),
secondary: const Icon(Icons.contrast),
value: themeProvider.highContrast,
onChanged: themeProvider.setHighContrast,
),
],
);
},
),
),
// 앱 잠금 설정 UI 숨김
// Card(
// margin: const EdgeInsets.all(16),
@@ -161,8 +238,9 @@ class SettingsScreen extends StatelessWidget {
// ),
// 알림 설정
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
child: Consumer<NotificationProvider>(
builder: (context, provider, child) {
return Column(
@@ -211,7 +289,7 @@ class SettingsScreen extends StatelessWidget {
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.3),
.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
@@ -273,7 +351,7 @@ class SettingsScreen extends StatelessWidget {
color: Theme.of(context)
.colorScheme
.outline
.withOpacity(0.5),
.withValues(alpha: 0.5),
),
borderRadius:
BorderRadius.circular(8),
@@ -329,7 +407,7 @@ class SettingsScreen extends StatelessWidget {
color: Theme.of(context)
.colorScheme
.surfaceVariant
.withOpacity(0.3),
.withValues(alpha: 0.3),
borderRadius:
BorderRadius.circular(8),
),
@@ -377,8 +455,9 @@ class SettingsScreen extends StatelessWidget {
),
// 데이터 관리
Card(
margin: const EdgeInsets.all(16),
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
child: Column(
children: [
// 데이터 백업 기능 비활성화
@@ -389,108 +468,14 @@ class SettingsScreen extends StatelessWidget {
// onTap: () => _backupData(context),
// ),
// const Divider(),
// SMS 스캔 - 시각적으로 강조된 UI
InkWell(
onTap: () => _navigateToSmsScan(context),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Theme.of(context).primaryColor.withOpacity(0.1),
Theme.of(context).primaryColor.withOpacity(0.2),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 16.0, horizontal: 8.0),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
margin: const EdgeInsets.only(left: 8, right: 16),
decoration: BoxDecoration(
color: Theme.of(context)
.primaryColor
.withOpacity(0.15),
borderRadius: BorderRadius.circular(12),
),
child: Icon(
Icons.sms_rounded,
color: Theme.of(context).primaryColor,
size: 28,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'SMS 스캔으로 구독 자동 찾기',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
color: Theme.of(context).primaryColor,
),
),
const SizedBox(width: 6),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(12),
),
child: const Text(
'추천',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
],
),
const SizedBox(height: 6),
const Text(
'2회 이상 반복 결제된 구독 서비스를 자동으로 찾아 추가합니다',
style: TextStyle(
color: Colors.black54,
fontSize: 13,
),
),
],
),
),
Icon(
Icons.arrow_forward_ios,
size: 16,
color: Theme.of(context).primaryColor,
),
const SizedBox(width: 16),
],
),
),
),
),
],
),
),
// 앱 정보
Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(8),
child: ListTile(
title: const Text('앱 정보'),
subtitle: const Text('버전 1.0.0'),
@@ -554,8 +539,36 @@ class SettingsScreen extends StatelessWidget {
},
),
),
SizedBox(
height: 20 + MediaQuery.of(context).padding.bottom, // 하단 여백
),
],
),
);
);
}
String _getThemeModeText(AppThemeMode mode) {
switch (mode) {
case AppThemeMode.light:
return '라이트';
case AppThemeMode.dark:
return '다크';
case AppThemeMode.oled:
return 'OLED 블랙';
case AppThemeMode.system:
return '시스템 설정';
}
}
IconData _getThemeModeIcon(AppThemeMode mode) {
switch (mode) {
case AppThemeMode.light:
return Icons.light_mode;
case AppThemeMode.dark:
return Icons.dark_mode;
case AppThemeMode.oled:
return Icons.phonelink_lock;
case AppThemeMode.system:
return Icons.settings_brightness;
}
}
}

View File

@@ -1,11 +1,17 @@
import 'package:flutter/material.dart';
import '../services/sms_scanner.dart';
import '../providers/subscription_provider.dart';
import '../providers/navigation_provider.dart';
import 'package:provider/provider.dart';
import '../models/subscription.dart';
import '../models/subscription_model.dart';
import '../services/subscription_url_matcher.dart';
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
import '../widgets/glassmorphic_scaffold.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/glassmorphism_card.dart';
import '../widgets/themed_text.dart';
import '../theme/app_colors.dart';
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@@ -100,8 +106,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
print('중복 제거 후 구독: ${filteredSubscriptions.length}');
if (filteredSubscriptions.isNotEmpty &&
filteredSubscriptions[0] != null) {
if (filteredSubscriptions.isNotEmpty) {
print(
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
}
@@ -163,10 +168,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 중복되지 않은 구독만 필터링
final nonDuplicates = scanned.where((scannedSub) {
if (scannedSub == null) {
print('_filterDuplicates: null 구독 객체 발견');
return false;
}
// 서비스명과 금액이 동일한 기존 구독 찾기
final hasDuplicate = existing.any((existingSub) =>
@@ -189,10 +190,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
for (int i = 0; i < nonDuplicates.length; i++) {
final subscription = nonDuplicates[i];
if (subscription == null) {
print('_filterDuplicates: null 구독 객체 무시');
continue;
}
String? websiteUrl = subscription.websiteUrl;
@@ -252,11 +249,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)');
_moveToNextSubscription();
return;
}
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
@@ -365,9 +357,38 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('${subscription.serviceName} 구독이 추가되었습니다.'),
backgroundColor: Colors.green,
duration: const Duration(seconds: 2),
content: Row(
children: [
const Icon(
Icons.check_circle,
color: Colors.white,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Text(
'${subscription.serviceName} 구독이 추가되었습니다.',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
],
),
backgroundColor: const Color(0xFF10B981), // 초록색
behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
left: 16,
right: 16,
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
duration: const Duration(seconds: 3),
dismissDirection: DismissDirection.horizontal,
),
);
}
@@ -402,21 +423,36 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
_currentIndex++;
_websiteUrlController.text = ''; // URL 입력 필드 초기화
// 모든 구독을 처리했으면 화면 종료
// 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) {
Navigator.of(context).pop(true);
_navigateToHome();
}
});
}
// 홈 화면으로 이동
void _navigateToHome() {
// NavigationProvider를 사용하여 홈 화면으로 이동
final navigationProvider = Provider.of<NavigationProvider>(context, listen: false);
navigationProvider.updateCurrentIndex(0);
// 완료 메시지 표시
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('모든 구독이 처리되었습니다.'),
backgroundColor: Colors.green,
duration: Duration(seconds: 2),
),
);
}
// 날짜 상태 텍스트 가져오기
String _getNextBillingText(DateTime date) {
final now = DateTime.now();
if (date.isBefore(now)) {
// 주기에 따라 다음 결제일 예측
if (_currentIndex >= _scannedSubscriptions.length ||
_scannedSubscriptions[_currentIndex] == null) {
if (_currentIndex >= _scannedSubscriptions.length) {
return '다음 결제일 확인 필요';
}
@@ -485,17 +521,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('SMS 스캔'),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState())),
return Padding(
padding: const EdgeInsets.all(16.0),
child: _isLoading
? _buildLoadingState()
: (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState()),
);
}
@@ -507,9 +539,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('SMS 메시지를 스캔 중입니다...'),
ThemedText('SMS 메시지를 스캔 중입니다...'),
SizedBox(height: 8),
Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)),
ThemedText('구독 서비스를 찾고 있습니다', opacity: 0.7),
],
),
);
@@ -524,24 +556,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (_errorMessage != null)
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
child: ThemedText(
_errorMessage!,
style: const TextStyle(color: Colors.red),
color: Colors.red,
textAlign: TextAlign.center,
),
),
const SizedBox(height: 24),
const Text(
const ThemedText(
'2회 이상 결제된 구독 서비스 찾기',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
fontSize: 20,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 32.0),
child: Text(
child: ThemedText(
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.grey),
opacity: 0.7,
),
),
const SizedBox(height: 32),
@@ -562,26 +595,11 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
Widget _buildSubscriptionState() {
if (_currentIndex >= _scannedSubscriptions.length) {
return const Center(
child: Text('모든 구독 처리 완료'),
child: ThemedText('모든 구독 처리 완료'),
);
}
final subscription = _scannedSubscriptions[_currentIndex];
if (subscription == null) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('오류: 구독 정보를 불러올 수 없습니다.'),
SizedBox(height: 16),
ElevatedButton(
onPressed: _moveToNextSubscription,
child: Text('건너뛰기'),
),
],
),
);
}
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
@@ -594,54 +612,42 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 진행 상태 표시
LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length,
backgroundColor: Colors.grey.withOpacity(0.2),
backgroundColor: Colors.grey.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary),
),
const SizedBox(height: 8),
Text(
ThemedText(
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
style: TextStyle(
color: Colors.grey.shade600,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 24),
// 구독 정보 카드
Card(
elevation: 4,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.all(16.0),
GlassmorphismCard(
width: double.infinity,
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'다음 구독을 찾았습니다',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
fontSize: 18,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 24),
// 서비스명
const Text(
const ThemedText(
'서비스명',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
subscription.serviceName,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
),
fontSize: 22,
fontWeight: FontWeight.bold,
),
const SizedBox(height: 16),
@@ -652,15 +658,13 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'월 비용',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
subscription.currency == 'USD'
? NumberFormat.currency(
locale: 'en_US',
@@ -672,10 +676,8 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
symbol: '',
decimalDigits: 0,
).format(subscription.monthlyCost),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
fontSize: 18,
fontWeight: FontWeight.bold,
),
],
),
@@ -684,21 +686,17 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'반복 횟수',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
_getRepeatCountText(subscription.repeatCount),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
fontSize: 16,
fontWeight: FontWeight.w500,
color: Theme.of(context).colorScheme.secondary,
),
],
),
@@ -714,20 +712,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'결제 주기',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
subscription.billingCycle,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
),
fontSize: 16,
fontWeight: FontWeight.w500,
),
],
),
@@ -736,20 +730,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
const ThemedText(
'결제일',
style: TextStyle(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
fontWeight: FontWeight.w500,
opacity: 0.7,
),
const SizedBox(height: 4),
Text(
ThemedText(
_getNextBillingText(subscription.nextBillingDate),
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
),
fontSize: 14,
fontWeight: FontWeight.w500,
),
],
),
@@ -800,7 +790,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
],
),
),
),
],
);
}
@@ -809,8 +798,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
void didChangeDependencies() {
super.didChangeDependencies();
if (_scannedSubscriptions.isNotEmpty &&
_currentIndex < _scannedSubscriptions.length &&
_scannedSubscriptions[_currentIndex] != null) {
_currentIndex < _scannedSubscriptions.length) {
final currentSub = _scannedSubscriptions[_currentIndex];
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
_websiteUrlController.text = currentSub.websiteUrl!;

View File

@@ -1,8 +1,12 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/app_lock_provider.dart';
import '../providers/navigation_provider.dart';
import '../theme/app_colors.dart';
import '../widgets/glassmorphism_card.dart';
import '../routes/app_routes.dart';
import 'app_lock_screen.dart';
import 'main_screen.dart';
@@ -101,18 +105,10 @@ class _SplashScreenState extends State<SplashScreen>
void navigateToNextScreen() {
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
Navigator.of(context).pushReplacement(
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
const MainScreen(),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: animation,
child: child,
);
},
transitionDuration: const Duration(milliseconds: 500),
),
// 모든 이전 라우트를 제거하고 홈으로 이동
Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main,
(route) => false,
);
}
@@ -127,244 +123,305 @@ class _SplashScreenState extends State<SplashScreen>
final size = MediaQuery.of(context).size;
return Scaffold(
body: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: AppColors.blueGradient,
body: Stack(
children: [
// 배경 그라디언트
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
AppColors.dayGradient[0],
AppColors.dayGradient[1],
],
),
),
),
),
child: Stack(
children: [
// 배경 파티클
..._particles.map((particle) {
return AnimatedPositioned(
duration: Duration(milliseconds: particle['duration'].toInt()),
curve: Curves.easeInOut,
left: particle['x'] - 50 + (size.width * 0.1),
top: particle['y'] - 50 + (size.height * 0.1),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
// 글래스모피즘 오버레이
Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
),
),
Stack(
children: [
// 배경 파티클
..._particles.map((particle) {
return AnimatedPositioned(
duration:
Duration(milliseconds: particle['duration'].toInt()),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: child,
);
},
child: Container(
width: particle['size'],
height: particle['size'],
decoration: BoxDecoration(
color: particle['color'],
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: particle['color'].withOpacity(0.3),
blurRadius: 10,
spreadRadius: 1,
),
],
),
),
),
);
}).toList(),
// 상단 원형 그라데이션
Positioned(
top: -size.height * 0.2,
right: -size.width * 0.2,
child: Container(
width: size.width * 0.8,
height: size.width * 0.8,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withOpacity(0.1),
Colors.white.withOpacity(0.0),
],
stops: const [0.2, 1.0],
),
),
),
),
// 하단 원형 그라데이션
Positioned(
bottom: -size.height * 0.1,
left: -size.width * 0.3,
child: Container(
width: size.width * 0.9,
height: size.width * 0.9,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withOpacity(0.07),
Colors.white.withOpacity(0.0),
],
stops: const [0.4, 1.0],
),
),
),
),
// 메인 콘텐츠
Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 로고 애니메이션
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotateAnimation.value,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 120,
height: 120,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(30),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
spreadRadius: 0,
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: AnimatedBuilder(
animation: _animationController,
builder: (context, _) {
return ShaderMask(
blendMode: BlendMode.srcIn,
shaderCallback: (bounds) =>
LinearGradient(
colors: AppColors.blueGradient,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
).createShader(bounds),
child: Icon(
Icons.subscriptions_outlined,
size: 64,
color: Theme.of(context)
.primaryColor,
),
);
}),
),
),
),
);
},
),
const SizedBox(height: 40),
// 앱 이름 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: child,
),
);
},
child: const Text(
'SubManager',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.2,
),
curve: Curves.easeInOut,
left: particle['x'] - 50 + (size.width * 0.1),
top: particle['y'] - 50 + (size.height * 0.1),
child: TweenAnimationBuilder<double>(
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
duration:
Duration(milliseconds: particle['duration'].toInt()),
builder: (context, value, child) {
return Opacity(
opacity: value,
child: child,
);
},
child: Container(
width: particle['size'],
height: particle['size'],
decoration: BoxDecoration(
color: particle['color'],
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: particle['color'].withValues(alpha: 0.3),
blurRadius: 10,
spreadRadius: 1,
),
),
const SizedBox(height: 16),
// 부제목 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value * 1.2),
child: child,
),
);
},
child: const Text(
'구독 서비스 관리를 더 쉽게',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 60),
// 로딩 인디케이터
FadeTransition(
opacity: _fadeAnimation,
child: Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(50),
),
child: const CircularProgressIndicator(
valueColor:
AlwaysStoppedAnimation<Color>(Colors.white),
strokeWidth: 3,
),
),
),
],
),
),
),
// 카피라이트 텍스트
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: FadeTransition(
opacity: _fadeAnimation,
child: const Text(
'© 2023 CClabs. All rights reserved.',
style: TextStyle(
fontSize: 12,
color: Colors.white60,
letterSpacing: 0.5,
],
),
),
),
);
}).toList(),
// 상단 원형 그라데이션
Positioned(
top: -size.height * 0.2,
right: -size.width * 0.2,
child: Container(
width: size.width * 0.8,
height: size.width * 0.8,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.1),
Colors.white.withValues(alpha: 0.0),
],
stops: const [0.2, 1.0],
),
),
),
],
),
],
),
),
// 하단 원형 그라데이션
Positioned(
bottom: -size.height * 0.1,
left: -size.width * 0.3,
child: Container(
width: size.width * 0.9,
height: size.width * 0.9,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.07),
Colors.white.withValues(alpha: 0.0),
],
stops: const [0.4, 1.0],
),
),
),
),
// 메인 콘텐츠
Column(
children: [
Expanded(
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// 로고 애니메이션
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotateAnimation.value,
child: AnimatedContainer(
duration:
const Duration(milliseconds: 200),
width: 120,
height: 120,
child: ClipRRect(
borderRadius:
BorderRadius.circular(30),
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 20, sigmaY: 20),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white
.withValues(alpha: 0.2),
Colors.white
.withValues(alpha: 0.1),
],
),
borderRadius:
BorderRadius.circular(30),
border: Border.all(
color: Colors.white
.withValues(alpha: 0.3),
width: 1.5,
),
boxShadow: [
BoxShadow(
color: Colors.black
.withValues(alpha: 0.1),
spreadRadius: 0,
blurRadius: 30,
offset: const Offset(0, 10),
),
],
),
child: Center(
child: AnimatedBuilder(
animation:
_animationController,
builder: (context, _) {
return ShaderMask(
blendMode:
BlendMode.srcIn,
shaderCallback:
(bounds) =>
LinearGradient(
colors: AppColors
.blueGradient,
begin:
Alignment.topLeft,
end: Alignment
.bottomRight,
).createShader(bounds),
child: Icon(
Icons
.subscriptions_outlined,
size: 64,
color:
Theme.of(context)
.primaryColor,
),
);
}),
),
),
),
)),
));
},
),
const SizedBox(height: 40),
// 앱 이름 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset: Offset(0, _slideAnimation.value),
child: child,
),
);
},
child: const Text(
'SubManager',
style: TextStyle(
fontSize: 36,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: 1.2,
),
),
),
const SizedBox(height: 16),
// 부제목 텍스트
AnimatedBuilder(
animation: _animationController,
builder: (context, child) {
return Opacity(
opacity: _fadeAnimation.value,
child: Transform.translate(
offset:
Offset(0, _slideAnimation.value * 1.2),
child: child,
),
);
},
child: const Text(
'구독 서비스 관리를 더 쉽게',
style: TextStyle(
fontSize: 16,
color: Colors.white70,
letterSpacing: 0.5,
),
),
),
const SizedBox(height: 60),
// 로딩 인디케이터
FadeTransition(
opacity: _fadeAnimation,
child: ClipRRect(
borderRadius: BorderRadius.circular(50),
child: BackdropFilter(
filter:
ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(6),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50),
border: Border.all(
color:
Colors.white.withValues(alpha: 0.2),
width: 1,
),
),
child: const CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(
Colors.white),
strokeWidth: 3,
),
),
),
),
),
],
),
),
),
// 카피라이트 텍스트
Padding(
padding: const EdgeInsets.only(bottom: 24.0),
child: FadeTransition(
opacity: _fadeAnimation,
child: const Text(
'© 2023 CClabs. All rights reserved.',
style: TextStyle(
fontSize: 12,
color: Colors.white60,
letterSpacing: 0.5,
),
),
),
),
],
),
],
),
],
),
);
}

View File

@@ -131,4 +131,29 @@ class CurrencyUtil {
).format(savings);
}
}
/// 금액과 통화를 받아 포맷팅하여 반환
static Future<String> formatAmount(double amount, String currency) async {
if (currency == 'USD') {
// USD 표시 + 원화 환산 금액
final usdFormatted = NumberFormat.currency(
locale: 'en_US',
symbol: '\$',
decimalDigits: 2,
).format(amount);
// 원화 환산 금액
final krwAmount = await _exchangeRateService
.getFormattedKrwAmount(amount);
return '$usdFormatted $krwAmount';
} else {
// 원화 표시
return NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(amount);
}
}
}

View File

@@ -0,0 +1,379 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'app_colors.dart';
import 'app_theme.dart';
/// 적응형 테마 관리 클래스
class AdaptiveTheme {
/// 라이트 테마
static ThemeData get lightTheme => AppTheme.lightTheme;
/// 다크 테마
static ThemeData get darkTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.dark,
colorScheme: ColorScheme.dark(
primary: AppColors.primaryColor,
onPrimary: Colors.white,
secondary: AppColors.secondaryColor,
tertiary: AppColors.infoColor,
error: AppColors.dangerColor,
background: const Color(0xFF121212),
surface: const Color(0xFF1E1E1E),
),
scaffoldBackgroundColor: const Color(0xFF121212),
cardTheme: CardTheme(
color: const Color(0xFF1E1E1E),
elevation: 2,
shadowColor: Colors.black.withValues(alpha: 0.3),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 0.5),
),
clipBehavior: Clip.antiAlias,
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
),
appBarTheme: AppBarTheme(
backgroundColor: const Color(0xFF1E1E1E),
foregroundColor: Colors.white,
elevation: 0,
centerTitle: false,
titleTextStyle: const TextStyle(
color: Colors.white,
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
iconTheme: IconThemeData(
color: Colors.white.withValues(alpha: 0.9),
size: 24,
),
),
textTheme: TextTheme(
headlineLarge: const TextStyle(
color: Colors.white,
fontSize: 32,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineMedium: const TextStyle(
color: Colors.white,
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
height: 1.2,
),
headlineSmall: const TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w600,
letterSpacing: -0.25,
height: 1.3,
),
titleLarge: const TextStyle(
color: Colors.white,
fontSize: 20,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
height: 1.4,
),
titleMedium: const TextStyle(
color: Colors.white,
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.1,
height: 1.4,
),
titleSmall: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0,
height: 1.4,
),
bodyLarge: TextStyle(
color: Colors.white.withValues(alpha: 0.9),
fontSize: 16,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodyMedium: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14,
fontWeight: FontWeight.w400,
letterSpacing: 0.1,
height: 1.5,
),
bodySmall: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 12,
fontWeight: FontWeight.w400,
letterSpacing: 0.2,
height: 1.5,
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: const Color(0xFF2A2A2A),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.white.withValues(alpha: 0.1), width: 1),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: AppColors.dangerColor, width: 1),
),
labelStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 14,
fontWeight: FontWeight.w500,
),
hintStyle: TextStyle(
color: Colors.white.withValues(alpha: 0.5),
fontSize: 14,
fontWeight: FontWeight.w400,
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
elevation: 0,
),
),
dividerTheme: DividerThemeData(
color: Colors.white.withValues(alpha: 0.1),
thickness: 1,
space: 16,
),
);
}
/// OLED 최적화 다크 테마
static ThemeData get oledTheme {
return darkTheme.copyWith(
scaffoldBackgroundColor: Colors.black,
colorScheme: darkTheme.colorScheme.copyWith(
background: Colors.black,
surface: const Color(0xFF0A0A0A),
),
cardTheme: darkTheme.cardTheme.copyWith(
color: const Color(0xFF0A0A0A),
),
appBarTheme: darkTheme.appBarTheme.copyWith(
backgroundColor: Colors.black,
),
inputDecorationTheme: darkTheme.inputDecorationTheme.copyWith(
fillColor: const Color(0xFF0A0A0A),
),
);
}
/// 고대비 테마
static ThemeData get highContrastTheme {
return ThemeData(
useMaterial3: true,
brightness: Brightness.light,
colorScheme: const ColorScheme.highContrastLight(
primary: Colors.black,
secondary: Colors.black87,
tertiary: Colors.black54,
error: Colors.red,
background: Colors.white,
surface: Colors.white,
),
textTheme: const TextTheme(
headlineLarge: TextStyle(
color: Colors.black,
fontSize: 32,
fontWeight: FontWeight.w900,
),
headlineMedium: TextStyle(
color: Colors.black,
fontSize: 28,
fontWeight: FontWeight.w900,
),
headlineSmall: TextStyle(
color: Colors.black,
fontSize: 24,
fontWeight: FontWeight.w800,
),
bodyLarge: TextStyle(
color: Colors.black,
fontSize: 16,
fontWeight: FontWeight.w600,
),
bodyMedium: TextStyle(
color: Colors.black87,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
cardTheme: CardTheme(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: const BorderSide(color: Colors.black, width: 2),
),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
side: const BorderSide(color: Colors.black, width: 2),
textStyle: const TextStyle(
fontWeight: FontWeight.w700,
),
),
),
);
}
/// 시스템 테마에 따른 상태바 스타일 적용
static void applySystemUIOverlay(BuildContext context) {
final brightness = Theme.of(context).brightness;
final isOled = Theme.of(context).scaffoldBackgroundColor == Colors.black;
SystemChrome.setSystemUIOverlayStyle(SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
statusBarBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
systemNavigationBarColor: isOled
? Colors.black
: (brightness == Brightness.dark
? const Color(0xFF121212)
: Colors.white),
systemNavigationBarIconBrightness: brightness == Brightness.dark
? Brightness.light
: Brightness.dark,
));
}
/// 접근성 설정에 따른 테마 조정
static ThemeData getAccessibleTheme(
ThemeData baseTheme, {
required bool largeText,
required bool reduceMotion,
required bool highContrast,
}) {
if (highContrast) {
return highContrastTheme;
}
ThemeData theme = baseTheme;
if (largeText) {
theme = theme.copyWith(
textTheme: theme.textTheme.apply(
fontSizeFactor: 1.2,
),
);
}
if (reduceMotion) {
theme = theme.copyWith(
pageTransitionsTheme: const PageTransitionsTheme(
builders: {
TargetPlatform.android: FadeUpwardsPageTransitionsBuilder(),
TargetPlatform.iOS: FadeUpwardsPageTransitionsBuilder(),
},
),
);
}
return theme;
}
}
/// 테마 모드 열거형
enum AppThemeMode {
light,
dark,
oled,
system,
}
/// 테마 설정 클래스
class ThemeSettings {
final AppThemeMode mode;
final bool useSystemColors;
final bool largeText;
final bool reduceMotion;
final bool highContrast;
const ThemeSettings({
this.mode = AppThemeMode.system,
this.useSystemColors = false,
this.largeText = false,
this.reduceMotion = false,
this.highContrast = false,
});
ThemeSettings copyWith({
AppThemeMode? mode,
bool? useSystemColors,
bool? largeText,
bool? reduceMotion,
bool? highContrast,
}) {
return ThemeSettings(
mode: mode ?? this.mode,
useSystemColors: useSystemColors ?? this.useSystemColors,
largeText: largeText ?? this.largeText,
reduceMotion: reduceMotion ?? this.reduceMotion,
highContrast: highContrast ?? this.highContrast,
);
}
Map<String, dynamic> toJson() => {
'mode': mode.name,
'useSystemColors': useSystemColors,
'largeText': largeText,
'reduceMotion': reduceMotion,
'highContrast': highContrast,
};
factory ThemeSettings.fromJson(Map<String, dynamic> json) {
return ThemeSettings(
mode: AppThemeMode.values.firstWhere(
(mode) => mode.name == json['mode'],
orElse: () => AppThemeMode.system,
),
useSystemColors: json['useSystemColors'] ?? false,
largeText: json['largeText'] ?? false,
reduceMotion: json['reduceMotion'] ?? false,
highContrast: json['highContrast'] ?? false,
);
}
}

View File

@@ -46,4 +46,49 @@ class AppColors {
Color(0xFFF43F5E),
Color(0xFFE11D48)
];
// Glassmorphism 효과를 위한 색상
static const glassSurface = Color(0x0FFFFFFF); // 매우 연한 흰색 (6% opacity)
static const glassBackground = Color(0x1AFFFFFF); // 연한 흰색 (10% opacity)
static const glassCard = Color(0x33FFFFFF); // 반투명 흰색 (20% opacity)
static const glassBorder = Color(0x4DFFFFFF); // 반투명 테두리 (30% opacity)
static const glassOverlay = Color(0x0D000000); // 연한 검정 오버레이 (5% opacity)
// 다크 모드용 Glassmorphism 색상
static const glassSurfaceDark = Color(0x0F000000); // 매우 연한 검정 (6% opacity)
static const glassBackgroundDark = Color(0x1A000000); // 연한 검정 (10% opacity)
static const glassCardDark = Color(0x33000000); // 반투명 검정 (20% opacity)
static const glassBorderDark = Color(0x4D000000); // 반투명 검정 테두리 (30% opacity)
// 백드롭 블러 효과를 위한 그라디언트
static const List<Color> glassGradient = [
Color(0x1AFFFFFF), // 10% white
Color(0x0FFFFFFF), // 6% white
];
static const List<Color> glassGradientDark = [
Color(0x1A000000), // 10% black
Color(0x0F000000), // 6% black
];
// 시간대별 배경 그라디언트
static const List<Color> morningGradient = [
Color(0xFFFED7AA), // 따뜻한 오렌지
Color(0xFFFBBF24), // 부드러운 노랑
];
static const List<Color> dayGradient = [
Color(0xFFDDEAFC), // 연한 하늘색
Color(0xFFBFDBFE), // 맑은 파랑
];
static const List<Color> eveningGradient = [
Color(0xFFFCA5A5), // 부드러운 핑크
Color(0xFFC084FC), // 연한 보라
];
static const List<Color> nightGradient = [
Color(0xFF4338CA), // 깊은 인디고
Color(0xFF1E1B4B), // 다크 네이비
];
}

View File

@@ -21,7 +21,7 @@ class AppTheme {
cardTheme: CardTheme(
color: AppColors.cardColor,
elevation: 1,
shadowColor: Colors.black.withOpacity(0.04),
shadowColor: Colors.black.withValues(alpha: 0.04),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
side: BorderSide(color: AppColors.borderColor, width: 0.5),
@@ -265,7 +265,7 @@ class AppTheme {
}),
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
if (states.contains(MaterialState.selected)) {
return AppColors.primaryColor.withOpacity(0.5);
return AppColors.primaryColor.withValues(alpha: 0.5);
}
return AppColors.borderColor;
}),
@@ -300,7 +300,7 @@ class AppTheme {
activeTrackColor: AppColors.primaryColor,
inactiveTrackColor: AppColors.borderColor,
thumbColor: AppColors.primaryColor,
overlayColor: AppColors.primaryColor.withOpacity(0.2),
overlayColor: AppColors.primaryColor.withValues(alpha: 0.2),
trackHeight: 4,
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),

View File

@@ -0,0 +1,74 @@
import 'package:flutter/services.dart';
import 'dart:io' show Platform;
/// 햅틱 피드백을 관리하는 헬퍼 클래스
class HapticFeedbackHelper {
static bool _isEnabled = true;
/// 햅틱 피드백 활성화 여부 설정
static void setEnabled(bool enabled) {
_isEnabled = enabled;
}
/// 가벼운 햅틱 피드백
static Future<void> lightImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.lightImpact();
}
/// 중간 강도 햅틱 피드백
static Future<void> mediumImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.mediumImpact();
}
/// 강한 햅틱 피드백
static Future<void> heavyImpact() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.heavyImpact();
}
/// 선택 햅틱 피드백 (iOS의 경우 Taptic Engine)
static Future<void> selectionClick() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.selectionClick();
}
/// 진동 패턴 (Android)
static Future<void> vibrate({int duration = 50}) async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.vibrate();
}
/// 성공 피드백 패턴
static Future<void> success() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.mediumImpact();
await Future.delayed(const Duration(milliseconds: 100));
await HapticFeedback.lightImpact();
}
/// 에러 피드백 패턴
static Future<void> error() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.heavyImpact();
await Future.delayed(const Duration(milliseconds: 100));
await HapticFeedback.heavyImpact();
}
/// 경고 피드백 패턴
static Future<void> warning() async {
if (!_isEnabled || !_isPlatformSupported()) return;
await HapticFeedback.mediumImpact();
}
/// 플랫폼이 햅틱 피드백을 지원하는지 확인
static bool _isPlatformSupported() {
try {
return Platform.isIOS || Platform.isAndroid;
} catch (e) {
// 웹이나 데스크톱에서는 Platform을 사용할 수 없음
return false;
}
}
}

View File

@@ -0,0 +1,287 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'dart:collection';
import 'dart:async';
/// 메모리 관리를 위한 헬퍼 클래스
class MemoryManager {
static final MemoryManager _instance = MemoryManager._internal();
factory MemoryManager() => _instance;
MemoryManager._internal();
// 캐시 관리
final Map<String, _CacheEntry> _cache = {};
final int _maxCacheSize = 100;
final Duration _defaultTTL = const Duration(minutes: 5);
// 이미지 캐시 관리
static const int maxImageCacheSize = 50 * 1024 * 1024; // 50MB
static const int maxImageCacheCount = 100;
// 위젯 참조 추적
final Map<String, WeakReference<State>> _widgetReferences = {};
/// 캐시에 데이터 저장
void cacheData<T>({
required String key,
required T data,
Duration? ttl,
}) {
_cleanupExpiredCache();
if (_cache.length >= _maxCacheSize) {
_evictOldestEntry();
}
_cache[key] = _CacheEntry(
data: data,
timestamp: DateTime.now(),
ttl: ttl ?? _defaultTTL,
);
}
/// 캐시에서 데이터 가져오기
T? getCachedData<T>(String key) {
final entry = _cache[key];
if (entry == null) return null;
if (entry.isExpired) {
_cache.remove(key);
return null;
}
entry.lastAccess = DateTime.now();
return entry.data as T?;
}
/// 캐시 비우기
void clearCache() {
_cache.clear();
if (kDebugMode) {
print('🧹 메모리 캐시가 비워졌습니다.');
}
}
/// 특정 패턴의 캐시 제거
void clearCacheByPattern(String pattern) {
final keysToRemove = _cache.keys
.where((key) => key.contains(pattern))
.toList();
for (final key in keysToRemove) {
_cache.remove(key);
}
}
/// 만료된 캐시 정리
void _cleanupExpiredCache() {
final expiredKeys = _cache.entries
.where((entry) => entry.value.isExpired)
.map((entry) => entry.key)
.toList();
for (final key in expiredKeys) {
_cache.remove(key);
}
}
/// 가장 오래된 캐시 항목 제거
void _evictOldestEntry() {
if (_cache.isEmpty) return;
var oldestKey = _cache.keys.first;
var oldestTime = _cache[oldestKey]!.lastAccess;
for (final entry in _cache.entries) {
if (entry.value.lastAccess.isBefore(oldestTime)) {
oldestKey = entry.key;
oldestTime = entry.value.lastAccess;
}
}
_cache.remove(oldestKey);
}
/// 이미지 캐시 최적화
static void optimizeImageCache() {
PaintingBinding.instance.imageCache.maximumSize = maxImageCacheCount;
PaintingBinding.instance.imageCache.maximumSizeBytes = maxImageCacheSize;
}
/// 이미지 캐시 상태 확인
static ImageCacheStatus getImageCacheStatus() {
final cache = PaintingBinding.instance.imageCache;
return ImageCacheStatus(
currentSize: cache.currentSize,
maximumSize: cache.maximumSize,
currentSizeBytes: cache.currentSizeBytes,
maximumSizeBytes: cache.maximumSizeBytes,
);
}
/// 이미지 캐시 비우기
static void clearImageCache() {
PaintingBinding.instance.imageCache.clear();
PaintingBinding.instance.imageCache.clearLiveImages();
if (kDebugMode) {
print('🖼️ 이미지 캐시가 비워졌습니다.');
}
}
/// 위젯 참조 추적
void trackWidget(String key, State widget) {
_widgetReferences[key] = WeakReference(widget);
}
/// 위젯 참조 제거
void untrackWidget(String key) {
_widgetReferences.remove(key);
}
/// 살아있는 위젯 수 확인
int getAliveWidgetCount() {
return _widgetReferences.values
.where((ref) => ref.target != null)
.length;
}
/// 메모리 압박 시 대응
void handleMemoryPressure() {
// 캐시 50% 제거
final keysToRemove = _cache.keys.take(_cache.length ~/ 2).toList();
for (final key in keysToRemove) {
_cache.remove(key);
}
// 이미지 캐시 축소
final imageCache = PaintingBinding.instance.imageCache;
imageCache.maximumSize = maxImageCacheCount ~/ 2;
imageCache.maximumSizeBytes = maxImageCacheSize ~/ 2;
if (kDebugMode) {
print('⚠️ 메모리 압박 대응: 캐시 크기 감소');
}
}
/// 자동 메모리 정리 시작
Timer? _cleanupTimer;
void startAutoCleanup({Duration interval = const Duration(minutes: 1)}) {
_cleanupTimer?.cancel();
_cleanupTimer = Timer.periodic(interval, (_) {
_cleanupExpiredCache();
// 죽은 위젯 참조 제거
final deadKeys = _widgetReferences.entries
.where((entry) => entry.value.target == null)
.map((entry) => entry.key)
.toList();
for (final key in deadKeys) {
_widgetReferences.remove(key);
}
});
}
/// 자동 메모리 정리 중지
void stopAutoCleanup() {
_cleanupTimer?.cancel();
_cleanupTimer = null;
}
/// 메모리 사용량 리포트
Map<String, dynamic> getMemoryReport() {
return {
'cacheSize': _cache.length,
'maxCacheSize': _maxCacheSize,
'aliveWidgets': getAliveWidgetCount(),
'totalWidgetReferences': _widgetReferences.length,
'imageCacheStatus': getImageCacheStatus().toJson(),
};
}
}
/// 캐시 항목 클래스
class _CacheEntry {
final dynamic data;
final DateTime timestamp;
final Duration ttl;
DateTime lastAccess;
_CacheEntry({
required this.data,
required this.timestamp,
required this.ttl,
}) : lastAccess = timestamp;
bool get isExpired => DateTime.now().difference(timestamp) > ttl;
}
/// 이미지 캐시 상태 클래스
class ImageCacheStatus {
final int currentSize;
final int maximumSize;
final int currentSizeBytes;
final int maximumSizeBytes;
ImageCacheStatus({
required this.currentSize,
required this.maximumSize,
required this.currentSizeBytes,
required this.maximumSizeBytes,
});
double get sizeUsagePercentage => (currentSize / maximumSize) * 100;
double get bytesUsagePercentage => (currentSizeBytes / maximumSizeBytes) * 100;
Map<String, dynamic> toJson() => {
'currentSize': currentSize,
'maximumSize': maximumSize,
'currentSizeBytes': currentSizeBytes,
'maximumSizeBytes': maximumSizeBytes,
'sizeUsagePercentage': sizeUsagePercentage.toStringAsFixed(2),
'bytesUsagePercentage': bytesUsagePercentage.toStringAsFixed(2),
};
}
/// 메모리 효율적인 리스트 뷰
class MemoryEfficientListView<T> extends StatefulWidget {
final List<T> items;
final Widget Function(BuildContext, T) itemBuilder;
final int cacheExtent;
final ScrollPhysics? physics;
const MemoryEfficientListView({
super.key,
required this.items,
required this.itemBuilder,
this.cacheExtent = 250,
this.physics,
});
@override
State<MemoryEfficientListView<T>> createState() =>
_MemoryEfficientListViewState<T>();
}
class _MemoryEfficientListViewState<T>
extends State<MemoryEfficientListView<T>>
with AutomaticKeepAliveClientMixin {
@override
bool get wantKeepAlive => false;
@override
Widget build(BuildContext context) {
super.build(context);
return ListView.builder(
itemCount: widget.items.length,
cacheExtent: widget.cacheExtent.toDouble(),
physics: widget.physics ?? const BouncingScrollPhysics(),
itemBuilder: (context, index) {
return widget.itemBuilder(context, widget.items[index]);
},
);
}
}

View File

@@ -0,0 +1,204 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'dart:async';
import 'dart:developer' as developer;
/// 성능 최적화를 위한 유틸리티 클래스
class PerformanceOptimizer {
static final PerformanceOptimizer _instance = PerformanceOptimizer._internal();
factory PerformanceOptimizer() => _instance;
PerformanceOptimizer._internal();
// 프레임 타이밍 정보
final List<FrameTiming> _frameTimings = [];
bool _isMonitoring = false;
/// 프레임 성능 모니터링 시작
void startFrameMonitoring() {
if (_isMonitoring) return;
_isMonitoring = true;
SchedulerBinding.instance.addTimingsCallback((timings) {
_frameTimings.addAll(timings);
// 최근 100개 프레임만 유지
if (_frameTimings.length > 100) {
_frameTimings.removeRange(0, _frameTimings.length - 100);
}
});
}
/// 프레임 성능 모니터링 중지
void stopFrameMonitoring() {
if (!_isMonitoring) return;
_isMonitoring = false;
SchedulerBinding.instance.addTimingsCallback((_) {});
}
/// 평균 FPS 계산
double getAverageFPS() {
if (_frameTimings.isEmpty) return 0.0;
double totalDuration = 0;
for (final timing in _frameTimings) {
totalDuration += timing.totalSpan.inMicroseconds;
}
final averageDuration = totalDuration / _frameTimings.length;
return 1000000 / averageDuration; // microseconds to FPS
}
/// 메모리 사용량 모니터링
static Future<MemoryInfo> getMemoryInfo() async {
// Flutter에서는 직접적인 메모리 사용량 측정이 제한적이므로
// 이미지 캐시 사용량을 기준으로 측정
final imageCache = PaintingBinding.instance.imageCache;
return MemoryInfo(
currentUsage: imageCache.currentSizeBytes,
capacity: imageCache.maximumSizeBytes,
);
}
/// 위젯 재빌드 최적화를 위한 데바운서
static Timer? _debounceTimer;
static void debounce(
VoidCallback callback, {
Duration delay = const Duration(milliseconds: 300),
}) {
_debounceTimer?.cancel();
_debounceTimer = Timer(delay, callback);
}
/// 스로틀링 - 지정된 시간 간격으로만 실행
static DateTime? _lastThrottleTime;
static void throttle(
VoidCallback callback, {
Duration interval = const Duration(milliseconds: 300),
}) {
final now = DateTime.now();
if (_lastThrottleTime == null ||
now.difference(_lastThrottleTime!) > interval) {
_lastThrottleTime = now;
callback();
}
}
/// 무거운 연산을 별도 Isolate에서 실행
static Future<T> runInIsolate<T>(
ComputeCallback<dynamic, T> callback,
dynamic parameter,
) async {
return await compute(callback, parameter);
}
/// 레이지 로딩을 위한 페이지네이션 헬퍼
static List<T> paginate<T>({
required List<T> items,
required int page,
required int pageSize,
}) {
final startIndex = page * pageSize;
final endIndex = (startIndex + pageSize).clamp(0, items.length);
if (startIndex >= items.length) return [];
return items.sublist(startIndex, endIndex);
}
/// 이미지 최적화 - 메모리 효율적인 크기로 조정
static double getOptimalImageSize(BuildContext context, {
required double originalSize,
double maxSize = 1000,
}) {
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final screenSize = MediaQuery.of(context).size;
final maxDimension = screenSize.width > screenSize.height
? screenSize.width
: screenSize.height;
final optimalSize = (maxDimension * devicePixelRatio).clamp(100.0, maxSize);
return optimalSize < originalSize ? optimalSize : originalSize;
}
/// 위젯 키 최적화
static Key generateOptimizedKey(String prefix, dynamic identifier) {
return ValueKey('${prefix}_$identifier');
}
/// 애니메이션 최적화 - 보이지 않는 애니메이션 중지
static bool shouldAnimateWidget(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return !mediaQuery.disableAnimations && mediaQuery.accessibleNavigation;
}
/// 스크롤 성능 최적화
static ScrollPhysics getOptimizedScrollPhysics() {
return const BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
);
}
/// 빌드 최적화를 위한 const 위젯 권장사항 체크
static void checkConstOptimization() {
if (kDebugMode) {
print('💡 성능 최적화 팁:');
print('1. 가능한 모든 위젯에 const 사용');
print('2. StatelessWidget 대신 const 생성자 사용');
print('3. 큰 리스트는 ListView.builder 사용');
print('4. 이미지는 캐싱과 함께 적절한 크기로 로드');
print('5. 애니메이션은 AnimatedBuilder 사용');
}
}
/// 메모리 누수 감지 헬퍼
static final Map<String, int> _widgetCounts = {};
static void trackWidget(String widgetName, bool isCreated) {
if (!kDebugMode) return;
_widgetCounts[widgetName] = (_widgetCounts[widgetName] ?? 0) +
(isCreated ? 1 : -1);
// 위젯이 비정상적으로 많이 생성되면 경고
if ((_widgetCounts[widgetName] ?? 0) > 100) {
print('⚠️ 경고: $widgetName 위젯이 100개 이상 생성됨. 메모리 누수 가능성!');
}
}
}
/// 메모리 정보 클래스
class MemoryInfo {
final int currentUsage;
final int capacity;
MemoryInfo({
required this.currentUsage,
required this.capacity,
});
double get usagePercentage => (currentUsage / capacity) * 100;
String get formattedUsage => '${(currentUsage / 1024 / 1024).toStringAsFixed(2)} MB';
String get formattedCapacity => '${(capacity / 1024 / 1024).toStringAsFixed(2)} MB';
}
/// 성능 측정 데코레이터
class PerformanceMeasure {
static Future<T> measure<T>({
required String name,
required Future<T> Function() operation,
}) async {
if (!kDebugMode) return await operation();
final stopwatch = Stopwatch()..start();
try {
final result = await operation();
stopwatch.stop();
print('$name 완료: ${stopwatch.elapsedMilliseconds}ms');
return result;
} catch (e) {
stopwatch.stop();
print('$name 실패: ${stopwatch.elapsedMilliseconds}ms - $e');
rethrow;
}
}
}

View File

@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
/// 파이 차트에서 선택된 섹션에 표시되는 배지 위젯
class AnalysisBadge extends StatelessWidget {
final double size;
final Color borderColor;
final SubscriptionModel subscription;
const AnalysisBadge({
super.key,
required this.size,
required this.borderColor,
required this.subscription,
});
@override
Widget build(BuildContext context) {
return AnimatedContainer(
duration: PieChart.defaultDuration,
width: size,
height: size,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: borderColor,
width: 2,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 10,
spreadRadius: 2,
),
],
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
subscription.serviceName.length > 5
? '${subscription.serviceName.substring(0, 5)}...'
: subscription.serviceName,
style: const TextStyle(
fontSize: 8,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 2),
FutureBuilder<String>(
future: CurrencyUtil.formatAmount(
subscription.monthlyCost,
subscription.currency,
),
builder: (context, snapshot) {
if (snapshot.hasData) {
final amountText = snapshot.data!;
// 금액이 너무 길면 축약
final displayText = amountText.length > 8
? amountText.replaceAll('', '').trim()
: amountText;
return Text(
displayText,
style: const TextStyle(
fontSize: 7,
color: Colors.black54,
),
);
}
return const SizedBox();
},
),
],
),
),
);
}
}

View File

@@ -0,0 +1,19 @@
import 'package:flutter/material.dart';
/// 분석 화면에서 사용하는 간격 위젯
/// SliverToBoxAdapter 오류를 해결하기 위해 별도 컴포넌트로 분리
class AnalysisScreenSpacer extends StatelessWidget {
final double height;
const AnalysisScreenSpacer({
super.key,
this.height = 24,
});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(height: height),
);
}
}

View File

@@ -0,0 +1,272 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart';
import '../../providers/subscription_provider.dart';
import '../../services/currency_util.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
/// 이벤트 할인 현황을 보여주는 카드 위젯
class EventAnalysisCard extends StatelessWidget {
final AnimationController animationController;
const EventAnalysisCard({
super.key,
required this.animationController,
});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Consumer<SubscriptionProvider>(
builder: (context, provider, child) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: provider.activeEventSubscriptions.isNotEmpty
? FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController,
curve: const Interval(0.6, 1.0, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: '이벤트 할인 현황',
style: const TextStyle(
fontSize: 18,
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [
Color(0xFFFF6B6B),
Color(0xFFFE7E7E),
],
),
borderRadius: BorderRadius.circular(4),
),
child: Row(
children: [
const FaIcon(
FontAwesomeIcons.fire,
size: 12,
color: Colors.white,
),
const SizedBox(width: 4),
Text(
'${provider.activeEventSubscriptions.length}개 진행중',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
],
),
),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
const Color(0xFFFF6B6B).withValues(alpha: 0.1),
const Color(0xFFFF8787).withValues(alpha: 0.1),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: const Color(0xFFFF6B6B).withValues(alpha: 0.3),
),
),
child: Row(
children: [
const Icon(
Icons.savings,
color: Color(0xFFFF6B6B),
size: 32,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ThemedText(
'월간 절약 금액',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmount(
provider.calculateTotalSavings(),
),
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
),
),
],
),
),
],
),
),
const SizedBox(height: 16),
const ThemedText(
'진행중인 이벤트',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
...provider.activeEventSubscriptions.map((sub) {
final savings = sub.originalPrice - (sub.eventPrice ?? sub.originalPrice);
final discountRate =
((savings / sub.originalPrice) * 100).round();
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
sub.serviceName,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
Row(
children: [
FutureBuilder<String>(
future: CurrencyUtil
.formatAmount(
sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
fontSize: 12,
decoration: TextDecoration
.lineThrough,
color: Colors.grey,
),
);
}
return const SizedBox();
},
),
const SizedBox(width: 8),
const Icon(
Icons.arrow_forward,
size: 12,
color: Colors.grey,
),
const SizedBox(width: 8),
FutureBuilder<String>(
future: CurrencyUtil
.formatAmount(
sub.eventPrice ?? sub.originalPrice,
sub.currency),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
fontSize: 12,
fontWeight:
FontWeight.bold,
color:
Color(0xFF10B981),
),
);
}
return const SizedBox();
},
),
],
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B)
.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(4),
),
child: Text(
'$discountRate% 할인',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFFFF6B6B),
),
),
),
],
),
);
}).toList(),
],
),
),
),
),
)
: const SizedBox.shrink(),
);
},
),
);
}
}

View File

@@ -0,0 +1,214 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import 'dart:math' as math;
import '../../services/currency_util.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
class MonthlyExpenseChartCard extends StatelessWidget {
final List<Map<String, dynamic>> monthlyData;
final AnimationController animationController;
const MonthlyExpenseChartCard({
super.key,
required this.monthlyData,
required this.animationController,
});
// 월간 지출 차트 데이터
List<BarChartGroupData> _getMonthlyBarGroups() {
final List<BarChartGroupData> barGroups = [];
final calculatedMax = monthlyData.fold<double>(
0, (max, data) => math.max(max, data['totalExpense'] as double));
final maxAmount = calculatedMax > 0 ? calculatedMax : 100000.0; // 기본값 10만원
for (int i = 0; i < monthlyData.length; i++) {
final data = monthlyData[i];
barGroups.add(
BarChartGroupData(
x: i,
barRods: [
BarChartRodData(
toY: data['totalExpense'],
gradient: LinearGradient(
colors: [
const Color(0xFF3B82F6).withValues(alpha: 0.7),
const Color(0xFF60A5FA),
],
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
),
width: 18,
borderRadius: BorderRadius.circular(4),
backDrawRodData: BackgroundBarChartRodData(
show: true,
toY: maxAmount + (maxAmount * 0.1),
color: Colors.grey.withValues(alpha: 0.1),
),
),
],
),
);
}
return barGroups;
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController,
curve: const Interval(0.4, 0.9, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText.headline(
text: '월별 지출 현황',
style: const TextStyle(
fontSize: 18,
),
),
const SizedBox(height: 8),
ThemedText.subtitle(
text: '최근 6개월간 추이',
style: const TextStyle(
fontSize: 14,
),
),
const SizedBox(height: 20),
// 바 차트
AspectRatio(
aspectRatio: 1.6,
child: BarChart(
BarChartData(
alignment: BarChartAlignment.spaceAround,
maxY: math.max(
monthlyData.fold<double>(
0,
(max, data) => math.max(
max, data['totalExpense'] as double)) *
1.2,
100000.0 // 최소값 10만원
),
barGroups: _getMonthlyBarGroups(),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: math.max(
monthlyData.fold<double>(
0,
(max, data) => math.max(max,
data['totalExpense'] as double)) /
4,
25000.0 // 최소 간격 2.5만원
),
getDrawingHorizontalLine: (value) {
return FlLine(
color: Colors.grey.withValues(alpha: 0.1),
strokeWidth: 1,
);
},
),
titlesData: FlTitlesData(
show: true,
topTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
getTitlesWidget: (value, meta) {
return Padding(
padding: const EdgeInsets.only(top: 8),
child: ThemedText.caption(
text: monthlyData[value.toInt()]
['monthName'],
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
);
},
),
),
leftTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
rightTitles: const AxisTitles(
sideTitles: SideTitles(showTitles: false),
),
),
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(
enabled: true,
touchTooltipData: BarTouchTooltipData(
tooltipBgColor: Colors.blueGrey.shade800,
tooltipRoundedRadius: 8,
getTooltipItem:
(group, groupIndex, rod, rodIndex) {
return BarTooltipItem(
'${monthlyData[group.x]['monthName']}\n',
const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
children: [
TextSpan(
text: CurrencyUtil.formatTotalAmount(
monthlyData[group.x]['totalExpense']
as double),
style: const TextStyle(
color: Colors.yellow,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
],
);
},
),
),
),
),
),
const SizedBox(height: 16),
Center(
child: ThemedText.caption(
text: '월 구독 지출',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,294 @@
import 'package:flutter/material.dart';
import 'package:fl_chart/fl_chart.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
import 'analysis_badge.dart';
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
class SubscriptionPieChartCard extends StatelessWidget {
final List<SubscriptionModel> subscriptions;
final AnimationController animationController;
final int touchedIndex;
final Function(int) onPieTouch;
const SubscriptionPieChartCard({
super.key,
required this.subscriptions,
required this.animationController,
required this.touchedIndex,
required this.onPieTouch,
});
// 파이 차트 섹션 데이터
List<PieChartSectionData> _getPieSections() {
if (subscriptions.isEmpty) return [];
final colors = [
const Color(0xFF3B82F6),
const Color(0xFF10B981),
const Color(0xFFF59E0B),
const Color(0xFFEF4444),
const Color(0xFF8B5CF6),
const Color(0xFF0EA5E9),
const Color(0xFFEC4899),
];
// 개별 구독의 비율 계산을 위한 값들
List<double> sectionValues = [];
// 각 구독의 원화 환산 금액 또는 원화 금액을 계산
for (var subscription in subscriptions) {
double value = subscription.monthlyCost;
if (subscription.currency == 'USD') {
// USD의 경우 마지막으로 조회된 환율로 대략적인 계산
// (정확한 계산은 비동기로 이루어지므로 UI 표시용으로만 사용)
const rate = 1350.0; // 기본 환율 (실제 값은 API로 별도로 가져옴)
value = value * rate;
}
sectionValues.add(value);
}
// 총합 계산
double sectionsTotal = sectionValues.fold(0, (sum, value) => sum + value);
// 섹션 데이터 생성
return List.generate(subscriptions.length, (i) {
final subscription = subscriptions[i];
final percentage = (sectionValues[i] / sectionsTotal) * 100;
final index = i % colors.length;
final isTouched = touchedIndex == i;
final fontSize = isTouched ? 16.0 : 12.0;
final radius = isTouched ? 105.0 : 100.0;
return PieChartSectionData(
value: sectionValues[i],
title: '${percentage.toStringAsFixed(1)}%',
titleStyle: TextStyle(
fontSize: fontSize,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: const [
Shadow(color: Colors.black26, blurRadius: 2, offset: Offset(0, 1))
],
),
color: colors[index],
radius: radius,
titlePositionPercentageOffset: 0.6,
badgeWidget: isTouched
? AnalysisBadge(
size: 40,
borderColor: colors[index],
subscription: subscription,
)
: null,
badgePositionPercentageOffset: .98,
);
});
}
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController,
curve: const Interval(0.0, 0.7, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: '구독 서비스 비율',
style: const TextStyle(
fontSize: 18,
),
),
FutureBuilder<String>(
future: CurrencyUtil.getExchangeRateInfo(),
builder: (context, snapshot) {
if (snapshot.hasData &&
snapshot.data!.isNotEmpty) {
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0xFFE5F2FF),
borderRadius:
BorderRadius.circular(4),
border: Border.all(
color: const Color(0xFFBFDBFE),
width: 1,
),
),
child: Text(
snapshot.data!,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Color(0xFF3B82F6),
),
),
);
}
return const SizedBox.shrink();
},
),
],
),
const SizedBox(height: 8),
ThemedText.subtitle(
text: '월 지출 기준',
style: const TextStyle(
fontSize: 14,
),
),
const SizedBox(height: 16),
Center(
child: subscriptions.isEmpty
? const SizedBox(
height: 250,
child: Center(
child: ThemedText(
'구독중인 서비스가 없습니다',
style: TextStyle(
fontSize: 16,
),
),
),
)
: SizedBox(
height: 250,
child: PieChart(
PieChartData(
borderData: FlBorderData(show: false),
sectionsSpace: 2,
centerSpaceRadius: 60,
sections: _getPieSections(),
pieTouchData: PieTouchData(
touchCallback: (FlTouchEvent event,
pieTouchResponse) {
if (!event
.isInterestedForInteractions ||
pieTouchResponse == null ||
pieTouchResponse
.touchedSection ==
null) {
onPieTouch(-1);
return;
}
onPieTouch(pieTouchResponse
.touchedSection!
.touchedSectionIndex);
},
),
),
),
),
),
const SizedBox(height: 16),
// 서비스 목록
Column(
children: subscriptions.isEmpty
? []
: List.generate(
subscriptions.length,
(index) {
final subscription =
subscriptions[index];
final color = [
const Color(0xFF3B82F6),
const Color(0xFF10B981),
const Color(0xFFF59E0B),
const Color(0xFFEF4444),
const Color(0xFF8B5CF6),
const Color(0xFF0EA5E9),
const Color(0xFFEC4899),
][index % 7];
return Padding(
padding: const EdgeInsets.only(
bottom: 4.0),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 8),
Expanded(
child: ThemedText(
subscription.serviceName,
style: const TextStyle(
fontSize: 14,
),
overflow:
TextOverflow.ellipsis,
),
),
FutureBuilder<String>(
future: CurrencyUtil
.formatSubscriptionAmount(
subscription),
builder: (context, snapshot) {
if (snapshot.hasData) {
return ThemedText(
snapshot.data!,
style: const TextStyle(
fontSize: 14,
fontWeight:
FontWeight.bold,
),
);
}
return const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
);
},
),
],
),
);
},
),
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../../models/subscription_model.dart';
import '../../services/currency_util.dart';
import '../../utils/haptic_feedback_helper.dart';
import '../../theme/app_colors.dart';
import '../glassmorphism_card.dart';
import '../themed_text.dart';
/// 총 지출 요약을 보여주는 카드 위젯
class TotalExpenseSummaryCard extends StatelessWidget {
final List<SubscriptionModel> subscriptions;
final double totalExpense;
final AnimationController animationController;
const TotalExpenseSummaryCard({
super.key,
required this.subscriptions,
required this.totalExpense,
required this.animationController,
});
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: FadeTransition(
opacity: CurvedAnimation(
parent: animationController,
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
),
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: animationController,
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
)),
child: GlassmorphismCard(
blur: 10,
opacity: 0.1,
borderRadius: 16,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ThemedText.headline(
text: '총 지출 요약',
style: const TextStyle(
fontSize: 18,
),
),
IconButton(
icon: const Icon(Icons.content_copy),
iconSize: 20,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () async {
final totalExpenseText = CurrencyUtil.formatTotalAmount(totalExpense);
await Clipboard.setData(
ClipboardData(text: totalExpenseText));
HapticFeedbackHelper.lightImpact();
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('총 지출액이 복사되었습니다: $totalExpenseText'),
duration: const Duration(seconds: 2),
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
backgroundColor: AppColors.glassBackground.withValues(alpha: 0.3),
margin: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
),
);
},
),
],
),
const SizedBox(height: 8),
ThemedText.subtitle(
text: '월 단위 총액',
style: const TextStyle(
fontSize: 14,
),
),
const SizedBox(height: 16),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: '총 지출',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 4),
ThemedText(
CurrencyUtil.formatTotalAmount(totalExpense),
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
letterSpacing: -0.5,
),
),
],
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
children: [
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.2),
),
),
child: const FaIcon(
FontAwesomeIcons.listCheck,
size: 16,
color: Colors.blue,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: '총 서비스',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
ThemedText(
'${subscriptions.length}',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
const SizedBox(height: 12),
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppColors.glassBackground.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: AppColors.glassBorder.withValues(alpha: 0.2),
),
),
child: const FaIcon(
FontAwesomeIcons.chartLine,
size: 16,
color: Colors.green,
),
),
const SizedBox(width: 12),
Column(
crossAxisAlignment:
CrossAxisAlignment.start,
children: [
ThemedText.caption(
text: '평균 요금',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 2),
ThemedText(
CurrencyUtil.formatTotalAmount(
subscriptions.isEmpty
? 0
: totalExpense / subscriptions.length),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
],
),
],
),
],
),
),
],
),
],
),
),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,311 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
/// 슬라이드 + 페이드 전환
class SlidePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final AxisDirection direction;
SlidePageRoute({
required this.page,
this.direction = AxisDirection.right,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
Offset begin;
switch (direction) {
case AxisDirection.right:
begin = const Offset(-1.0, 0.0);
break;
case AxisDirection.left:
begin = const Offset(1.0, 0.0);
break;
case AxisDirection.up:
begin = const Offset(0.0, 1.0);
break;
case AxisDirection.down:
begin = const Offset(0.0, -1.0);
break;
}
const end = Offset.zero;
const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end).chain(
CurveTween(curve: curve),
);
var offsetAnimation = animation.drive(tween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var fadeAnimation = animation.drive(fadeTween);
return SlideTransition(
position: offsetAnimation,
child: FadeTransition(
opacity: fadeAnimation,
child: child,
),
);
},
);
}
/// 스케일 + 페이드 전환
class ScalePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final Alignment alignment;
ScalePageRoute({
required this.page,
this.alignment = Alignment.center,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 400),
reverseTransitionDuration: const Duration(milliseconds: 400),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.elasticOut;
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var scaleAnimation = animation.drive(scaleTween);
var fadeTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: Curves.easeIn),
);
var fadeAnimation = animation.drive(fadeTween);
return ScaleTransition(
scale: scaleAnimation,
alignment: alignment,
child: FadeTransition(
opacity: fadeAnimation,
child: child,
),
);
},
);
}
/// 회전 + 스케일 전환
class RotatePageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
RotatePageRoute({required this.page})
: super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
const curve = Curves.easeInOut;
var rotateTween = Tween(begin: -0.5, end: 0.0).chain(
CurveTween(curve: curve),
);
var rotateAnimation = animation.drive(rotateTween);
var scaleTween = Tween(begin: 0.0, end: 1.0).chain(
CurveTween(curve: curve),
);
var scaleAnimation = animation.drive(scaleTween);
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateZ(rotateAnimation.value)
..scale(scaleAnimation.value),
child: child,
);
},
);
}
/// 3D 플립 전환
class FlipPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final bool horizontal;
FlipPageRoute({
required this.page,
this.horizontal = true,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 800),
reverseTransitionDuration: const Duration(milliseconds: 800),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
final isAnimatingForward = animation.status == AnimationStatus.forward;
final flipAnimation = Tween(
begin: 0.0,
end: isAnimatingForward ? -math.pi : math.pi,
).animate(CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
));
return AnimatedBuilder(
animation: flipAnimation,
builder: (context, child) {
final isShowingFront = flipAnimation.value.abs() < math.pi / 2;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(horizontal ? flipAnimation.value : 0)
..rotateX(horizontal ? 0 : flipAnimation.value),
child: isShowingFront
? Container()
: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..rotateY(horizontal ? math.pi : 0)
..rotateX(horizontal ? 0 : math.pi),
child: child,
),
);
},
child: child,
);
},
);
}
/// 컨테이너 트랜스폼 (Material Design)
class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final Widget startWidget;
final BorderRadius? borderRadius;
ContainerTransformPageRoute({
required this.page,
required this.startWidget,
this.borderRadius,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return Stack(
children: [
// 배경 페이드
FadeTransition(
opacity: animation,
child: Container(
color: Colors.black.withValues(alpha: 0.3),
),
),
// 컨테이너 확장 애니메이션
AnimatedBuilder(
animation: animation,
builder: (context, _) {
final progress = animation.value;
final scale = 0.5 + (0.5 * progress);
final radius = borderRadius?.topLeft.x ?? 0;
final currentRadius = radius * (1 - progress);
return Transform.scale(
scale: scale,
child: ClipRRect(
borderRadius: BorderRadius.circular(currentRadius),
child: progress < 0.5 ? startWidget : child,
),
);
},
child: child,
),
],
);
},
);
}
/// 커스텀 Hero 애니메이션
class CustomHeroPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final String heroTag;
CustomHeroPageRoute({
required this.page,
required this.heroTag,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 500),
reverseTransitionDuration: const Duration(milliseconds: 500),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: CurvedAnimation(
parent: animation,
curve: const Interval(0.5, 1.0),
),
child: child,
);
},
);
}
/// 공유 축 전환 (Material Design)
class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
final Widget page;
final SharedAxisTransitionType transitionType;
SharedAxisPageRoute({
required this.page,
required this.transitionType,
}) : super(
pageBuilder: (context, animation, secondaryAnimation) => page,
transitionDuration: const Duration(milliseconds: 300),
reverseTransitionDuration: const Duration(milliseconds: 300),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
late final Offset begin;
late final Offset end;
switch (transitionType) {
case SharedAxisTransitionType.horizontal:
begin = const Offset(1.0, 0.0);
end = Offset.zero;
break;
case SharedAxisTransitionType.vertical:
begin = const Offset(0.0, 1.0);
end = Offset.zero;
break;
case SharedAxisTransitionType.scaled:
begin = Offset.zero;
end = Offset.zero;
break;
}
final slideTween = Tween(begin: begin, end: end);
final fadeTween = Tween(begin: 0.0, end: 1.0);
final scaleTween = transitionType == SharedAxisTransitionType.scaled
? Tween(begin: 0.8, end: 1.0)
: Tween(begin: 1.0, end: 1.0);
final slideAnimation = animation.drive(slideTween);
final fadeAnimation = animation.drive(fadeTween);
final scaleAnimation = animation.drive(scaleTween);
return SlideTransition(
position: slideAnimation,
child: FadeTransition(
opacity: fadeAnimation,
child: ScaleTransition(
scale: scaleAnimation,
child: child,
),
),
);
},
);
}
enum SharedAxisTransitionType {
horizontal,
vertical,
scaled,
}

View File

@@ -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),

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../screens/main_screen.dart';
import '../screens/analysis_screen.dart';
import '../screens/add_subscription_screen.dart';
import '../screens/detail_screen.dart';
import '../screens/settings_screen.dart';
import '../screens/sms_scan_screen.dart';
import '../screens/category_management_screen.dart';
import '../screens/app_lock_screen.dart';
import '../models/subscription_model.dart';
import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart';
import 'animated_page_transitions.dart';
/// 앱 전체의 네비게이션을 관리하는 클래스
class AppNavigator {
// NavigationProvider를 사용하여 상태를 관리하므로 더 이상 싱글톤 패턴이 필요하지 않음
/// 홈으로 네비게이션
static Future<void> toHome(BuildContext context) async {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.clearHistoryAndGoHome();
await Navigator.of(context).pushNamedAndRemoveUntil(
AppRoutes.main,
(route) => false,
);
}
/// 분석 화면으로 네비게이션
static Future<void> toAnalysis(BuildContext context) async {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(1);
await Navigator.of(context).pushNamed(AppRoutes.analysis);
}
/// 구독 추가 화면으로 네비게이션
static Future<void> toAddSubscription(BuildContext context) async {
HapticFeedback.mediumImpact();
await Navigator.of(context).pushNamed(AppRoutes.addSubscription);
}
/// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
HapticFeedback.lightImpact();
await Navigator.of(context).pushNamed(
AppRoutes.subscriptionDetail,
arguments: subscription,
);
}
/// SMS 스캔 화면으로 네비게이션
static Future<void> toSmsScan(BuildContext context) async {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(3);
await Navigator.of(context).pushNamed(AppRoutes.smsScanner);
}
/// 설정 화면으로 네비게이션
static Future<void> toSettings(BuildContext context) async {
HapticFeedback.lightImpact();
final navigationProvider = context.read<NavigationProvider>();
navigationProvider.updateCurrentIndex(4);
await Navigator.of(context).pushNamed(AppRoutes.settings);
}
/// 카테고리 관리 화면으로 네비게이션
static Future<void> toCategoryManagement(BuildContext context) async {
HapticFeedback.lightImpact();
await Navigator.of(context).push(
SlidePageRoute(
page: const CategoryManagementScreen(),
direction: AxisDirection.up,
),
);
}
/// 앱 잠금 화면으로 네비게이션
static Future<void> toAppLock(BuildContext context) async {
await Navigator.of(context).push(
MaterialPageRoute(
builder: (context) => const AppLockScreen(),
fullscreenDialog: true,
),
);
}
/// 뒤로가기 처리
static Future<bool> handleBackButton(BuildContext context) async {
final navigator = Navigator.of(context);
final navigationProvider = context.read<NavigationProvider>();
// 네비게이션 스택이 있으면 팝
if (navigator.canPop()) {
HapticFeedback.lightImpact();
// NavigationProvider의 히스토리를 사용하여 이전 인덱스로 복원
if (navigationProvider.canPop()) {
navigationProvider.pop();
}
navigator.pop();
return false;
}
// 앱 종료 확인
final shouldExit = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('앱 종료'),
content: const Text('SubManager를 종료하시겠습니까?'),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('취소'),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
child: const Text('종료'),
),
],
),
);
return shouldExit ?? false;
}
/// 플로팅 네비게이션 바 탭 처리
static void handleFloatingNavTap(BuildContext context, int index) {
final navigationProvider = context.read<NavigationProvider>();
final currentIndex = navigationProvider.currentIndex;
// 같은 탭을 다시 탭하면 아무 동작 안 함
if (currentIndex == index) {
return;
}
// 현재 화면이 메인이 아니면 먼저 메인으로 돌아가기
if (Navigator.of(context).canPop()) {
Navigator.of(context).popUntil((route) => route.isFirst);
}
// 선택된 인덱스에 따라 네비게이션
switch (index) {
case 0: // 홈
navigationProvider.updateCurrentIndex(0);
break;
case 1: // 분석
toAnalysis(context);
break;
case 2: // 추가
toAddSubscription(context);
break;
case 3: // SMS
toSmsScan(context);
break;
case 4: // 설정
toSettings(context);
break;
}
}
}
/// 네비게이션 관찰자 (디버깅용)
class AppNavigationObserver extends NavigatorObserver {
@override
void didPush(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPush(route, previousRoute);
debugPrint('Navigation: Push ${route.settings.name}');
}
@override
void didPop(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didPop(route, previousRoute);
debugPrint('Navigation: Pop ${route.settings.name}');
}
@override
void didRemove(Route<dynamic> route, Route<dynamic>? previousRoute) {
super.didRemove(route, previousRoute);
debugPrint('Navigation: Remove ${route.settings.name}');
}
@override
void didReplace({Route<dynamic>? newRoute, Route<dynamic>? oldRoute}) {
super.didReplace(newRoute: newRoute, oldRoute: oldRoute);
debugPrint('Navigation: Replace ${oldRoute?.settings.name} with ${newRoute?.settings.name}');
}
}

View File

@@ -0,0 +1,315 @@
import 'package:flutter/material.dart';
import 'package:cached_network_image/cached_network_image.dart';
import '../theme/app_colors.dart';
import 'skeleton_loading.dart';
/// 최적화된 캐시 네트워크 이미지 위젯
class OptimizedCachedNetworkImage extends StatelessWidget {
final String imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
final Duration fadeInDuration;
final Duration fadeOutDuration;
final Widget? placeholder;
final Widget? errorWidget;
final Map<String, String>? httpHeaders;
final bool enableMemoryCache;
final bool enableDiskCache;
final int? maxWidth;
final int? maxHeight;
const OptimizedCachedNetworkImage({
super.key,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
this.fadeInDuration = const Duration(milliseconds: 300),
this.fadeOutDuration = const Duration(milliseconds: 300),
this.placeholder,
this.errorWidget,
this.httpHeaders,
this.enableMemoryCache = true,
this.enableDiskCache = true,
this.maxWidth,
this.maxHeight,
});
@override
Widget build(BuildContext context) {
// 성능 최적화를 위한 이미지 크기 계산
final devicePixelRatio = MediaQuery.of(context).devicePixelRatio;
final optimalWidth = maxWidth ??
(width != null ? (width! * devicePixelRatio).round() : null);
final optimalHeight = maxHeight ??
(height != null ? (height! * devicePixelRatio).round() : null);
Widget image = CachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
fadeInDuration: fadeInDuration,
fadeOutDuration: fadeOutDuration,
httpHeaders: httpHeaders,
memCacheWidth: optimalWidth,
memCacheHeight: optimalHeight,
maxWidthDiskCache: optimalWidth,
maxHeightDiskCache: optimalHeight,
placeholder: (context, url) => placeholder ?? _buildDefaultPlaceholder(),
errorWidget: (context, url, error) =>
errorWidget ?? _buildDefaultErrorWidget(),
imageBuilder: (context, imageProvider) {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
borderRadius: borderRadius,
image: DecorationImage(
image: imageProvider,
fit: fit,
),
),
);
},
);
if (borderRadius != null) {
return ClipRRect(
borderRadius: borderRadius!,
child: image,
);
}
return image;
}
Widget _buildDefaultPlaceholder() {
return SkeletonLoading(
width: width,
height: height,
borderRadius: borderRadius?.topLeft.x ?? 0,
);
}
Widget _buildDefaultErrorWidget() {
return Container(
width: width,
height: height,
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius: borderRadius,
),
child: const Icon(
Icons.broken_image_outlined,
color: AppColors.textMuted,
size: 24,
),
);
}
}
/// 프로그레시브 이미지 로더 (저화질 → 고화질)
class ProgressiveNetworkImage extends StatelessWidget {
final String thumbnailUrl;
final String imageUrl;
final double? width;
final double? height;
final BoxFit fit;
final BorderRadius? borderRadius;
const ProgressiveNetworkImage({
super.key,
required this.thumbnailUrl,
required this.imageUrl,
this.width,
this.height,
this.fit = BoxFit.cover,
this.borderRadius,
});
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.passthrough,
children: [
// 썸네일 (저화질)
OptimizedCachedNetworkImage(
imageUrl: thumbnailUrl,
width: width,
height: height,
fit: fit,
borderRadius: borderRadius,
fadeInDuration: Duration.zero,
),
// 원본 이미지 (고화질)
OptimizedCachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
borderRadius: borderRadius,
),
],
);
}
}
/// 이미지 갤러리 위젯 (메모리 효율적)
class OptimizedImageGallery extends StatefulWidget {
final List<String> imageUrls;
final double itemHeight;
final double spacing;
final int crossAxisCount;
final void Function(int)? onImageTap;
const OptimizedImageGallery({
super.key,
required this.imageUrls,
this.itemHeight = 120,
this.spacing = 8,
this.crossAxisCount = 3,
this.onImageTap,
});
@override
State<OptimizedImageGallery> createState() => _OptimizedImageGalleryState();
}
class _OptimizedImageGalleryState extends State<OptimizedImageGallery> {
final ScrollController _scrollController = ScrollController();
final Set<int> _visibleIndices = {};
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
// 초기 보이는 아이템 계산
WidgetsBinding.instance.addPostFrameCallback((_) {
_calculateVisibleIndices();
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
_calculateVisibleIndices();
}
void _calculateVisibleIndices() {
if (!mounted) return;
final viewportHeight = context.size?.height ?? 0;
final scrollOffset = _scrollController.offset;
final itemHeight = widget.itemHeight + widget.spacing;
final itemsPerRow = widget.crossAxisCount;
final firstVisibleRow = (scrollOffset / itemHeight).floor();
final lastVisibleRow = ((scrollOffset + viewportHeight) / itemHeight).ceil();
final newVisibleIndices = <int>{};
for (int row = firstVisibleRow; row <= lastVisibleRow; row++) {
for (int col = 0; col < itemsPerRow; col++) {
final index = row * itemsPerRow + col;
if (index < widget.imageUrls.length) {
newVisibleIndices.add(index);
}
}
}
if (!setEquals(_visibleIndices, newVisibleIndices)) {
setState(() {
_visibleIndices.clear();
_visibleIndices.addAll(newVisibleIndices);
});
}
}
@override
Widget build(BuildContext context) {
return GridView.builder(
controller: _scrollController,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount,
childAspectRatio: 1.0,
crossAxisSpacing: widget.spacing,
mainAxisSpacing: widget.spacing,
),
itemCount: widget.imageUrls.length,
itemBuilder: (context, index) {
// 보이는 영역의 이미지만 로드
if (_visibleIndices.contains(index) ||
(index >= _visibleIndices.first - widget.crossAxisCount &&
index <= _visibleIndices.last + widget.crossAxisCount)) {
return GestureDetector(
onTap: () => widget.onImageTap?.call(index),
child: OptimizedCachedNetworkImage(
imageUrl: widget.imageUrls[index],
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(8),
),
);
}
// 보이지 않는 영역은 플레이스홀더
return Container(
decoration: BoxDecoration(
color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(8),
),
);
},
);
}
bool setEquals(Set<int> a, Set<int> b) {
if (a.length != b.length) return false;
for (final item in a) {
if (!b.contains(item)) return false;
}
return true;
}
}
/// 히어로 애니메이션이 적용된 이미지
class HeroNetworkImage extends StatelessWidget {
final String imageUrl;
final String heroTag;
final double? width;
final double? height;
final BoxFit fit;
final VoidCallback? onTap;
const HeroNetworkImage({
super.key,
required this.imageUrl,
required this.heroTag,
this.width,
this.height,
this.fit = BoxFit.cover,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Hero(
tag: heroTag,
child: OptimizedCachedNetworkImage(
imageUrl: imageUrl,
width: width,
height: height,
fit: fit,
),
),
);
}
}

View File

@@ -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,
),
),
),

View File

@@ -0,0 +1,268 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;
import '../theme/app_colors.dart';
import '../utils/haptic_feedback_helper.dart';
import 'glassmorphism_card.dart';
class ExpandableFab extends StatefulWidget {
final List<FabAction> actions;
final double distance;
const ExpandableFab({
super.key,
required this.actions,
this.distance = 100.0,
});
@override
State<ExpandableFab> createState() => _ExpandableFabState();
}
class _ExpandableFabState extends State<ExpandableFab>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _expandAnimation;
late Animation<double> _rotateAnimation;
bool _isExpanded = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_expandAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.easeOutBack,
reverseCurve: Curves.easeInBack,
);
_rotateAnimation = Tween<double>(
begin: 0.0,
end: math.pi / 4,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _toggle() {
setState(() {
_isExpanded = !_isExpanded;
});
if (_isExpanded) {
HapticFeedbackHelper.mediumImpact();
_controller.forward();
} else {
HapticFeedbackHelper.lightImpact();
_controller.reverse();
}
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.bottomRight,
children: [
// 배경 오버레이 (확장 시)
if (_isExpanded)
GestureDetector(
onTap: _toggle,
child: AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
return Container(
color: Colors.black.withValues(alpha: 0.3 * _expandAnimation.value),
);
},
),
),
// 액션 버튼들
...widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
return AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
final distance = widget.distance * _expandAnimation.value;
final x = distance * math.cos(angle);
final y = distance * math.sin(angle);
return Transform.translate(
offset: Offset(-x, -y),
child: ScaleTransition(
scale: _expandAnimation,
child: FloatingActionButton.small(
heroTag: 'fab_action_$index',
onPressed: _isExpanded
? () {
HapticFeedbackHelper.lightImpact();
_toggle();
action.onPressed();
}
: null,
backgroundColor: action.color ?? AppColors.primaryColor,
child: Icon(
action.icon,
size: 20,
color: Colors.white,
),
),
),
);
},
);
}),
// 메인 FAB
AnimatedBuilder(
animation: _rotateAnimation,
builder: (context, child) {
return Transform.rotate(
angle: _rotateAnimation.value,
child: FloatingActionButton(
onPressed: _toggle,
backgroundColor: AppColors.primaryColor,
child: Icon(
_isExpanded ? Icons.close : Icons.add,
size: 28,
color: Colors.white,
),
),
);
},
),
// 라벨 표시
if (_isExpanded)
...widget.actions.asMap().entries.map((entry) {
final index = entry.key;
final action = entry.value;
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
return AnimatedBuilder(
animation: _expandAnimation,
builder: (context, child) {
final distance = widget.distance * _expandAnimation.value;
final x = distance * math.cos(angle);
final y = distance * math.sin(angle);
return Transform.translate(
offset: Offset(-x - 80, -y),
child: FadeTransition(
opacity: _expandAnimation,
child: GlassmorphismCard(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
borderRadius: 8,
blur: 10,
child: Text(
action.label,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
),
),
);
},
);
}),
],
);
}
}
class FabAction {
final IconData icon;
final String label;
final VoidCallback onPressed;
final Color? color;
const FabAction({
required this.icon,
required this.label,
required this.onPressed,
this.color,
});
}
// 드래그 가능한 FAB
class DraggableFab extends StatefulWidget {
final Widget child;
final EdgeInsets? padding;
const DraggableFab({
super.key,
required this.child,
this.padding,
});
@override
State<DraggableFab> createState() => _DraggableFabState();
}
class _DraggableFabState extends State<DraggableFab> {
Offset _position = const Offset(20, 20);
bool _isDragging = false;
@override
Widget build(BuildContext context) {
final screenSize = MediaQuery.of(context).size;
final padding = widget.padding ?? const EdgeInsets.all(20);
return Stack(
children: [
Positioned(
right: _position.dx,
bottom: _position.dy,
child: GestureDetector(
onPanStart: (_) {
setState(() => _isDragging = true);
HapticFeedbackHelper.lightImpact();
},
onPanUpdate: (details) {
setState(() {
_position = Offset(
(_position.dx - details.delta.dx).clamp(
padding.right,
screenSize.width - 100 - padding.left,
),
(_position.dy - details.delta.dy).clamp(
padding.bottom,
screenSize.height - 200 - padding.top,
),
);
});
},
onPanEnd: (_) {
setState(() => _isDragging = false);
HapticFeedbackHelper.lightImpact();
},
child: AnimatedScale(
duration: const Duration(milliseconds: 150),
scale: _isDragging ? 0.9 : 1.0,
child: widget.child,
),
),
),
],
);
}
}

View File

@@ -0,0 +1,310 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
import 'glassmorphism_card.dart';
class FloatingNavigationBar extends StatefulWidget {
final int selectedIndex;
final Function(int) onItemTapped;
final bool isVisible;
const FloatingNavigationBar({
super.key,
required this.selectedIndex,
required this.onItemTapped,
this.isVisible = true,
});
@override
State<FloatingNavigationBar> createState() => _FloatingNavigationBarState();
}
class _FloatingNavigationBarState extends State<FloatingNavigationBar>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
);
if (widget.isVisible) {
_controller.forward();
}
}
@override
void didUpdateWidget(FloatingNavigationBar oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isVisible != oldWidget.isVisible) {
if (widget.isVisible) {
_controller.forward();
} else {
_controller.reverse();
}
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Positioned(
bottom: 20,
left: 20,
right: 20,
child: Transform.translate(
offset: Offset(0, 100 * (1 - _animation.value)),
child: Opacity(
opacity: _animation.value,
child: GlassmorphismCard(
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
borderRadius: 24,
blur: 10.0,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_NavigationItem(
icon: Icons.home_rounded,
label: '',
isSelected: widget.selectedIndex == 0,
onTap: () => _onItemTapped(0),
),
_NavigationItem(
icon: Icons.analytics_rounded,
label: '분석',
isSelected: widget.selectedIndex == 1,
onTap: () => _onItemTapped(1),
),
_AddButton(
onTap: () => _onItemTapped(2),
),
_NavigationItem(
icon: Icons.qr_code_scanner_rounded,
label: 'SMS',
isSelected: widget.selectedIndex == 3,
onTap: () => _onItemTapped(3),
),
_NavigationItem(
icon: Icons.settings_rounded,
label: '설정',
isSelected: widget.selectedIndex == 4,
onTap: () => _onItemTapped(4),
),
],
),
),
),
),
);
},
);
}
void _onItemTapped(int index) {
HapticFeedback.lightImpact();
widget.onItemTapped(index);
}
}
class _NavigationItem extends StatelessWidget {
final IconData icon;
final String label;
final bool isSelected;
final VoidCallback onTap;
const _NavigationItem({
required this.icon,
required this.label,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: isSelected
? const Color(0xFF14B8A6).withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
child: Icon(
icon,
color: isSelected
? const Color(0xFF14B8A6)
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
size: isSelected ? 26 : 24,
),
),
const SizedBox(height: 4),
AnimatedDefaultTextStyle(
duration: const Duration(milliseconds: 200),
style: TextStyle(
fontSize: 11,
fontWeight: isSelected ? FontWeight.w600 : FontWeight.w500,
color: isSelected
? const Color(0xFF14B8A6)
: (isDarkMode ? Colors.white70 : AppColors.textSecondary),
),
child: Text(label),
),
],
),
),
);
}
}
class _AddButton extends StatefulWidget {
final VoidCallback onTap;
const _AddButton({required this.onTap});
@override
State<_AddButton> createState() => _AddButtonState();
}
class _AddButtonState extends State<_AddButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 150),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.9,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: (_) => _controller.forward(),
onTapUp: (_) {
_controller.reverse();
widget.onTap();
},
onTapCancel: () => _controller.reverse(),
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
width: 56,
height: 56,
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: AppColors.blueGradient,
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: AppColors.primaryColor.withValues(alpha: 0.3),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Icon(
Icons.add_rounded,
color: Colors.white,
size: 28,
),
),
);
},
),
);
}
}
// 스크롤 감지를 위한 유틸리티 클래스
class FloatingNavBarScrollController {
final ScrollController scrollController;
final VoidCallback onHide;
final VoidCallback onShow;
double _lastScrollPosition = 0;
bool _isVisible = true;
FloatingNavBarScrollController({
required this.scrollController,
required this.onHide,
required this.onShow,
}) {
scrollController.addListener(_handleScroll);
}
void _handleScroll() {
final currentScroll = scrollController.position.pixels;
if (currentScroll > _lastScrollPosition && currentScroll > 50) {
// 스크롤 다운
if (_isVisible) {
_isVisible = false;
onHide();
}
} else if (currentScroll < _lastScrollPosition - 5) {
// 스크롤 업
if (!_isVisible) {
_isVisible = true;
onShow();
}
}
_lastScrollPosition = currentScroll;
}
void dispose() {
scrollController.removeListener(_handleScroll);
}
}

View File

@@ -0,0 +1,304 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
import 'themed_text.dart';
/// 글래스모피즘 효과가 적용된 통일된 앱바
class GlassmorphicAppBar extends StatelessWidget implements PreferredSizeWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
final bool automaticallyImplyLeading;
final double elevation;
final Color? backgroundColor;
final double blur;
final double opacity;
final PreferredSizeWidget? bottom;
final bool centerTitle;
final double? titleSpacing;
final VoidCallback? onBackPressed;
const GlassmorphicAppBar({
super.key,
required this.title,
this.actions,
this.leading,
this.automaticallyImplyLeading = true,
this.elevation = 0,
this.backgroundColor,
this.blur = 20,
this.opacity = 0.1,
this.bottom,
this.centerTitle = false,
this.titleSpacing,
this.onBackPressed,
});
@override
Size get preferredSize => Size.fromHeight(
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5);
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final canPop = Navigator.of(context).canPop();
return ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
(backgroundColor ?? (isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground)).withValues(alpha: opacity),
(backgroundColor ?? (isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface)).withValues(alpha: opacity * 0.8),
],
),
border: Border(
bottom: BorderSide(
color: isDarkMode
? AppColors.glassBorderDark.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.3),
width: 0.5,
),
),
),
child: SafeArea(
bottom: false,
child: ClipRect(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: SizedBox(
height: kToolbarHeight,
child: NavigationToolbar(
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
? _buildBackButton(context)
: null),
middle: _buildTitle(context),
trailing: actions != null
? Row(
mainAxisSize: MainAxisSize.min,
children: actions!,
)
: null,
centerMiddle: centerTitle,
middleSpacing: titleSpacing ?? NavigationToolbar.kMiddleSpacing,
),
),
),
if (bottom != null) bottom!,
],
),
),
),
),
),
);
}
Widget _buildBackButton(BuildContext context) {
return IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ?? () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: '뒤로가기',
color: ThemedText.getContrastColor(context),
);
}
Widget _buildTitle(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ThemedText.headline(
text: title,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
),
);
}
/// 투명 스타일 팩토리
static GlassmorphicAppBar transparent({
required String title,
List<Widget>? actions,
Widget? leading,
VoidCallback? onBackPressed,
}) {
return GlassmorphicAppBar(
title: title,
actions: actions,
leading: leading,
blur: 30,
opacity: 0.05,
onBackPressed: onBackPressed,
);
}
/// 반투명 스타일 팩토리
static GlassmorphicAppBar translucent({
required String title,
List<Widget>? actions,
Widget? leading,
VoidCallback? onBackPressed,
}) {
return GlassmorphicAppBar(
title: title,
actions: actions,
leading: leading,
blur: 20,
opacity: 0.15,
onBackPressed: onBackPressed,
);
}
}
/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함)
class GlassmorphicSliverAppBar extends StatelessWidget {
final String title;
final List<Widget>? actions;
final Widget? leading;
final double expandedHeight;
final bool floating;
final bool pinned;
final bool snap;
final Widget? flexibleSpace;
final double blur;
final double opacity;
final bool automaticallyImplyLeading;
final VoidCallback? onBackPressed;
final bool centerTitle;
const GlassmorphicSliverAppBar({
super.key,
required this.title,
this.actions,
this.leading,
this.expandedHeight = kToolbarHeight,
this.floating = false,
this.pinned = true,
this.snap = false,
this.flexibleSpace,
this.blur = 20,
this.opacity = 0.1,
this.automaticallyImplyLeading = true,
this.onBackPressed,
this.centerTitle = false,
});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final canPop = Navigator.of(context).canPop();
return SliverAppBar(
expandedHeight: expandedHeight,
floating: floating,
pinned: pinned,
snap: snap,
backgroundColor: Colors.transparent,
elevation: 0,
automaticallyImplyLeading: false,
leading: leading ?? (automaticallyImplyLeading && (canPop || onBackPressed != null)
? IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: onBackPressed ?? () {
HapticFeedback.lightImpact();
Navigator.of(context).pop();
},
splashRadius: 24,
tooltip: '뒤로가기',
)
: null),
actions: actions,
centerTitle: centerTitle,
flexibleSpace: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
final top = constraints.biggest.height;
final isCollapsed = top <= kToolbarHeight + MediaQuery.of(context).padding.top;
return FlexibleSpaceBar(
title: isCollapsed
? ThemedText.headline(
text: title,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
letterSpacing: -0.2,
),
)
: null,
centerTitle: centerTitle,
titlePadding: const EdgeInsets.only(left: 16, bottom: 16, right: 16),
background: Stack(
fit: StackFit.expand,
children: [
// 글래스모피즘 배경
ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
(isDarkMode
? AppColors.glassBackgroundDark
: AppColors.glassBackground).withValues(alpha: opacity),
(isDarkMode
? AppColors.glassSurfaceDark
: AppColors.glassSurface).withValues(alpha: opacity * 0.8),
],
),
border: Border(
bottom: BorderSide(
color: isDarkMode
? AppColors.glassBorderDark.withValues(alpha: 0.3)
: AppColors.glassBorder.withValues(alpha: 0.3),
width: 0.5,
),
),
),
),
),
),
// 확장 상태에서만 보이는 타이틀
if (!isCollapsed)
Positioned(
left: 16,
right: 16,
bottom: 16,
child: ThemedText.headline(
text: title,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.w700,
letterSpacing: -0.5,
),
),
),
// 커스텀 flexibleSpace가 있으면 추가
if (flexibleSpace != null) flexibleSpace!,
],
),
);
},
),
);
}
}

View File

@@ -0,0 +1,314 @@
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'dart:math' as math;
import '../theme/app_colors.dart';
import 'glassmorphic_app_bar.dart';
import 'floating_navigation_bar.dart';
/// 글래스모피즘 디자인이 적용된 통일된 스캐폴드
class GlassmorphicScaffold extends StatefulWidget {
final PreferredSizeWidget? appBar;
final Widget body;
final Widget? floatingActionButton;
final FloatingActionButtonLocation? floatingActionButtonLocation;
final List<Color>? backgroundGradient;
final bool extendBodyBehindAppBar;
final bool extendBody;
final Widget? bottomNavigationBar;
final bool useFloatingNavBar;
final int? floatingNavBarIndex;
final Function(int)? onFloatingNavBarTapped;
final bool resizeToAvoidBottomInset;
final Widget? drawer;
final Widget? endDrawer;
final Color? backgroundColor;
final bool enableParticles;
final bool enableWaveAnimation;
const GlassmorphicScaffold({
super.key,
this.appBar,
required this.body,
this.floatingActionButton,
this.floatingActionButtonLocation,
this.backgroundGradient,
this.extendBodyBehindAppBar = true,
this.extendBody = true,
this.bottomNavigationBar,
this.useFloatingNavBar = false,
this.floatingNavBarIndex,
this.onFloatingNavBarTapped,
this.resizeToAvoidBottomInset = true,
this.drawer,
this.endDrawer,
this.backgroundColor,
this.enableParticles = false,
this.enableWaveAnimation = false,
});
@override
State<GlassmorphicScaffold> createState() => _GlassmorphicScaffoldState();
}
class _GlassmorphicScaffoldState extends State<GlassmorphicScaffold>
with TickerProviderStateMixin {
late AnimationController _particleController;
late AnimationController _waveController;
ScrollController? _scrollController;
bool _isFloatingNavBarVisible = true;
@override
void initState() {
super.initState();
_particleController = AnimationController(
duration: const Duration(seconds: 20),
vsync: this,
)..repeat();
_waveController = AnimationController(
duration: const Duration(seconds: 10),
vsync: this,
)..repeat();
if (widget.useFloatingNavBar) {
_scrollController = ScrollController();
_setupScrollListener();
}
}
void _setupScrollListener() {
_scrollController?.addListener(() {
final currentScroll = _scrollController!.position.pixels;
final maxScroll = _scrollController!.position.maxScrollExtent;
// 스크롤 방향에 따라 플로팅 네비게이션 바 표시/숨김
if (currentScroll > 50 && _scrollController!.position.userScrollDirection == ScrollDirection.reverse) {
if (_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = false);
}
} else if (_scrollController!.position.userScrollDirection == ScrollDirection.forward) {
if (!_isFloatingNavBarVisible) {
setState(() => _isFloatingNavBarVisible = true);
}
}
});
}
@override
void dispose() {
_particleController.dispose();
_waveController.dispose();
_scrollController?.dispose();
super.dispose();
}
List<Color> _getBackgroundGradient() {
if (widget.backgroundGradient != null) {
return widget.backgroundGradient!;
}
// 시간대별 기본 그라디언트
final hour = DateTime.now().hour;
if (hour >= 6 && hour < 10) {
return AppColors.morningGradient;
} else if (hour >= 10 && hour < 17) {
return AppColors.dayGradient;
} else if (hour >= 17 && hour < 20) {
return AppColors.eveningGradient;
} else {
return AppColors.nightGradient;
}
}
@override
Widget build(BuildContext context) {
final backgroundGradient = _getBackgroundGradient();
return Stack(
children: [
// 배경 그라디언트
_buildBackground(backgroundGradient),
// 파티클 효과 (선택적)
if (widget.enableParticles) _buildParticles(),
// 웨이브 애니메이션 (선택적)
if (widget.enableWaveAnimation) _buildWaveAnimation(),
// 메인 스캐폴드
Scaffold(
backgroundColor: widget.backgroundColor ?? Colors.transparent,
appBar: widget.appBar,
body: widget.body,
floatingActionButton: widget.floatingActionButton,
floatingActionButtonLocation: widget.floatingActionButtonLocation,
bottomNavigationBar: widget.bottomNavigationBar,
extendBodyBehindAppBar: widget.extendBodyBehindAppBar,
extendBody: widget.extendBody,
resizeToAvoidBottomInset: widget.resizeToAvoidBottomInset,
drawer: widget.drawer,
endDrawer: widget.endDrawer,
),
// 플로팅 네비게이션 바 (선택적)
if (widget.useFloatingNavBar && widget.floatingNavBarIndex != null)
FloatingNavigationBar(
selectedIndex: widget.floatingNavBarIndex!,
isVisible: _isFloatingNavBarVisible,
onItemTapped: widget.onFloatingNavBarTapped ?? (_) {},
),
],
);
}
Widget _buildBackground(List<Color> gradientColors) {
return Positioned.fill(
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: gradientColors.map((color) => color.withValues(alpha: 0.1)).toList(),
),
),
),
);
}
Widget _buildParticles() {
return Positioned.fill(
child: AnimatedBuilder(
animation: _particleController,
builder: (context, child) {
return CustomPaint(
painter: ParticlePainter(
animation: _particleController,
particleCount: 30,
),
);
},
),
);
}
Widget _buildWaveAnimation() {
return Positioned(
bottom: 0,
left: 0,
right: 0,
height: 200,
child: AnimatedBuilder(
animation: _waveController,
builder: (context, child) {
return CustomPaint(
painter: WavePainter(
animation: _waveController,
waveColor: AppColors.primaryColor.withValues(alpha: 0.1),
),
);
},
),
);
}
}
/// 파티클 페인터
class ParticlePainter extends CustomPainter {
final Animation<double> animation;
final int particleCount;
final List<Particle> particles = [];
ParticlePainter({
required this.animation,
this.particleCount = 50,
}) : super(repaint: animation) {
_initParticles();
}
void _initParticles() {
final random = math.Random();
for (int i = 0; i < particleCount; i++) {
particles.add(Particle(
x: random.nextDouble(),
y: random.nextDouble(),
size: random.nextDouble() * 3 + 1,
speed: random.nextDouble() * 0.5 + 0.1,
opacity: random.nextDouble() * 0.5 + 0.1,
));
}
}
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..style = PaintingStyle.fill;
for (final particle in particles) {
final progress = animation.value;
final y = (particle.y + progress * particle.speed) % 1.0;
paint.color = Colors.white.withValues(alpha: particle.opacity);
canvas.drawCircle(
Offset(particle.x * size.width, y * size.height),
particle.size,
paint,
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// 웨이브 페인터
class WavePainter extends CustomPainter {
final Animation<double> animation;
final Color waveColor;
WavePainter({
required this.animation,
required this.waveColor,
}) : super(repaint: animation);
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = waveColor
..style = PaintingStyle.fill;
final path = Path();
final progress = animation.value;
path.moveTo(0, size.height);
for (double x = 0; x <= size.width; x++) {
final y = math.sin((x / size.width * 2 * math.pi) + (progress * 2 * math.pi)) * 20 +
size.height * 0.5;
path.lineTo(x, y);
}
path.lineTo(size.width, size.height);
path.close();
canvas.drawPath(path, paint);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// 파티클 데이터 클래스
class Particle {
final double x;
final double y;
final double size;
final double speed;
final double opacity;
Particle({
required this.x,
required this.y,
required this.size,
required this.speed,
required this.opacity,
});
}

View File

@@ -0,0 +1,210 @@
import 'package:flutter/material.dart';
import 'dart:ui';
import '../theme/app_colors.dart';
class GlassmorphismCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final double borderRadius;
final double blur;
final double opacity;
final Color? backgroundColor;
final Gradient? gradient;
final Border? border;
final List<BoxShadow>? boxShadow;
final VoidCallback? onTap;
const GlassmorphismCard({
super.key,
required this.child,
this.padding,
this.margin,
this.width,
this.height,
this.borderRadius = 16.0,
this.blur = 10.0,
this.opacity = 0.1,
this.backgroundColor,
this.gradient,
this.border,
this.boxShadow,
this.onTap,
});
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
return Container(
width: width,
height: height,
margin: margin,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(borderRadius),
child: ClipRRect(
borderRadius: BorderRadius.circular(borderRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Container(
padding: padding,
decoration: BoxDecoration(
color: backgroundColor ?? (isDarkMode
? AppColors.glassCardDark
: AppColors.glassCard),
gradient: gradient ?? LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: isDarkMode
? AppColors.glassGradientDark
: AppColors.glassGradient,
),
borderRadius: BorderRadius.circular(borderRadius),
border: border ?? Border.all(
color: isDarkMode
? AppColors.glassBorderDark
: AppColors.glassBorder,
width: 1.5,
),
boxShadow: boxShadow ?? [
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
spreadRadius: -5,
offset: const Offset(0, 10),
),
],
),
child: child,
),
),
),
),
),
);
}
}
// 애니메이션이 적용된 글래스모피즘 카드
class AnimatedGlassmorphismCard extends StatefulWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final double? width;
final double? height;
final double borderRadius;
final double blur;
final double opacity;
final Duration animationDuration;
final VoidCallback? onTap;
const AnimatedGlassmorphismCard({
super.key,
required this.child,
this.padding,
this.margin,
this.width,
this.height,
this.borderRadius = 16.0,
this.blur = 10.0,
this.opacity = 0.1,
this.animationDuration = const Duration(milliseconds: 200),
this.onTap,
});
@override
State<AnimatedGlassmorphismCard> createState() => _AnimatedGlassmorphismCardState();
}
class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
late Animation<double> _blurAnimation;
bool _isPressed = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.98,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
_blurAnimation = Tween<double>(
begin: widget.blur,
end: widget.blur * 1.5,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
setState(() {
_isPressed = true;
});
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
setState(() {
_isPressed = false;
});
_controller.reverse();
}
void _handleTapCancel() {
setState(() {
_isPressed = false;
});
_controller.reverse();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
onTap: widget.onTap,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: GlassmorphismCard(
padding: widget.padding,
margin: widget.margin,
width: widget.width,
height: widget.height,
borderRadius: widget.borderRadius,
blur: _blurAnimation.value,
opacity: widget.opacity,
child: widget.child,
),
);
},
),
);
}
}

View File

@@ -0,0 +1,154 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import '../providers/category_provider.dart';
import '../utils/subscription_category_helper.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../theme/app_colors.dart';
import '../routes/app_routes.dart';
class HomeContent extends StatelessWidget {
final AnimationController fadeController;
final AnimationController rotateController;
final AnimationController slideController;
final AnimationController pulseController;
final AnimationController waveController;
final ScrollController scrollController;
final VoidCallback onAddPressed;
const HomeContent({
super.key,
required this.fadeController,
required this.rotateController,
required this.slideController,
required this.pulseController,
required this.waveController,
required this.scrollController,
required this.onAddPressed,
});
@override
Widget build(BuildContext context) {
final provider = context.watch<SubscriptionProvider>();
if (provider.isLoading) {
return const Center(
child: CircularProgressIndicator(
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
),
);
}
if (provider.subscriptions.isEmpty) {
return EmptyStateWidget(
fadeController: fadeController,
rotateController: rotateController,
slideController: slideController,
onAddPressed: onAddPressed,
);
}
// 카테고리별 구독 구분
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
final categorizedSubscriptions = SubscriptionCategoryHelper.categorizeSubscriptions(
provider.subscriptions,
categoryProvider,
);
return RefreshIndicator(
onRefresh: () async {
await provider.refreshSubscriptions();
},
color: const Color(0xFF3B82F6),
child: CustomScrollView(
controller: scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
const GlassmorphicSliverAppBar(
title: '',
pinned: true,
expandedHeight: kToolbarHeight,
),
SliverToBoxAdapter(
child: NativeAdWidget(key: const ValueKey('home_ad')),
),
SliverToBoxAdapter(
child: SlideTransition(
position: Tween<Offset>(
begin: const Offset(0, 0.2),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
child: MainScreenSummaryCard(
provider: provider,
fadeController: fadeController,
pulseController: pulseController,
waveController: waveController,
slideController: slideController,
),
),
),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
SlideTransition(
position: Tween<Offset>(
begin: const Offset(-0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
child: Text(
'나의 구독 서비스',
style: Theme.of(context).textTheme.titleLarge,
),
),
SlideTransition(
position: Tween<Offset>(
begin: const Offset(0.2, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: slideController, curve: Curves.easeOutCubic)),
child: Row(
children: [
Text(
'${provider.subscriptions.length}',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primaryColor,
),
),
const SizedBox(width: 4),
Icon(
Icons.arrow_forward_ios,
size: 14,
color: AppColors.primaryColor,
),
],
),
),
],
),
),
),
SubscriptionListWidget(
categorizedSubscriptions: categorizedSubscriptions,
fadeController: fadeController,
),
SliverToBoxAdapter(
child: SizedBox(
height: 100 + MediaQuery.of(context).padding.bottom,
),
),
],
),
);
}
}

View File

@@ -0,0 +1,416 @@
import 'package:flutter/material.dart';
import 'dart:async';
import '../utils/performance_optimizer.dart';
import '../widgets/skeleton_loading.dart';
/// 레이지 로딩이 적용된 리스트 위젯
class LazyLoadingList<T> extends StatefulWidget {
final Future<List<T>> Function(int page, int pageSize) loadMore;
final Widget Function(BuildContext, T, int) itemBuilder;
final int pageSize;
final double scrollThreshold;
final Widget? loadingWidget;
final Widget? emptyWidget;
final Widget? errorWidget;
final bool enableRefresh;
final ScrollPhysics? physics;
final EdgeInsetsGeometry? padding;
const LazyLoadingList({
super.key,
required this.loadMore,
required this.itemBuilder,
this.pageSize = 20,
this.scrollThreshold = 0.8,
this.loadingWidget,
this.emptyWidget,
this.errorWidget,
this.enableRefresh = true,
this.physics,
this.padding,
});
@override
State<LazyLoadingList<T>> createState() => _LazyLoadingListState<T>();
}
class _LazyLoadingListState<T> extends State<LazyLoadingList<T>> {
final List<T> _items = [];
final ScrollController _scrollController = ScrollController();
int _currentPage = 0;
bool _isLoading = false;
bool _hasMore = true;
String? _error;
@override
void initState() {
super.initState();
_loadInitialData();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_isLoading || !_hasMore) return;
final position = _scrollController.position;
final maxScroll = position.maxScrollExtent;
final currentScroll = position.pixels;
if (currentScroll >= maxScroll * widget.scrollThreshold) {
_loadMoreData();
}
}
Future<void> _loadInitialData() async {
setState(() {
_isLoading = true;
_error = null;
});
try {
final newItems = await PerformanceMeasure.measure(
name: 'Initial data load',
operation: () => widget.loadMore(0, widget.pageSize),
);
setState(() {
_items.clear();
_items.addAll(newItems);
_currentPage = 0;
_hasMore = newItems.length >= widget.pageSize;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _loadMoreData() async {
if (_isLoading || !_hasMore) return;
setState(() {
_isLoading = true;
});
try {
final nextPage = _currentPage + 1;
final newItems = await PerformanceMeasure.measure(
name: 'Load more data (page $nextPage)',
operation: () => widget.loadMore(nextPage, widget.pageSize),
);
setState(() {
_items.addAll(newItems);
_currentPage = nextPage;
_hasMore = newItems.length >= widget.pageSize;
_isLoading = false;
});
} catch (e) {
setState(() {
_error = e.toString();
_isLoading = false;
});
}
}
Future<void> _refresh() async {
await _loadInitialData();
}
@override
Widget build(BuildContext context) {
if (_error != null && _items.isEmpty) {
return Center(
child: widget.errorWidget ?? _buildDefaultErrorWidget(),
);
}
if (!_isLoading && _items.isEmpty) {
return Center(
child: widget.emptyWidget ?? _buildDefaultEmptyWidget(),
);
}
Widget listView = ListView.builder(
controller: _scrollController,
physics: widget.physics ?? PerformanceOptimizer.getOptimizedScrollPhysics(),
padding: widget.padding,
itemCount: _items.length + (_isLoading || _hasMore ? 1 : 0),
itemBuilder: (context, index) {
if (index < _items.length) {
return widget.itemBuilder(context, _items[index], index);
}
// 로딩 인디케이터
return Padding(
padding: const EdgeInsets.all(16.0),
child: Center(
child: widget.loadingWidget ?? _buildDefaultLoadingWidget(),
),
);
},
);
if (widget.enableRefresh) {
return RefreshIndicator(
onRefresh: _refresh,
child: listView,
);
}
return listView;
}
Widget _buildDefaultLoadingWidget() {
return const CircularProgressIndicator();
}
Widget _buildDefaultEmptyWidget() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_outlined,
size: 64,
color: Colors.grey[400],
),
const SizedBox(height: 16),
Text(
'데이터가 없습니다',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
],
);
}
Widget _buildDefaultErrorWidget() {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red[400],
),
const SizedBox(height: 16),
Text(
'오류가 발생했습니다',
style: TextStyle(
fontSize: 16,
color: Colors.grey[600],
),
),
const SizedBox(height: 8),
Text(
_error ?? '',
style: TextStyle(
fontSize: 14,
color: Colors.grey[500],
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _loadInitialData,
child: const Text('다시 시도'),
),
],
);
}
}
/// 캐시가 적용된 레이지 로딩 리스트
class CachedLazyLoadingList<T> extends StatefulWidget {
final String cacheKey;
final Future<List<T>> Function(int page, int pageSize) loadMore;
final Widget Function(BuildContext, T, int) itemBuilder;
final int pageSize;
final Duration cacheDuration;
final Widget? loadingWidget;
final Widget? emptyWidget;
const CachedLazyLoadingList({
super.key,
required this.cacheKey,
required this.loadMore,
required this.itemBuilder,
this.pageSize = 20,
this.cacheDuration = const Duration(minutes: 5),
this.loadingWidget,
this.emptyWidget,
});
@override
State<CachedLazyLoadingList<T>> createState() => _CachedLazyLoadingListState<T>();
}
class _CachedLazyLoadingListState<T> extends State<CachedLazyLoadingList<T>> {
final Map<int, List<T>> _pageCache = {};
Future<List<T>> _loadWithCache(int page, int pageSize) async {
// 캐시 확인
if (_pageCache.containsKey(page)) {
return _pageCache[page]!;
}
// 데이터 로드
final items = await widget.loadMore(page, pageSize);
// 캐시 저장
_pageCache[page] = items;
// 일정 시간 후 캐시 제거
Timer(widget.cacheDuration, () {
if (mounted) {
setState(() {
_pageCache.remove(page);
});
}
});
return items;
}
@override
Widget build(BuildContext context) {
return LazyLoadingList<T>(
loadMore: _loadWithCache,
itemBuilder: widget.itemBuilder,
pageSize: widget.pageSize,
loadingWidget: widget.loadingWidget,
emptyWidget: widget.emptyWidget,
);
}
}
/// 무한 스크롤 그리드 뷰
class LazyLoadingGrid<T> extends StatefulWidget {
final Future<List<T>> Function(int page, int pageSize) loadMore;
final Widget Function(BuildContext, T, int) itemBuilder;
final int crossAxisCount;
final int pageSize;
final double scrollThreshold;
final double childAspectRatio;
final double crossAxisSpacing;
final double mainAxisSpacing;
final EdgeInsetsGeometry? padding;
const LazyLoadingGrid({
super.key,
required this.loadMore,
required this.itemBuilder,
required this.crossAxisCount,
this.pageSize = 20,
this.scrollThreshold = 0.8,
this.childAspectRatio = 1.0,
this.crossAxisSpacing = 8.0,
this.mainAxisSpacing = 8.0,
this.padding,
});
@override
State<LazyLoadingGrid<T>> createState() => _LazyLoadingGridState<T>();
}
class _LazyLoadingGridState<T> extends State<LazyLoadingGrid<T>> {
final List<T> _items = [];
final ScrollController _scrollController = ScrollController();
int _currentPage = 0;
bool _isLoading = false;
bool _hasMore = true;
@override
void initState() {
super.initState();
_loadInitialData();
_scrollController.addListener(_onScroll);
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_isLoading || !_hasMore) return;
final position = _scrollController.position;
final maxScroll = position.maxScrollExtent;
final currentScroll = position.pixels;
if (currentScroll >= maxScroll * widget.scrollThreshold) {
_loadMoreData();
}
}
Future<void> _loadInitialData() async {
setState(() => _isLoading = true);
final newItems = await widget.loadMore(0, widget.pageSize);
setState(() {
_items.clear();
_items.addAll(newItems);
_currentPage = 0;
_hasMore = newItems.length >= widget.pageSize;
_isLoading = false;
});
}
Future<void> _loadMoreData() async {
if (_isLoading || !_hasMore) return;
setState(() => _isLoading = true);
final nextPage = _currentPage + 1;
final newItems = await widget.loadMore(nextPage, widget.pageSize);
setState(() {
_items.addAll(newItems);
_currentPage = nextPage;
_hasMore = newItems.length >= widget.pageSize;
_isLoading = false;
});
}
@override
Widget build(BuildContext context) {
return GridView.builder(
controller: _scrollController,
padding: widget.padding,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: widget.crossAxisCount,
childAspectRatio: widget.childAspectRatio,
crossAxisSpacing: widget.crossAxisSpacing,
mainAxisSpacing: widget.mainAxisSpacing,
),
itemCount: _items.length + (_isLoading ? widget.crossAxisCount : 0),
itemBuilder: (context, index) {
if (index < _items.length) {
return widget.itemBuilder(context, _items[index], index);
}
// 로딩 스켈레톤
return const SkeletonLoading(
height: 100,
borderRadius: 12,
);
},
);
}
}

View File

@@ -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,
),

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:io' show Platform;
import 'glassmorphism_card.dart';
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
/// SRP에 따라 광고 전용 위젯으로 분리
@@ -84,9 +85,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
Widget _buildWebPlaceholder() {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: GlassmorphismCard(
borderRadius: 16,
blur: 10,
opacity: 0.1,
child: Container(
height: 80,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -186,9 +188,10 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
// 광고 정상 노출
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Card(
elevation: 2,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: GlassmorphismCard(
borderRadius: 16,
blur: 10,
opacity: 0.1,
child: SizedBox(
height: 80, // 네이티브 광고 높이 조정
child: AdWidget(ad: _nativeAd!),

View File

@@ -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),
),
),
],
);
}
}
}

View File

@@ -0,0 +1,350 @@
import 'package:flutter/material.dart';
import 'package:flutter/physics.dart';
import 'dart:math' as math;
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
class SpringAnimationWidget extends StatefulWidget {
final Widget child;
final Duration delay;
final SpringDescription spring;
final Offset? initialOffset;
final double? initialScale;
final double? initialRotation;
const SpringAnimationWidget({
super.key,
required this.child,
this.delay = Duration.zero,
this.spring = const SpringDescription(
mass: 1,
stiffness: 100,
damping: 10,
),
this.initialOffset,
this.initialScale,
this.initialRotation,
});
@override
State<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
}
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
with TickerProviderStateMixin {
late AnimationController _controller;
late Animation<Offset> _offsetAnimation;
late Animation<double> _scaleAnimation;
late Animation<double> _rotationAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
);
// 스프링 시뮬레이션
final simulation = SpringSimulation(
widget.spring,
0.0,
1.0,
0.0,
);
// 오프셋 애니메이션
_offsetAnimation = Tween<Offset>(
begin: widget.initialOffset ?? const Offset(0, 50),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 스케일 애니메이션
_scaleAnimation = Tween<double>(
begin: widget.initialScale ?? 0.5,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 회전 애니메이션
_rotationAnimation = Tween<double>(
begin: widget.initialRotation ?? 0.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
));
// 지연 후 애니메이션 시작
Future.delayed(widget.delay, () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.translate(
offset: _offsetAnimation.value,
child: Transform.scale(
scale: _scaleAnimation.value,
child: Transform.rotate(
angle: _rotationAnimation.value,
child: child,
),
),
);
},
child: widget.child,
);
}
}
/// 바운스 효과가 있는 버튼
class BouncyButton extends StatefulWidget {
final Widget child;
final VoidCallback? onPressed;
final EdgeInsetsGeometry? padding;
final BoxDecoration? decoration;
const BouncyButton({
super.key,
required this.child,
this.onPressed,
this.padding,
this.decoration,
});
@override
State<BouncyButton> createState() => _BouncyButtonState();
}
class _BouncyButtonState extends State<BouncyButton>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 200),
vsync: this,
);
_scaleAnimation = Tween<double>(
begin: 1.0,
end: 0.95,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
widget.onPressed?.call();
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: AnimatedBuilder(
animation: _scaleAnimation,
builder: (context, child) {
return Transform.scale(
scale: _scaleAnimation.value,
child: Container(
padding: widget.padding,
decoration: widget.decoration,
child: widget.child,
),
);
},
),
);
}
}
/// 중력 효과 애니메이션
class GravityAnimation extends StatefulWidget {
final Widget child;
final double gravity;
final double bounceFactor;
final double initialVelocity;
const GravityAnimation({
super.key,
required this.child,
this.gravity = 9.8,
this.bounceFactor = 0.8,
this.initialVelocity = 0,
});
@override
State<GravityAnimation> createState() => _GravityAnimationState();
}
class _GravityAnimationState extends State<GravityAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
double _position = 0;
double _velocity = 0;
double _floor = 300;
@override
void initState() {
super.initState();
_velocity = widget.initialVelocity;
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 10),
)..addListener(_updatePhysics);
_controller.repeat();
}
void _updatePhysics() {
setState(() {
// 속도 업데이트 (중력 적용)
_velocity += widget.gravity * 0.016; // 60fps 가정
// 위치 업데이트
_position += _velocity;
// 바닥 충돌 감지
if (_position >= _floor) {
_position = _floor;
_velocity = -_velocity * widget.bounceFactor;
// 너무 작은 바운스는 멈춤
if (_velocity.abs() < 1) {
_velocity = 0;
}
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Transform.translate(
offset: Offset(0, _position),
child: widget.child,
);
}
}
/// 물결 효과 애니메이션
class RippleAnimation extends StatefulWidget {
final Widget child;
final Color rippleColor;
final Duration duration;
const RippleAnimation({
super.key,
required this.child,
this.rippleColor = Colors.blue,
this.duration = const Duration(milliseconds: 600),
});
@override
State<RippleAnimation> createState() => _RippleAnimationState();
}
class _RippleAnimationState extends State<RippleAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTap() {
_controller.forward(from: 0.0);
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _handleTap,
child: Stack(
alignment: Alignment.center,
children: [
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Container(
width: 100 + 200 * _animation.value,
height: 100 + 200 * _animation.value,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.rippleColor.withValues(alpha:
(1 - _animation.value) * 0.3,
),
),
);
},
),
widget.child,
],
),
);
}
}

View File

@@ -0,0 +1,302 @@
import 'package:flutter/material.dart';
import 'dart:math' as math;
/// 스태거 애니메이션이 적용된 리스트 위젯
class StaggeredListAnimation extends StatefulWidget {
final List<Widget> children;
final Duration itemDelay;
final Duration animationDuration;
final Curve curve;
final Axis direction;
const StaggeredListAnimation({
super.key,
required this.children,
this.itemDelay = const Duration(milliseconds: 100),
this.animationDuration = const Duration(milliseconds: 500),
this.curve = Curves.easeOutBack,
this.direction = Axis.vertical,
});
@override
State<StaggeredListAnimation> createState() => _StaggeredListAnimationState();
}
class _StaggeredListAnimationState extends State<StaggeredListAnimation>
with TickerProviderStateMixin {
final List<AnimationController> _controllers = [];
final List<Animation<double>> _fadeAnimations = [];
final List<Animation<Offset>> _slideAnimations = [];
final List<Animation<double>> _scaleAnimations = [];
@override
void initState() {
super.initState();
_createAnimations();
_startAnimations();
}
void _createAnimations() {
for (int i = 0; i < widget.children.length; i++) {
final controller = AnimationController(
duration: widget.animationDuration,
vsync: this,
);
final fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: controller,
curve: widget.curve,
));
final slideAnimation = Tween<Offset>(
begin: widget.direction == Axis.vertical
? const Offset(0, 0.3)
: const Offset(0.3, 0),
end: Offset.zero,
).animate(CurvedAnimation(
parent: controller,
curve: widget.curve,
));
final scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: controller,
curve: widget.curve,
));
_controllers.add(controller);
_fadeAnimations.add(fadeAnimation);
_slideAnimations.add(slideAnimation);
_scaleAnimations.add(scaleAnimation);
}
}
void _startAnimations() async {
for (int i = 0; i < _controllers.length; i++) {
await Future.delayed(widget.itemDelay * i);
if (mounted) {
_controllers[i].forward();
}
}
}
@override
void dispose() {
for (final controller in _controllers) {
controller.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return widget.direction == Axis.vertical
? Column(
children: _buildAnimatedChildren(),
)
: Row(
children: _buildAnimatedChildren(),
);
}
List<Widget> _buildAnimatedChildren() {
return List.generate(widget.children.length, (index) {
return AnimatedBuilder(
animation: _controllers[index],
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimations[index],
child: SlideTransition(
position: _slideAnimations[index],
child: ScaleTransition(
scale: _scaleAnimations[index],
child: widget.children[index],
),
),
);
},
);
});
}
}
/// 개별 스태거 애니메이션 아이템
class StaggeredAnimationItem extends StatefulWidget {
final Widget child;
final int index;
final Duration delay;
final Duration duration;
final Curve curve;
const StaggeredAnimationItem({
super.key,
required this.child,
required this.index,
this.delay = const Duration(milliseconds: 100),
this.duration = const Duration(milliseconds: 500),
this.curve = Curves.easeOutBack,
});
@override
State<StaggeredAnimationItem> createState() => _StaggeredAnimationItemState();
}
class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
late Animation<double> _scaleAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_fadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 0.3),
end: Offset.zero,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
_scaleAnimation = Tween<double>(
begin: 0.8,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: widget.curve,
));
// 지연 후 애니메이션 시작
Future.delayed(widget.delay * widget.index, () {
if (mounted) {
_controller.forward();
}
});
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return FadeTransition(
opacity: _fadeAnimation,
child: SlideTransition(
position: _slideAnimation,
child: ScaleTransition(
scale: _scaleAnimation,
child: widget.child,
),
),
);
},
);
}
}
/// 카드 플립 애니메이션
class FlipAnimationCard extends StatefulWidget {
final Widget front;
final Widget back;
final Duration duration;
const FlipAnimationCard({
super.key,
required this.front,
required this.back,
this.duration = const Duration(milliseconds: 800),
});
@override
State<FlipAnimationCard> createState() => _FlipAnimationCardState();
}
class _FlipAnimationCardState extends State<FlipAnimationCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
bool _isFlipped = false;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: widget.duration,
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _flip() {
if (_isFlipped) {
_controller.reverse();
} else {
_controller.forward();
}
_isFlipped = !_isFlipped;
}
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: _flip,
child: AnimatedBuilder(
animation: _animation,
builder: (context, child) {
final isShowingFront = _animation.value < 0.5;
return Transform(
alignment: Alignment.center,
transform: Matrix4.identity()
..setEntry(3, 2, 0.001)
..rotateY(math.pi * _animation.value),
child: isShowingFront
? widget.front
: Transform(
alignment: Alignment.center,
transform: Matrix4.identity()..rotateY(math.pi),
child: widget.back,
),
);
},
),
);
}
}

View File

@@ -1,12 +1,15 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'dart:math' as math;
import '../models/subscription_model.dart';
import '../screens/detail_screen.dart';
import 'website_icon.dart';
import 'app_navigator.dart';
import '../theme/app_colors.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
import 'glassmorphism_card.dart';
class SubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription;
@@ -230,67 +233,30 @@ class _SubscriptionCardState extends State<SubscriptionCard>
color: Colors.transparent,
child: InkWell(
onTap: () async {
final result = await Navigator.push(
context,
PageRouteBuilder(
pageBuilder: (context, animation, secondaryAnimation) =>
DetailScreen(subscription: widget.subscription),
transitionsBuilder:
(context, animation, secondaryAnimation, child) {
const begin = Offset(0.0, 0.05);
const end = Offset.zero;
const curve = Curves.easeOutCubic;
var tween = Tween(begin: begin, end: end)
.chain(CurveTween(curve: curve));
var fadeAnimation =
Tween<double>(begin: 0.6, end: 1.0)
.chain(CurveTween(curve: curve))
.animate(animation);
return FadeTransition(
opacity: fadeAnimation,
child: SlideTransition(
position: animation.drive(tween),
child: child,
),
);
},
),
);
if (result == true) {
// 변경 사항이 있을 경우 미리 저장된 Provider 참조를 사용하여 구독 목록 갱신
await _subscriptionProvider.refreshSubscriptions();
// 메인 화면의 State를 갱신하기 위해 미세한 지연 후 다시 한번 알림
// mounted 상태를 확인하여 dispose된 위젯에서 Provider를 참조하지 않도록 합니다.
Future.delayed(const Duration(milliseconds: 100), () {
// 위젯이 아직 마운트 상태인지 확인하고, 미리 저장된 Provider 참조 사용
if (mounted) {
_subscriptionProvider.notifyListeners();
}
});
}
await AppNavigator.toDetail(context, widget.subscription);
},
splashColor: AppColors.primaryColor.withOpacity(0.1),
highlightColor: AppColors.primaryColor.withOpacity(0.05),
splashColor: AppColors.primaryColor.withValues(alpha: 0.1),
highlightColor: AppColors.primaryColor.withValues(alpha: 0.05),
borderRadius: BorderRadius.circular(16),
child: Container(
child: AnimatedGlassmorphismCard(
onTap: () {}, // onTap은 이미 InkWell에서 처리됨
padding: EdgeInsets.zero,
borderRadius: 16,
blur: _isHovering ? 15 : 10,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isHovering
? AppColors.primaryColor.withOpacity(0.3)
? AppColors.primaryColor.withValues(alpha: 0.3)
: AppColors.borderColor,
width: _isHovering ? 1.5 : 0.5,
),
boxShadow: [
BoxShadow(
color: AppColors.primaryColor.withOpacity(
color: AppColors.primaryColor.withValues(alpha:
0.03 + (0.05 * _hoverController.value)),
blurRadius: 8 + (8 * _hoverController.value),
spreadRadius: 0,
@@ -502,9 +468,9 @@ class _SubscriptionCardState extends State<SubscriptionCard>
decoration: BoxDecoration(
color: isNearBilling
? AppColors.warningColor
.withOpacity(0.1)
.withValues(alpha: 0.1)
: AppColors.successColor
.withOpacity(0.1),
.withValues(alpha: 0.1),
borderRadius:
BorderRadius.circular(12),
),
@@ -551,7 +517,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
vertical: 2,
),
decoration: BoxDecoration(
color: const Color(0xFFFF6B6B).withOpacity(0.1),
color: const Color(0xFFFF6B6B).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
),
child: Row(
@@ -607,6 +573,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
],
),
),
),
),
),
);

View File

@@ -2,6 +2,13 @@ import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../widgets/subscription_card.dart';
import '../widgets/category_header_widget.dart';
import '../widgets/swipeable_subscription_card.dart';
import '../widgets/staggered_list_animation.dart';
import '../screens/detail_screen.dart';
import '../widgets/animated_page_transitions.dart';
import '../widgets/app_navigator.dart';
import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
@@ -75,8 +82,29 @@ class SubscriptionListWidget extends StatelessWidget {
curve: Curves.easeOut))),
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: SubscriptionCard(
subscription: subscriptions[subIndex],
child: StaggeredAnimationItem(
index: subIndex,
delay: const Duration(milliseconds: 50),
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
onTap: () {
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onEdit: () {
// 편집 화면으로 이동
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onDelete: () async {
// 삭제 확인 다이얼로그
final provider = Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
},
),
),
),
);

View File

@@ -0,0 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'dart:math' as math;
import '../models/subscription_model.dart';
import '../utils/haptic_feedback_helper.dart';
import 'subscription_card.dart';
import '../theme/app_colors.dart';
class SwipeableSubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription;
final VoidCallback? onEdit;
final VoidCallback? onDelete;
final VoidCallback? onTap;
const SwipeableSubscriptionCard({
super.key,
required this.subscription,
this.onEdit,
this.onDelete,
this.onTap,
});
@override
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState();
}
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _animation;
double _dragStartX = 0;
double _dragExtent = 0;
bool _isSwipingLeft = false;
bool _hapticTriggered = false;
static const double _swipeThreshold = 80.0;
static const double _deleteThreshold = 150.0;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_animation = Tween<double>(
begin: 0.0,
end: 0.0,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutExpo,
));
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleDragStart(DragStartDetails details) {
_dragStartX = details.localPosition.dx;
_hapticTriggered = false;
}
void _handleDragUpdate(DragUpdateDetails details) {
final delta = details.localPosition.dx - _dragStartX;
setState(() {
_dragExtent = delta;
_isSwipingLeft = delta < 0;
});
// 햅틱 피드백 트리거
if (!_hapticTriggered && _dragExtent.abs() > _swipeThreshold) {
_hapticTriggered = true;
HapticFeedbackHelper.mediumImpact();
}
// 삭제 임계값에 도달했을 때 강한 햅틱
if (_dragExtent.abs() > _deleteThreshold && _hapticTriggered) {
HapticFeedbackHelper.heavyImpact();
_hapticTriggered = false; // 반복 방지
}
}
void _handleDragEnd(DragEndDetails details) {
final velocity = details.velocity.pixelsPerSecond.dx;
final extent = _dragExtent.abs();
if (extent > _deleteThreshold || velocity.abs() > 800) {
// 삭제 액션
if (_isSwipingLeft && widget.onDelete != null) {
HapticFeedbackHelper.success();
_animateToOffset(-MediaQuery.of(context).size.width);
Future.delayed(const Duration(milliseconds: 300), () {
widget.onDelete!();
});
} else if (!_isSwipingLeft && widget.onEdit != null) {
HapticFeedbackHelper.success();
_animateToOffset(MediaQuery.of(context).size.width);
Future.delayed(const Duration(milliseconds: 300), () {
widget.onEdit!();
});
}
} else if (extent > _swipeThreshold) {
// 액션 버튼 표시
HapticFeedbackHelper.lightImpact();
_animateToOffset(_isSwipingLeft ? -_swipeThreshold : _swipeThreshold);
} else {
// 원위치로 복귀
_animateToOffset(0);
}
}
void _animateToOffset(double offset) {
_animation = Tween<double>(
begin: _dragExtent,
end: offset,
).animate(CurvedAnimation(
parent: _controller,
curve: Curves.easeOutExpo,
));
_controller.forward(from: 0).then((_) {
setState(() {
_dragExtent = offset;
});
});
}
@override
Widget build(BuildContext context) {
return Stack(
children: [
// 배경 액션 버튼들
Positioned.fill(
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(16),
color: _isSwipingLeft
? AppColors.dangerColor
: AppColors.primaryColor,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// 편집 버튼 (오른쪽 스와이프)
if (!_isSwipingLeft)
Padding(
padding: const EdgeInsets.only(left: 24),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _dragExtent > 40 ? 1.0 : 0.0,
child: AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: _dragExtent > 40 ? 1.0 : 0.5,
child: const Icon(
Icons.edit_rounded,
color: Colors.white,
size: 28,
),
),
),
),
// 삭제 버튼 (왼쪽 스와이프)
if (_isSwipingLeft)
Padding(
padding: const EdgeInsets.only(right: 24),
child: AnimatedOpacity(
duration: const Duration(milliseconds: 200),
opacity: _dragExtent.abs() > 40 ? 1.0 : 0.0,
child: AnimatedScale(
duration: const Duration(milliseconds: 200),
scale: _dragExtent.abs() > 40 ? 1.0 : 0.5,
child: Icon(
_dragExtent.abs() > _deleteThreshold
? Icons.delete_forever_rounded
: Icons.delete_rounded,
color: Colors.white,
size: 28,
),
),
),
),
],
),
),
),
// 스와이프 가능한 카드
AnimatedBuilder(
animation: _animation,
builder: (context, child) {
return Transform.translate(
offset: Offset(_animation.value, 0),
child: child,
);
},
child: GestureDetector(
onHorizontalDragStart: _handleDragStart,
onHorizontalDragUpdate: _handleDragUpdate,
onHorizontalDragEnd: _handleDragEnd,
child: Transform.translate(
offset: Offset(_dragExtent, 0),
child: Transform.scale(
scale: 1.0 - (_dragExtent.abs() / 2000),
child: Transform.rotate(
angle: _dragExtent / 2000,
child: GestureDetector(
onTap: () {
if (_dragExtent.abs() < 10) {
widget.onTap?.call();
}
},
child: SubscriptionCard(
subscription: widget.subscription,
),
),
),
),
),
),
),
],
);
}
}

View File

@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// 배경에 따라 자동으로 색상 대비를 조정하는 텍스트 위젯
class ThemedText extends StatelessWidget {
final String text;
final TextStyle? style;
final TextAlign? textAlign;
final TextOverflow? overflow;
final int? maxLines;
final bool softWrap;
final bool forceLight;
final bool forceDark;
final double? opacity;
final double? fontSize;
final FontWeight? fontWeight;
final double? letterSpacing;
final Color? color;
const ThemedText(
this.text, {
super.key,
this.style,
this.textAlign,
this.overflow,
this.maxLines,
this.softWrap = true,
this.forceLight = false,
this.forceDark = false,
this.opacity,
this.fontSize,
this.fontWeight,
this.letterSpacing,
this.color,
});
/// 배경 밝기에 따른 텍스트 색상 결정
static Color getContrastColor(BuildContext context, {
bool forceLight = false,
bool forceDark = false,
}) {
if (forceLight) return Colors.white;
if (forceDark) return AppColors.textPrimary;
final brightness = Theme.of(context).brightness;
final backgroundColor = Theme.of(context).scaffoldBackgroundColor;
// 글래스모피즘 환경에서는 보통 어두운 배경 위에 밝은 텍스트
if (_isGlassmorphicContext(context)) {
return brightness == Brightness.dark
? Colors.white.withValues(alpha: 0.95)
: AppColors.textPrimary;
}
// 일반 환경
return brightness == Brightness.dark
? Colors.white
: AppColors.textPrimary;
}
/// 글래스모피즘 컨텍스트인지 확인
static bool _isGlassmorphicContext(BuildContext context) {
// 부모 위젯 체인에서 글래스모피즘 카드가 있는지 확인
final glassmorphic = context.findAncestorWidgetOfExactType<GlassmorphicIndicator>();
return glassmorphic != null;
}
@override
Widget build(BuildContext context) {
final textColor = color ?? getContrastColor(
context,
forceLight: forceLight,
forceDark: forceDark,
);
final finalColor = opacity != null
? textColor.withValues(alpha: opacity!)
: textColor;
final defaultStyle = DefaultTextStyle.of(context).style;
// 개별 스타일 속성들을 병합
final baseStyle = TextStyle(
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
color: finalColor,
);
final effectiveStyle = defaultStyle.merge(baseStyle).merge(style);
return Text(
text,
style: effectiveStyle,
textAlign: textAlign,
overflow: overflow,
maxLines: maxLines,
softWrap: softWrap,
);
}
/// 제목용 스타일 팩토리
static ThemedText headline({
required String text,
TextStyle? style,
bool forceLight = false,
bool forceDark = false,
}) {
return ThemedText(
text,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
).merge(style),
forceLight: forceLight,
forceDark: forceDark,
);
}
/// 부제목용 스타일 팩토리
static ThemedText subtitle({
required String text,
TextStyle? style,
bool forceLight = false,
bool forceDark = false,
double opacity = 0.8,
}) {
return ThemedText(
text,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
).merge(style),
forceLight: forceLight,
forceDark: forceDark,
opacity: opacity,
);
}
/// 본문용 스타일 팩토리
static ThemedText body({
required String text,
TextStyle? style,
bool forceLight = false,
bool forceDark = false,
double opacity = 0.9,
}) {
return ThemedText(
text,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
).merge(style),
forceLight: forceLight,
forceDark: forceDark,
opacity: opacity,
);
}
/// 캡션용 스타일 팩토리
static ThemedText caption({
required String text,
TextStyle? style,
bool forceLight = false,
bool forceDark = false,
double opacity = 0.7,
}) {
return ThemedText(
text,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
).merge(style),
forceLight: forceLight,
forceDark: forceDark,
opacity: opacity,
);
}
}
/// 글래스모피즘 컨텍스트를 표시하는 마커 위젯
class GlassmorphicIndicator extends InheritedWidget {
const GlassmorphicIndicator({
super.key,
required super.child,
});
static GlassmorphicIndicator? of(BuildContext context) {
return context.dependOnInheritedWidgetOfExactType<GlassmorphicIndicator>();
}
@override
bool updateShouldNotify(GlassmorphicIndicator oldWidget) => false;
}
/// 글래스모피즘 환경에서 텍스트 색상을 자동 조정하는 래퍼
class GlassmorphicTextWrapper extends StatelessWidget {
final Widget child;
const GlassmorphicTextWrapper({
super.key,
required this.child,
});
@override
Widget build(BuildContext context) {
return GlassmorphicIndicator(
child: DefaultTextStyle(
style: DefaultTextStyle.of(context).style.copyWith(
color: ThemedText.getContrastColor(context),
),
child: child,
),
);
}
}

75
macos/Podfile.lock Normal file
View File

@@ -0,0 +1,75 @@
PODS:
- flutter_local_notifications (0.0.1):
- FlutterMacOS
- flutter_secure_storage_macos (6.1.3):
- FlutterMacOS
- FlutterMacOS (1.0.0)
- local_auth_darwin (0.0.1):
- Flutter
- FlutterMacOS
- path_provider_foundation (0.0.1):
- Flutter
- FlutterMacOS
- share_plus (0.0.1):
- FlutterMacOS
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- url_launcher_macos (0.0.1):
- FlutterMacOS
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- flutter_secure_storage_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
- local_auth_darwin (from `Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin`)
- path_provider_foundation (from `Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin`)
- share_plus (from `Flutter/ephemeral/.symlinks/plugins/share_plus/macos`)
- shared_preferences_foundation (from `Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin`)
- url_launcher_macos (from `Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos`)
- webview_flutter_wkwebview (from `Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin`)
EXTERNAL SOURCES:
flutter_local_notifications:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos
flutter_secure_storage_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_secure_storage_macos/macos
FlutterMacOS:
:path: Flutter/ephemeral
local_auth_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/local_auth_darwin/darwin
path_provider_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/path_provider_foundation/darwin
share_plus:
:path: Flutter/ephemeral/.symlinks/plugins/share_plus/macos
shared_preferences_foundation:
:path: Flutter/ephemeral/.symlinks/plugins/shared_preferences_foundation/darwin
sqflite_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/sqflite_darwin/darwin
url_launcher_macos:
:path: Flutter/ephemeral/.symlinks/plugins/url_launcher_macos/macos
webview_flutter_wkwebview:
:path: Flutter/ephemeral/.symlinks/plugins/webview_flutter_wkwebview/darwin
SPEC CHECKSUMS:
flutter_local_notifications: 3805ca215b2fb7f397d78b66db91f6a747af52e4
flutter_secure_storage_macos: c2754d3483d20bb207bb9e5a14f1b8e771abcdb9
FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24
local_auth_darwin: 66e40372f1c29f383a314c738c7446e2f7fdadc3
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
share_plus: 76dd39142738f7a68dd57b05093b5e8193f220f7
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite_darwin: 5a7236e3b501866c1c9befc6771dfd73ffb8702d
url_launcher_macos: c82c93949963e55b228a30115bd219499a6fe404
webview_flutter_wkwebview: a4af96a051138e28e29f60101d094683b9f82188
PODFILE CHECKSUM: 7eb978b976557c8c1cd717d8185ec483fd090a82
COCOAPODS: 1.16.2

View File

@@ -27,6 +27,8 @@
33CC10F32044A3C60003C045 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F22044A3C60003C045 /* Assets.xcassets */; };
33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; };
33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; };
72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4C4015C586B270010D4F62A7 /* Pods_Runner.framework */; };
EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -64,7 +66,7 @@
331C80D7294CF71000263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = "<group>"; };
333000ED22D3DE5D00554162 /* Warnings.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Warnings.xcconfig; sourceTree = "<group>"; };
335BBD1A22A9A15E00E9071D /* GeneratedPluginRegistrant.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = GeneratedPluginRegistrant.swift; sourceTree = "<group>"; };
33CC10ED2044A3C60003C045 /* submanager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "submanager.app"; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10ED2044A3C60003C045 /* submanager.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = submanager.app; sourceTree = BUILT_PRODUCTS_DIR; };
33CC10F02044A3C60003C045 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = "<group>"; };
33CC10F22044A3C60003C045 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Assets.xcassets; path = Runner/Assets.xcassets; sourceTree = "<group>"; };
33CC10F52044A3C60003C045 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.xib; name = Base; path = Base.lproj/MainMenu.xib; sourceTree = "<group>"; };
@@ -76,8 +78,16 @@
33E51913231747F40026EE4D /* DebugProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = DebugProfile.entitlements; sourceTree = "<group>"; };
33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = "<group>"; };
33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = "<group>"; };
449D7C4E91ECC2307A618B21 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = "<group>"; };
4C4015C586B270010D4F62A7 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = "<group>"; };
9119DFDCC41763FA448B6987 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = "<group>"; };
9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = "<group>"; };
98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = "<group>"; };
ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = "<group>"; };
BC9E591D2F7240B7A962E51A /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = "<group>"; };
F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = "<group>"; };
FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -85,6 +95,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
EE096231EB4A9A751F40F20F /* Pods_RunnerTests.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -92,12 +103,27 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
72F60518A5F9095E49917AA9 /* Pods_Runner.framework in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
2A845BBB3A2FF55EFF11D802 /* Pods */ = {
isa = PBXGroup;
children = (
9119DFDCC41763FA448B6987 /* Pods-Runner.debug.xcconfig */,
BC9E591D2F7240B7A962E51A /* Pods-Runner.release.xcconfig */,
449D7C4E91ECC2307A618B21 /* Pods-Runner.profile.xcconfig */,
F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */,
98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */,
ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */,
);
name = Pods;
path = Pods;
sourceTree = "<group>";
};
331C80D6294CF71000263BE5 /* RunnerTests */ = {
isa = PBXGroup;
children = (
@@ -125,6 +151,7 @@
331C80D6294CF71000263BE5 /* RunnerTests */,
33CC10EE2044A3C60003C045 /* Products */,
D73912EC22F37F3D000D13A0 /* Frameworks */,
2A845BBB3A2FF55EFF11D802 /* Pods */,
);
sourceTree = "<group>";
};
@@ -175,6 +202,8 @@
D73912EC22F37F3D000D13A0 /* Frameworks */ = {
isa = PBXGroup;
children = (
4C4015C586B270010D4F62A7 /* Pods_Runner.framework */,
FD4665FB04725B1F18390AD3 /* Pods_RunnerTests.framework */,
);
name = Frameworks;
sourceTree = "<group>";
@@ -186,6 +215,7 @@
isa = PBXNativeTarget;
buildConfigurationList = 331C80DE294CF71000263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */;
buildPhases = (
451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */,
331C80D1294CF70F00263BE5 /* Sources */,
331C80D2294CF70F00263BE5 /* Frameworks */,
331C80D3294CF70F00263BE5 /* Resources */,
@@ -204,11 +234,13 @@
isa = PBXNativeTarget;
buildConfigurationList = 33CC10FB2044A3C60003C045 /* Build configuration list for PBXNativeTarget "Runner" */;
buildPhases = (
7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */,
33CC10E92044A3C60003C045 /* Sources */,
33CC10EA2044A3C60003C045 /* Frameworks */,
33CC10EB2044A3C60003C045 /* Resources */,
33CC110E2044A8840003C045 /* Bundle Framework */,
3399D490228B24CF009A79C7 /* ShellScript */,
CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */,
);
buildRules = (
);
@@ -329,6 +361,67 @@
shellPath = /bin/sh;
shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire";
};
451D4640541196349C145B5C /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
7C3B79DF1E84203D7984FD6C /* [CP] Check Pods Manifest.lock */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
);
inputPaths = (
"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
"${PODS_ROOT}/Manifest.lock",
);
name = "[CP] Check Pods Manifest.lock";
outputFileListPaths = (
);
outputPaths = (
"$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
showEnvVarsInLog = 0;
};
CE04D97F8DBFE7829E972FBA /* [CP] Embed Pods Frameworks */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@@ -380,6 +473,7 @@
/* Begin XCBuildConfiguration section */
331C80DB294CF71000263BE5 /* Debug */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = F0AF324ABE617476AEE8A9C9 /* Pods-RunnerTests.debug.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@@ -394,6 +488,7 @@
};
331C80DC294CF71000263BE5 /* Release */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = 98411C537772476E3C6B3062 /* Pods-RunnerTests.release.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;
@@ -408,6 +503,7 @@
};
331C80DD294CF71000263BE5 /* Profile */ = {
isa = XCBuildConfiguration;
baseConfigurationReference = ABFAA58A25E16D1B2188E977 /* Pods-RunnerTests.profile.xcconfig */;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CURRENT_PROJECT_VERSION = 1;

View File

@@ -4,4 +4,7 @@
<FileRef
location = "group:Runner.xcodeproj">
</FileRef>
<FileRef
location = "group:Pods/Pods.xcodeproj">
</FileRef>
</Workspace>