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)
|
- 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';
|
import '../widgets/common/snackbar/app_snackbar.dart';
|
||||||
|
|
||||||
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
/// DetailScreen의 비즈니스 로직을 관리하는 Controller
|
||||||
class DetailScreenController {
|
class DetailScreenController extends ChangeNotifier {
|
||||||
final BuildContext context;
|
final BuildContext context;
|
||||||
final SubscriptionModel subscription;
|
final SubscriptionModel subscription;
|
||||||
|
|
||||||
@@ -22,16 +22,85 @@ class DetailScreenController {
|
|||||||
late TextEditingController eventPriceController;
|
late TextEditingController eventPriceController;
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
late String billingCycle;
|
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
|
||||||
late DateTime nextBillingDate;
|
late String _billingCycle;
|
||||||
String? selectedCategoryId;
|
late DateTime _nextBillingDate;
|
||||||
late String currency;
|
String? _selectedCategoryId;
|
||||||
bool isLoading = false;
|
late String _currency;
|
||||||
|
bool _isLoading = false;
|
||||||
|
|
||||||
// Event State
|
// Event State
|
||||||
late bool isEventActive;
|
late bool _isEventActive;
|
||||||
DateTime? eventStartDate;
|
DateTime? _eventStartDate;
|
||||||
DateTime? eventEndDate;
|
DateTime? _eventEndDate;
|
||||||
|
|
||||||
|
// Getters
|
||||||
|
String get billingCycle => _billingCycle;
|
||||||
|
DateTime get nextBillingDate => _nextBillingDate;
|
||||||
|
String? get selectedCategoryId => _selectedCategoryId;
|
||||||
|
String get currency => _currency;
|
||||||
|
bool get isLoading => _isLoading;
|
||||||
|
bool get isEventActive => _isEventActive;
|
||||||
|
DateTime? get eventStartDate => _eventStartDate;
|
||||||
|
DateTime? get eventEndDate => _eventEndDate;
|
||||||
|
|
||||||
|
// Setters
|
||||||
|
set billingCycle(String value) {
|
||||||
|
if (_billingCycle != value) {
|
||||||
|
_billingCycle = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set nextBillingDate(DateTime value) {
|
||||||
|
if (_nextBillingDate != value) {
|
||||||
|
_nextBillingDate = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set selectedCategoryId(String? value) {
|
||||||
|
if (_selectedCategoryId != value) {
|
||||||
|
_selectedCategoryId = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set currency(String value) {
|
||||||
|
if (_currency != value) {
|
||||||
|
_currency = value;
|
||||||
|
_updateMonthlyCostFormat();
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set isLoading(bool value) {
|
||||||
|
if (_isLoading != value) {
|
||||||
|
_isLoading = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set isEventActive(bool value) {
|
||||||
|
if (_isEventActive != value) {
|
||||||
|
_isEventActive = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set eventStartDate(DateTime? value) {
|
||||||
|
if (_eventStartDate != value) {
|
||||||
|
_eventStartDate = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
set eventEndDate(DateTime? value) {
|
||||||
|
if (_eventEndDate != value) {
|
||||||
|
_eventEndDate = value;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Focus Nodes
|
// Focus Nodes
|
||||||
final serviceNameFocus = FocusNode();
|
final serviceNameFocus = FocusNode();
|
||||||
@@ -70,15 +139,15 @@ class DetailScreenController {
|
|||||||
eventPriceController = TextEditingController();
|
eventPriceController = TextEditingController();
|
||||||
|
|
||||||
// Form State 초기화
|
// Form State 초기화
|
||||||
billingCycle = subscription.billingCycle;
|
_billingCycle = subscription.billingCycle;
|
||||||
nextBillingDate = subscription.nextBillingDate;
|
_nextBillingDate = subscription.nextBillingDate;
|
||||||
selectedCategoryId = subscription.categoryId;
|
_selectedCategoryId = subscription.categoryId;
|
||||||
currency = subscription.currency;
|
_currency = subscription.currency;
|
||||||
|
|
||||||
// Event State 초기화
|
// Event State 초기화
|
||||||
isEventActive = subscription.isEventActive;
|
_isEventActive = subscription.isEventActive;
|
||||||
eventStartDate = subscription.eventStartDate;
|
_eventStartDate = subscription.eventStartDate;
|
||||||
eventEndDate = subscription.eventEndDate;
|
_eventEndDate = subscription.eventEndDate;
|
||||||
|
|
||||||
// 이벤트 가격 초기화
|
// 이벤트 가격 초기화
|
||||||
if (subscription.eventPrice != null) {
|
if (subscription.eventPrice != null) {
|
||||||
@@ -137,6 +206,7 @@ class DetailScreenController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 리소스 정리
|
/// 리소스 정리
|
||||||
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
// Controllers
|
// Controllers
|
||||||
serviceNameController.dispose();
|
serviceNameController.dispose();
|
||||||
@@ -158,11 +228,13 @@ class DetailScreenController {
|
|||||||
|
|
||||||
// Scroll
|
// Scroll
|
||||||
scrollController.dispose();
|
scrollController.dispose();
|
||||||
|
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 통화 단위에 따른 금액 표시 형식 업데이트
|
/// 통화 단위에 따른 금액 표시 형식 업데이트
|
||||||
void _updateMonthlyCostFormat() {
|
void _updateMonthlyCostFormat() {
|
||||||
if (currency == 'KRW') {
|
if (_currency == 'KRW') {
|
||||||
// 원화는 소수점 없이 표시
|
// 원화는 소수점 없이 표시
|
||||||
final intValue = subscription.monthlyCost.toInt();
|
final intValue = subscription.monthlyCost.toInt();
|
||||||
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
|
monthlyCostController.text = NumberFormat.decimalPattern().format(intValue);
|
||||||
@@ -197,7 +269,7 @@ class DetailScreenController {
|
|||||||
serviceName.contains('coupang play') ||
|
serviceName.contains('coupang play') ||
|
||||||
serviceName.contains('쿠팡플레이')) {
|
serviceName.contains('쿠팡플레이')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '엔터테인먼트',
|
(cat) => cat.name == 'OTT 서비스',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -209,7 +281,7 @@ class DetailScreenController {
|
|||||||
serviceName.contains('플로') ||
|
serviceName.contains('플로') ||
|
||||||
serviceName.contains('벅스')) {
|
serviceName.contains('벅스')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '음악',
|
(cat) => cat.name == '음악 서비스',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -222,18 +294,18 @@ class DetailScreenController {
|
|||||||
serviceName.contains('icloud') ||
|
serviceName.contains('icloud') ||
|
||||||
serviceName.contains('adobe')) {
|
serviceName.contains('adobe')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '생산성',
|
(cat) => cat.name == '오피스/협업 툴',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 게임 관련 키워드
|
// AI 관련 키워드
|
||||||
else if (serviceName.contains('xbox') ||
|
else if (serviceName.contains('chatgpt') ||
|
||||||
serviceName.contains('playstation') ||
|
serviceName.contains('claude') ||
|
||||||
serviceName.contains('nintendo') ||
|
serviceName.contains('gemini') ||
|
||||||
serviceName.contains('steam') ||
|
serviceName.contains('copilot') ||
|
||||||
serviceName.contains('게임')) {
|
serviceName.contains('midjourney')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '게임',
|
(cat) => cat.name == 'AI 서비스',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -244,7 +316,7 @@ class DetailScreenController {
|
|||||||
serviceName.contains('패스트캠퍼스') ||
|
serviceName.contains('패스트캠퍼스') ||
|
||||||
serviceName.contains('클래스101')) {
|
serviceName.contains('클래스101')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '교육',
|
(cat) => cat.name == '프로그래밍/개발',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -255,7 +327,7 @@ class DetailScreenController {
|
|||||||
serviceName.contains('네이버') ||
|
serviceName.contains('네이버') ||
|
||||||
serviceName.contains('11번가')) {
|
serviceName.contains('11번가')) {
|
||||||
matchedCategory = categories.firstWhere(
|
matchedCategory = categories.firstWhere(
|
||||||
(cat) => cat.name == '쇼핑',
|
(cat) => cat.name == '기타 서비스',
|
||||||
orElse: () => categories.first,
|
orElse: () => categories.first,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -267,6 +339,15 @@ class DetailScreenController {
|
|||||||
|
|
||||||
/// 구독 정보 업데이트
|
/// 구독 정보 업데이트
|
||||||
Future<void> updateSubscription() async {
|
Future<void> updateSubscription() async {
|
||||||
|
// Form 검증
|
||||||
|
if (formKey.currentState != null && !formKey.currentState!.validate()) {
|
||||||
|
AppSnackBar.showError(
|
||||||
|
context: context,
|
||||||
|
message: '필수 항목을 모두 입력해주세요',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||||
|
|
||||||
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
|
// 웹사이트 URL이 비어있는 경우 자동 매칭 다시 시도
|
||||||
@@ -289,18 +370,18 @@ class DetailScreenController {
|
|||||||
subscription.serviceName = serviceNameController.text;
|
subscription.serviceName = serviceNameController.text;
|
||||||
subscription.monthlyCost = monthlyCost;
|
subscription.monthlyCost = monthlyCost;
|
||||||
subscription.websiteUrl = websiteUrl;
|
subscription.websiteUrl = websiteUrl;
|
||||||
subscription.billingCycle = billingCycle;
|
subscription.billingCycle = _billingCycle;
|
||||||
subscription.nextBillingDate = nextBillingDate;
|
subscription.nextBillingDate = _nextBillingDate;
|
||||||
subscription.categoryId = selectedCategoryId;
|
subscription.categoryId = _selectedCategoryId;
|
||||||
subscription.currency = currency;
|
subscription.currency = _currency;
|
||||||
|
|
||||||
// 이벤트 정보 업데이트
|
// 이벤트 정보 업데이트
|
||||||
subscription.isEventActive = isEventActive;
|
subscription.isEventActive = _isEventActive;
|
||||||
subscription.eventStartDate = eventStartDate;
|
subscription.eventStartDate = _eventStartDate;
|
||||||
subscription.eventEndDate = eventEndDate;
|
subscription.eventEndDate = _eventEndDate;
|
||||||
|
|
||||||
// 이벤트 가격 파싱
|
// 이벤트 가격 파싱
|
||||||
if (isEventActive && eventPriceController.text.isNotEmpty) {
|
if (_isEventActive && eventPriceController.text.isNotEmpty) {
|
||||||
try {
|
try {
|
||||||
subscription.eventPrice =
|
subscription.eventPrice =
|
||||||
double.parse(eventPriceController.text.replaceAll(',', ''));
|
double.parse(eventPriceController.text.replaceAll(',', ''));
|
||||||
@@ -345,7 +426,7 @@ class DetailScreenController {
|
|||||||
await provider.deleteSubscription(subscription.id);
|
await provider.deleteSubscription(subscription.id);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showSuccess(
|
AppSnackBar.showError(
|
||||||
context: context,
|
context: context,
|
||||||
message: '구독이 삭제되었습니다.',
|
message: '구독이 삭제되었습니다.',
|
||||||
icon: Icons.delete_forever_rounded,
|
icon: Icons.delete_forever_rounded,
|
||||||
|
|||||||
@@ -7,7 +7,36 @@ class CategoryProvider extends ChangeNotifier {
|
|||||||
List<CategoryModel> _categories = [];
|
List<CategoryModel> _categories = [];
|
||||||
late Box<CategoryModel> _categoryBox;
|
late Box<CategoryModel> _categoryBox;
|
||||||
|
|
||||||
List<CategoryModel> get categories => _categories;
|
// 카테고리 표시 순서 정의
|
||||||
|
static const List<String> _categoryOrder = [
|
||||||
|
'음악',
|
||||||
|
'OTT(동영상)',
|
||||||
|
'저장/클라우드',
|
||||||
|
'통신 · 인터넷 · TV',
|
||||||
|
'생활/라이프스타일',
|
||||||
|
'쇼핑/이커머스',
|
||||||
|
'프로그래밍',
|
||||||
|
'협업/오피스',
|
||||||
|
'AI 서비스',
|
||||||
|
'기타',
|
||||||
|
];
|
||||||
|
|
||||||
|
List<CategoryModel> get categories {
|
||||||
|
// 정의된 순서로 카테고리 정렬
|
||||||
|
final sortedCategories = List<CategoryModel>.from(_categories);
|
||||||
|
sortedCategories.sort((a, b) {
|
||||||
|
final aIndex = _categoryOrder.indexOf(a.name);
|
||||||
|
final bIndex = _categoryOrder.indexOf(b.name);
|
||||||
|
|
||||||
|
// 순서 목록에 없는 카테고리는 맨 뒤로
|
||||||
|
if (aIndex == -1) return 1;
|
||||||
|
if (bIndex == -1) return -1;
|
||||||
|
|
||||||
|
return aIndex.compareTo(bIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
return sortedCategories;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
_categoryBox = await Hive.openBox<CategoryModel>('categories');
|
_categoryBox = await Hive.openBox<CategoryModel>('categories');
|
||||||
@@ -24,12 +53,16 @@ class CategoryProvider extends ChangeNotifier {
|
|||||||
// 기본 카테고리 초기화
|
// 기본 카테고리 초기화
|
||||||
Future<void> _initDefaultCategories() async {
|
Future<void> _initDefaultCategories() async {
|
||||||
final defaultCategories = [
|
final defaultCategories = [
|
||||||
{'name': 'OTT 서비스', 'color': '#3B82F6', 'icon': 'live_tv'},
|
{'name': '음악', 'color': '#E91E63', 'icon': 'music_note'},
|
||||||
{'name': '음악 서비스', 'color': '#EC4899', 'icon': 'music_note'},
|
{'name': 'OTT(동영상)', 'color': '#9C27B0', 'icon': 'movie_filter'},
|
||||||
{'name': 'AI 서비스', 'color': '#8B5CF6', 'icon': 'psychology'},
|
{'name': '저장/클라우드', 'color': '#2196F3', 'icon': 'cloud'},
|
||||||
{'name': '프로그래밍/개발', 'color': '#10B981', 'icon': 'code'},
|
{'name': '통신 · 인터넷 · TV', 'color': '#00BCD4', 'icon': 'wifi'},
|
||||||
{'name': '오피스/협업 툴', 'color': '#F59E0B', 'icon': 'business_center'},
|
{'name': '생활/라이프스타일', 'color': '#4CAF50', 'icon': 'home'},
|
||||||
{'name': '기타 서비스', 'color': '#6B7280', 'icon': 'more_horiz'},
|
{'name': '쇼핑/이커머스', 'color': '#FF9800', 'icon': 'shopping_cart'},
|
||||||
|
{'name': '프로그래밍', 'color': '#795548', 'icon': 'code'},
|
||||||
|
{'name': '협업/오피스', 'color': '#607D8B', 'icon': 'business_center'},
|
||||||
|
{'name': 'AI 서비스', 'color': '#673AB7', 'icon': 'smart_toy'},
|
||||||
|
{'name': '기타', 'color': '#9E9E9E', 'icon': 'category'},
|
||||||
];
|
];
|
||||||
|
|
||||||
for (final category in defaultCategories) {
|
for (final category in defaultCategories) {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ class NotificationProvider extends ChangeNotifier {
|
|||||||
static const String _dailyReminderKey = 'daily_reminder_enabled';
|
static const String _dailyReminderKey = 'daily_reminder_enabled';
|
||||||
|
|
||||||
bool _isEnabled = false;
|
bool _isEnabled = false;
|
||||||
bool _isPaymentEnabled = false;
|
bool _isPaymentEnabled = true;
|
||||||
bool _isUnusedServiceNotificationEnabled = false;
|
bool _isUnusedServiceNotificationEnabled = false;
|
||||||
int _reminderDays = 3; // 기본값: 3일 전
|
int _reminderDays = 3; // 기본값: 3일 전
|
||||||
int _reminderHour = 10; // 기본값: 오전 10시
|
int _reminderHour = 10; // 기본값: 오전 10시
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:hive/hive.dart';
|
|||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../services/notification_service.dart';
|
import '../services/notification_service.dart';
|
||||||
|
import 'category_provider.dart';
|
||||||
|
|
||||||
class SubscriptionProvider extends ChangeNotifier {
|
class SubscriptionProvider extends ChangeNotifier {
|
||||||
late Box<SubscriptionModel> _subscriptionBox;
|
late Box<SubscriptionModel> _subscriptionBox;
|
||||||
@@ -46,6 +47,9 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
|
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
|
||||||
await refreshSubscriptions();
|
await refreshSubscriptions();
|
||||||
|
|
||||||
|
// categoryId 마이그레이션
|
||||||
|
await _migrateCategoryIds();
|
||||||
|
|
||||||
// 앱 시작 시 이벤트 상태 확인
|
// 앱 시작 시 이벤트 상태 확인
|
||||||
await checkAndUpdateEventStatus();
|
await checkAndUpdateEventStatus();
|
||||||
|
|
||||||
@@ -290,4 +294,105 @@ class SubscriptionProvider extends ChangeNotifier {
|
|||||||
];
|
];
|
||||||
return months[month.month - 1];
|
return months[month.month - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// categoryId가 없는 기존 구독들에 대해 자동으로 카테고리 할당
|
||||||
|
Future<void> _migrateCategoryIds() async {
|
||||||
|
debugPrint('❎ CategoryId 마이그레이션 시작...');
|
||||||
|
|
||||||
|
final categoryProvider = CategoryProvider();
|
||||||
|
await categoryProvider.init();
|
||||||
|
final categories = categoryProvider.categories;
|
||||||
|
|
||||||
|
int migratedCount = 0;
|
||||||
|
|
||||||
|
for (var subscription in _subscriptions) {
|
||||||
|
if (subscription.categoryId == null) {
|
||||||
|
final serviceName = subscription.serviceName.toLowerCase();
|
||||||
|
String? categoryId;
|
||||||
|
|
||||||
|
debugPrint('🔍 ${subscription.serviceName} 카테고리 매칭 시도...');
|
||||||
|
|
||||||
|
// OTT 서비스
|
||||||
|
if (serviceName.contains('netflix') ||
|
||||||
|
serviceName.contains('youtube') ||
|
||||||
|
serviceName.contains('disney') ||
|
||||||
|
serviceName.contains('왓차') ||
|
||||||
|
serviceName.contains('티빙') ||
|
||||||
|
serviceName.contains('디즈니') ||
|
||||||
|
serviceName.contains('넷플릭스')) {
|
||||||
|
categoryId = categories.firstWhere(
|
||||||
|
(cat) => cat.name == 'OTT 서비스',
|
||||||
|
orElse: () => categories.first,
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
// 음악 서비스
|
||||||
|
else if (serviceName.contains('spotify') ||
|
||||||
|
serviceName.contains('apple music') ||
|
||||||
|
serviceName.contains('멜론') ||
|
||||||
|
serviceName.contains('지니') ||
|
||||||
|
serviceName.contains('플로') ||
|
||||||
|
serviceName.contains('벡스')) {
|
||||||
|
categoryId = categories.firstWhere(
|
||||||
|
(cat) => cat.name == '음악 서비스',
|
||||||
|
orElse: () => categories.first,
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
// AI 서비스
|
||||||
|
else if (serviceName.contains('chatgpt') ||
|
||||||
|
serviceName.contains('claude') ||
|
||||||
|
serviceName.contains('midjourney') ||
|
||||||
|
serviceName.contains('copilot')) {
|
||||||
|
categoryId = categories.firstWhere(
|
||||||
|
(cat) => cat.name == 'AI 서비스',
|
||||||
|
orElse: () => categories.first,
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
// 프로그래밍/개발
|
||||||
|
else if (serviceName.contains('github') ||
|
||||||
|
serviceName.contains('intellij') ||
|
||||||
|
serviceName.contains('webstorm') ||
|
||||||
|
serviceName.contains('jetbrains')) {
|
||||||
|
categoryId = categories.firstWhere(
|
||||||
|
(cat) => cat.name == '프로그래밍/개발',
|
||||||
|
orElse: () => categories.first,
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
// 오피스/협업 툴
|
||||||
|
else if (serviceName.contains('notion') ||
|
||||||
|
serviceName.contains('microsoft') ||
|
||||||
|
serviceName.contains('office') ||
|
||||||
|
serviceName.contains('slack') ||
|
||||||
|
serviceName.contains('figma') ||
|
||||||
|
serviceName.contains('icloud') ||
|
||||||
|
serviceName.contains('아이클라우드')) {
|
||||||
|
categoryId = categories.firstWhere(
|
||||||
|
(cat) => cat.name == '오피스/협업 툴',
|
||||||
|
orElse: () => categories.first,
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
// 기타 서비스 (기본값)
|
||||||
|
else {
|
||||||
|
categoryId = categories.firstWhere(
|
||||||
|
(cat) => cat.name == '기타 서비스',
|
||||||
|
orElse: () => categories.first,
|
||||||
|
).id;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (categoryId != null) {
|
||||||
|
subscription.categoryId = categoryId;
|
||||||
|
await subscription.save();
|
||||||
|
migratedCount++;
|
||||||
|
final categoryName = categories.firstWhere((cat) => cat.id == categoryId).name;
|
||||||
|
debugPrint('✅ ${subscription.serviceName} → $categoryName');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (migratedCount > 0) {
|
||||||
|
debugPrint('❎ 총 ${migratedCount}개의 구독에 categoryId 할당 완료');
|
||||||
|
await refreshSubscriptions();
|
||||||
|
} else {
|
||||||
|
debugPrint('❎ 모든 구독이 이미 categoryId를 가지고 있습니다');
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../providers/subscription_provider.dart';
|
import '../providers/subscription_provider.dart';
|
||||||
import '../widgets/glassmorphic_app_bar.dart';
|
|
||||||
import '../widgets/native_ad_widget.dart';
|
import '../widgets/native_ad_widget.dart';
|
||||||
import '../widgets/analysis/analysis_screen_spacer.dart';
|
import '../widgets/analysis/analysis_screen_spacer.dart';
|
||||||
import '../widgets/analysis/subscription_pie_chart_card.dart';
|
import '../widgets/analysis/subscription_pie_chart_card.dart';
|
||||||
@@ -100,10 +99,10 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: <Widget>[
|
slivers: <Widget>[
|
||||||
const GlassmorphicSliverAppBar(
|
SliverToBoxAdapter(
|
||||||
title: '분석',
|
child: SizedBox(
|
||||||
pinned: true,
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||||
expandedHeight: kToolbarHeight,
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 네이티브 광고 위젯
|
// 네이티브 광고 위젯
|
||||||
@@ -148,7 +147,12 @@ class _AnalysisScreenState extends State<AnalysisScreen>
|
|||||||
animationController: _animationController,
|
animationController: _animationController,
|
||||||
),
|
),
|
||||||
|
|
||||||
const AnalysisScreenSpacer(height: 32),
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||||
|
SliverToBoxAdapter(
|
||||||
|
child: SizedBox(
|
||||||
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../controllers/detail_screen_controller.dart';
|
import '../controllers/detail_screen_controller.dart';
|
||||||
import '../widgets/detail/detail_header_section.dart';
|
import '../widgets/detail/detail_header_section.dart';
|
||||||
@@ -46,9 +47,11 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final baseColor = _controller.getCardColor();
|
final baseColor = _controller.getCardColor();
|
||||||
|
|
||||||
return Scaffold(
|
return ChangeNotifierProvider<DetailScreenController>.value(
|
||||||
backgroundColor: AppColors.backgroundColor,
|
value: _controller,
|
||||||
body: CustomScrollView(
|
child: Scaffold(
|
||||||
|
backgroundColor: AppColors.backgroundColor,
|
||||||
|
body: CustomScrollView(
|
||||||
controller: _controller.scrollController,
|
controller: _controller.scrollController,
|
||||||
slivers: [
|
slivers: [
|
||||||
// 상단 헤더 섹션
|
// 상단 헤더 섹션
|
||||||
@@ -74,8 +77,19 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: baseColor.withValues(alpha: 0.1),
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
baseColor.withValues(alpha: 0.15),
|
||||||
|
baseColor.withValues(alpha: 0.08),
|
||||||
|
],
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: baseColor.withValues(alpha: 0.2),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -98,7 +112,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
'변경사항은 저장 후 적용됩니다',
|
'변경사항은 저장 후 적용됩니다',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: baseColor.withValues(alpha: 0.8),
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -141,6 +155,7 @@ class _DetailScreenState extends State<DetailScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -183,10 +183,10 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
backgroundColor: AppColors.successColor,
|
backgroundColor: AppColors.successColor,
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: EdgeInsets.only(
|
margin: EdgeInsets.only(
|
||||||
top: MediaQuery.of(context).padding.top + 16, // 상단 여백
|
top: MediaQuery.of(context).padding.top + 8, // 더 상단으로
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: MediaQuery.of(context).size.height - 120, // 상단에 위치하도록 bottom 마진 설정
|
bottom: MediaQuery.of(context).size.height - 100, // 더 상단으로
|
||||||
),
|
),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import '../providers/theme_provider.dart';
|
|||||||
import '../theme/adaptive_theme.dart';
|
import '../theme/adaptive_theme.dart';
|
||||||
import '../widgets/glassmorphism_card.dart';
|
import '../widgets/glassmorphism_card.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../widgets/native_ad_widget.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatelessWidget {
|
class SettingsScreen extends StatelessWidget {
|
||||||
const SettingsScreen({super.key});
|
const SettingsScreen({super.key});
|
||||||
@@ -70,81 +71,18 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListView(
|
return Column(
|
||||||
padding: const EdgeInsets.only(top: 20),
|
children: [
|
||||||
children: [
|
SizedBox(
|
||||||
// 테마 설정
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||||
GlassmorphismCard(
|
),
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
Expanded(
|
||||||
padding: const EdgeInsets.all(8),
|
child: ListView(
|
||||||
child: Consumer<ThemeProvider>(
|
padding: const EdgeInsets.only(top: 20),
|
||||||
builder: (context, themeProvider, child) {
|
children: [
|
||||||
return Column(
|
// 광고 위젯 추가
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
const NativeAdWidget(key: ValueKey('settings_ad')),
|
||||||
children: [
|
const SizedBox(height: 16),
|
||||||
const Padding(
|
|
||||||
padding: EdgeInsets.all(16),
|
|
||||||
child: Text(
|
|
||||||
'테마 설정',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 테마 모드 선택
|
|
||||||
ListTile(
|
|
||||||
title: const Text('테마 모드'),
|
|
||||||
subtitle: Text(_getThemeModeText(themeProvider.themeMode)),
|
|
||||||
leading: Icon(
|
|
||||||
_getThemeModeIcon(themeProvider.themeMode),
|
|
||||||
color: Theme.of(context).colorScheme.primary,
|
|
||||||
),
|
|
||||||
trailing: DropdownButton<AppThemeMode>(
|
|
||||||
value: themeProvider.themeMode,
|
|
||||||
underline: Container(),
|
|
||||||
onChanged: (mode) {
|
|
||||||
if (mode != null) {
|
|
||||||
themeProvider.setThemeMode(mode);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
items: AppThemeMode.values.map((mode) =>
|
|
||||||
DropdownMenuItem(
|
|
||||||
value: mode,
|
|
||||||
child: Text(_getThemeModeText(mode)),
|
|
||||||
),
|
|
||||||
).toList(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Divider(height: 1),
|
|
||||||
|
|
||||||
// 접근성 설정
|
|
||||||
SwitchListTile(
|
|
||||||
title: const Text('큰 텍스트'),
|
|
||||||
subtitle: const Text('텍스트 크기를 크게 표시합니다'),
|
|
||||||
secondary: const Icon(Icons.text_fields),
|
|
||||||
value: themeProvider.largeText,
|
|
||||||
onChanged: themeProvider.setLargeText,
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
|
||||||
title: const Text('모션 감소'),
|
|
||||||
subtitle: const Text('애니메이션 효과를 줄입니다'),
|
|
||||||
secondary: const Icon(Icons.slow_motion_video),
|
|
||||||
value: themeProvider.reduceMotion,
|
|
||||||
onChanged: themeProvider.setReduceMotion,
|
|
||||||
),
|
|
||||||
SwitchListTile(
|
|
||||||
title: const Text('고대비 모드'),
|
|
||||||
subtitle: const Text('더 선명한 색상으로 표시합니다'),
|
|
||||||
secondary: const Icon(Icons.contrast),
|
|
||||||
value: themeProvider.highContrast,
|
|
||||||
onChanged: themeProvider.setHighContrast,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 앱 잠금 설정 UI 숨김
|
// 앱 잠금 설정 UI 숨김
|
||||||
// Card(
|
// Card(
|
||||||
// margin: const EdgeInsets.all(16),
|
// margin: const EdgeInsets.all(16),
|
||||||
@@ -392,24 +330,6 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// 데이터 관리
|
|
||||||
const GlassmorphismCard(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
child: const Column(
|
|
||||||
children: [
|
|
||||||
// 데이터 백업 기능 비활성화
|
|
||||||
// ListTile(
|
|
||||||
// title: const Text('데이터 백업'),
|
|
||||||
// subtitle: const Text('구독 데이터를 백업합니다'),
|
|
||||||
// leading: const Icon(Icons.backup),
|
|
||||||
// onTap: () => _backupData(context),
|
|
||||||
// ),
|
|
||||||
// const Divider(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 앱 정보
|
// 앱 정보
|
||||||
GlassmorphismCard(
|
GlassmorphismCard(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -485,11 +405,15 @@ class SettingsScreen extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: 20 + MediaQuery.of(context).padding.bottom, // 하단 여백
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getThemeModeText(AppThemeMode mode) {
|
String _getThemeModeText(AppThemeMode mode) {
|
||||||
|
|||||||
@@ -14,6 +14,10 @@ import '../widgets/common/snackbar/app_snackbar.dart';
|
|||||||
import '../widgets/common/buttons/primary_button.dart';
|
import '../widgets/common/buttons/primary_button.dart';
|
||||||
import '../widgets/common/buttons/secondary_button.dart';
|
import '../widgets/common/buttons/secondary_button.dart';
|
||||||
import '../widgets/common/form_fields/base_text_field.dart';
|
import '../widgets/common/form_fields/base_text_field.dart';
|
||||||
|
import '../providers/category_provider.dart';
|
||||||
|
import '../models/category_model.dart';
|
||||||
|
import '../widgets/common/form_fields/category_selector.dart';
|
||||||
|
import '../widgets/native_ad_widget.dart';
|
||||||
|
|
||||||
class SmsScanScreen extends StatefulWidget {
|
class SmsScanScreen extends StatefulWidget {
|
||||||
const SmsScanScreen({super.key});
|
const SmsScanScreen({super.key});
|
||||||
@@ -35,6 +39,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
|
|
||||||
// 웹사이트 URL 컨트롤러
|
// 웹사이트 URL 컨트롤러
|
||||||
final TextEditingController _websiteUrlController = TextEditingController();
|
final TextEditingController _websiteUrlController = TextEditingController();
|
||||||
|
|
||||||
|
// 선택된 카테고리 ID 저장
|
||||||
|
String? _selectedCategoryId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@@ -113,6 +120,25 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 중복 제거 후 신규 구독이 없는 경우
|
||||||
|
if (filteredSubscriptions.isEmpty) {
|
||||||
|
print('중복 제거 후 신규 구독이 없음');
|
||||||
|
setState(() {
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// 스낵바로 안내 메시지 표시
|
||||||
|
if (mounted) {
|
||||||
|
AppSnackBar.showInfo(
|
||||||
|
context: context,
|
||||||
|
message: '신규 구독 관련 SMS를 찾을 수 없습니다',
|
||||||
|
icon: Icons.search_off_rounded,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_scannedSubscriptions = filteredSubscriptions;
|
_scannedSubscriptions = filteredSubscriptions;
|
||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
@@ -349,7 +375,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
isAutoDetected: true,
|
isAutoDetected: true,
|
||||||
repeatCount: safeRepeatCount,
|
repeatCount: safeRepeatCount,
|
||||||
lastPaymentDate: subscription.lastPaymentDate,
|
lastPaymentDate: subscription.lastPaymentDate,
|
||||||
categoryId: subscription.category,
|
categoryId: _selectedCategoryId ?? subscription.category,
|
||||||
currency: subscription.currency, // 통화 단위 정보 추가
|
currency: subscription.currency, // 통화 단위 정보 추가
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -399,6 +425,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
setState(() {
|
setState(() {
|
||||||
_currentIndex++;
|
_currentIndex++;
|
||||||
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||||
|
_selectedCategoryId = null; // 카테고리 선택 초기화
|
||||||
|
|
||||||
// 모든 구독을 처리했으면 홈 화면으로 이동
|
// 모든 구독을 처리했으면 홈 화면으로 이동
|
||||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||||
@@ -493,15 +520,90 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
return '$count회 결제 감지됨';
|
return '$count회 결제 감지됨';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리 칩 빌드
|
||||||
|
Widget _buildCategoryChip(String? categoryId, CategoryProvider categoryProvider) {
|
||||||
|
final category = categoryId != null
|
||||||
|
? categoryProvider.getCategoryById(categoryId)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// 카테고리가 없으면 기타 카테고리 찾기
|
||||||
|
final defaultCategory = category ?? categoryProvider.categories.firstWhere(
|
||||||
|
(cat) => cat.name == '기타',
|
||||||
|
orElse: () => categoryProvider.categories.first,
|
||||||
|
);
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.navyGray.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 카테고리 아이콘 표시
|
||||||
|
Icon(
|
||||||
|
_getCategoryIcon(defaultCategory),
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
ThemedText(
|
||||||
|
defaultCategory.name,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
forceDark: true,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// 카테고리 아이콘 반환
|
||||||
|
IconData _getCategoryIcon(CategoryModel category) {
|
||||||
|
switch (category.name) {
|
||||||
|
case '음악':
|
||||||
|
return Icons.music_note_rounded;
|
||||||
|
case 'OTT(동영상)':
|
||||||
|
return Icons.movie_filter_rounded;
|
||||||
|
case '저장/클라우드':
|
||||||
|
return Icons.cloud_outlined;
|
||||||
|
case '통신 · 인터넷 · TV':
|
||||||
|
return Icons.wifi_rounded;
|
||||||
|
case '생활/라이프스타일':
|
||||||
|
return Icons.home_outlined;
|
||||||
|
case '쇼핑/이커머스':
|
||||||
|
return Icons.shopping_cart_outlined;
|
||||||
|
case '프로그래밍':
|
||||||
|
return Icons.code_rounded;
|
||||||
|
case '협업/오피스':
|
||||||
|
return Icons.business_center_outlined;
|
||||||
|
case 'AI 서비스':
|
||||||
|
return Icons.smart_toy_outlined;
|
||||||
|
case '기타':
|
||||||
|
default:
|
||||||
|
return Icons.category_outlined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return SingleChildScrollView(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: _isLoading
|
child: Column(
|
||||||
? _buildLoadingState()
|
children: [
|
||||||
: (_scannedSubscriptions.isEmpty
|
_isLoading
|
||||||
? _buildInitialState()
|
? _buildLoadingState()
|
||||||
: _buildSubscriptionState()),
|
: (_scannedSubscriptions.isEmpty
|
||||||
|
? _buildInitialState()
|
||||||
|
: _buildSubscriptionState()),
|
||||||
|
// FloatingNavigationBar를 위한 충분한 하단 여백
|
||||||
|
SizedBox(
|
||||||
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -525,10 +627,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
|
|
||||||
// 초기 상태 UI
|
// 초기 상태 UI
|
||||||
Widget _buildInitialState() {
|
Widget _buildInitialState() {
|
||||||
return Center(
|
return Column(
|
||||||
child: Column(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
// 광고 위젯 추가
|
||||||
children: [
|
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
|
||||||
|
const SizedBox(height: 48),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
if (_errorMessage != null)
|
if (_errorMessage != null)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
@@ -564,8 +672,10 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
height: 56,
|
height: 56,
|
||||||
backgroundColor: AppColors.primaryColor,
|
backgroundColor: AppColors.primaryColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -579,6 +689,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final subscription = _scannedSubscriptions[_currentIndex];
|
final subscription = _scannedSubscriptions[_currentIndex];
|
||||||
|
final categoryProvider = Provider.of<CategoryProvider>(context, listen: false);
|
||||||
|
|
||||||
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
||||||
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
||||||
@@ -588,6 +699,9 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
|
// 광고 위젯 추가
|
||||||
|
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
|
||||||
|
const SizedBox(height: 16),
|
||||||
// 진행 상태 표시
|
// 진행 상태 표시
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: (_currentIndex + 1) / _scannedSubscriptions.length,
|
value: (_currentIndex + 1) / _scannedSubscriptions.length,
|
||||||
@@ -634,7 +748,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
// 금액 및 반복 횟수
|
// 금액 및 결제 주기
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -667,33 +781,6 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const ThemedText(
|
|
||||||
'반복 횟수',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
opacity: 0.7,
|
|
||||||
forceDark: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
ThemedText(
|
|
||||||
_getRepeatCountText(subscription.repeatCount),
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Theme.of(context).colorScheme.secondary,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
|
|
||||||
// 결제 주기
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -714,28 +801,51 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const ThemedText(
|
|
||||||
'결제일',
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
opacity: 0.7,
|
|
||||||
forceDark: true,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
ThemedText(
|
|
||||||
_getNextBillingText(subscription.nextBillingDate),
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
forceDark: true,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 다음 결제일
|
||||||
|
const ThemedText(
|
||||||
|
'다음 결제일',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
opacity: 0.7,
|
||||||
|
forceDark: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
ThemedText(
|
||||||
|
_getNextBillingText(subscription.nextBillingDate),
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
forceDark: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
|
||||||
|
// 카테고리 선택
|
||||||
|
const ThemedText(
|
||||||
|
'카테고리',
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
opacity: 0.7,
|
||||||
|
forceDark: true,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
CategorySelector(
|
||||||
|
categories: categoryProvider.categories,
|
||||||
|
selectedCategoryId: _selectedCategoryId ?? subscription.category,
|
||||||
|
onChanged: (categoryId) {
|
||||||
|
setState(() {
|
||||||
|
_selectedCategoryId = categoryId;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
baseColor: (() {
|
||||||
|
final categoryId = _selectedCategoryId ?? subscription.category;
|
||||||
|
if (categoryId == null) return null;
|
||||||
|
final category = categoryProvider.getCategoryById(categoryId);
|
||||||
|
if (category == null) return null;
|
||||||
|
return Color(int.parse(category.color.replaceFirst('#', '0xFF')));
|
||||||
|
})(),
|
||||||
|
isGlassmorphism: true,
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// 웹사이트 URL 입력 필드 추가/수정
|
// 웹사이트 URL 입력 필드 추가/수정
|
||||||
|
|||||||
@@ -265,7 +265,8 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.shadowBlack,
|
color:
|
||||||
|
AppColors.shadowBlack,
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
blurRadius: 30,
|
blurRadius: 30,
|
||||||
offset: const Offset(0, 10),
|
offset: const Offset(0, 10),
|
||||||
@@ -280,9 +281,8 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
return ShaderMask(
|
return ShaderMask(
|
||||||
blendMode:
|
blendMode:
|
||||||
BlendMode.srcIn,
|
BlendMode.srcIn,
|
||||||
shaderCallback:
|
shaderCallback: (bounds) =>
|
||||||
(bounds) =>
|
const LinearGradient(
|
||||||
const LinearGradient(
|
|
||||||
colors: AppColors
|
colors: AppColors
|
||||||
.blueGradient,
|
.blueGradient,
|
||||||
begin:
|
begin:
|
||||||
@@ -323,11 +323,12 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
'SubManager',
|
'Digital Rent Manager',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 36,
|
fontSize: 36,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
color: AppColors.pureWhite,
|
color: AppColors.primaryColor
|
||||||
|
.withValues(alpha: 0.9),
|
||||||
letterSpacing: 1.2,
|
letterSpacing: 1.2,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -352,7 +353,8 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
'구독 서비스 관리를 더 쉽게',
|
'구독 서비스 관리를 더 쉽게',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: AppColors.pureWhite.withValues(alpha: 0.7),
|
color: AppColors.primaryColor
|
||||||
|
.withValues(alpha: 0.7),
|
||||||
letterSpacing: 0.5,
|
letterSpacing: 0.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -373,11 +375,12 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
height: 60,
|
height: 60,
|
||||||
padding: const EdgeInsets.all(6),
|
padding: const EdgeInsets.all(6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.pureWhite.withValues(alpha: 0.1),
|
color: AppColors.pureWhite
|
||||||
|
.withValues(alpha: 0.1),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: BorderRadius.circular(50),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color:
|
color: AppColors.pureWhite
|
||||||
AppColors.pureWhite.withValues(alpha: 0.2),
|
.withValues(alpha: 0.2),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -401,7 +404,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
child: FadeTransition(
|
child: FadeTransition(
|
||||||
opacity: _fadeAnimation,
|
opacity: _fadeAnimation,
|
||||||
child: Text(
|
child: Text(
|
||||||
'© 2023 CClabs. All rights reserved.',
|
'© 2025 NatureBridgeAI. All rights reserved.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: AppColors.pureWhite.withValues(alpha: 0.6),
|
color: AppColors.pureWhite.withValues(alpha: 0.6),
|
||||||
|
|||||||
@@ -137,10 +137,7 @@ class NotificationService {
|
|||||||
// 각 구독에 대해 알림 재설정
|
// 각 구독에 대해 알림 재설정
|
||||||
for (final subscription in subscriptions) {
|
for (final subscription in subscriptions) {
|
||||||
await schedulePaymentReminder(
|
await schedulePaymentReminder(
|
||||||
id: subscription.id.hashCode,
|
subscription: subscription,
|
||||||
serviceName: subscription.serviceName,
|
|
||||||
amount: subscription.monthlyCost,
|
|
||||||
billingDate: subscription.nextBillingDate,
|
|
||||||
reminderDays: reminderDays,
|
reminderDays: reminderDays,
|
||||||
reminderHour: reminderHour,
|
reminderHour: reminderHour,
|
||||||
reminderMinute: reminderMinute,
|
reminderMinute: reminderMinute,
|
||||||
@@ -421,10 +418,7 @@ class NotificationService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> schedulePaymentReminder({
|
static Future<void> schedulePaymentReminder({
|
||||||
required int id,
|
required SubscriptionModel subscription,
|
||||||
required String serviceName,
|
|
||||||
required double amount,
|
|
||||||
required DateTime billingDate,
|
|
||||||
int reminderDays = 3,
|
int reminderDays = 3,
|
||||||
int reminderHour = 10,
|
int reminderHour = 10,
|
||||||
int reminderMinute = 0,
|
int reminderMinute = 0,
|
||||||
@@ -457,7 +451,7 @@ class NotificationService {
|
|||||||
|
|
||||||
// 기본 알림 예약 (지정된 일수 전)
|
// 기본 알림 예약 (지정된 일수 전)
|
||||||
final scheduledDate =
|
final scheduledDate =
|
||||||
billingDate.subtract(Duration(days: reminderDays)).copyWith(
|
subscription.nextBillingDate.subtract(Duration(days: reminderDays)).copyWith(
|
||||||
hour: reminderHour,
|
hour: reminderHour,
|
||||||
minute: reminderMinute,
|
minute: reminderMinute,
|
||||||
second: 0,
|
second: 0,
|
||||||
@@ -471,10 +465,27 @@ class NotificationService {
|
|||||||
daysText = '내일';
|
daysText = '내일';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 이벤트 종료로 인한 가격 변동 확인
|
||||||
|
String notificationBody;
|
||||||
|
if (subscription.isEventActive &&
|
||||||
|
subscription.eventEndDate != null &&
|
||||||
|
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
||||||
|
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||||
|
// 이벤트가 결제일 전에 종료되는 경우
|
||||||
|
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
||||||
|
final normalPrice = subscription.monthlyCost;
|
||||||
|
notificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.\n'
|
||||||
|
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||||
|
} else {
|
||||||
|
// 일반 알림
|
||||||
|
final currentPrice = subscription.currentPrice;
|
||||||
|
notificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.';
|
||||||
|
}
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
id,
|
subscription.id.hashCode,
|
||||||
'구독 결제 예정 알림',
|
'구독 결제 예정 알림',
|
||||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.',
|
notificationBody,
|
||||||
tz.TZDateTime.from(scheduledDate, location),
|
tz.TZDateTime.from(scheduledDate, location),
|
||||||
const NotificationDetails(
|
const NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
@@ -495,7 +506,7 @@ class NotificationService {
|
|||||||
if (isDailyReminder && reminderDays >= 2) {
|
if (isDailyReminder && reminderDays >= 2) {
|
||||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||||
final dailyDate = billingDate.subtract(Duration(days: i)).copyWith(
|
final dailyDate = subscription.nextBillingDate.subtract(Duration(days: i)).copyWith(
|
||||||
hour: reminderHour,
|
hour: reminderHour,
|
||||||
minute: reminderMinute,
|
minute: reminderMinute,
|
||||||
second: 0,
|
second: 0,
|
||||||
@@ -509,10 +520,25 @@ class NotificationService {
|
|||||||
remainingDaysText = '내일';
|
remainingDaysText = '내일';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 각 날짜에 대한 이벤트 종료 확인
|
||||||
|
String dailyNotificationBody;
|
||||||
|
if (subscription.isEventActive &&
|
||||||
|
subscription.eventEndDate != null &&
|
||||||
|
subscription.eventEndDate!.isBefore(subscription.nextBillingDate) &&
|
||||||
|
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||||
|
final eventPrice = subscription.eventPrice ?? subscription.monthlyCost;
|
||||||
|
final normalPrice = subscription.monthlyCost;
|
||||||
|
dailyNotificationBody = '${subscription.serviceName} 구독료 ${normalPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.\n'
|
||||||
|
'⚠️ 이벤트 종료로 가격이 ${eventPrice.toStringAsFixed(0)}원에서 ${normalPrice.toStringAsFixed(0)}원으로 변경됩니다.';
|
||||||
|
} else {
|
||||||
|
final currentPrice = subscription.currentPrice;
|
||||||
|
dailyNotificationBody = '${subscription.serviceName} 구독료 ${currentPrice.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.';
|
||||||
|
}
|
||||||
|
|
||||||
await _notifications.zonedSchedule(
|
await _notifications.zonedSchedule(
|
||||||
id + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
subscription.id.hashCode + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||||
'구독 결제 예정 알림',
|
'구독 결제 예정 알림',
|
||||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.',
|
dailyNotificationBody,
|
||||||
tz.TZDateTime.from(dailyDate, location),
|
tz.TZDateTime.from(dailyDate, location),
|
||||||
const NotificationDetails(
|
const NotificationDetails(
|
||||||
android: AndroidNotificationDetails(
|
android: AndroidNotificationDetails(
|
||||||
|
|||||||
@@ -49,6 +49,89 @@ class SubscriptionUrlMatcher {
|
|||||||
'타이달': 'https://www.tidal.com',
|
'타이달': 'https://www.tidal.com',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 저장 (클라우드/파일) 서비스
|
||||||
|
static final Map<String, String> storageServices = {
|
||||||
|
'google drive': 'https://www.google.com/drive/',
|
||||||
|
'구글 드라이브': 'https://www.google.com/drive/',
|
||||||
|
'dropbox': 'https://www.dropbox.com',
|
||||||
|
'드롭박스': 'https://www.dropbox.com',
|
||||||
|
'onedrive': 'https://www.onedrive.com',
|
||||||
|
'원드라이브': 'https://www.onedrive.com',
|
||||||
|
'icloud': 'https://www.icloud.com',
|
||||||
|
'아이클라우드': 'https://www.icloud.com',
|
||||||
|
'box': 'https://www.box.com',
|
||||||
|
'박스': 'https://www.box.com',
|
||||||
|
'pcloud': 'https://www.pcloud.com',
|
||||||
|
'mega': 'https://mega.nz',
|
||||||
|
'메가': 'https://mega.nz',
|
||||||
|
'naver mybox': 'https://mybox.naver.com',
|
||||||
|
'네이버 마이박스': 'https://mybox.naver.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 통신 · 인터넷 · TV 서비스
|
||||||
|
static final Map<String, String> telecomServices = {
|
||||||
|
'skt': 'https://www.sktelecom.com',
|
||||||
|
'sk텔레콤': 'https://www.sktelecom.com',
|
||||||
|
'kt': 'https://www.kt.com',
|
||||||
|
'lgu+': 'https://www.lguplus.com',
|
||||||
|
'lg유플러스': 'https://www.lguplus.com',
|
||||||
|
'olleh tv': 'https://www.kt.com/olleh_tv',
|
||||||
|
'올레 tv': 'https://www.kt.com/olleh_tv',
|
||||||
|
'b tv': 'https://www.skbroadband.com',
|
||||||
|
'비티비': 'https://www.skbroadband.com',
|
||||||
|
'u+모바일tv': 'https://www.lguplus.com',
|
||||||
|
'유플러스모바일tv': 'https://www.lguplus.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 생활/라이프스타일 서비스
|
||||||
|
static final Map<String, String> lifestyleServices = {
|
||||||
|
'네이버 플러스': 'https://plus.naver.com',
|
||||||
|
'naver plus': 'https://plus.naver.com',
|
||||||
|
'카카오 구독': 'https://subscribe.kakao.com',
|
||||||
|
'kakao subscribe': 'https://subscribe.kakao.com',
|
||||||
|
'쿠팡 와우': 'https://www.coupang.com/np/coupangplus',
|
||||||
|
'coupang wow': 'https://www.coupang.com/np/coupangplus',
|
||||||
|
'스타벅스 버디': 'https://www.starbucks.co.kr',
|
||||||
|
'starbucks buddy': 'https://www.starbucks.co.kr',
|
||||||
|
'cu 구독': 'https://cu.bgfretail.com',
|
||||||
|
'gs25 구독': 'https://gs25.gsretail.com',
|
||||||
|
'현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription',
|
||||||
|
'lg전자 구독': 'https://www.lge.co.kr',
|
||||||
|
'삼성전자 구독': 'https://www.samsung.com/sec',
|
||||||
|
'다이슨 케어': 'https://www.dyson.co.kr',
|
||||||
|
'dyson care': 'https://www.dyson.co.kr',
|
||||||
|
'마켓컬리': 'https://www.kurly.com',
|
||||||
|
'kurly': 'https://www.kurly.com',
|
||||||
|
'헬로네이처': 'https://www.hellonature.com',
|
||||||
|
'hello nature': 'https://www.hellonature.com',
|
||||||
|
'이마트 트레이더스': 'https://www.emarttraders.co.kr',
|
||||||
|
'홈플러스': 'https://www.homeplus.co.kr',
|
||||||
|
'hellofresh': 'https://www.hellofresh.com',
|
||||||
|
'헬로프레시': 'https://www.hellofresh.com',
|
||||||
|
'bespoke post': 'https://www.bespokepost.com',
|
||||||
|
};
|
||||||
|
|
||||||
|
// 쇼핑/이커머스 서비스
|
||||||
|
static final Map<String, String> shoppingServices = {
|
||||||
|
'amazon prime': 'https://www.amazon.com/prime',
|
||||||
|
'아마존 프라임': 'https://www.amazon.com/prime',
|
||||||
|
'walmart+': 'https://www.walmart.com/plus',
|
||||||
|
'월마트플러스': 'https://www.walmart.com/plus',
|
||||||
|
'chewy': 'https://www.chewy.com',
|
||||||
|
'츄이': 'https://www.chewy.com',
|
||||||
|
'dollar shave club': 'https://www.dollarshaveclub.com',
|
||||||
|
'달러셰이브클럽': 'https://www.dollarshaveclub.com',
|
||||||
|
'instacart': 'https://www.instacart.com',
|
||||||
|
'인스타카트': 'https://www.instacart.com',
|
||||||
|
'shipt': 'https://www.shipt.com',
|
||||||
|
'십트': 'https://www.shipt.com',
|
||||||
|
'grove': 'https://grove.co',
|
||||||
|
'그로브': 'https://grove.co',
|
||||||
|
'cratejoy': 'https://www.cratejoy.com',
|
||||||
|
'shopify': 'https://www.shopify.com',
|
||||||
|
'쇼피파이': 'https://www.shopify.com',
|
||||||
|
};
|
||||||
|
|
||||||
// AI 서비스
|
// AI 서비스
|
||||||
static final Map<String, String> aiServices = {
|
static final Map<String, String> aiServices = {
|
||||||
'chatgpt': 'https://chat.openai.com',
|
'chatgpt': 'https://chat.openai.com',
|
||||||
|
|||||||
@@ -33,23 +33,71 @@ class SubscriptionCategoryHelper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 카테고리 ID가 없거나 카테고리를 찾을 수 없는 경우 서비스 이름 기반 분류
|
// 카테고리 ID가 없거나 카테고리를 찾을 수 없는 경우 서비스 이름 기반 분류
|
||||||
// OTT
|
|
||||||
if (_isInCategory(
|
|
||||||
subscription.serviceName, SubscriptionUrlMatcher.ottServices)) {
|
|
||||||
if (!categorizedSubscriptions.containsKey('OTT 서비스')) {
|
|
||||||
categorizedSubscriptions['OTT 서비스'] = [];
|
|
||||||
}
|
|
||||||
categorizedSubscriptions['OTT 서비스']!.add(subscription);
|
|
||||||
}
|
|
||||||
// 음악
|
// 음악
|
||||||
else if (_isInCategory(
|
if (_isInCategory(
|
||||||
subscription.serviceName, SubscriptionUrlMatcher.musicServices)) {
|
subscription.serviceName, SubscriptionUrlMatcher.musicServices)) {
|
||||||
if (!categorizedSubscriptions.containsKey('음악 서비스')) {
|
if (!categorizedSubscriptions.containsKey('음악')) {
|
||||||
categorizedSubscriptions['음악 서비스'] = [];
|
categorizedSubscriptions['음악'] = [];
|
||||||
}
|
}
|
||||||
categorizedSubscriptions['음악 서비스']!.add(subscription);
|
categorizedSubscriptions['음악']!.add(subscription);
|
||||||
}
|
}
|
||||||
// AI
|
// OTT(동영상)
|
||||||
|
else if (_isInCategory(
|
||||||
|
subscription.serviceName, SubscriptionUrlMatcher.ottServices)) {
|
||||||
|
if (!categorizedSubscriptions.containsKey('OTT(동영상)')) {
|
||||||
|
categorizedSubscriptions['OTT(동영상)'] = [];
|
||||||
|
}
|
||||||
|
categorizedSubscriptions['OTT(동영상)']!.add(subscription);
|
||||||
|
}
|
||||||
|
// 저장/클라우드
|
||||||
|
else if (_isInCategory(
|
||||||
|
subscription.serviceName, SubscriptionUrlMatcher.storageServices)) {
|
||||||
|
if (!categorizedSubscriptions.containsKey('저장/클라우드')) {
|
||||||
|
categorizedSubscriptions['저장/클라우드'] = [];
|
||||||
|
}
|
||||||
|
categorizedSubscriptions['저장/클라우드']!.add(subscription);
|
||||||
|
}
|
||||||
|
// 통신 · 인터넷 · TV
|
||||||
|
else if (_isInCategory(
|
||||||
|
subscription.serviceName, SubscriptionUrlMatcher.telecomServices)) {
|
||||||
|
if (!categorizedSubscriptions.containsKey('통신 · 인터넷 · TV')) {
|
||||||
|
categorizedSubscriptions['통신 · 인터넷 · TV'] = [];
|
||||||
|
}
|
||||||
|
categorizedSubscriptions['통신 · 인터넷 · TV']!.add(subscription);
|
||||||
|
}
|
||||||
|
// 생활/라이프스타일
|
||||||
|
else if (_isInCategory(
|
||||||
|
subscription.serviceName, SubscriptionUrlMatcher.lifestyleServices)) {
|
||||||
|
if (!categorizedSubscriptions.containsKey('생활/라이프스타일')) {
|
||||||
|
categorizedSubscriptions['생활/라이프스타일'] = [];
|
||||||
|
}
|
||||||
|
categorizedSubscriptions['생활/라이프스타일']!.add(subscription);
|
||||||
|
}
|
||||||
|
// 쇼핑/이커머스
|
||||||
|
else if (_isInCategory(
|
||||||
|
subscription.serviceName, SubscriptionUrlMatcher.shoppingServices)) {
|
||||||
|
if (!categorizedSubscriptions.containsKey('쇼핑/이커머스')) {
|
||||||
|
categorizedSubscriptions['쇼핑/이커머스'] = [];
|
||||||
|
}
|
||||||
|
categorizedSubscriptions['쇼핑/이커머스']!.add(subscription);
|
||||||
|
}
|
||||||
|
// 프로그래밍
|
||||||
|
else if (_isInCategory(subscription.serviceName,
|
||||||
|
SubscriptionUrlMatcher.programmingServices)) {
|
||||||
|
if (!categorizedSubscriptions.containsKey('프로그래밍')) {
|
||||||
|
categorizedSubscriptions['프로그래밍'] = [];
|
||||||
|
}
|
||||||
|
categorizedSubscriptions['프로그래밍']!.add(subscription);
|
||||||
|
}
|
||||||
|
// 협업/오피스
|
||||||
|
else if (_isInCategory(
|
||||||
|
subscription.serviceName, SubscriptionUrlMatcher.officeTools)) {
|
||||||
|
if (!categorizedSubscriptions.containsKey('협업/오피스')) {
|
||||||
|
categorizedSubscriptions['협업/오피스'] = [];
|
||||||
|
}
|
||||||
|
categorizedSubscriptions['협업/오피스']!.add(subscription);
|
||||||
|
}
|
||||||
|
// AI 서비스
|
||||||
else if (_isInCategory(
|
else if (_isInCategory(
|
||||||
subscription.serviceName, SubscriptionUrlMatcher.aiServices)) {
|
subscription.serviceName, SubscriptionUrlMatcher.aiServices)) {
|
||||||
if (!categorizedSubscriptions.containsKey('AI 서비스')) {
|
if (!categorizedSubscriptions.containsKey('AI 서비스')) {
|
||||||
@@ -57,29 +105,13 @@ class SubscriptionCategoryHelper {
|
|||||||
}
|
}
|
||||||
categorizedSubscriptions['AI 서비스']!.add(subscription);
|
categorizedSubscriptions['AI 서비스']!.add(subscription);
|
||||||
}
|
}
|
||||||
// 프로그래밍/개발
|
// 기타
|
||||||
else if (_isInCategory(subscription.serviceName,
|
|
||||||
SubscriptionUrlMatcher.programmingServices)) {
|
|
||||||
if (!categorizedSubscriptions.containsKey('프로그래밍/개발 서비스')) {
|
|
||||||
categorizedSubscriptions['프로그래밍/개발 서비스'] = [];
|
|
||||||
}
|
|
||||||
categorizedSubscriptions['프로그래밍/개발 서비스']!.add(subscription);
|
|
||||||
}
|
|
||||||
// 오피스/협업 툴
|
|
||||||
else if (_isInCategory(
|
|
||||||
subscription.serviceName, SubscriptionUrlMatcher.officeTools)) {
|
|
||||||
if (!categorizedSubscriptions.containsKey('오피스/협업 툴')) {
|
|
||||||
categorizedSubscriptions['오피스/협업 툴'] = [];
|
|
||||||
}
|
|
||||||
categorizedSubscriptions['오피스/협업 툴']!.add(subscription);
|
|
||||||
}
|
|
||||||
// 기타 서비스
|
|
||||||
else if (_isInCategory(
|
else if (_isInCategory(
|
||||||
subscription.serviceName, SubscriptionUrlMatcher.otherServices)) {
|
subscription.serviceName, SubscriptionUrlMatcher.otherServices)) {
|
||||||
if (!categorizedSubscriptions.containsKey('기타 서비스')) {
|
if (!categorizedSubscriptions.containsKey('기타')) {
|
||||||
categorizedSubscriptions['기타 서비스'] = [];
|
categorizedSubscriptions['기타'] = [];
|
||||||
}
|
}
|
||||||
categorizedSubscriptions['기타 서비스']!.add(subscription);
|
categorizedSubscriptions['기타']!.add(subscription);
|
||||||
}
|
}
|
||||||
// 미분류된 서비스
|
// 미분류된 서비스
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -40,6 +40,14 @@ class AddSubscriptionAppBar extends StatelessWidget implements PreferredSizeWidg
|
|||||||
),
|
),
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: AppBar(
|
child: AppBar(
|
||||||
|
leading: IconButton(
|
||||||
|
icon: const Icon(
|
||||||
|
Icons.chevron_left,
|
||||||
|
size: 28,
|
||||||
|
color: Color(0xFF1E293B),
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'구독 추가',
|
'구독 추가',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../../controllers/add_subscription_controller.dart';
|
import '../../controllers/add_subscription_controller.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 구독 추가 화면의 이벤트/할인 섹션
|
/// 구독 추가 화면의 이벤트/할인 섹션
|
||||||
class AddSubscriptionEventSection extends StatelessWidget {
|
class AddSubscriptionEventSection extends StatelessWidget {
|
||||||
@@ -37,56 +38,67 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
)),
|
)),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 20),
|
margin: const EdgeInsets.only(bottom: 20),
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(24),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: AppColors.glassCard,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: controller.isEventActive
|
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||||
? const Color(0xFF3B82F6)
|
width: 1,
|
||||||
: Colors.grey.withValues(alpha: 0.2),
|
|
||||||
width: controller.isEventActive ? 2 : 1,
|
|
||||||
),
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.shadowBlack,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
Checkbox(
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: controller.gradientColors[0].withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.local_offer_rounded,
|
||||||
|
color: controller.gradientColors[0],
|
||||||
|
size: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text(
|
||||||
|
'이벤트 가격',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Switch.adaptive(
|
||||||
value: controller.isEventActive,
|
value: controller.isEventActive,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.isEventActive = value ?? false;
|
controller.isEventActive = value;
|
||||||
if (!controller.isEventActive) {
|
if (!controller.isEventActive) {
|
||||||
// 이벤트 비활성화 시 관련 데이터 초기화
|
// 이벤트 비활성화 시 관련 데이터 초기화
|
||||||
controller.eventStartDate = DateTime.now();
|
controller.eventStartDate = null;
|
||||||
controller.eventEndDate = DateTime.now().add(const Duration(days: 30));
|
controller.eventEndDate = null;
|
||||||
controller.eventPriceController.clear();
|
controller.eventPriceController.clear();
|
||||||
} else {
|
|
||||||
// 이벤트 활성화 시 날짜가 null이면 기본값 설정
|
|
||||||
controller.eventStartDate ??= DateTime.now();
|
|
||||||
controller.eventEndDate ??= DateTime.now().add(const Duration(days: 30));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
activeColor: const Color(0xFF3B82F6),
|
activeColor: controller.gradientColors[0],
|
||||||
),
|
|
||||||
const Text(
|
|
||||||
'이벤트/할인 설정',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Color(0xFF1E293B),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Icon(
|
|
||||||
Icons.local_offer,
|
|
||||||
size: 20,
|
|
||||||
color: controller.isEventActive
|
|
||||||
? const Color(0xFF3B82F6)
|
|
||||||
: Colors.grey,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -101,6 +113,39 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
// 이벤트 설명
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.infoColor.withValues(alpha: 0.08),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.infoColor.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline_rounded,
|
||||||
|
color: AppColors.infoColor,
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'할인 또는 프로모션 가격을 설정하세요',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
// 이벤트 기간
|
// 이벤트 기간
|
||||||
DateRangePickerField(
|
DateRangePickerField(
|
||||||
@@ -109,6 +154,10 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
onStartDateSelected: (date) {
|
onStartDateSelected: (date) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.eventStartDate = date;
|
controller.eventStartDate = date;
|
||||||
|
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||||
|
if (date != null && controller.eventEndDate == null) {
|
||||||
|
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
onEndDateSelected: (date) {
|
onEndDateSelected: (date) {
|
||||||
@@ -118,7 +167,7 @@ class AddSubscriptionEventSection extends StatelessWidget {
|
|||||||
},
|
},
|
||||||
startLabel: '시작일',
|
startLabel: '시작일',
|
||||||
endLabel: '종료일',
|
endLabel: '종료일',
|
||||||
primaryColor: const Color(0xFF3B82F6),
|
primaryColor: controller.gradientColors[0],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,11 @@ import '../../providers/category_provider.dart';
|
|||||||
import '../common/form_fields/base_text_field.dart';
|
import '../common/form_fields/base_text_field.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
|
import '../common/form_fields/currency_selector.dart';
|
||||||
|
import '../common/form_fields/billing_cycle_selector.dart';
|
||||||
|
import '../common/form_fields/category_selector.dart';
|
||||||
|
import '../glassmorphism_card.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 구독 추가 화면의 폼 섹션
|
/// 구독 추가 화면의 폼 섹션
|
||||||
class AddSubscriptionForm extends StatelessWidget {
|
class AddSubscriptionForm extends StatelessWidget {
|
||||||
@@ -39,12 +44,8 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
parent: controller.animationController!,
|
parent: controller.animationController!,
|
||||||
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
curve: const Interval(0.2, 1.0, curve: Curves.easeOutCubic),
|
||||||
)),
|
)),
|
||||||
child: Card(
|
child: GlassmorphismCard(
|
||||||
elevation: 1,
|
backgroundColor: AppColors.glassCard,
|
||||||
shadowColor: Colors.black12,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -134,8 +135,9 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_CurrencySelector(
|
CurrencySelector(
|
||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
|
isGlassmorphism: true,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.currency = value;
|
controller.currency = value;
|
||||||
@@ -161,9 +163,10 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_BillingCycleSelector(
|
BillingCycleSelector(
|
||||||
billingCycle: controller.billingCycle,
|
billingCycle: controller.billingCycle,
|
||||||
gradientColors: controller.gradientColors,
|
baseColor: controller.gradientColors[0],
|
||||||
|
isGlassmorphism: true,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.billingCycle = value;
|
controller.billingCycle = value;
|
||||||
@@ -217,10 +220,11 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_CategorySelector(
|
CategorySelector(
|
||||||
categories: categoryProvider.categories,
|
categories: categoryProvider.categories,
|
||||||
selectedCategoryId: controller.selectedCategoryId,
|
selectedCategoryId: controller.selectedCategoryId,
|
||||||
gradientColors: controller.gradientColors,
|
baseColor: controller.gradientColors[0],
|
||||||
|
isGlassmorphism: true,
|
||||||
onChanged: (categoryId) {
|
onChanged: (categoryId) {
|
||||||
setState(() {
|
setState(() {
|
||||||
controller.selectedCategoryId = categoryId;
|
controller.selectedCategoryId = categoryId;
|
||||||
@@ -240,192 +244,3 @@ class AddSubscriptionForm extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 통화 선택기
|
|
||||||
class _CurrencySelector extends StatelessWidget {
|
|
||||||
final String currency;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
|
|
||||||
const _CurrencySelector({
|
|
||||||
required this.currency,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
_CurrencyOption(
|
|
||||||
label: '₩',
|
|
||||||
value: 'KRW',
|
|
||||||
isSelected: currency == 'KRW',
|
|
||||||
onTap: () => onChanged('KRW'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_CurrencyOption(
|
|
||||||
label: '\$',
|
|
||||||
value: 'USD',
|
|
||||||
isSelected: currency == 'USD',
|
|
||||||
onTap: () => onChanged('USD'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 통화 옵션
|
|
||||||
class _CurrencyOption extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _CurrencyOption({
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Expanded(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? const Color(0xFF3B82F6)
|
|
||||||
: Colors.grey.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isSelected ? Colors.white : Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 결제 주기 선택기
|
|
||||||
class _BillingCycleSelector extends StatelessWidget {
|
|
||||||
final String billingCycle;
|
|
||||||
final List<Color> gradientColors;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
|
|
||||||
const _BillingCycleSelector({
|
|
||||||
required this.billingCycle,
|
|
||||||
required this.gradientColors,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final cycles = ['월간', '분기별', '반기별', '연간'];
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: cycles.map((cycle) {
|
|
||||||
final isSelected = billingCycle == cycle;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => onChanged(cycle),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? gradientColors[0]
|
|
||||||
: Colors.grey.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
cycle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: isSelected ? Colors.white : Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 카테고리 선택기
|
|
||||||
class _CategorySelector extends StatelessWidget {
|
|
||||||
final List<dynamic> categories;
|
|
||||||
final String? selectedCategoryId;
|
|
||||||
final List<Color> gradientColors;
|
|
||||||
final ValueChanged<String?> onChanged;
|
|
||||||
|
|
||||||
const _CategorySelector({
|
|
||||||
required this.categories,
|
|
||||||
required this.selectedCategoryId,
|
|
||||||
required this.gradientColors,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: categories.map((category) {
|
|
||||||
final isSelected = selectedCategoryId == category.id;
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onChanged(category.id),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 10,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? gradientColors[0]
|
|
||||||
: Colors.grey.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
category.icon,
|
|
||||||
style: const TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
category.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: isSelected ? Colors.white : Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -52,7 +52,7 @@ class AnalysisBadge extends StatelessWidget {
|
|||||||
color: AppColors.darkNavy,
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 0),
|
||||||
FutureBuilder<String>(
|
FutureBuilder<String>(
|
||||||
future: CurrencyUtil.formatAmount(
|
future: CurrencyUtil.formatAmount(
|
||||||
subscription.monthlyCost,
|
subscription.monthlyCost,
|
||||||
|
|||||||
@@ -42,12 +42,18 @@ class AppNavigator {
|
|||||||
|
|
||||||
/// 구독 상세 화면으로 네비게이션
|
/// 구독 상세 화면으로 네비게이션
|
||||||
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
|
static Future<void> toDetail(BuildContext context, SubscriptionModel subscription) async {
|
||||||
|
print('AppNavigator.toDetail 호출됨: ${subscription.serviceName}');
|
||||||
HapticFeedback.lightImpact();
|
HapticFeedback.lightImpact();
|
||||||
|
|
||||||
await Navigator.of(context).pushNamed(
|
try {
|
||||||
AppRoutes.subscriptionDetail,
|
await Navigator.of(context).pushNamed(
|
||||||
arguments: subscription,
|
AppRoutes.subscriptionDetail,
|
||||||
);
|
arguments: subscription,
|
||||||
|
);
|
||||||
|
print('DetailScreen 네비게이션 성공');
|
||||||
|
} catch (e) {
|
||||||
|
print('DetailScreen 네비게이션 오류: $e');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// SMS 스캔 화면으로 네비게이션
|
/// SMS 스캔 화면으로 네비게이션
|
||||||
|
|||||||
@@ -4,17 +4,19 @@ import 'package:intl/intl.dart';
|
|||||||
/// 카테고리별 구독 그룹의 헤더 위젯
|
/// 카테고리별 구독 그룹의 헤더 위젯
|
||||||
///
|
///
|
||||||
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
|
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
|
||||||
/// 참고: 여러 통화 단위가 혼합된 경우 간단히 원화 표시 형식을 사용합니다.
|
/// 통화별로 구분하여 표시하며, 혼재된 경우 각각 표시합니다.
|
||||||
class CategoryHeaderWidget extends StatelessWidget {
|
class CategoryHeaderWidget extends StatelessWidget {
|
||||||
final String categoryName;
|
final String categoryName;
|
||||||
final int subscriptionCount;
|
final int subscriptionCount;
|
||||||
final double totalCost;
|
final double totalCostUSD;
|
||||||
|
final double totalCostKRW;
|
||||||
|
|
||||||
const CategoryHeaderWidget({
|
const CategoryHeaderWidget({
|
||||||
Key? key,
|
Key? key,
|
||||||
required this.categoryName,
|
required this.categoryName,
|
||||||
required this.subscriptionCount,
|
required this.subscriptionCount,
|
||||||
required this.totalCost,
|
required this.totalCostUSD,
|
||||||
|
required this.totalCostKRW,
|
||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -36,7 +38,7 @@ class CategoryHeaderWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0).format(totalCost)}',
|
_buildCostDisplay(),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -55,4 +57,44 @@ class CategoryHeaderWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 통화별 합계를 표시하는 문자열을 생성합니다.
|
||||||
|
String _buildCostDisplay() {
|
||||||
|
final parts = <String>[];
|
||||||
|
|
||||||
|
// 개수는 항상 표시
|
||||||
|
parts.add('$subscriptionCount개');
|
||||||
|
|
||||||
|
// 통화 부분을 별도로 처리
|
||||||
|
final currencyParts = <String>[];
|
||||||
|
|
||||||
|
// 달러가 있는 경우
|
||||||
|
if (totalCostUSD > 0) {
|
||||||
|
final formatter = NumberFormat.currency(
|
||||||
|
locale: 'en_US',
|
||||||
|
symbol: '\$',
|
||||||
|
decimalDigits: 2,
|
||||||
|
);
|
||||||
|
currencyParts.add(formatter.format(totalCostUSD));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 원화가 있는 경우
|
||||||
|
if (totalCostKRW > 0) {
|
||||||
|
final formatter = NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
);
|
||||||
|
currencyParts.add(formatter.format(totalCostKRW));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통화가 하나 이상 있는 경우
|
||||||
|
if (currencyParts.isNotEmpty) {
|
||||||
|
// 통화가 여러 개인 경우 + 로 연결, 하나인 경우 그대로
|
||||||
|
final currencyDisplay = currencyParts.join(' + ');
|
||||||
|
parts.add(currencyDisplay);
|
||||||
|
}
|
||||||
|
|
||||||
|
return parts.join(' · ');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,7 +103,7 @@ class BaseTextField extends StatelessWidget {
|
|||||||
prefixText: prefixText,
|
prefixText: prefixText,
|
||||||
suffixIcon: suffixIcon,
|
suffixIcon: suffixIcon,
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: fillColor ?? AppColors.glassBackground,
|
fillColor: fillColor ?? AppColors.surfaceColorAlt,
|
||||||
contentPadding: contentPadding ?? const EdgeInsets.all(16),
|
contentPadding: contentPadding ?? const EdgeInsets.all(16),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
@@ -119,8 +119,8 @@ class BaseTextField extends StatelessWidget {
|
|||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
borderSide: BorderSide(
|
borderSide: BorderSide(
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.borderColor.withValues(alpha: 0.7),
|
||||||
width: 1,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
disabledBorder: OutlineInputBorder(
|
disabledBorder: OutlineInputBorder(
|
||||||
|
|||||||
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> {
|
class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
||||||
late TextEditingController _formattedController;
|
late FocusNode _focusNode;
|
||||||
|
bool _isFormatted = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_formattedController = TextEditingController();
|
_focusNode = widget.focusNode ?? FocusNode();
|
||||||
_updateFormattedValue();
|
_focusNode.addListener(_onFocusChanged);
|
||||||
widget.controller.addListener(_onControllerChanged);
|
|
||||||
|
// 초기값이 있으면 포맷팅 적용
|
||||||
|
if (widget.controller.text.isNotEmpty) {
|
||||||
|
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
|
||||||
|
if (value != null) {
|
||||||
|
widget.controller.text = _formatCurrency(value);
|
||||||
|
_isFormatted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
widget.controller.removeListener(_onControllerChanged);
|
if (widget.focusNode == null) {
|
||||||
_formattedController.dispose();
|
_focusNode.dispose();
|
||||||
|
} else {
|
||||||
|
_focusNode.removeListener(_onFocusChanged);
|
||||||
|
}
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onControllerChanged() {
|
void _onFocusChanged() {
|
||||||
_updateFormattedValue();
|
if (!_focusNode.hasFocus && widget.controller.text.isNotEmpty) {
|
||||||
}
|
// 포커스를 잃었을 때 포맷팅 적용
|
||||||
|
final value = _parseValue(widget.controller.text);
|
||||||
void _updateFormattedValue() {
|
if (value != null) {
|
||||||
final value = double.tryParse(widget.controller.text.replaceAll(',', ''));
|
setState(() {
|
||||||
if (value != null) {
|
widget.controller.text = _formatCurrency(value);
|
||||||
_formattedController.text = _formatCurrency(value);
|
_isFormatted = true;
|
||||||
} else {
|
});
|
||||||
_formattedController.text = '';
|
}
|
||||||
|
} else if (_focusNode.hasFocus && _isFormatted) {
|
||||||
|
// 포커스를 받았을 때 포맷팅 제거
|
||||||
|
final value = _parseValue(widget.controller.text);
|
||||||
|
if (value != null) {
|
||||||
|
setState(() {
|
||||||
|
if (widget.currency == 'KRW') {
|
||||||
|
widget.controller.text = value.toInt().toString();
|
||||||
|
} else {
|
||||||
|
widget.controller.text = value.toString();
|
||||||
|
}
|
||||||
|
_isFormatted = false;
|
||||||
|
});
|
||||||
|
// 커서를 끝으로 이동
|
||||||
|
widget.controller.selection = TextSelection.fromPosition(
|
||||||
|
TextPosition(offset: widget.controller.text.length),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,38 +119,42 @@ class _CurrencyInputFieldState extends State<CurrencyInputField> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return BaseTextField(
|
return BaseTextField(
|
||||||
controller: _formattedController,
|
controller: widget.controller,
|
||||||
focusNode: widget.focusNode,
|
focusNode: _focusNode,
|
||||||
label: widget.label,
|
label: widget.label,
|
||||||
hintText: widget.hintText ?? _defaultHintText,
|
hintText: widget.hintText ?? _defaultHintText,
|
||||||
textInputAction: widget.textInputAction,
|
textInputAction: widget.textInputAction,
|
||||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||||
inputFormatters: [
|
inputFormatters: [
|
||||||
FilteringTextInputFormatter.allow(RegExp(r'[\d,.]')),
|
FilteringTextInputFormatter.allow(
|
||||||
|
widget.currency == 'KRW'
|
||||||
|
? RegExp(r'[0-9]')
|
||||||
|
: RegExp(r'[0-9.]')
|
||||||
|
),
|
||||||
|
if (widget.currency == 'USD')
|
||||||
|
// USD의 경우 소수점 이하 2자리까지만 허용
|
||||||
|
TextInputFormatter.withFunction((oldValue, newValue) {
|
||||||
|
final text = newValue.text;
|
||||||
|
if (text.isEmpty) return newValue;
|
||||||
|
|
||||||
|
final parts = text.split('.');
|
||||||
|
if (parts.length > 2) {
|
||||||
|
// 소수점이 2개 이상인 경우 거부
|
||||||
|
return oldValue;
|
||||||
|
}
|
||||||
|
if (parts.length == 2 && parts[1].length > 2) {
|
||||||
|
// 소수점 이하가 2자리를 초과하는 경우 거부
|
||||||
|
return oldValue;
|
||||||
|
}
|
||||||
|
return newValue;
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
prefixText: _prefixText,
|
prefixText: _prefixText,
|
||||||
onEditingComplete: widget.onEditingComplete,
|
onEditingComplete: widget.onEditingComplete,
|
||||||
enabled: widget.enabled,
|
enabled: widget.enabled,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
final parsedValue = _parseValue(value);
|
final parsedValue = _parseValue(value);
|
||||||
if (parsedValue != null) {
|
widget.onChanged?.call(parsedValue);
|
||||||
widget.controller.text = parsedValue.toString();
|
|
||||||
widget.onChanged?.call(parsedValue);
|
|
||||||
} else {
|
|
||||||
widget.controller.text = '';
|
|
||||||
widget.onChanged?.call(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 포맷팅 업데이트
|
|
||||||
if (parsedValue != null) {
|
|
||||||
final formattedText = _formatCurrency(parsedValue);
|
|
||||||
if (formattedText != value) {
|
|
||||||
_formattedController.value = TextEditingValue(
|
|
||||||
text: formattedText,
|
|
||||||
selection: TextSelection.collapsed(offset: formattedText.length),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
validator: widget.validator ?? (value) {
|
validator: widget.validator ?? (value) {
|
||||||
if (value == null || value.isEmpty) {
|
if (value == null || value.isEmpty) {
|
||||||
|
|||||||
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:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import '../../../theme/app_colors.dart';
|
||||||
|
|
||||||
/// 날짜 선택 필드 위젯
|
/// 날짜 선택 필드 위젯
|
||||||
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
|
/// 탭하면 날짜 선택기가 표시되며, 선택된 날짜를 보기 좋은 형식으로 표시합니다.
|
||||||
@@ -47,7 +48,7 @@ class DatePickerField extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: theme.colorScheme.onSurface,
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
@@ -82,10 +83,11 @@ class DatePickerField extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: contentPadding ?? const EdgeInsets.all(16),
|
padding: contentPadding ?? const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: backgroundColor ?? Colors.white,
|
color: backgroundColor ?? AppColors.surfaceColorAlt,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.transparent,
|
color: AppColors.borderColor.withValues(alpha: 0.7),
|
||||||
|
width: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -96,8 +98,8 @@ class DatePickerField extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: enabled
|
color: enabled
|
||||||
? theme.colorScheme.onSurface
|
? AppColors.textPrimary
|
||||||
: theme.colorScheme.onSurface.withValues(alpha: 0.5),
|
: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -105,8 +107,8 @@ class DatePickerField extends StatelessWidget {
|
|||||||
Icons.calendar_today,
|
Icons.calendar_today,
|
||||||
size: 20,
|
size: 20,
|
||||||
color: enabled
|
color: enabled
|
||||||
? theme.colorScheme.onSurface.withValues(alpha: 0.6)
|
? AppColors.navyGray
|
||||||
: theme.colorScheme.onSurface.withValues(alpha: 0.3),
|
: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -227,8 +229,12 @@ class _DateRangeItem extends StatelessWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: AppColors.surfaceColorAlt,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.borderColor.withValues(alpha: 0.7),
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -237,7 +243,7 @@ class _DateRangeItem extends StatelessWidget {
|
|||||||
label,
|
label,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -249,8 +255,8 @@ class _DateRangeItem extends StatelessWidget {
|
|||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: date != null
|
color: date != null
|
||||||
? theme.colorScheme.onSurface
|
? AppColors.textPrimary
|
||||||
: theme.colorScheme.onSurface.withValues(alpha: 0.4),
|
: AppColors.textMuted,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -161,10 +161,10 @@ class AppSnackBar {
|
|||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: showAtTop
|
margin: showAtTop
|
||||||
? EdgeInsets.only(
|
? EdgeInsets.only(
|
||||||
top: MediaQuery.of(context).padding.top + 16,
|
top: MediaQuery.of(context).padding.top,
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: MediaQuery.of(context).size.height - 120,
|
bottom: MediaQuery.of(context).size.height - 100,
|
||||||
)
|
)
|
||||||
: const EdgeInsets.all(16),
|
: const EdgeInsets.all(16),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@@ -222,10 +222,10 @@ class AppSnackBar {
|
|||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
margin: showAtTop
|
margin: showAtTop
|
||||||
? EdgeInsets.only(
|
? EdgeInsets.only(
|
||||||
top: MediaQuery.of(context).padding.top + 16,
|
top: MediaQuery.of(context).padding.top,
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: MediaQuery.of(context).size.height - 120,
|
bottom: MediaQuery.of(context).size.height - 100,
|
||||||
)
|
)
|
||||||
: const EdgeInsets.all(16),
|
: const EdgeInsets.all(16),
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../../controllers/detail_screen_controller.dart';
|
import '../../controllers/detail_screen_controller.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
|
|
||||||
@@ -19,9 +21,11 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final baseColor = controller.getCardColor();
|
return Consumer<DetailScreenController>(
|
||||||
|
builder: (context, controller, child) {
|
||||||
|
final baseColor = controller.getCardColor();
|
||||||
|
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: fadeAnimation,
|
opacity: fadeAnimation,
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
position: Tween<Offset>(
|
position: Tween<Offset>(
|
||||||
@@ -31,11 +35,21 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
parent: controller.animationController!,
|
parent: controller.animationController!,
|
||||||
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
|
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
|
||||||
)),
|
)),
|
||||||
child: Card(
|
child: Container(
|
||||||
elevation: 1,
|
decoration: BoxDecoration(
|
||||||
shadowColor: Colors.black12,
|
color: AppColors.glassCard,
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.shadowBlack,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -66,6 +80,7 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -93,14 +108,18 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.withValues(alpha: 0.1),
|
color: AppColors.infoColor.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.infoColor.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.info_outline_rounded,
|
Icons.info_outline_rounded,
|
||||||
color: Colors.blue[700],
|
color: AppColors.infoColor,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -109,7 +128,8 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
'할인 또는 프로모션 가격을 설정하세요',
|
'할인 또는 프로모션 가격을 설정하세요',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Colors.blue[700],
|
color: AppColors.darkNavy,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -123,6 +143,10 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
endDate: controller.eventEndDate,
|
endDate: controller.eventEndDate,
|
||||||
onStartDateSelected: (date) {
|
onStartDateSelected: (date) {
|
||||||
controller.eventStartDate = date;
|
controller.eventStartDate = date;
|
||||||
|
// 시작일 설정 시 종료일이 없으면 자동으로 1달 후로 설정
|
||||||
|
if (date != null && controller.eventEndDate == null) {
|
||||||
|
controller.eventEndDate = date.add(const Duration(days: 30));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onEndDateSelected: (date) {
|
onEndDateSelected: (date) {
|
||||||
controller.eventEndDate = date;
|
controller.eventEndDate = date;
|
||||||
@@ -138,6 +162,18 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
label: '이벤트 가격',
|
label: '이벤트 가격',
|
||||||
hintText: '할인된 가격을 입력하세요',
|
hintText: '할인된 가격을 입력하세요',
|
||||||
|
validator: controller.isEventActive
|
||||||
|
? (value) {
|
||||||
|
if (value == null || value.isEmpty) {
|
||||||
|
return '이벤트 가격을 입력해주세요';
|
||||||
|
}
|
||||||
|
final price = double.tryParse(value.replaceAll(',', ''));
|
||||||
|
if (price == null || price <= 0) {
|
||||||
|
return '올바른 가격을 입력해주세요';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
// 할인율 표시
|
// 할인율 표시
|
||||||
@@ -156,6 +192,8 @@ class DetailEventSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -209,7 +247,7 @@ class _DiscountBadge extends StatelessWidget {
|
|||||||
? '₩${discountAmount.toInt().toString()}원 절약'
|
? '₩${discountAmount.toInt().toString()}원 절약'
|
||||||
: '\$${discountAmount.toStringAsFixed(2)} 절약',
|
: '\$${discountAmount.toStringAsFixed(2)} 절약',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: Colors.green[700],
|
color: const Color(0xFF15803D),
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -2,9 +2,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../../controllers/detail_screen_controller.dart';
|
import '../../controllers/detail_screen_controller.dart';
|
||||||
import '../../providers/category_provider.dart';
|
import '../../providers/category_provider.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
import '../common/form_fields/base_text_field.dart';
|
import '../common/form_fields/base_text_field.dart';
|
||||||
import '../common/form_fields/currency_input_field.dart';
|
import '../common/form_fields/currency_input_field.dart';
|
||||||
import '../common/form_fields/date_picker_field.dart';
|
import '../common/form_fields/date_picker_field.dart';
|
||||||
|
import '../common/form_fields/currency_selector.dart';
|
||||||
|
import '../common/form_fields/billing_cycle_selector.dart';
|
||||||
|
import '../common/form_fields/category_selector.dart';
|
||||||
|
|
||||||
/// 상세 화면 폼 섹션
|
/// 상세 화면 폼 섹션
|
||||||
/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다.
|
/// 구독 정보를 편집할 수 있는 폼 필드들을 포함합니다.
|
||||||
@@ -22,9 +26,11 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final baseColor = controller.getCardColor();
|
return Consumer<DetailScreenController>(
|
||||||
|
builder: (context, controller, child) {
|
||||||
|
final baseColor = controller.getCardColor();
|
||||||
|
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: fadeAnimation,
|
opacity: fadeAnimation,
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
position: Tween<Offset>(
|
position: Tween<Offset>(
|
||||||
@@ -34,11 +40,21 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
parent: controller.animationController!,
|
parent: controller.animationController!,
|
||||||
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
|
curve: const Interval(0.3, 1.0, curve: Curves.easeOutCubic),
|
||||||
)),
|
)),
|
||||||
child: Card(
|
child: Container(
|
||||||
elevation: 1,
|
decoration: BoxDecoration(
|
||||||
shadowColor: Colors.black12,
|
color: AppColors.glassCard,
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.shadowBlack,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -86,11 +102,13 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_CurrencySelector(
|
CurrencySelector(
|
||||||
currency: controller.currency,
|
currency: controller.currency,
|
||||||
|
isGlassmorphism: true,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.currency = value;
|
controller.currency = value;
|
||||||
// 통화 변경시 금액 포맷 업데이트
|
// 통화 변경시 금액 포맷 업데이트
|
||||||
@@ -121,12 +139,14 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_BillingCycleSelector(
|
BillingCycleSelector(
|
||||||
billingCycle: controller.billingCycle,
|
billingCycle: controller.billingCycle,
|
||||||
baseColor: baseColor,
|
baseColor: baseColor,
|
||||||
|
isGlassmorphism: true,
|
||||||
onChanged: (value) {
|
onChanged: (value) {
|
||||||
controller.billingCycle = value;
|
controller.billingCycle = value;
|
||||||
},
|
},
|
||||||
@@ -159,13 +179,15 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_CategorySelector(
|
CategorySelector(
|
||||||
categories: categoryProvider.categories,
|
categories: categoryProvider.categories,
|
||||||
selectedCategoryId: controller.selectedCategoryId,
|
selectedCategoryId: controller.selectedCategoryId,
|
||||||
baseColor: baseColor,
|
baseColor: baseColor,
|
||||||
|
isGlassmorphism: true,
|
||||||
onChanged: (categoryId) {
|
onChanged: (categoryId) {
|
||||||
controller.selectedCategoryId = categoryId;
|
controller.selectedCategoryId = categoryId;
|
||||||
},
|
},
|
||||||
@@ -180,191 +202,8 @@ class DetailFormSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
}
|
|
||||||
|
|
||||||
/// 통화 선택기
|
|
||||||
class _CurrencySelector extends StatelessWidget {
|
|
||||||
final String currency;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
|
|
||||||
const _CurrencySelector({
|
|
||||||
required this.currency,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
_CurrencyOption(
|
|
||||||
label: '₩',
|
|
||||||
value: 'KRW',
|
|
||||||
isSelected: currency == 'KRW',
|
|
||||||
onTap: () => onChanged('KRW'),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
_CurrencyOption(
|
|
||||||
label: '\$',
|
|
||||||
value: 'USD',
|
|
||||||
isSelected: currency == 'USD',
|
|
||||||
onTap: () => onChanged('USD'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 통화 옵션
|
|
||||||
class _CurrencyOption extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final bool isSelected;
|
|
||||||
final VoidCallback onTap;
|
|
||||||
|
|
||||||
const _CurrencyOption({
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
required this.isSelected,
|
|
||||||
required this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Expanded(
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
|
||||||
? Theme.of(context).primaryColor
|
|
||||||
: Colors.grey.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: isSelected ? Colors.white : Colors.grey[600],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 결제 주기 선택기
|
|
||||||
class _BillingCycleSelector extends StatelessWidget {
|
|
||||||
final String billingCycle;
|
|
||||||
final Color baseColor;
|
|
||||||
final ValueChanged<String> onChanged;
|
|
||||||
|
|
||||||
const _BillingCycleSelector({
|
|
||||||
required this.billingCycle,
|
|
||||||
required this.baseColor,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final cycles = ['매월', '분기별', '반기별', '매년'];
|
|
||||||
|
|
||||||
return SingleChildScrollView(
|
|
||||||
scrollDirection: Axis.horizontal,
|
|
||||||
child: Row(
|
|
||||||
children: cycles.map((cycle) {
|
|
||||||
final isSelected = billingCycle == cycle;
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 8),
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () => onChanged(cycle),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 20,
|
|
||||||
vertical: 12,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
cycle,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: isSelected ? Colors.white : Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 카테고리 선택기
|
|
||||||
class _CategorySelector extends StatelessWidget {
|
|
||||||
final List<dynamic> categories;
|
|
||||||
final String? selectedCategoryId;
|
|
||||||
final Color baseColor;
|
|
||||||
final ValueChanged<String?> onChanged;
|
|
||||||
|
|
||||||
const _CategorySelector({
|
|
||||||
required this.categories,
|
|
||||||
required this.selectedCategoryId,
|
|
||||||
required this.baseColor,
|
|
||||||
required this.onChanged,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Wrap(
|
|
||||||
spacing: 8,
|
|
||||||
runSpacing: 8,
|
|
||||||
children: categories.map((category) {
|
|
||||||
final isSelected = selectedCategoryId == category.id;
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => onChanged(category.id),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 16,
|
|
||||||
vertical: 10,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected ? baseColor : Colors.grey.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
category.icon,
|
|
||||||
style: const TextStyle(fontSize: 16),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 6),
|
|
||||||
Text(
|
|
||||||
category.name,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: isSelected ? Colors.white : Colors.grey[700],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}).toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../../models/subscription_model.dart';
|
import '../../models/subscription_model.dart';
|
||||||
import '../../controllers/detail_screen_controller.dart';
|
import '../../controllers/detail_screen_controller.dart';
|
||||||
@@ -24,10 +25,12 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final baseColor = controller.getCardColor();
|
return Consumer<DetailScreenController>(
|
||||||
final gradient = controller.getGradient(baseColor);
|
builder: (context, controller, child) {
|
||||||
|
final baseColor = controller.getCardColor();
|
||||||
|
final gradient = controller.getGradient(baseColor);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: 320,
|
height: 320,
|
||||||
decoration: BoxDecoration(gradient: gradient),
|
decoration: BoxDecoration(gradient: gradient),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -118,8 +121,8 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
child: WebsiteIcon(
|
child: WebsiteIcon(
|
||||||
url: subscription.websiteUrl,
|
url: controller.websiteUrlController.text,
|
||||||
serviceName: subscription.serviceName,
|
serviceName: controller.serviceNameController.text,
|
||||||
size: 48,
|
size: 48,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -131,7 +134,7 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
subscription.serviceName,
|
controller.serviceNameController.text,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
@@ -148,7 +151,7 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
Text(
|
Text(
|
||||||
'${subscription.billingCycle} 결제',
|
'${controller.billingCycle} 결제',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@@ -174,20 +177,22 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
_InfoColumn(
|
_InfoColumn(
|
||||||
label: '다음 결제일',
|
label: '다음 결제일',
|
||||||
value: DateFormat('yyyy년 MM월 dd일')
|
value: DateFormat('yyyy년 MM월 dd일')
|
||||||
.format(subscription.nextBillingDate),
|
.format(controller.nextBillingDate),
|
||||||
),
|
),
|
||||||
_InfoColumn(
|
_InfoColumn(
|
||||||
label: '월 지출',
|
label: '월 지출',
|
||||||
value: NumberFormat.currency(
|
value: NumberFormat.currency(
|
||||||
locale: subscription.currency == 'KRW'
|
locale: controller.currency == 'KRW'
|
||||||
? 'ko_KR'
|
? 'ko_KR'
|
||||||
: 'en_US',
|
: 'en_US',
|
||||||
symbol: subscription.currency == 'KRW'
|
symbol: controller.currency == 'KRW'
|
||||||
? '₩'
|
? '₩'
|
||||||
: '\$',
|
: '\$',
|
||||||
decimalDigits:
|
decimalDigits:
|
||||||
subscription.currency == 'KRW' ? 0 : 2,
|
controller.currency == 'KRW' ? 0 : 2,
|
||||||
).format(subscription.monthlyCost),
|
).format(double.tryParse(
|
||||||
|
controller.monthlyCostController.text.replaceAll(',', '')
|
||||||
|
) ?? 0),
|
||||||
alignment: CrossAxisAlignment.end,
|
alignment: CrossAxisAlignment.end,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -204,6 +209,8 @@ class DetailHeaderSection extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../controllers/detail_screen_controller.dart';
|
import '../../controllers/detail_screen_controller.dart';
|
||||||
|
import '../../theme/app_colors.dart';
|
||||||
import '../common/form_fields/base_text_field.dart';
|
import '../common/form_fields/base_text_field.dart';
|
||||||
import '../common/buttons/secondary_button.dart';
|
import '../common/buttons/secondary_button.dart';
|
||||||
|
|
||||||
@@ -31,11 +32,21 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
parent: controller.animationController!,
|
parent: controller.animationController!,
|
||||||
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
|
curve: const Interval(0.4, 1.0, curve: Curves.easeOutCubic),
|
||||||
)),
|
)),
|
||||||
child: Card(
|
child: Container(
|
||||||
elevation: 1,
|
decoration: BoxDecoration(
|
||||||
shadowColor: Colors.black12,
|
color: AppColors.glassCard,
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder.withValues(alpha: 0.1),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.shadowBlack,
|
||||||
|
blurRadius: 10,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.all(24),
|
||||||
@@ -63,6 +74,7 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -78,7 +90,7 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
keyboardType: TextInputType.url,
|
keyboardType: TextInputType.url,
|
||||||
prefixIcon: Icon(
|
prefixIcon: Icon(
|
||||||
Icons.link_rounded,
|
Icons.link_rounded,
|
||||||
color: Colors.grey[600],
|
color: AppColors.navyGray,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -89,10 +101,10 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.orange.withValues(alpha: 0.1),
|
color: AppColors.warningColor.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: Colors.orange.withValues(alpha: 0.3),
|
color: AppColors.warningColor.withValues(alpha: 0.4),
|
||||||
width: 1,
|
width: 1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -103,7 +115,7 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.info_outline_rounded,
|
Icons.info_outline_rounded,
|
||||||
color: Colors.orange[700],
|
color: AppColors.warningColor,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -112,7 +124,7 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.orange[700],
|
color: AppColors.darkNavy,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -122,7 +134,8 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.',
|
'이 서비스를 해지하려면 아래 링크를 통해 해지 페이지로 이동하세요.',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Colors.grey[700],
|
color: AppColors.darkNavy,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
height: 1.5,
|
height: 1.5,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -131,7 +144,7 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
text: '해지 페이지로 이동',
|
text: '해지 페이지로 이동',
|
||||||
icon: Icons.open_in_new_rounded,
|
icon: Icons.open_in_new_rounded,
|
||||||
onPressed: controller.openCancellationPage,
|
onPressed: controller.openCancellationPage,
|
||||||
color: Colors.orange[700],
|
color: AppColors.warningColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -144,14 +157,18 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.blue.withValues(alpha: 0.1),
|
color: AppColors.infoColor.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.infoColor.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
Icons.auto_fix_high_rounded,
|
Icons.auto_fix_high_rounded,
|
||||||
color: Colors.blue[700],
|
color: AppColors.infoColor,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@@ -160,7 +177,8 @@ class DetailUrlSection extends StatelessWidget {
|
|||||||
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다',
|
'URL이 비어있으면 서비스명을 기반으로 자동 매칭됩니다',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
color: Colors.blue[700],
|
color: AppColors.darkNavy,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -68,23 +68,12 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
|||||||
bottom: 20,
|
bottom: 20,
|
||||||
left: 20,
|
left: 20,
|
||||||
right: 20,
|
right: 20,
|
||||||
|
height: 88,
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: Offset(0, 100 * (1 - _animation.value)),
|
offset: Offset(0, 100 * (1 - _animation.value)),
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: _animation.value,
|
opacity: _animation.value,
|
||||||
child: Stack(
|
child: GlassmorphismCard(
|
||||||
children: [
|
|
||||||
// 차단 레이어 - 크기 명시
|
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 글래스모피즘 레이어
|
|
||||||
GlassmorphismCard(
|
|
||||||
padding:
|
padding:
|
||||||
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
const EdgeInsets.symmetric(vertical: 8, horizontal: 8),
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
@@ -123,8 +112,6 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -174,10 +174,17 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque, // translucent에서 opaque로 변경하여 이벤트 충돌 방지
|
||||||
onTapDown: _handleTapDown,
|
onTapDown: _handleTapDown,
|
||||||
onTapUp: _handleTapUp,
|
onTapUp: (details) {
|
||||||
|
_handleTapUp(details);
|
||||||
|
// onTap 콜백 실행
|
||||||
|
if (widget.onTap != null) {
|
||||||
|
print('[AnimatedGlassmorphismCard] onTap 콜백 실행');
|
||||||
|
widget.onTap!();
|
||||||
|
}
|
||||||
|
},
|
||||||
onTapCancel: _handleTapCancel,
|
onTapCancel: _handleTapCancel,
|
||||||
onTap: widget.onTap,
|
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _controller,
|
animation: _controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
@@ -191,6 +198,7 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
|||||||
borderRadius: widget.borderRadius,
|
borderRadius: widget.borderRadius,
|
||||||
blur: _blurAnimation.value,
|
blur: _blurAnimation.value,
|
||||||
opacity: widget.opacity,
|
opacity: widget.opacity,
|
||||||
|
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../widgets/native_ad_widget.dart';
|
|||||||
import '../widgets/main_summary_card.dart';
|
import '../widgets/main_summary_card.dart';
|
||||||
import '../widgets/subscription_list_widget.dart';
|
import '../widgets/subscription_list_widget.dart';
|
||||||
import '../widgets/empty_state_widget.dart';
|
import '../widgets/empty_state_widget.dart';
|
||||||
import '../widgets/glassmorphic_app_bar.dart';
|
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
|
||||||
class HomeContent extends StatelessWidget {
|
class HomeContent extends StatelessWidget {
|
||||||
@@ -67,10 +66,10 @@ class HomeContent extends StatelessWidget {
|
|||||||
controller: scrollController,
|
controller: scrollController,
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
const GlassmorphicSliverAppBar(
|
SliverToBoxAdapter(
|
||||||
title: '홈',
|
child: SizedBox(
|
||||||
pinned: true,
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||||
expandedHeight: kToolbarHeight,
|
),
|
||||||
),
|
),
|
||||||
const SliverToBoxAdapter(
|
const SliverToBoxAdapter(
|
||||||
child: NativeAdWidget(key: ValueKey('home_ad')),
|
child: NativeAdWidget(key: ValueKey('home_ad')),
|
||||||
@@ -105,7 +104,9 @@ class HomeContent extends StatelessWidget {
|
|||||||
parent: slideController, curve: Curves.easeOutCubic)),
|
parent: slideController, curve: Curves.easeOutCubic)),
|
||||||
child: Text(
|
child: Text(
|
||||||
'나의 구독 서비스',
|
'나의 구독 서비스',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge?.copyWith(
|
||||||
|
color: AppColors.darkNavy,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SlideTransition(
|
SlideTransition(
|
||||||
@@ -143,7 +144,7 @@ class HomeContent extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 100 + MediaQuery.of(context).padding.bottom,
|
height: 120 + MediaQuery.of(context).padding.bottom,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -37,185 +37,196 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||||
child: GlassmorphismCard(
|
child: GlassmorphismCard(
|
||||||
borderRadius: 24,
|
borderRadius: 24,
|
||||||
blur: 15,
|
blur: 15,
|
||||||
backgroundColor: AppColors.glassCard,
|
backgroundColor: AppColors.glassCard,
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: AppColors.mainGradient.map((color) => color.withValues(alpha: 0.2)).toList(),
|
colors: AppColors.mainGradient
|
||||||
|
.map((color) => color.withValues(alpha: 0.2))
|
||||||
|
.toList(),
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder,
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
width: double.infinity,
|
||||||
|
constraints: BoxConstraints(
|
||||||
|
minHeight: 180,
|
||||||
|
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||||
),
|
),
|
||||||
border: Border.all(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.glassBorder,
|
borderRadius: BorderRadius.circular(24),
|
||||||
width: 1,
|
color: Colors.transparent,
|
||||||
),
|
),
|
||||||
child: Container(
|
child: ClipRRect(
|
||||||
width: double.infinity,
|
borderRadius: BorderRadius.circular(24),
|
||||||
constraints: BoxConstraints(
|
child: Stack(
|
||||||
minHeight: 180,
|
children: [
|
||||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
// 애니메이션 웨이브 배경
|
||||||
),
|
Positioned.fill(
|
||||||
decoration: BoxDecoration(
|
child: AnimatedWaveBackground(
|
||||||
borderRadius: BorderRadius.circular(24),
|
controller: waveController,
|
||||||
color: Colors.transparent,
|
pulseController: pulseController,
|
||||||
),
|
|
||||||
child: ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
|
||||||
// 애니메이션 웨이브 배경
|
|
||||||
Positioned.fill(
|
|
||||||
child: AnimatedWaveBackground(
|
|
||||||
controller: waveController,
|
|
||||||
pulseController: pulseController,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Padding(
|
),
|
||||||
padding: const EdgeInsets.all(24.0),
|
Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(24.0),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
Text(
|
children: [
|
||||||
'이번 달 총 구독 비용',
|
Text(
|
||||||
style: TextStyle(
|
'이번 달 총 구독 비용',
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
style: TextStyle(
|
||||||
fontSize: 15,
|
color: AppColors
|
||||||
fontWeight: FontWeight.w500,
|
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
|
textBaseline: TextBaseline.alphabetic,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(monthlyCost),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors
|
||||||
|
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||||
|
fontSize: 32,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
letterSpacing: -1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
const SizedBox(height: 8),
|
Text(
|
||||||
Row(
|
'원',
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
style: TextStyle(
|
||||||
textBaseline: TextBaseline.alphabetic,
|
color: AppColors
|
||||||
children: [
|
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||||
Text(
|
fontSize: 16,
|
||||||
NumberFormat.currency(
|
fontWeight: FontWeight.w500,
|
||||||
locale: 'ko_KR',
|
|
||||||
symbol: '',
|
|
||||||
decimalDigits: 0,
|
|
||||||
).format(monthlyCost),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
|
||||||
fontSize: 32,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: -1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(
|
|
||||||
'원',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
_buildInfoBox(
|
|
||||||
context,
|
|
||||||
title: '연간 구독 비용',
|
|
||||||
value: '${NumberFormat.currency(
|
|
||||||
locale: 'ko_KR',
|
|
||||||
symbol: '',
|
|
||||||
decimalDigits: 0,
|
|
||||||
).format(yearlyCost)}원',
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildInfoBox(
|
|
||||||
context,
|
|
||||||
title: '총 구독 서비스',
|
|
||||||
value: '$totalSubscriptions개',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
// 이벤트 절약액 표시
|
|
||||||
if (activeEvents > 0) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Colors.white.withValues(alpha: 0.2),
|
|
||||||
Colors.white.withValues(alpha: 0.15),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.primaryColor.withValues(alpha: 0.3),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.25),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.local_offer_rounded,
|
|
||||||
size: 14,
|
|
||||||
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'이벤트 할인 중',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
NumberFormat.currency(
|
|
||||||
locale: 'ko_KR',
|
|
||||||
symbol: '₩',
|
|
||||||
decimalDigits: 0,
|
|
||||||
).format(eventSavings),
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
' 절약 ($activeEvents개)',
|
|
||||||
style: TextStyle(
|
|
||||||
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
_buildInfoBox(
|
||||||
|
context,
|
||||||
|
title: '예상 연간 구독 비용',
|
||||||
|
value: '${NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(yearlyCost)}원',
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
_buildInfoBox(
|
||||||
|
context,
|
||||||
|
title: '총 구독 서비스',
|
||||||
|
value: '$totalSubscriptions개',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 이벤트 절약액 표시
|
||||||
|
if (activeEvents > 0) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 10, horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.white.withValues(alpha: 0.2),
|
||||||
|
Colors.white.withValues(alpha: 0.15),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryColor
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white.withValues(alpha: 0.25),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.local_offer_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors
|
||||||
|
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'이벤트 할인 중',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors
|
||||||
|
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
NumberFormat.currency(
|
||||||
|
locale: 'ko_KR',
|
||||||
|
symbol: '₩',
|
||||||
|
decimalDigits: 0,
|
||||||
|
).format(eventSavings),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors
|
||||||
|
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 강조
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
' 절약 ($activeEvents개)',
|
||||||
|
style: TextStyle(
|
||||||
|
color: AppColors
|
||||||
|
.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -236,7 +247,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
color: AppColors.navyGray, // color.md 가이드: 밝은 배경 위 서브 텍스트
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
@@ -245,7 +256,7 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
value,
|
value,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
color: AppColors.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
|
import '../providers/category_provider.dart';
|
||||||
import 'website_icon.dart';
|
import 'website_icon.dart';
|
||||||
import 'app_navigator.dart';
|
import 'app_navigator.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
@@ -8,10 +10,12 @@ import 'glassmorphism_card.dart';
|
|||||||
|
|
||||||
class SubscriptionCard extends StatefulWidget {
|
class SubscriptionCard extends StatefulWidget {
|
||||||
final SubscriptionModel subscription;
|
final SubscriptionModel subscription;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const SubscriptionCard({
|
const SubscriptionCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.subscription,
|
required this.subscription,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -190,6 +194,34 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 카테고리별 그라데이션 색상 생성
|
||||||
|
List<Color> _getCategoryGradientColors(BuildContext context) {
|
||||||
|
try {
|
||||||
|
if (widget.subscription.categoryId == null) {
|
||||||
|
return AppColors.blueGradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
final categoryProvider = context.watch<CategoryProvider>();
|
||||||
|
final category = categoryProvider.getCategoryById(widget.subscription.categoryId!);
|
||||||
|
|
||||||
|
if (category == null) {
|
||||||
|
return AppColors.blueGradient;
|
||||||
|
}
|
||||||
|
|
||||||
|
final categoryColor = Color(
|
||||||
|
int.parse(category.color.replaceAll('#', '0xFF'))
|
||||||
|
);
|
||||||
|
|
||||||
|
return [
|
||||||
|
categoryColor,
|
||||||
|
categoryColor.withValues(alpha: 0.8),
|
||||||
|
];
|
||||||
|
} catch (e) {
|
||||||
|
// 색상 파싱 실패 시 기본 파란색 그라데이션 반환
|
||||||
|
return AppColors.blueGradient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -200,53 +232,39 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
child: MouseRegion(
|
child: MouseRegion(
|
||||||
onEnter: (_) => _onHover(true),
|
onEnter: (_) => _onHover(true),
|
||||||
onExit: (_) => _onHover(false),
|
onExit: (_) => _onHover(false),
|
||||||
child: AnimatedBuilder(
|
child: AnimatedGlassmorphismCard(
|
||||||
animation: _hoverController,
|
padding: EdgeInsets.zero,
|
||||||
builder: (context, child) {
|
borderRadius: 16,
|
||||||
final scale = 1.0 + (0.02 * _hoverController.value);
|
blur: _isHovering ? 15 : 10,
|
||||||
|
width: double.infinity, // 전체 너비를 차지하도록 설정
|
||||||
|
onTap: widget.onTap ?? () async {
|
||||||
|
await AppNavigator.toDetail(context, widget.subscription);
|
||||||
|
},
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
// 그라데이션 상단 바 효과
|
||||||
|
AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: widget.subscription.isCurrentlyInEvent
|
||||||
|
? [
|
||||||
|
const Color(0xFFFF6B6B),
|
||||||
|
const Color(0xFFFF8787),
|
||||||
|
]
|
||||||
|
: isNearBilling
|
||||||
|
? AppColors.amberGradient
|
||||||
|
: _getCategoryGradientColors(context),
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
return Transform.scale(
|
Padding(
|
||||||
scale: scale,
|
padding: const EdgeInsets.all(16),
|
||||||
child: Material(
|
child: Row(
|
||||||
color: Colors.transparent,
|
|
||||||
child: InkWell(
|
|
||||||
onTap: () async {
|
|
||||||
await AppNavigator.toDetail(context, widget.subscription);
|
|
||||||
},
|
|
||||||
splashColor: AppColors.primaryColor.withValues(alpha: 0.1),
|
|
||||||
highlightColor: AppColors.primaryColor.withValues(alpha: 0.05),
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: AnimatedGlassmorphismCard(
|
|
||||||
onTap: () {}, // onTap은 이미 InkWell에서 처리됨
|
|
||||||
padding: EdgeInsets.zero,
|
|
||||||
borderRadius: 16,
|
|
||||||
blur: _isHovering ? 15 : 10,
|
|
||||||
width: double.infinity, // 전체 너비를 차지하도록 설정
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
// 그라데이션 상단 바 효과
|
|
||||||
AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
height: 4,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: widget.subscription.isCurrentlyInEvent
|
|
||||||
? [
|
|
||||||
const Color(0xFFFF6B6B),
|
|
||||||
const Color(0xFFFF8787),
|
|
||||||
]
|
|
||||||
: isNearBilling
|
|
||||||
? AppColors.amberGradient
|
|
||||||
: AppColors.blueGradient,
|
|
||||||
begin: Alignment.centerLeft,
|
|
||||||
end: Alignment.centerRight,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// 서비스 아이콘
|
// 서비스 아이콘
|
||||||
@@ -304,7 +322,7 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(12),
|
BorderRadius.circular(12),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -526,15 +544,10 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
],
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -42,10 +42,8 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
child: CategoryHeaderWidget(
|
child: CategoryHeaderWidget(
|
||||||
categoryName: category,
|
categoryName: category,
|
||||||
subscriptionCount: subscriptions.length,
|
subscriptionCount: subscriptions.length,
|
||||||
totalCost: subscriptions.fold(
|
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
|
||||||
0.0,
|
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
|
||||||
(sum, sub) => sum + sub.monthlyCost,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// 카테고리별 구독 목록
|
// 카테고리별 구독 목록
|
||||||
@@ -87,10 +85,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
child: SwipeableSubscriptionCard(
|
child: SwipeableSubscriptionCard(
|
||||||
subscription: subscriptions[subIndex],
|
subscription: subscriptions[subIndex],
|
||||||
onTap: () {
|
onTap: () {
|
||||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
print('[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||||
},
|
|
||||||
onEdit: () {
|
|
||||||
// 편집 화면으로 이동
|
|
||||||
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
AppNavigator.toDetail(context, subscriptions[subIndex]);
|
||||||
},
|
},
|
||||||
onDelete: () async {
|
onDelete: () async {
|
||||||
@@ -111,7 +106,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
AppSnackBar.showSuccess(
|
AppSnackBar.showError(
|
||||||
context: context,
|
context: context,
|
||||||
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.',
|
message: '${subscriptions[subIndex].serviceName} 구독이 삭제되었습니다.',
|
||||||
icon: Icons.delete_forever_rounded,
|
icon: Icons.delete_forever_rounded,
|
||||||
@@ -134,6 +129,13 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 특정 통화의 총 합계를 계산합니다.
|
||||||
|
double _calculateTotalByCurrency(List<SubscriptionModel> subscriptions, String currency) {
|
||||||
|
return subscriptions
|
||||||
|
.where((sub) => sub.currency == currency)
|
||||||
|
.fold(0.0, (sum, sub) => sum + sub.monthlyCost);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯
|
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/gestures.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../utils/haptic_feedback_helper.dart';
|
import '../utils/haptic_feedback_helper.dart';
|
||||||
import 'subscription_card.dart';
|
import 'subscription_card.dart';
|
||||||
@@ -8,7 +9,7 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
|||||||
final VoidCallback? onEdit;
|
final VoidCallback? onEdit;
|
||||||
final Future<void> Function()? onDelete;
|
final Future<void> Function()? onDelete;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
const SwipeableSubscriptionCard({
|
const SwipeableSubscriptionCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.subscription,
|
required this.subscription,
|
||||||
@@ -18,23 +19,34 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SwipeableSubscriptionCard> createState() => _SwipeableSubscriptionCardState();
|
State<SwipeableSubscriptionCard> createState() =>
|
||||||
|
_SwipeableSubscriptionCardState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
|
// 상수 정의
|
||||||
|
static const double _tapTolerance = 20.0; // 탭 허용 범위
|
||||||
|
static const double _actionThresholdPercent = 0.15;
|
||||||
|
static const double _deleteThresholdPercent = 0.40;
|
||||||
|
static const int _tapDurationMs = 500;
|
||||||
|
static const double _velocityThreshold = 800.0;
|
||||||
|
// static const double _animationDuration = 300.0;
|
||||||
|
|
||||||
|
// 애니메이션 관련
|
||||||
late AnimationController _controller;
|
late AnimationController _controller;
|
||||||
late Animation<double> _animation;
|
late Animation<double> _animation;
|
||||||
double _dragStartX = 0;
|
|
||||||
double _currentOffset = 0; // 현재 카드의 실제 위치
|
// 제스처 추적
|
||||||
bool _isDragging = false; // 드래그 중인지 여부
|
Offset? _startPosition;
|
||||||
|
DateTime? _startTime;
|
||||||
|
bool _isValidTap = true;
|
||||||
|
|
||||||
|
// 상태 관리
|
||||||
|
double _currentOffset = 0;
|
||||||
bool _isSwipingLeft = false;
|
bool _isSwipingLeft = false;
|
||||||
bool _hapticTriggered = false;
|
bool _hapticTriggered = false;
|
||||||
double _screenWidth = 0;
|
double _cardWidth = 0;
|
||||||
double _cardWidth = 0; // 카드의 실제 너비 (margin 제외)
|
|
||||||
|
|
||||||
static const double _actionThresholdPercent = 0.15; // 15%에서 액션 버튼 표시
|
|
||||||
static const double _deleteThresholdPercent = 0.40; // 40%에서 삭제/편집 실행
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -50,128 +62,128 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
|||||||
parent: _controller,
|
parent: _controller,
|
||||||
curve: Curves.easeOutExpo,
|
curve: Curves.easeOutExpo,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 애니메이션 상태 리스너 추가
|
_controller.addListener(() {
|
||||||
_controller.addStatusListener(_onAnimationStatusChanged);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
// 애니메이션 리스너 추가
|
_currentOffset = _animation.value;
|
||||||
_controller.addListener(_onAnimationUpdate);
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
_screenWidth = MediaQuery.of(context).size.width;
|
_cardWidth = MediaQuery.of(context).size.width - 32;
|
||||||
_cardWidth = _screenWidth - 32; // 좌우 margin 16px씩 제외
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didUpdateWidget(SwipeableSubscriptionCard oldWidget) {
|
void didUpdateWidget(SwipeableSubscriptionCard oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
// 위젯이 업데이트될 때 카드를 원위치로 복귀
|
|
||||||
if (oldWidget.subscription.id != widget.subscription.id) {
|
if (oldWidget.subscription.id != widget.subscription.id) {
|
||||||
_controller.stop();
|
_resetCard();
|
||||||
setState(() {
|
|
||||||
_currentOffset = 0;
|
|
||||||
_isDragging = false;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_controller.removeListener(_onAnimationUpdate);
|
|
||||||
_controller.removeStatusListener(_onAnimationStatusChanged);
|
|
||||||
_controller.stop();
|
|
||||||
_controller.dispose();
|
_controller.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAnimationUpdate() {
|
|
||||||
if (!_isDragging) {
|
|
||||||
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;
|
_hapticTriggered = false;
|
||||||
_isDragging = true;
|
_controller.stop();
|
||||||
_controller.stop(); // 진행 중인 애니메이션 중지
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragUpdate(DragUpdateDetails details) {
|
void _handlePanUpdate(DragUpdateDetails details) {
|
||||||
final delta = details.localPosition.dx - _dragStartX;
|
final currentPosition = details.localPosition;
|
||||||
|
final delta = currentPosition.dx - _startPosition!.dx;
|
||||||
|
final distance = (currentPosition - _startPosition!).distance;
|
||||||
|
|
||||||
|
// 탭 유효성 검사 - 거리가 허용 범위를 벗어나면 스와이프로 간주
|
||||||
|
if (distance > _tapTolerance) {
|
||||||
|
_isValidTap = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 카드 이동
|
||||||
setState(() {
|
setState(() {
|
||||||
_currentOffset = delta;
|
_currentOffset = delta;
|
||||||
_isSwipingLeft = delta < 0;
|
_isSwipingLeft = delta < 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
// 햅틱 피드백 트리거 (카드 너비의 15%)
|
// 햅틱 피드백
|
||||||
final actionThreshold = _cardWidth * _actionThresholdPercent;
|
_triggerHapticFeedback();
|
||||||
if (!_hapticTriggered && _currentOffset.abs() > actionThreshold) {
|
|
||||||
_hapticTriggered = true;
|
|
||||||
HapticFeedbackHelper.mediumImpact();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 삭제 임계값에 도달했을 때 강한 햅틱 (카드 너비의 40%)
|
|
||||||
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
|
||||||
if (_currentOffset.abs() > deleteThreshold && _hapticTriggered) {
|
|
||||||
HapticFeedbackHelper.heavyImpact();
|
|
||||||
_hapticTriggered = false; // 반복 방지
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragEnd(DragEndDetails details) async {
|
void _handlePanEnd(DragEndDetails details) {
|
||||||
_isDragging = false;
|
final duration = DateTime.now().difference(_startTime!);
|
||||||
final velocity = details.velocity.pixelsPerSecond.dx;
|
final velocity = details.velocity.pixelsPerSecond.dx;
|
||||||
|
|
||||||
|
// 탭/스와이프 처리 분기
|
||||||
|
|
||||||
|
// 탭 처리 - 짧은 시간 내에 작은 움직임만 있었다면 탭으로 처리
|
||||||
|
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 extent = _currentOffset.abs();
|
||||||
|
|
||||||
// 카드 너비의 40% 계산
|
|
||||||
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
final deleteThreshold = _cardWidth * _deleteThresholdPercent;
|
||||||
|
|
||||||
if (extent > deleteThreshold || velocity.abs() > 800) {
|
if (extent > deleteThreshold || velocity.abs() > _velocityThreshold) {
|
||||||
// 40% 이상 스와이프 시 삭제/편집 액션
|
// 삭제 실행
|
||||||
if (_isSwipingLeft && widget.onDelete != null) {
|
if (widget.onDelete != null) {
|
||||||
HapticFeedbackHelper.success();
|
HapticFeedbackHelper.success();
|
||||||
// 삭제 확인 다이얼로그 표시
|
widget.onDelete!().then((_) {
|
||||||
await widget.onDelete!();
|
if (mounted) {
|
||||||
// 다이얼로그가 닫힌 후 원위치로 복귀
|
_animateToOffset(0);
|
||||||
if (mounted) {
|
}
|
||||||
_animateToOffset(0);
|
|
||||||
}
|
|
||||||
} else if (!_isSwipingLeft && widget.onEdit != null) {
|
|
||||||
HapticFeedbackHelper.success();
|
|
||||||
// 편집 화면으로 이동 전 원위치로 복귀
|
|
||||||
_animateToOffset(0);
|
|
||||||
Future.delayed(const Duration(milliseconds: 300), () {
|
|
||||||
widget.onEdit!();
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
// 액션이 없는 경우 원위치로 복귀
|
|
||||||
_animateToOffset(0);
|
_animateToOffset(0);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// 40% 미만: 모두 원위치로 복귀
|
// 원위치 복귀
|
||||||
_animateToOffset(0);
|
_animateToOffset(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _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) {
|
void _animateToOffset(double offset) {
|
||||||
// 애니메이션 컨트롤러 리셋
|
|
||||||
_controller.stop();
|
|
||||||
_controller.value = 0;
|
|
||||||
|
|
||||||
_animation = Tween<double>(
|
_animation = Tween<double>(
|
||||||
begin: _currentOffset,
|
begin: _currentOffset,
|
||||||
end: offset,
|
end: offset,
|
||||||
@@ -179,94 +191,97 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
|||||||
parent: _controller,
|
parent: _controller,
|
||||||
curve: Curves.easeOutExpo,
|
curve: Curves.easeOutExpo,
|
||||||
));
|
));
|
||||||
|
|
||||||
_controller.forward();
|
_controller.forward(from: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _resetCard() {
|
||||||
|
_controller.stop();
|
||||||
|
setState(() {
|
||||||
|
_currentOffset = 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 빌드 메서드
|
||||||
|
Widget _buildActionButtons() {
|
||||||
|
return Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
if (!_isSwipingLeft) _buildDeleteIcon(true),
|
||||||
|
if (_isSwipingLeft) _buildDeleteIcon(false),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildDeleteIcon(bool isLeft) {
|
||||||
|
final showIcon = _currentOffset.abs() > (_cardWidth * 0.10);
|
||||||
|
final isDeleteThreshold =
|
||||||
|
_currentOffset.abs() > (_cardWidth * _deleteThresholdPercent);
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: isLeft ? 24 : 0,
|
||||||
|
right: isLeft ? 0 : 24,
|
||||||
|
),
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
opacity: showIcon ? 1.0 : 0.0,
|
||||||
|
child: AnimatedScale(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
scale: showIcon ? 1.0 : 0.5,
|
||||||
|
child: Icon(
|
||||||
|
isDeleteThreshold
|
||||||
|
? Icons.delete_forever_rounded
|
||||||
|
: Icons.delete_rounded,
|
||||||
|
color: Colors.white,
|
||||||
|
size: 28,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildCard() {
|
||||||
|
return Transform.translate(
|
||||||
|
offset: Offset(_currentOffset, 0),
|
||||||
|
child: Transform.scale(
|
||||||
|
scale: 1.0 - (_currentOffset.abs() / 2000),
|
||||||
|
child: Transform.rotate(
|
||||||
|
angle: _currentOffset / 2000,
|
||||||
|
child: SubscriptionCard(
|
||||||
|
subscription: widget.subscription,
|
||||||
|
onTap: widget.onTap,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// 웹과 모바일 모두 동일한 스와이프 기능 제공
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// 배경 액션 버튼들
|
_buildActionButtons(),
|
||||||
Positioned.fill(
|
|
||||||
child: Container(
|
|
||||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
color: Colors.transparent, // 투명하게 변경
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
// 편집 버튼 (오른쪽 스와이프)
|
|
||||||
if (!_isSwipingLeft)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 24),
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
opacity: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.0,
|
|
||||||
child: AnimatedScale(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
scale: _currentOffset > (_cardWidth * 0.10) ? 1.0 : 0.5,
|
|
||||||
child: const Icon(
|
|
||||||
Icons.edit_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 삭제 버튼 (왼쪽 스와이프)
|
|
||||||
if (_isSwipingLeft)
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(right: 24),
|
|
||||||
child: AnimatedOpacity(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
opacity: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.0,
|
|
||||||
child: AnimatedScale(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
scale: _currentOffset.abs() > (_cardWidth * 0.10) ? 1.0 : 0.5,
|
|
||||||
child: Icon(
|
|
||||||
_currentOffset.abs() > (_cardWidth * _deleteThresholdPercent)
|
|
||||||
? Icons.delete_forever_rounded
|
|
||||||
: Icons.delete_rounded,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 28,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 스와이프 가능한 카드
|
|
||||||
GestureDetector(
|
GestureDetector(
|
||||||
onHorizontalDragStart: _handleDragStart,
|
behavior: HitTestBehavior.opaque,
|
||||||
onHorizontalDragUpdate: _handleDragUpdate,
|
onPanStart: _handlePanStart,
|
||||||
onHorizontalDragEnd: _handleDragEnd,
|
onPanUpdate: _handlePanUpdate,
|
||||||
child: Transform.translate(
|
onPanEnd: _handlePanEnd,
|
||||||
offset: Offset(_currentOffset, 0),
|
child: _buildCard(),
|
||||||
child: Transform.scale(
|
|
||||||
scale: 1.0 - (_currentOffset.abs() / 2000),
|
|
||||||
child: Transform.rotate(
|
|
||||||
angle: _currentOffset / 2000,
|
|
||||||
child: GestureDetector(
|
|
||||||
onTap: () {
|
|
||||||
if (_currentOffset.abs() < 10) {
|
|
||||||
widget.onTap?.call();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
child: SubscriptionCard(
|
|
||||||
subscription: widget.subscription,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user