Files
submanager/doc/SubManager_PRD.md
JiWoong Sul 8619e96739 Initial commit: SubManager Flutter App
주요 구현 완료 기능:
- 구독 관리 (추가/편집/삭제/카테고리 분류)
- 이벤트 할인 시스템 (기본값 자동 설정)
- SMS 자동 스캔 및 구독 정보 추출
- 알림 시스템 (타임존 처리 안정화)
- 환율 변환 지원 (KRW/USD)
- 반응형 UI 및 애니메이션
- 다국어 지원 (한국어/영어)

버그 수정:
- NotificationService tz.local 초기화 오류 해결
- MainScreenSummaryCard 레이아웃 오버플로우 수정
- 구독 추가 시 LateInitializationError 완전 해결

🤖 Generated with Claude Code

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-09 14:29:53 +09:00

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절 참조