# 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` 방식으로 상태 구독 - 위젯 트리 최상단에 `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 _subscriptionBox; List _subscriptions = []; bool _isLoading = true; List get subscriptions => _subscriptions; bool get isLoading => _isLoading; double get totalMonthlyExpense { return _subscriptions.fold( 0.0, (sum, subscription) => sum + subscription.monthlyCost, ); } Future init() async { try { _isLoading = true; notifyListeners(); _subscriptionBox = await Hive.openBox('subscriptions'); await refreshSubscriptions(); _isLoading = false; notifyListeners(); } catch (e) { debugPrint('구독 초기화 중 오류 발생: $e'); _isLoading = false; notifyListeners(); rethrow; } } Future refreshSubscriptions() async { try { _subscriptions = _subscriptionBox.values.toList() ..sort((a, b) => a.nextBillingDate.compareTo(b.nextBillingDate)); notifyListeners(); } catch (e) { // 오류 처리 rethrow; } } Future 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 updateSubscription(SubscriptionModel subscription) async { // 구현... } Future deleteSubscription(String id) async { // 구현... } } ``` #### 5.3.2 카테고리 Provider (CategoryProvider) ```dart class CategoryProvider extends ChangeNotifier { List _categories = []; late Box _categoryBox; List get categories => _categories; Future init() async { _categoryBox = await Hive.openBox('categories'); _categories = _categoryBox.values.toList(); // 카테고리가 비어있으면 기본 카테고리 추가 if (_categories.isEmpty) { await _initDefaultCategories(); } notifyListeners(); } Future _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( 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((states) { if (states.contains(MaterialState.disabled)) return Colors.grey; return Colors.white; }), backgroundColor: MaterialStateProperty.resolveWith((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((states) { if (states.contains(MaterialState.disabled)) return 0; if (states.contains(MaterialState.pressed)) return 2; return 4; }), overlayColor: MaterialStateProperty.all(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 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 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 with SingleTickerProviderStateMixin { late AnimationController _controller; late List 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 cards; CardStack({required this.cards}); @override _CardStackState createState() => _CardStackState(); } class _CardStackState extends State { 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 with SingleTickerProviderStateMixin { late AnimationController _controller; late Animation _animation; @override void initState() { super.initState(); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: 1500), )..repeat(reverse: true); _animation = Tween(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 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 convertUsdToKrw(double usdAmount) async { final rate = await getUsdToKrwRate(); if (rate != null) { return usdAmount * rate; } return null; } } ``` ### 7.4 결제일 알림 스케줄링 알고리즘 ```dart // notification_service.dart 내부 구현 static Future 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 _calculateTotalExpense() async { setState(() => _isLoading = true); try { final provider = Provider.of(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 serviceUrls = { 'netflix': 'https://www.netflix.com', '넷플릭스': 'https://www.netflix.com', // ...많은 서비스 매핑... }; // 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들) static final Map 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 ``` - **알림 채널 설정**: - 구독 알림: "subscription_channel" - 결제 알림: "payment_channel" - **네이티브 코드**: - SMS 스캐너 (코틀린): `MainActivity.kt`에 MethodChannel 구현 #### 9.3.2 iOS 구현 가이드 - **iOS 최소 버전**: 12.0 - **권한 요청**: ```xml NSFaceIDUsageDescription 앱 잠금 해제를 위해 Face ID 사용 NSUserNotificationsUsageDescription 구독 결제 예정 알림을 위해 사용 ``` - **알림 설정**: - 권한 요청 시 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 addSubscription(...) async { ... } // 알림 관리 로직 Future _scheduleNotifications() async { ... } } ``` **개선된 코드:** ```dart // 데이터 관리만 담당 class SubscriptionRepository { Future addSubscription(...) async { ... } } // 알림 관리만 담당 class SubscriptionNotificationManager { Future 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( 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(() => ExchangeRateServiceImpl()); sl.registerLazySingleton(() => NotificationServiceImpl()); // 리포지토리 등록 sl.registerLazySingleton( () => SubscriptionRepositoryImpl(sl()) ); // 상태 관리 Provider 등록 sl.registerFactory( () => SubscriptionProvider( sl(), sl(), ) ); } ``` **장점:** - 명확한 의존성 관계 - 테스트 시 의존성 모킹 용이 - 컴포넌트 교체 용이성 ### 11.5 데이터와 비즈니스 로직 분리 하드코딩된 데이터와 이를 처리하는 로직을 분리합니다. **기존 코드:** ```dart class SubscriptionUrlMatcher { static final Map ottServices = { 'netflix': 'https://www.netflix.com', '넷플릭스': 'https://www.netflix.com', // ... 더 많은 서비스 매핑 }; static String? findMatchingUrl(String serviceName) { // 매칭 로직 } } ``` **개선된 코드:** ```dart // 데이터만 포함 class ServiceData { static const Map ottServices = { ... }; static const Map musicServices = { ... }; // ... } // 로직만 처리 class ServiceMatcher { final Map> allServices; ServiceMatcher(this.allServices); String? findMatchingUrl(String serviceName) { // 매칭 로직 } } ``` **장점:** - 데이터 업데이트 용이성 - 비즈니스 로직 테스트 용이성 - 관심사 분리로 인한 유지보수성 향상 이러한 아키텍처 및 설계 원칙을 적용함으로써, SubManager 앱은 확장 가능하고 유지보수하기 쉬운 코드베이스를 갖추게 되어 장기적인 개발과 기능 추가가 용이해질 것입니다. ### 11.6 구현 이행 전략 기존 코드베이스에서 제안된 아키텍처로 마이그레이션하기 위한 단계적 접근 방식은 다음과 같습니다: 1. **점진적 리팩토링**: - 모든 코드를 한 번에 변경하지 않고, 기능별로 점진적으로 리팩토링 - 특히 CRUD 작업과 비즈니스 로직이 혼합된 Provider 클래스부터 분리 시작 2. **어댑터 패턴 활용**: - 리포지토리 인터페이스를 도입하고 기존 Provider에서 이를 구현하는 방식으로 전환 - 점진적으로 데이터 액세스 로직을 분리하여 리포지토리 구현체로 이동 3. **테스트 우선 접근방식**: - 변경 전후의 동일한 동작을 보장하기 위해 단위 테스트 및 통합 테스트 작성 - 리팩토링 후 위젯 테스트로 UI 일관성 검증 4. **새 기능에 새 구조 적용**: - 신규 기능은 처음부터 제안된 아키텍처로 개발 - 기존 코드는 점진적으로 새 구조에 맞게 업데이트 이러한 접근 방식을 통해 앱의 기능을 중단 없이 유지하면서도 코드 품질을 지속적으로 개선할 수 있습니다. 해지 안내 페이지 URL 목록은 이제 위의 7.6절 참조