diff --git a/CLAUDE.md b/CLAUDE.md index 18f6eab..92bd1e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,9 +6,262 @@ ## 프로젝트 정보 - Flutter 기반 구독 관리 앱 (SubManager) -- 글래스모피즘 디자인 시스템 적용 중 -- @doc/color.md의 색상 가이드를 전체 UI에 통일성 있게 적용하는 작업 진행 중 ## 현재 작업 -- 전체 10개 화면과 50개 이상의 위젯에 통일된 글래스모피즘 스타일 적용 -- 색상 시스템 업데이트 및 일관성 있는 UI 구현 \ No newline at end of file +- 구독카드가 클릭이 되지 않아서 문제를 찾는 중. + +## 🎯 Mandatory Response Format + +Before starting any task, you MUST respond in the following format: + +``` +[Model Name]. I have reviewed all the following rules: [rule file list or categories]. Proceeding with the task. Master! +``` + +**Examples:** + +- `Claude Sonnet 4. I have reviewed all the following rules: development guidelines, class structure, testing rules. Proceeding with the task. Master!` +- For extensive rules: `coding style, class design, exception handling, testing rules` (categorized summary) + +## 🚀 Mandatory 3-Phase Task Process + +### Phase 1: Codebase Exploration & Analysis + +**Required Actions:** + +- Systematically discover ALL relevant files, directories, modules +- Search for related keywords, functions, classes, patterns +- Thoroughly examine each identified file +- Document coding conventions and style guidelines +- Identify framework/library usage patterns + +### Phase 2: Implementation Planning + +**Required Actions:** + +- Create detailed implementation roadmap based on Phase 1 findings +- Define specific task lists and acceptance criteria per module +- Specify performance/quality requirements + +### Phase 3: Implementation Execution + +**Required Actions:** + +- Implement each module following Phase 2 plan +- Verify ALL acceptance criteria before proceeding +- Ensure adherence to conventions identified in Phase 1 + +## ✅ Core Development Principles + +### Language & Type Rules + +- **Write ALL code, variables, and names in English** +- **Write ALL comments, documentation, prompts, and responses in Korean** +- **Always declare types explicitly** for variables, parameters, and return values +- Avoid `any`, `dynamic`, or loosely typed declarations (except when strictly necessary) +- Define **custom types** when needed +- Extract magic numbers and literals into named constants or enums + +### Naming Conventions + +|Element|Style|Example| +|---|---|---| +|Classes|`PascalCase`|`UserService`, `DataRepository`| +|Variables/Methods|`camelCase`|`userName`, `calculateTotal`| +|Files/Folders|`under_score_case`|`user_service.dart`, `data_models/`| +|Environment Variables|`UPPERCASE`|`API_URL`, `DATABASE_PASSWORD`| + +**Critical Rules:** + +- **Boolean variables must be verb-based**: `isReady`, `hasError`, `canDelete` +- **Function/method names start with verbs**: `executeLogin`, `saveUser` +- Use meaningful, descriptive names +- Avoid abbreviations unless widely accepted: `i`, `j`, `err`, `ctx`, `API`, `URL` + +## 🔧 Function & Method Design + +### Function Structure Principles + +- **Keep functions short and focused** (≤20 lines recommended) +- **Avoid blank lines inside functions** +- **Follow Single Responsibility Principle** +- **Use verb + object format** for naming: + - Boolean return: `isValid`, `canRetry`, `hasPermission` + - Void return: `executeLogin`, `saveUser`, `startAnimation` + +### Function Optimization Techniques + +- Use **early returns** to avoid nested logic +- Extract logic into helper functions +- Prefer **arrow functions** for short expressions (≤3 lines) +- Use **named functions** for complex logic +- Minimize null checks by using **default values** +- Minimize parameters using **RO-RO pattern** (Receive Object – Return Object) + +## 📦 Data & Class Design + +### Class Design Principles + +- **Strictly follow Single Responsibility Principle (SRP)** +- **Favor composition over inheritance** +- **Define interfaces/abstract classes** to establish contracts +- **Prefer immutable data structures** (use `readonly`, `const`) + +### File Size Management + +- **Split by responsibility when exceeding 200 lines** (responsibility-based, not line-based) +- ✅ **May remain as-is if**: + - Has **single clear responsibility** + - Is **easy to maintain** +- 🔁 **Must split when**: + - Contains **multiple concerns** + - Requires **excessive scrolling** + - Patterns repeat across files + - Difficult for new developer onboarding + +### Class Recommendations + +- ≤ 200 lines (not mandatory) +- ≤ 10 public methods +- ≤ 10 properties + +### Data Model Design + +- Avoid excessive use of primitives — use **composite types or classes** +- Move **validation logic inside data models** (not in business logic) + +## ❗ Exception Handling + +### Exception Usage Principles + +- Use exceptions only for **truly unexpected or critical issues** +- **Catch exceptions only to**: + - Handle known failure scenarios + - Add useful context +- Otherwise, let global handlers manage them + +## 🧪 Testing + +### Test Structure + +- Follow **Arrange–Act–Assert** pattern +- Clear test variable naming: `inputX`, `mockX`, `actualX`, `expectedX` +- **Write unit tests for every public method** + +### Test Doubles Usage + +- Use **test doubles** (mock/fake/stub) for dependencies +- Exception: allow real use of **lightweight third-party libraries** + +### Integration Testing + +- Write **integration tests per module** +- Follow **Given–When–Then** structure +- Ensure **100% test pass rate in CI** and **apply immediate fixes** for failures + +## 🧠 Error Analysis & Rule Documentation + +### Mandatory Process When Errors Occur + +1. **Analyze root cause in detail** +2. **Document preventive rule in `.cursor/rules/error_analysis.mdc`** +3. **Write in English including**: + - Error description and context + - Cause and reproducibility steps + - Resolution approach + - Rule for preventing future recurrences + - Sample code and references to related rules + +### Rule Writing Standards + +```markdown +--- +description: Clear, one-line description of what the rule enforces +globs: path/to/files/*.ext, other/path/**/* +alwaysApply: boolean +--- + +**Main Points in Bold** +- Sub-points with details +- Examples and explanations +``` + +## 🏗️ Architectural Guidelines + +### Clean Architecture Compliance + +- **Layered structure**: `modules`, `controllers`, `services`, `repositories`, `entities` +- Apply **Repository Pattern** for data abstraction +- Use **Dependency Injection** (`getIt`, `inject`, etc.) +- Controllers handle business logic (not view processing) + +### Code Structuring + +- **One export or public declaration per file** +- Centralize constants, error messages, and configuration +- Make **all shared logic reusable** and place in dedicated helper modules + +## 🌲 UI Structure & Component Design + +### UI Optimization Principles + +- **Avoid deeply nested widget/component trees**: + - Flatten hierarchy for **better performance and readability** + - Easier **state management and testability** +- **Split large components into small, focused widgets/components** +- Use `const` constructors (or equivalents) for performance optimization +- Apply clear **naming and separation** between view, logic, and data layers + +## 📈 Continuous Rule Improvement + +### Rule Improvement Triggers + +- New code patterns not covered by existing rules +- Repeated similar implementations across files +- Common error patterns that could be prevented +- New libraries or tools being used consistently +- Emerging best practices in the codebase + +### Rule Update Criteria + +**Add New Rules When:** + +- A new technology/pattern is used in 3+ files +- Common bugs could be prevented by a rule +- Code reviews repeatedly mention the same feedback + +**Modify Existing Rules When:** + +- Better examples exist in the codebase +- Additional edge cases are discovered +- Related rules have been updated + +## ✅ Quality Validation Checklist + +Before completing any task, confirm: + +- ✅ All three phases completed sequentially +- ✅ Each phase output meets specified format requirements +- ✅ Implementation satisfies all acceptance criteria +- ✅ Code quality meets professional standards +- ✅ Started with mandatory response format +- ✅ All naming conventions followed +- ✅ Type safety ensured +- ✅ Single Responsibility Principle adhered to + +## 🎯 Success Validation Framework + +### Expert-Level Standards Verification + +- **Minimalistic Approach**: High-quality, clean solutions without unnecessary complexity +- **Professional Standards**: Every output meets industry-standard software engineering practices +- **Concrete Results**: Specific, actionable details at each step + +### Final Quality Gates + +- [ ] All acceptance criteria validated +- [ ] Code follows established conventions +- [ ] Minimalistic approach maintained +- [ ] Expert-level implementation standards met +- [ ] Korean comments and documentation provided +- [ ] English code and variable names used consistently \ No newline at end of file diff --git a/doc/list.md b/doc/list.md new file mode 100644 index 0000000..0b41eab --- /dev/null +++ b/doc/list.md @@ -0,0 +1,228 @@ +## 🎵 음악 스트리밍 + +| 서비스명 | 한국 URL | 영어 URL | 한국어 해지 안내 링크 | 영어 해지 안내 링크 | +| ----------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Spotify** | [https://www.spotify.com/kr/](https://www.spotify.com/kr/) | [https://www.spotify.com](https://www.spotify.com/) | [https://support.spotify.com/kr/article/premium-구독-취소/](https://support.spotify.com/kr/article/premium-%EA%B5%AC%EB%8F%85-%EC%B7%A8%EC%86%8C/) | [https://support.spotify.com/us/article/cancel-premium-subscription/](https://support.spotify.com/us/article/cancel-premium-subscription/) | +| **Apple Music** | [https://www.apple.com/kr/apple-music/](https://www.apple.com/kr/apple-music/) | [https://www.apple.com/apple-music/](https://www.apple.com/apple-music/) | [https://support.apple.com/ko-kr/HT204939](https://support.apple.com/ko-kr/HT204939) | [https://support.apple.com/en-us/HT204939](https://support.apple.com/en-us/HT204939) | +| **YouTube Music** | [https://music.youtube.com](https://music.youtube.com/) | [https://music.youtube.com](https://music.youtube.com/) | [https://support.google.com/youtubemusic/answer/6313533?hl=ko](https://support.google.com/youtubemusic/answer/6313533?hl=ko) | [https://support.google.com/youtubemusic/answer/6313533?hl=en](https://support.google.com/youtubemusic/answer/6313533?hl=en) | +| **Amazon Music** | [https://music.amazon.co.kr](https://music.amazon.co.kr/) | [https://music.amazon.com](https://music.amazon.com/) | [https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M) | [https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M) | +| **Deezer** | [https://www.deezer.com/kr/](https://www.deezer.com/kr/) | [https://www.deezer.com](https://www.deezer.com/) | [https://support.deezer.com/hc/ko/articles/115003525289](https://support.deezer.com/hc/ko/articles/115003525289) | [https://support.deezer.com/hc/en-gb/articles/115003525289-How-do-I-cancel-my-subscription-](https://support.deezer.com/hc/en-gb/articles/115003525289-How-do-I-cancel-my-subscription-) | +| **Tidal** | [https://tidal.com/kr/](https://tidal.com/kr/) | [https://tidal.com](https://tidal.com/) | [https://support.tidal.com/hc/ko/articles/115003662529](https://support.tidal.com/hc/ko/articles/115003662529) | [https://support.tidal.com/hc/en-us/articles/115003662529-How-do-I-cancel-my-TIDAL-subscription-](https://support.tidal.com/hc/en-us/articles/115003662529-How-do-I-cancel-my-TIDAL-subscription-) | +| **SoundCloud Go** | [https://soundcloud.com/connect](https://soundcloud.com/connect) | [https://soundcloud.com/connect](https://soundcloud.com/connect) | [https://help.soundcloud.com/hc/ko/articles/115003449467](https://help.soundcloud.com/hc/ko/articles/115003449467) | [https://help.soundcloud.com/hc/en-us/articles/115003449467-Canceling-your-Next-Pro-Plan-or-Go-subscription](https://help.soundcloud.com/hc/en-us/articles/115003449467-Canceling-your-Next-Pro-Plan-or-Go-subscription) | +| **Pandora** | [https://www.pandora.com](https://www.pandora.com/) | [https://www.pandora.com](https://www.pandora.com/) | 없음 | [https://help.pandora.com/s/article/Canceling-a-subscription?language=en_US](https://help.pandora.com/s/article/Canceling-a-subscription?language=en_US) | +| **QQ Music** | [https://y.qq.com](https://y.qq.com/) | [https://y.qq.com](https://y.qq.com/) | 없음 | 없음 | +| **Melon** | [https://www.melon.com](https://www.melon.com/) | [https://www.melon.com](https://www.melon.com/) | [https://help.melon.com/customer/faq/faq_view.htm?faqSeq=3701](https://help.melon.com/customer/faq/faq_view.htm?faqSeq=3701) | 없음 | +| **Genie Music** | [https://www.genie.co.kr](https://www.genie.co.kr/) | [https://www.genie.co.kr](https://www.genie.co.kr/) | [https://help.genie.co.kr/customer/faq/faq_view.htm?faqSeq=1132](https://help.genie.co.kr/customer/faq/faq_view.htm?faqSeq=1132) | 없음 | +| **FLO** | [https://www.music-flo.com](https://www.music-flo.com/) | [https://www.music-flo.com](https://www.music-flo.com/) | 없음 | 없음 | +| **Bugs** | [https://www.bugs.co.kr](https://www.bugs.co.kr/) | [https://www.bugs.co.kr](https://www.bugs.co.kr/) | [https://help.bugs.co.kr/faq/faqDetail?faqId=1000000000000039](https://help.bugs.co.kr/faq/faqDetail?faqId=1000000000000039) | 없음 | +| **VIBE** | [https://www.vibe.naver.com](https://www.vibe.naver.com/) | [https://www.vibe.naver.com](https://www.vibe.naver.com/) | 없음 | 없음 | +| **Line Music** | [https://music.line.me](https://music.line.me/) | [https://music.line.me](https://music.line.me/) | [https://help.line.me/line_music/?contentId=20011412](https://help.line.me/line_music/?contentId=20011412) | 없음 | +| **JOOX** | [https://www.joox.com](https://www.joox.com/) | [https://www.joox.com](https://www.joox.com/) | [https://www.joox.com/my-en/article/268](https://www.joox.com/my-en/article/268) | 없음 | +| **Anghami** | [https://www.anghami.com](https://www.anghami.com/) | [https://www.anghami.com](https://www.anghami.com/) | [https://support.anghami.com/ko/articles/2058632-how-can-i-cancel-my-anghami-plus-subscription](https://support.anghami.com/ko/articles/2058632-how-can-i-cancel-my-anghami-plus-subscription) | [https://support.anghami.com/en/articles/2058632-how-can-i-cancel-my-anghami-plus-subscription](https://support.anghami.com/en/articles/2058632-how-can-i-cancel-my-anghami-plus-subscription) | +| **Boomplay** | [https://www.boomplaymusic.com](https://www.boomplaymusic.com/) | [https://www.boomplaymusic.com](https://www.boomplaymusic.com/) | 없음 | 없음 | + +--- + +## 🎬 OTT (동영상 스트리밍) + +| 서비스명 | 한국 URL | 영어 URL | 한국어 해지 안내 링크 | 영어 해지 안내 링크 | +| ---------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| **Netflix** | [https://www.netflix.com/kr/](https://www.netflix.com/kr/) | [https://www.netflix.com](https://www.netflix.com/) | [https://help.netflix.com/ko/node/407](https://help.netflix.com/ko/node/407) | [https://help.netflix.com/en/node/407](https://help.netflix.com/en/node/407) | +| **Disney+** | [https://www.disneyplus.com/kr](https://www.disneyplus.com/kr) | [https://www.disneyplus.com](https://www.disneyplus.com/) | 없음 | 없음 | +| **Apple TV+** | [https://tv.apple.com/kr/](https://tv.apple.com/kr/) | [https://tv.apple.com](https://tv.apple.com/) | [https://support.apple.com/ko-kr/HT207043](https://support.apple.com/ko-kr/HT207043) | [https://support.apple.com/en-us/HT207043](https://support.apple.com/en-us/HT207043) | +| **Amazon Prime Video** | [https://www.primevideo.com](https://www.primevideo.com/) | [https://www.primevideo.com](https://www.primevideo.com/) | [https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M) | [https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M) | +| **Hulu** | [https://www.hulu.com](https://www.hulu.com/) | [https://www.hulu.com](https://www.hulu.com/) | [https://help.hulu.com/hc/ko/articles/360001164823](https://help.hulu.com/hc/ko/articles/360001164823) | [https://help.hulu.com/s/article/how-do-i-cancel](https://help.hulu.com/s/article/how-do-i-cancel) | +| **Paramount+** | [https://www.paramountplus.com/kr](https://www.paramountplus.com/kr) | [https://www.paramountplus.com](https://www.paramountplus.com/) | [https://help.paramountplus.com/hc/ko/articles/360052491132](https://help.paramountplus.com/hc/ko/articles/360052491132) | [https://help.paramountplus.com/s/article/PD-How-can-I-cancel-my-subscription](https://help.paramountplus.com/s/article/PD-How-can-I-cancel-my-subscription) | +| **HBO Max** | [https://www.hbomax.com/kr/](https://www.hbomax.com/kr/) | [https://www.hbomax.com](https://www.hbomax.com/) | [https://help.hbomax.com/kr/Answer/Detail/000001278](https://help.hbomax.com/kr/Answer/Detail/000001278) | [https://help.hbomax.com/Answer/Detail/000001278](https://help.hbomax.com/Answer/Detail/000001278) | +| **Peacock** | [https://www.peacocktv.com](https://www.peacocktv.com/) | [https://www.peacocktv.com](https://www.peacocktv.com/) | [https://www.peacocktv.com/help/article/cancel](https://www.peacocktv.com/help/article/cancel) | [https://www.peacocktv.com/help/article/cancel](https://www.peacocktv.com/help/article/cancel) | +| **YouTube Premium** | [https://www.youtube.com/premium?gl=KR](https://www.youtube.com/premium?gl=KR) | [https://www.youtube.com/premium](https://www.youtube.com/premium) | [https://support.google.com/youtube/answer/6306271?hl=ko](https://support.google.com/youtube/answer/6306271?hl=ko) | [https://support.google.com/youtube/answer/6306271?hl=en](https://support.google.com/youtube/answer/6306271?hl=en) | +| **TVING** | [https://www.tving.com](https://www.tving.com/) | [https://www.tving.com](https://www.tving.com/) | 없음 | 없음 | +| **Wavve** | [https://www.wavve.com](https://www.wavve.com/) | [https://www.wavve.com](https://www.wavve.com/) | 없음 | 없음 | +| **Coupang Play** | [https://www.coupangplay.com](https://www.coupangplay.com/) | [https://www.coupangplay.com](https://www.coupangplay.com/) | 없음 | 없음 | +| **Watcha** | [https://watcha.com](https://watcha.com/) | [https://watcha.com](https://watcha.com/) | 없음 | 없음 | +| **Series On** | [https://www.serieson.com](https://www.serieson.com/) | [https://www.serieson.com](https://www.serieson.com/) | 없음 | 없음 | +| **U+모바일tv** | [https://www.lguplus.com](https://www.lguplus.com/) | [https://www.lguplus.com](https://www.lguplus.com/) | 없음 | 없음 | +| **B tv** | [https://www.skbroadband.com](https://www.skbroadband.com/) | [https://www.skbroadband.com](https://www.skbroadband.com/) | 없음 | 없음 | +| **Seezn** | [https://www.seezn.com](https://www.seezn.com/) | [https://www.seezn.com](https://www.seezn.com/) | 없음 | 없음 | +| **Rakuten Viki** | [https://www.viki.com](https://www.viki.com/) | [https://www.viki.com](https://www.viki.com/) | 없음 | [https://support.viki.com/hc/en-us/articles/200139754-How-do-I-cancel-my-Viki-Pass-subscription-](https://support.viki.com/hc/en-us/articles/200139754-How-do-I-cancel-my-Viki-Pass-subscription-) | +| **Crunchyroll** | [https://www.crunchyroll.com](https://www.crunchyroll.com/) | [https://www.crunchyroll.com](https://www.crunchyroll.com/) | 없음 | [https://help.crunchyroll.com/hc/en-us/articles/206513166-How-do-I-cancel-my-membership-](https://help.crunchyroll.com/hc/en-us/articles/206513166-How-do-I-cancel-my-membership-) | +| **DAZN** | [https://www.dazn.com](https://www.dazn.com/) | [https://www.dazn.com](https://www.dazn.com/) | 없음 | [https://www.dazn.com/en-GB/help/articles/how-do-i-cancel-my-subscription](https://www.dazn.com/en-GB/help/articles/how-do-i-cancel-my-subscription) | +| **ESPN+** | [https://plus.espn.com](https://plus.espn.com/) | [https://plus.espn.com](https://plus.espn.com/) | 없음 | [https://help.espnplus.com/hc/en-us/articles/360003403132-How-do-I-cancel-my-subscription-](https://help.espnplus.com/hc/en-us/articles/360003403132-How-do-I-cancel-my-subscription-) | +| **Discovery+** | [https://www.discoveryplus.com](https://www.discoveryplus.com/) | [https://www.discoveryplus.com](https://www.discoveryplus.com/) | 없음 | [https://help.discoveryplus.com/hc/en-us/articles/360055508474-How-do-I-cancel-my-subscription-](https://help.discoveryplus.com/hc/en-us/articles/360055508474-How-do-I-cancel-my-subscription-) | +| **Sling TV** | [https://www.sling.com](https://www.sling.com/) | [https://www.sling.com](https://www.sling.com/) | 없음 | [https://www.sling.com/help/en/account/cancel-sling](https://www.sling.com/help/en/account/cancel-sling) | +| **fuboTV** | [https://www.fubo.tv](https://www.fubo.tv/) | [https://www.fubo.tv](https://www.fubo.tv/) | 없음 | [https://support.fubo.tv/hc/en-us/articles/360021351192-How-do-I-cancel-my-subscription-](https://support.fubo.tv/hc/en-us/articles/360021351192-How-do-I-cancel-my-subscription-) | +| **Pluto TV** | [https://pluto.tv](https://pluto.tv/) | [https://pluto.tv](https://pluto.tv/) | 없음 | 없음 | +| **Vudu** | [https://www.vudu.com](https://www.vudu.com/) | [https://www.vudu.com](https://www.vudu.com/) | 없음 | 없음 | +| **Crackle** | [https://www.crackle.com](https://www.crackle.com/) | [https://www.crackle.com](https://www.crackle.com/) | 없음 | 없음 | +| **Shahid VIP** | [https://shahid.mbc.net](https://shahid.mbc.net/) | [https://shahid.mbc.net](https://shahid.mbc.net/) | 없음 | [https://help.shahid.net/hc/en-us/articles/360010377760-How-can-I-cancel-my-subscription-](https://help.shahid.net/hc/en-us/articles/360010377760-How-can-I-cancel-my-subscription-) | + +--- + +## ☁️ 저장 (클라우드/파일) + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**Google Drive**|[https://www.google.com/drive/](https://www.google.com/drive/)|[https://www.google.com/drive/](https://www.google.com/drive/)|[https://support.google.com/drive/answer/2375082?hl=ko](https://support.google.com/drive/answer/2375082?hl=ko) (구글 드라이브 플랜 취소 방법 안내) ([Google Help](https://support.google.com/googleone/answer/6374282?co=GENIE.Platform%3DDesktop&hl=en&utm_source=chatgpt.com "Change or cancel storage plans - Computer - Google One Help"))|[https://support.google.com/drive/answer/2375082?hl=en](https://support.google.com/drive/answer/2375082?hl=en)| +|**Dropbox**|[https://www.dropbox.com](https://www.dropbox.com/)|[https://www.dropbox.com](https://www.dropbox.com/)|[https://help.dropbox.com/plans/downgrade-dropbox-individual-plans](https://help.dropbox.com/plans/downgrade-dropbox-individual-plans) (계정 취소 안내)|[https://help.dropbox.com/plans/downgrade-dropbox-individual-plans](https://help.dropbox.com/plans/downgrade-dropbox-individual-plans)| +|**Microsoft OneDrive**|[https://www.onedrive.com](https://www.onedrive.com/)|[https://www.onedrive.com](https://www.onedrive.com/)|**삭제됨** (한국어 취소 페이지 찾을 수 없음)|[https://support.microsoft.com/en-us/office/cancel-your-microsoft-365-subscription-](https://support.microsoft.com/en-us/office/cancel-your-microsoft-365-subscription-)...| +|**Apple iCloud**|[https://www.icloud.com](https://www.icloud.com/)|[https://www.icloud.com](https://www.icloud.com/)|**삭제됨** (국문 해지 페이지 확인 불가)|[https://support.apple.com/en-us/HT207043](https://support.apple.com/en-us/HT207043)| +|**Box**|[https://www.box.com](https://www.box.com/)|[https://www.box.com](https://www.box.com/)|**삭제됨** (국문 해지 페이지 확인 불가)|[https://support.box.com/hc/en-us/articles/360044194693-How-Do-I-Cancel-My-Box-Subscription-](https://support.box.com/hc/en-us/articles/360044194693-How-Do-I-Cancel-My-Box-Subscription-)| +|**pCloud**|[https://www.pcloud.com](https://www.pcloud.com/)|[https://www.pcloud.com](https://www.pcloud.com/)|**삭제됨** (국문 해지 안내 페이지 없음)|[https://www.pcloud.com/help/general-help/how-to-cancel-your-subscription.html](https://www.pcloud.com/help/general-help/how-to-cancel-your-subscription.html)| +|**MEGA**|[https://mega.nz](https://mega.nz/)|[https://mega.nz](https://mega.nz/)|**삭제됨**|[https://help.mega.io/hc/en-us/articles/360042469013-How-do-I-cancel-my-subscription-](https://help.mega.io/hc/en-us/articles/360042469013-How-do-I-cancel-my-subscription-)| +|**Tresorit**|[https://tresorit.com](https://tresorit.com/)|[https://tresorit.com](https://tresorit.com/)|**삭제됨**|[https://support.tresorit.com/hc/en-us/articles/360014951479-How-to-cancel-your-subscription](https://support.tresorit.com/hc/en-us/articles/360014951479-How-to-cancel-your-subscription)| +|**Amazon Drive**|[https://www.amazon.com/clouddrive](https://www.amazon.com/clouddrive)|[https://www.amazon.com/clouddrive](https://www.amazon.com/clouddrive)|[https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M)|[https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M)| +|**Sync.com**|[https://www.sync.com](https://www.sync.com/)|[https://www.sync.com](https://www.sync.com/)|[https://help.sync.com/hc/ko/articles/360014003492](https://help.sync.com/hc/ko/articles/360014003492)|[https://help.sync.com/hc/en-us/articles/360014003492-How-do-I-cancel-my-Sync-subscription-](https://help.sync.com/hc/en-us/articles/360014003492-How-do-I-cancel-my-Sync-subscription-)| +|**Yandex Disk**|[https://disk.yandex.com](https://disk.yandex.com/)|[https://disk.yandex.com](https://disk.yandex.com/)|**삭제됨**|[https://yandex.com/support/disk/buy/cancel.html](https://yandex.com/support/disk/buy/cancel.html)| +|**iDrive**|[https://www.idrive.com](https://www.idrive.com/)|[https://www.idrive.com](https://www.idrive.com/)|[https://www.idrive.com/faq/account/cancel-account](https://www.idrive.com/faq/account/cancel-account)|[https://www.idrive.com/faq/account/cancel-account](https://www.idrive.com/faq/account/cancel-account)| +|**Backblaze**|[https://www.backblaze.com](https://www.backblaze.com/)|[https://www.backblaze.com](https://www.backblaze.com/)|[https://help.backblaze.com/hc/ko/articles/217665008](https://help.backblaze.com/hc/ko/articles/217665008)|[https://help.backblaze.com/hc/en-us/articles/217665008-How-to-Cancel-Your-Backblaze-Subscription](https://help.backblaze.com/hc/en-us/articles/217665008-How-to-Cancel-Your-Backblaze-Subscription)| +|**Koofr**|[https://koofr.eu](https://koofr.eu/)|[https://koofr.eu](https://koofr.eu/)|[https://koofr.eu/blog/posts/how-to-cancel-your-koofr-subscription](https://koofr.eu/blog/posts/how-to-cancel-your-koofr-subscription)|[https://koofr.eu/blog/posts/how-to-cancel-your-koofr-subscription](https://koofr.eu/blog/posts/how-to-cancel-your-koofr-subscription)| +|**네이버 MY BOX**|[https://mybox.naver.com](https://mybox.naver.com/)|—|[https://help.naver.com/service/5638/contents/10041?osType=PC](https://help.naver.com/service/5638/contents/10041?osType=PC)|없음| + +--- + +## 📡 통신 · 인터넷 · TV + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**T‑Mobile (미국)**|[https://www.t-mobile.com](https://www.t-mobile.com/)|[https://www.t-mobile.com](https://www.t-mobile.com/)|**삭제됨** (국문 페이지 없음)|[https://www.t-mobile.com/support/account/cancel-service](https://www.t-mobile.com/support/account/cancel-service)| +|**SK텔레콤 (SKT)**|[https://www.sktelecom.com](https://www.sktelecom.com/)|[https://www.sktelecom.com](https://www.sktelecom.com/)|[https://www.sktelecom.com/support/cancel.do](https://www.sktelecom.com/support/cancel.do)|동일| +|**KT**|[https://www.kt.com](https://www.kt.com/)|[https://www.kt.com](https://www.kt.com/)|**삭제됨** (제공 정보 오류)|동일| +|**LG U+**|[https://www.lguplus.com](https://www.lguplus.com/)|[https://www.lguplus.com](https://www.lguplus.com/)|[https://www.lguplus.com/support/faq/faqDetail?faqId=FAQ00000000000002720](https://www.lguplus.com/support/faq/faqDetail?faqId=FAQ00000000000002720)|동일| +|**올레 tv (KT)**|[https://www.kt.com/olleh_tv](https://www.kt.com/olleh_tv)|[https://www.kt.com/olleh_tv](https://www.kt.com/olleh_tv)|**삭제됨** (잘못된 안내)|동일| +|**B tv (SK Broadband)**|[https://www.skbroadband.com](https://www.skbroadband.com/)|[https://www.skbroadband.com](https://www.skbroadband.com/)|[https://www.skbroadband.com/customer/faq/faqView.do?faqSeq=1000000000000030](https://www.skbroadband.com/customer/faq/faqView.do?faqSeq=1000000000000030)|동일| +|**LG U+인터넷 / U+모바일tv**|[https://www.lguplus.com](https://www.lguplus.com/)|[https://www.lguplus.com](https://www.lguplus.com/)|[https://www.lguplus.com/support/faq/faqDetail?faqId=FAQ00000000000002720](https://www.lguplus.com/support/faq/faqDetail?faqId=FAQ00000000000002720)|동일| + +--- + +## 🏠 생활/라이프스타일 + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**네이버 플러스 멤버십**|[https://plus.naver.com](https://plus.naver.com/)|—|[https://help.naver.com/service/5638/contents/10041?osType=PC](https://help.naver.com/service/5638/contents/10041?osType=PC)|—| +|**카카오 구독 ON**|[https://subscribe.kakao.com](https://subscribe.kakao.com/)|—|없음|—| +|**쿠팡 와우 멤버십**|[https://www.coupang.com/np/coupangplus](https://www.coupang.com/np/coupangplus)|—|[https://help.coupang.com/cc/ko/contents/faq/1000002013](https://help.coupang.com/cc/ko/contents/faq/1000002013)|—| +|**스타벅스 버디 패스**|[https://www.starbucks.co.kr](https://www.starbucks.co.kr/)|—|없음|—| +|**CU 구독**|[https://cu.bgfretail.com](https://cu.bgfretail.com/)|—|없음|—| +|**GS25 구독**|[https://gs25.gsretail.com](https://gs25.gsretail.com/)|—|없음|—| +|**현대차 차량 구독**|[https://www.hyundai.com/kr/ko/eco/vehicle-subscription](https://www.hyundai.com/kr/ko/eco/vehicle-subscription)|—|없음|—| +|**LG전자 가전 구독**|[https://www.lge.co.kr](https://www.lge.co.kr/)|—|없음|—| +|**삼성전자 가전 구독**|[https://www.samsung.com/sec](https://www.samsung.com/sec)|—|없음|—| +|**다이슨 케어 플랜**|[https://www.dyson.co.kr](https://www.dyson.co.kr/)|—|없음|—| +|**헬로네이처/마켓컬리 멤버십**|[https://www.hellonature.com](https://www.hellonature.com/) / [https://www.kurly.com](https://www.kurly.com/)|—|없음|—| +|**이마트 트레이더스 멤버십**|[https://www.emarttraders.co.kr](https://www.emarttraders.co.kr/)|—|없음|—| +|**홈플러스 멤버십**|[https://www.homeplus.co.kr](https://www.homeplus.co.kr/)|—|없음|—| +|**HelloFresh**|[https://www.hellofresh.com](https://www.hellofresh.com/)|[https://www.hellofresh.com](https://www.hellofresh.com/)|[https://www.hellofresh.com/contact-us/cancel](https://www.hellofresh.com/contact-us/cancel) (정상 작동)|[https://www.hellofresh.com/contact-us/cancel](https://www.hellofresh.com/contact-us/cancel)| +|**Bespoke Post**|[https://www.bespokepost.com](https://www.bespokepost.com/)|[https://www.bespokepost.com](https://www.bespokepost.com/)|[https://www.bespokepost.com/faq#cancel](https://www.bespokepost.com/faq#cancel) (정상 작동)|[https://www.bespokepost.com/faq#cancel](https://www.bespokepost.com/faq#cancel)| + +--- + +## 🛒 쇼핑/이커머스 + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**Amazon Prime**|[https://www.amazon.co.kr/prime](https://www.amazon.co.kr/prime)|[https://www.amazon.com/prime](https://www.amazon.com/prime)|[https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.co.kr/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M)|[https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M](https://www.amazon.com/gp/help/customer/display.html?nodeId=G2K2F2D8K9YJ4Y7M)| +|**Walmart+**|—|[https://www.walmart.com/plus](https://www.walmart.com/plus)|—|[https://www.walmart.com/help/article/how-do-i-cancel-my-walmart-membership/2c1f2b2c9e6e4e3c9c8d9e5e](https://www.walmart.com/help/article/how-do-i-cancel-my-walmart-membership/2c1f2b2c9e6e4e3c9c8d9e5e)| +|**Chewy**|—|[https://www.chewy.com](https://www.chewy.com/)|—|[https://www.chewy.com/app/content/subscriptions](https://www.chewy.com/app/content/subscriptions)| +|**Dollar Shave Club**|—|[https://www.dollarshaveclub.com](https://www.dollarshaveclub.com/)|—|[https://help.dollarshaveclub.com/hc/en-us/articles/115005575206-How-do-I-cancel-my-membership-](https://help.dollarshaveclub.com/hc/en-us/articles/115005575206-How-do-I-cancel-my-membership-)| +|**Instacart Express**|—|[https://www.instacart.com](https://www.instacart.com/)|—|[https://www.instacart.com/help/section/200758574](https://www.instacart.com/help/section/200758574)| +|**Shipt**|—|[https://www.shipt.com](https://www.shipt.com/)|—|[https://help.shipt.com/account-management/how-do-i-cancel-my-shipt-membership](https://help.shipt.com/account-management/how-do-i-cancel-my-shipt-membership)| +|**HelloFresh**|[https://www.hellofresh.com](https://www.hellofresh.com/)|[https://www.hellofresh.com](https://www.hellofresh.com/)|[https://www.hellofresh.com/contact-us/cancel](https://www.hellofresh.com/contact-us/cancel) (정상 작동)|[https://www.hellofresh.com/contact-us/cancel](https://www.hellofresh.com/contact-us/cancel)| +|**Grove Collaborative**|—|[https://grove.co](https://grove.co/)|—|[https://support.grove.co/hc/en-us/articles/360026120491-How-do-I-cancel-my-VIP-membership-](https://support.grove.co/hc/en-us/articles/360026120491-How-do-I-cancel-my-VIP-membership-)| +|**Cratejoy**|—|[https://www.cratejoy.com](https://www.cratejoy.com/)|—|[https://support.cratejoy.com/hc/en-us/articles/360045202232-How-do-I-cancel-my-subscription-](https://support.cratejoy.com/hc/en-us/articles/360045202232-How-do-I-cancel-my-subscription-)| +|**Shopify**|—|[https://www.shopify.com](https://www.shopify.com/)|—|[https://help.shopify.com/en/manual/your-account/pause-close-store](https://help.shopify.com/en/manual/your-account/pause-close-store)| +|**Subbly**|—|[https://www.subbly.co](https://www.subbly.co/)|—|[https://help.subbly.co/en/articles/3275896-how-to-cancel-a-subscription](https://help.subbly.co/en/articles/3275896-how-to-cancel-a-subscription)| +|**Shift4Shop**|—|[https://www.shift4shop.com](https://www.shift4shop.com/)|—|없음| +|**Pabbly**|—|[https://www.pabbly.com](https://www.pabbly.com/)|—|[https://help.pabbly.com/portal/en/kb/articles/how-to-cancel-your-subscription](https://help.pabbly.com/portal/en/kb/articles/how-to-cancel-your-subscription)| + +--- + +## 💻 프로그래밍/개발 + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**GitHub Copilot**|[https://github.com/features/copilot/](https://github.com/features/copilot/)|[https://github.com/features/copilot/](https://github.com/features/copilot/)|[https://docs.github.com/ko/billing/managing-billing-for-github-copilot/canceling-github-copilot-subscription](https://docs.github.com/ko/billing/managing-billing-for-github-copilot/canceling-github-copilot-subscription)|[https://docs.github.com/en/billing/managing-billing-for-github-copilot/canceling-github-copilot-subscription](https://docs.github.com/en/billing/managing-billing-for-github-copilot/canceling-github-copilot-subscription)| +|**ChatGPT Plus**|[https://chat.openai.com/plus](https://chat.openai.com/plus)|[https://chat.openai.com/plus](https://chat.openai.com/plus)|[https://help.openai.com/ko/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription](https://help.openai.com/ko/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription)|[https://help.openai.com/en/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription](https://help.openai.com/en/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription)| +|**Replit**|[https://replit.com](https://replit.com/)|[https://replit.com](https://replit.com/)|없음|[https://docs.replit.com/teams/teams-pro/cancel-teams-pro](https://docs.replit.com/teams/teams-pro/cancel-teams-pro)| +|**JetBrains All Products Pack**|[https://www.jetbrains.com/all/](https://www.jetbrains.com/all/)|[https://www.jetbrains.com/all/](https://www.jetbrains.com/all/)|없음|[https://sales.jetbrains.com/hc/en-gb/articles/206544589-How-to-cancel-your-subscription](https://sales.jetbrains.com/hc/en-gb/articles/206544589-How-to-cancel-your-subscription)| +|**Microsoft Visual Studio Subscription**|[https://visualstudio.microsoft.com/subscriptions/](https://visualstudio.microsoft.com/subscriptions/)|[https://visualstudio.microsoft.com/subscriptions/](https://visualstudio.microsoft.com/subscriptions/)|없음|[https://learn.microsoft.com/en-us/visualstudio/subscriptions/cancel-subscription](https://learn.microsoft.com/en-us/visualstudio/subscriptions/cancel-subscription)| +|**GitHub Pro**|[https://github.com](https://github.com/)|[https://github.com](https://github.com/)|없음|[https://docs.github.com/en/billing/managing-billing-for-your-github-account/canceling-a-personal-account-subscription](https://docs.github.com/en/billing/managing-billing-for-your-github-account/canceling-a-personal-account-subscription)| +|**Notion Plus**|[https://www.notion.so](https://www.notion.so/)|[https://www.notion.so](https://www.notion.so/)|없음|[https://www.notion.so/help/cancel-subscription](https://www.notion.so/help/cancel-subscription)| +|**Evernote Premium**|[https://evernote.com](https://evernote.com/)|[https://evernote.com](https://evernote.com/)|없음|[https://help.evernote.com/hc/en-us/articles/209005247-How-to-cancel-your-subscription](https://help.evernote.com/hc/en-us/articles/209005247-How-to-cancel-your-subscription)| +|**Zoom Pro**|[https://zoom.us/pricing](https://zoom.us/pricing)|[https://zoom.us/pricing](https://zoom.us/pricing)|없음|[https://support.zoom.us/hc/en-us/articles/201363233-How-Do-I-Cancel-My-Subscription-](https://support.zoom.us/hc/en-us/articles/201363233-How-Do-I-Cancel-My-Subscription-)| +|**Slack Pro**|[https://slack.com](https://slack.com/)|[https://slack.com](https://slack.com/)|없음|[https://slack.com/help/articles/204475027-Cancel-your-paid-Slack-subscription](https://slack.com/help/articles/204475027-Cancel-your-paid-Slack-subscription)| +|**Trello Gold**|[https://trello.com](https://trello.com/)|[https://trello.com](https://trello.com/)|없음|[https://support.atlassian.com/trello/docs/canceling-trello-gold-or-standard/](https://support.atlassian.com/trello/docs/canceling-trello-gold-or-standard/)| +|**Todoist Premium**|[https://todoist.com](https://todoist.com/)|[https://todoist.com](https://todoist.com/)|없음|[https://get.todoist.help/hc/en-us/articles/360000420760-Canceling-a-Todoist-Premium-or-Business-subscription](https://get.todoist.help/hc/en-us/articles/360000420760-Canceling-a-Todoist-Premium-or-Business-subscription)| +|**Asana Premium**|[https://asana.com](https://asana.com/)|[https://asana.com](https://asana.com/)|없음|[https://asana.com/guide/help/premium/cancel](https://asana.com/guide/help/premium/cancel)| +|**Grammarly Premium**|[https://grammarly.com](https://grammarly.com/)|[https://grammarly.com](https://grammarly.com/)|없음|[https://support.grammarly.com/hc/en-us/articles/115000091651-How-do-I-cancel-my-subscription-](https://support.grammarly.com/hc/en-us/articles/115000091651-How-do-I-cancel-my-subscription-)| + +--- + +## 🏢 오피스 / 협업툴 + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**Microsoft 365**|[https://www.microsoft.com/microsoft-365](https://www.microsoft.com/microsoft-365)|[https://www.microsoft.com/microsoft-365](https://www.microsoft.com/microsoft-365)|없음|[https://support.microsoft.com/en-us/account-billing/cancel-a-microsoft-subscription-b1c8be5d-](https://support.microsoft.com/en-us/account-billing/cancel-a-microsoft-subscription-b1c8be5d-)...| +|**Google Workspace**|[https://workspace.google.com](https://workspace.google.com/)|[https://workspace.google.com](https://workspace.google.com/)|없음|[https://support.google.com/a/answer/1257646?hl=en](https://support.google.com/a/answer/1257646?hl=en)| +|**Slack**|[https://slack.com](https://slack.com/)|[https://slack.com](https://slack.com/)|없음|[https://slack.com/help/articles/204475027-Cancel-your-paid-Slack-subscription](https://slack.com/help/articles/204475027-Cancel-your-paid-Slack-subscription)| +|**Notion**|[https://www.notion.so](https://www.notion.so/)|[https://www.notion.so](https://www.notion.so/)|없음|[https://www.notion.so/help/cancel-subscription](https://www.notion.so/help/cancel-subscription)| +|**Zoom**|[https://zoom.us](https://zoom.us/)|[https://zoom.us](https://zoom.us/)|없음|[https://support.zoom.us/hc/en-us/articles/201363233-How-Do-I-Cancel-My-Subscription-](https://support.zoom.us/hc/en-us/articles/201363233-How-Do-I-Cancel-My-Subscription-)| +|**Wrike**|[https://www.wrike.com](https://www.wrike.com/)|[https://www.wrike.com](https://www.wrike.com/)|없음|[https://help.wrike.com/hc/en-us/articles/210324585-Cancel-a-subscription](https://help.wrike.com/hc/en-us/articles/210324585-Cancel-a-subscription)| +|**Hive**|[https://hive.com](https://hive.com/)|[https://hive.com](https://hive.com/)|없음|[https://help.hive.com/hc/en-us/articles/360018286773-How-to-cancel-your-Hive-subscription](https://help.hive.com/hc/en-us/articles/360018286773-How-to-cancel-your-Hive-subscription)| +|**Monday.com**|[https://monday.com](https://monday.com/)|[https://monday.com](https://monday.com/)|없음|[https://support.monday.com/hc/en-us/articles/360002197259-How-to-cancel-your-account](https://support.monday.com/hc/en-us/articles/360002197259-How-to-cancel-your-account)| +|**ClickUp**|[https://clickup.com](https://clickup.com/)|[https://clickup.com](https://clickup.com/)|없음|[https://help.clickup.com/hc/en-us/articles/6317214434711-Cancel-your-subscription](https://help.clickup.com/hc/en-us/articles/6317214434711-Cancel-your-subscription)| +|**Dropbox Paper**|[https://paper.dropbox.com](https://paper.dropbox.com/)|[https://paper.dropbox.com](https://paper.dropbox.com/)|없음|[https://help.dropbox.com/en-us/billing/cancellations-refunds/cancel-dropbox-plus](https://help.dropbox.com/en-us/billing/cancellations-refunds/cancel-dropbox-plus)| +|**Confluence**|[https://www.atlassian.com/software/confluence](https://www.atlassian.com/software/confluence)|[https://www.atlassian.com/software/confluence](https://www.atlassian.com/software/confluence)|없음|[https://support.atlassian.com/confluence-cloud/docs/cancel-your-subscription/](https://support.atlassian.com/confluence-cloud/docs/cancel-your-subscription/)| +|**Jira**|[https://www.atlassian.com/software/jira](https://www.atlassian.com/software/jira)|[https://www.atlassian.com/software/jira](https://www.atlassian.com/software/jira)|없음|[https://support.atlassian.com/jira-cloud-administration/docs/cancel-your-subscription/](https://support.atlassian.com/jira-cloud-administration/docs/cancel-your-subscription/)| +|**Asana**|[https://asana.com](https://asana.com/)|[https://asana.com](https://asana.com/)|없음|[https://asana.com/guide/help/premium/cancel](https://asana.com/guide/help/premium/cancel)| +|**Trello**|[https://trello.com](https://trello.com/)|[https://trello.com](https://trello.com/)|없음|[https://support.atlassian.com/trello/docs/canceling-trello-gold-or-standard/](https://support.atlassian.com/trello/docs/canceling-trello-gold-or-standard/)| +|**Quip**|[https://quip.com](https://quip.com/)|[https://quip.com](https://quip.com/)|없음|[https://help.salesforce.com/s/articleView?id=000352034&type=1](https://help.salesforce.com/s/articleView?id=000352034&type=1)| + +--- + +## 🤖 AI 서비스 + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**ChatGPT Plus**|[https://chat.openai.com/plus](https://chat.openai.com/plus)|[https://chat.openai.com/plus](https://chat.openai.com/plus)|[https://help.openai.com/ko/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription](https://help.openai.com/ko/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription)|[https://help.openai.com/en/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription](https://help.openai.com/en/articles/6611477-how-do-i-cancel-my-chatgpt-plus-subscription)| +|**Claude Pro**|—|[https://cloud.ai](https://cloud.ai/)|—|—| +|**Google Gemini Advanced**|—|[https://gemini.google.com](https://gemini.google.com/)|—|—| +|**Microsoft Copilot Pro**|—|[https://copilot.microsoft.com](https://copilot.microsoft.com/)|—|—| +|**Jasper AI**|—|[https://www.jasper.ai](https://www.jasper.ai/)|—|[https://help.jasper.ai/hc/en-us/articles/4410013724823-How-do-I-cancel-my-subscription-](https://help.jasper.ai/hc/en-us/articles/4410013724823-How-do-I-cancel-my-subscription-)| +|**Midjourney**|—|[https://www.midjourney.com](https://www.midjourney.com/)|—|[https://docs.midjourney.com/docs/account-management#canceling-your-subscription](https://docs.midjourney.com/docs/account-management#canceling-your-subscription)| +|**DALL·E**|—|[https://labs.openai.com](https://labs.openai.com/)|—|—| +|**Synthesia**|—|[https://www.synthesia.io](https://www.synthesia.io/)|—|[https://help.synthesia.io/en/articles/5184675-how-to-cancel-your-subscription](https://help.synthesia.io/en/articles/5184675-how-to-cancel-your-subscription)| +|**DeepL Pro**|—|[https://www.deepl.com/pro](https://www.deepl.com/pro)|—|[https://support.deepl.com/hc/en-us/articles/360020113099-Canceling-or-deleting-your-subscription](https://support.deepl.com/hc/en-us/articles/360020113099-Canceling-or-deleting-your-subscription)| +|**Copy.ai**|—|[https://www.copy.ai](https://www.copy.ai/)|—|[https://help.copy.ai/en/articles/4621193-how-do-i-cancel-my-subscription](https://help.copy.ai/en/articles/4621193-how-do-i-cancel-my-subscription)| +|**Writesonic**|—|[https://writesonic.com](https://writesonic.com/)|—|[https://writesonic.com/blog/how-to-cancel-your-writesonic-subscription/](https://writesonic.com/blog/how-to-cancel-your-writesonic-subscription/)| +|**Perplexity Pro**|—|[https://www.perplexity.ai](https://www.perplexity.ai/)|—|—| + +--- + +## 📚 기타 (전자책 · 클래스 등) + +|서비스명|한국 URL|영어 URL|한국어 해지 안내 링크|영어 해지 안내 링크| +|---|---|---|---|---| +|**Audible**|[https://www.audible.com/](https://www.audible.com/)|[https://www.audible.com/](https://www.audible.com/)|[https://help.audible.com/ko/articles/How-can-I-cancel-my-membership](https://help.audible.com/ko/articles/How-can-I-cancel-my-membership)|[https://help.audible.com/s/article/How-can-I-cancel-my-membership](https://help.audible.com/s/article/How-can-I-cancel-my-membership)| +|**밀리의 서재**|[https://www.ridicdn.net](https://www.ridicdn.net/)|—|[https://www.millie.co.kr/faq/1000000000000001](https://www.millie.co.kr/faq/1000000000000001)|—| +|**리디셀렉트**|[https://www.ridicdn.net](https://www.ridicdn.net/)|—|[https://help.ridibooks.com/hc/ko/articles/360006263453-%EC%84%A4%EC%A0%95-%EA%B5%AC%EB%8F%85-%EC%B7%A8%EC%86%8C-%EB%B0%A9%EB%B2%95](https://help.ridibooks.com/hc/ko/articles/360006263453-%EC%84%A4%EC%A0%95-%EA%B5%AC%EB%8F%85-%EC%B7%A8%EC%86%8C-%EB%B0%A9%EB%B2%95)|—| +|**Scribd**|[https://www.scribd.com](https://www.scribd.com/)|[https://www.scribd.com](https://www.scribd.com/)|[https://support.scribd.com/hc/ko/articles/210134386](https://support.scribd.com/hc/ko/articles/210134386)|[https://support.scribd.com/hc/en-us/articles/210134386-How-do-I-cancel-my-subscription-](https://support.scribd.com/hc/en-us/articles/210134386-How-do-I-cancel-my-subscription-)| +|**클래스101**|[https://class101.net](https://class101.net/)|—|[https://101.gg/faq/5c6b7b7d1d5c1c001f7c3c3b](https://101.gg/faq/5c6b7b7d1d5c1c001f7c3c3b)|—| +|**탈잉**|[https://taling.me](https://taling.me/)|—|[https://taling.me/faq/faq_view/213](https://taling.me/faq/faq_view/213)|—| +|**Coursera Plus**|[https://www.coursera.org](https://www.coursera.org/)|[https://www.coursera.org](https://www.coursera.org/)|[https://learner.coursera.help/hc/ko/articles/209818613](https://learner.coursera.help/hc/ko/articles/209818613)|[https://learner.coursera.help/hc/en-us/articles/209818613-Cancel-a-subscription](https://learner.coursera.help/hc/en-us/articles/209818613-Cancel-a-subscription)| +|**Udemy Pro**|[https://www.udemy.com](https://www.udemy.com/)|[https://www.udemy.com](https://www.udemy.com/)|[https://support.udemy.com/hc/ko/articles/229605008](https://support.udemy.com/hc/ko/articles/229605008)|[https://support.udemy.com/hc/en-us/articles/229605008-Canceling-a-Udemy-subscription](https://support.udemy.com/hc/en-us/articles/229605008-Canceling-a-Udemy-subscription)| +|**Skillshare**|[https://www.skillshare.com](https://www.skillshare.com/)|[https://www.skillshare.com](https://www.skillshare.com/)|[https://help.skillshare.com/hc/ko/articles/206760685](https://help.skillshare.com/hc/ko/articles/206760685)|[https://help.skillshare.com/hc/en-us/articles/206760685-How-do-I-cancel-my-membership-](https://help.skillshare.com/hc/en-us/articles/206760685-How-do-I-cancel-my-membership-)| +|**MasterClass**|[https://www.masterclass.com](https://www.masterclass.com/)|[https://www.masterclass.com](https://www.masterclass.com/)|[https://support.masterclass.com/hc/ko/articles/115015678168](https://support.masterclass.com/hc/ko/articles/115015678168)|[https://support.masterclass.com/hc/en-us/articles/115015678168-How-do-I-cancel-my-membership-](https://support.masterclass.com/hc/en-us/articles/115015678168-How-do-I-cancel-my-membership-)| +|**Calm Premium**|[https://www.calm.com](https://www.calm.com/)|[https://www.calm.com](https://www.calm.com/)|[https://support.calm.com/hc/ko/articles/360047502233](https://support.calm.com/hc/ko/articles/360047502233)|[https://support.calm.com/hc/en-us/articles/360047502233-How-do-I-cancel-my-subscription-](https://support.calm.com/hc/en-us/articles/360047502233-How-do-I-cancel-my-subscription-)| +|**Headspace Plus**|[https://www.headspace.com](https://www.headspace.com/)|[https://www.headspace.com](https://www.headspace.com/)|[https://help.headspace.com/hc/ko/articles/115003283173](https://help.headspace.com/hc/ko/articles/115003283173)|[https://help.headspace.com/hc/en-us/articles/115003283173-How-to-cancel-your-subscription](https://help.headspace.com/hc/en-us/articles/115003283173-How-to-cancel-your-subscription)| +|**FitOn Pro**|[https://fitonapp.com](https://fitonapp.com/)|[https://fitonapp.com](https://fitonapp.com/)|[https://help.fitonapp.com/hc/ko/articles/360042300212](https://help.fitonapp.com/hc/ko/articles/360042300212)|[https://help.fitonapp.com/hc/en-us/articles/360042300212-How-do-I-cancel-my-FitOn-PRO-subscription-](https://help.fitonapp.com/hc/en-us/articles/360042300212-How-do-I-cancel-my-FitOn-PRO-subscription-)| +|**Peloton App**|[https://www.onepeloton.com/app](https://www.onepeloton.com/app)|[https://www.onepeloton.com/app](https://www.onepeloton.com/app)|[https://support.onepeloton.com/hc/ko/articles/360043009912](https://support.onepeloton.com/hc/ko/articles/360043009912)|[https://support.onepeloton.com/hc/en-us/articles/360043009912-How-do-I-cancel-my-Peloton-membership-](https://support.onepeloton.com/hc/en-us/articles/360043009912-How-do-I-cancel-my-Peloton-membership-)| +|**Nike Training Club Premium**|—|[https://www.nike.com/ntc-app](https://www.nike.com/ntc-app)|—|—| +|**LinkedIn Premium**|—|[https://www.linkedin.com/premium/](https://www.linkedin.com/premium/)|—|[https://www.linkedin.com/help/linkedin/answer/a521](https://www.linkedin.com/help/linkedin/answer/a521)| +|**Tinder Plus/Gold**|[https://tinder.com](https://tinder.com/)|[https://tinder.com](https://tinder.com/)|—|[https://www.help.tinder.com/hc/en-us/articles/115004487406-How-do-I-cancel-my-subscription-](https://www.help.tinder.com/hc/en-us/articles/115004487406-How-do-I-cancel-my-subscription-)| +|**Bumble Boost**|[https://bumble.com](https://bumble.com/)|[https://bumble.com](https://bumble.com/)|—|[https://bumble.com/en/help/how-do-i-cancel-my-subscription](https://bumble.com/en/help/how-do-i-cancel-my-subscription)| +|**OkCupid A‑List**|[https://www.okcupid.com](https://www.okcupid.com/)|[https://www.okcupid.com](https://www.okcupid.com/)|—|[https://help.okcupid.com/hc/en-us/articles/360032679391-How-do-I-cancel-my-subscription-](https://help.okcupid.com/hc/en-us/articles/360032679391-How-do-I-cancel-my-subscription-)| +|**Duolingo Plus**|[https://www.duolingo.com/plus](https://www.duolingo.com/plus)|[https://www.duolingo.com/plus](https://www.duolingo.com/plus)|—|[https://support.duolingo.com/hc/en-us/articles/204830690-How-do-I-cancel-my-subscription-](https://support.duolingo.com/hc/en-us/articles/204830690-How-do-I-cancel-my-subscription-)| +|**Babbel**|[https://www.babbel.com](https://www.babbel.com/)|[https://www.babbel.com](https://www.babbel.com/)|—|[https://support.babbel.com/hc/en-us/articles/205813021-How-can-I-cancel-my-subscription-](https://support.babbel.com/hc/en-us/articles/205813021-How-can-I-cancel-my-subscription-)| +|**Rosetta Stone**|[https://www.rosettastone.com](https://www.rosettastone.com/)|[https://www.rosettastone.com](https://www.rosettastone.com/)|—|[https://support.rosettastone.com/s/article/How-do-I-cancel-my-Rosetta-Stone-subscription](https://support.rosettastone.com/s/article/How-do-I-cancel-my-Rosetta-Stone-subscription)| + +--- diff --git a/lib/controllers/detail_screen_controller.dart b/lib/controllers/detail_screen_controller.dart index e262865..ae56ef7 100644 --- a/lib/controllers/detail_screen_controller.dart +++ b/lib/controllers/detail_screen_controller.dart @@ -11,7 +11,7 @@ import '../widgets/dialogs/delete_confirmation_dialog.dart'; import '../widgets/common/snackbar/app_snackbar.dart'; /// DetailScreen의 비즈니스 로직을 관리하는 Controller -class DetailScreenController { +class DetailScreenController extends ChangeNotifier { final BuildContext context; final SubscriptionModel subscription; @@ -22,16 +22,85 @@ class DetailScreenController { late TextEditingController eventPriceController; // Form State - late String billingCycle; - late DateTime nextBillingDate; - String? selectedCategoryId; - late String currency; - bool isLoading = false; + final GlobalKey formKey = GlobalKey(); + late String _billingCycle; + late DateTime _nextBillingDate; + String? _selectedCategoryId; + late String _currency; + bool _isLoading = false; // Event State - late bool isEventActive; - DateTime? eventStartDate; - DateTime? eventEndDate; + late bool _isEventActive; + DateTime? _eventStartDate; + DateTime? _eventEndDate; + + // Getters + String get billingCycle => _billingCycle; + DateTime get nextBillingDate => _nextBillingDate; + String? get selectedCategoryId => _selectedCategoryId; + String get currency => _currency; + bool get isLoading => _isLoading; + bool get isEventActive => _isEventActive; + DateTime? get eventStartDate => _eventStartDate; + DateTime? get eventEndDate => _eventEndDate; + + // Setters + set billingCycle(String value) { + if (_billingCycle != value) { + _billingCycle = value; + notifyListeners(); + } + } + + set nextBillingDate(DateTime value) { + if (_nextBillingDate != value) { + _nextBillingDate = value; + notifyListeners(); + } + } + + set selectedCategoryId(String? value) { + if (_selectedCategoryId != value) { + _selectedCategoryId = value; + notifyListeners(); + } + } + + set currency(String value) { + if (_currency != value) { + _currency = value; + _updateMonthlyCostFormat(); + notifyListeners(); + } + } + + set isLoading(bool value) { + if (_isLoading != value) { + _isLoading = value; + notifyListeners(); + } + } + + set isEventActive(bool value) { + if (_isEventActive != value) { + _isEventActive = value; + notifyListeners(); + } + } + + set eventStartDate(DateTime? value) { + if (_eventStartDate != value) { + _eventStartDate = value; + notifyListeners(); + } + } + + set eventEndDate(DateTime? value) { + if (_eventEndDate != value) { + _eventEndDate = value; + notifyListeners(); + } + } // Focus Nodes final serviceNameFocus = FocusNode(); @@ -70,15 +139,15 @@ class DetailScreenController { eventPriceController = TextEditingController(); // Form State 초기화 - billingCycle = subscription.billingCycle; - nextBillingDate = subscription.nextBillingDate; - selectedCategoryId = subscription.categoryId; - currency = subscription.currency; + _billingCycle = subscription.billingCycle; + _nextBillingDate = subscription.nextBillingDate; + _selectedCategoryId = subscription.categoryId; + _currency = subscription.currency; // Event State 초기화 - isEventActive = subscription.isEventActive; - eventStartDate = subscription.eventStartDate; - eventEndDate = subscription.eventEndDate; + _isEventActive = subscription.isEventActive; + _eventStartDate = subscription.eventStartDate; + _eventEndDate = subscription.eventEndDate; // 이벤트 가격 초기화 if (subscription.eventPrice != null) { @@ -137,6 +206,7 @@ class DetailScreenController { } /// 리소스 정리 + @override void dispose() { // Controllers serviceNameController.dispose(); @@ -158,11 +228,13 @@ class DetailScreenController { // Scroll scrollController.dispose(); + + super.dispose(); } /// 통화 단위에 따른 금액 표시 형식 업데이트 void _updateMonthlyCostFormat() { - if (currency == 'KRW') { + if (_currency == 'KRW') { // 원화는 소수점 없이 표시 final intValue = subscription.monthlyCost.toInt(); monthlyCostController.text = NumberFormat.decimalPattern().format(intValue); @@ -197,7 +269,7 @@ class DetailScreenController { serviceName.contains('coupang play') || serviceName.contains('쿠팡플레이')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '엔터테인먼트', + (cat) => cat.name == 'OTT 서비스', orElse: () => categories.first, ); } @@ -209,7 +281,7 @@ class DetailScreenController { serviceName.contains('플로') || serviceName.contains('벅스')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '음악', + (cat) => cat.name == '음악 서비스', orElse: () => categories.first, ); } @@ -222,18 +294,18 @@ class DetailScreenController { serviceName.contains('icloud') || serviceName.contains('adobe')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '생산성', + (cat) => cat.name == '오피스/협업 툴', orElse: () => categories.first, ); } - // 게임 관련 키워드 - else if (serviceName.contains('xbox') || - serviceName.contains('playstation') || - serviceName.contains('nintendo') || - serviceName.contains('steam') || - serviceName.contains('게임')) { + // AI 관련 키워드 + else if (serviceName.contains('chatgpt') || + serviceName.contains('claude') || + serviceName.contains('gemini') || + serviceName.contains('copilot') || + serviceName.contains('midjourney')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '게임', + (cat) => cat.name == 'AI 서비스', orElse: () => categories.first, ); } @@ -244,7 +316,7 @@ class DetailScreenController { serviceName.contains('패스트캠퍼스') || serviceName.contains('클래스101')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '교육', + (cat) => cat.name == '프로그래밍/개발', orElse: () => categories.first, ); } @@ -255,7 +327,7 @@ class DetailScreenController { serviceName.contains('네이버') || serviceName.contains('11번가')) { matchedCategory = categories.firstWhere( - (cat) => cat.name == '쇼핑', + (cat) => cat.name == '기타 서비스', orElse: () => categories.first, ); } @@ -267,6 +339,15 @@ class DetailScreenController { /// 구독 정보 업데이트 Future updateSubscription() async { + // Form 검증 + if (formKey.currentState != null && !formKey.currentState!.validate()) { + AppSnackBar.showError( + context: context, + message: '필수 항목을 모두 입력해주세요', + ); + return; + } + final provider = Provider.of(context, listen: false); // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 @@ -289,18 +370,18 @@ class DetailScreenController { subscription.serviceName = serviceNameController.text; subscription.monthlyCost = monthlyCost; subscription.websiteUrl = websiteUrl; - subscription.billingCycle = billingCycle; - subscription.nextBillingDate = nextBillingDate; - subscription.categoryId = selectedCategoryId; - subscription.currency = currency; + subscription.billingCycle = _billingCycle; + subscription.nextBillingDate = _nextBillingDate; + subscription.categoryId = _selectedCategoryId; + subscription.currency = _currency; // 이벤트 정보 업데이트 - subscription.isEventActive = isEventActive; - subscription.eventStartDate = eventStartDate; - subscription.eventEndDate = eventEndDate; + subscription.isEventActive = _isEventActive; + subscription.eventStartDate = _eventStartDate; + subscription.eventEndDate = _eventEndDate; // 이벤트 가격 파싱 - if (isEventActive && eventPriceController.text.isNotEmpty) { + if (_isEventActive && eventPriceController.text.isNotEmpty) { try { subscription.eventPrice = double.parse(eventPriceController.text.replaceAll(',', '')); @@ -345,7 +426,7 @@ class DetailScreenController { await provider.deleteSubscription(subscription.id); if (context.mounted) { - AppSnackBar.showSuccess( + AppSnackBar.showError( context: context, message: '구독이 삭제되었습니다.', icon: Icons.delete_forever_rounded, diff --git a/lib/providers/category_provider.dart b/lib/providers/category_provider.dart index 9ce640d..446f7e1 100644 --- a/lib/providers/category_provider.dart +++ b/lib/providers/category_provider.dart @@ -7,7 +7,36 @@ class CategoryProvider extends ChangeNotifier { List _categories = []; late Box _categoryBox; - List get categories => _categories; + // 카테고리 표시 순서 정의 + static const List _categoryOrder = [ + '음악', + 'OTT(동영상)', + '저장/클라우드', + '통신 · 인터넷 · TV', + '생활/라이프스타일', + '쇼핑/이커머스', + '프로그래밍', + '협업/오피스', + 'AI 서비스', + '기타', + ]; + + List get categories { + // 정의된 순서로 카테고리 정렬 + final sortedCategories = List.from(_categories); + sortedCategories.sort((a, b) { + final aIndex = _categoryOrder.indexOf(a.name); + final bIndex = _categoryOrder.indexOf(b.name); + + // 순서 목록에 없는 카테고리는 맨 뒤로 + if (aIndex == -1) return 1; + if (bIndex == -1) return -1; + + return aIndex.compareTo(bIndex); + }); + + return sortedCategories; + } Future init() async { _categoryBox = await Hive.openBox('categories'); @@ -24,12 +53,16 @@ class CategoryProvider extends ChangeNotifier { // 기본 카테고리 초기화 Future _initDefaultCategories() async { final defaultCategories = [ - {'name': 'OTT 서비스', 'color': '#3B82F6', 'icon': 'live_tv'}, - {'name': '음악 서비스', 'color': '#EC4899', 'icon': 'music_note'}, - {'name': 'AI 서비스', 'color': '#8B5CF6', 'icon': 'psychology'}, - {'name': '프로그래밍/개발', 'color': '#10B981', 'icon': 'code'}, - {'name': '오피스/협업 툴', 'color': '#F59E0B', 'icon': 'business_center'}, - {'name': '기타 서비스', 'color': '#6B7280', 'icon': 'more_horiz'}, + {'name': '음악', 'color': '#E91E63', 'icon': 'music_note'}, + {'name': 'OTT(동영상)', 'color': '#9C27B0', 'icon': 'movie_filter'}, + {'name': '저장/클라우드', 'color': '#2196F3', 'icon': 'cloud'}, + {'name': '통신 · 인터넷 · TV', 'color': '#00BCD4', 'icon': 'wifi'}, + {'name': '생활/라이프스타일', 'color': '#4CAF50', 'icon': 'home'}, + {'name': '쇼핑/이커머스', 'color': '#FF9800', 'icon': 'shopping_cart'}, + {'name': '프로그래밍', 'color': '#795548', 'icon': 'code'}, + {'name': '협업/오피스', 'color': '#607D8B', 'icon': 'business_center'}, + {'name': 'AI 서비스', 'color': '#673AB7', 'icon': 'smart_toy'}, + {'name': '기타', 'color': '#9E9E9E', 'icon': 'category'}, ]; for (final category in defaultCategories) { diff --git a/lib/providers/notification_provider.dart b/lib/providers/notification_provider.dart index ff5c2b1..464dabe 100644 --- a/lib/providers/notification_provider.dart +++ b/lib/providers/notification_provider.dart @@ -14,7 +14,7 @@ class NotificationProvider extends ChangeNotifier { static const String _dailyReminderKey = 'daily_reminder_enabled'; bool _isEnabled = false; - bool _isPaymentEnabled = false; + bool _isPaymentEnabled = true; bool _isUnusedServiceNotificationEnabled = false; int _reminderDays = 3; // 기본값: 3일 전 int _reminderHour = 10; // 기본값: 오전 10시 diff --git a/lib/providers/subscription_provider.dart b/lib/providers/subscription_provider.dart index 57804de..9edeb8e 100644 --- a/lib/providers/subscription_provider.dart +++ b/lib/providers/subscription_provider.dart @@ -4,6 +4,7 @@ import 'package:hive/hive.dart'; import 'package:uuid/uuid.dart'; import '../models/subscription_model.dart'; import '../services/notification_service.dart'; +import 'category_provider.dart'; class SubscriptionProvider extends ChangeNotifier { late Box _subscriptionBox; @@ -46,6 +47,9 @@ class SubscriptionProvider extends ChangeNotifier { _subscriptionBox = await Hive.openBox('subscriptions'); await refreshSubscriptions(); + // categoryId 마이그레이션 + await _migrateCategoryIds(); + // 앱 시작 시 이벤트 상태 확인 await checkAndUpdateEventStatus(); @@ -290,4 +294,105 @@ class SubscriptionProvider extends ChangeNotifier { ]; return months[month.month - 1]; } + + /// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당 + Future _migrateCategoryIds() async { + debugPrint('❎ CategoryId 마이그레이션 시작...'); + + final categoryProvider = CategoryProvider(); + await categoryProvider.init(); + final categories = categoryProvider.categories; + + int migratedCount = 0; + + for (var subscription in _subscriptions) { + if (subscription.categoryId == null) { + final serviceName = subscription.serviceName.toLowerCase(); + String? categoryId; + + debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...'); + + // OTT 서비스 + if (serviceName.contains('netflix') || + serviceName.contains('youtube') || + serviceName.contains('disney') || + serviceName.contains('왓차') || + serviceName.contains('티빙') || + serviceName.contains('디즈니') || + serviceName.contains('넷플릭스')) { + categoryId = categories.firstWhere( + (cat) => cat.name == 'OTT 서비스', + orElse: () => categories.first, + ).id; + } + // 음악 서비스 + else if (serviceName.contains('spotify') || + serviceName.contains('apple music') || + serviceName.contains('멜론') || + serviceName.contains('지니') || + serviceName.contains('플로') || + serviceName.contains('벡스')) { + categoryId = categories.firstWhere( + (cat) => cat.name == '음악 서비스', + orElse: () => categories.first, + ).id; + } + // AI 서비스 + else if (serviceName.contains('chatgpt') || + serviceName.contains('claude') || + serviceName.contains('midjourney') || + serviceName.contains('copilot')) { + categoryId = categories.firstWhere( + (cat) => cat.name == 'AI 서비스', + orElse: () => categories.first, + ).id; + } + // 프로그래밍/개발 + else if (serviceName.contains('github') || + serviceName.contains('intellij') || + serviceName.contains('webstorm') || + serviceName.contains('jetbrains')) { + categoryId = categories.firstWhere( + (cat) => cat.name == '프로그래밍/개발', + orElse: () => categories.first, + ).id; + } + // 오피스/협업 툴 + else if (serviceName.contains('notion') || + serviceName.contains('microsoft') || + serviceName.contains('office') || + serviceName.contains('slack') || + serviceName.contains('figma') || + serviceName.contains('icloud') || + serviceName.contains('아이클라우드')) { + categoryId = categories.firstWhere( + (cat) => cat.name == '오피스/협업 툴', + orElse: () => categories.first, + ).id; + } + // 기타 서비스 (기본값) + else { + categoryId = categories.firstWhere( + (cat) => cat.name == '기타 서비스', + orElse: () => categories.first, + ).id; + } + + if (categoryId != null) { + subscription.categoryId = categoryId; + await subscription.save(); + migratedCount++; + final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name; + debugPrint('✅ ${subscription.serviceName} → $categoryName'); + } + } + } + + if (migratedCount > 0) { + debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료'); + await refreshSubscriptions(); + } else { + debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다'); + } + } } diff --git a/lib/screens/analysis_screen.dart b/lib/screens/analysis_screen.dart index aeaee33..c98444e 100644 --- a/lib/screens/analysis_screen.dart +++ b/lib/screens/analysis_screen.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../providers/subscription_provider.dart'; -import '../widgets/glassmorphic_app_bar.dart'; import '../widgets/native_ad_widget.dart'; import '../widgets/analysis/analysis_screen_spacer.dart'; import '../widgets/analysis/subscription_pie_chart_card.dart'; @@ -100,10 +99,10 @@ class _AnalysisScreenState extends State controller: _scrollController, physics: const BouncingScrollPhysics(), slivers: [ - const GlassmorphicSliverAppBar( - title: '분석', - pinned: true, - expandedHeight: kToolbarHeight, + SliverToBoxAdapter( + child: SizedBox( + height: kToolbarHeight + MediaQuery.of(context).padding.top, + ), ), // 네이티브 광고 위젯 @@ -148,7 +147,12 @@ class _AnalysisScreenState extends State animationController: _animationController, ), - const AnalysisScreenSpacer(height: 32), + // FloatingNavigationBar를 위한 충분한 하단 여백 + SliverToBoxAdapter( + child: SizedBox( + height: 120 + MediaQuery.of(context).padding.bottom, + ), + ), ], ); }, diff --git a/lib/screens/detail_screen.dart b/lib/screens/detail_screen.dart index eb7336f..77fc21b 100644 --- a/lib/screens/detail_screen.dart +++ b/lib/screens/detail_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; import '../controllers/detail_screen_controller.dart'; import '../widgets/detail/detail_header_section.dart'; @@ -46,9 +47,11 @@ class _DetailScreenState extends State Widget build(BuildContext context) { final baseColor = _controller.getCardColor(); - return Scaffold( - backgroundColor: AppColors.backgroundColor, - body: CustomScrollView( + return ChangeNotifierProvider.value( + value: _controller, + child: Scaffold( + backgroundColor: AppColors.backgroundColor, + body: CustomScrollView( controller: _controller.scrollController, slivers: [ // 상단 헤더 섹션 @@ -74,8 +77,19 @@ class _DetailScreenState extends State vertical: 12, ), decoration: BoxDecoration( - color: baseColor.withValues(alpha: 0.1), + gradient: LinearGradient( + colors: [ + baseColor.withValues(alpha: 0.15), + baseColor.withValues(alpha: 0.08), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), borderRadius: BorderRadius.circular(16), + border: Border.all( + color: baseColor.withValues(alpha: 0.2), + width: 1, + ), ), child: Row( children: [ @@ -98,7 +112,7 @@ class _DetailScreenState extends State '변경사항은 저장 후 적용됩니다', style: TextStyle( fontSize: 14, - color: baseColor.withValues(alpha: 0.8), + color: AppColors.darkNavy, ), ), ], @@ -141,6 +155,7 @@ class _DetailScreenState extends State ), ), ], + ), ), ); } diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 9914d3a..b5bc393 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -183,10 +183,10 @@ class _MainScreenState extends State backgroundColor: AppColors.successColor, behavior: SnackBarBehavior.floating, margin: EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 16, // 상단 여백 + top: MediaQuery.of(context).padding.top + 8, // 더 상단으로 left: 16, right: 16, - bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정 + bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로 ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index 0190900..c32f70d 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -9,6 +9,7 @@ import '../providers/theme_provider.dart'; import '../theme/adaptive_theme.dart'; import '../widgets/glassmorphism_card.dart'; import '../theme/app_colors.dart'; +import '../widgets/native_ad_widget.dart'; class SettingsScreen extends StatelessWidget { const SettingsScreen({super.key}); @@ -70,81 +71,18 @@ class SettingsScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return ListView( - padding: const EdgeInsets.only(top: 20), - children: [ - // 테마 설정 - GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: Consumer( - builder: (context, themeProvider, child) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Padding( - padding: EdgeInsets.all(16), - child: Text( - '테마 설정', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.bold, - ), - ), - ), - // 테마 모드 선택 - ListTile( - title: const Text('테마 모드'), - subtitle: Text(_getThemeModeText(themeProvider.themeMode)), - leading: Icon( - _getThemeModeIcon(themeProvider.themeMode), - color: Theme.of(context).colorScheme.primary, - ), - trailing: DropdownButton( - value: themeProvider.themeMode, - underline: Container(), - onChanged: (mode) { - if (mode != null) { - themeProvider.setThemeMode(mode); - } - }, - items: AppThemeMode.values.map((mode) => - DropdownMenuItem( - value: mode, - child: Text(_getThemeModeText(mode)), - ), - ).toList(), - ), - ), - const Divider(height: 1), - - // 접근성 설정 - SwitchListTile( - title: const Text('큰 텍스트'), - subtitle: const Text('텍스트 크기를 크게 표시합니다'), - secondary: const Icon(Icons.text_fields), - value: themeProvider.largeText, - onChanged: themeProvider.setLargeText, - ), - SwitchListTile( - title: const Text('모션 감소'), - subtitle: const Text('애니메이션 효과를 줄입니다'), - secondary: const Icon(Icons.slow_motion_video), - value: themeProvider.reduceMotion, - onChanged: themeProvider.setReduceMotion, - ), - SwitchListTile( - title: const Text('고대비 모드'), - subtitle: const Text('더 선명한 색상으로 표시합니다'), - secondary: const Icon(Icons.contrast), - value: themeProvider.highContrast, - onChanged: themeProvider.setHighContrast, - ), - ], - ); - }, - ), - ), + return Column( + children: [ + SizedBox( + height: kToolbarHeight + MediaQuery.of(context).padding.top, + ), + Expanded( + child: ListView( + padding: const EdgeInsets.only(top: 20), + children: [ + // 광고 위젯 추가 + const NativeAdWidget(key: ValueKey('settings_ad')), + const SizedBox(height: 16), // 앱 잠금 설정 UI 숨김 // Card( // margin: const EdgeInsets.all(16), @@ -392,24 +330,6 @@ class SettingsScreen extends StatelessWidget { ), ), - // 데이터 관리 - const GlassmorphismCard( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(8), - child: const Column( - children: [ - // 데이터 백업 기능 비활성화 - // ListTile( - // title: const Text('데이터 백업'), - // subtitle: const Text('구독 데이터를 백업합니다'), - // leading: const Icon(Icons.backup), - // onTap: () => _backupData(context), - // ), - // const Divider(), - ], - ), - ), - // 앱 정보 GlassmorphismCard( margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -485,11 +405,15 @@ class SettingsScreen extends StatelessWidget { }, ), ), + // FloatingNavigationBar를 위한 충분한 하단 여백 SizedBox( - height: 20 + MediaQuery.of(context).padding.bottom, // 하단 여백 + height: 120 + MediaQuery.of(context).padding.bottom, ), ], - ); + ), + ), + ], + ); } String _getThemeModeText(AppThemeMode mode) { diff --git a/lib/screens/sms_scan_screen.dart b/lib/screens/sms_scan_screen.dart index bacf8f3..e7f1d78 100644 --- a/lib/screens/sms_scan_screen.dart +++ b/lib/screens/sms_scan_screen.dart @@ -14,6 +14,10 @@ import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/buttons/primary_button.dart'; import '../widgets/common/buttons/secondary_button.dart'; import '../widgets/common/form_fields/base_text_field.dart'; +import '../providers/category_provider.dart'; +import '../models/category_model.dart'; +import '../widgets/common/form_fields/category_selector.dart'; +import '../widgets/native_ad_widget.dart'; class SmsScanScreen extends StatefulWidget { const SmsScanScreen({super.key}); @@ -35,6 +39,9 @@ class _SmsScanScreenState extends State { // 웹사이트 URL 컨트롤러 final TextEditingController _websiteUrlController = TextEditingController(); + + // 선택된 카테고리 ID 저장 + String? _selectedCategoryId; @override void dispose() { @@ -113,6 +120,25 @@ class _SmsScanScreenState extends State { '첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); } + // 중복 제거 후 신규 구독이 없는 경우 + if (filteredSubscriptions.isEmpty) { + print('중복 제거 후 신규 구독이 없음'); + setState(() { + _isLoading = false; + }); + + // 스낵바로 안내 메시지 표시 + if (mounted) { + AppSnackBar.showInfo( + context: context, + message: '신규 구독 관련 SMS를 찾을 수 없습니다', + icon: Icons.search_off_rounded, + ); + } + + return; + } + setState(() { _scannedSubscriptions = filteredSubscriptions; _isLoading = false; @@ -349,7 +375,7 @@ class _SmsScanScreenState extends State { isAutoDetected: true, repeatCount: safeRepeatCount, lastPaymentDate: subscription.lastPaymentDate, - categoryId: subscription.category, + categoryId: _selectedCategoryId ?? subscription.category, currency: subscription.currency, // 통화 단위 정보 추가 ); @@ -399,6 +425,7 @@ class _SmsScanScreenState extends State { setState(() { _currentIndex++; _websiteUrlController.text = ''; // URL 입력 필드 초기화 + _selectedCategoryId = null; // 카테고리 선택 초기화 // 모든 구독을 처리했으면 홈 화면으로 이동 if (_currentIndex >= _scannedSubscriptions.length) { @@ -493,15 +520,90 @@ class _SmsScanScreenState extends State { return '$count회 결제 감지됨'; } + // 카테고리 칩 빌드 + Widget _buildCategoryChip(String? categoryId, CategoryProvider categoryProvider) { + final category = categoryId != null + ? categoryProvider.getCategoryById(categoryId) + : null; + + // 카테고리가 없으면 기타 카테고리 찾기 + final defaultCategory = category ?? categoryProvider.categories.firstWhere( + (cat) => cat.name == '기타', + orElse: () => categoryProvider.categories.first, + ); + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: AppColors.navyGray.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + // 카테고리 아이콘 표시 + Icon( + _getCategoryIcon(defaultCategory), + size: 16, + color: AppColors.darkNavy, + ), + const SizedBox(width: 6), + ThemedText( + defaultCategory.name, + fontSize: 14, + fontWeight: FontWeight.w500, + forceDark: true, + ), + ], + ), + ); + } + + + // 카테고리 아이콘 반환 + IconData _getCategoryIcon(CategoryModel category) { + switch (category.name) { + case '음악': + return Icons.music_note_rounded; + case 'OTT(동영상)': + return Icons.movie_filter_rounded; + case '저장/클라우드': + return Icons.cloud_outlined; + case '통신 · 인터넷 · TV': + return Icons.wifi_rounded; + case '생활/라이프스타일': + return Icons.home_outlined; + case '쇼핑/이커머스': + return Icons.shopping_cart_outlined; + case '프로그래밍': + return Icons.code_rounded; + case '협업/오피스': + return Icons.business_center_outlined; + case 'AI 서비스': + return Icons.smart_toy_outlined; + case '기타': + default: + return Icons.category_outlined; + } + } + @override Widget build(BuildContext context) { - return Padding( + return SingleChildScrollView( padding: const EdgeInsets.all(16.0), - child: _isLoading - ? _buildLoadingState() - : (_scannedSubscriptions.isEmpty - ? _buildInitialState() - : _buildSubscriptionState()), + child: Column( + children: [ + _isLoading + ? _buildLoadingState() + : (_scannedSubscriptions.isEmpty + ? _buildInitialState() + : _buildSubscriptionState()), + // FloatingNavigationBar를 위한 충분한 하단 여백 + SizedBox( + height: 120 + MediaQuery.of(context).padding.bottom, + ), + ], + ), ); } @@ -525,10 +627,16 @@ class _SmsScanScreenState extends State { // 초기 상태 UI Widget _buildInitialState() { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ + return Column( + children: [ + // 광고 위젯 추가 + const NativeAdWidget(key: ValueKey('sms_scan_start_ad')), + const SizedBox(height: 48), + Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ if (_errorMessage != null) Padding( padding: const EdgeInsets.all(16.0), @@ -564,8 +672,10 @@ class _SmsScanScreenState extends State { height: 56, backgroundColor: AppColors.primaryColor, ), - ], - ), + ], + ), + ), + ], ); } @@ -579,6 +689,7 @@ class _SmsScanScreenState extends State { } final subscription = _scannedSubscriptions[_currentIndex]; + final categoryProvider = Provider.of(context, listen: false); // 구독 리스트 카드를 표시할 때 URL 필드 자동 설정 if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) { @@ -588,6 +699,9 @@ class _SmsScanScreenState extends State { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ + // 광고 위젯 추가 + const NativeAdWidget(key: ValueKey('sms_scan_result_ad')), + const SizedBox(height: 16), // 진행 상태 표시 LinearProgressIndicator( value: (_currentIndex + 1) / _scannedSubscriptions.length, @@ -634,7 +748,7 @@ class _SmsScanScreenState extends State { ), const SizedBox(height: 16), - // 금액 및 반복 횟수 + // 금액 및 결제 주기 Row( children: [ Expanded( @@ -667,33 +781,6 @@ class _SmsScanScreenState extends State { ], ), ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ThemedText( - '반복 횟수', - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 4), - ThemedText( - _getRepeatCountText(subscription.repeatCount), - fontSize: 16, - fontWeight: FontWeight.w500, - color: Theme.of(context).colorScheme.secondary, - ), - ], - ), - ), - ], - ), - const SizedBox(height: 16), - - // 결제 주기 - Row( - children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -714,28 +801,51 @@ class _SmsScanScreenState extends State { ], ), ), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const ThemedText( - '결제일', - fontWeight: FontWeight.w500, - opacity: 0.7, - forceDark: true, - ), - const SizedBox(height: 4), - ThemedText( - _getNextBillingText(subscription.nextBillingDate), - fontSize: 14, - fontWeight: FontWeight.w500, - forceDark: true, - ), - ], - ), - ), ], ), + const SizedBox(height: 16), + + // 다음 결제일 + const ThemedText( + '다음 결제일', + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 4), + ThemedText( + _getNextBillingText(subscription.nextBillingDate), + fontSize: 16, + fontWeight: FontWeight.w500, + forceDark: true, + ), + const SizedBox(height: 16), + + // 카테고리 선택 + const ThemedText( + '카테고리', + fontWeight: FontWeight.w500, + opacity: 0.7, + forceDark: true, + ), + const SizedBox(height: 8), + CategorySelector( + categories: categoryProvider.categories, + selectedCategoryId: _selectedCategoryId ?? subscription.category, + onChanged: (categoryId) { + setState(() { + _selectedCategoryId = categoryId; + }); + }, + baseColor: (() { + final categoryId = _selectedCategoryId ?? subscription.category; + if (categoryId == null) return null; + final category = categoryProvider.getCategoryById(categoryId); + if (category == null) return null; + return Color(int.parse(category.color.replaceFirst('#', '0xFF'))); + })(), + isGlassmorphism: true, + ), const SizedBox(height: 24), // 웹사이트 URL 입력 필드 추가/수정 diff --git a/lib/screens/splash_screen.dart b/lib/screens/splash_screen.dart index caacece..3be859b 100644 --- a/lib/screens/splash_screen.dart +++ b/lib/screens/splash_screen.dart @@ -265,7 +265,8 @@ class _SplashScreenState extends State ), boxShadow: [ BoxShadow( - color: AppColors.shadowBlack, + color: + AppColors.shadowBlack, spreadRadius: 0, blurRadius: 30, offset: const Offset(0, 10), @@ -280,9 +281,8 @@ class _SplashScreenState extends State return ShaderMask( blendMode: BlendMode.srcIn, - shaderCallback: - (bounds) => - const LinearGradient( + shaderCallback: (bounds) => + const LinearGradient( colors: AppColors .blueGradient, begin: @@ -323,11 +323,12 @@ class _SplashScreenState extends State ); }, child: Text( - 'SubManager', + 'Digital Rent Manager', style: TextStyle( fontSize: 36, fontWeight: FontWeight.bold, - color: AppColors.pureWhite, + color: AppColors.primaryColor + .withValues(alpha: 0.9), letterSpacing: 1.2, ), ), @@ -352,7 +353,8 @@ class _SplashScreenState extends State '구독 서비스 관리를 더 쉽게', style: TextStyle( fontSize: 16, - color: AppColors.pureWhite.withValues(alpha: 0.7), + color: AppColors.primaryColor + .withValues(alpha: 0.7), letterSpacing: 0.5, ), ), @@ -373,11 +375,12 @@ class _SplashScreenState extends State height: 60, padding: const EdgeInsets.all(6), decoration: BoxDecoration( - color: AppColors.pureWhite.withValues(alpha: 0.1), + color: AppColors.pureWhite + .withValues(alpha: 0.1), borderRadius: BorderRadius.circular(50), border: Border.all( - color: - AppColors.pureWhite.withValues(alpha: 0.2), + color: AppColors.pureWhite + .withValues(alpha: 0.2), width: 1, ), ), @@ -401,7 +404,7 @@ class _SplashScreenState extends State child: FadeTransition( opacity: _fadeAnimation, child: Text( - '© 2023 CClabs. All rights reserved.', + '© 2025 NatureBridgeAI. All rights reserved.', style: TextStyle( fontSize: 12, color: AppColors.pureWhite.withValues(alpha: 0.6), diff --git a/lib/services/notification_service.dart b/lib/services/notification_service.dart index 7549e75..b060cba 100644 --- a/lib/services/notification_service.dart +++ b/lib/services/notification_service.dart @@ -137,10 +137,7 @@ class NotificationService { // 각 구독에 대해 알림 재설정 for (final subscription in subscriptions) { await schedulePaymentReminder( - id: subscription.id.hashCode, - serviceName: subscription.serviceName, - amount: subscription.monthlyCost, - billingDate: subscription.nextBillingDate, + subscription: subscription, reminderDays: reminderDays, reminderHour: reminderHour, reminderMinute: reminderMinute, @@ -421,10 +418,7 @@ class NotificationService { } static Future schedulePaymentReminder({ - required int id, - required String serviceName, - required double amount, - required DateTime billingDate, + required SubscriptionModel subscription, int reminderDays = 3, int reminderHour = 10, int reminderMinute = 0, @@ -457,7 +451,7 @@ class NotificationService { // 기본 알림 예약 (지정된 일수 전) final scheduledDate = - billingDate.subtract(Duration(days: reminderDays)).copyWith( + subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith( hour: reminderHour, minute: reminderMinute, second: 0, @@ -471,10 +465,27 @@ class NotificationService { daysText = '내일'; } + // 이벤트 종료로 인한 가격 변동 확인 + String notificationBody; + if (subscription.isEventActive && + subscription.eventEndDate != null && + subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && + subscription.eventEndDate!.isAfter(DateTime.now())) { + // 이벤트가 결제일 전에 종료되는 경우 + final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; + final normalPrice = subscription.monthlyCost; + notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n' + '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; + } else { + // 일반 알림 + final currentPrice = subscription.currentPrice; + notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.'; + } + await _notifications.zonedSchedule( - id, + subscription.id.hashCode, '구독 결제 예정 알림', - '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.', + notificationBody, tz.TZDateTime.from(scheduledDate, location), const NotificationDetails( android: AndroidNotificationDetails( @@ -495,7 +506,7 @@ class NotificationService { if (isDailyReminder && reminderDays >= 2) { // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 for (int i = reminderDays - 1; i >= 1; i--) { - final dailyDate = billingDate.subtract(Duration(days: i)).copyWith( + final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith( hour: reminderHour, minute: reminderMinute, second: 0, @@ -509,10 +520,25 @@ class NotificationService { remainingDaysText = '내일'; } + // 각 날짜에 대한 이벤트 종료 확인 + String dailyNotificationBody; + if (subscription.isEventActive && + subscription.eventEndDate != null && + subscription.eventEndDate!.isBefore(subscription.nextBillingDate) && + subscription.eventEndDate!.isAfter(DateTime.now())) { + final eventPrice = subscription.eventPrice ?? subscription.monthlyCost; + final normalPrice = subscription.monthlyCost; + dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n' + '⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.'; + } else { + final currentPrice = subscription.currentPrice; + dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.'; + } + await _notifications.zonedSchedule( - id + i, // 고유한 ID 생성을 위해 날짜 차이 더함 + subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함 '구독 결제 예정 알림', - '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.', + dailyNotificationBody, tz.TZDateTime.from(dailyDate, location), const NotificationDetails( android: AndroidNotificationDetails( diff --git a/lib/services/subscription_url_matcher.dart b/lib/services/subscription_url_matcher.dart index 41d9493..8e4007c 100644 --- a/lib/services/subscription_url_matcher.dart +++ b/lib/services/subscription_url_matcher.dart @@ -49,6 +49,89 @@ class SubscriptionUrlMatcher { '타이달': 'https://www.tidal.com', }; + // 저장 (클라우드/파일) 서비스 + static final Map storageServices = { + 'google drive': 'https://www.google.com/drive/', + '구글 드라이브': 'https://www.google.com/drive/', + 'dropbox': 'https://www.dropbox.com', + '드롭박스': 'https://www.dropbox.com', + 'onedrive': 'https://www.onedrive.com', + '원드라이브': 'https://www.onedrive.com', + 'icloud': 'https://www.icloud.com', + '아이클라우드': 'https://www.icloud.com', + 'box': 'https://www.box.com', + '박스': 'https://www.box.com', + 'pcloud': 'https://www.pcloud.com', + 'mega': 'https://mega.nz', + '메가': 'https://mega.nz', + 'naver mybox': 'https://mybox.naver.com', + '네이버 마이박스': 'https://mybox.naver.com', + }; + + // 통신 · 인터넷 · TV 서비스 + static final Map telecomServices = { + 'skt': 'https://www.sktelecom.com', + 'sk텔레콤': 'https://www.sktelecom.com', + 'kt': 'https://www.kt.com', + 'lgu+': 'https://www.lguplus.com', + 'lg유플러스': 'https://www.lguplus.com', + 'olleh tv': 'https://www.kt.com/olleh_tv', + '올레 tv': 'https://www.kt.com/olleh_tv', + 'b tv': 'https://www.skbroadband.com', + '비티비': 'https://www.skbroadband.com', + 'u+모바일tv': 'https://www.lguplus.com', + '유플러스모바일tv': 'https://www.lguplus.com', + }; + + // 생활/라이프스타일 서비스 + static final Map lifestyleServices = { + '네이버 플러스': 'https://plus.naver.com', + 'naver plus': 'https://plus.naver.com', + '카카오 구독': 'https://subscribe.kakao.com', + 'kakao subscribe': 'https://subscribe.kakao.com', + '쿠팡 와우': 'https://www.coupang.com/np/coupangplus', + 'coupang wow': 'https://www.coupang.com/np/coupangplus', + '스타벅스 버디': 'https://www.starbucks.co.kr', + 'starbucks buddy': 'https://www.starbucks.co.kr', + 'cu 구독': 'https://cu.bgfretail.com', + 'gs25 구독': 'https://gs25.gsretail.com', + '현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription', + 'lg전자 구독': 'https://www.lge.co.kr', + '삼성전자 구독': 'https://www.samsung.com/sec', + '다이슨 케어': 'https://www.dyson.co.kr', + 'dyson care': 'https://www.dyson.co.kr', + '마켓컬리': 'https://www.kurly.com', + 'kurly': 'https://www.kurly.com', + '헬로네이처': 'https://www.hellonature.com', + 'hello nature': 'https://www.hellonature.com', + '이마트 트레이더스': 'https://www.emarttraders.co.kr', + '홈플러스': 'https://www.homeplus.co.kr', + 'hellofresh': 'https://www.hellofresh.com', + '헬로프레시': 'https://www.hellofresh.com', + 'bespoke post': 'https://www.bespokepost.com', + }; + + // 쇼핑/이커머스 서비스 + static final Map shoppingServices = { + 'amazon prime': 'https://www.amazon.com/prime', + '아마존 프라임': 'https://www.amazon.com/prime', + 'walmart+': 'https://www.walmart.com/plus', + '월마트플러스': 'https://www.walmart.com/plus', + 'chewy': 'https://www.chewy.com', + '츄이': 'https://www.chewy.com', + 'dollar shave club': 'https://www.dollarshaveclub.com', + '달러셰이브클럽': 'https://www.dollarshaveclub.com', + 'instacart': 'https://www.instacart.com', + '인스타카트': 'https://www.instacart.com', + 'shipt': 'https://www.shipt.com', + '십트': 'https://www.shipt.com', + 'grove': 'https://grove.co', + '그로브': 'https://grove.co', + 'cratejoy': 'https://www.cratejoy.com', + 'shopify': 'https://www.shopify.com', + '쇼피파이': 'https://www.shopify.com', + }; + // AI 서비스 static final Map aiServices = { 'chatgpt': 'https://chat.openai.com', diff --git a/lib/utils/subscription_category_helper.dart b/lib/utils/subscription_category_helper.dart index ae0057f..3620185 100644 --- a/lib/utils/subscription_category_helper.dart +++ b/lib/utils/subscription_category_helper.dart @@ -33,23 +33,71 @@ class SubscriptionCategoryHelper { } // 카테고리 ID가 없거나 카테고리를 찾을 수 없는 경우 서비스 이름 기반 분류 - // OTT - if (_isInCategory( - subscription.serviceName, SubscriptionUrlMatcher.ottServices)) { - if (!categorizedSubscriptions.containsKey('OTT 서비스')) { - categorizedSubscriptions['OTT 서비스'] = []; - } - categorizedSubscriptions['OTT 서비스']!.add(subscription); - } // 음악 - else if (_isInCategory( + if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.musicServices)) { - if (!categorizedSubscriptions.containsKey('음악 서비스')) { - categorizedSubscriptions['음악 서비스'] = []; + if (!categorizedSubscriptions.containsKey('음악')) { + categorizedSubscriptions['음악'] = []; } - categorizedSubscriptions['음악 서비스']!.add(subscription); + categorizedSubscriptions['음악']!.add(subscription); } - // AI + // OTT(동영상) + else if (_isInCategory( + subscription.serviceName, SubscriptionUrlMatcher.ottServices)) { + if (!categorizedSubscriptions.containsKey('OTT(동영상)')) { + categorizedSubscriptions['OTT(동영상)'] = []; + } + categorizedSubscriptions['OTT(동영상)']!.add(subscription); + } + // 저장/클라우드 + else if (_isInCategory( + subscription.serviceName, SubscriptionUrlMatcher.storageServices)) { + if (!categorizedSubscriptions.containsKey('저장/클라우드')) { + categorizedSubscriptions['저장/클라우드'] = []; + } + categorizedSubscriptions['저장/클라우드']!.add(subscription); + } + // 통신 · 인터넷 · TV + else if (_isInCategory( + subscription.serviceName, SubscriptionUrlMatcher.telecomServices)) { + if (!categorizedSubscriptions.containsKey('통신 · 인터넷 · TV')) { + categorizedSubscriptions['통신 · 인터넷 · TV'] = []; + } + categorizedSubscriptions['통신 · 인터넷 · TV']!.add(subscription); + } + // 생활/라이프스타일 + else if (_isInCategory( + subscription.serviceName, SubscriptionUrlMatcher.lifestyleServices)) { + if (!categorizedSubscriptions.containsKey('생활/라이프스타일')) { + categorizedSubscriptions['생활/라이프스타일'] = []; + } + categorizedSubscriptions['생활/라이프스타일']!.add(subscription); + } + // 쇼핑/이커머스 + else if (_isInCategory( + subscription.serviceName, SubscriptionUrlMatcher.shoppingServices)) { + if (!categorizedSubscriptions.containsKey('쇼핑/이커머스')) { + categorizedSubscriptions['쇼핑/이커머스'] = []; + } + categorizedSubscriptions['쇼핑/이커머스']!.add(subscription); + } + // 프로그래밍 + else if (_isInCategory(subscription.serviceName, + SubscriptionUrlMatcher.programmingServices)) { + if (!categorizedSubscriptions.containsKey('프로그래밍')) { + categorizedSubscriptions['프로그래밍'] = []; + } + categorizedSubscriptions['프로그래밍']!.add(subscription); + } + // 협업/오피스 + else if (_isInCategory( + subscription.serviceName, SubscriptionUrlMatcher.officeTools)) { + if (!categorizedSubscriptions.containsKey('협업/오피스')) { + categorizedSubscriptions['협업/오피스'] = []; + } + categorizedSubscriptions['협업/오피스']!.add(subscription); + } + // AI 서비스 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.aiServices)) { if (!categorizedSubscriptions.containsKey('AI 서비스')) { @@ -57,29 +105,13 @@ class SubscriptionCategoryHelper { } categorizedSubscriptions['AI 서비스']!.add(subscription); } - // 프로그래밍/개발 - else if (_isInCategory(subscription.serviceName, - SubscriptionUrlMatcher.programmingServices)) { - if (!categorizedSubscriptions.containsKey('프로그래밍/개발 서비스')) { - categorizedSubscriptions['프로그래밍/개발 서비스'] = []; - } - categorizedSubscriptions['프로그래밍/개발 서비스']!.add(subscription); - } - // 오피스/협업 툴 - else if (_isInCategory( - subscription.serviceName, SubscriptionUrlMatcher.officeTools)) { - if (!categorizedSubscriptions.containsKey('오피스/협업 툴')) { - categorizedSubscriptions['오피스/협업 툴'] = []; - } - categorizedSubscriptions['오피스/협업 툴']!.add(subscription); - } - // 기타 서비스 + // 기타 else if (_isInCategory( subscription.serviceName, SubscriptionUrlMatcher.otherServices)) { - if (!categorizedSubscriptions.containsKey('기타 서비스')) { - categorizedSubscriptions['기타 서비스'] = []; + if (!categorizedSubscriptions.containsKey('기타')) { + categorizedSubscriptions['기타'] = []; } - categorizedSubscriptions['기타 서비스']!.add(subscription); + categorizedSubscriptions['기타']!.add(subscription); } // 미분류된 서비스 else { diff --git a/lib/widgets/add_subscription/add_subscription_app_bar.dart b/lib/widgets/add_subscription/add_subscription_app_bar.dart index 911ca9e..bf07481 100644 --- a/lib/widgets/add_subscription/add_subscription_app_bar.dart +++ b/lib/widgets/add_subscription/add_subscription_app_bar.dart @@ -40,6 +40,14 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg ), child: SafeArea( child: AppBar( + leading: IconButton( + icon: const Icon( + Icons.chevron_left, + size: 28, + color: Color(0xFF1E293B), + ), + onPressed: () => Navigator.of(context).pop(), + ), title: Text( '구독 추가', style: TextStyle( diff --git a/lib/widgets/add_subscription/add_subscription_event_section.dart b/lib/widgets/add_subscription/add_subscription_event_section.dart index 4132135..796ae1a 100644 --- a/lib/widgets/add_subscription/add_subscription_event_section.dart +++ b/lib/widgets/add_subscription/add_subscription_event_section.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../controllers/add_subscription_controller.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; +import '../../theme/app_colors.dart'; /// 구독 추가 화면의 이벤트/할인 섹션 class AddSubscriptionEventSection extends StatelessWidget { @@ -37,56 +38,67 @@ class AddSubscriptionEventSection extends StatelessWidget { )), child: Container( margin: const EdgeInsets.only(bottom: 20), - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), + color: AppColors.glassCard, + borderRadius: BorderRadius.circular(20), border: Border.all( - color: controller.isEventActive - ? const Color(0xFF3B82F6) - : Colors.grey.withValues(alpha: 0.2), - width: controller.isEventActive ? 2 : 1, + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, ), + boxShadow: [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Checkbox( + Row( + children: [ + Container( + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: controller.gradientColors[0].withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.local_offer_rounded, + color: controller.gradientColors[0], + size: 24, + ), + ), + const SizedBox(width: 12), + const Text( + '이벤트 가격', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w700, + color: AppColors.darkNavy, + ), + ), + ], + ), + Switch.adaptive( value: controller.isEventActive, onChanged: (value) { setState(() { - controller.isEventActive = value ?? false; + controller.isEventActive = value; if (!controller.isEventActive) { // 이벤트 비활성화 시 관련 데이터 초기화 - controller.eventStartDate = DateTime.now(); - controller.eventEndDate = DateTime.now().add(const Duration(days: 30)); + controller.eventStartDate = null; + controller.eventEndDate = null; controller.eventPriceController.clear(); - } else { - // 이벤트 활성화 시 날짜가 null이면 기본값 설정 - controller.eventStartDate ??= DateTime.now(); - controller.eventEndDate ??= DateTime.now().add(const Duration(days: 30)); } }); }, - activeColor: const Color(0xFF3B82F6), - ), - const Text( - '이벤트/할인 설정', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: Color(0xFF1E293B), - ), - ), - const SizedBox(width: 8), - Icon( - Icons.local_offer, - size: 20, - color: controller.isEventActive - ? const Color(0xFF3B82F6) - : Colors.grey, + activeColor: controller.gradientColors[0], ), ], ), @@ -101,6 +113,39 @@ class AddSubscriptionEventSection extends StatelessWidget { child: Column( children: [ const SizedBox(height: 20), + // 이벤트 설명 + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AppColors.infoColor.withValues(alpha: 0.08), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.infoColor.withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + children: [ + Icon( + Icons.info_outline_rounded, + color: AppColors.infoColor, + size: 20, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + '할인 또는 프로모션 가격을 설정하세요', + style: TextStyle( + fontSize: 14, + color: AppColors.darkNavy, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + const SizedBox(height: 20), // 이벤트 기간 DateRangePickerField( @@ -109,6 +154,10 @@ class AddSubscriptionEventSection extends StatelessWidget { onStartDateSelected: (date) { setState(() { controller.eventStartDate = date; + // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 + if (date != null && controller.eventEndDate == null) { + controller.eventEndDate = date.add(const Duration(days: 30)); + } }); }, onEndDateSelected: (date) { @@ -118,7 +167,7 @@ class AddSubscriptionEventSection extends StatelessWidget { }, startLabel: '시작일', endLabel: '종료일', - primaryColor: const Color(0xFF3B82F6), + primaryColor: controller.gradientColors[0], ), const SizedBox(height: 20), diff --git a/lib/widgets/add_subscription/add_subscription_form.dart b/lib/widgets/add_subscription/add_subscription_form.dart index f9eca9b..87a6773 100644 --- a/lib/widgets/add_subscription/add_subscription_form.dart +++ b/lib/widgets/add_subscription/add_subscription_form.dart @@ -6,6 +6,11 @@ import '../../providers/category_provider.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; +import '../common/form_fields/currency_selector.dart'; +import '../common/form_fields/billing_cycle_selector.dart'; +import '../common/form_fields/category_selector.dart'; +import '../glassmorphism_card.dart'; +import '../../theme/app_colors.dart'; /// 구독 추가 화면의 폼 섹션 class AddSubscriptionForm extends StatelessWidget { @@ -39,12 +44,8 @@ class AddSubscriptionForm extends StatelessWidget { parent: controller.animationController!, curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), )), - child: Card( - elevation: 1, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(20), - ), + child: GlassmorphismCard( + backgroundColor: AppColors.glassCard, child: Padding( padding: const EdgeInsets.all(24), child: Column( @@ -134,8 +135,9 @@ class AddSubscriptionForm extends StatelessWidget { ), ), const SizedBox(height: 8), - _CurrencySelector( + CurrencySelector( currency: controller.currency, + isGlassmorphism: true, onChanged: (value) { setState(() { controller.currency = value; @@ -161,9 +163,10 @@ class AddSubscriptionForm extends StatelessWidget { ), ), const SizedBox(height: 8), - _BillingCycleSelector( + BillingCycleSelector( billingCycle: controller.billingCycle, - gradientColors: controller.gradientColors, + baseColor: controller.gradientColors[0], + isGlassmorphism: true, onChanged: (value) { setState(() { controller.billingCycle = value; @@ -217,10 +220,11 @@ class AddSubscriptionForm extends StatelessWidget { ), ), const SizedBox(height: 8), - _CategorySelector( + CategorySelector( categories: categoryProvider.categories, selectedCategoryId: controller.selectedCategoryId, - gradientColors: controller.gradientColors, + baseColor: controller.gradientColors[0], + isGlassmorphism: true, onChanged: (categoryId) { setState(() { controller.selectedCategoryId = categoryId; @@ -240,192 +244,3 @@ class AddSubscriptionForm extends StatelessWidget { } } -/// 통화 선택기 -class _CurrencySelector extends StatelessWidget { - final String currency; - final ValueChanged onChanged; - - const _CurrencySelector({ - required this.currency, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - _CurrencyOption( - label: '₩', - value: 'KRW', - isSelected: currency == 'KRW', - onTap: () => onChanged('KRW'), - ), - const SizedBox(width: 8), - _CurrencyOption( - label: '\$', - value: 'USD', - isSelected: currency == 'USD', - onTap: () => onChanged('USD'), - ), - ], - ); - } -} - -/// 통화 옵션 -class _CurrencyOption extends StatelessWidget { - final String label; - final String value; - final bool isSelected; - final VoidCallback onTap; - - const _CurrencyOption({ - required this.label, - required this.value, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isSelected - ? const Color(0xFF3B82F6) - : Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - label, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: isSelected ? Colors.white : Colors.grey[600], - ), - ), - ), - ), - ), - ); - } -} - -/// 결제 주기 선택기 -class _BillingCycleSelector extends StatelessWidget { - final String billingCycle; - final List gradientColors; - final ValueChanged onChanged; - - const _BillingCycleSelector({ - required this.billingCycle, - required this.gradientColors, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final cycles = ['월간', '분기별', '반기별', '연간']; - - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: cycles.map((cycle) { - final isSelected = billingCycle == cycle; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: InkWell( - onTap: () => onChanged(cycle), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - decoration: BoxDecoration( - color: isSelected - ? gradientColors[0] - : Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - cycle, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white : Colors.grey[700], - ), - ), - ), - ), - ); - }).toList(), - ), - ); - } -} - -/// 카테고리 선택기 -class _CategorySelector extends StatelessWidget { - final List categories; - final String? selectedCategoryId; - final List gradientColors; - final ValueChanged onChanged; - - const _CategorySelector({ - required this.categories, - required this.selectedCategoryId, - required this.gradientColors, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Wrap( - spacing: 8, - runSpacing: 8, - children: categories.map((category) { - final isSelected = selectedCategoryId == category.id; - return InkWell( - onTap: () => onChanged(category.id), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - decoration: BoxDecoration( - color: isSelected - ? gradientColors[0] - : Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - category.icon, - style: const TextStyle(fontSize: 16), - ), - const SizedBox(width: 6), - Text( - category.name, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white : Colors.grey[700], - ), - ), - ], - ), - ), - ); - }).toList(), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/analysis/analysis_badge.dart b/lib/widgets/analysis/analysis_badge.dart index 88ecef9..c28a073 100644 --- a/lib/widgets/analysis/analysis_badge.dart +++ b/lib/widgets/analysis/analysis_badge.dart @@ -52,7 +52,7 @@ class AnalysisBadge extends StatelessWidget { color: AppColors.darkNavy, ), ), - const SizedBox(height: 2), + const SizedBox(height: 0), FutureBuilder( future: CurrencyUtil.formatAmount( subscription.monthlyCost, diff --git a/lib/widgets/app_navigator.dart b/lib/widgets/app_navigator.dart index f0a689f..1215034 100644 --- a/lib/widgets/app_navigator.dart +++ b/lib/widgets/app_navigator.dart @@ -42,12 +42,18 @@ class AppNavigator { /// 구독 상세 화면으로 네비게이션 static Future toDetail(BuildContext context, SubscriptionModel subscription) async { + print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}'); HapticFeedback.lightImpact(); - await Navigator.of(context).pushNamed( - AppRoutes.subscriptionDetail, - arguments: subscription, - ); + try { + await Navigator.of(context).pushNamed( + AppRoutes.subscriptionDetail, + arguments: subscription, + ); + print('DetailScreen 네비게이션 성공'); + } catch (e) { + print('DetailScreen 네비게이션 오류: $e'); + } } /// SMS 스캔 화면으로 네비게이션 diff --git a/lib/widgets/category_header_widget.dart b/lib/widgets/category_header_widget.dart index 97065cc..8971c58 100644 --- a/lib/widgets/category_header_widget.dart +++ b/lib/widgets/category_header_widget.dart @@ -4,17 +4,19 @@ import 'package:intl/intl.dart'; /// 카테고리별 구독 그룹의 헤더 위젯 /// /// 카테고리 이름, 구독 개수, 총 비용을 표시합니다. -/// 참고: 여러 통화 단위가 혼합된 경우 간단히 원화 표시 형식을 사용합니다. +/// 통화별로 구분하여 표시하며, 혼재된 경우 각각 표시합니다. class CategoryHeaderWidget extends StatelessWidget { final String categoryName; final int subscriptionCount; - final double totalCost; + final double totalCostUSD; + final double totalCostKRW; const CategoryHeaderWidget({ Key? key, required this.categoryName, required this.subscriptionCount, - required this.totalCost, + required this.totalCostUSD, + required this.totalCostKRW, }) : super(key: key); @override @@ -36,7 +38,7 @@ class CategoryHeaderWidget extends StatelessWidget { ), ), Text( - '${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0).format(totalCost)}', + _buildCostDisplay(), style: const TextStyle( fontSize: 12, fontWeight: FontWeight.w500, @@ -55,4 +57,44 @@ class CategoryHeaderWidget extends StatelessWidget { ), ); } + + /// 통화별 합계를 표시하는 문자열을 생성합니다. + String _buildCostDisplay() { + final parts = []; + + // 개수는 항상 표시 + parts.add('$subscriptionCount개'); + + // 통화 부분을 별도로 처리 + final currencyParts = []; + + // 달러가 있는 경우 + if (totalCostUSD > 0) { + final formatter = NumberFormat.currency( + locale: 'en_US', + symbol: '\$', + decimalDigits: 2, + ); + currencyParts.add(formatter.format(totalCostUSD)); + } + + // 원화가 있는 경우 + if (totalCostKRW > 0) { + final formatter = NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ); + currencyParts.add(formatter.format(totalCostKRW)); + } + + // 통화가 하나 이상 있는 경우 + if (currencyParts.isNotEmpty) { + // 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로 + final currencyDisplay = currencyParts.join(' + '); + parts.add(currencyDisplay); + } + + return parts.join(' · '); + } } diff --git a/lib/widgets/common/form_fields/base_text_field.dart b/lib/widgets/common/form_fields/base_text_field.dart index ada517f..915b79a 100644 --- a/lib/widgets/common/form_fields/base_text_field.dart +++ b/lib/widgets/common/form_fields/base_text_field.dart @@ -103,7 +103,7 @@ class BaseTextField extends StatelessWidget { prefixText: prefixText, suffixIcon: suffixIcon, filled: true, - fillColor: fillColor ?? AppColors.glassBackground, + fillColor: fillColor ?? AppColors.surfaceColorAlt, contentPadding: contentPadding ?? const EdgeInsets.all(16), border: OutlineInputBorder( borderRadius: BorderRadius.circular(16), @@ -119,8 +119,8 @@ class BaseTextField extends StatelessWidget { enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(16), borderSide: BorderSide( - color: AppColors.textSecondary, - width: 1, + color: AppColors.borderColor.withValues(alpha: 0.7), + width: 1.5, ), ), disabledBorder: OutlineInputBorder( diff --git a/lib/widgets/common/form_fields/billing_cycle_selector.dart b/lib/widgets/common/form_fields/billing_cycle_selector.dart new file mode 100644 index 0000000..513ea37 --- /dev/null +++ b/lib/widgets/common/form_fields/billing_cycle_selector.dart @@ -0,0 +1,101 @@ +import 'package:flutter/material.dart'; +import '../../../theme/app_colors.dart'; + +/// 결제 주기 선택 위젯 +/// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다. +class BillingCycleSelector extends StatelessWidget { + final String billingCycle; + final ValueChanged onChanged; + final Color? baseColor; + final List? gradientColors; + final bool isGlassmorphism; + + const BillingCycleSelector({ + super.key, + required this.billingCycle, + required this.onChanged, + this.baseColor, + this.gradientColors, + this.isGlassmorphism = false, + }); + + @override + Widget build(BuildContext context) { + // 상세 화면에서는 '매월', 추가 화면에서는 '월간'으로 표시 + final cycles = isGlassmorphism + ? ['매월', '분기별', '반기별', '매년'] + : ['월간', '분기별', '반기별', '연간']; + + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: cycles.map((cycle) { + final isSelected = billingCycle == cycle; + return Padding( + padding: const EdgeInsets.only(right: 8), + child: InkWell( + onTap: () => onChanged(cycle), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 20, + vertical: 12, + ), + decoration: BoxDecoration( + color: _getBackgroundColor(isSelected), + borderRadius: BorderRadius.circular(12), + border: _getBorder(isSelected), + ), + child: Text( + cycle, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: _getTextColor(isSelected), + ), + ), + ), + ), + ); + }).toList(), + ), + ); + } + + Color _getBackgroundColor(bool isSelected) { + if (!isSelected) { + return isGlassmorphism + ? AppColors.backgroundColor + : Colors.grey.withValues(alpha: 0.1); + } + + if (baseColor != null) { + return baseColor!; + } + + if (gradientColors != null && gradientColors!.isNotEmpty) { + return gradientColors![0]; + } + + return const Color(0xFF3B82F6); + } + + Border? _getBorder(bool isSelected) { + if (isSelected || !isGlassmorphism) { + return null; + } + return Border.all( + color: AppColors.borderColor.withValues(alpha: 0.5), + width: 1.5, + ); + } + + Color _getTextColor(bool isSelected) { + if (isSelected) { + return Colors.white; + } + return isGlassmorphism + ? AppColors.darkNavy + : Colors.grey[700]!; + } +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/category_selector.dart b/lib/widgets/common/form_fields/category_selector.dart new file mode 100644 index 0000000..ea82d31 --- /dev/null +++ b/lib/widgets/common/form_fields/category_selector.dart @@ -0,0 +1,132 @@ +import 'package:flutter/material.dart'; +import '../../../theme/app_colors.dart'; + +/// 카테고리 선택 위젯 +/// 구독 서비스의 카테고리를 선택할 수 있습니다. +class CategorySelector extends StatelessWidget { + final List categories; + final String? selectedCategoryId; + final ValueChanged onChanged; + final Color? baseColor; + final List? gradientColors; + final bool isGlassmorphism; + + const CategorySelector({ + super.key, + required this.categories, + required this.selectedCategoryId, + required this.onChanged, + this.baseColor, + this.gradientColors, + this.isGlassmorphism = false, + }); + + @override + Widget build(BuildContext context) { + return Wrap( + spacing: 8, + runSpacing: 8, + children: categories.map((category) { + final isSelected = selectedCategoryId == category.id; + return InkWell( + onTap: () => onChanged(category.id), + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + decoration: BoxDecoration( + color: _getBackgroundColor(isSelected), + borderRadius: BorderRadius.circular(12), + border: _getBorder(isSelected), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + _getCategoryIcon(category), + size: 18, + color: _getTextColor(isSelected), + ), + const SizedBox(width: 6), + Text( + category.name, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: _getTextColor(isSelected), + ), + ), + ], + ), + ), + ); + }).toList(), + ); + } + + IconData _getCategoryIcon(dynamic category) { + // 카테고리명에 따른 아이콘 반환 + switch (category.name) { + case '음악': + return Icons.music_note_rounded; + case 'OTT(동영상)': + return Icons.movie_filter_rounded; + case '저장/클라우드': + return Icons.cloud_outlined; + case '통신 · 인터넷 · TV': + return Icons.wifi_rounded; + case '생활/라이프스타일': + return Icons.home_outlined; + case '쇼핑/이커머스': + return Icons.shopping_cart_outlined; + case '프로그래밍': + return Icons.code_rounded; + case '협업/오피스': + return Icons.business_center_outlined; + case 'AI 서비스': + return Icons.smart_toy_outlined; + case '기타': + default: + return Icons.category_outlined; + } + } + + Color _getBackgroundColor(bool isSelected) { + if (!isSelected) { + return isGlassmorphism + ? AppColors.backgroundColor + : Colors.grey.withValues(alpha: 0.1); + } + + if (baseColor != null) { + return baseColor!; + } + + if (gradientColors != null && gradientColors!.isNotEmpty) { + return gradientColors![0]; + } + + return const Color(0xFF3B82F6); + } + + Border? _getBorder(bool isSelected) { + if (isSelected || !isGlassmorphism) { + return null; + } + return Border.all( + color: AppColors.borderColor.withValues(alpha: 0.5), + width: 1.5, + ); + } + + Color _getTextColor(bool isSelected) { + if (isSelected) { + return Colors.white; + } + return isGlassmorphism + ? AppColors.darkNavy + : Colors.grey[700]!; + } +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/currency_input_field.dart b/lib/widgets/common/form_fields/currency_input_field.dart index 3c32ece..70f5b62 100644 --- a/lib/widgets/common/form_fields/currency_input_field.dart +++ b/lib/widgets/common/form_fields/currency_input_field.dart @@ -36,33 +36,62 @@ class CurrencyInputField extends StatefulWidget { } class _CurrencyInputFieldState extends State { - late TextEditingController _formattedController; + late FocusNode _focusNode; + bool _isFormatted = false; @override void initState() { super.initState(); - _formattedController = TextEditingController(); - _updateFormattedValue(); - widget.controller.addListener(_onControllerChanged); + _focusNode = widget.focusNode ?? FocusNode(); + _focusNode.addListener(_onFocusChanged); + + // 초기값이 있으면 포맷팅 적용 + if (widget.controller.text.isNotEmpty) { + final value = double.tryParse(widget.controller.text.replaceAll(',', '')); + if (value != null) { + widget.controller.text = _formatCurrency(value); + _isFormatted = true; + } + } } @override void dispose() { - widget.controller.removeListener(_onControllerChanged); - _formattedController.dispose(); + if (widget.focusNode == null) { + _focusNode.dispose(); + } else { + _focusNode.removeListener(_onFocusChanged); + } super.dispose(); } - void _onControllerChanged() { - _updateFormattedValue(); - } - - void _updateFormattedValue() { - final value = double.tryParse(widget.controller.text.replaceAll(',', '')); - if (value != null) { - _formattedController.text = _formatCurrency(value); - } else { - _formattedController.text = ''; + void _onFocusChanged() { + if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) { + // 포커스를 잃었을 때 포맷팅 적용 + final value = _parseValue(widget.controller.text); + if (value != null) { + setState(() { + widget.controller.text = _formatCurrency(value); + _isFormatted = true; + }); + } + } else if (_focusNode.hasFocus && _isFormatted) { + // 포커스를 받았을 때 포맷팅 제거 + final value = _parseValue(widget.controller.text); + if (value != null) { + setState(() { + if (widget.currency == 'KRW') { + widget.controller.text = value.toInt().toString(); + } else { + widget.controller.text = value.toString(); + } + _isFormatted = false; + }); + // 커서를 끝으로 이동 + widget.controller.selection = TextSelection.fromPosition( + TextPosition(offset: widget.controller.text.length), + ); + } } } @@ -90,38 +119,42 @@ class _CurrencyInputFieldState extends State { @override Widget build(BuildContext context) { return BaseTextField( - controller: _formattedController, - focusNode: widget.focusNode, + controller: widget.controller, + focusNode: _focusNode, label: widget.label, hintText: widget.hintText ?? _defaultHintText, textInputAction: widget.textInputAction, keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ - FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')), + FilteringTextInputFormatter.allow( + widget.currency == 'KRW' + ? RegExp(r'[0-9]') + : RegExp(r'[0-9.]') + ), + if (widget.currency == 'USD') + // USD의 경우 소수점 이하 2자리까지만 허용 + TextInputFormatter.withFunction((oldValue, newValue) { + final text = newValue.text; + if (text.isEmpty) return newValue; + + final parts = text.split('.'); + if (parts.length > 2) { + // 소수점이 2개 이상인 경우 거부 + return oldValue; + } + if (parts.length == 2 && parts[1].length > 2) { + // 소수점 이하가 2자리를 초과하는 경우 거부 + return oldValue; + } + return newValue; + }), ], prefixText: _prefixText, onEditingComplete: widget.onEditingComplete, enabled: widget.enabled, onChanged: (value) { final parsedValue = _parseValue(value); - if (parsedValue != null) { - widget.controller.text = parsedValue.toString(); - widget.onChanged?.call(parsedValue); - } else { - widget.controller.text = ''; - widget.onChanged?.call(null); - } - - // 포맷팅 업데이트 - if (parsedValue != null) { - final formattedText = _formatCurrency(parsedValue); - if (formattedText != value) { - _formattedController.value = TextEditingValue( - text: formattedText, - selection: TextSelection.collapsed(offset: formattedText.length), - ); - } - } + widget.onChanged?.call(parsedValue); }, validator: widget.validator ?? (value) { if (value == null || value.isEmpty) { diff --git a/lib/widgets/common/form_fields/currency_selector.dart b/lib/widgets/common/form_fields/currency_selector.dart new file mode 100644 index 0000000..ec327df --- /dev/null +++ b/lib/widgets/common/form_fields/currency_selector.dart @@ -0,0 +1,117 @@ +import 'package:flutter/material.dart'; +import '../../../theme/app_colors.dart'; + +/// 통화 선택 위젯 +/// KRW(원화)와 USD(달러) 중 선택할 수 있습니다. +class CurrencySelector extends StatelessWidget { + final String currency; + final ValueChanged onChanged; + final bool isGlassmorphism; + + const CurrencySelector({ + super.key, + required this.currency, + required this.onChanged, + this.isGlassmorphism = false, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + _CurrencyOption( + label: '₩', + value: 'KRW', + isSelected: currency == 'KRW', + onTap: () => onChanged('KRW'), + isGlassmorphism: isGlassmorphism, + ), + const SizedBox(width: 8), + _CurrencyOption( + label: '\$', + value: 'USD', + isSelected: currency == 'USD', + onTap: () => onChanged('USD'), + isGlassmorphism: isGlassmorphism, + ), + ], + ); + } +} + +/// 통화 옵션 버튼 +class _CurrencyOption extends StatelessWidget { + final String label; + final String value; + final bool isSelected; + final VoidCallback onTap; + final bool isGlassmorphism; + + const _CurrencyOption({ + required this.label, + required this.value, + required this.isSelected, + required this.onTap, + required this.isGlassmorphism, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Expanded( + child: InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: _getBackgroundColor(theme), + borderRadius: BorderRadius.circular(12), + border: _getBorder(), + ), + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: _getTextColor(), + ), + ), + ), + ), + ), + ); + } + + Color _getBackgroundColor(ThemeData theme) { + if (isSelected) { + return isGlassmorphism + ? theme.primaryColor + : const Color(0xFF3B82F6); + } + return isGlassmorphism + ? AppColors.surfaceColorAlt + : Colors.grey.withValues(alpha: 0.1); + } + + Border? _getBorder() { + if (isSelected || !isGlassmorphism) { + return null; + } + return Border.all( + color: AppColors.borderColor, + width: 1.5, + ); + } + + Color _getTextColor() { + if (isSelected) { + return Colors.white; + } + return isGlassmorphism + ? AppColors.navyGray + : Colors.grey[600]!; + } +} \ No newline at end of file diff --git a/lib/widgets/common/form_fields/date_picker_field.dart b/lib/widgets/common/form_fields/date_picker_field.dart index 753bdde..fcc9d40 100644 --- a/lib/widgets/common/form_fields/date_picker_field.dart +++ b/lib/widgets/common/form_fields/date_picker_field.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import '../../../theme/app_colors.dart'; /// 날짜 선택 필드 위젯 /// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다. @@ -47,7 +48,7 @@ class DatePickerField extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: theme.colorScheme.onSurface, + color: AppColors.darkNavy, ), ), const SizedBox(height: 8), @@ -82,10 +83,11 @@ class DatePickerField extends StatelessWidget { child: Container( padding: contentPadding ?? const EdgeInsets.all(16), decoration: BoxDecoration( - color: backgroundColor ?? Colors.white, + color: backgroundColor ?? AppColors.surfaceColorAlt, borderRadius: BorderRadius.circular(16), border: Border.all( - color: Colors.transparent, + color: AppColors.borderColor.withValues(alpha: 0.7), + width: 1.5, ), ), child: Row( @@ -96,8 +98,8 @@ class DatePickerField extends StatelessWidget { style: TextStyle( fontSize: 16, color: enabled - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurface.withValues(alpha: 0.5), + ? AppColors.textPrimary + : AppColors.textMuted, ), ), ), @@ -105,8 +107,8 @@ class DatePickerField extends StatelessWidget { Icons.calendar_today, size: 20, color: enabled - ? theme.colorScheme.onSurface.withValues(alpha: 0.6) - : theme.colorScheme.onSurface.withValues(alpha: 0.3), + ? AppColors.navyGray + : AppColors.textMuted, ), ], ), @@ -227,8 +229,12 @@ class _DateRangeItem extends StatelessWidget { child: Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.white, + color: AppColors.surfaceColorAlt, borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.borderColor.withValues(alpha: 0.7), + width: 1.5, + ), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -237,7 +243,7 @@ class _DateRangeItem extends StatelessWidget { label, style: TextStyle( fontSize: 12, - color: theme.colorScheme.onSurface.withValues(alpha: 0.6), + color: AppColors.textSecondary, ), ), const SizedBox(height: 4), @@ -249,8 +255,8 @@ class _DateRangeItem extends StatelessWidget { fontSize: 16, fontWeight: FontWeight.w500, color: date != null - ? theme.colorScheme.onSurface - : theme.colorScheme.onSurface.withValues(alpha: 0.4), + ? AppColors.textPrimary + : AppColors.textMuted, ), ), ], diff --git a/lib/widgets/common/snackbar/app_snackbar.dart b/lib/widgets/common/snackbar/app_snackbar.dart index 5f11cf4..9d4c92d 100644 --- a/lib/widgets/common/snackbar/app_snackbar.dart +++ b/lib/widgets/common/snackbar/app_snackbar.dart @@ -161,10 +161,10 @@ class AppSnackBar { behavior: SnackBarBehavior.floating, margin: showAtTop ? EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 16, + top: MediaQuery.of(context).padding.top, left: 16, right: 16, - bottom: MediaQuery.of(context).size.height - 120, + bottom: MediaQuery.of(context).size.height - 100, ) : const EdgeInsets.all(16), padding: const EdgeInsets.symmetric( @@ -222,10 +222,10 @@ class AppSnackBar { behavior: SnackBarBehavior.floating, margin: showAtTop ? EdgeInsets.only( - top: MediaQuery.of(context).padding.top + 16, + top: MediaQuery.of(context).padding.top, left: 16, right: 16, - bottom: MediaQuery.of(context).size.height - 120, + bottom: MediaQuery.of(context).size.height - 100, ) : const EdgeInsets.all(16), padding: const EdgeInsets.symmetric( diff --git a/lib/widgets/detail/detail_event_section.dart b/lib/widgets/detail/detail_event_section.dart index 1d84c5d..ee68208 100644 --- a/lib/widgets/detail/detail_event_section.dart +++ b/lib/widgets/detail/detail_event_section.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../../controllers/detail_screen_controller.dart'; +import '../../theme/app_colors.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; @@ -19,9 +21,11 @@ class DetailEventSection extends StatelessWidget { @override Widget build(BuildContext context) { - final baseColor = controller.getCardColor(); + return Consumer( + builder: (context, controller, child) { + final baseColor = controller.getCardColor(); - return FadeTransition( + return FadeTransition( opacity: fadeAnimation, child: SlideTransition( position: Tween( @@ -31,11 +35,21 @@ class DetailEventSection extends StatelessWidget { parent: controller.animationController!, curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), )), - child: Card( - elevation: 1, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( + child: Container( + decoration: BoxDecoration( + color: AppColors.glassCard, borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Padding( padding: const EdgeInsets.all(24), @@ -66,6 +80,7 @@ class DetailEventSection extends StatelessWidget { style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, + color: AppColors.darkNavy, ), ), ], @@ -93,14 +108,18 @@ class DetailEventSection extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), + color: AppColors.infoColor.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.infoColor.withValues(alpha: 0.3), + width: 1, + ), ), child: Row( children: [ Icon( Icons.info_outline_rounded, - color: Colors.blue[700], + color: AppColors.infoColor, size: 20, ), const SizedBox(width: 8), @@ -109,7 +128,8 @@ class DetailEventSection extends StatelessWidget { '할인 또는 프로모션 가격을 설정하세요', style: TextStyle( fontSize: 14, - color: Colors.blue[700], + color: AppColors.darkNavy, + fontWeight: FontWeight.w500, ), ), ), @@ -123,6 +143,10 @@ class DetailEventSection extends StatelessWidget { endDate: controller.eventEndDate, onStartDateSelected: (date) { controller.eventStartDate = date; + // 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정 + if (date != null && controller.eventEndDate == null) { + controller.eventEndDate = date.add(const Duration(days: 30)); + } }, onEndDateSelected: (date) { controller.eventEndDate = date; @@ -138,6 +162,18 @@ class DetailEventSection extends StatelessWidget { currency: controller.currency, label: '이벤트 가격', hintText: '할인된 가격을 입력하세요', + validator: controller.isEventActive + ? (value) { + if (value == null || value.isEmpty) { + return '이벤트 가격을 입력해주세요'; + } + final price = double.tryParse(value.replaceAll(',', '')); + if (price == null || price <= 0) { + return '올바른 가격을 입력해주세요'; + } + return null; + } + : null, ), const SizedBox(height: 16), // 할인율 표시 @@ -156,6 +192,8 @@ class DetailEventSection extends StatelessWidget { ), ), ); + }, + ); } } @@ -209,7 +247,7 @@ class _DiscountBadge extends StatelessWidget { ? '₩${discountAmount.toInt().toString()}원 절약' : '\$${discountAmount.toStringAsFixed(2)} 절약', style: TextStyle( - color: Colors.green[700], + color: const Color(0xFF15803D), fontSize: 14, fontWeight: FontWeight.w500, ), diff --git a/lib/widgets/detail/detail_form_section.dart b/lib/widgets/detail/detail_form_section.dart index bb87db9..57e84e8 100644 --- a/lib/widgets/detail/detail_form_section.dart +++ b/lib/widgets/detail/detail_form_section.dart @@ -2,9 +2,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../controllers/detail_screen_controller.dart'; import '../../providers/category_provider.dart'; +import '../../theme/app_colors.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/date_picker_field.dart'; +import '../common/form_fields/currency_selector.dart'; +import '../common/form_fields/billing_cycle_selector.dart'; +import '../common/form_fields/category_selector.dart'; /// 상세 화면 폼 섹션 /// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다. @@ -22,9 +26,11 @@ class DetailFormSection extends StatelessWidget { @override Widget build(BuildContext context) { - final baseColor = controller.getCardColor(); + return Consumer( + builder: (context, controller, child) { + final baseColor = controller.getCardColor(); - return FadeTransition( + return FadeTransition( opacity: fadeAnimation, child: SlideTransition( position: Tween( @@ -34,11 +40,21 @@ class DetailFormSection extends StatelessWidget { parent: controller.animationController!, curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), )), - child: Card( - elevation: 1, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( + child: Container( + decoration: BoxDecoration( + color: AppColors.glassCard, borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Padding( padding: const EdgeInsets.all(24), @@ -86,11 +102,13 @@ class DetailFormSection extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, + color: AppColors.darkNavy, ), ), const SizedBox(height: 8), - _CurrencySelector( + CurrencySelector( currency: controller.currency, + isGlassmorphism: true, onChanged: (value) { controller.currency = value; // 통화 변경시 금액 포맷 업데이트 @@ -121,12 +139,14 @@ class DetailFormSection extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, + color: AppColors.darkNavy, ), ), const SizedBox(height: 8), - _BillingCycleSelector( + BillingCycleSelector( billingCycle: controller.billingCycle, baseColor: baseColor, + isGlassmorphism: true, onChanged: (value) { controller.billingCycle = value; }, @@ -159,13 +179,15 @@ class DetailFormSection extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, + color: AppColors.darkNavy, ), ), const SizedBox(height: 8), - _CategorySelector( + CategorySelector( categories: categoryProvider.categories, selectedCategoryId: controller.selectedCategoryId, baseColor: baseColor, + isGlassmorphism: true, onChanged: (categoryId) { controller.selectedCategoryId = categoryId; }, @@ -180,191 +202,8 @@ class DetailFormSection extends StatelessWidget { ), ), ); - } -} - -/// 통화 선택기 -class _CurrencySelector extends StatelessWidget { - final String currency; - final ValueChanged onChanged; - - const _CurrencySelector({ - required this.currency, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Row( - children: [ - _CurrencyOption( - label: '₩', - value: 'KRW', - isSelected: currency == 'KRW', - onTap: () => onChanged('KRW'), - ), - const SizedBox(width: 8), - _CurrencyOption( - label: '\$', - value: 'USD', - isSelected: currency == 'USD', - onTap: () => onChanged('USD'), - ), - ], + }, ); } } -/// 통화 옵션 -class _CurrencyOption extends StatelessWidget { - final String label; - final String value; - final bool isSelected; - final VoidCallback onTap; - - const _CurrencyOption({ - required this.label, - required this.value, - required this.isSelected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return Expanded( - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration( - color: isSelected - ? Theme.of(context).primaryColor - : Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Center( - child: Text( - label, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: isSelected ? Colors.white : Colors.grey[600], - ), - ), - ), - ), - ), - ); - } -} - -/// 결제 주기 선택기 -class _BillingCycleSelector extends StatelessWidget { - final String billingCycle; - final Color baseColor; - final ValueChanged onChanged; - - const _BillingCycleSelector({ - required this.billingCycle, - required this.baseColor, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - final cycles = ['매월', '분기별', '반기별', '매년']; - - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: cycles.map((cycle) { - final isSelected = billingCycle == cycle; - return Padding( - padding: const EdgeInsets.only(right: 8), - child: InkWell( - onTap: () => onChanged(cycle), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 20, - vertical: 12, - ), - decoration: BoxDecoration( - color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - cycle, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white : Colors.grey[700], - ), - ), - ), - ), - ); - }).toList(), - ), - ); - } -} - -/// 카테고리 선택기 -class _CategorySelector extends StatelessWidget { - final List categories; - final String? selectedCategoryId; - final Color baseColor; - final ValueChanged onChanged; - - const _CategorySelector({ - required this.categories, - required this.selectedCategoryId, - required this.baseColor, - required this.onChanged, - }); - - @override - Widget build(BuildContext context) { - return Wrap( - spacing: 8, - runSpacing: 8, - children: categories.map((category) { - final isSelected = selectedCategoryId == category.id; - return InkWell( - onTap: () => onChanged(category.id), - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 10, - ), - decoration: BoxDecoration( - color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(12), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - category.icon, - style: const TextStyle(fontSize: 16), - ), - const SizedBox(width: 6), - Text( - category.name, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: isSelected ? Colors.white : Colors.grey[700], - ), - ), - ], - ), - ), - ); - }).toList(), - ); - } -} \ No newline at end of file diff --git a/lib/widgets/detail/detail_header_section.dart b/lib/widgets/detail/detail_header_section.dart index 83527bb..c38f00e 100644 --- a/lib/widgets/detail/detail_header_section.dart +++ b/lib/widgets/detail/detail_header_section.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import 'package:intl/intl.dart'; import '../../models/subscription_model.dart'; import '../../controllers/detail_screen_controller.dart'; @@ -24,10 +25,12 @@ class DetailHeaderSection extends StatelessWidget { @override Widget build(BuildContext context) { - final baseColor = controller.getCardColor(); - final gradient = controller.getGradient(baseColor); + return Consumer( + builder: (context, controller, child) { + final baseColor = controller.getCardColor(); + final gradient = controller.getGradient(baseColor); - return Container( + return Container( height: 320, decoration: BoxDecoration(gradient: gradient), child: Stack( @@ -118,8 +121,8 @@ class DetailHeaderSection extends StatelessWidget { child: ClipRRect( borderRadius: BorderRadius.circular(16), child: WebsiteIcon( - url: subscription.websiteUrl, - serviceName: subscription.serviceName, + url: controller.websiteUrlController.text, + serviceName: controller.serviceNameController.text, size: 48, ), ), @@ -131,7 +134,7 @@ class DetailHeaderSection extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - subscription.serviceName, + controller.serviceNameController.text, style: const TextStyle( fontSize: 28, fontWeight: FontWeight.w800, @@ -148,7 +151,7 @@ class DetailHeaderSection extends StatelessWidget { ), const SizedBox(height: 4), Text( - '${subscription.billingCycle} 결제', + '${controller.billingCycle} 결제', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, @@ -174,20 +177,22 @@ class DetailHeaderSection extends StatelessWidget { _InfoColumn( label: '다음 결제일', value: DateFormat('yyyy년 MM월 dd일') - .format(subscription.nextBillingDate), + .format(controller.nextBillingDate), ), _InfoColumn( label: '월 지출', value: NumberFormat.currency( - locale: subscription.currency == 'KRW' + locale: controller.currency == 'KRW' ? 'ko_KR' : 'en_US', - symbol: subscription.currency == 'KRW' + symbol: controller.currency == 'KRW' ? '₩' : '\$', decimalDigits: - subscription.currency == 'KRW' ? 0 : 2, - ).format(subscription.monthlyCost), + controller.currency == 'KRW' ? 0 : 2, + ).format(double.tryParse( + controller.monthlyCostController.text.replaceAll(',', '') + ) ?? 0), alignment: CrossAxisAlignment.end, ), ], @@ -204,6 +209,8 @@ class DetailHeaderSection extends StatelessWidget { ], ), ); + }, + ); } } diff --git a/lib/widgets/detail/detail_url_section.dart b/lib/widgets/detail/detail_url_section.dart index 426c62f..dfa9431 100644 --- a/lib/widgets/detail/detail_url_section.dart +++ b/lib/widgets/detail/detail_url_section.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../controllers/detail_screen_controller.dart'; +import '../../theme/app_colors.dart'; import '../common/form_fields/base_text_field.dart'; import '../common/buttons/secondary_button.dart'; @@ -31,11 +32,21 @@ class DetailUrlSection extends StatelessWidget { parent: controller.animationController!, curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), )), - child: Card( - elevation: 1, - shadowColor: Colors.black12, - shape: RoundedRectangleBorder( + child: Container( + decoration: BoxDecoration( + color: AppColors.glassCard, borderRadius: BorderRadius.circular(20), + border: Border.all( + color: AppColors.glassBorder.withValues(alpha: 0.1), + width: 1, + ), + boxShadow: [ + BoxShadow( + color: AppColors.shadowBlack, + blurRadius: 10, + offset: const Offset(0, 4), + ), + ], ), child: Padding( padding: const EdgeInsets.all(24), @@ -63,6 +74,7 @@ class DetailUrlSection extends StatelessWidget { style: TextStyle( fontSize: 18, fontWeight: FontWeight.w700, + color: AppColors.darkNavy, ), ), ], @@ -78,7 +90,7 @@ class DetailUrlSection extends StatelessWidget { keyboardType: TextInputType.url, prefixIcon: Icon( Icons.link_rounded, - color: Colors.grey[600], + color: AppColors.navyGray, ), ), @@ -89,10 +101,10 @@ class DetailUrlSection extends StatelessWidget { Container( padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.orange.withValues(alpha: 0.1), + color: AppColors.warningColor.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(16), border: Border.all( - color: Colors.orange.withValues(alpha: 0.3), + color: AppColors.warningColor.withValues(alpha: 0.4), width: 1, ), ), @@ -103,7 +115,7 @@ class DetailUrlSection extends StatelessWidget { children: [ Icon( Icons.info_outline_rounded, - color: Colors.orange[700], + color: AppColors.warningColor, size: 20, ), const SizedBox(width: 8), @@ -112,7 +124,7 @@ class DetailUrlSection extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: Colors.orange[700], + color: AppColors.darkNavy, ), ), ], @@ -122,7 +134,8 @@ class DetailUrlSection extends StatelessWidget { '이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.', style: TextStyle( fontSize: 14, - color: Colors.grey[700], + color: AppColors.darkNavy, + fontWeight: FontWeight.w500, height: 1.5, ), ), @@ -131,7 +144,7 @@ class DetailUrlSection extends StatelessWidget { text: '해지 페이지로 이동', icon: Icons.open_in_new_rounded, onPressed: controller.openCancellationPage, - color: Colors.orange[700], + color: AppColors.warningColor, ), ], ), @@ -144,14 +157,18 @@ class DetailUrlSection extends StatelessWidget { Container( padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.blue.withValues(alpha: 0.1), + color: AppColors.infoColor.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.infoColor.withValues(alpha: 0.3), + width: 1, + ), ), child: Row( children: [ Icon( Icons.auto_fix_high_rounded, - color: Colors.blue[700], + color: AppColors.infoColor, size: 20, ), const SizedBox(width: 8), @@ -160,7 +177,8 @@ class DetailUrlSection extends StatelessWidget { 'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다', style: TextStyle( fontSize: 14, - color: Colors.blue[700], + color: AppColors.darkNavy, + fontWeight: FontWeight.w500, ), ), ), diff --git a/lib/widgets/floating_navigation_bar.dart b/lib/widgets/floating_navigation_bar.dart index e14e560..1bae993 100644 --- a/lib/widgets/floating_navigation_bar.dart +++ b/lib/widgets/floating_navigation_bar.dart @@ -68,23 +68,12 @@ class _FloatingNavigationBarState extends State bottom: 20, left: 20, right: 20, + height: 88, child: Transform.translate( offset: Offset(0, 100 * (1 - _animation.value)), child: Opacity( opacity: _animation.value, - child: Stack( - children: [ - // 차단 레이어 - 크기 명시 - Positioned.fill( - child: Container( - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.9), - borderRadius: BorderRadius.circular(24), - ), - ), - ), - // 글래스모피즘 레이어 - GlassmorphismCard( + child: GlassmorphismCard( padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 8), borderRadius: 24, @@ -123,8 +112,6 @@ class _FloatingNavigationBarState extends State ], ), ), - ], - ), ), ), ); diff --git a/lib/widgets/glassmorphism_card.dart b/lib/widgets/glassmorphism_card.dart index 4ff57ca..00c6b0c 100644 --- a/lib/widgets/glassmorphism_card.dart +++ b/lib/widgets/glassmorphism_card.dart @@ -174,10 +174,17 @@ class _AnimatedGlassmorphismCardState extends State @override Widget build(BuildContext context) { return GestureDetector( + behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지 onTapDown: _handleTapDown, - onTapUp: _handleTapUp, + onTapUp: (details) { + _handleTapUp(details); + // onTap 콜백 실행 + if (widget.onTap != null) { + print('[AnimatedGlassmorphismCard] onTap 콜백 실행'); + widget.onTap!(); + } + }, onTapCancel: _handleTapCancel, - onTap: widget.onTap, child: AnimatedBuilder( animation: _controller, builder: (context, child) { @@ -191,6 +198,7 @@ class _AnimatedGlassmorphismCardState extends State borderRadius: widget.borderRadius, blur: _blurAnimation.value, opacity: widget.opacity, + onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음 child: widget.child, ), ); diff --git a/lib/widgets/home_content.dart b/lib/widgets/home_content.dart index 60ae091..5b0ee2e 100644 --- a/lib/widgets/home_content.dart +++ b/lib/widgets/home_content.dart @@ -7,7 +7,6 @@ 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'; class HomeContent extends StatelessWidget { @@ -67,10 +66,10 @@ class HomeContent extends StatelessWidget { controller: scrollController, physics: const BouncingScrollPhysics(), slivers: [ - const GlassmorphicSliverAppBar( - title: '홈', - pinned: true, - expandedHeight: kToolbarHeight, + SliverToBoxAdapter( + child: SizedBox( + height: kToolbarHeight + MediaQuery.of(context).padding.top, + ), ), const SliverToBoxAdapter( child: NativeAdWidget(key: ValueKey('home_ad')), @@ -105,7 +104,9 @@ class HomeContent extends StatelessWidget { parent: slideController, curve: Curves.easeOutCubic)), child: Text( '나의 구독 서비스', - style: Theme.of(context).textTheme.titleLarge, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + color: AppColors.darkNavy, + ), ), ), SlideTransition( @@ -143,7 +144,7 @@ class HomeContent extends StatelessWidget { ), SliverToBoxAdapter( child: SizedBox( - height: 100 + MediaQuery.of(context).padding.bottom, + height: 120 + MediaQuery.of(context).padding.bottom, ), ), ], diff --git a/lib/widgets/main_summary_card.dart b/lib/widgets/main_summary_card.dart index dc6febe..e2c037a 100644 --- a/lib/widgets/main_summary_card.dart +++ b/lib/widgets/main_summary_card.dart @@ -37,185 +37,196 @@ class MainScreenSummaryCard extends StatelessWidget { child: Padding( padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), child: GlassmorphismCard( - borderRadius: 24, - blur: 15, - backgroundColor: AppColors.glassCard, - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: AppColors.mainGradient.map((color) => color.withValues(alpha: 0.2)).toList(), + borderRadius: 24, + blur: 15, + backgroundColor: AppColors.glassCard, + gradient: LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: AppColors.mainGradient + .map((color) => color.withValues(alpha: 0.2)) + .toList(), + ), + border: Border.all( + color: AppColors.glassBorder, + width: 1, + ), + child: Container( + width: double.infinity, + constraints: BoxConstraints( + minHeight: 180, + maxHeight: activeEvents > 0 ? 300 : 240, ), - border: Border.all( - color: AppColors.glassBorder, - width: 1, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(24), + color: Colors.transparent, ), - child: Container( - width: double.infinity, - constraints: BoxConstraints( - minHeight: 180, - maxHeight: activeEvents > 0 ? 300 : 240, - ), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(24), - color: Colors.transparent, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(24), - child: Stack( - children: [ - // 애니메이션 웨이브 배경 - Positioned.fill( - child: AnimatedWaveBackground( - controller: waveController, - pulseController: pulseController, - ), + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: Stack( + children: [ + // 애니메이션 웨이브 배경 + Positioned.fill( + child: AnimatedWaveBackground( + controller: waveController, + pulseController: pulseController, ), - Padding( - padding: const EdgeInsets.all(24.0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '이번 달 총 구독 비용', - style: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 15, - fontWeight: FontWeight.w500, + ), + Padding( + padding: const EdgeInsets.all(24.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '이번 달 총 구독 비용', + style: TextStyle( + color: AppColors + .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + fontSize: 15, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 8), + Row( + crossAxisAlignment: CrossAxisAlignment.baseline, + textBaseline: TextBaseline.alphabetic, + children: [ + Text( + NumberFormat.currency( + locale: 'ko_KR', + symbol: '', + decimalDigits: 0, + ).format(monthlyCost), + style: const TextStyle( + color: AppColors + .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + fontSize: 32, + fontWeight: FontWeight.bold, + letterSpacing: -1, + ), ), - ), - const SizedBox(height: 8), - Row( - crossAxisAlignment: CrossAxisAlignment.baseline, - textBaseline: TextBaseline.alphabetic, - children: [ - Text( - NumberFormat.currency( - locale: 'ko_KR', - symbol: '', - decimalDigits: 0, - ).format(monthlyCost), - style: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 32, - fontWeight: FontWeight.bold, - letterSpacing: -1, - ), - ), - const SizedBox(width: 4), - Text( - '원', - style: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 16, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - const SizedBox(height: 16), - Row( - children: [ - _buildInfoBox( - context, - title: '연간 구독 비용', - value: '${NumberFormat.currency( - locale: 'ko_KR', - symbol: '', - decimalDigits: 0, - ).format(yearlyCost)}원', - ), - const SizedBox(width: 16), - _buildInfoBox( - context, - title: '총 구독 서비스', - value: '$totalSubscriptions개', - ), - ], - ), - // 이벤트 절약액 표시 - if (activeEvents > 0) ...[ - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - Colors.white.withValues(alpha: 0.2), - Colors.white.withValues(alpha: 0.15), - ], - ), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: AppColors.primaryColor.withValues(alpha: 0.3), - width: 1, - ), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - padding: const EdgeInsets.all(6), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.25), - shape: BoxShape.circle, - ), - child: const Icon( - Icons.local_offer_rounded, - size: 14, - color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘 - ), - ), - const SizedBox(width: 10), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '이벤트 할인 중', - style: TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 - fontSize: 11, - fontWeight: FontWeight.w500, - ), - ), - const SizedBox(height: 2), - Row( - children: [ - Text( - NumberFormat.currency( - locale: 'ko_KR', - symbol: '₩', - decimalDigits: 0, - ).format(eventSavings), - style: const TextStyle( - color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조 - fontSize: 14, - fontWeight: FontWeight.bold, - ), - ), - Text( - ' 절약 ($activeEvents개)', - style: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 - fontSize: 12, - fontWeight: FontWeight.w500, - ), - ), - ], - ), - ], - ), - ], + const SizedBox(width: 4), + Text( + '원', + style: TextStyle( + color: AppColors + .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + fontSize: 16, + fontWeight: FontWeight.w500, ), ), ], + ), + const SizedBox(height: 16), + Row( + children: [ + _buildInfoBox( + context, + title: '예상 연간 구독 비용', + value: '${NumberFormat.currency( + locale: 'ko_KR', + symbol: '', + decimalDigits: 0, + ).format(yearlyCost)}원', + ), + const SizedBox(width: 16), + _buildInfoBox( + context, + title: '총 구독 서비스', + value: '$totalSubscriptions개', + ), + ], + ), + // 이벤트 절약액 표시 + if (activeEvents > 0) ...[ + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.symmetric( + vertical: 10, horizontal: 14), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + Colors.white.withValues(alpha: 0.2), + Colors.white.withValues(alpha: 0.15), + ], + ), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: AppColors.primaryColor + .withValues(alpha: 0.3), + width: 1, + ), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: Colors.white.withValues(alpha: 0.25), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.local_offer_rounded, + size: 14, + color: AppColors + .primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘 + ), + ), + const SizedBox(width: 10), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '이벤트 할인 중', + style: TextStyle( + color: AppColors + .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + fontSize: 11, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Row( + children: [ + Text( + NumberFormat.currency( + locale: 'ko_KR', + symbol: '₩', + decimalDigits: 0, + ).format(eventSavings), + style: const TextStyle( + color: AppColors + .primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조 + fontSize: 14, + fontWeight: FontWeight.bold, + ), + ), + Text( + ' 절약 ($activeEvents개)', + style: TextStyle( + color: AppColors + .navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 + fontSize: 12, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ], + ), + ], + ), + ), ], - ), + ], ), - ], - ), + ), + ], ), ), + ), ), ), ); @@ -236,7 +247,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( title, style: TextStyle( - color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 + color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 fontSize: 12, fontWeight: FontWeight.w500, ), @@ -245,7 +256,7 @@ class MainScreenSummaryCard extends StatelessWidget { Text( value, style: const TextStyle( - color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 + color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 fontSize: 14, fontWeight: FontWeight.bold, ), diff --git a/lib/widgets/subscription_card.dart b/lib/widgets/subscription_card.dart index 545b5bf..a4fe8be 100644 --- a/lib/widgets/subscription_card.dart +++ b/lib/widgets/subscription_card.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:provider/provider.dart'; import '../models/subscription_model.dart'; +import '../providers/category_provider.dart'; import 'website_icon.dart'; import 'app_navigator.dart'; import '../theme/app_colors.dart'; @@ -8,10 +10,12 @@ import 'glassmorphism_card.dart'; class SubscriptionCard extends StatefulWidget { final SubscriptionModel subscription; + final VoidCallback? onTap; const SubscriptionCard({ super.key, required this.subscription, + this.onTap, }); @override @@ -190,6 +194,34 @@ class _SubscriptionCardState extends State return false; } + // 카테고리별 그라데이션 색상 생성 + List _getCategoryGradientColors(BuildContext context) { + try { + if (widget.subscription.categoryId == null) { + return AppColors.blueGradient; + } + + final categoryProvider = context.watch(); + final category = categoryProvider.getCategoryById(widget.subscription.categoryId!); + + if (category == null) { + return AppColors.blueGradient; + } + + final categoryColor = Color( + int.parse(category.color.replaceAll('#', '0xFF')) + ); + + return [ + categoryColor, + categoryColor.withValues(alpha: 0.8), + ]; + } catch (e) { + // 색상 파싱 실패 시 기본 파란색 그라데이션 반환 + return AppColors.blueGradient; + } + } + @override Widget build(BuildContext context) { @@ -200,53 +232,39 @@ class _SubscriptionCardState extends State child: MouseRegion( onEnter: (_) => _onHover(true), onExit: (_) => _onHover(false), - child: AnimatedBuilder( - animation: _hoverController, - builder: (context, child) { - final scale = 1.0 + (0.02 * _hoverController.value); + child: AnimatedGlassmorphismCard( + padding: EdgeInsets.zero, + borderRadius: 16, + blur: _isHovering ? 15 : 10, + width: double.infinity, // 전체 너비를 차지하도록 설정 + onTap: widget.onTap ?? () async { + await AppNavigator.toDetail(context, widget.subscription); + }, + child: Column( + children: [ + // 그라데이션 상단 바 효과 + AnimatedContainer( + duration: const Duration(milliseconds: 200), + height: 4, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: widget.subscription.isCurrentlyInEvent + ? [ + const Color(0xFFFF6B6B), + const Color(0xFFFF8787), + ] + : isNearBilling + ? AppColors.amberGradient + : _getCategoryGradientColors(context), + begin: Alignment.centerLeft, + end: Alignment.centerRight, + ), + ), + ), - return Transform.scale( - scale: scale, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () async { - await AppNavigator.toDetail(context, widget.subscription); - }, - splashColor: AppColors.primaryColor.withValues(alpha: 0.1), - highlightColor: AppColors.primaryColor.withValues(alpha: 0.05), - borderRadius: BorderRadius.circular(16), - child: AnimatedGlassmorphismCard( - onTap: () {}, // onTap은 이미 InkWell에서 처리됨 - padding: EdgeInsets.zero, - borderRadius: 16, - blur: _isHovering ? 15 : 10, - width: double.infinity, // 전체 너비를 차지하도록 설정 - child: Column( - children: [ - // 그라데이션 상단 바 효과 - AnimatedContainer( - duration: const Duration(milliseconds: 200), - height: 4, - decoration: BoxDecoration( - gradient: LinearGradient( - colors: widget.subscription.isCurrentlyInEvent - ? [ - const Color(0xFFFF6B6B), - const Color(0xFFFF8787), - ] - : isNearBilling - ? AppColors.amberGradient - : AppColors.blueGradient, - begin: Alignment.centerLeft, - end: Alignment.centerRight, - ), - ), - ), - - Padding( - padding: const EdgeInsets.all(16), - child: Row( + Padding( + padding: const EdgeInsets.all(16), + child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 서비스 아이콘 @@ -304,7 +322,7 @@ class _SubscriptionCardState extends State borderRadius: BorderRadius.circular(12), ), - child: Row( + child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon( @@ -526,15 +544,10 @@ class _SubscriptionCardState extends State ), ), ], - ), - ), - ], - ), - ), ), ), - ); - }, + ], + ), ), ), ); diff --git a/lib/widgets/subscription_list_widget.dart b/lib/widgets/subscription_list_widget.dart index 0c81291..9c9c8b4 100644 --- a/lib/widgets/subscription_list_widget.dart +++ b/lib/widgets/subscription_list_widget.dart @@ -42,10 +42,8 @@ class SubscriptionListWidget extends StatelessWidget { child: CategoryHeaderWidget( categoryName: category, subscriptionCount: subscriptions.length, - totalCost: subscriptions.fold( - 0.0, - (sum, sub) => sum + sub.monthlyCost, - ), + totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'), + totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'), ), ), // 카테고리별 구독 목록 @@ -87,10 +85,7 @@ class SubscriptionListWidget extends StatelessWidget { child: SwipeableSubscriptionCard( subscription: subscriptions[subIndex], onTap: () { - AppNavigator.toDetail(context, subscriptions[subIndex]); - }, - onEdit: () { - // 편집 화면으로 이동 + print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨'); AppNavigator.toDetail(context, subscriptions[subIndex]); }, onDelete: () async { @@ -111,7 +106,7 @@ class SubscriptionListWidget extends StatelessWidget { ); if (context.mounted) { - AppSnackBar.showSuccess( + AppSnackBar.showError( context: context, message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.', icon: Icons.delete_forever_rounded, @@ -134,6 +129,13 @@ class SubscriptionListWidget extends StatelessWidget { ), ); } + + /// 특정 통화의 총 합계를 계산합니다. + double _calculateTotalByCurrency(List subscriptions, String currency) { + return subscriptions + .where((sub) => sub.currency == currency) + .fold(0.0, (sum, sub) => sum + sub.monthlyCost); + } } /// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯 diff --git a/lib/widgets/swipeable_subscription_card.dart b/lib/widgets/swipeable_subscription_card.dart index 9bbf7c1..0e1b314 100644 --- a/lib/widgets/swipeable_subscription_card.dart +++ b/lib/widgets/swipeable_subscription_card.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/gestures.dart'; import '../models/subscription_model.dart'; import '../utils/haptic_feedback_helper.dart'; import 'subscription_card.dart'; @@ -8,7 +9,7 @@ class SwipeableSubscriptionCard extends StatefulWidget { final VoidCallback? onEdit; final Future Function()? onDelete; final VoidCallback? onTap; - + const SwipeableSubscriptionCard({ super.key, required this.subscription, @@ -18,23 +19,34 @@ class SwipeableSubscriptionCard extends StatefulWidget { }); @override - State createState() => _SwipeableSubscriptionCardState(); + State createState() => + _SwipeableSubscriptionCardState(); } class _SwipeableSubscriptionCardState extends State with SingleTickerProviderStateMixin { + // 상수 정의 + static const double _tapTolerance = 20.0; // 탭 허용 범위 + static const double _actionThresholdPercent = 0.15; + static const double _deleteThresholdPercent = 0.40; + static const int _tapDurationMs = 500; + static const double _velocityThreshold = 800.0; + // static const double _animationDuration = 300.0; + + // 애니메이션 관련 late AnimationController _controller; late Animation _animation; - double _dragStartX = 0; - double _currentOffset = 0; // 현재 카드의 실제 위치 - bool _isDragging = false; // 드래그 중인지 여부 + + // 제스처 추적 + Offset? _startPosition; + DateTime? _startTime; + bool _isValidTap = true; + + // 상태 관리 + double _currentOffset = 0; bool _isSwipingLeft = false; bool _hapticTriggered = false; - double _screenWidth = 0; - double _cardWidth = 0; // 카드의 실제 너비 (margin 제외) - - static const double _actionThresholdPercent = 0.15; // 15%에서 액션 버튼 표시 - static const double _deleteThresholdPercent = 0.40; // 40%에서 삭제/편집 실행 + double _cardWidth = 0; @override void initState() { @@ -50,128 +62,128 @@ class _SwipeableSubscriptionCardState extends State parent: _controller, curve: Curves.easeOutExpo, )); - - // 애니메이션 상태 리스너 추가 - _controller.addStatusListener(_onAnimationStatusChanged); - - // 애니메이션 리스너 추가 - _controller.addListener(_onAnimationUpdate); + + _controller.addListener(() { + if (mounted) { + setState(() { + _currentOffset = _animation.value; + }); + } + }); } - + @override void didChangeDependencies() { super.didChangeDependencies(); - _screenWidth = MediaQuery.of(context).size.width; - _cardWidth = _screenWidth - 32; // 좌우 margin 16px씩 제외 + _cardWidth = MediaQuery.of(context).size.width - 32; } - + @override void didUpdateWidget(SwipeableSubscriptionCard oldWidget) { super.didUpdateWidget(oldWidget); - // 위젯이 업데이트될 때 카드를 원위치로 복귀 if (oldWidget.subscription.id != widget.subscription.id) { - _controller.stop(); - setState(() { - _currentOffset = 0; - _isDragging = false; - }); + _resetCard(); } } @override void dispose() { - _controller.removeListener(_onAnimationUpdate); - _controller.removeStatusListener(_onAnimationStatusChanged); - _controller.stop(); _controller.dispose(); super.dispose(); } - - void _onAnimationUpdate() { - if (!_isDragging) { - setState(() { - _currentOffset = _animation.value; - }); - } - } - - void _onAnimationStatusChanged(AnimationStatus status) { - if (status == AnimationStatus.completed && !_isDragging) { - setState(() { - _currentOffset = _animation.value; - }); - } - } - void _handleDragStart(DragStartDetails details) { - _dragStartX = details.localPosition.dx; + // 제스처 핸들러 + void _handlePanStart(DragStartDetails details) { + _startPosition = details.localPosition; + _startTime = DateTime.now(); + _isValidTap = true; _hapticTriggered = false; - _isDragging = true; - _controller.stop(); // 진행 중인 애니메이션 중지 + _controller.stop(); } - void _handleDragUpdate(DragUpdateDetails details) { - final delta = details.localPosition.dx - _dragStartX; + void _handlePanUpdate(DragUpdateDetails details) { + final currentPosition = details.localPosition; + final delta = currentPosition.dx - _startPosition!.dx; + final distance = (currentPosition - _startPosition!).distance; + + // 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주 + if (distance > _tapTolerance) { + _isValidTap = false; + } + + // 카드 이동 setState(() { _currentOffset = delta; _isSwipingLeft = delta < 0; }); - - // 햅틱 피드백 트리거 (카드 너비의 15%) - final actionThreshold = _cardWidth * _actionThresholdPercent; - if (!_hapticTriggered && _currentOffset.abs() > actionThreshold) { - _hapticTriggered = true; - HapticFeedbackHelper.mediumImpact(); - } - - // 삭제 임계값에 도달했을 때 강한 햅틱 (카드 너비의 40%) - final deleteThreshold = _cardWidth * _deleteThresholdPercent; - if (_currentOffset.abs() > deleteThreshold && _hapticTriggered) { - HapticFeedbackHelper.heavyImpact(); - _hapticTriggered = false; // 반복 방지 - } + + // 햅틱 피드백 + _triggerHapticFeedback(); } - void _handleDragEnd(DragEndDetails details) async { - _isDragging = false; + void _handlePanEnd(DragEndDetails details) { + final duration = DateTime.now().difference(_startTime!); final velocity = details.velocity.pixelsPerSecond.dx; + + // 탭/스와이프 처리 분기 + + // 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리 + if (_isValidTap && + duration.inMilliseconds < _tapDurationMs && + _currentOffset.abs() < _tapTolerance) { + _processTap(); + return; + } + + // 스와이프 처리 + _processSwipe(velocity); + } + + // 헬퍼 메서드 + void _processTap() { + if (widget.onTap != null) { + widget.onTap!(); + } + _animateToOffset(0); + } + + void _processSwipe(double velocity) { final extent = _currentOffset.abs(); - - // 카드 너비의 40% 계산 final deleteThreshold = _cardWidth * _deleteThresholdPercent; - - if (extent > deleteThreshold || velocity.abs() > 800) { - // 40% 이상 스와이프 시 삭제/편집 액션 - if (_isSwipingLeft && widget.onDelete != null) { + + if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) { + // 삭제 실행 + if (widget.onDelete != null) { HapticFeedbackHelper.success(); - // 삭제 확인 다이얼로그 표시 - await widget.onDelete!(); - // 다이얼로그가 닫힌 후 원위치로 복귀 - if (mounted) { - _animateToOffset(0); - } - } else if (!_isSwipingLeft && widget.onEdit != null) { - HapticFeedbackHelper.success(); - // 편집 화면으로 이동 전 원위치로 복귀 - _animateToOffset(0); - Future.delayed(const Duration(milliseconds: 300), () { - widget.onEdit!(); + widget.onDelete!().then((_) { + if (mounted) { + _animateToOffset(0); + } }); } else { - // 액션이 없는 경우 원위치로 복귀 _animateToOffset(0); } } else { - // 40% 미만: 모두 원위치로 복귀 + // 원위치 복귀 _animateToOffset(0); } } + void _triggerHapticFeedback() { + final actionThreshold = _cardWidth * _actionThresholdPercent; + final deleteThreshold = _cardWidth * _deleteThresholdPercent; + final absOffset = _currentOffset.abs(); + + if (!_hapticTriggered && absOffset > actionThreshold) { + _hapticTriggered = true; + HapticFeedbackHelper.mediumImpact(); + } else if (_hapticTriggered && absOffset > deleteThreshold) { + HapticFeedbackHelper.heavyImpact(); + _hapticTriggered = false; + } + } + void _animateToOffset(double offset) { - // 애니메이션 컨트롤러 리셋 - _controller.stop(); - _controller.value = 0; - _animation = Tween( begin: _currentOffset, end: offset, @@ -179,94 +191,97 @@ class _SwipeableSubscriptionCardState extends State parent: _controller, curve: Curves.easeOutExpo, )); - - _controller.forward(); + + _controller.forward(from: 0); + } + + void _resetCard() { + _controller.stop(); + setState(() { + _currentOffset = 0; + }); + } + + // 빌드 메서드 + Widget _buildActionButtons() { + return Positioned.fill( + child: IgnorePointer( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(16), + color: Colors.transparent, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (!_isSwipingLeft) _buildDeleteIcon(true), + if (_isSwipingLeft) _buildDeleteIcon(false), + ], + ), + ), + ), + ); + } + + Widget _buildDeleteIcon(bool isLeft) { + final showIcon = _currentOffset.abs() > (_cardWidth * 0.10); + final isDeleteThreshold = + _currentOffset.abs() > (_cardWidth * _deleteThresholdPercent); + + return Padding( + padding: EdgeInsets.only( + left: isLeft ? 24 : 0, + right: isLeft ? 0 : 24, + ), + child: AnimatedOpacity( + duration: const Duration(milliseconds: 200), + opacity: showIcon ? 1.0 : 0.0, + child: AnimatedScale( + duration: const Duration(milliseconds: 200), + scale: showIcon ? 1.0 : 0.5, + child: Icon( + isDeleteThreshold + ? Icons.delete_forever_rounded + : Icons.delete_rounded, + color: Colors.white, + size: 28, + ), + ), + ), + ); + } + + Widget _buildCard() { + return Transform.translate( + offset: Offset(_currentOffset, 0), + child: Transform.scale( + scale: 1.0 - (_currentOffset.abs() / 2000), + child: Transform.rotate( + angle: _currentOffset / 2000, + child: SubscriptionCard( + subscription: widget.subscription, + onTap: widget.onTap, + ), + ), + ), + ); } @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: Colors.transparent, // 투명하게 변경 - ), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - // 편집 버튼 (오른쪽 스와이프) - if (!_isSwipingLeft) - Padding( - padding: const EdgeInsets.only(left: 24), - child: AnimatedOpacity( - duration: const Duration(milliseconds: 200), - opacity: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.0, - child: AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: _currentOffset > (_cardWidth * 0.10) ? 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: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.0, - child: AnimatedScale( - duration: const Duration(milliseconds: 200), - scale: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.5, - child: Icon( - _currentOffset.abs() > (_cardWidth * _deleteThresholdPercent) - ? Icons.delete_forever_rounded - : Icons.delete_rounded, - color: Colors.white, - size: 28, - ), - ), - ), - ), - ], - ), - ), - ), - - // 스와이프 가능한 카드 + _buildActionButtons(), GestureDetector( - onHorizontalDragStart: _handleDragStart, - onHorizontalDragUpdate: _handleDragUpdate, - onHorizontalDragEnd: _handleDragEnd, - child: Transform.translate( - offset: Offset(_currentOffset, 0), - child: Transform.scale( - scale: 1.0 - (_currentOffset.abs() / 2000), - child: Transform.rotate( - angle: _currentOffset / 2000, - child: GestureDetector( - onTap: () { - if (_currentOffset.abs() < 10) { - widget.onTap?.call(); - } - }, - child: SubscriptionCard( - subscription: widget.subscription, - ), - ), - ), - ), - ), + behavior: HitTestBehavior.opaque, + onPanStart: _handlePanStart, + onPanUpdate: _handlePanUpdate, + onPanEnd: _handlePanEnd, + child: _buildCard(), ), ], ); } -} \ No newline at end of file +}