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