feat: 폼 필드 컴포넌트 분리 및 구독 카드 인터랙션 개선

- billing_cycle_selector, category_selector, currency_selector 컴포넌트 분리
- 구독 카드 클릭 이슈 해결을 위한 리팩토링
- SMS 스캔 화면 UI/UX 개선 및 기능 강화
- 상세 화면 컨트롤러 로직 개선
- 알림 서비스 및 구독 URL 매칭 기능 추가
- CLAUDE.md 프로젝트 가이드라인 대폭 확장
- 전반적인 코드 구조 개선 및 타입 안정성 강화

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
JiWoong Sul
2025-07-14 15:47:46 +09:00
parent 2f60ef585a
commit 111c519883
39 changed files with 2376 additions and 1231 deletions

261
CLAUDE.md
View File

@@ -6,9 +6,262 @@
## 프로젝트 정보 ## 프로젝트 정보
- Flutter 기반 구독 관리 앱 (SubManager) - Flutter 기반 구독 관리 앱 (SubManager)
- 글래스모피즘 디자인 시스템 적용 중
- @doc/color.md의 색상 가이드를 전체 UI에 통일성 있게 적용하는 작업 진행 중
## 현재 작업 ## 현재 작업
- 전체 10개 화면과 50개 이상의 위젯에 통일된 글래스모피즘 스타일 적용 - 구독카드가 클릭이 되지 않아서 문제를 찾는 중.
- 색상 시스템 업데이트 및 일관성 있는 UI 구현
## 🎯 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 **ArrangeActAssert** 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 **GivenWhenThen** 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

228
doc/list.md Normal file
View File

@@ -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/) | 없음 | 없음 |
| **Btv** | [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|한국어 해지 안내 링크|영어 해지 안내 링크|
|---|---|---|---|---|
|**TMobile (미국)**|[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 AList**|[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)|
---

View File

@@ -11,7 +11,7 @@ import '../widgets/dialogs/delete_confirmation_dialog.dart';
import '../widgets/common/snackbar/app_snackbar.dart'; import '../widgets/common/snackbar/app_snackbar.dart';
/// DetailScreen의 비즈니스 로직을 관리하는 Controller /// DetailScreen의 비즈니스 로직을 관리하는 Controller
class DetailScreenController { class DetailScreenController extends ChangeNotifier {
final BuildContext context; final BuildContext context;
final SubscriptionModel subscription; final SubscriptionModel subscription;
@@ -22,16 +22,85 @@ class DetailScreenController {
late TextEditingController eventPriceController; late TextEditingController eventPriceController;
// Form State // Form State
late String billingCycle; final GlobalKey<FormState> formKey = GlobalKey<FormState>();
late DateTime nextBillingDate; late String _billingCycle;
String? selectedCategoryId; late DateTime _nextBillingDate;
late String currency; String? _selectedCategoryId;
bool isLoading = false; late String _currency;
bool _isLoading = false;
// Event State // Event State
late bool isEventActive; late bool _isEventActive;
DateTime? eventStartDate; DateTime? _eventStartDate;
DateTime? eventEndDate; 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 // Focus Nodes
final serviceNameFocus = FocusNode(); final serviceNameFocus = FocusNode();
@@ -70,15 +139,15 @@ class DetailScreenController {
eventPriceController = TextEditingController(); eventPriceController = TextEditingController();
// Form State 초기화 // Form State 초기화
billingCycle = subscription.billingCycle; _billingCycle = subscription.billingCycle;
nextBillingDate = subscription.nextBillingDate; _nextBillingDate = subscription.nextBillingDate;
selectedCategoryId = subscription.categoryId; _selectedCategoryId = subscription.categoryId;
currency = subscription.currency; _currency = subscription.currency;
// Event State 초기화 // Event State 초기화
isEventActive = subscription.isEventActive; _isEventActive = subscription.isEventActive;
eventStartDate = subscription.eventStartDate; _eventStartDate = subscription.eventStartDate;
eventEndDate = subscription.eventEndDate; _eventEndDate = subscription.eventEndDate;
// 이벤트 가격 초기화 // 이벤트 가격 초기화
if (subscription.eventPrice != null) { if (subscription.eventPrice != null) {
@@ -137,6 +206,7 @@ class DetailScreenController {
} }
/// 리소스 정리 /// 리소스 정리
@override
void dispose() { void dispose() {
// Controllers // Controllers
serviceNameController.dispose(); serviceNameController.dispose();
@@ -158,11 +228,13 @@ class DetailScreenController {
// Scroll // Scroll
scrollController.dispose(); scrollController.dispose();
super.dispose();
} }
/// 통화 단위에 따른 금액 표시 형식 업데이트 /// 통화 단위에 따른 금액 표시 형식 업데이트
void _updateMonthlyCostFormat() { void _updateMonthlyCostFormat() {
if (currency == 'KRW') { if (_currency == 'KRW') {
// 원화는 소수점 없이 표시 // 원화는 소수점 없이 표시
final intValue = subscription.monthlyCost.toInt(); final intValue = subscription.monthlyCost.toInt();
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue); monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
@@ -197,7 +269,7 @@ class DetailScreenController {
serviceName.contains('coupang play') || serviceName.contains('coupang play') ||
serviceName.contains('쿠팡플레이')) { serviceName.contains('쿠팡플레이')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '엔터테인먼트', (cat) => cat.name == 'OTT 서비스',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -209,7 +281,7 @@ class DetailScreenController {
serviceName.contains('플로') || serviceName.contains('플로') ||
serviceName.contains('벅스')) { serviceName.contains('벅스')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '음악', (cat) => cat.name == '음악 서비스',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -222,18 +294,18 @@ class DetailScreenController {
serviceName.contains('icloud') || serviceName.contains('icloud') ||
serviceName.contains('adobe')) { serviceName.contains('adobe')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '생산성', (cat) => cat.name == '오피스/협업 툴',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
// 게임 관련 키워드 // AI 관련 키워드
else if (serviceName.contains('xbox') || else if (serviceName.contains('chatgpt') ||
serviceName.contains('playstation') || serviceName.contains('claude') ||
serviceName.contains('nintendo') || serviceName.contains('gemini') ||
serviceName.contains('steam') || serviceName.contains('copilot') ||
serviceName.contains('게임')) { serviceName.contains('midjourney')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '게임', (cat) => cat.name == 'AI 서비스',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -244,7 +316,7 @@ class DetailScreenController {
serviceName.contains('패스트캠퍼스') || serviceName.contains('패스트캠퍼스') ||
serviceName.contains('클래스101')) { serviceName.contains('클래스101')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '교육', (cat) => cat.name == '프로그래밍/개발',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -255,7 +327,7 @@ class DetailScreenController {
serviceName.contains('네이버') || serviceName.contains('네이버') ||
serviceName.contains('11번가')) { serviceName.contains('11번가')) {
matchedCategory = categories.firstWhere( matchedCategory = categories.firstWhere(
(cat) => cat.name == '쇼핑', (cat) => cat.name == '기타 서비스',
orElse: () => categories.first, orElse: () => categories.first,
); );
} }
@@ -267,6 +339,15 @@ class DetailScreenController {
/// 구독 정보 업데이트 /// 구독 정보 업데이트
Future<void> updateSubscription() async { Future<void> updateSubscription() async {
// Form 검증
if (formKey.currentState != null && !formKey.currentState!.validate()) {
AppSnackBar.showError(
context: context,
message: '필수 항목을 모두 입력해주세요',
);
return;
}
final provider = Provider.of<SubscriptionProvider>(context, listen: false); final provider = Provider.of<SubscriptionProvider>(context, listen: false);
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도 // 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
@@ -289,18 +370,18 @@ class DetailScreenController {
subscription.serviceName = serviceNameController.text; subscription.serviceName = serviceNameController.text;
subscription.monthlyCost = monthlyCost; subscription.monthlyCost = monthlyCost;
subscription.websiteUrl = websiteUrl; subscription.websiteUrl = websiteUrl;
subscription.billingCycle = billingCycle; subscription.billingCycle = _billingCycle;
subscription.nextBillingDate = nextBillingDate; subscription.nextBillingDate = _nextBillingDate;
subscription.categoryId = selectedCategoryId; subscription.categoryId = _selectedCategoryId;
subscription.currency = currency; subscription.currency = _currency;
// 이벤트 정보 업데이트 // 이벤트 정보 업데이트
subscription.isEventActive = isEventActive; subscription.isEventActive = _isEventActive;
subscription.eventStartDate = eventStartDate; subscription.eventStartDate = _eventStartDate;
subscription.eventEndDate = eventEndDate; subscription.eventEndDate = _eventEndDate;
// 이벤트 가격 파싱 // 이벤트 가격 파싱
if (isEventActive && eventPriceController.text.isNotEmpty) { if (_isEventActive && eventPriceController.text.isNotEmpty) {
try { try {
subscription.eventPrice = subscription.eventPrice =
double.parse(eventPriceController.text.replaceAll(',', '')); double.parse(eventPriceController.text.replaceAll(',', ''));
@@ -345,7 +426,7 @@ class DetailScreenController {
await provider.deleteSubscription(subscription.id); await provider.deleteSubscription(subscription.id);
if (context.mounted) { if (context.mounted) {
AppSnackBar.showSuccess( AppSnackBar.showError(
context: context, context: context,
message: '구독이 삭제되었습니다.', message: '구독이 삭제되었습니다.',
icon: Icons.delete_forever_rounded, icon: Icons.delete_forever_rounded,

View File

@@ -7,7 +7,36 @@ class CategoryProvider extends ChangeNotifier {
List<CategoryModel> _categories = []; List<CategoryModel> _categories = [];
late Box<CategoryModel> _categoryBox; late Box<CategoryModel> _categoryBox;
List<CategoryModel> get categories => _categories; // 카테고리 표시 순서 정의
static const List<String> _categoryOrder = [
'음악',
'OTT(동영상)',
'저장/클라우드',
'통신 · 인터넷 · TV',
'생활/라이프스타일',
'쇼핑/이커머스',
'프로그래밍',
'협업/오피스',
'AI 서비스',
'기타',
];
List<CategoryModel> get categories {
// 정의된 순서로 카테고리 정렬
final sortedCategories = List<CategoryModel>.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<void> init() async { Future<void> init() async {
_categoryBox = await Hive.openBox<CategoryModel>('categories'); _categoryBox = await Hive.openBox<CategoryModel>('categories');
@@ -24,12 +53,16 @@ class CategoryProvider extends ChangeNotifier {
// 기본 카테고리 초기화 // 기본 카테고리 초기화
Future<void> _initDefaultCategories() async { Future<void> _initDefaultCategories() async {
final defaultCategories = [ final defaultCategories = [
{'name': 'OTT 서비스', 'color': '#3B82F6', 'icon': 'live_tv'}, {'name': '음악', 'color': '#E91E63', 'icon': 'music_note'},
{'name': '음악 서비스', 'color': '#EC4899', 'icon': 'music_note'}, {'name': 'OTT(동영상)', 'color': '#9C27B0', 'icon': 'movie_filter'},
{'name': 'AI 서비스', 'color': '#8B5CF6', 'icon': 'psychology'}, {'name': '저장/클라우드', 'color': '#2196F3', 'icon': 'cloud'},
{'name': '프로그래밍/개발', 'color': '#10B981', 'icon': 'code'}, {'name': '통신 · 인터넷 · TV', 'color': '#00BCD4', 'icon': 'wifi'},
{'name': '오피스/협업 툴', 'color': '#F59E0B', 'icon': 'business_center'}, {'name': '생활/라이프스타일', 'color': '#4CAF50', 'icon': 'home'},
{'name': '기타 서비', 'color': '#6B7280', 'icon': 'more_horiz'}, {'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) { for (final category in defaultCategories) {

View File

@@ -14,7 +14,7 @@ class NotificationProvider extends ChangeNotifier {
static const String _dailyReminderKey = 'daily_reminder_enabled'; static const String _dailyReminderKey = 'daily_reminder_enabled';
bool _isEnabled = false; bool _isEnabled = false;
bool _isPaymentEnabled = false; bool _isPaymentEnabled = true;
bool _isUnusedServiceNotificationEnabled = false; bool _isUnusedServiceNotificationEnabled = false;
int _reminderDays = 3; // 기본값: 3일 전 int _reminderDays = 3; // 기본값: 3일 전
int _reminderHour = 10; // 기본값: 오전 10시 int _reminderHour = 10; // 기본값: 오전 10시

View File

@@ -4,6 +4,7 @@ import 'package:hive/hive.dart';
import 'package:uuid/uuid.dart'; import 'package:uuid/uuid.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../services/notification_service.dart'; import '../services/notification_service.dart';
import 'category_provider.dart';
class SubscriptionProvider extends ChangeNotifier { class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox; late Box<SubscriptionModel> _subscriptionBox;
@@ -46,6 +47,9 @@ class SubscriptionProvider extends ChangeNotifier {
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions'); _subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
await refreshSubscriptions(); await refreshSubscriptions();
// categoryId 마이그레이션
await _migrateCategoryIds();
// 앱 시작 시 이벤트 상태 확인 // 앱 시작 시 이벤트 상태 확인
await checkAndUpdateEventStatus(); await checkAndUpdateEventStatus();
@@ -290,4 +294,105 @@ class SubscriptionProvider extends ChangeNotifier {
]; ];
return months[month.month - 1]; return months[month.month - 1];
} }
/// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당
Future<void> _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를 가지고 있습니다');
}
}
} }

View File

@@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../providers/subscription_provider.dart'; import '../providers/subscription_provider.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../widgets/native_ad_widget.dart'; import '../widgets/native_ad_widget.dart';
import '../widgets/analysis/analysis_screen_spacer.dart'; import '../widgets/analysis/analysis_screen_spacer.dart';
import '../widgets/analysis/subscription_pie_chart_card.dart'; import '../widgets/analysis/subscription_pie_chart_card.dart';
@@ -100,10 +99,10 @@ class _AnalysisScreenState extends State<AnalysisScreen>
controller: _scrollController, controller: _scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: <Widget>[ slivers: <Widget>[
const GlassmorphicSliverAppBar( SliverToBoxAdapter(
title: '분석', child: SizedBox(
pinned: true, height: kToolbarHeight + MediaQuery.of(context).padding.top,
expandedHeight: kToolbarHeight, ),
), ),
// 네이티브 광고 위젯 // 네이티브 광고 위젯
@@ -148,7 +147,12 @@ class _AnalysisScreenState extends State<AnalysisScreen>
animationController: _animationController, animationController: _animationController,
), ),
const AnalysisScreenSpacer(height: 32), // FloatingNavigationBar를 위한 충분한 하단 여백
SliverToBoxAdapter(
child: SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
),
], ],
); );
}, },

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../controllers/detail_screen_controller.dart'; import '../controllers/detail_screen_controller.dart';
import '../widgets/detail/detail_header_section.dart'; import '../widgets/detail/detail_header_section.dart';
@@ -46,9 +47,11 @@ class _DetailScreenState extends State<DetailScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
final baseColor = _controller.getCardColor(); final baseColor = _controller.getCardColor();
return Scaffold( return ChangeNotifierProvider<DetailScreenController>.value(
backgroundColor: AppColors.backgroundColor, value: _controller,
body: CustomScrollView( child: Scaffold(
backgroundColor: AppColors.backgroundColor,
body: CustomScrollView(
controller: _controller.scrollController, controller: _controller.scrollController,
slivers: [ slivers: [
// 상단 헤더 섹션 // 상단 헤더 섹션
@@ -74,8 +77,19 @@ class _DetailScreenState extends State<DetailScreen>
vertical: 12, vertical: 12,
), ),
decoration: BoxDecoration( 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), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: baseColor.withValues(alpha: 0.2),
width: 1,
),
), ),
child: Row( child: Row(
children: [ children: [
@@ -98,7 +112,7 @@ class _DetailScreenState extends State<DetailScreen>
'변경사항은 저장 후 적용됩니다', '변경사항은 저장 후 적용됩니다',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: baseColor.withValues(alpha: 0.8), color: AppColors.darkNavy,
), ),
), ),
], ],
@@ -141,6 +155,7 @@ class _DetailScreenState extends State<DetailScreen>
), ),
), ),
], ],
),
), ),
); );
} }

View File

@@ -183,10 +183,10 @@ class _MainScreenState extends State<MainScreen>
backgroundColor: AppColors.successColor, backgroundColor: AppColors.successColor,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: EdgeInsets.only( margin: EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, // 상단 여백 top: MediaQuery.of(context).padding.top + 8, // 더 상단으로
left: 16, left: 16,
right: 16, right: 16,
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정 bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),

View File

@@ -9,6 +9,7 @@ import '../providers/theme_provider.dart';
import '../theme/adaptive_theme.dart'; import '../theme/adaptive_theme.dart';
import '../widgets/glassmorphism_card.dart'; import '../widgets/glassmorphism_card.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart';
class SettingsScreen extends StatelessWidget { class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key}); const SettingsScreen({super.key});
@@ -70,81 +71,18 @@ class SettingsScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListView( return Column(
padding: const EdgeInsets.only(top: 20), children: [
children: [ SizedBox(
// 테마 설정 height: kToolbarHeight + MediaQuery.of(context).padding.top,
GlassmorphismCard( ),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), Expanded(
padding: const EdgeInsets.all(8), child: ListView(
child: Consumer<ThemeProvider>( padding: const EdgeInsets.only(top: 20),
builder: (context, themeProvider, child) { children: [
return Column( // 광고 위젯 추가
crossAxisAlignment: CrossAxisAlignment.start, const NativeAdWidget(key: ValueKey('settings_ad')),
children: [ const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.all(16),
child: Text(
'테마 설정',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
// 테마 모드 선택
ListTile(
title: const Text('테마 모드'),
subtitle: Text(_getThemeModeText(themeProvider.themeMode)),
leading: Icon(
_getThemeModeIcon(themeProvider.themeMode),
color: Theme.of(context).colorScheme.primary,
),
trailing: DropdownButton<AppThemeMode>(
value: themeProvider.themeMode,
underline: Container(),
onChanged: (mode) {
if (mode != null) {
themeProvider.setThemeMode(mode);
}
},
items: AppThemeMode.values.map((mode) =>
DropdownMenuItem(
value: mode,
child: Text(_getThemeModeText(mode)),
),
).toList(),
),
),
const Divider(height: 1),
// 접근성 설정
SwitchListTile(
title: const Text('큰 텍스트'),
subtitle: const Text('텍스트 크기를 크게 표시합니다'),
secondary: const Icon(Icons.text_fields),
value: themeProvider.largeText,
onChanged: themeProvider.setLargeText,
),
SwitchListTile(
title: const Text('모션 감소'),
subtitle: const Text('애니메이션 효과를 줄입니다'),
secondary: const Icon(Icons.slow_motion_video),
value: themeProvider.reduceMotion,
onChanged: themeProvider.setReduceMotion,
),
SwitchListTile(
title: const Text('고대비 모드'),
subtitle: const Text('더 선명한 색상으로 표시합니다'),
secondary: const Icon(Icons.contrast),
value: themeProvider.highContrast,
onChanged: themeProvider.setHighContrast,
),
],
);
},
),
),
// 앱 잠금 설정 UI 숨김 // 앱 잠금 설정 UI 숨김
// Card( // Card(
// margin: const EdgeInsets.all(16), // margin: const EdgeInsets.all(16),
@@ -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( GlassmorphismCard(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -485,11 +405,15 @@ class SettingsScreen extends StatelessWidget {
}, },
), ),
), ),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox( SizedBox(
height: 20 + MediaQuery.of(context).padding.bottom, // 하단 여백 height: 120 + MediaQuery.of(context).padding.bottom,
), ),
], ],
); ),
),
],
);
} }
String _getThemeModeText(AppThemeMode mode) { String _getThemeModeText(AppThemeMode mode) {

View File

@@ -14,6 +14,10 @@ import '../widgets/common/snackbar/app_snackbar.dart';
import '../widgets/common/buttons/primary_button.dart'; import '../widgets/common/buttons/primary_button.dart';
import '../widgets/common/buttons/secondary_button.dart'; import '../widgets/common/buttons/secondary_button.dart';
import '../widgets/common/form_fields/base_text_field.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 { class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key}); const SmsScanScreen({super.key});
@@ -36,6 +40,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 웹사이트 URL 컨트롤러 // 웹사이트 URL 컨트롤러
final TextEditingController _websiteUrlController = TextEditingController(); final TextEditingController _websiteUrlController = TextEditingController();
// 선택된 카테고리 ID 저장
String? _selectedCategoryId;
@override @override
void dispose() { void dispose() {
_websiteUrlController.dispose(); _websiteUrlController.dispose();
@@ -113,6 +120,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}'); '첫 번째 필터링된 구독: ${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(() { setState(() {
_scannedSubscriptions = filteredSubscriptions; _scannedSubscriptions = filteredSubscriptions;
_isLoading = false; _isLoading = false;
@@ -349,7 +375,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
isAutoDetected: true, isAutoDetected: true,
repeatCount: safeRepeatCount, repeatCount: safeRepeatCount,
lastPaymentDate: subscription.lastPaymentDate, lastPaymentDate: subscription.lastPaymentDate,
categoryId: subscription.category, categoryId: _selectedCategoryId ?? subscription.category,
currency: subscription.currency, // 통화 단위 정보 추가 currency: subscription.currency, // 통화 단위 정보 추가
); );
@@ -399,6 +425,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
setState(() { setState(() {
_currentIndex++; _currentIndex++;
_websiteUrlController.text = ''; // URL 입력 필드 초기화 _websiteUrlController.text = ''; // URL 입력 필드 초기화
_selectedCategoryId = null; // 카테고리 선택 초기화
// 모든 구독을 처리했으면 홈 화면으로 이동 // 모든 구독을 처리했으면 홈 화면으로 이동
if (_currentIndex >= _scannedSubscriptions.length) { if (_currentIndex >= _scannedSubscriptions.length) {
@@ -493,15 +520,90 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
return '$count회 결제 감지됨'; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding( return SingleChildScrollView(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: _isLoading child: Column(
? _buildLoadingState() children: [
: (_scannedSubscriptions.isEmpty _isLoading
? _buildInitialState() ? _buildLoadingState()
: _buildSubscriptionState()), : (_scannedSubscriptions.isEmpty
? _buildInitialState()
: _buildSubscriptionState()),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
),
); );
} }
@@ -525,10 +627,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
// 초기 상태 UI // 초기 상태 UI
Widget _buildInitialState() { Widget _buildInitialState() {
return Center( return Column(
child: Column( children: [
mainAxisAlignment: MainAxisAlignment.center, // 광고 위젯 추가
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) if (_errorMessage != null)
Padding( Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@@ -564,8 +672,10 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
height: 56, height: 56,
backgroundColor: AppColors.primaryColor, backgroundColor: AppColors.primaryColor,
), ),
], ],
), ),
),
],
); );
} }
@@ -579,6 +689,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
} }
final subscription = _scannedSubscriptions[_currentIndex]; final subscription = _scannedSubscriptions[_currentIndex];
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정 // 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) { if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
@@ -588,6 +699,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
// 진행 상태 표시 // 진행 상태 표시
LinearProgressIndicator( LinearProgressIndicator(
value: (_currentIndex + 1) / _scannedSubscriptions.length, value: (_currentIndex + 1) / _scannedSubscriptions.length,
@@ -634,7 +748,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
// 금액 및 반복 횟수 // 금액 및 결제 주기
Row( Row(
children: [ children: [
Expanded( Expanded(
@@ -667,33 +781,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
], ],
), ),
), ),
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( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -714,28 +801,51 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
], ],
), ),
), ),
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), const SizedBox(height: 24),
// 웹사이트 URL 입력 필드 추가/수정 // 웹사이트 URL 입력 필드 추가/수정

View File

@@ -265,7 +265,8 @@ class _SplashScreenState extends State<SplashScreen>
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: AppColors.shadowBlack, color:
AppColors.shadowBlack,
spreadRadius: 0, spreadRadius: 0,
blurRadius: 30, blurRadius: 30,
offset: const Offset(0, 10), offset: const Offset(0, 10),
@@ -280,9 +281,8 @@ class _SplashScreenState extends State<SplashScreen>
return ShaderMask( return ShaderMask(
blendMode: blendMode:
BlendMode.srcIn, BlendMode.srcIn,
shaderCallback: shaderCallback: (bounds) =>
(bounds) => const LinearGradient(
const LinearGradient(
colors: AppColors colors: AppColors
.blueGradient, .blueGradient,
begin: begin:
@@ -323,11 +323,12 @@ class _SplashScreenState extends State<SplashScreen>
); );
}, },
child: Text( child: Text(
'SubManager', 'Digital Rent Manager',
style: TextStyle( style: TextStyle(
fontSize: 36, fontSize: 36,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
color: AppColors.pureWhite, color: AppColors.primaryColor
.withValues(alpha: 0.9),
letterSpacing: 1.2, letterSpacing: 1.2,
), ),
), ),
@@ -352,7 +353,8 @@ class _SplashScreenState extends State<SplashScreen>
'구독 서비스 관리를 더 쉽게', '구독 서비스 관리를 더 쉽게',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: AppColors.pureWhite.withValues(alpha: 0.7), color: AppColors.primaryColor
.withValues(alpha: 0.7),
letterSpacing: 0.5, letterSpacing: 0.5,
), ),
), ),
@@ -373,11 +375,12 @@ class _SplashScreenState extends State<SplashScreen>
height: 60, height: 60,
padding: const EdgeInsets.all(6), padding: const EdgeInsets.all(6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.pureWhite.withValues(alpha: 0.1), color: AppColors.pureWhite
.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(50),
border: Border.all( border: Border.all(
color: color: AppColors.pureWhite
AppColors.pureWhite.withValues(alpha: 0.2), .withValues(alpha: 0.2),
width: 1, width: 1,
), ),
), ),
@@ -401,7 +404,7 @@ class _SplashScreenState extends State<SplashScreen>
child: FadeTransition( child: FadeTransition(
opacity: _fadeAnimation, opacity: _fadeAnimation,
child: Text( child: Text(
'© 2023 CClabs. All rights reserved.', '© 2025 NatureBridgeAI. All rights reserved.',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.pureWhite.withValues(alpha: 0.6), color: AppColors.pureWhite.withValues(alpha: 0.6),

View File

@@ -137,10 +137,7 @@ class NotificationService {
// 각 구독에 대해 알림 재설정 // 각 구독에 대해 알림 재설정
for (final subscription in subscriptions) { for (final subscription in subscriptions) {
await schedulePaymentReminder( await schedulePaymentReminder(
id: subscription.id.hashCode, subscription: subscription,
serviceName: subscription.serviceName,
amount: subscription.monthlyCost,
billingDate: subscription.nextBillingDate,
reminderDays: reminderDays, reminderDays: reminderDays,
reminderHour: reminderHour, reminderHour: reminderHour,
reminderMinute: reminderMinute, reminderMinute: reminderMinute,
@@ -421,10 +418,7 @@ class NotificationService {
} }
static Future<void> schedulePaymentReminder({ static Future<void> schedulePaymentReminder({
required int id, required SubscriptionModel subscription,
required String serviceName,
required double amount,
required DateTime billingDate,
int reminderDays = 3, int reminderDays = 3,
int reminderHour = 10, int reminderHour = 10,
int reminderMinute = 0, int reminderMinute = 0,
@@ -457,7 +451,7 @@ class NotificationService {
// 기본 알림 예약 (지정된 일수 전) // 기본 알림 예약 (지정된 일수 전)
final scheduledDate = final scheduledDate =
billingDate.subtract(Duration(days: reminderDays)).copyWith( subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith(
hour: reminderHour, hour: reminderHour,
minute: reminderMinute, minute: reminderMinute,
second: 0, second: 0,
@@ -471,10 +465,27 @@ class NotificationService {
daysText = '내일'; 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( await _notifications.zonedSchedule(
id, subscription.id.hashCode,
'구독 결제 예정 알림', '구독 결제 예정 알림',
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.', notificationBody,
tz.TZDateTime.from(scheduledDate, location), tz.TZDateTime.from(scheduledDate, location),
const NotificationDetails( const NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(
@@ -495,7 +506,7 @@ class NotificationService {
if (isDailyReminder && reminderDays >= 2) { if (isDailyReminder && reminderDays >= 2) {
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약 // 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
for (int i = reminderDays - 1; i >= 1; i--) { 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, hour: reminderHour,
minute: reminderMinute, minute: reminderMinute,
second: 0, second: 0,
@@ -509,10 +520,25 @@ class NotificationService {
remainingDaysText = '내일'; 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( await _notifications.zonedSchedule(
id + i, // 고유한 ID 생성을 위해 날짜 차이 더함 subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
'구독 결제 예정 알림', '구독 결제 예정 알림',
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.', dailyNotificationBody,
tz.TZDateTime.from(dailyDate, location), tz.TZDateTime.from(dailyDate, location),
const NotificationDetails( const NotificationDetails(
android: AndroidNotificationDetails( android: AndroidNotificationDetails(

View File

@@ -49,6 +49,89 @@ class SubscriptionUrlMatcher {
'타이달': 'https://www.tidal.com', '타이달': 'https://www.tidal.com',
}; };
// 저장 (클라우드/파일) 서비스
static final Map<String, String> 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<String, String> 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<String, String> 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<String, String> 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 서비스 // AI 서비스
static final Map<String, String> aiServices = { static final Map<String, String> aiServices = {
'chatgpt': 'https://chat.openai.com', 'chatgpt': 'https://chat.openai.com',

View File

@@ -33,23 +33,71 @@ class SubscriptionCategoryHelper {
} }
// 카테고리 ID가 없거나 카테고리를 찾을 수 없는 경우 서비스 이름 기반 분류 // 카테고리 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)) { subscription.serviceName, SubscriptionUrlMatcher.musicServices)) {
if (!categorizedSubscriptions.containsKey('음악 서비스')) { if (!categorizedSubscriptions.containsKey('음악')) {
categorizedSubscriptions['음악 서비스'] = []; 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( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.aiServices)) { subscription.serviceName, SubscriptionUrlMatcher.aiServices)) {
if (!categorizedSubscriptions.containsKey('AI 서비스')) { if (!categorizedSubscriptions.containsKey('AI 서비스')) {
@@ -57,29 +105,13 @@ class SubscriptionCategoryHelper {
} }
categorizedSubscriptions['AI 서비스']!.add(subscription); 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( else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.otherServices)) { subscription.serviceName, SubscriptionUrlMatcher.otherServices)) {
if (!categorizedSubscriptions.containsKey('기타 서비스')) { if (!categorizedSubscriptions.containsKey('기타')) {
categorizedSubscriptions['기타 서비스'] = []; categorizedSubscriptions['기타'] = [];
} }
categorizedSubscriptions['기타 서비스']!.add(subscription); categorizedSubscriptions['기타']!.add(subscription);
} }
// 미분류된 서비스 // 미분류된 서비스
else { else {

View File

@@ -40,6 +40,14 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
), ),
child: SafeArea( child: SafeArea(
child: AppBar( child: AppBar(
leading: IconButton(
icon: const Icon(
Icons.chevron_left,
size: 28,
color: Color(0xFF1E293B),
),
onPressed: () => Navigator.of(context).pop(),
),
title: Text( title: Text(
'구독 추가', '구독 추가',
style: TextStyle( style: TextStyle(

View File

@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart'; import '../../controllers/add_subscription_controller.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
import '../../theme/app_colors.dart';
/// 구독 추가 화면의 이벤트/할인 섹션 /// 구독 추가 화면의 이벤트/할인 섹션
class AddSubscriptionEventSection extends StatelessWidget { class AddSubscriptionEventSection extends StatelessWidget {
@@ -37,56 +38,67 @@ class AddSubscriptionEventSection extends StatelessWidget {
)), )),
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 20), margin: const EdgeInsets.only(bottom: 20),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.glassCard,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: controller.isEventActive color: AppColors.glassBorder.withValues(alpha: 0.1),
? const Color(0xFF3B82F6) width: 1,
: Colors.grey.withValues(alpha: 0.2),
width: controller.isEventActive ? 2 : 1,
), ),
boxShadow: [
BoxShadow(
color: AppColors.shadowBlack,
blurRadius: 10,
offset: const Offset(0, 4),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Row( Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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, value: controller.isEventActive,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
controller.isEventActive = value ?? false; controller.isEventActive = value;
if (!controller.isEventActive) { if (!controller.isEventActive) {
// 이벤트 비활성화 시 관련 데이터 초기화 // 이벤트 비활성화 시 관련 데이터 초기화
controller.eventStartDate = DateTime.now(); controller.eventStartDate = null;
controller.eventEndDate = DateTime.now().add(const Duration(days: 30)); controller.eventEndDate = null;
controller.eventPriceController.clear(); controller.eventPriceController.clear();
} else {
// 이벤트 활성화 시 날짜가 null이면 기본값 설정
controller.eventStartDate ??= DateTime.now();
controller.eventEndDate ??= DateTime.now().add(const Duration(days: 30));
} }
}); });
}, },
activeColor: const Color(0xFF3B82F6), activeColor: controller.gradientColors[0],
),
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,
), ),
], ],
), ),
@@ -101,6 +113,39 @@ class AddSubscriptionEventSection extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 20), 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( DateRangePickerField(
@@ -109,6 +154,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
onStartDateSelected: (date) { onStartDateSelected: (date) {
setState(() { setState(() {
controller.eventStartDate = date; controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
}
}); });
}, },
onEndDateSelected: (date) { onEndDateSelected: (date) {
@@ -118,7 +167,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
}, },
startLabel: '시작일', startLabel: '시작일',
endLabel: '종료일', endLabel: '종료일',
primaryColor: const Color(0xFF3B82F6), primaryColor: controller.gradientColors[0],
), ),
const SizedBox(height: 20), const SizedBox(height: 20),

View File

@@ -6,6 +6,11 @@ import '../../providers/category_provider.dart';
import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_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 { class AddSubscriptionForm extends StatelessWidget {
@@ -39,12 +44,8 @@ class AddSubscriptionForm extends StatelessWidget {
parent: controller.animationController!, parent: controller.animationController!,
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic), curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
)), )),
child: Card( child: GlassmorphismCard(
elevation: 1, backgroundColor: AppColors.glassCard,
shadowColor: Colors.black12,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20),
),
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
@@ -134,8 +135,9 @@ class AddSubscriptionForm extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_CurrencySelector( CurrencySelector(
currency: controller.currency, currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
controller.currency = value; controller.currency = value;
@@ -161,9 +163,10 @@ class AddSubscriptionForm extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_BillingCycleSelector( BillingCycleSelector(
billingCycle: controller.billingCycle, billingCycle: controller.billingCycle,
gradientColors: controller.gradientColors, baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
controller.billingCycle = value; controller.billingCycle = value;
@@ -217,10 +220,11 @@ class AddSubscriptionForm extends StatelessWidget {
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_CategorySelector( CategorySelector(
categories: categoryProvider.categories, categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId, selectedCategoryId: controller.selectedCategoryId,
gradientColors: controller.gradientColors, baseColor: controller.gradientColors[0],
isGlassmorphism: true,
onChanged: (categoryId) { onChanged: (categoryId) {
setState(() { setState(() {
controller.selectedCategoryId = categoryId; controller.selectedCategoryId = categoryId;
@@ -240,192 +244,3 @@ class AddSubscriptionForm extends StatelessWidget {
} }
} }
/// 통화 선택기
class _CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> 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<Color> gradientColors;
final ValueChanged<String> 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<dynamic> categories;
final String? selectedCategoryId;
final List<Color> gradientColors;
final ValueChanged<String?> 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(),
);
}
}

View File

@@ -52,7 +52,7 @@ class AnalysisBadge extends StatelessWidget {
color: AppColors.darkNavy, color: AppColors.darkNavy,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 0),
FutureBuilder<String>( FutureBuilder<String>(
future: CurrencyUtil.formatAmount( future: CurrencyUtil.formatAmount(
subscription.monthlyCost, subscription.monthlyCost,

View File

@@ -42,12 +42,18 @@ class AppNavigator {
/// 구독 상세 화면으로 네비게이션 /// 구독 상세 화면으로 네비게이션
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async { static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
HapticFeedback.lightImpact(); HapticFeedback.lightImpact();
await Navigator.of(context).pushNamed( try {
AppRoutes.subscriptionDetail, await Navigator.of(context).pushNamed(
arguments: subscription, AppRoutes.subscriptionDetail,
); arguments: subscription,
);
print('DetailScreen 네비게이션 성공');
} catch (e) {
print('DetailScreen 네비게이션 오류: $e');
}
} }
/// SMS 스캔 화면으로 네비게이션 /// SMS 스캔 화면으로 네비게이션

View File

@@ -4,17 +4,19 @@ import 'package:intl/intl.dart';
/// 카테고리별 구독 그룹의 헤더 위젯 /// 카테고리별 구독 그룹의 헤더 위젯
/// ///
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다. /// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
/// 참고: 여러 통화 단위가된 경우 간단히 원화 표시 형식을 사용합니다. /// 통화별로 구분하여 표시하며,된 경우 각각 표시합니다.
class CategoryHeaderWidget extends StatelessWidget { class CategoryHeaderWidget extends StatelessWidget {
final String categoryName; final String categoryName;
final int subscriptionCount; final int subscriptionCount;
final double totalCost; final double totalCostUSD;
final double totalCostKRW;
const CategoryHeaderWidget({ const CategoryHeaderWidget({
Key? key, Key? key,
required this.categoryName, required this.categoryName,
required this.subscriptionCount, required this.subscriptionCount,
required this.totalCost, required this.totalCostUSD,
required this.totalCostKRW,
}) : super(key: key); }) : super(key: key);
@override @override
@@ -36,7 +38,7 @@ class CategoryHeaderWidget extends StatelessWidget {
), ),
), ),
Text( Text(
'${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '', decimalDigits: 0).format(totalCost)}', _buildCostDisplay(),
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -55,4 +57,44 @@ class CategoryHeaderWidget extends StatelessWidget {
), ),
); );
} }
/// 통화별 합계를 표시하는 문자열을 생성합니다.
String _buildCostDisplay() {
final parts = <String>[];
// 개수는 항상 표시
parts.add('$subscriptionCount개');
// 통화 부분을 별도로 처리
final currencyParts = <String>[];
// 달러가 있는 경우
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(' · ');
}
} }

View File

@@ -103,7 +103,7 @@ class BaseTextField extends StatelessWidget {
prefixText: prefixText, prefixText: prefixText,
suffixIcon: suffixIcon, suffixIcon: suffixIcon,
filled: true, filled: true,
fillColor: fillColor ?? AppColors.glassBackground, fillColor: fillColor ?? AppColors.surfaceColorAlt,
contentPadding: contentPadding ?? const EdgeInsets.all(16), contentPadding: contentPadding ?? const EdgeInsets.all(16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@@ -119,8 +119,8 @@ class BaseTextField extends StatelessWidget {
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
borderSide: BorderSide( borderSide: BorderSide(
color: AppColors.textSecondary, color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1, width: 1.5,
), ),
), ),
disabledBorder: OutlineInputBorder( disabledBorder: OutlineInputBorder(

View File

@@ -0,0 +1,101 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 결제 주기 선택 위젯
/// 월간, 분기별, 반기별, 연간 중 선택할 수 있습니다.
class BillingCycleSelector extends StatelessWidget {
final String billingCycle;
final ValueChanged<String> onChanged;
final Color? baseColor;
final List<Color>? 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]!;
}
}

View File

@@ -0,0 +1,132 @@
import 'package:flutter/material.dart';
import '../../../theme/app_colors.dart';
/// 카테고리 선택 위젯
/// 구독 서비스의 카테고리를 선택할 수 있습니다.
class CategorySelector extends StatelessWidget {
final List<dynamic> categories;
final String? selectedCategoryId;
final ValueChanged<String?> onChanged;
final Color? baseColor;
final List<Color>? 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]!;
}
}

View File

@@ -36,33 +36,62 @@ class CurrencyInputField extends StatefulWidget {
} }
class _CurrencyInputFieldState extends State<CurrencyInputField> { class _CurrencyInputFieldState extends State<CurrencyInputField> {
late TextEditingController _formattedController; late FocusNode _focusNode;
bool _isFormatted = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_formattedController = TextEditingController(); _focusNode = widget.focusNode ?? FocusNode();
_updateFormattedValue(); _focusNode.addListener(_onFocusChanged);
widget.controller.addListener(_onControllerChanged);
// 초기값이 있으면 포맷팅 적용
if (widget.controller.text.isNotEmpty) {
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
if (value != null) {
widget.controller.text = _formatCurrency(value);
_isFormatted = true;
}
}
} }
@override @override
void dispose() { void dispose() {
widget.controller.removeListener(_onControllerChanged); if (widget.focusNode == null) {
_formattedController.dispose(); _focusNode.dispose();
} else {
_focusNode.removeListener(_onFocusChanged);
}
super.dispose(); super.dispose();
} }
void _onControllerChanged() { void _onFocusChanged() {
_updateFormattedValue(); if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
} // 포커스를 잃었을 때 포맷팅 적용
final value = _parseValue(widget.controller.text);
void _updateFormattedValue() { if (value != null) {
final value = double.tryParse(widget.controller.text.replaceAll(',', '')); setState(() {
if (value != null) { widget.controller.text = _formatCurrency(value);
_formattedController.text = _formatCurrency(value); _isFormatted = true;
} else { });
_formattedController.text = ''; }
} 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<CurrencyInputField> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return BaseTextField( return BaseTextField(
controller: _formattedController, controller: widget.controller,
focusNode: widget.focusNode, focusNode: _focusNode,
label: widget.label, label: widget.label,
hintText: widget.hintText ?? _defaultHintText, hintText: widget.hintText ?? _defaultHintText,
textInputAction: widget.textInputAction, textInputAction: widget.textInputAction,
keyboardType: const TextInputType.numberWithOptions(decimal: true), keyboardType: const TextInputType.numberWithOptions(decimal: true),
inputFormatters: [ 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, prefixText: _prefixText,
onEditingComplete: widget.onEditingComplete, onEditingComplete: widget.onEditingComplete,
enabled: widget.enabled, enabled: widget.enabled,
onChanged: (value) { onChanged: (value) {
final parsedValue = _parseValue(value); final parsedValue = _parseValue(value);
if (parsedValue != null) { widget.onChanged?.call(parsedValue);
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),
);
}
}
}, },
validator: widget.validator ?? (value) { validator: widget.validator ?? (value) {
if (value == null || value.isEmpty) { if (value == null || value.isEmpty) {

View File

@@ -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<String> 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]!;
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../../theme/app_colors.dart';
/// 날짜 선택 필드 위젯 /// 날짜 선택 필드 위젯
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다. /// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
@@ -47,7 +48,7 @@ class DatePickerField extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: theme.colorScheme.onSurface, color: AppColors.darkNavy,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -82,10 +83,11 @@ class DatePickerField extends StatelessWidget {
child: Container( child: Container(
padding: contentPadding ?? const EdgeInsets.all(16), padding: contentPadding ?? const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor ?? Colors.white, color: backgroundColor ?? AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: Colors.transparent, color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
), ),
), ),
child: Row( child: Row(
@@ -96,8 +98,8 @@ class DatePickerField extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
color: enabled color: enabled
? theme.colorScheme.onSurface ? AppColors.textPrimary
: theme.colorScheme.onSurface.withValues(alpha: 0.5), : AppColors.textMuted,
), ),
), ),
), ),
@@ -105,8 +107,8 @@ class DatePickerField extends StatelessWidget {
Icons.calendar_today, Icons.calendar_today,
size: 20, size: 20,
color: enabled color: enabled
? theme.colorScheme.onSurface.withValues(alpha: 0.6) ? AppColors.navyGray
: theme.colorScheme.onSurface.withValues(alpha: 0.3), : AppColors.textMuted,
), ),
], ],
), ),
@@ -227,8 +229,12 @@ class _DateRangeItem extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.surfaceColorAlt,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all(
color: AppColors.borderColor.withValues(alpha: 0.7),
width: 1.5,
),
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -237,7 +243,7 @@ class _DateRangeItem extends StatelessWidget {
label, label,
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: theme.colorScheme.onSurface.withValues(alpha: 0.6), color: AppColors.textSecondary,
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@@ -249,8 +255,8 @@ class _DateRangeItem extends StatelessWidget {
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: date != null color: date != null
? theme.colorScheme.onSurface ? AppColors.textPrimary
: theme.colorScheme.onSurface.withValues(alpha: 0.4), : AppColors.textMuted,
), ),
), ),
], ],

View File

@@ -161,10 +161,10 @@ class AppSnackBar {
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: showAtTop margin: showAtTop
? EdgeInsets.only( ? EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, top: MediaQuery.of(context).padding.top,
left: 16, left: 16,
right: 16, right: 16,
bottom: MediaQuery.of(context).size.height - 120, bottom: MediaQuery.of(context).size.height - 100,
) )
: const EdgeInsets.all(16), : const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@@ -222,10 +222,10 @@ class AppSnackBar {
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
margin: showAtTop margin: showAtTop
? EdgeInsets.only( ? EdgeInsets.only(
top: MediaQuery.of(context).padding.top + 16, top: MediaQuery.of(context).padding.top,
left: 16, left: 16,
right: 16, right: 16,
bottom: MediaQuery.of(context).size.height - 120, bottom: MediaQuery.of(context).size.height - 100,
) )
: const EdgeInsets.all(16), : const EdgeInsets.all(16),
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart'; import '../common/form_fields/date_picker_field.dart';
@@ -19,9 +21,11 @@ class DetailEventSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final baseColor = controller.getCardColor(); return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
return FadeTransition( return FadeTransition(
opacity: fadeAnimation, opacity: fadeAnimation,
child: SlideTransition( child: SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
@@ -31,11 +35,21 @@ class DetailEventSection extends StatelessWidget {
parent: controller.animationController!, parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)), )),
child: Card( child: Container(
elevation: 1, decoration: BoxDecoration(
shadowColor: Colors.black12, color: AppColors.glassCard,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), 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( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -66,6 +80,7 @@ class DetailEventSection extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
), ),
), ),
], ],
@@ -93,14 +108,18 @@ class DetailEventSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1), color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: Colors.blue[700], color: AppColors.infoColor,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -109,7 +128,8 @@ class DetailEventSection extends StatelessWidget {
'할인 또는 프로모션 가격을 설정하세요', '할인 또는 프로모션 가격을 설정하세요',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.blue[700], color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
), ),
), ),
), ),
@@ -123,6 +143,10 @@ class DetailEventSection extends StatelessWidget {
endDate: controller.eventEndDate, endDate: controller.eventEndDate,
onStartDateSelected: (date) { onStartDateSelected: (date) {
controller.eventStartDate = date; controller.eventStartDate = date;
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
if (date != null && controller.eventEndDate == null) {
controller.eventEndDate = date.add(const Duration(days: 30));
}
}, },
onEndDateSelected: (date) { onEndDateSelected: (date) {
controller.eventEndDate = date; controller.eventEndDate = date;
@@ -138,6 +162,18 @@ class DetailEventSection extends StatelessWidget {
currency: controller.currency, currency: controller.currency,
label: '이벤트 가격', label: '이벤트 가격',
hintText: '할인된 가격을 입력하세요', 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), const SizedBox(height: 16),
// 할인율 표시 // 할인율 표시
@@ -156,6 +192,8 @@ class DetailEventSection extends StatelessWidget {
), ),
), ),
); );
},
);
} }
} }
@@ -209,7 +247,7 @@ class _DiscountBadge extends StatelessWidget {
? '${discountAmount.toInt().toString()}원 절약' ? '${discountAmount.toInt().toString()}원 절약'
: '\$${discountAmount.toStringAsFixed(2)} 절약', : '\$${discountAmount.toStringAsFixed(2)} 절약',
style: TextStyle( style: TextStyle(
color: Colors.green[700], color: const Color(0xFF15803D),
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),

View File

@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../providers/category_provider.dart'; import '../../providers/category_provider.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/base_text_field.dart';
import '../common/form_fields/currency_input_field.dart'; import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final baseColor = controller.getCardColor(); return Consumer<DetailScreenController>(
builder: (context, controller, child) {
final baseColor = controller.getCardColor();
return FadeTransition( return FadeTransition(
opacity: fadeAnimation, opacity: fadeAnimation,
child: SlideTransition( child: SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
@@ -34,11 +40,21 @@ class DetailFormSection extends StatelessWidget {
parent: controller.animationController!, parent: controller.animationController!,
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic), curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
)), )),
child: Card( child: Container(
elevation: 1, decoration: BoxDecoration(
shadowColor: Colors.black12, color: AppColors.glassCard,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), 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( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -86,11 +102,13 @@ class DetailFormSection extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_CurrencySelector( CurrencySelector(
currency: controller.currency, currency: controller.currency,
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
controller.currency = value; controller.currency = value;
// 통화 변경시 금액 포맷 업데이트 // 통화 변경시 금액 포맷 업데이트
@@ -121,12 +139,14 @@ class DetailFormSection extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_BillingCycleSelector( BillingCycleSelector(
billingCycle: controller.billingCycle, billingCycle: controller.billingCycle,
baseColor: baseColor, baseColor: baseColor,
isGlassmorphism: true,
onChanged: (value) { onChanged: (value) {
controller.billingCycle = value; controller.billingCycle = value;
}, },
@@ -159,13 +179,15 @@ class DetailFormSection extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.darkNavy,
), ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_CategorySelector( CategorySelector(
categories: categoryProvider.categories, categories: categoryProvider.categories,
selectedCategoryId: controller.selectedCategoryId, selectedCategoryId: controller.selectedCategoryId,
baseColor: baseColor, baseColor: baseColor,
isGlassmorphism: true,
onChanged: (categoryId) { onChanged: (categoryId) {
controller.selectedCategoryId = categoryId; controller.selectedCategoryId = categoryId;
}, },
@@ -180,191 +202,8 @@ class DetailFormSection extends StatelessWidget {
), ),
), ),
); );
} },
}
/// 통화 선택기
class _CurrencySelector extends StatelessWidget {
final String currency;
final ValueChanged<String> 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<String> 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<dynamic> categories;
final String? selectedCategoryId;
final Color baseColor;
final ValueChanged<String?> 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(),
);
}
}

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import '../../models/subscription_model.dart'; import '../../models/subscription_model.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
@@ -24,10 +25,12 @@ class DetailHeaderSection extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final baseColor = controller.getCardColor(); return Consumer<DetailScreenController>(
final gradient = controller.getGradient(baseColor); builder: (context, controller, child) {
final baseColor = controller.getCardColor();
final gradient = controller.getGradient(baseColor);
return Container( return Container(
height: 320, height: 320,
decoration: BoxDecoration(gradient: gradient), decoration: BoxDecoration(gradient: gradient),
child: Stack( child: Stack(
@@ -118,8 +121,8 @@ class DetailHeaderSection extends StatelessWidget {
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: WebsiteIcon( child: WebsiteIcon(
url: subscription.websiteUrl, url: controller.websiteUrlController.text,
serviceName: subscription.serviceName, serviceName: controller.serviceNameController.text,
size: 48, size: 48,
), ),
), ),
@@ -131,7 +134,7 @@ class DetailHeaderSection extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
subscription.serviceName, controller.serviceNameController.text,
style: const TextStyle( style: const TextStyle(
fontSize: 28, fontSize: 28,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w800,
@@ -148,7 +151,7 @@ class DetailHeaderSection extends StatelessWidget {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
'${subscription.billingCycle} 결제', '${controller.billingCycle} 결제',
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
@@ -174,20 +177,22 @@ class DetailHeaderSection extends StatelessWidget {
_InfoColumn( _InfoColumn(
label: '다음 결제일', label: '다음 결제일',
value: DateFormat('yyyy년 MM월 dd일') value: DateFormat('yyyy년 MM월 dd일')
.format(subscription.nextBillingDate), .format(controller.nextBillingDate),
), ),
_InfoColumn( _InfoColumn(
label: '월 지출', label: '월 지출',
value: NumberFormat.currency( value: NumberFormat.currency(
locale: subscription.currency == 'KRW' locale: controller.currency == 'KRW'
? 'ko_KR' ? 'ko_KR'
: 'en_US', : 'en_US',
symbol: subscription.currency == 'KRW' symbol: controller.currency == 'KRW'
? '' ? ''
: '\$', : '\$',
decimalDigits: decimalDigits:
subscription.currency == 'KRW' ? 0 : 2, controller.currency == 'KRW' ? 0 : 2,
).format(subscription.monthlyCost), ).format(double.tryParse(
controller.monthlyCostController.text.replaceAll(',', '')
) ?? 0),
alignment: CrossAxisAlignment.end, alignment: CrossAxisAlignment.end,
), ),
], ],
@@ -204,6 +209,8 @@ class DetailHeaderSection extends StatelessWidget {
], ],
), ),
); );
},
);
} }
} }

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../controllers/detail_screen_controller.dart'; import '../../controllers/detail_screen_controller.dart';
import '../../theme/app_colors.dart';
import '../common/form_fields/base_text_field.dart'; import '../common/form_fields/base_text_field.dart';
import '../common/buttons/secondary_button.dart'; import '../common/buttons/secondary_button.dart';
@@ -31,11 +32,21 @@ class DetailUrlSection extends StatelessWidget {
parent: controller.animationController!, parent: controller.animationController!,
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic), curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
)), )),
child: Card( child: Container(
elevation: 1, decoration: BoxDecoration(
shadowColor: Colors.black12, color: AppColors.glassCard,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(20), 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( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
@@ -63,6 +74,7 @@ class DetailUrlSection extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.darkNavy,
), ),
), ),
], ],
@@ -78,7 +90,7 @@ class DetailUrlSection extends StatelessWidget {
keyboardType: TextInputType.url, keyboardType: TextInputType.url,
prefixIcon: Icon( prefixIcon: Icon(
Icons.link_rounded, Icons.link_rounded,
color: Colors.grey[600], color: AppColors.navyGray,
), ),
), ),
@@ -89,10 +101,10 @@ class DetailUrlSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.orange.withValues(alpha: 0.1), color: AppColors.warningColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
border: Border.all( border: Border.all(
color: Colors.orange.withValues(alpha: 0.3), color: AppColors.warningColor.withValues(alpha: 0.4),
width: 1, width: 1,
), ),
), ),
@@ -103,7 +115,7 @@ class DetailUrlSection extends StatelessWidget {
children: [ children: [
Icon( Icon(
Icons.info_outline_rounded, Icons.info_outline_rounded,
color: Colors.orange[700], color: AppColors.warningColor,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -112,7 +124,7 @@ class DetailUrlSection extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: Colors.orange[700], color: AppColors.darkNavy,
), ),
), ),
], ],
@@ -122,7 +134,8 @@ class DetailUrlSection extends StatelessWidget {
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.', '이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.grey[700], color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
height: 1.5, height: 1.5,
), ),
), ),
@@ -131,7 +144,7 @@ class DetailUrlSection extends StatelessWidget {
text: '해지 페이지로 이동', text: '해지 페이지로 이동',
icon: Icons.open_in_new_rounded, icon: Icons.open_in_new_rounded,
onPressed: controller.openCancellationPage, onPressed: controller.openCancellationPage,
color: Colors.orange[700], color: AppColors.warningColor,
), ),
], ],
), ),
@@ -144,14 +157,18 @@ class DetailUrlSection extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1), color: AppColors.infoColor.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(
color: AppColors.infoColor.withValues(alpha: 0.3),
width: 1,
),
), ),
child: Row( child: Row(
children: [ children: [
Icon( Icon(
Icons.auto_fix_high_rounded, Icons.auto_fix_high_rounded,
color: Colors.blue[700], color: AppColors.infoColor,
size: 20, size: 20,
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@@ -160,7 +177,8 @@ class DetailUrlSection extends StatelessWidget {
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다', 'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: Colors.blue[700], color: AppColors.darkNavy,
fontWeight: FontWeight.w500,
), ),
), ),
), ),

View File

@@ -68,23 +68,12 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
bottom: 20, bottom: 20,
left: 20, left: 20,
right: 20, right: 20,
height: 88,
child: Transform.translate( child: Transform.translate(
offset: Offset(0, 100 * (1 - _animation.value)), offset: Offset(0, 100 * (1 - _animation.value)),
child: Opacity( child: Opacity(
opacity: _animation.value, opacity: _animation.value,
child: Stack( child: GlassmorphismCard(
children: [
// 차단 레이어 - 크기 명시
Positioned.fill(
child: Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(24),
),
),
),
// 글래스모피즘 레이어
GlassmorphismCard(
padding: padding:
const EdgeInsets.symmetric(vertical: 8, horizontal: 8), const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
borderRadius: 24, borderRadius: 24,
@@ -123,8 +112,6 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
], ],
), ),
), ),
],
),
), ),
), ),
); );

View File

@@ -174,10 +174,17 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지
onTapDown: _handleTapDown, onTapDown: _handleTapDown,
onTapUp: _handleTapUp, onTapUp: (details) {
_handleTapUp(details);
// onTap 콜백 실행
if (widget.onTap != null) {
print('[AnimatedGlassmorphismCard] onTap 콜백 실행');
widget.onTap!();
}
},
onTapCancel: _handleTapCancel, onTapCancel: _handleTapCancel,
onTap: widget.onTap,
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _controller, animation: _controller,
builder: (context, child) { builder: (context, child) {
@@ -191,6 +198,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
borderRadius: widget.borderRadius, borderRadius: widget.borderRadius,
blur: _blurAnimation.value, blur: _blurAnimation.value,
opacity: widget.opacity, opacity: widget.opacity,
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
child: widget.child, child: widget.child,
), ),
); );

View File

@@ -7,7 +7,6 @@ import '../widgets/native_ad_widget.dart';
import '../widgets/main_summary_card.dart'; import '../widgets/main_summary_card.dart';
import '../widgets/subscription_list_widget.dart'; import '../widgets/subscription_list_widget.dart';
import '../widgets/empty_state_widget.dart'; import '../widgets/empty_state_widget.dart';
import '../widgets/glassmorphic_app_bar.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
class HomeContent extends StatelessWidget { class HomeContent extends StatelessWidget {
@@ -67,10 +66,10 @@ class HomeContent extends StatelessWidget {
controller: scrollController, controller: scrollController,
physics: const BouncingScrollPhysics(), physics: const BouncingScrollPhysics(),
slivers: [ slivers: [
const GlassmorphicSliverAppBar( SliverToBoxAdapter(
title: '', child: SizedBox(
pinned: true, height: kToolbarHeight + MediaQuery.of(context).padding.top,
expandedHeight: kToolbarHeight, ),
), ),
const SliverToBoxAdapter( const SliverToBoxAdapter(
child: NativeAdWidget(key: ValueKey('home_ad')), child: NativeAdWidget(key: ValueKey('home_ad')),
@@ -105,7 +104,9 @@ class HomeContent extends StatelessWidget {
parent: slideController, curve: Curves.easeOutCubic)), parent: slideController, curve: Curves.easeOutCubic)),
child: Text( child: Text(
'나의 구독 서비스', '나의 구독 서비스',
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: AppColors.darkNavy,
),
), ),
), ),
SlideTransition( SlideTransition(
@@ -143,7 +144,7 @@ class HomeContent extends StatelessWidget {
), ),
SliverToBoxAdapter( SliverToBoxAdapter(
child: SizedBox( child: SizedBox(
height: 100 + MediaQuery.of(context).padding.bottom, height: 120 + MediaQuery.of(context).padding.bottom,
), ),
), ),
], ],

View File

@@ -37,185 +37,196 @@ class MainScreenSummaryCard extends StatelessWidget {
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4), padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
child: GlassmorphismCard( child: GlassmorphismCard(
borderRadius: 24, borderRadius: 24,
blur: 15, blur: 15,
backgroundColor: AppColors.glassCard, backgroundColor: AppColors.glassCard,
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: AppColors.mainGradient.map((color) => color.withValues(alpha: 0.2)).toList(), 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( decoration: BoxDecoration(
color: AppColors.glassBorder, borderRadius: BorderRadius.circular(24),
width: 1, color: Colors.transparent,
), ),
child: Container( child: ClipRRect(
width: double.infinity, borderRadius: BorderRadius.circular(24),
constraints: BoxConstraints( child: Stack(
minHeight: 180, children: [
maxHeight: activeEvents > 0 ? 300 : 240, // 애니메이션 웨이브 배경
), Positioned.fill(
decoration: BoxDecoration( child: AnimatedWaveBackground(
borderRadius: BorderRadius.circular(24), controller: waveController,
color: Colors.transparent, 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), Padding(
child: Column( padding: const EdgeInsets.all(24.0),
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start,
children: [ mainAxisSize: MainAxisSize.min,
Text( children: [
'이번 달 총 구독 비용', Text(
style: TextStyle( '이번 달 총 구독 비용',
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 style: TextStyle(
fontSize: 15, color: AppColors
fontWeight: FontWeight.w500, .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(width: 4),
const SizedBox(height: 8), Text(
Row( '',
crossAxisAlignment: CrossAxisAlignment.baseline, style: TextStyle(
textBaseline: TextBaseline.alphabetic, color: AppColors
children: [ .darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
Text( fontSize: 16,
NumberFormat.currency( fontWeight: FontWeight.w500,
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(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( Text(
title, title,
style: TextStyle( style: TextStyle(
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트 color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
), ),
@@ -245,7 +256,7 @@ class MainScreenSummaryCard extends StatelessWidget {
Text( Text(
value, value,
style: const TextStyle( style: const TextStyle(
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트 color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
), ),

View File

@@ -1,6 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:provider/provider.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import 'website_icon.dart'; import 'website_icon.dart';
import 'app_navigator.dart'; import 'app_navigator.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
@@ -8,10 +10,12 @@ import 'glassmorphism_card.dart';
class SubscriptionCard extends StatefulWidget { class SubscriptionCard extends StatefulWidget {
final SubscriptionModel subscription; final SubscriptionModel subscription;
final VoidCallback? onTap;
const SubscriptionCard({ const SubscriptionCard({
super.key, super.key,
required this.subscription, required this.subscription,
this.onTap,
}); });
@override @override
@@ -190,6 +194,34 @@ class _SubscriptionCardState extends State<SubscriptionCard>
return false; return false;
} }
// 카테고리별 그라데이션 색상 생성
List<Color> _getCategoryGradientColors(BuildContext context) {
try {
if (widget.subscription.categoryId == null) {
return AppColors.blueGradient;
}
final categoryProvider = context.watch<CategoryProvider>();
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -200,53 +232,39 @@ class _SubscriptionCardState extends State<SubscriptionCard>
child: MouseRegion( child: MouseRegion(
onEnter: (_) => _onHover(true), onEnter: (_) => _onHover(true),
onExit: (_) => _onHover(false), onExit: (_) => _onHover(false),
child: AnimatedBuilder( child: AnimatedGlassmorphismCard(
animation: _hoverController, padding: EdgeInsets.zero,
builder: (context, child) { borderRadius: 16,
final scale = 1.0 + (0.02 * _hoverController.value); 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( Padding(
scale: scale, padding: const EdgeInsets.all(16),
child: Material( child: Row(
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(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
// 서비스 아이콘 // 서비스 아이콘
@@ -304,7 +322,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
borderRadius: borderRadius:
BorderRadius.circular(12), BorderRadius.circular(12),
), ),
child: Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon( Icon(
@@ -526,15 +544,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
), ),
), ),
], ],
),
),
],
),
),
), ),
), ),
); ],
}, ),
), ),
), ),
); );

View File

@@ -42,10 +42,8 @@ class SubscriptionListWidget extends StatelessWidget {
child: CategoryHeaderWidget( child: CategoryHeaderWidget(
categoryName: category, categoryName: category,
subscriptionCount: subscriptions.length, subscriptionCount: subscriptions.length,
totalCost: subscriptions.fold( totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
0.0, totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
(sum, sub) => sum + sub.monthlyCost,
),
), ),
), ),
// 카테고리별 구독 목록 // 카테고리별 구독 목록
@@ -87,10 +85,7 @@ class SubscriptionListWidget extends StatelessWidget {
child: SwipeableSubscriptionCard( child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex], subscription: subscriptions[subIndex],
onTap: () { onTap: () {
AppNavigator.toDetail(context, subscriptions[subIndex]); print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
},
onEdit: () {
// 편집 화면으로 이동
AppNavigator.toDetail(context, subscriptions[subIndex]); AppNavigator.toDetail(context, subscriptions[subIndex]);
}, },
onDelete: () async { onDelete: () async {
@@ -111,7 +106,7 @@ class SubscriptionListWidget extends StatelessWidget {
); );
if (context.mounted) { if (context.mounted) {
AppSnackBar.showSuccess( AppSnackBar.showError(
context: context, context: context,
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.', message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.',
icon: Icons.delete_forever_rounded, icon: Icons.delete_forever_rounded,
@@ -134,6 +129,13 @@ class SubscriptionListWidget extends StatelessWidget {
), ),
); );
} }
/// 특정 통화의 총 합계를 계산합니다.
double _calculateTotalByCurrency(List<SubscriptionModel> subscriptions, String currency) {
return subscriptions
.where((sub) => sub.currency == currency)
.fold(0.0, (sum, sub) => sum + sub.monthlyCost);
}
} }
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯 /// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/gestures.dart';
import '../models/subscription_model.dart'; import '../models/subscription_model.dart';
import '../utils/haptic_feedback_helper.dart'; import '../utils/haptic_feedback_helper.dart';
import 'subscription_card.dart'; import 'subscription_card.dart';
@@ -18,23 +19,34 @@ class SwipeableSubscriptionCard extends StatefulWidget {
}); });
@override @override
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState(); State<SwipeableSubscriptionCard> createState() =>
_SwipeableSubscriptionCardState();
} }
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard> class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
with SingleTickerProviderStateMixin { 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 AnimationController _controller;
late Animation<double> _animation; late Animation<double> _animation;
double _dragStartX = 0;
double _currentOffset = 0; // 현재 카드의 실제 위치 // 제스처 추적
bool _isDragging = false; // 드래그 중인지 여부 Offset? _startPosition;
DateTime? _startTime;
bool _isValidTap = true;
// 상태 관리
double _currentOffset = 0;
bool _isSwipingLeft = false; bool _isSwipingLeft = false;
bool _hapticTriggered = false; bool _hapticTriggered = false;
double _screenWidth = 0; double _cardWidth = 0;
double _cardWidth = 0; // 카드의 실제 너비 (margin 제외)
static const double _actionThresholdPercent = 0.15; // 15%에서 액션 버튼 표시
static const double _deleteThresholdPercent = 0.40; // 40%에서 삭제/편집 실행
@override @override
void initState() { void initState() {
@@ -51,127 +63,127 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
curve: Curves.easeOutExpo, curve: Curves.easeOutExpo,
)); ));
// 애니메이션 상태 리스너 추가 _controller.addListener(() {
_controller.addStatusListener(_onAnimationStatusChanged); if (mounted) {
setState(() {
// 애니메이션 리스너 추가 _currentOffset = _animation.value;
_controller.addListener(_onAnimationUpdate); });
}
});
} }
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
_screenWidth = MediaQuery.of(context).size.width; _cardWidth = MediaQuery.of(context).size.width - 32;
_cardWidth = _screenWidth - 32; // 좌우 margin 16px씩 제외
} }
@override @override
void didUpdateWidget(SwipeableSubscriptionCard oldWidget) { void didUpdateWidget(SwipeableSubscriptionCard oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// 위젯이 업데이트될 때 카드를 원위치로 복귀
if (oldWidget.subscription.id != widget.subscription.id) { if (oldWidget.subscription.id != widget.subscription.id) {
_controller.stop(); _resetCard();
setState(() {
_currentOffset = 0;
_isDragging = false;
});
} }
} }
@override @override
void dispose() { void dispose() {
_controller.removeListener(_onAnimationUpdate);
_controller.removeStatusListener(_onAnimationStatusChanged);
_controller.stop();
_controller.dispose(); _controller.dispose();
super.dispose(); super.dispose();
} }
void _onAnimationUpdate() { // 제스처 핸들러
if (!_isDragging) { void _handlePanStart(DragStartDetails details) {
setState(() { _startPosition = details.localPosition;
_currentOffset = _animation.value; _startTime = DateTime.now();
}); _isValidTap = true;
}
}
void _onAnimationStatusChanged(AnimationStatus status) {
if (status == AnimationStatus.completed && !_isDragging) {
setState(() {
_currentOffset = _animation.value;
});
}
}
void _handleDragStart(DragStartDetails details) {
_dragStartX = details.localPosition.dx;
_hapticTriggered = false; _hapticTriggered = false;
_isDragging = true; _controller.stop();
_controller.stop(); // 진행 중인 애니메이션 중지
} }
void _handleDragUpdate(DragUpdateDetails details) { void _handlePanUpdate(DragUpdateDetails details) {
final delta = details.localPosition.dx - _dragStartX; final currentPosition = details.localPosition;
final delta = currentPosition.dx - _startPosition!.dx;
final distance = (currentPosition - _startPosition!).distance;
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
if (distance > _tapTolerance) {
_isValidTap = false;
}
// 카드 이동
setState(() { setState(() {
_currentOffset = delta; _currentOffset = delta;
_isSwipingLeft = delta < 0; _isSwipingLeft = delta < 0;
}); });
// 햅틱 피드백 트리거 (카드 너비의 15%) // 햅틱 피드백
final actionThreshold = _cardWidth * _actionThresholdPercent; _triggerHapticFeedback();
if (!_hapticTriggered && _currentOffset.abs() > actionThreshold) {
_hapticTriggered = true;
HapticFeedbackHelper.mediumImpact();
}
// 삭제 임계값에 도달했을 때 강한 햅틱 (카드 너비의 40%)
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
if (_currentOffset.abs() > deleteThreshold && _hapticTriggered) {
HapticFeedbackHelper.heavyImpact();
_hapticTriggered = false; // 반복 방지
}
} }
void _handleDragEnd(DragEndDetails details) async { void _handlePanEnd(DragEndDetails details) {
_isDragging = false; final duration = DateTime.now().difference(_startTime!);
final velocity = details.velocity.pixelsPerSecond.dx; final velocity = details.velocity.pixelsPerSecond.dx;
final extent = _currentOffset.abs();
// 카드 너비의 40% 계산 // 탭/스와이프 처리 분기
// 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리
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();
final deleteThreshold = _cardWidth * _deleteThresholdPercent; final deleteThreshold = _cardWidth * _deleteThresholdPercent;
if (extent > deleteThreshold || velocity.abs() > 800) { if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) {
// 40% 이상 스와이프 시 삭제/편집 액션 // 삭제 실행
if (_isSwipingLeft && widget.onDelete != null) { if (widget.onDelete != null) {
HapticFeedbackHelper.success(); HapticFeedbackHelper.success();
// 삭제 확인 다이얼로그 표시 widget.onDelete!().then((_) {
await widget.onDelete!(); if (mounted) {
// 다이얼로그가 닫힌 후 원위치로 복귀 _animateToOffset(0);
if (mounted) { }
_animateToOffset(0);
}
} else if (!_isSwipingLeft && widget.onEdit != null) {
HapticFeedbackHelper.success();
// 편집 화면으로 이동 전 원위치로 복귀
_animateToOffset(0);
Future.delayed(const Duration(milliseconds: 300), () {
widget.onEdit!();
}); });
} else { } else {
// 액션이 없는 경우 원위치로 복귀
_animateToOffset(0); _animateToOffset(0);
} }
} else { } else {
// 40% 미만: 모두 원위치 복귀 // 원위치 복귀
_animateToOffset(0); _animateToOffset(0);
} }
} }
void _animateToOffset(double offset) { void _triggerHapticFeedback() {
// 애니메이션 컨트롤러 리셋 final actionThreshold = _cardWidth * _actionThresholdPercent;
_controller.stop(); final deleteThreshold = _cardWidth * _deleteThresholdPercent;
_controller.value = 0; 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) {
_animation = Tween<double>( _animation = Tween<double>(
begin: _currentOffset, begin: _currentOffset,
end: offset, end: offset,
@@ -180,91 +192,94 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
curve: Curves.easeOutExpo, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 웹과 모바일 모두 동일한 스와이프 기능 제공
return Stack( return Stack(
children: [ children: [
// 배경 액션 버튼들 _buildActionButtons(),
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,
),
),
),
),
],
),
),
),
// 스와이프 가능한 카드
GestureDetector( GestureDetector(
onHorizontalDragStart: _handleDragStart, behavior: HitTestBehavior.opaque,
onHorizontalDragUpdate: _handleDragUpdate, onPanStart: _handlePanStart,
onHorizontalDragEnd: _handleDragEnd, onPanUpdate: _handlePanUpdate,
child: Transform.translate( onPanEnd: _handlePanEnd,
offset: Offset(_currentOffset, 0), child: _buildCard(),
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,
),
),
),
),
),
), ),
], ],
); );