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>
This commit is contained in:
JiWoong Sul
2025-07-09 14:29:53 +09:00
commit 8619e96739
177 changed files with 23085 additions and 0 deletions

View File

@@ -0,0 +1,84 @@
import 'package:flutter/material.dart';
/// 애니메이션 컨트롤러 관리를 위한 헬퍼 클래스
class AnimationControllerHelper {
/// 모든 애니메이션 컨트롤러를 초기화하는 메서드
static void initControllers({
required TickerProvider vsync,
required AnimationController fadeController,
required AnimationController scaleController,
required AnimationController rotateController,
required AnimationController slideController,
required AnimationController pulseController,
required AnimationController waveController,
}) {
// 페이드 컨트롤러 초기화
fadeController.duration = const Duration(milliseconds: 600);
fadeController.forward();
// 스케일 컨트롤러 초기화
scaleController.duration = const Duration(milliseconds: 600);
scaleController.forward();
// 회전 컨트롤러 초기화
rotateController.duration = const Duration(seconds: 10);
rotateController.repeat();
// 슬라이드 컨트롤러 초기화
slideController.duration = const Duration(milliseconds: 600);
slideController.forward();
// 펄스 컨트롤러 초기화
pulseController.duration = const Duration(milliseconds: 1500);
pulseController.repeat(reverse: true);
// 웨이브 컨트롤러 초기화
waveController.duration = const Duration(milliseconds: 8000);
waveController.forward();
// 웨이브 애니메이션이 끝나면 다시 처음부터 부드럽게 시작하도록 설정
waveController.addStatusListener((status) {
if (status == AnimationStatus.completed) {
waveController.reset();
waveController.forward();
}
});
}
/// 모든 애니메이션 컨트롤러를 재설정하는 메서드
static void resetAnimations({
required AnimationController fadeController,
required AnimationController scaleController,
required AnimationController slideController,
required AnimationController pulseController,
required AnimationController waveController,
}) {
fadeController.reset();
scaleController.reset();
slideController.reset();
pulseController.repeat(reverse: true);
waveController.repeat();
fadeController.forward();
scaleController.forward();
slideController.forward();
}
/// 모든 애니메이션 컨트롤러를 해제하는 메서드
static void disposeControllers({
required AnimationController fadeController,
required AnimationController scaleController,
required AnimationController rotateController,
required AnimationController slideController,
required AnimationController pulseController,
required AnimationController waveController,
}) {
fadeController.dispose();
scaleController.dispose();
rotateController.dispose();
slideController.dispose();
pulseController.dispose();
waveController.dispose();
}
}

View File

@@ -0,0 +1,37 @@
import 'package:intl/intl.dart';
/// 숫자와 날짜를 포맷팅하는 유틸리티 클래스
class FormatHelper {
/// 통화 형식으로 숫자 포맷팅
static String formatCurrency(double value) {
return NumberFormat.currency(
locale: 'ko_KR',
symbol: '',
decimalDigits: 0,
).format(value);
}
/// 날짜를 yyyy년 MM월 dd일 형식으로 포맷팅
static String formatDate(DateTime date) {
return '${date.year}${date.month}${date.day}';
}
/// 날짜를 MM.dd 형식으로 포맷팅 (짧은 형식)
static String formatShortDate(DateTime date) {
return '${date.month}.${date.day}';
}
/// 현재 날짜로부터 남은 일 수 계산
static String getRemainingDays(DateTime date) {
final now = DateTime.now();
final difference = date.difference(now).inDays;
if (difference < 0) {
return '${-difference}일 지남';
} else if (difference == 0) {
return '오늘';
} else {
return '$difference일';
}
}
}

View File

@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import '../models/subscription_model.dart';
import '../providers/category_provider.dart';
import '../services/subscription_url_matcher.dart';
/// 구독 서비스를 카테고리별로 구분하는 도우미 클래스
class SubscriptionCategoryHelper {
/// 구독 서비스 목록을 카테고리별로 그룹화하여 반환
///
/// [subscriptions] 구독 목록
/// [categoryProvider] 카테고리 제공자
///
/// 반환값: 카테고리 이름을 키로 하고 해당 카테고리에 속하는 구독 목록을 값으로 가지는 Map
static Map<String, List<SubscriptionModel>> categorizeSubscriptions(
List<SubscriptionModel> subscriptions,
CategoryProvider categoryProvider,
) {
final Map<String, List<SubscriptionModel>> categorizedSubscriptions = {};
// 카테고리 ID별로 구독 그룹화
for (final subscription in subscriptions) {
if (subscription.categoryId != null) {
// 카테고리 ID가 있는 경우, 해당 카테고리 이름으로 그룹화
final category =
categoryProvider.getCategoryById(subscription.categoryId!);
if (category != null) {
final categoryName = category.name;
if (!categorizedSubscriptions.containsKey(categoryName)) {
categorizedSubscriptions[categoryName] = [];
}
categorizedSubscriptions[categoryName]!.add(subscription);
continue;
}
}
// 카테고리 ID가 없거나 카테고리를 찾을 수 없는 경우 서비스 이름 기반 분류
// OTT
if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.ottServices)) {
if (!categorizedSubscriptions.containsKey('OTT 서비스')) {
categorizedSubscriptions['OTT 서비스'] = [];
}
categorizedSubscriptions['OTT 서비스']!.add(subscription);
}
// 음악
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.musicServices)) {
if (!categorizedSubscriptions.containsKey('음악 서비스')) {
categorizedSubscriptions['음악 서비스'] = [];
}
categorizedSubscriptions['음악 서비스']!.add(subscription);
}
// AI
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.aiServices)) {
if (!categorizedSubscriptions.containsKey('AI 서비스')) {
categorizedSubscriptions['AI 서비스'] = [];
}
categorizedSubscriptions['AI 서비스']!.add(subscription);
}
// 프로그래밍/개발
else if (_isInCategory(subscription.serviceName,
SubscriptionUrlMatcher.programmingServices)) {
if (!categorizedSubscriptions.containsKey('프로그래밍/개발 서비스')) {
categorizedSubscriptions['프로그래밍/개발 서비스'] = [];
}
categorizedSubscriptions['프로그래밍/개발 서비스']!.add(subscription);
}
// 오피스/협업 툴
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.officeTools)) {
if (!categorizedSubscriptions.containsKey('오피스/협업 툴')) {
categorizedSubscriptions['오피스/협업 툴'] = [];
}
categorizedSubscriptions['오피스/협업 툴']!.add(subscription);
}
// 기타 서비스
else if (_isInCategory(
subscription.serviceName, SubscriptionUrlMatcher.otherServices)) {
if (!categorizedSubscriptions.containsKey('기타 서비스')) {
categorizedSubscriptions['기타 서비스'] = [];
}
categorizedSubscriptions['기타 서비스']!.add(subscription);
}
// 미분류된 서비스
else {
if (!categorizedSubscriptions.containsKey('미분류')) {
categorizedSubscriptions['미분류'] = [];
}
categorizedSubscriptions['미분류']!.add(subscription);
}
}
// 빈 카테고리 제거
categorizedSubscriptions.removeWhere((key, value) => value.isEmpty);
return categorizedSubscriptions;
}
/// 서비스 이름이 특정 카테고리에 속하는지 확인
static bool _isInCategory(
String serviceName, Map<String, String> categoryServices) {
final lowerServiceName = serviceName.toLowerCase();
return categoryServices.keys.any((key) =>
lowerServiceName.contains(key.toLowerCase()) ||
(key.isNotEmpty && key.toLowerCase().contains(lowerServiceName)));
}
}