주요 구현 완료 기능: - 구독 관리 (추가/편집/삭제/카테고리 분류) - 이벤트 할인 시스템 (기본값 자동 설정) - SMS 자동 스캔 및 구독 정보 추출 - 알림 시스템 (타임존 처리 안정화) - 환율 변환 지원 (KRW/USD) - 반응형 UI 및 애니메이션 - 다국어 지원 (한국어/영어) 버그 수정: - NotificationService tz.local 초기화 오류 해결 - MainScreenSummaryCard 레이아웃 오버플로우 수정 - 구독 추가 시 LateInitializationError 완전 해결 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
2023 lines
70 KiB
Markdown
2023 lines
70 KiB
Markdown
# SubManager - 향상된 제품 요구사항 정의서(PRD)
|
|
|
|
## 1. 제품 개요
|
|
|
|
### 1.1 앱 소개
|
|
**SubManager**는 사용자의 정기 구독 서비스를 효율적으로 관리하고, 지출을 추적하며 절약을 돕기 위한 모바일 앱입니다. 사용자들이 자신의 구독 서비스를 손쉽게 추가하고, 결제일을 기억하며, 총 지출을 한눈에 파악할 수 있도록 설계되었습니다.
|
|
|
|
### 1.2 핵심 가치
|
|
- **서버 없는 구조**: 모든 데이터는 로컬에 저장되어 프라이버시 보호
|
|
- **자동 탐지**: SMS 메시지 분석을 통한 구독 서비스 자동 탐지 기능
|
|
- **직관적 UI/UX**: Material 3 디자인의 카드 기반 사용자 인터페이스
|
|
- **지출 분석**: 다양한 차트와 통계를 통한 구독 지출 최적화 지원
|
|
|
|
### 1.3 타겟 사용자
|
|
- 다수의 구독 서비스를 이용하는 디지털 소비자
|
|
- 월간 지출 관리에 관심 있는 사용자
|
|
- 결제일과 예산을 체계적으로 관리하고자 하는 사용자
|
|
|
|
## 2. 기술 스택
|
|
|
|
### 2.1 프레임워크 및 언어
|
|
- **Flutter 3.x**: 크로스 플랫폼 모바일 애플리케이션 개발
|
|
- **Dart**: 프로그래밍 언어, 널 안전성(null-safety) 활용
|
|
|
|
### 2.2 데이터 저장
|
|
- **Hive**: 로컬 데이터베이스 (NoSQL 키-값 저장소)
|
|
- 모든 데이터를 타입별 Box에 저장 (`subscriptions`, `categories` 등)
|
|
- 데이터 모델에 `@HiveType` 및 `@HiveField` 애노테이션 적용
|
|
- **flutter_secure_storage**: 민감한 정보를 위한 암호화 저장소
|
|
- 생체 인증 설정, 알림 설정 등 저장
|
|
|
|
### 2.3 상태 관리
|
|
- **Provider**: 애플리케이션 상태 관리 패턴
|
|
- `ChangeNotifier`를 상속한 Provider 클래스로 상태 관리
|
|
- `Consumer` 및 `Provider.of<T>` 방식으로 상태 구독
|
|
- 위젯 트리 최상단에 `MultiProvider`로 모든 Provider 등록
|
|
|
|
### 2.4 주요 패키지
|
|
- **flutter_local_notifications**: 로컬 알림 관리
|
|
- 구독 결제일 알림 스케줄링
|
|
- 알림 채널 구성 및 권한 관리
|
|
- **fl_chart**: 차트 및 그래프 시각화
|
|
- `BarChart`, `PieChart`로 지출 데이터 시각화
|
|
- **intl**: 국제화 및 날짜/숫자 포맷팅
|
|
- 통화 형식, 날짜 포맷을 현지화하여 표시
|
|
- **local_auth**: 생체 인증
|
|
- 지문, 얼굴 인식을 통한 앱 잠금 관리
|
|
- **telephony/flutter_sms**: SMS 메시지 접근
|
|
- 구독 서비스 자동 탐지용 SMS 분석
|
|
- **http**: API 요청
|
|
- 환율 정보 가져오기 (ExchangeRate-API 사용)
|
|
- **url_launcher**: 외부 URL 열기
|
|
- 구독 서비스 웹사이트로 바로 이동 기능
|
|
|
|
## 3. 핵심 기능 명세
|
|
|
|
### 3.1 구독 관리
|
|
- **구독 서비스 수동 추가/편집/삭제**
|
|
- 사용자가 직접 구독 정보 입력 및 관리
|
|
- 모든 필드에 대한 유효성 검사 구현
|
|
- **서비스명, 월 비용, 결제 주기, 결제일 관리**
|
|
- 주기 옵션: 월간, 연간, 주간 선택 가능
|
|
- 결제일: 달력 UI로 날짜 선택
|
|
- **카테고리별 구독 서비스 분류**
|
|
- 기본 제공 카테고리: OTT, 음악, AI, 프로그래밍/개발, 오피스/협업 툴, 기타
|
|
- 사용자 정의 카테고리 추가/편집/삭제 가능
|
|
- **중복 구독 확인 및 관리**
|
|
- 동일 서비스 중복 등록 시 알림
|
|
- 서비스명 유사도 검사 알고리즘 구현
|
|
|
|
### 3.2 SMS 자동 탐지
|
|
- **SMS 메시지 기반 구독 서비스 자동 인식**
|
|
- 구독 관련 키워드 필터링 (`구독`, `결제`, `청구`, `subscription` 등)
|
|
- 발신자 및 내용 패턴 분석
|
|
- **반복 결제 패턴 분석**
|
|
- 동일 발신자, 유사 금액으로 2회 이상 메시지가 발견된 경우 구독으로 판단
|
|
- 결제 주기 자동 감지 (월 단위, 연 단위)
|
|
- **일회성 결제와 정기 구독 구분**
|
|
- 반복 결제 횟수(`repeatCount`) 추적
|
|
- 구독 서비스 이름 매칭 로직으로 서비스 식별
|
|
|
|
### 3.3 결제일 알림
|
|
- **결제 예정일 3일 전 알림**
|
|
- 알림 제목: "구독 결제 예정 알림"
|
|
- 알림 내용: "[서비스명] 결제가 3일 후 예정되어 있습니다"
|
|
- **미사용 서비스 알림 (2개월 이상)**
|
|
- 마지막 이용일 트래킹
|
|
- 장기 미사용 감지 시 알림 발송
|
|
- **맞춤형 알림 설정**
|
|
- 알림 활성화/비활성화 옵션
|
|
- 알림 타입별 개별 설정 가능
|
|
|
|
### 3.4 분석 및 리포트
|
|
- **월별 총 지출 차트**
|
|
- 막대 그래프로 월별 지출 추이 시각화
|
|
- 최근 6개월 데이터 표시
|
|
- **서비스별 지출 비율 차트**
|
|
- 파이 차트로 구독 서비스별 지출 비중 시각화
|
|
- 차트 항목 터치 시 상세 정보 표시
|
|
- **카테고리별 지출 분석**
|
|
- 카테고리별 총 지출 집계
|
|
- 이전 달 대비 증감률 표시
|
|
|
|
### 3.5 다국어 및 환율 지원
|
|
- **앱 다국어 지원 (한국어/영어)**
|
|
- `app_ko.arb`, `app_en.arb` 리소스 파일로 현지화
|
|
- 시스템 언어 기반 자동 언어 설정 및 수동 변경 옵션
|
|
- **USD/KRW 환율 자동 변환 기능**
|
|
- 실시간 환율 정보 API 호출 (6시간 주기 캐시)
|
|
- 달러 금액 입력 시 원화 환산 금액 동시 표시
|
|
- **통화 단위 선택 옵션**
|
|
- 각 구독별 통화 단위(KRW, USD) 설정 가능
|
|
- 통합 보기 시 단일 통화로 합산 표시
|
|
|
|
### 3.6 보안 기능
|
|
- **생체 인증을 통한 앱 잠금**
|
|
- 지문 인식 또는 얼굴 인식으로 앱 접근 제어
|
|
- 인증 실패 시 재시도 옵션 제공
|
|
- **앱 백그라운드 전환 시 자동 잠금**
|
|
- 앱 라이프사이클 상태 감지 (paused → resumed)
|
|
- 설정에 따라 자동 잠금 활성화/비활성화
|
|
|
|
## 4. 아키텍처 및 코드 구조
|
|
|
|
### 4.1 아키텍처 패턴
|
|
- **MVVM 패턴 응용**
|
|
- **Model**: Hive 데이터 모델 (SubscriptionModel, CategoryModel)
|
|
- **View**: 화면 위젯 (StatelessWidget, StatefulWidget)
|
|
- **ViewModel**: Provider 클래스 (ChangeNotifier)
|
|
- **서비스 레이어 패턴**
|
|
- 비즈니스 로직을 서비스 클래스로 분리
|
|
- 각 서비스는 단일 책임 원칙(SRP)을 따름
|
|
|
|
### 4.2 폴더 구조 및 파일 조직
|
|
```
|
|
/lib
|
|
├── main.dart # 앱 진입점
|
|
├── navigator_key.dart # 전역 네비게이터 키
|
|
├── screens/ # 화면 구성 파일
|
|
│ ├── main_screen.dart # 메인 화면
|
|
│ ├── add_subscription_screen.dart # 구독 추가 화면
|
|
│ ├── detail_screen.dart # 구독 상세 화면
|
|
│ ├── analysis_screen.dart # 분석 화면
|
|
│ ├── settings_screen.dart # 설정 화면
|
|
│ ├── sms_scan_screen.dart # SMS 스캔 화면
|
|
│ ├── splash_screen.dart # 스플래시 화면
|
|
│ ├── app_lock_screen.dart # 앱 잠금 화면
|
|
│ └── category_management_screen.dart # 카테고리 관리 화면
|
|
├── models/ # 데이터 모델
|
|
│ ├── subscription_model.dart # 구독 모델
|
|
│ ├── subscription_model.g.dart # 자동 생성 코드
|
|
│ ├── category_model.dart # 카테고리 모델
|
|
│ └── category_model.g.dart # 자동 생성 코드
|
|
├── providers/ # 상태 관리
|
|
│ ├── subscription_provider.dart # 구독 데이터 관리
|
|
│ ├── category_provider.dart # 카테고리 데이터 관리
|
|
│ ├── app_lock_provider.dart # 앱 잠금 관리
|
|
│ ├── notification_provider.dart # 알림 설정 관리
|
|
│ └── locale_provider.dart # 다국어 관리
|
|
├── services/ # 서비스 클래스
|
|
│ ├── notification_service.dart # 알림 서비스
|
|
│ ├── sms_scanner.dart # SMS 스캔 로직
|
|
│ ├── sms_service.dart # SMS 접근 서비스
|
|
│ ├── exchange_rate_service.dart # 환율 서비스
|
|
│ ├── currency_util.dart # 통화 유틸리티
|
|
│ └── subscription_url_matcher.dart # URL 매칭 서비스
|
|
├── utils/ # 유틸리티 함수
|
|
│ ├── format_helper.dart # 포맷팅 유틸리티
|
|
│ ├── animation_controller_helper.dart # 애니메이션 유틸리티
|
|
│ └── subscription_category_helper.dart # 카테고리 유틸리티
|
|
├── widgets/ # 재사용 위젯
|
|
│ ├── subscription_card.dart # 구독 카드 위젯
|
|
│ ├── main_summary_card.dart # 메인 요약 카드
|
|
│ ├── exchange_rate_widget.dart # 환율 정보 위젯
|
|
│ ├── website_icon.dart # 웹사이트 아이콘 위젯
|
|
│ ├── category_header_widget.dart # 카테고리 헤더
|
|
│ ├── subscription_list_widget.dart # 구독 목록 위젯
|
|
│ ├── animated_wave_background.dart # 웨이브 배경 위젯
|
|
│ ├── empty_state_widget.dart # 빈 상태 위젯
|
|
│ └── skeleton_loading.dart # 로딩 스켈레톤 위젯
|
|
├── theme/ # 테마 관련
|
|
│ ├── app_theme.dart # 앱 테마 정의
|
|
│ └── app_colors.dart # 색상 정의
|
|
└── l10n/ # 다국어 리소스
|
|
├── app_localizations.dart # 다국어 클래스
|
|
├── app_en.arb # 영어 리소스
|
|
└── app_ko.arb # 한국어 리소스
|
|
```
|
|
|
|
### 4.3 데이터 흐름
|
|
1. **사용자 입력** → **화면 위젯** → **Provider 메서드 호출**
|
|
2. **Provider** → **데이터 처리** → **Hive Box 저장/조회**
|
|
3. **Provider** → **상태 변경** → **notifyListeners()** → **UI 갱신**
|
|
|
|
### 4.4 코딩 표준 및 가이드라인
|
|
- **명명 규칙**
|
|
- **클래스**: PascalCase (예: `SubscriptionModel`, `MainScreen`)
|
|
- **변수/메서드**: camelCase (예: `monthlyCost`, `addSubscription()`)
|
|
- **상수**: SNAKE_CASE (예: `MAX_SUBSCRIPTIONS`)
|
|
- **비공개 멤버**: 언더스코어 접두사 (예: `_subscriptions`, `_loadData()`)
|
|
- **코드 구성**
|
|
- 각 파일은 한 가지 주요 클래스/위젯만 포함
|
|
- 주석을 통한 코드 문서화 (특히 복잡한 로직)
|
|
- 관련 기능은 같은 디렉토리에 그룹화
|
|
- **코드 스타일**
|
|
- Dart 공식 스타일 가이드 준수
|
|
- Lint 규칙 적용 (`analysis_options.yaml`)
|
|
- 한 줄 최대 80자 제한
|
|
|
|
## 5. 데이터 모델
|
|
|
|
### 5.1 구독 모델 (SubscriptionModel)
|
|
```dart
|
|
@HiveType(typeId: 0)
|
|
class SubscriptionModel extends HiveObject {
|
|
@HiveField(0)
|
|
final String id; // UUID 형식의 고유 식별자
|
|
|
|
@HiveField(1)
|
|
String serviceName; // 구독 서비스명
|
|
|
|
@HiveField(2)
|
|
double monthlyCost; // 월 비용
|
|
|
|
@HiveField(3)
|
|
String billingCycle; // 결제 주기: '월간', '연간', '주간'
|
|
|
|
@HiveField(4)
|
|
DateTime nextBillingDate; // 다음 결제 예정일
|
|
|
|
@HiveField(5)
|
|
bool isAutoDetected; // SMS 자동 탐지 여부
|
|
|
|
@HiveField(6)
|
|
String? categoryId; // 카테고리 ID (외래 키)
|
|
|
|
@HiveField(7)
|
|
String? websiteUrl; // 서비스 웹사이트 URL
|
|
|
|
@HiveField(8)
|
|
int repeatCount; // 반복 결제 횟수 (SMS 탐지용)
|
|
|
|
@HiveField(9)
|
|
DateTime? lastPaymentDate; // 마지막 결제일
|
|
|
|
@HiveField(10)
|
|
String currency; // 통화 단위: 'KRW' 또는 'USD'
|
|
|
|
// 생성자
|
|
SubscriptionModel({
|
|
required this.id,
|
|
required this.serviceName,
|
|
required this.monthlyCost,
|
|
required this.billingCycle,
|
|
required this.nextBillingDate,
|
|
this.isAutoDetected = false,
|
|
this.categoryId,
|
|
this.websiteUrl,
|
|
this.repeatCount = 1,
|
|
this.lastPaymentDate,
|
|
this.currency = 'KRW', // 기본값은 KRW
|
|
});
|
|
|
|
// 주기적 결제 여부 확인
|
|
bool get isRecurring => repeatCount > 1;
|
|
}
|
|
```
|
|
|
|
### 5.2 카테고리 모델 (CategoryModel)
|
|
```dart
|
|
@HiveType(typeId: 1)
|
|
class CategoryModel extends HiveObject {
|
|
@HiveField(0)
|
|
String id; // UUID 형식의 고유 식별자
|
|
|
|
@HiveField(1)
|
|
String name; // 카테고리 이름
|
|
|
|
@HiveField(2)
|
|
String color; // 색상 (HEX 코드: '#3B82F6')
|
|
|
|
@HiveField(3)
|
|
String icon; // 아이콘 이름 (Material Icons)
|
|
|
|
// 생성자
|
|
CategoryModel({
|
|
required this.id,
|
|
required this.name,
|
|
required this.color,
|
|
required this.icon,
|
|
});
|
|
}
|
|
```
|
|
|
|
### 5.3 Provider 모델 구현
|
|
|
|
#### 5.3.1 구독 Provider (SubscriptionProvider)
|
|
```dart
|
|
class SubscriptionProvider extends ChangeNotifier {
|
|
late Box<SubscriptionModel> _subscriptionBox;
|
|
List<SubscriptionModel> _subscriptions = [];
|
|
bool _isLoading = true;
|
|
|
|
List<SubscriptionModel> get subscriptions => _subscriptions;
|
|
bool get isLoading => _isLoading;
|
|
|
|
double get totalMonthlyExpense {
|
|
return _subscriptions.fold(
|
|
0.0,
|
|
(sum, subscription) => sum + subscription.monthlyCost,
|
|
);
|
|
}
|
|
|
|
Future<void> init() async {
|
|
try {
|
|
_isLoading = true;
|
|
notifyListeners();
|
|
|
|
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
|
|
await refreshSubscriptions();
|
|
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
} catch (e) {
|
|
debugPrint('구독 초기화 중 오류 발생: $e');
|
|
_isLoading = false;
|
|
notifyListeners();
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> refreshSubscriptions() async {
|
|
try {
|
|
_subscriptions = _subscriptionBox.values.toList()
|
|
..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate));
|
|
notifyListeners();
|
|
} catch (e) {
|
|
// 오류 처리
|
|
rethrow;
|
|
}
|
|
}
|
|
|
|
Future<void> addSubscription({
|
|
required String serviceName,
|
|
required double monthlyCost,
|
|
required String billingCycle,
|
|
required DateTime nextBillingDate,
|
|
String? websiteUrl,
|
|
String? categoryId,
|
|
bool isAutoDetected = false,
|
|
int repeatCount = 1,
|
|
DateTime? lastPaymentDate,
|
|
String currency = 'KRW',
|
|
}) async {
|
|
// 구현...
|
|
}
|
|
|
|
Future<void> updateSubscription(SubscriptionModel subscription) async {
|
|
// 구현...
|
|
}
|
|
|
|
Future<void> deleteSubscription(String id) async {
|
|
// 구현...
|
|
}
|
|
}
|
|
```
|
|
|
|
#### 5.3.2 카테고리 Provider (CategoryProvider)
|
|
```dart
|
|
class CategoryProvider extends ChangeNotifier {
|
|
List<CategoryModel> _categories = [];
|
|
late Box<CategoryModel> _categoryBox;
|
|
|
|
List<CategoryModel> get categories => _categories;
|
|
|
|
Future<void> init() async {
|
|
_categoryBox = await Hive.openBox<CategoryModel>('categories');
|
|
_categories = _categoryBox.values.toList();
|
|
|
|
// 카테고리가 비어있으면 기본 카테고리 추가
|
|
if (_categories.isEmpty) {
|
|
await _initDefaultCategories();
|
|
}
|
|
|
|
notifyListeners();
|
|
}
|
|
|
|
Future<void> _initDefaultCategories() async {
|
|
final defaultCategories = [
|
|
{'name': 'OTT 서비스', 'color': '#3B82F6', 'icon': 'live_tv'},
|
|
{'name': '음악 서비스', 'color': '#EC4899', 'icon': 'music_note'},
|
|
{'name': 'AI 서비스', 'color': '#8B5CF6', 'icon': 'psychology'},
|
|
{'name': '프로그래밍/개발', 'color': '#10B981', 'icon': 'code'},
|
|
{'name': '오피스/협업 툴', 'color': '#F59E0B', 'icon': 'business_center'},
|
|
{'name': '기타 서비스', 'color': '#6B7280', 'icon': 'more_horiz'},
|
|
];
|
|
|
|
// 구현...
|
|
}
|
|
|
|
// 기타 메서드...
|
|
}
|
|
```
|
|
|
|
## 6. UI 디자인 및 레이아웃
|
|
|
|
### 6.1 디자인 시스템
|
|
|
|
#### 6.1.1 색상 팔레트
|
|
- **주 색상(Primary)**: `#3B82F6` (파란색) - 버튼, 강조 텍스트, 앱바 등
|
|
- Tailwind 매핑: `blue-500`
|
|
- 밝은 변형: `#60A5FA` (blue-400), 어두운 변형: `#2563EB` (blue-600)
|
|
- **보조 색상(Secondary)**: `#EC4899` (분홍색) - 음악 카테고리, 부가 기능
|
|
- Tailwind 매핑: `pink-500`
|
|
- 밝은 변형: `#F472B6` (pink-400), 어두운 변형: `#DB2777` (pink-600)
|
|
- **배경색(Background)**: `#F8FAFC` (밝은 회색) - 앱 배경
|
|
- Tailwind 매핑: `slate-50`
|
|
- **표면색(Surface)**: `#FFFFFF` (흰색) - 카드, 다이얼로그 배경
|
|
- Tailwind 매핑: `white`
|
|
- 대체 표면색: `#F1F5F9` (slate-100)
|
|
- **오류색(Error)**: `#EF4444` (빨간색) - 경고, 삭제 버튼
|
|
- Tailwind 매핑: `red-500`
|
|
- **성공색(Success)**: `#10B981` (녹색) - 성공 메시지
|
|
- Tailwind 매핑: `emerald-500`
|
|
- **카테고리 색상**:
|
|
- OTT 서비스: `#3B82F6` (blue-500)
|
|
- 음악 서비스: `#EC4899` (pink-500)
|
|
- AI 서비스: `#8B5CF6` (violet-500)
|
|
- 프로그래밍/개발: `#10B981` (emerald-500)
|
|
- 오피스/협업 툴: `#F59E0B` (amber-500)
|
|
- 기타 서비스: `#6B7280` (gray-500)
|
|
|
|
##### 그라데이션 색상
|
|
- **파란색 그라데이션**: `linear-gradient(135deg, #3B82F6 0%, #60A5FA 100%)`
|
|
- Tailwind 구현: `bg-gradient-to-tr from-blue-500 to-blue-400`
|
|
- **분홍색 그라데이션**: `linear-gradient(135deg, #EC4899 0%, #F472B6 100%)`
|
|
- Tailwind 구현: `bg-gradient-to-tr from-pink-500 to-pink-400`
|
|
- **보라색 그라데이션**: `linear-gradient(135deg, #8B5CF6 0%, #A78BFA 100%)`
|
|
- Tailwind 구현: `bg-gradient-to-tr from-violet-500 to-violet-400`
|
|
- **요약 카드 그라데이션**: `linear-gradient(135deg, #3B82F6 0%, #2563EB 100%)`
|
|
- Tailwind 구현: `bg-gradient-to-tr from-blue-500 to-blue-600`
|
|
|
|
#### 6.1.2 타이포그래피
|
|
- **폰트 패밀리**: Roboto (안드로이드), SF Pro (iOS), Noto Sans KR (한글)
|
|
- Tailwind 매핑: `font-sans`
|
|
- **크기 체계**:
|
|
- 타이틀 대: 24sp, 굵게(700) - `text-2xl font-bold`
|
|
- 타이틀 중: 20sp, 굵게(700) - `text-xl font-bold`
|
|
- 타이틀 소: 16sp, 굵게(700) - `text-base font-bold`
|
|
- 본문: 14sp, 일반(400) - `text-sm font-normal`
|
|
- 부제목: 12sp, 일반(400) - `text-xs font-normal`
|
|
- 캡션: 10sp, 일반(400) - `text-[10px] font-normal`
|
|
- **행간**:
|
|
- 타이틀: 1.2 - `leading-tight`
|
|
- 본문: 1.5 - `leading-normal`
|
|
- **색상**:
|
|
- 기본 텍스트: `#0F172A` (slate-900) - `text-slate-900`
|
|
- 보조 텍스트: `#64748B` (slate-500) - `text-slate-500`
|
|
- 비활성화 텍스트: `#94A3B8` (slate-400) - `text-slate-400`
|
|
- 반전 텍스트(어두운 배경): `#FFFFFF` (white) - `text-white`
|
|
|
|
#### 6.1.3 그림자 및 입체감
|
|
- **카드 그림자**: `box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06)`
|
|
- Tailwind 매핑: `shadow-md`
|
|
- **버튼 그림자**: `box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1)`
|
|
- Tailwind 매핑: `shadow-sm`
|
|
- **팝업 그림자**: `box-shadow: 0 8px 16px rgba(0, 0, 0, 0.15)`
|
|
- Tailwind 매핑: `shadow-lg`
|
|
- **입체 효과**:
|
|
- 약간 올라옴: `shadow-sm hover:shadow-md transition-shadow`
|
|
- 확실히 올라옴: `shadow-md hover:shadow-lg transition-shadow`
|
|
- 플로팅 버튼: `shadow-lg`
|
|
|
|
#### 6.1.4 모서리 둥글기
|
|
- **카드**: BorderRadius.circular(16) - `rounded-2xl`
|
|
- **버튼**: BorderRadius.circular(12) - `rounded-xl`
|
|
- **입력 필드**: BorderRadius.circular(8) - `rounded-lg`
|
|
- **칩**: BorderRadius.circular(24) - `rounded-full`
|
|
- **팝업/다이얼로그**: BorderRadius.circular(24) - `rounded-3xl`
|
|
|
|
#### 6.1.5 아이콘 시스템
|
|
- **아이콘 세트**: Material Icons
|
|
- **아이콘 크기**:
|
|
- 표준: 24dp - `w-6 h-6`
|
|
- 작게: 18dp - `w-4.5 h-4.5`
|
|
- 크게: 36dp - `w-9 h-9`
|
|
- **아이콘 색상**: 주로 색상 팔레트의 주 색상과 보조 색상 활용
|
|
- 기본: `text-slate-700`
|
|
- 강조: `text-blue-500`
|
|
- 비활성화: `text-slate-300`
|
|
|
|
#### 6.1.6 간격 및 여백 체계
|
|
- **기본 여백(Margin)**: 16dp - `m-4`
|
|
- **기본 패딩(Padding)**: 16dp - `p-4`
|
|
- **섹션 간격**: 24dp - `gap-6`
|
|
- **항목 간격**: 8dp - `gap-2`
|
|
- **내부 패딩**:
|
|
- 카드: 16dp - `p-4`
|
|
- 버튼: 수평 16dp, 수직 12dp - `px-4 py-3`
|
|
- 입력 필드: 수평 12dp, 수직 12dp - `px-3 py-3`
|
|
|
|
#### 6.1.7 애니메이션 및 전환 효과
|
|
- **지속 시간**:
|
|
- 빠르게: 150ms - `duration-150`
|
|
- 표준: 300ms - `duration-300`
|
|
- 천천히: 500ms - `duration-500`
|
|
- **이징 함수**:
|
|
- 기본: 'ease-in-out' - `ease-in-out`
|
|
- 진입: 'ease-out' - `ease-out`
|
|
- 퇴장: 'ease-in' - `ease-in`
|
|
- **기본 애니메이션**:
|
|
- 페이드 인: `opacity-0 animate-fadeIn`
|
|
- 슬라이드 업: `translate-y-4 animate-slideUp`
|
|
- 확장: `scale-95 animate-scaleUp`
|
|
|
|
### 6.2 화면별 UI 레이아웃 상세
|
|
|
|
#### 6.2.1 스플래시 화면
|
|
- **배경**: 그라데이션 배경 (시작: `#3B82F6`, 끝: `#60A5FA`)
|
|
- **로고**: 앱 로고 중앙 배치, 애니메이션 적용
|
|
- 페이드 인 애니메이션 (0.0 → 1.0, 300ms)
|
|
- 스케일 애니메이션 (0.8 → 1.0, 400ms)
|
|
- **앱 이름**: 로고 아래 Text 위젯, 흰색(#FFFFFF)
|
|
- **애니메이션 효과**:
|
|
- 물결 파동 배경 (AnimatedWaveBackground 위젯)
|
|
- 파티클 효과 (20개의 움직이는 작은 원형 위젯)
|
|
|
|
#### 6.2.2 메인 화면
|
|
- **앱바**:
|
|
- 제목: "SubManager" (좌측 정렬)
|
|
- 설정 버튼 (우측 정렬)
|
|
- 투명도 변화: 스크롤에 따라 0 → 1.0
|
|
- **상단 요약 카드**:
|
|
- 파란색 그라데이션 배경
|
|
- 총 구독 수 표시 (큰 숫자)
|
|
- 총 지출 금액 표시 (중간 크기 텍스트)
|
|
- 물결 애니메이션 배경
|
|
- Card 형태, 모서리 둥글게
|
|
- 터치 시 분석 화면으로 이동
|
|
- **카테고리 헤더**:
|
|
- 카테고리 이름 (좌측)
|
|
- 구독 수 및 화살표 아이콘 (우측)
|
|
- **구독 카드 리스트**:
|
|
- 카테고리별로 그룹화
|
|
- 각 구독은 Card 위젯으로 표현
|
|
- 좌측: 웹사이트 아이콘/이미지
|
|
- 중앙: 서비스명, 다음 결제일
|
|
- 우측: 금액, 통화 단위
|
|
- 스와이프 액션: 편집/삭제
|
|
- **플로팅 액션 버튼**:
|
|
- 우측 하단 고정
|
|
- + 아이콘
|
|
- 주 색상 배경
|
|
- 누르면 구독 추가 화면으로 이동
|
|
|
|
#### 6.2.3 구독 추가 화면
|
|
- **앱바**:
|
|
- 제목: "구독 추가"
|
|
- 뒤로 가기 버튼
|
|
- SMS 스캔 버튼 (우측)
|
|
- **입력 폼**:
|
|
- 각 필드는 Material 디자인의 OutlinedTextField
|
|
- 에러 메시지는 필드 하단에 빨간색 텍스트
|
|
- 필드 포커스 시 하이라이트 효과
|
|
- **서비스명 필드**:
|
|
- 자동완성 기능 (인기 서비스 목록)
|
|
- 입력 시 웹사이트 URL 추천
|
|
- **금액 필드**:
|
|
- TextInputType.number 키보드
|
|
- 통화 선택 드롭다운 (KRW/USD)
|
|
- USD 선택 시 환율 정보 표시
|
|
- **결제 주기 선택**:
|
|
- SegmentedButton 또는 DropdownButton
|
|
- 옵션: 월간, 연간, 주간
|
|
- **결제일 선택**:
|
|
- DatePicker 호출 아이콘 버튼
|
|
- 선택된 날짜 표시
|
|
- **카테고리 선택**:
|
|
- 카테고리 칩 목록 (가로 스크롤)
|
|
- 각 칩은 카테고리 색상 적용
|
|
- 선택된 카테고리 하이라이트
|
|
- **웹사이트 URL 필드**:
|
|
- 선택적 입력 필드
|
|
- URL 형식 검증
|
|
- **저장 버튼**:
|
|
- 하단 고정
|
|
- 넓은 버튼 (화면 폭의 80%)
|
|
- 주 색상 배경
|
|
- 저장 중에는 로딩 인디케이터 표시
|
|
|
|
#### 6.2.4 구독 상세 화면
|
|
- **헤더**:
|
|
- 서비스 아이콘/이미지 (큰 크기)
|
|
- 서비스명 (큰 텍스트)
|
|
- 웹사이트 방문 버튼
|
|
- **정보 카드**:
|
|
- 각 정보는 카드 섹션으로 구분
|
|
- 편집 모드 전환 버튼 (우측 상단)
|
|
- **금액 정보**:
|
|
- 큰 글씨로 금액 표시
|
|
- 통화 단위 표시
|
|
- 환율 정보 (해당하는 경우)
|
|
- **결제 주기 정보**:
|
|
- 주기 아이콘 및 텍스트
|
|
- 다음 결제일까지 남은 일수
|
|
- **카테고리 정보**:
|
|
- 카테고리 색상 칩
|
|
- 카테고리 이름
|
|
- **편집 모드 UI**:
|
|
- 각 필드가 편집 가능한 상태로 전환
|
|
- 저장/취소 버튼 표시
|
|
- **삭제 버튼**:
|
|
- 하단 배치
|
|
- 빨간색 텍스트와 아이콘
|
|
- 누르면 확인 다이얼로그 표시
|
|
|
|
#### 6.2.5 분석 화면
|
|
- **앱바**:
|
|
- 제목: "지출 분석"
|
|
- 뒤로 가기 버튼
|
|
- 기간 필터 옵션 (우측)
|
|
- **총 지출 요약 카드**:
|
|
- 큰 숫자로 총액 표시
|
|
- 이전 기간 대비 증감률 (화살표 아이콘)
|
|
- 그라데이션 배경
|
|
- **그래프 섹션**:
|
|
- 각 그래프는 카드 형태로 표시
|
|
- 그래프 제목 및 설명
|
|
- 인터랙티브 요소 (터치 피드백)
|
|
- **막대 그래프 (월별 지출)**:
|
|
- fl_chart BarChart 위젯
|
|
- x축: 월 레이블
|
|
- y축: 금액
|
|
- 데이터 막대: 그라데이션 색상
|
|
- **파이 차트 (서비스별 지출)**:
|
|
- fl_chart PieChart 위젯
|
|
- 각 섹션: 카테고리 색상
|
|
- 터치 시 확대 효과
|
|
- 레이블: 퍼센트 및 금액
|
|
- **카테고리별 지출 목록**:
|
|
- 각 카테고리: 가로 막대형 진행 표시줄
|
|
- 카테고리 아이콘, 이름, 금액, 퍼센트
|
|
- 내림차순 정렬 (금액 기준)
|
|
|
|
#### 6.2.6 SMS 스캔 화면
|
|
- **헤더**:
|
|
- 제목: "구독 자동 탐지"
|
|
- 설명 텍스트
|
|
- **스캔 버튼**:
|
|
- 큰 원형 버튼
|
|
- 스캔 아이콘
|
|
- 주 색상 배경
|
|
- **스캔 중 UI**:
|
|
- 원형 프로그레스 인디케이터
|
|
- "스캔 중..." 텍스트
|
|
- **결과 리스트**:
|
|
- 각 발견된 구독은 카드 형태
|
|
- 서비스명, 금액, 반복 횟수 표시
|
|
- 체크박스로 선택 가능
|
|
- **추가 버튼**:
|
|
- 하단 고정
|
|
- 선택된 항목 수 표시
|
|
- 비활성화 조건: 선택 항목 없음
|
|
|
|
#### 6.2.7 설정 화면
|
|
- **앱바**:
|
|
- 제목: "설정"
|
|
- 뒤로 가기 버튼
|
|
- **설정 그룹**:
|
|
- 섹션 제목
|
|
- 구분선으로 섹션 구분
|
|
- **알림 설정**:
|
|
- 알림 마스터 스위치
|
|
- 결제 예정 알림 스위치
|
|
- 미사용 서비스 알림 스위치
|
|
- **보안 설정**:
|
|
- 앱 잠금 마스터 스위치
|
|
- 생체 인증 스위치
|
|
- **언어 설정**:
|
|
- 라디오 버튼 목록
|
|
- 현재 선택된 언어 표시
|
|
- **데이터 관리**:
|
|
- 백업/복원 버튼
|
|
- 데이터 초기화 버튼 (빨간색 경고 아이콘)
|
|
- **앱 정보**:
|
|
- 앱 버전, 개발자 정보
|
|
- 오픈소스 라이선스 링크
|
|
|
|
### 6.3 모션 디자인 시스템
|
|
|
|
#### 6.3.1 기본 애니메이션 속성
|
|
- **기본 지속 시간**:
|
|
- 컴포넌트 진입/퇴장: 300ms
|
|
- 상태 변화: 200ms
|
|
- 강조 효과: 150ms
|
|
- 페이지 전환: 400ms
|
|
- **이징 함수**:
|
|
- 기본: cubic-bezier(0.4, 0, 0.2, 1) - 부드러운 가속 및 감속 (ease-in-out)
|
|
- 진입: cubic-bezier(0.0, 0, 0.2, 1) - 빠른 시작, 점진적 감속 (ease-out)
|
|
- 퇴장: cubic-bezier(0.4, 0, 1, 1) - 점진적 가속, 빠른 종료 (ease-in)
|
|
- 강조: cubic-bezier(0.2, 0, 0, 1) - 반동 효과 (spring)
|
|
|
|
#### 6.3.2 화면 전환 효과
|
|
- **기본 화면 전환** (일반 화면 간 이동):
|
|
- 방식: 밀어내기 효과 (slide)
|
|
- 새 화면: 오른쪽에서 왼쪽으로 슬라이드 인
|
|
- 기존 화면: 왼쪽으로 밀려나가며 약간 투명해짐
|
|
- 지속 시간: 300ms
|
|
- 코드 구현:
|
|
```dart
|
|
PageRouteBuilder(
|
|
pageBuilder: (context, animation, secondaryAnimation) => NewScreen(),
|
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
|
var begin = Offset(1.0, 0.0);
|
|
var end = Offset.zero;
|
|
var curve = Curves.easeOutCubic;
|
|
var tween = Tween(begin: begin, end: end).chain(CurveTween(curve: curve));
|
|
return SlideTransition(position: animation.drive(tween), child: child);
|
|
},
|
|
)
|
|
```
|
|
|
|
- **모달 화면 전환** (세부 정보, 설정 등):
|
|
- 방식: 페이드 인 + 슬라이드 업
|
|
- 새 화면: 아래에서 위로 슬라이드 인 + 불투명도 증가
|
|
- 백드롭: 반투명 어두움으로 페이드 인
|
|
- 지속 시간: 400ms
|
|
- 코드 구현:
|
|
```dart
|
|
showModalBottomSheet(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: Colors.transparent,
|
|
barrierColor: Colors.black54,
|
|
transitionAnimationController: AnimationController(
|
|
vsync: this,
|
|
duration: Duration(milliseconds: 400),
|
|
),
|
|
builder: (context) => /* 모달 내용 */,
|
|
)
|
|
```
|
|
|
|
- **팝업 전환** (알림, 확인 다이얼로그):
|
|
- 방식: 스케일 + 페이드 인
|
|
- 새 화면: 중앙에서 스케일 업(0.8 → 1.0) + 불투명도 증가
|
|
- 백드롭: 반투명 어두움으로 페이드 인
|
|
- 지속 시간: 250ms
|
|
- 코드 구현:
|
|
```dart
|
|
showDialog(
|
|
context: context,
|
|
barrierDismissible: true,
|
|
builder: (BuildContext context) {
|
|
return TweenAnimationBuilder<double>(
|
|
duration: Duration(milliseconds: 250),
|
|
curve: Curves.easeOutCubic,
|
|
tween: Tween(begin: 0.8, end: 1.0),
|
|
builder: (context, value, child) {
|
|
return Transform.scale(
|
|
scale: value,
|
|
child: Opacity(
|
|
opacity: value,
|
|
child: AlertDialog(/* 다이얼로그 내용 */),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
},
|
|
)
|
|
```
|
|
|
|
#### 6.3.3 스크롤 효과 및 동작
|
|
- **목록 스크롤 효과**:
|
|
- 오버스크롤: 탄력적 바운스 효과 (iOS 스타일)
|
|
- 스크롤바: 스크롤 중에만 표시되는 반투명 스크롤바
|
|
- 목록 항목 애니메이션: 스크롤 시 항목별 지연된 페이드 인 효과
|
|
- 코드 구현:
|
|
```dart
|
|
ListView.builder(
|
|
physics: BouncingScrollPhysics(),
|
|
itemCount: items.length,
|
|
itemBuilder: (context, index) {
|
|
return AnimatedBuilder(
|
|
animation: scrollController,
|
|
builder: (context, child) {
|
|
return AnimatedOpacity(
|
|
duration: Duration(milliseconds: 300),
|
|
opacity: index < scrollController.offset / 50 ? 1.0 : 0.0,
|
|
child: child,
|
|
);
|
|
},
|
|
child: ListItem(item: items[index]),
|
|
);
|
|
},
|
|
)
|
|
```
|
|
|
|
- **패럴랙스 효과** (주로 상세 화면):
|
|
- 배경 이미지: 스크롤 시 더 느리게 이동하여 깊이감 생성
|
|
- 카드 요소: 스크롤 속도에 따라 약간의 회전 및 이동
|
|
- 코드 구현:
|
|
```dart
|
|
ScrollController _scrollController = ScrollController();
|
|
// ...
|
|
return SingleChildScrollView(
|
|
controller: _scrollController,
|
|
child: Column(
|
|
children: [
|
|
AnimatedBuilder(
|
|
animation: _scrollController,
|
|
builder: (context, child) {
|
|
return Container(
|
|
height: 200 - _scrollController.offset * 0.5,
|
|
transform: Matrix4.translationValues(0, _scrollController.offset * 0.3, 0),
|
|
child: Image.asset('assets/header_bg.png', fit: BoxFit.cover),
|
|
);
|
|
},
|
|
),
|
|
// 나머지 콘텐츠
|
|
],
|
|
),
|
|
)
|
|
```
|
|
|
|
- **스크롤 감지 기반 UI 변화**:
|
|
- 앱바: 스크롤 다운 시 축소, 스크롤 업 시 확장
|
|
- FAB(플로팅 액션 버튼): 스크롤 다운 시 숨김, 스크롤 업 시 표시
|
|
- 코드 구현:
|
|
```dart
|
|
ScrollController _scrollController = ScrollController();
|
|
bool _isScrollingDown = false;
|
|
bool _isAppBarExpanded = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_scrollController.addListener(() {
|
|
if (_scrollController.position.userScrollDirection == ScrollDirection.reverse) {
|
|
if (!_isScrollingDown) {
|
|
setState(() {
|
|
_isScrollingDown = true;
|
|
_isAppBarExpanded = false;
|
|
});
|
|
}
|
|
}
|
|
if (_scrollController.position.userScrollDirection == ScrollDirection.forward) {
|
|
if (_isScrollingDown) {
|
|
setState(() {
|
|
_isScrollingDown = false;
|
|
_isAppBarExpanded = true;
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|
|
```
|
|
|
|
#### 6.3.4 마이크로 인터랙션 및 상태 변화
|
|
- **버튼 상태 변화**:
|
|
- 누름 효과: 스케일 다운(0.96) + 그림자 감소
|
|
- 호버 효과: 약간의 상승 + 그림자 증가
|
|
- 비활성화: 불투명도 감소(0.7) + 회색조 효과
|
|
- 코드 구현:
|
|
```dart
|
|
ElevatedButton(
|
|
onPressed: isEnabled ? () => handlePress() : null,
|
|
style: ElevatedButton.styleFrom(
|
|
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
|
elevation: 4,
|
|
).copyWith(
|
|
foregroundColor: MaterialStateProperty.resolveWith<Color>((states) {
|
|
if (states.contains(MaterialState.disabled)) return Colors.grey;
|
|
return Colors.white;
|
|
}),
|
|
backgroundColor: MaterialStateProperty.resolveWith<Color>((states) {
|
|
if (states.contains(MaterialState.disabled)) return Colors.grey[300]!;
|
|
if (states.contains(MaterialState.pressed)) return Colors.blue[700]!;
|
|
return Colors.blue[500]!;
|
|
}),
|
|
elevation: MaterialStateProperty.resolveWith<double>((states) {
|
|
if (states.contains(MaterialState.disabled)) return 0;
|
|
if (states.contains(MaterialState.pressed)) return 2;
|
|
return 4;
|
|
}),
|
|
overlayColor: MaterialStateProperty.all<Color>(Colors.white.withOpacity(0.1)),
|
|
),
|
|
child: Text('Button'),
|
|
)
|
|
```
|
|
|
|
- **필드 포커스 효과**:
|
|
- 포커스 진입: 테두리 색상 변경 + 약간의 확장(1.02)
|
|
- 오류 상태: 좌우 흔들림 애니메이션 + 빨간색 테두리
|
|
- 완료 상태: 페이드 인 체크 아이콘 + 녹색 테두리
|
|
- 코드 구현:
|
|
```dart
|
|
FocusNode _focusNode = FocusNode();
|
|
bool _hasError = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_focusNode.addListener(() {
|
|
setState(() {});
|
|
});
|
|
}
|
|
|
|
// 위젯 내부
|
|
AnimatedContainer(
|
|
duration: Duration(milliseconds: 200),
|
|
curve: Curves.easeInOut,
|
|
transform: _hasError
|
|
? (Matrix4.identity()..translate(sin(DateTime.now().millisecondsSinceEpoch / 100) * 5.0, 0.0))
|
|
: Matrix4.identity(),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: _hasError
|
|
? Colors.red
|
|
: (_focusNode.hasFocus ? Colors.blue : Colors.grey),
|
|
width: _focusNode.hasFocus ? 2.0 : 1.0,
|
|
),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: TextField(
|
|
focusNode: _focusNode,
|
|
decoration: InputDecoration(
|
|
border: InputBorder.none,
|
|
contentPadding: EdgeInsets.all(12),
|
|
),
|
|
),
|
|
)
|
|
```
|
|
|
|
- **체크 박스/토글 효과**:
|
|
- 체크 표시: 페이드 인 + 스케일 업(0.5 → 1.0)
|
|
- 토글 슬라이더: 부드러운 좌우 이동 + 배경색 변경
|
|
- 코드 구현:
|
|
```dart
|
|
AnimatedContainer(
|
|
duration: Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
width: 50,
|
|
height: 30,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(15),
|
|
color: isEnabled ? Colors.blue[500] : Colors.grey[300],
|
|
),
|
|
child: Stack(
|
|
children: [
|
|
AnimatedPositioned(
|
|
duration: Duration(milliseconds: 300),
|
|
curve: Curves.easeInOut,
|
|
left: isEnabled ? 20 : 0,
|
|
top: 0,
|
|
bottom: 0,
|
|
child: Container(
|
|
width: 30,
|
|
height: 30,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
color: Colors.white,
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withOpacity(0.1),
|
|
blurRadius: 4,
|
|
spreadRadius: 1,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
)
|
|
```
|
|
|
|
#### 6.3.5 특수 효과 및 고급 애니메이션
|
|
- **물결 배경 효과** (메인 카드 및 스플래시 화면):
|
|
- 구성: 여러 층의 곡선 Path를 사용한 파동 효과
|
|
- 애니메이션: sin 함수 기반으로 연속적인 물결 모양 변형
|
|
- 상호작용: 터치 위치에 따라 물결 진폭 변화
|
|
- 코드 구현:
|
|
```dart
|
|
class WaveBackground extends StatefulWidget {
|
|
@override
|
|
_WaveBackgroundState createState() => _WaveBackgroundState();
|
|
}
|
|
|
|
class _WaveBackgroundState extends State<WaveBackground> with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: Duration(seconds: 4),
|
|
)..repeat();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_controller.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return CustomPaint(
|
|
painter: WavePainter(
|
|
animation: _controller,
|
|
waveColor: Colors.blue.withOpacity(0.3),
|
|
),
|
|
child: Container(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class WavePainter extends CustomPainter {
|
|
final Animation<double> animation;
|
|
final Color waveColor;
|
|
|
|
WavePainter({required this.animation, required this.waveColor});
|
|
|
|
@override
|
|
void paint(Canvas canvas, Size size) {
|
|
// 물결 그리기 로직...
|
|
}
|
|
|
|
@override
|
|
bool shouldRepaint(WavePainter oldDelegate) => true;
|
|
}
|
|
```
|
|
|
|
- **파티클 효과** (달성, 저장 성공 시):
|
|
- 구성: 랜덤한 크기, 색상, 방향으로 분산되는 작은 원형 파티클
|
|
- 애니메이션: 중심점에서 밖으로 퍼져나가는 궤적 + 페이드 아웃
|
|
- 코드 구현:
|
|
```dart
|
|
class ParticleEffect extends StatefulWidget {
|
|
@override
|
|
_ParticleEffectState createState() => _ParticleEffectState();
|
|
}
|
|
|
|
class _ParticleEffectState extends State<ParticleEffect> with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late List<Particle> particles;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: Duration(milliseconds: 1000),
|
|
)..forward();
|
|
|
|
particles = List.generate(30, (index) => Particle());
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _controller,
|
|
builder: (context, child) {
|
|
return CustomPaint(
|
|
painter: ParticlePainter(
|
|
animation: _controller,
|
|
particles: particles,
|
|
),
|
|
child: Container(),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
class Particle {
|
|
late double speed;
|
|
late double theta;
|
|
late double radius;
|
|
late Color color;
|
|
|
|
Particle() {
|
|
// 랜덤 초기화 로직...
|
|
}
|
|
}
|
|
|
|
class ParticlePainter extends CustomPainter {
|
|
// 구현...
|
|
}
|
|
```
|
|
|
|
- **카드 스택 효과** (메인 화면 구독 카드):
|
|
- 구성: 스택에 쌓인 카드들이 3D 회전 및 깊이감 표현
|
|
- 애니메이션: 카드 선택 시 카드가 앞으로 나오며 확대
|
|
- 스와이프: 스와이프로 카드 스택 탐색
|
|
- 코드 구현:
|
|
```dart
|
|
class CardStack extends StatefulWidget {
|
|
final List<Widget> cards;
|
|
CardStack({required this.cards});
|
|
|
|
@override
|
|
_CardStackState createState() => _CardStackState();
|
|
}
|
|
|
|
class _CardStackState extends State<CardStack> {
|
|
int _selectedIndex = 0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onHorizontalDragEnd: (details) {
|
|
if (details.primaryVelocity! > 0) {
|
|
// 오른쪽 스와이프
|
|
setState(() {
|
|
_selectedIndex = math.max(0, _selectedIndex - 1);
|
|
});
|
|
} else if (details.primaryVelocity! < 0) {
|
|
// 왼쪽 스와이프
|
|
setState(() {
|
|
_selectedIndex = math.min(widget.cards.length - 1, _selectedIndex + 1);
|
|
});
|
|
}
|
|
},
|
|
child: Stack(
|
|
alignment: Alignment.center,
|
|
children: List.generate(widget.cards.length, (index) {
|
|
// 각 카드의 위치 및 크기 계산
|
|
final offset = index - _selectedIndex;
|
|
final isSelected = offset == 0;
|
|
|
|
return AnimatedPositioned(
|
|
duration: Duration(milliseconds: 300),
|
|
curve: Curves.easeOutCubic,
|
|
top: isSelected ? 0 : 10 * offset.abs(),
|
|
right: isSelected ? 0 : offset < 0 ? -20 * offset.abs() : 0,
|
|
left: isSelected ? 0 : offset > 0 ? -20 * offset.abs() : 0,
|
|
child: AnimatedOpacity(
|
|
duration: Duration(milliseconds: 300),
|
|
opacity: offset.abs() < 3 ? 1.0 - (0.2 * offset.abs()) : 0,
|
|
child: AnimatedScale(
|
|
duration: Duration(milliseconds: 300),
|
|
scale: 1.0 - (0.05 * offset.abs()),
|
|
child: widget.cards[index],
|
|
),
|
|
),
|
|
);
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
- **로딩 및 진행 애니메이션**:
|
|
- 스켈레톤 로딩: 펄스 애니메이션이 있는 그레이스케일 레이아웃
|
|
- 원형 진행: 원형 프로그레스 인디케이터 + 숫자 증가 애니메이션
|
|
- 성공 체크: 그리기 애니메이션으로 구현된 체크 마크
|
|
- 코드 구현:
|
|
```dart
|
|
// 스켈레톤 로딩
|
|
class SkeletonLoading extends StatefulWidget {
|
|
final Widget child;
|
|
|
|
const SkeletonLoading({Key? key, required this.child}) : super(key: key);
|
|
|
|
@override
|
|
_SkeletonLoadingState createState() => _SkeletonLoadingState();
|
|
}
|
|
|
|
class _SkeletonLoadingState extends State<SkeletonLoading> with SingleTickerProviderStateMixin {
|
|
late AnimationController _controller;
|
|
late Animation<double> _animation;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_controller = AnimationController(
|
|
vsync: this,
|
|
duration: Duration(milliseconds: 1500),
|
|
)..repeat(reverse: true);
|
|
|
|
_animation = Tween<double>(begin: 0.4, end: 0.9).animate(_controller);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return AnimatedBuilder(
|
|
animation: _animation,
|
|
builder: (context, child) {
|
|
return ShaderMask(
|
|
shaderCallback: (rect) {
|
|
return LinearGradient(
|
|
colors: [
|
|
Colors.grey[300]!,
|
|
Colors.grey[100]!,
|
|
Colors.grey[300]!,
|
|
],
|
|
stops: [
|
|
0.0,
|
|
_animation.value,
|
|
1.0,
|
|
],
|
|
).createShader(rect);
|
|
},
|
|
child: Container(
|
|
color: Colors.white,
|
|
child: widget.child,
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
## 7. 핵심 알고리즘 및 기능 구현
|
|
|
|
### 7.1 SMS 기반 구독 탐지 알고리즘
|
|
```dart
|
|
// sms_scanner.dart 내부 구현 예시
|
|
const platform = MethodChannel('com.submanager/sms');
|
|
try {
|
|
smsList = await platform.invokeMethod('scanSubscriptions');
|
|
print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
|
|
} catch (e) {
|
|
print('SmsScanner: 네이티브 호출 실패: $e');
|
|
// 오류 발생 시 빈 목록 반환
|
|
return [];
|
|
}
|
|
```
|
|
|
|
### 7.2 구독 서비스 자동 카테고리 분류 알고리즘
|
|
```dart
|
|
// subscription_category_helper.dart 내부 구현
|
|
void autoDetectCategory(String serviceName) {
|
|
// OTT 서비스 확인
|
|
if (SubscriptionUrlMatcher.ottServices.keys.any((key) =>
|
|
serviceName.toLowerCase().contains(key.toLowerCase()) ||
|
|
key.toLowerCase().contains(serviceName.toLowerCase()))) {
|
|
try {
|
|
final ottCategory = categoryProvider.categories.firstWhere(
|
|
(cat) => cat.name.contains('OTT') || cat.name.contains('스트리밍'),
|
|
);
|
|
_selectedCategoryId = ottCategory.id;
|
|
return;
|
|
} catch (_) {
|
|
// 카테고리를 찾지 못한 경우 기본 처리
|
|
}
|
|
}
|
|
|
|
// 음악 서비스 확인
|
|
if (SubscriptionUrlMatcher.musicServices.keys.any((key) =>
|
|
serviceName.toLowerCase().contains(key.toLowerCase()) ||
|
|
key.toLowerCase().contains(serviceName.toLowerCase()))) {
|
|
try {
|
|
final musicCategory = categoryProvider.categories.firstWhere(
|
|
(cat) => cat.name.contains('음악') || cat.name.contains('스트리밍'),
|
|
);
|
|
_selectedCategoryId = musicCategory.id;
|
|
return;
|
|
} catch (_) {
|
|
// 카테고리를 찾지 못한 경우 기본 처리
|
|
}
|
|
}
|
|
|
|
// AI 서비스, 프로그래밍/개발, 오피스/협업 툴 등 확인 로직 생략...
|
|
|
|
// 기타 서비스 처리
|
|
try {
|
|
final otherCategory = categoryProvider.categories.firstWhere(
|
|
(cat) => cat.name.contains('기타'),
|
|
);
|
|
_selectedCategoryId = otherCategory.id;
|
|
} catch (_) {
|
|
// 기타 카테고리도 없는 경우 첫 번째 카테고리 선택
|
|
if (categoryProvider.categories.isNotEmpty) {
|
|
_selectedCategoryId = categoryProvider.categories.first.id;
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.3 환율 변환 및 캐싱 알고리즘
|
|
```dart
|
|
// exchange_rate_service.dart 내부 구현
|
|
class ExchangeRateService {
|
|
// 싱글톤 인스턴스
|
|
static final ExchangeRateService _instance = ExchangeRateService._internal();
|
|
factory ExchangeRateService() => _instance;
|
|
ExchangeRateService._internal();
|
|
|
|
// 캐싱된 환율 정보
|
|
double? _usdToKrwRate;
|
|
DateTime? _lastUpdated;
|
|
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';
|
|
|
|
Future<double?> getUsdToKrwRate() async {
|
|
// 캐싱된 데이터 있고 6시간 이내면 캐싱된 데이터 반환
|
|
if (_usdToKrwRate != null && _lastUpdated != null) {
|
|
final difference = DateTime.now().difference(_lastUpdated!);
|
|
if (difference.inHours < 6) {
|
|
return _usdToKrwRate;
|
|
}
|
|
}
|
|
|
|
try {
|
|
// API 요청
|
|
final response = await http.get(Uri.parse(_apiUrl));
|
|
|
|
if (response.statusCode == 200) {
|
|
final data = json.decode(response.body);
|
|
_usdToKrwRate = data['rates']['KRW'].toDouble();
|
|
_lastUpdated = DateTime.now();
|
|
return _usdToKrwRate;
|
|
} else {
|
|
// 실패 시 캐싱된 값이라도 반환
|
|
return _usdToKrwRate;
|
|
}
|
|
} catch (e) {
|
|
// 오류 발생 시 캐싱된 값이라도 반환
|
|
return _usdToKrwRate;
|
|
}
|
|
}
|
|
|
|
// USD 금액을 KRW로 변환
|
|
Future<double?> convertUsdToKrw(double usdAmount) async {
|
|
final rate = await getUsdToKrwRate();
|
|
if (rate != null) {
|
|
return usdAmount * rate;
|
|
}
|
|
return null;
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.4 결제일 알림 스케줄링 알고리즘
|
|
```dart
|
|
// notification_service.dart 내부 구현
|
|
static Future<void> schedulePaymentReminder({
|
|
required int id,
|
|
required String serviceName,
|
|
required double amount,
|
|
required DateTime billingDate,
|
|
}) async {
|
|
// 결제 3일 전 오전 10시에 알림 설정
|
|
final scheduledDate = billingDate
|
|
.subtract(const Duration(days: 3))
|
|
.copyWith(hour: 10, minute: 0, second: 0, millisecond: 0, microsecond: 0);
|
|
|
|
await _notifications.zonedSchedule(
|
|
id,
|
|
'구독 결제 예정 알림',
|
|
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 3일 후 결제 예정입니다.',
|
|
tz.TZDateTime.from(scheduledDate, tz.local),
|
|
const NotificationDetails(
|
|
android: AndroidNotificationDetails(
|
|
'subscription_channel',
|
|
'Subscription Notifications',
|
|
channelDescription: 'Channel for subscription reminders',
|
|
importance: Importance.high,
|
|
priority: Priority.high,
|
|
),
|
|
iOS: DarwinNotificationDetails(),
|
|
),
|
|
uiLocalNotificationDateInterpretation:
|
|
UILocalNotificationDateInterpretation.absoluteTime,
|
|
androidScheduleMode: AndroidScheduleMode.exactAllowWhileIdle,
|
|
);
|
|
}
|
|
```
|
|
|
|
### 7.5 구독료 통계 계산 알고리즘
|
|
```dart
|
|
Future<void> _calculateTotalExpense() async {
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
|
final subscriptions = provider.subscriptions;
|
|
|
|
if (subscriptions.isEmpty) {
|
|
setState(() {
|
|
_totalExpense = 0.0;
|
|
_isLoading = false;
|
|
});
|
|
return;
|
|
}
|
|
|
|
// 모든 구독의 월 비용을 원화로 환산하여 계산
|
|
final total = await CurrencyUtil.calculateTotalMonthlyExpense(subscriptions);
|
|
|
|
setState(() {
|
|
_totalExpense = total;
|
|
_isLoading = false;
|
|
});
|
|
} catch (e) {
|
|
debugPrint('총 지출 계산 오류: $e');
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
```
|
|
|
|
### 7.6 서비스 데이터베이스 구현
|
|
|
|
구독 서비스의 URL 및 해지 페이지를 관리하기 위한 데이터베이스를 구현합니다. 사용자에게 서비스 관련 정보를 쉽게 제공하기 위해 다음과 같은 데이터 구조를 사용합니다:
|
|
|
|
```dart
|
|
// 서비스 URL 매핑
|
|
static final Map<String, String> serviceUrls = {
|
|
'netflix': 'https://www.netflix.com',
|
|
'넷플릭스': 'https://www.netflix.com',
|
|
// ...많은 서비스 매핑...
|
|
};
|
|
|
|
// 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들)
|
|
static final Map<String, String> cancellationUrls = {
|
|
'netflix': 'https://help.netflix.com/ko/node/407',
|
|
'넷플릭스': 'https://help.netflix.com/ko/node/407',
|
|
// ...많은 서비스 해지 페이지 매핑...
|
|
};
|
|
```
|
|
|
|
이 데이터는 `SubscriptionUrlMatcher` 서비스 클래스에서 관리되며, 서비스명으로 URL을 검색하거나 해지 페이지를 안내하는 기능을 제공합니다.
|
|
|
|
## 8. 테스트 및 품질 보증 계획
|
|
|
|
### 8.1 테스트 전략
|
|
|
|
#### 8.1.1 단위 테스트
|
|
- **대상**: Provider 클래스, 서비스 클래스, 유틸리티 함수
|
|
- **도구**: Flutter Test 프레임워크
|
|
- **테스트 사례 예시**:
|
|
```dart
|
|
void main() {
|
|
late SubscriptionProvider provider;
|
|
late MockHiveBox mockBox;
|
|
|
|
setUp(() {
|
|
mockBox = MockHiveBox();
|
|
provider = SubscriptionProvider();
|
|
// 의존성 주입
|
|
});
|
|
|
|
test('새 구독 추가 시 목록에 추가되어야 함', () async {
|
|
// Given
|
|
final subscription = SubscriptionModel(
|
|
id: 'test-id',
|
|
serviceName: 'Test Service',
|
|
monthlyCost: 9900,
|
|
billingCycle: '월간',
|
|
nextBillingDate: DateTime.now().add(Duration(days: 30)),
|
|
);
|
|
|
|
// When
|
|
await provider.addSubscription(
|
|
serviceName: subscription.serviceName,
|
|
monthlyCost: subscription.monthlyCost,
|
|
billingCycle: subscription.billingCycle,
|
|
nextBillingDate: subscription.nextBillingDate,
|
|
);
|
|
|
|
// Then
|
|
expect(provider.subscriptions.length, 1);
|
|
expect(provider.subscriptions.first.serviceName, 'Test Service');
|
|
});
|
|
|
|
test('총 지출 계산이 정확해야 함', () {
|
|
// Given
|
|
final subscription1 = SubscriptionModel(/*...*/);
|
|
final subscription2 = SubscriptionModel(/*...*/);
|
|
provider.subscriptions = [subscription1, subscription2];
|
|
|
|
// When
|
|
final total = provider.totalMonthlyExpense;
|
|
|
|
// Then
|
|
expect(total, subscription1.monthlyCost + subscription2.monthlyCost);
|
|
});
|
|
}
|
|
```
|
|
|
|
#### 8.1.2 통합 테스트
|
|
- **대상**: 화면 간 이동, 데이터 흐름, 상태 관리
|
|
- **도구**: Flutter Integration Test
|
|
- **테스트 사례 예시**:
|
|
```dart
|
|
void main() {
|
|
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
|
|
|
testWidgets('구독 추가 후 메인 화면에 표시되는지 확인', (tester) async {
|
|
// 앱 시작
|
|
app.main();
|
|
await tester.pumpAndSettle();
|
|
|
|
// 구독 추가 화면으로 이동
|
|
await tester.tap(find.byIcon(Icons.add));
|
|
await tester.pumpAndSettle();
|
|
|
|
// 구독 정보 입력
|
|
await tester.enterText(
|
|
find.byKey(Key('service_name_field')),
|
|
'Test Service'
|
|
);
|
|
await tester.enterText(
|
|
find.byKey(Key('monthly_cost_field')),
|
|
'9900'
|
|
);
|
|
// 나머지 필드 입력...
|
|
|
|
// 저장 버튼 클릭
|
|
await tester.tap(find.text('저장하기'));
|
|
await tester.pumpAndSettle();
|
|
|
|
// 메인 화면에 추가된 구독이 표시되는지 확인
|
|
expect(find.text('Test Service'), findsOneWidget);
|
|
expect(find.text('₩9,900'), findsOneWidget);
|
|
});
|
|
}
|
|
```
|
|
|
|
#### 8.1.3 UI/UX 테스트
|
|
- **대상**: 화면 레이아웃, 애니메이션, 반응성
|
|
- **방법**: 다양한 화면 크기 및 기기에서 수동 테스트
|
|
- **체크리스트**:
|
|
- [ ] 모든 화면이 다양한 기기 크기에서 적절히 렌더링됨
|
|
- [ ] 애니메이션이 부드럽게 실행됨
|
|
- [ ] 터치 영역이 충분히 큼
|
|
- [ ] 색상 대비가 접근성 기준 충족
|
|
- [ ] 키보드 네비게이션 가능
|
|
|
|
### 8.2 성능 테스트
|
|
|
|
#### 8.2.1 메모리 사용량
|
|
- **목표**: 앱 사용 중 메모리 누수 없음
|
|
- **측정 방법**: DevTools Memory 프로파일러
|
|
- **허용 기준**: 장시간 사용 후에도 비정상적 메모리 증가 없음
|
|
|
|
#### 8.2.2 응답성
|
|
- **목표**: 사용자 인터랙션에 빠르게 반응
|
|
- **측정 방법**: Flutter Performance 오버레이
|
|
- **허용 기준**:
|
|
- UI 스레드 지연 < 16ms (60fps 유지)
|
|
- 화면 전환 < 300ms
|
|
- 데이터 로딩 시 적절한 로딩 인디케이터 표시
|
|
|
|
#### 8.2.3 배터리 사용량
|
|
- **목표**: 최소한의 배터리 소모
|
|
- **측정 방법**: OS 배터리 사용량 통계
|
|
- **허용 기준**: 백그라운드에서 최소한의 배터리 사용
|
|
|
|
### 8.3 호환성 테스트
|
|
|
|
#### 8.3.1 OS 버전 호환성
|
|
- **Android**: API 레벨 21 (Android 5.0) 이상
|
|
- **iOS**: iOS 12.0 이상
|
|
- **테스트 기기 목록**:
|
|
- Android: Pixel 3, Samsung Galaxy S10, Galaxy Tab S7
|
|
- iOS: iPhone 8, iPhone 12, iPad 6세대
|
|
|
|
#### 8.3.2 화면 크기 호환성
|
|
- **작은 화면**: 4-5인치 스마트폰
|
|
- **중간 화면**: 5.5-6.5인치 스마트폰
|
|
- **큰 화면**: 태블릿 (7-10인치)
|
|
|
|
### 8.4 보안 테스트
|
|
|
|
#### 8.4.1 데이터 저장 보안
|
|
- **테스트 항목**: 민감 데이터가 암호화되어 저장되는지 확인
|
|
- **방법**: 기기 파일 시스템 검사, 앱 데이터 추출 시도
|
|
|
|
#### 8.4.2 권한 관리
|
|
- **테스트 항목**: 필요한 최소한의 권한만 요청하는지 확인
|
|
- **방법**: 앱 매니페스트 검사, 런타임 권한 요청 검증
|
|
|
|
## 9. 구현 가이드라인
|
|
|
|
### 9.1 코딩 표준
|
|
|
|
#### 9.1.1 명명 규칙
|
|
- **클래스명**: PascalCase (예: `SubscriptionModel`, `MainScreen`)
|
|
- **변수/메서드명**: camelCase (예: `monthlyCost`, `addSubscription()`)
|
|
- **상수명**: SNAKE_CASE (예: `MAX_SUBSCRIPTIONS`)
|
|
- **비공개 멤버**: 언더스코어 접두사 (예: `_subscriptions`, `_loadData()`)
|
|
- **파일명**: snake_case (예: `subscription_model.dart`, `main_screen.dart`)
|
|
|
|
#### 9.1.2 코드 구성
|
|
- **클래스 멤버 순서**:
|
|
1. 클래스 변수 (static)
|
|
2. 인스턴스 변수
|
|
3. 생성자
|
|
4. getter/setter
|
|
5. 메서드 (private 메서드는 호출되는 public 메서드 직후)
|
|
- **주석 형식**:
|
|
- 클래스: 기능 및 역할 설명
|
|
- 메서드: 파라미터, 반환값, 예외 설명
|
|
- 복잡한 로직: 알고리즘 설명
|
|
- **import 구성**:
|
|
1. Dart 기본 라이브러리
|
|
2. Flutter 라이브러리
|
|
3. 외부 패키지
|
|
4. 프로젝트 내 패키지 (상대 경로)
|
|
|
|
#### 9.1.3 SOLID 원칙 적용
|
|
- **단일 책임 원칙(SRP)**: 각 클래스는 하나의 책임만 가짐
|
|
- `SubscriptionProvider`는 구독 데이터 CRUD 작업 처리만 담당
|
|
- `NotificationService`는 알림 관리만 담당
|
|
- **개방-폐쇄 원칙(OCP)**: 확장에 열려있고 수정에 닫혀있음
|
|
- 새 카테고리 추가 시 기존 코드 수정 없이 가능
|
|
- **리스코프 치환 원칙(LSP)**: 하위 클래스는 상위 클래스를 대체 가능
|
|
- **인터페이스 분리 원칙(ISP)**: 하나의 일반 인터페이스보다 특정 인터페이스 선호
|
|
- **의존성 역전 원칙(DIP)**: 추상화에 의존, 구체화에 의존하지 않음
|
|
|
|
#### 9.1.4 성능 최적화
|
|
- **상태 관리**:
|
|
- `setState()` 대신 `Provider`의 `notifyListeners()` 사용
|
|
- 필요한 부분만 갱신되도록 `Consumer` 위젯 적절히 배치
|
|
- **메모리 관리**:
|
|
- 큰 이미지는 캐싱 및 메모리 최적화
|
|
- 리스트는 `ListView.builder`로 화면에 표시된 항목만 빌드
|
|
- **애니메이션**:
|
|
- 복잡한 애니메이션은 `AnimationController` 활용
|
|
- `dispose()` 메서드에서 컨트롤러 해제
|
|
|
|
### 9.2 프로젝트 디렉토리 구조 가이드
|
|
|
|
```
|
|
/lib
|
|
├── main.dart # 앱 진입점
|
|
├── navigator_key.dart # 전역 네비게이터 키
|
|
├── screens/ # 화면 구성 파일
|
|
│ ├── main_screen.dart # 메인 화면
|
|
│ ├── add_subscription_screen.dart # 구독 추가 화면
|
|
│ ├── detail_screen.dart # 구독 상세 화면
|
|
│ ├── analysis_screen.dart # 분석 화면
|
|
│ ├── settings_screen.dart # 설정 화면
|
|
│ ├── sms_scan_screen.dart # SMS 스캔 화면
|
|
│ ├── splash_screen.dart # 스플래시 화면
|
|
│ ├── app_lock_screen.dart # 앱 잠금 화면
|
|
│ └── category_management_screen.dart # 카테고리 관리 화면
|
|
├── models/ # 데이터 모델
|
|
│ ├── subscription_model.dart # 구독 모델
|
|
│ ├── subscription_model.g.dart # 자동 생성 코드
|
|
│ ├── category_model.dart # 카테고리 모델
|
|
│ └── category_model.g.dart # 자동 생성 코드
|
|
├── providers/ # 상태 관리
|
|
│ ├── subscription_provider.dart # 구독 데이터 관리
|
|
│ ├── category_provider.dart # 카테고리 데이터 관리
|
|
│ ├── app_lock_provider.dart # 앱 잠금 관리
|
|
│ ├── notification_provider.dart # 알림 설정 관리
|
|
│ └── locale_provider.dart # 다국어 관리
|
|
├── services/ # 서비스 클래스
|
|
│ ├── notification_service.dart # 알림 서비스
|
|
│ ├── sms_scanner.dart # SMS 스캔 로직
|
|
│ ├── sms_service.dart # SMS 접근 서비스
|
|
│ ├── exchange_rate_service.dart # 환율 서비스
|
|
│ ├── currency_util.dart # 통화 유틸리티
|
|
│ └── subscription_url_matcher.dart # URL 매칭 서비스
|
|
├── utils/ # 유틸리티 함수
|
|
│ ├── format_helper.dart # 포맷팅 유틸리티
|
|
│ ├── animation_controller_helper.dart # 애니메이션 유틸리티
|
|
│ └── subscription_category_helper.dart # 카테고리 유틸리티
|
|
├── widgets/ # 재사용 위젯
|
|
│ ├── subscription_card.dart # 구독 카드 위젯
|
|
│ ├── main_summary_card.dart # 메인 요약 카드
|
|
│ ├── exchange_rate_widget.dart # 환율 정보 위젯
|
|
│ ├── website_icon.dart # 웹사이트 아이콘 위젯
|
|
│ ├── category_header_widget.dart # 카테고리 헤더
|
|
│ ├── subscription_list_widget.dart # 구독 목록 위젯
|
|
│ ├── animated_wave_background.dart # 웨이브 배경 위젯
|
|
│ ├── empty_state_widget.dart # 빈 상태 위젯
|
|
│ └── skeleton_loading.dart # 로딩 스켈레톤 위젯
|
|
├── theme/ # 테마 관련
|
|
│ ├── app_theme.dart # 앱 테마 정의
|
|
│ └── app_colors.dart # 색상 정의
|
|
└── l10n/ # 다국어 리소스
|
|
├── app_localizations.dart # 다국어 클래스
|
|
├── app_en.arb # 영어 리소스
|
|
└── app_ko.arb # 한국어 리소스
|
|
```
|
|
|
|
### 9.3 플랫폼별 구현 가이드
|
|
|
|
#### 9.3.1 Android 구현 가이드
|
|
- **targetSdkVersion**: 33 (Android 13)
|
|
- **minSdkVersion**: 21 (Android 5.0)
|
|
- **권한 요청**:
|
|
```xml
|
|
<!-- AndroidManifest.xml -->
|
|
<uses-permission android:name="android.permission.INTERNET" />
|
|
<uses-permission android:name="android.permission.READ_SMS" />
|
|
<uses-permission android:name="android.permission.RECEIVE_SMS" />
|
|
<uses-permission android:name="android.permission.USE_BIOMETRIC" />
|
|
<uses-permission android:name="android.permission.VIBRATE" />
|
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
|
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
|
|
```
|
|
- **알림 채널 설정**:
|
|
- 구독 알림: "subscription_channel"
|
|
- 결제 알림: "payment_channel"
|
|
- **네이티브 코드**:
|
|
- SMS 스캐너 (코틀린): `MainActivity.kt`에 MethodChannel 구현
|
|
|
|
#### 9.3.2 iOS 구현 가이드
|
|
- **iOS 최소 버전**: 12.0
|
|
- **권한 요청**:
|
|
```xml
|
|
<!-- Info.plist -->
|
|
<key>NSFaceIDUsageDescription</key>
|
|
<string>앱 잠금 해제를 위해 Face ID 사용</string>
|
|
<key>NSUserNotificationsUsageDescription</key>
|
|
<string>구독 결제 예정 알림을 위해 사용</string>
|
|
```
|
|
- **알림 설정**:
|
|
- 권한 요청 시 UNUserNotificationCenter 사용
|
|
- PushKit은 사용하지 않음 (로컬 알림만 사용)
|
|
- **앱 백그라운드 모드**:
|
|
- "Background Fetch" 활성화
|
|
|
|
### 9.4 배포 준비 체크리스트
|
|
|
|
#### 9.4.1 앱 매니페스트 파일
|
|
- [ ] 앱 이름 설정
|
|
- [ ] 앱 아이콘 설정
|
|
- [ ] 버전 코드 및 버전명 설정
|
|
- [ ] 필요한 권한만 요청
|
|
- [ ] 심사 대비 사유 설명 준비 (SMS 접근 등)
|
|
|
|
#### 9.4.2 앱 서명 및 인증서
|
|
- [ ] Android: Keystore 파일 생성 및 보관
|
|
- [ ] iOS: 개발자 계정 및 인증서 설정
|
|
|
|
#### 9.4.3 스토어 등록 준비
|
|
- [ ] 앱 설명 작성
|
|
- [ ] 스크린샷 준비 (다양한 기기)
|
|
- [ ] 개인정보 처리방침 준비
|
|
- [ ] 마케팅 이미지 준비
|
|
|
|
## 10. 결론 및 요약
|
|
|
|
### 10.1 주요 기능 요약
|
|
SubManager 앱은 사용자가 구독 서비스를 쉽게 관리하고 지출을 최적화할 수 있는 도구입니다. 주요 기능은 다음과 같습니다:
|
|
|
|
1. **구독 관리**: 구독 서비스의 추가, 수정, 삭제 및 카테고리별 분류
|
|
2. **SMS 자동 탐지**: 문자 메시지를 분석하여 구독 서비스 자동 인식
|
|
3. **결제일 알림**: 결제 예정일 3일 전 알림으로 예상치 못한 결제 방지
|
|
4. **분석 및 리포트**: 월별 지출, 서비스별 비율 등 시각화된 분석 제공
|
|
5. **환율 지원**: 원화/달러 간 자동 변환으로 해외 서비스 관리 용이
|
|
6. **보안 기능**: 생체 인증을 통한 앱 잠금
|
|
|
|
### 10.2 개발 철학
|
|
SubManager 앱 개발의 핵심 철학은 다음과 같습니다:
|
|
|
|
1. **사용자 중심**: 사용자의 구독 관리 경험을 최우선으로 고려
|
|
2. **오프라인 우선**: 서버 없이 로컬에서 모든 데이터 처리
|
|
3. **단순함**: 복잡한 설정 없이 직관적인 UI/UX 제공
|
|
4. **개인정보 보호**: 민감한 금융 정보를 안전하게 보호
|
|
|
|
### 10.3 개발 로드맵
|
|
SubManager 앱의 개발 로드맵은 다음과 같습니다:
|
|
|
|
1. **1단계** (1개월):
|
|
- 기본 구독 관리 기능 구현
|
|
- UI/UX 기본 프레임워크 구축
|
|
- Hive DB 초기 설정
|
|
|
|
2. **2단계** (1개월):
|
|
- SMS 스캔 기능 추가
|
|
- 알림 시스템 구현
|
|
- 분석 및 리포트 화면 개발
|
|
|
|
3. **3단계** (2주):
|
|
- 다국어 지원 추가
|
|
- 환율 변환 기능 구현
|
|
- 성능 최적화 및 버그 수정
|
|
|
|
4. **4단계** (2주):
|
|
- 베타 테스트 진행
|
|
- 사용자 피드백 반영
|
|
- 출시 준비 및 스토어 등록
|
|
|
|
### 10.4 최종 산출물
|
|
이 PRD를 통해 개발될 최종 산출물은 다음과 같습니다:
|
|
|
|
1. **Flutter 앱**: Android 및 iOS 플랫폼 지원
|
|
2. **소스 코드**: 체계적으로 구조화된 확장 가능한 코드베이스
|
|
3. **사용자 문서**: 앱 사용 가이드 및 FAQ
|
|
4. **개발자 문서**: API 문서 및 아키텍처 설명
|
|
|
|
이 향상된 PRD 문서는 SubManager 앱을 성공적으로 개발하기 위한 포괄적인 청사진을 제공합니다. 개발자는 이 문서를 통해 앱의 모든 측면을 이해하고, 품질 높은 애플리케이션을 구현할 수 있을 것입니다.
|
|
|
|
## 11. 아키텍처 및 유지보수 권장사항
|
|
|
|
품질 높은 코드베이스를 유지하고 지속적인 개발을 용이하게 하기 위해 다음과 같은 아키텍처 및 코드 구조화 방식을 권장합니다.
|
|
|
|
### 11.1 Clean Architecture 도입
|
|
|
|
데이터, 도메인, 프레젠테이션 계층을 명확하게 분리하여 코드의 의존성 방향을 제어하고 테스트 용이성을 높입니다.
|
|
|
|
현재 9.2절에 설명된 프로젝트 구조에서 발전하여, 더 체계적으로 관심사를 분리하는 구조로 점진적으로 마이그레이션할 수 있습니다. 기존 구성요소는 다음과 같이 매핑됩니다:
|
|
- `models/` → `data/models/`
|
|
- `services/` → `data/datasources/` 및 `data/repositories/`
|
|
- `providers/` → `presentation/providers/`
|
|
- `screens/` → `presentation/screens/`
|
|
- `widgets/` → `presentation/widgets/`
|
|
|
|
```
|
|
/lib
|
|
├── data/ # 데이터 계층
|
|
│ ├── datasources/ # 데이터 소스 (Hive, API 등)
|
|
│ ├── models/ # 데이터 모델 (JSON 직렬화 포함)
|
|
│ └── repositories/ # 리포지토리 구현체
|
|
├── domain/ # 도메인 계층
|
|
│ ├── entities/ # 비즈니스 엔티티
|
|
│ ├── repositories/ # 리포지토리 인터페이스
|
|
│ └── usecases/ # 비즈니스 유스케이스
|
|
└── presentation/ # 표현 계층
|
|
├── providers/ # 상태 관리
|
|
├── screens/ # 화면 위젯
|
|
└── widgets/ # 재사용 위젯
|
|
```
|
|
|
|
**장점:**
|
|
- 비즈니스 로직이 UI나 데이터 소스 구현에 의존하지 않음
|
|
- 각 계층별 단위 테스트 용이
|
|
- 확장성 및 유지보수성 향상
|
|
|
|
### 11.2 단일 책임 원칙 강화
|
|
|
|
현재 여러 책임을 가진 클래스들을 더 작고 집중된 컴포넌트로 분리합니다.
|
|
|
|
**기존 코드:**
|
|
```dart
|
|
// 데이터 관리와 알림 관리가 혼합된 Provider
|
|
class SubscriptionProvider extends ChangeNotifier {
|
|
// 구독 CRUD 로직
|
|
Future<void> addSubscription(...) async { ... }
|
|
|
|
// 알림 관리 로직
|
|
Future<void> _scheduleNotifications() async { ... }
|
|
}
|
|
```
|
|
|
|
**개선된 코드:**
|
|
```dart
|
|
// 데이터 관리만 담당
|
|
class SubscriptionRepository {
|
|
Future<void> addSubscription(...) async { ... }
|
|
}
|
|
|
|
// 알림 관리만 담당
|
|
class SubscriptionNotificationManager {
|
|
Future<void> scheduleNotification(...) async { ... }
|
|
}
|
|
|
|
// 상태 관리 및 UI 연결만 담당
|
|
class SubscriptionProvider extends ChangeNotifier {
|
|
final SubscriptionRepository _repository;
|
|
final SubscriptionNotificationManager _notificationManager;
|
|
|
|
SubscriptionProvider(this._repository, this._notificationManager);
|
|
// ...
|
|
}
|
|
```
|
|
|
|
**장점:**
|
|
- 코드 가독성 및 유지보수성 향상
|
|
- 단위 테스트 용이성 증가
|
|
- 컴포넌트 재사용 가능성 향상
|
|
|
|
### 11.3 재사용 가능한 UI 컴포넌트
|
|
|
|
애니메이션, 상태 관리, 화면 전환 등의 공통 기능을 재사용 가능한 위젯으로 추출합니다.
|
|
|
|
```dart
|
|
// 애니메이션된 카드 위젯
|
|
class AnimatedCard extends StatelessWidget {
|
|
final Widget child;
|
|
final Duration duration;
|
|
final bool animate;
|
|
|
|
const AnimatedCard({
|
|
Key? key,
|
|
required this.child,
|
|
this.duration = const Duration(milliseconds: 300),
|
|
this.animate = true,
|
|
}) : super(key: key);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return TweenAnimationBuilder<double>(
|
|
tween: Tween(begin: animate ? 0.0 : 1.0, end: 1.0),
|
|
duration: duration,
|
|
curve: Curves.easeOut,
|
|
builder: (context, value, child) {
|
|
return Opacity(
|
|
opacity: value,
|
|
child: Transform.translate(
|
|
offset: Offset(0, 20 * (1 - value)),
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
```
|
|
|
|
**장점:**
|
|
- 코드 중복 감소
|
|
- UI 일관성 향상
|
|
- 유지보수 용이성 증가
|
|
|
|
### 11.4 의존성 주입 패턴 적용
|
|
|
|
컴포넌트 간 의존성을 명시적으로 관리하여 테스트 용이성을 향상시킵니다.
|
|
|
|
```dart
|
|
// 의존성 주입 컨테이너 설정
|
|
final GetIt sl = GetIt.instance;
|
|
|
|
void setupDependencies() {
|
|
// 서비스 등록
|
|
sl.registerLazySingleton<ExchangeRateService>(() => ExchangeRateServiceImpl());
|
|
sl.registerLazySingleton<NotificationService>(() => NotificationServiceImpl());
|
|
|
|
// 리포지토리 등록
|
|
sl.registerLazySingleton<SubscriptionRepository>(
|
|
() => SubscriptionRepositoryImpl(sl<HiveDatabase>())
|
|
);
|
|
|
|
// 상태 관리 Provider 등록
|
|
sl.registerFactory<SubscriptionProvider>(
|
|
() => SubscriptionProvider(
|
|
sl<SubscriptionRepository>(),
|
|
sl<NotificationService>(),
|
|
)
|
|
);
|
|
}
|
|
```
|
|
|
|
**장점:**
|
|
- 명확한 의존성 관계
|
|
- 테스트 시 의존성 모킹 용이
|
|
- 컴포넌트 교체 용이성
|
|
|
|
### 11.5 데이터와 비즈니스 로직 분리
|
|
|
|
하드코딩된 데이터와 이를 처리하는 로직을 분리합니다.
|
|
|
|
**기존 코드:**
|
|
```dart
|
|
class SubscriptionUrlMatcher {
|
|
static final Map<String, String> ottServices = {
|
|
'netflix': 'https://www.netflix.com',
|
|
'넷플릭스': 'https://www.netflix.com',
|
|
// ... 더 많은 서비스 매핑
|
|
};
|
|
|
|
static String? findMatchingUrl(String serviceName) {
|
|
// 매칭 로직
|
|
}
|
|
}
|
|
```
|
|
|
|
**개선된 코드:**
|
|
```dart
|
|
// 데이터만 포함
|
|
class ServiceData {
|
|
static const Map<String, String> ottServices = { ... };
|
|
static const Map<String, String> musicServices = { ... };
|
|
// ...
|
|
}
|
|
|
|
// 로직만 처리
|
|
class ServiceMatcher {
|
|
final Map<String, Map<String, String>> allServices;
|
|
|
|
ServiceMatcher(this.allServices);
|
|
|
|
String? findMatchingUrl(String serviceName) {
|
|
// 매칭 로직
|
|
}
|
|
}
|
|
```
|
|
|
|
**장점:**
|
|
- 데이터 업데이트 용이성
|
|
- 비즈니스 로직 테스트 용이성
|
|
- 관심사 분리로 인한 유지보수성 향상
|
|
|
|
이러한 아키텍처 및 설계 원칙을 적용함으로써, SubManager 앱은 확장 가능하고 유지보수하기 쉬운 코드베이스를 갖추게 되어 장기적인 개발과 기능 추가가 용이해질 것입니다.
|
|
|
|
### 11.6 구현 이행 전략
|
|
|
|
기존 코드베이스에서 제안된 아키텍처로 마이그레이션하기 위한 단계적 접근 방식은 다음과 같습니다:
|
|
|
|
1. **점진적 리팩토링**:
|
|
- 모든 코드를 한 번에 변경하지 않고, 기능별로 점진적으로 리팩토링
|
|
- 특히 CRUD 작업과 비즈니스 로직이 혼합된 Provider 클래스부터 분리 시작
|
|
|
|
2. **어댑터 패턴 활용**:
|
|
- 리포지토리 인터페이스를 도입하고 기존 Provider에서 이를 구현하는 방식으로 전환
|
|
- 점진적으로 데이터 액세스 로직을 분리하여 리포지토리 구현체로 이동
|
|
|
|
3. **테스트 우선 접근방식**:
|
|
- 변경 전후의 동일한 동작을 보장하기 위해 단위 테스트 및 통합 테스트 작성
|
|
- 리팩토링 후 위젯 테스트로 UI 일관성 검증
|
|
|
|
4. **새 기능에 새 구조 적용**:
|
|
- 신규 기능은 처음부터 제안된 아키텍처로 개발
|
|
- 기존 코드는 점진적으로 새 구조에 맞게 업데이트
|
|
|
|
이러한 접근 방식을 통해 앱의 기능을 중단 없이 유지하면서도 코드 품질을 지속적으로 개선할 수 있습니다.
|
|
|
|
해지 안내 페이지 URL 목록은 이제 위의 7.6절 참조 |