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:
136
lib/l10n/app_localizations.dart
Normal file
136
lib/l10n/app_localizations.dart
Normal file
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppLocalizations {
|
||||
final Locale locale;
|
||||
|
||||
AppLocalizations(this.locale);
|
||||
|
||||
static AppLocalizations of(BuildContext context) {
|
||||
return Localizations.of<AppLocalizations>(context, AppLocalizations)!;
|
||||
}
|
||||
|
||||
static const _localizedValues = <String, Map<String, String>>{
|
||||
'en': {
|
||||
'appTitle': 'SubManager',
|
||||
'subscriptionManagement': 'Subscription Management',
|
||||
'addSubscription': 'Add Subscription',
|
||||
'subscriptionName': 'Service Name',
|
||||
'monthlyCost': 'Monthly Cost',
|
||||
'billingCycle': 'Billing Cycle',
|
||||
'nextBillingDate': 'Next Billing Date',
|
||||
'save': 'Save',
|
||||
'cancel': 'Cancel',
|
||||
'delete': 'Delete',
|
||||
'edit': 'Edit',
|
||||
'totalSubscriptions': 'Total Subscriptions',
|
||||
'totalMonthlyExpense': 'Total Monthly Expense',
|
||||
'noSubscriptions': 'No subscriptions registered',
|
||||
'addSubscriptionNow': 'Add Subscription Now',
|
||||
'paymentReminder': 'Payment Reminder',
|
||||
'expirationReminder': 'Expiration Reminder',
|
||||
'daysLeft': 'days left',
|
||||
'categoryManagement': 'Category Management',
|
||||
'categoryName': 'Category Name',
|
||||
'selectColor': 'Select Color',
|
||||
'selectIcon': 'Select Icon',
|
||||
'addCategory': 'Add Category',
|
||||
'settings': 'Settings',
|
||||
'darkMode': 'Dark Mode',
|
||||
'language': 'Language',
|
||||
'notifications': 'Notifications',
|
||||
'appLock': 'App Lock',
|
||||
},
|
||||
'ko': {
|
||||
'appTitle': '구독 관리',
|
||||
'subscriptionManagement': '구독 관리',
|
||||
'addSubscription': '구독 추가',
|
||||
'subscriptionName': '서비스명',
|
||||
'monthlyCost': '월 비용',
|
||||
'billingCycle': '결제 주기',
|
||||
'nextBillingDate': '다음 결제일',
|
||||
'save': '저장',
|
||||
'cancel': '취소',
|
||||
'delete': '삭제',
|
||||
'edit': '수정',
|
||||
'totalSubscriptions': '총 구독',
|
||||
'totalMonthlyExpense': '이번 달 총 지출',
|
||||
'noSubscriptions': '등록된 구독 서비스가 없습니다',
|
||||
'addSubscriptionNow': '구독 추가하기',
|
||||
'paymentReminder': '결제 예정 알림',
|
||||
'expirationReminder': '만료 예정 알림',
|
||||
'daysLeft': '일 남음',
|
||||
'categoryManagement': '카테고리 관리',
|
||||
'categoryName': '카테고리 이름',
|
||||
'selectColor': '색상 선택',
|
||||
'selectIcon': '아이콘 선택',
|
||||
'addCategory': '카테고리 추가',
|
||||
'settings': '설정',
|
||||
'darkMode': '다크 모드',
|
||||
'language': '언어',
|
||||
'notifications': '알림',
|
||||
'appLock': '앱 잠금',
|
||||
},
|
||||
};
|
||||
|
||||
String get appTitle => _localizedValues[locale.languageCode]!['appTitle']!;
|
||||
String get subscriptionManagement =>
|
||||
_localizedValues[locale.languageCode]!['subscriptionManagement']!;
|
||||
String get addSubscription =>
|
||||
_localizedValues[locale.languageCode]!['addSubscription']!;
|
||||
String get subscriptionName =>
|
||||
_localizedValues[locale.languageCode]!['subscriptionName']!;
|
||||
String get monthlyCost =>
|
||||
_localizedValues[locale.languageCode]!['monthlyCost']!;
|
||||
String get billingCycle =>
|
||||
_localizedValues[locale.languageCode]!['billingCycle']!;
|
||||
String get nextBillingDate =>
|
||||
_localizedValues[locale.languageCode]!['nextBillingDate']!;
|
||||
String get save => _localizedValues[locale.languageCode]!['save']!;
|
||||
String get cancel => _localizedValues[locale.languageCode]!['cancel']!;
|
||||
String get delete => _localizedValues[locale.languageCode]!['delete']!;
|
||||
String get edit => _localizedValues[locale.languageCode]!['edit']!;
|
||||
String get totalSubscriptions =>
|
||||
_localizedValues[locale.languageCode]!['totalSubscriptions']!;
|
||||
String get totalMonthlyExpense =>
|
||||
_localizedValues[locale.languageCode]!['totalMonthlyExpense']!;
|
||||
String get noSubscriptions =>
|
||||
_localizedValues[locale.languageCode]!['noSubscriptions']!;
|
||||
String get addSubscriptionNow =>
|
||||
_localizedValues[locale.languageCode]!['addSubscriptionNow']!;
|
||||
String get paymentReminder =>
|
||||
_localizedValues[locale.languageCode]!['paymentReminder']!;
|
||||
String get expirationReminder =>
|
||||
_localizedValues[locale.languageCode]!['expirationReminder']!;
|
||||
String get daysLeft => _localizedValues[locale.languageCode]!['daysLeft']!;
|
||||
String get categoryManagement =>
|
||||
_localizedValues[locale.languageCode]!['categoryManagement']!;
|
||||
String get categoryName =>
|
||||
_localizedValues[locale.languageCode]!['categoryName']!;
|
||||
String get selectColor =>
|
||||
_localizedValues[locale.languageCode]!['selectColor']!;
|
||||
String get selectIcon =>
|
||||
_localizedValues[locale.languageCode]!['selectIcon']!;
|
||||
String get addCategory =>
|
||||
_localizedValues[locale.languageCode]!['addCategory']!;
|
||||
String get settings => _localizedValues[locale.languageCode]!['settings']!;
|
||||
String get darkMode => _localizedValues[locale.languageCode]!['darkMode']!;
|
||||
String get language => _localizedValues[locale.languageCode]!['language']!;
|
||||
String get notifications =>
|
||||
_localizedValues[locale.languageCode]!['notifications']!;
|
||||
String get appLock => _localizedValues[locale.languageCode]!['appLock']!;
|
||||
}
|
||||
|
||||
class AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
|
||||
const AppLocalizationsDelegate();
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) => ['en', 'ko'].contains(locale.languageCode);
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) async {
|
||||
return AppLocalizations(locale);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReload(AppLocalizationsDelegate old) => false;
|
||||
}
|
||||
126
lib/main.dart
Normal file
126
lib/main.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'models/subscription_model.dart';
|
||||
import 'models/category_model.dart';
|
||||
import 'providers/subscription_provider.dart';
|
||||
import 'providers/app_lock_provider.dart';
|
||||
import 'providers/notification_provider.dart';
|
||||
import 'screens/main_screen.dart';
|
||||
import 'screens/app_lock_screen.dart';
|
||||
import 'services/notification_service.dart';
|
||||
import 'providers/category_provider.dart';
|
||||
import 'providers/locale_provider.dart';
|
||||
import 'l10n/app_localizations.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'screens/splash_screen.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
// 구글 모바일 광고 SDK 초기화 (웹이 아니고, Android/iOS에서만)
|
||||
if (!kIsWeb && (Platform.isAndroid || Platform.isIOS)) {
|
||||
await MobileAds.instance.initialize();
|
||||
}
|
||||
|
||||
// 앱 시작 시 이미지 캐시 관리
|
||||
try {
|
||||
// 메모리 이미지 캐시는 유지하지만 필요한 경우 삭제할 수 있도록 준비
|
||||
final cache = PaintingBinding.instance.imageCache;
|
||||
|
||||
// 오래된 디스크 캐시 파일만 지우기 (새로운 것은 유지)
|
||||
await DefaultCacheManager().emptyCache();
|
||||
|
||||
print('이미지 캐시 관리 초기화 완료');
|
||||
} catch (e) {
|
||||
print('캐시 초기화 오류: $e');
|
||||
}
|
||||
|
||||
// Hive 초기화
|
||||
await Hive.initFlutter();
|
||||
Hive.registerAdapter(SubscriptionModelAdapter());
|
||||
Hive.registerAdapter(CategoryModelAdapter());
|
||||
await Hive.openBox<SubscriptionModel>('subscriptions');
|
||||
await Hive.openBox<CategoryModel>('categories');
|
||||
final appLockBox = await Hive.openBox<bool>('app_lock');
|
||||
// 알림 서비스를 가장 먼저 초기화
|
||||
await NotificationService.init();
|
||||
|
||||
final subscriptionProvider = SubscriptionProvider();
|
||||
final categoryProvider = CategoryProvider();
|
||||
final localeProvider = LocaleProvider();
|
||||
final notificationProvider = NotificationProvider();
|
||||
|
||||
await subscriptionProvider.init();
|
||||
await categoryProvider.init();
|
||||
await localeProvider.init();
|
||||
await notificationProvider.init();
|
||||
|
||||
// NotificationProvider에 SubscriptionProvider 연결 (알림 재예약용)
|
||||
// SRP 원칙에 따라 다른 Provider 객체를 명시적으로 주입
|
||||
notificationProvider.setSubscriptionProvider(subscriptionProvider);
|
||||
|
||||
// 별도의 비동기 함수로 알림 관련 초기화 오류 처리
|
||||
Future.delayed(Duration.zero, () {
|
||||
try {
|
||||
if (notificationProvider.isPaymentEnabled) {
|
||||
// 백그라운드에서 비동기적으로 알림 설정 업데이트
|
||||
NotificationService.reschedulAllNotifications(
|
||||
subscriptionProvider.subscriptions);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('알림 초기 설정 중 오류 발생: $e');
|
||||
}
|
||||
});
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => subscriptionProvider),
|
||||
ChangeNotifierProvider(create: (_) => categoryProvider),
|
||||
ChangeNotifierProvider(create: (_) => AppLockProvider(appLockBox)),
|
||||
ChangeNotifierProvider(create: (_) => notificationProvider),
|
||||
ChangeNotifierProvider(create: (_) => localeProvider),
|
||||
],
|
||||
child: const SubManagerApp(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class SubManagerApp extends StatelessWidget {
|
||||
const SubManagerApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Consumer<LocaleProvider>(
|
||||
builder: (context, localeProvider, child) {
|
||||
return MaterialApp(
|
||||
title: 'SubManager',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
locale: localeProvider.locale,
|
||||
localizationsDelegates: const [
|
||||
AppLocalizationsDelegate(),
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [
|
||||
Locale('en'),
|
||||
Locale('ko'),
|
||||
],
|
||||
navigatorKey: navigatorKey,
|
||||
home: const SplashScreen(),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
25
lib/models/category_model.dart
Normal file
25
lib/models/category_model.dart
Normal file
@@ -0,0 +1,25 @@
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
part 'category_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 1)
|
||||
class CategoryModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
String id;
|
||||
|
||||
@HiveField(1)
|
||||
String name;
|
||||
|
||||
@HiveField(2)
|
||||
String color;
|
||||
|
||||
@HiveField(3)
|
||||
String icon;
|
||||
|
||||
CategoryModel({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.color,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
50
lib/models/category_model.g.dart
Normal file
50
lib/models/category_model.g.dart
Normal file
@@ -0,0 +1,50 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'category_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class CategoryModelAdapter extends TypeAdapter<CategoryModel> {
|
||||
@override
|
||||
final int typeId = 1;
|
||||
|
||||
@override
|
||||
CategoryModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return CategoryModel(
|
||||
id: fields[0] as String,
|
||||
name: fields[1] as String,
|
||||
color: fields[2] as String,
|
||||
icon: fields[3] as String,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, CategoryModel obj) {
|
||||
writer
|
||||
..writeByte(4)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.name)
|
||||
..writeByte(2)
|
||||
..write(obj.color)
|
||||
..writeByte(3)
|
||||
..write(obj.icon);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is CategoryModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
64
lib/models/subscription.dart
Normal file
64
lib/models/subscription.dart
Normal file
@@ -0,0 +1,64 @@
|
||||
class Subscription {
|
||||
final String id;
|
||||
final String serviceName;
|
||||
final double monthlyCost;
|
||||
final String billingCycle;
|
||||
final DateTime nextBillingDate;
|
||||
final String? category;
|
||||
final String? notes;
|
||||
final int repeatCount;
|
||||
final DateTime? lastPaymentDate;
|
||||
final String? websiteUrl;
|
||||
final String currency;
|
||||
|
||||
Subscription({
|
||||
required this.id,
|
||||
required this.serviceName,
|
||||
required this.monthlyCost,
|
||||
required this.billingCycle,
|
||||
required this.nextBillingDate,
|
||||
this.category,
|
||||
this.notes,
|
||||
this.repeatCount = 1,
|
||||
this.lastPaymentDate,
|
||||
this.websiteUrl,
|
||||
this.currency = 'KRW',
|
||||
});
|
||||
|
||||
Map<String, dynamic> toMap() {
|
||||
return {
|
||||
'id': id,
|
||||
'serviceName': serviceName,
|
||||
'monthlyCost': monthlyCost,
|
||||
'billingCycle': billingCycle,
|
||||
'nextBillingDate': nextBillingDate.toIso8601String(),
|
||||
'category': category,
|
||||
'notes': notes,
|
||||
'repeatCount': repeatCount,
|
||||
'lastPaymentDate': lastPaymentDate?.toIso8601String(),
|
||||
'websiteUrl': websiteUrl,
|
||||
'currency': currency,
|
||||
};
|
||||
}
|
||||
|
||||
factory Subscription.fromMap(Map<String, dynamic> map) {
|
||||
return Subscription(
|
||||
id: map['id'] as String,
|
||||
serviceName: map['serviceName'] as String,
|
||||
monthlyCost: map['monthlyCost'] as double,
|
||||
billingCycle: map['billingCycle'] as String,
|
||||
nextBillingDate: DateTime.parse(map['nextBillingDate'] as String),
|
||||
category: map['category'] as String?,
|
||||
notes: map['notes'] as String?,
|
||||
repeatCount: (map['repeatCount'] as num?)?.toInt() ?? 1,
|
||||
lastPaymentDate: map['lastPaymentDate'] != null
|
||||
? DateTime.parse(map['lastPaymentDate'] as String)
|
||||
: null,
|
||||
websiteUrl: map['websiteUrl'] as String?,
|
||||
currency: map['currency'] as String? ?? 'KRW',
|
||||
);
|
||||
}
|
||||
|
||||
// 주기적 결제 여부 확인
|
||||
bool get isRecurring => repeatCount > 1;
|
||||
}
|
||||
101
lib/models/subscription_model.dart
Normal file
101
lib/models/subscription_model.dart
Normal file
@@ -0,0 +1,101 @@
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
|
||||
part 'subscription_model.g.dart';
|
||||
|
||||
@HiveType(typeId: 0)
|
||||
class SubscriptionModel extends HiveObject {
|
||||
@HiveField(0)
|
||||
final String id;
|
||||
|
||||
@HiveField(1)
|
||||
String serviceName;
|
||||
|
||||
@HiveField(2)
|
||||
double monthlyCost;
|
||||
|
||||
@HiveField(3)
|
||||
String billingCycle; // '월간', '연간', '주간' 등
|
||||
|
||||
@HiveField(4)
|
||||
DateTime nextBillingDate;
|
||||
|
||||
@HiveField(5)
|
||||
bool isAutoDetected; // SMS로 추가된 경우 true
|
||||
|
||||
@HiveField(6)
|
||||
String? categoryId;
|
||||
|
||||
@HiveField(7)
|
||||
String? websiteUrl; // 홈페이지 URL
|
||||
|
||||
@HiveField(8)
|
||||
int repeatCount; // 반복 결제 횟수
|
||||
|
||||
@HiveField(9)
|
||||
DateTime? lastPaymentDate; // 마지막 결제일
|
||||
|
||||
@HiveField(10)
|
||||
String currency; // 통화 단위: 'KRW' 또는 'USD'
|
||||
|
||||
@HiveField(11)
|
||||
bool isEventActive; // 이벤트 활성화 여부
|
||||
|
||||
@HiveField(12)
|
||||
DateTime? eventStartDate; // 이벤트 시작일
|
||||
|
||||
@HiveField(13)
|
||||
DateTime? eventEndDate; // 이벤트 종료일
|
||||
|
||||
@HiveField(14)
|
||||
double? eventPrice; // 이벤트 기간 중 가격
|
||||
|
||||
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
|
||||
this.isEventActive = false, // 기본값은 false
|
||||
this.eventStartDate,
|
||||
this.eventEndDate,
|
||||
this.eventPrice,
|
||||
});
|
||||
|
||||
// 주기적 결제 여부 확인
|
||||
bool get isRecurring => repeatCount > 1;
|
||||
|
||||
// 현재 이벤트 기간인지 확인
|
||||
bool get isCurrentlyInEvent {
|
||||
if (!isEventActive || eventStartDate == null || eventEndDate == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final now = DateTime.now();
|
||||
return now.isAfter(eventStartDate!) && now.isBefore(eventEndDate!);
|
||||
}
|
||||
|
||||
// 현재 적용되는 가격 (이벤트 또는 정상 가격)
|
||||
double get currentPrice {
|
||||
if (isCurrentlyInEvent && eventPrice != null) {
|
||||
return eventPrice!;
|
||||
}
|
||||
return monthlyCost;
|
||||
}
|
||||
|
||||
// 이벤트로 인한 절약액
|
||||
double get eventSavings {
|
||||
if (isCurrentlyInEvent && eventPrice != null) {
|
||||
return monthlyCost - eventPrice!;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Hive TypeAdapter 생성을 위한 명령어
|
||||
// flutter pub run build_runner build
|
||||
83
lib/models/subscription_model.g.dart
Normal file
83
lib/models/subscription_model.g.dart
Normal file
@@ -0,0 +1,83 @@
|
||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||
|
||||
part of 'subscription_model.dart';
|
||||
|
||||
// **************************************************************************
|
||||
// TypeAdapterGenerator
|
||||
// **************************************************************************
|
||||
|
||||
class SubscriptionModelAdapter extends TypeAdapter<SubscriptionModel> {
|
||||
@override
|
||||
final int typeId = 0;
|
||||
|
||||
@override
|
||||
SubscriptionModel read(BinaryReader reader) {
|
||||
final numOfFields = reader.readByte();
|
||||
final fields = <int, dynamic>{
|
||||
for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
|
||||
};
|
||||
return SubscriptionModel(
|
||||
id: fields[0] as String,
|
||||
serviceName: fields[1] as String,
|
||||
monthlyCost: fields[2] as double,
|
||||
billingCycle: fields[3] as String,
|
||||
nextBillingDate: fields[4] as DateTime,
|
||||
isAutoDetected: fields[5] as bool,
|
||||
categoryId: fields[6] as String?,
|
||||
websiteUrl: fields[7] as String?,
|
||||
repeatCount: fields[8] as int,
|
||||
lastPaymentDate: fields[9] as DateTime?,
|
||||
currency: fields[10] as String,
|
||||
isEventActive: fields[11] as bool,
|
||||
eventStartDate: fields[12] as DateTime?,
|
||||
eventEndDate: fields[13] as DateTime?,
|
||||
eventPrice: fields[14] as double?,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void write(BinaryWriter writer, SubscriptionModel obj) {
|
||||
writer
|
||||
..writeByte(15)
|
||||
..writeByte(0)
|
||||
..write(obj.id)
|
||||
..writeByte(1)
|
||||
..write(obj.serviceName)
|
||||
..writeByte(2)
|
||||
..write(obj.monthlyCost)
|
||||
..writeByte(3)
|
||||
..write(obj.billingCycle)
|
||||
..writeByte(4)
|
||||
..write(obj.nextBillingDate)
|
||||
..writeByte(5)
|
||||
..write(obj.isAutoDetected)
|
||||
..writeByte(6)
|
||||
..write(obj.categoryId)
|
||||
..writeByte(7)
|
||||
..write(obj.websiteUrl)
|
||||
..writeByte(8)
|
||||
..write(obj.repeatCount)
|
||||
..writeByte(9)
|
||||
..write(obj.lastPaymentDate)
|
||||
..writeByte(10)
|
||||
..write(obj.currency)
|
||||
..writeByte(11)
|
||||
..write(obj.isEventActive)
|
||||
..writeByte(12)
|
||||
..write(obj.eventStartDate)
|
||||
..writeByte(13)
|
||||
..write(obj.eventEndDate)
|
||||
..writeByte(14)
|
||||
..write(obj.eventPrice);
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => typeId.hashCode;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is SubscriptionModelAdapter &&
|
||||
runtimeType == other.runtimeType &&
|
||||
typeId == other.typeId;
|
||||
}
|
||||
3
lib/navigator_key.dart
Normal file
3
lib/navigator_key.dart
Normal file
@@ -0,0 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
171
lib/providers/app_lock_provider.dart
Normal file
171
lib/providers/app_lock_provider.dart
Normal file
@@ -0,0 +1,171 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:hive_flutter/hive_flutter.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
class AppLockProvider extends ChangeNotifier {
|
||||
final Box<bool> _appLockBox;
|
||||
final LocalAuthentication _localAuth = LocalAuthentication();
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
static const String _isEnabledKey = 'app_lock_enabled';
|
||||
|
||||
bool _isEnabled = false;
|
||||
bool _isLocked = false;
|
||||
bool _isBiometricEnabled = false;
|
||||
bool _isBiometricAvailable = false;
|
||||
|
||||
AppLockProvider(this._appLockBox) {
|
||||
_init();
|
||||
}
|
||||
|
||||
bool get isEnabled => _isEnabled;
|
||||
bool get isLocked => _isLocked;
|
||||
bool get isBiometricEnabled => _isBiometricEnabled;
|
||||
bool get isBiometricAvailable => _isBiometricAvailable;
|
||||
|
||||
Future<void> _init() async {
|
||||
if (!kIsWeb) {
|
||||
_isBiometricAvailable = await _localAuth.canCheckBiometrics;
|
||||
} else {
|
||||
_isBiometricAvailable = false;
|
||||
}
|
||||
_isLocked = _appLockBox.get('isLocked', defaultValue: false) ?? false;
|
||||
await _loadState();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadState() async {
|
||||
final value = await _secureStorage.read(key: _isEnabledKey);
|
||||
_isEnabled = value == 'true';
|
||||
_isLocked = _appLockBox.get('isLocked', defaultValue: false) ?? false;
|
||||
await _loadSettings();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
try {
|
||||
final biometricEnabled =
|
||||
await _secureStorage.read(key: 'biometric_enabled');
|
||||
_isBiometricEnabled = biometricEnabled == 'true';
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('설정 로드 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> authenticate() async {
|
||||
if (kIsWeb) {
|
||||
_isLocked = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
final canCheck = await _checkBiometrics();
|
||||
if (!canCheck) {
|
||||
_isLocked = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
final authenticated = await _localAuth.authenticate(
|
||||
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.',
|
||||
options: const AuthenticationOptions(
|
||||
stickyAuth: true,
|
||||
biometricOnly: true,
|
||||
),
|
||||
);
|
||||
|
||||
if (authenticated) {
|
||||
_isLocked = false;
|
||||
await _appLockBox.put('isLocked', false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
return authenticated;
|
||||
} catch (e) {
|
||||
_isLocked = false;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _checkBiometrics() async {
|
||||
if (kIsWeb) return false;
|
||||
try {
|
||||
return await _localAuth.canCheckBiometrics;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleBiometricAuth() async {
|
||||
if (kIsWeb) {
|
||||
_isBiometricEnabled = false;
|
||||
await _appLockBox.put('biometric_enabled', false);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final canCheck = await _checkBiometrics();
|
||||
if (!canCheck) {
|
||||
_isBiometricEnabled = false;
|
||||
await _appLockBox.put('biometric_enabled', false);
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
_isBiometricEnabled = !_isBiometricEnabled;
|
||||
await _secureStorage.write(
|
||||
key: 'biometric_enabled',
|
||||
value: _isBiometricEnabled.toString(),
|
||||
);
|
||||
await _appLockBox.put('biometric_enabled', _isBiometricEnabled);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_isBiometricEnabled = false;
|
||||
await _appLockBox.put('biometric_enabled', false);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
void lock() {
|
||||
if (_isBiometricEnabled) {
|
||||
_isLocked = true;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> unlock() async {
|
||||
_isLocked = false;
|
||||
await _appLockBox.put('isLocked', false);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> enable() async {
|
||||
_isEnabled = true;
|
||||
await _secureStorage.write(key: _isEnabledKey, value: 'true');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> disable() async {
|
||||
_isEnabled = false;
|
||||
await _secureStorage.write(key: _isEnabledKey, value: 'false');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> refreshNotifications(BuildContext context) async {
|
||||
final subscriptionProvider = Provider.of<SubscriptionProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
for (var subscription in subscriptionProvider.subscriptions) {
|
||||
await NotificationService.scheduleSubscriptionNotification(subscription);
|
||||
}
|
||||
}
|
||||
}
|
||||
86
lib/providers/category_provider.dart
Normal file
86
lib/providers/category_provider.dart
Normal file
@@ -0,0 +1,86 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import '../models/category_model.dart';
|
||||
import 'package:uuid/uuid.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'},
|
||||
];
|
||||
|
||||
for (final category in defaultCategories) {
|
||||
final newCategory = CategoryModel(
|
||||
id: const Uuid().v4(),
|
||||
name: category['name']!,
|
||||
color: category['color']!,
|
||||
icon: category['icon']!,
|
||||
);
|
||||
|
||||
await _categoryBox.put(newCategory.id, newCategory);
|
||||
_categories.add(newCategory);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> addCategory({
|
||||
required String name,
|
||||
required String color,
|
||||
required String icon,
|
||||
}) async {
|
||||
final newCategory = CategoryModel(
|
||||
id: const Uuid().v4(),
|
||||
name: name,
|
||||
color: color,
|
||||
icon: icon,
|
||||
);
|
||||
await _categoryBox.put(newCategory.id, newCategory);
|
||||
_categories.add(newCategory);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> updateCategory(CategoryModel updated) async {
|
||||
await _categoryBox.put(updated.id, updated);
|
||||
int index = _categories.indexWhere((cat) => cat.id == updated.id);
|
||||
if (index != -1) {
|
||||
_categories[index] = updated;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteCategory(String id) async {
|
||||
await _categoryBox.delete(id);
|
||||
_categories.removeWhere((cat) => cat.id == id);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
CategoryModel? getCategoryById(String id) {
|
||||
try {
|
||||
return _categories.firstWhere((cat) => cat.id == id);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
lib/providers/locale_provider.dart
Normal file
22
lib/providers/locale_provider.dart
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
|
||||
class LocaleProvider extends ChangeNotifier {
|
||||
late Box<String> _localeBox;
|
||||
Locale _locale = const Locale('ko');
|
||||
|
||||
Locale get locale => _locale;
|
||||
|
||||
Future<void> init() async {
|
||||
_localeBox = await Hive.openBox<String>('locale');
|
||||
final savedLocale = _localeBox.get('locale', defaultValue: 'ko');
|
||||
_locale = Locale(savedLocale ?? 'ko');
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> setLocale(String languageCode) async {
|
||||
_locale = Locale(languageCode);
|
||||
await _localeBox.put('locale', languageCode);
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
263
lib/providers/notification_provider.dart
Normal file
263
lib/providers/notification_provider.dart
Normal file
@@ -0,0 +1,263 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
|
||||
class NotificationProvider extends ChangeNotifier {
|
||||
final FlutterSecureStorage _secureStorage = const FlutterSecureStorage();
|
||||
static const String _paymentNotificationKey = 'payment_notification_enabled';
|
||||
static const String _unusedServiceNotificationKey =
|
||||
'unused_service_notification_enabled';
|
||||
static const String _reminderDaysKey = 'reminder_days';
|
||||
static const String _reminderHourKey = 'reminder_hour';
|
||||
static const String _reminderMinuteKey = 'reminder_minute';
|
||||
static const String _dailyReminderKey = 'daily_reminder_enabled';
|
||||
|
||||
bool _isEnabled = false;
|
||||
bool _isPaymentEnabled = false;
|
||||
bool _isUnusedServiceNotificationEnabled = false;
|
||||
int _reminderDays = 3; // 기본값: 3일 전
|
||||
int _reminderHour = 10; // 기본값: 오전 10시
|
||||
int _reminderMinute = 0; // 기본값: 0분
|
||||
bool _isDailyReminderEnabled = false; // 기본값: 반복 알림 비활성화
|
||||
|
||||
// 웹 플랫폼 여부 확인 (웹에서는 알림이 지원되지 않음)
|
||||
bool get _isWeb => kIsWeb;
|
||||
|
||||
// SubscriptionProvider 인스턴스 (알림 재예약 시 사용)
|
||||
SubscriptionProvider? _subscriptionProvider;
|
||||
|
||||
bool get isEnabled => _isEnabled;
|
||||
bool get isPaymentEnabled => _isPaymentEnabled;
|
||||
bool get isUnusedServiceNotificationEnabled =>
|
||||
_isUnusedServiceNotificationEnabled;
|
||||
int get reminderDays => _reminderDays;
|
||||
int get reminderHour => _reminderHour;
|
||||
int get reminderMinute => _reminderMinute;
|
||||
bool get isDailyReminderEnabled => _isDailyReminderEnabled;
|
||||
|
||||
NotificationProvider() {
|
||||
_loadSettings();
|
||||
}
|
||||
|
||||
// SubscriptionProvider 설정 (알림 재예약 시 사용)
|
||||
void setSubscriptionProvider(SubscriptionProvider provider) {
|
||||
_subscriptionProvider = provider;
|
||||
}
|
||||
|
||||
Future<void> _loadSettings() async {
|
||||
final paymentValue =
|
||||
await _secureStorage.read(key: _paymentNotificationKey);
|
||||
final unusedValue =
|
||||
await _secureStorage.read(key: _unusedServiceNotificationKey);
|
||||
final reminderDaysValue = await _secureStorage.read(key: _reminderDaysKey);
|
||||
final reminderHourValue = await _secureStorage.read(key: _reminderHourKey);
|
||||
final reminderMinuteValue =
|
||||
await _secureStorage.read(key: _reminderMinuteKey);
|
||||
final dailyReminderValue =
|
||||
await _secureStorage.read(key: _dailyReminderKey);
|
||||
|
||||
_isPaymentEnabled = paymentValue == 'true';
|
||||
_isUnusedServiceNotificationEnabled = unusedValue == 'true';
|
||||
_reminderDays =
|
||||
reminderDaysValue != null ? int.tryParse(reminderDaysValue) ?? 3 : 3;
|
||||
_reminderHour =
|
||||
reminderHourValue != null ? int.tryParse(reminderHourValue) ?? 10 : 10;
|
||||
_reminderMinute = reminderMinuteValue != null
|
||||
? int.tryParse(reminderMinuteValue) ?? 0
|
||||
: 0;
|
||||
_isDailyReminderEnabled = dailyReminderValue == 'true';
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
_isEnabled = await NotificationService.isNotificationEnabled();
|
||||
_isPaymentEnabled =
|
||||
await NotificationService.isPaymentNotificationEnabled();
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('알림 설정 초기화 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setEnabled(bool value) async {
|
||||
try {
|
||||
_isEnabled = value;
|
||||
await NotificationService.setNotificationEnabled(value);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('알림 활성화 설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setPaymentEnabled(bool value) async {
|
||||
try {
|
||||
// 설정값이 변경된 경우에만 처리
|
||||
if (_isPaymentEnabled != value) {
|
||||
_isPaymentEnabled = value;
|
||||
await NotificationService.setPaymentNotificationEnabled(value);
|
||||
|
||||
// 웹에서는 알림 기능 비활성화
|
||||
if (_isWeb) {
|
||||
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// 알림이 활성화된 경우에만 알림 재예약 (비활성화 시에는 필요 없음)
|
||||
if (value) {
|
||||
// 알림 설정 변경 시 모든 구독의 알림 재예약
|
||||
// 지연 실행으로 UI 응답성 향상
|
||||
Future.microtask(() => _rescheduleNotificationsIfNeeded());
|
||||
} else {
|
||||
// 알림이 비활성화되면 모든 알림 취소
|
||||
try {
|
||||
await NotificationService.cancelAllNotifications();
|
||||
} catch (e) {
|
||||
debugPrint('알림 취소 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setUnusedServiceNotificationEnabled(bool value) async {
|
||||
try {
|
||||
_isUnusedServiceNotificationEnabled = value;
|
||||
await _secureStorage.write(
|
||||
key: _unusedServiceNotificationKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('미사용 서비스 알림 설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 시점 설정 (1일전, 2일전, 3일전)
|
||||
Future<void> setReminderDays(int days) async {
|
||||
try {
|
||||
// 값이 변경된 경우에만 처리
|
||||
if (_reminderDays != days) {
|
||||
_reminderDays = days;
|
||||
await _secureStorage.write(
|
||||
key: _reminderDaysKey,
|
||||
value: days.toString(),
|
||||
);
|
||||
|
||||
// 웹에서는 알림 기능 비활성화
|
||||
if (_isWeb) {
|
||||
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// 1일 전으로 설정되면 반복 알림 자동 비활성화
|
||||
if (days == 1 && _isDailyReminderEnabled) {
|
||||
await setDailyReminderEnabled(false);
|
||||
} else if (_isPaymentEnabled) {
|
||||
// 알림이 활성화된 경우에만 처리
|
||||
// 알림 설정 변경 시 모든 구독의 알림 재예약 (백그라운드에서 처리)
|
||||
Future.microtask(() => _rescheduleNotificationsIfNeeded());
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('알림 시점 설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 시간 설정
|
||||
Future<void> setReminderTime(int hour, int minute) async {
|
||||
try {
|
||||
// 값이 변경된 경우에만 처리
|
||||
if (_reminderHour != hour || _reminderMinute != minute) {
|
||||
_reminderHour = hour;
|
||||
_reminderMinute = minute;
|
||||
await _secureStorage.write(
|
||||
key: _reminderHourKey,
|
||||
value: hour.toString(),
|
||||
);
|
||||
await _secureStorage.write(
|
||||
key: _reminderMinuteKey,
|
||||
value: minute.toString(),
|
||||
);
|
||||
|
||||
// 웹에서는 알림 기능 비활성화
|
||||
if (_isWeb) {
|
||||
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// 알림이 활성화된 경우에만 처리
|
||||
if (_isPaymentEnabled) {
|
||||
// 알림 설정 변경 시 모든 구독의 알림 재예약 (백그라운드에서 처리)
|
||||
Future.microtask(() => _rescheduleNotificationsIfNeeded());
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('알림 시간 설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 반복 알림 설정
|
||||
Future<void> setDailyReminderEnabled(bool value) async {
|
||||
try {
|
||||
// 값이 변경된 경우에만 처리
|
||||
if (_isDailyReminderEnabled != value) {
|
||||
_isDailyReminderEnabled = value;
|
||||
await _secureStorage.write(
|
||||
key: _dailyReminderKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
|
||||
// 웹에서는 알림 기능 비활성화
|
||||
if (_isWeb) {
|
||||
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
// 알림이 활성화된 경우에만 처리
|
||||
if (_isPaymentEnabled) {
|
||||
// 알림 설정 변경 시 모든 구독의 알림 재예약 (백그라운드에서 처리)
|
||||
Future.microtask(() => _rescheduleNotificationsIfNeeded());
|
||||
}
|
||||
|
||||
notifyListeners();
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('반복 알림 설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 설정 변경 시 모든 구독의 알림 일정 재예약
|
||||
Future<void> _rescheduleNotificationsIfNeeded() async {
|
||||
try {
|
||||
// 웹 플랫폼에서는 지원하지 않음
|
||||
if (_isWeb) {
|
||||
debugPrint('웹 플랫폼에서는 알림 기능이 지원되지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 구독 목록을 가져올 수 있고, 알림이 활성화된 경우에만 재예약
|
||||
if (_subscriptionProvider != null && _isPaymentEnabled) {
|
||||
final subscriptions = _subscriptionProvider!.subscriptions;
|
||||
await NotificationService.reschedulAllNotifications(subscriptions);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('알림 재예약 중 오류 발생: $e');
|
||||
// 오류가 발생해도 앱 동작에 영향을 주지 않도록 처리
|
||||
}
|
||||
}
|
||||
}
|
||||
246
lib/providers/subscription_provider.dart
Normal file
246
lib/providers/subscription_provider.dart
Normal file
@@ -0,0 +1,246 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hive/hive.dart';
|
||||
import 'package:uuid/uuid.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../services/notification_service.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'notification_provider.dart';
|
||||
import '../navigator_key.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.currentPrice, // 이벤트 가격 반영
|
||||
);
|
||||
}
|
||||
|
||||
/// 월간 총 비용을 반환합니다.
|
||||
double getTotalMonthlyCost() {
|
||||
return totalMonthlyExpense;
|
||||
}
|
||||
|
||||
/// 이벤트로 인한 총 절약액을 반환합니다.
|
||||
double get totalEventSavings {
|
||||
return _subscriptions.fold(
|
||||
0.0,
|
||||
(sum, subscription) => sum + subscription.eventSavings,
|
||||
);
|
||||
}
|
||||
|
||||
/// 현재 이벤트 중인 구독 목록을 반환합니다.
|
||||
List<SubscriptionModel> get activeEventSubscriptions {
|
||||
return _subscriptions.where((sub) => sub.isCurrentlyInEvent).toList();
|
||||
}
|
||||
|
||||
Future<void> init() async {
|
||||
try {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
_subscriptionBox = await Hive.openBox<SubscriptionModel>('subscriptions');
|
||||
await refreshSubscriptions();
|
||||
|
||||
// 앱 시작 시 이벤트 상태 확인
|
||||
await checkAndUpdateEventStatus();
|
||||
|
||||
_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) {
|
||||
debugPrint('구독 목록 새로고침 중 오류 발생: $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',
|
||||
bool isEventActive = false,
|
||||
DateTime? eventStartDate,
|
||||
DateTime? eventEndDate,
|
||||
double? eventPrice,
|
||||
}) async {
|
||||
try {
|
||||
final subscription = SubscriptionModel(
|
||||
id: const Uuid().v4(),
|
||||
serviceName: serviceName,
|
||||
monthlyCost: monthlyCost,
|
||||
billingCycle: billingCycle,
|
||||
nextBillingDate: nextBillingDate,
|
||||
websiteUrl: websiteUrl,
|
||||
categoryId: categoryId,
|
||||
isAutoDetected: isAutoDetected,
|
||||
repeatCount: repeatCount,
|
||||
lastPaymentDate: lastPaymentDate,
|
||||
currency: currency,
|
||||
isEventActive: isEventActive,
|
||||
eventStartDate: eventStartDate,
|
||||
eventEndDate: eventEndDate,
|
||||
eventPrice: eventPrice,
|
||||
);
|
||||
|
||||
await _subscriptionBox.put(subscription.id, subscription);
|
||||
await refreshSubscriptions();
|
||||
|
||||
// 이벤트가 활성화된 경우 알림 스케줄 재설정
|
||||
if (isEventActive && eventEndDate != null) {
|
||||
await _scheduleEventEndNotification(subscription);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('구독 추가 중 오류 발생: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSubscription(SubscriptionModel subscription) async {
|
||||
try {
|
||||
notifyListeners();
|
||||
|
||||
await _subscriptionBox.put(subscription.id, subscription);
|
||||
|
||||
// 이벤트 관련 알림 업데이트
|
||||
if (subscription.isEventActive && subscription.eventEndDate != null) {
|
||||
await _scheduleEventEndNotification(subscription);
|
||||
} else {
|
||||
// 이벤트가 비활성화된 경우 이벤트 종료 알림 취소
|
||||
await NotificationService.cancelNotification(
|
||||
'${subscription.id}_event_end'.hashCode,
|
||||
);
|
||||
}
|
||||
|
||||
await refreshSubscriptions();
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('구독 업데이트 중 오류 발생: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteSubscription(String id) async {
|
||||
try {
|
||||
await _subscriptionBox.delete(id);
|
||||
await refreshSubscriptions();
|
||||
} catch (e) {
|
||||
debugPrint('구독 삭제 중 오류 발생: $e');
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _scheduleNotifications() async {
|
||||
final BuildContext? context = navigatorKey.currentContext;
|
||||
if (context == null) return;
|
||||
|
||||
final notificationProvider = Provider.of<NotificationProvider>(
|
||||
context,
|
||||
listen: false,
|
||||
);
|
||||
|
||||
if (!notificationProvider.isEnabled ||
|
||||
!notificationProvider.isPaymentEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (final subscription in _subscriptions) {
|
||||
final notificationDate = subscription.nextBillingDate.subtract(
|
||||
const Duration(days: 3),
|
||||
);
|
||||
|
||||
if (notificationDate.isAfter(DateTime.now())) {
|
||||
await NotificationService.scheduleNotification(
|
||||
id: subscription.id.hashCode,
|
||||
title: '구독 결제 예정 알림',
|
||||
body: '${subscription.serviceName}의 결제가 3일 후 예정되어 있습니다.',
|
||||
scheduledDate: notificationDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> clearAllSubscriptions() async {
|
||||
_isLoading = true;
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
// 모든 알림 취소
|
||||
for (var subscription in _subscriptions) {
|
||||
await NotificationService.cancelSubscriptionNotification(subscription);
|
||||
}
|
||||
|
||||
// 모든 데이터 삭제
|
||||
await _subscriptionBox.clear();
|
||||
_subscriptions = [];
|
||||
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
debugPrint('모든 구독 정보 삭제 중 오류 발생: $e');
|
||||
rethrow;
|
||||
} finally {
|
||||
_isLoading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
/// 이벤트 종료 알림을 스케줄링합니다.
|
||||
Future<void> _scheduleEventEndNotification(SubscriptionModel subscription) async {
|
||||
if (subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isAfter(DateTime.now())) {
|
||||
await NotificationService.scheduleNotification(
|
||||
id: '${subscription.id}_event_end'.hashCode,
|
||||
title: '이벤트 종료 알림',
|
||||
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
|
||||
scheduledDate: subscription.eventEndDate!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 모든 구독의 이벤트 상태를 확인하고 업데이트합니다.
|
||||
Future<void> checkAndUpdateEventStatus() async {
|
||||
bool hasChanges = false;
|
||||
|
||||
for (var subscription in _subscriptions) {
|
||||
// 이벤트가 종료되었지만 아직 활성화되어 있는 경우
|
||||
if (subscription.isEventActive &&
|
||||
subscription.eventEndDate != null &&
|
||||
subscription.eventEndDate!.isBefore(DateTime.now())) {
|
||||
|
||||
subscription.isEventActive = false;
|
||||
await _subscriptionBox.put(subscription.id, subscription);
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
await refreshSubscriptions();
|
||||
}
|
||||
}
|
||||
}
|
||||
2022
lib/screens/add_subscription_screen.dart
Normal file
2022
lib/screens/add_subscription_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
1143
lib/screens/analysis_screen.dart
Normal file
1143
lib/screens/analysis_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
63
lib/screens/app_lock_screen.dart
Normal file
63
lib/screens/app_lock_screen.dart
Normal file
@@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
|
||||
class AppLockScreen extends StatelessWidget {
|
||||
const AppLockScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lock_outline,
|
||||
size: 80,
|
||||
color: Colors.grey,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'앱이 잠겨 있습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'생체 인증으로 잠금을 해제하세요',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton.icon(
|
||||
onPressed: () async {
|
||||
final appLock = context.read<AppLockProvider>();
|
||||
final success = await appLock.authenticate();
|
||||
if (!success) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('인증에 실패했습니다. 다시 시도해주세요.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.fingerprint),
|
||||
label: const Text('생체 인증으로 잠금 해제'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
lib/screens/category_management_screen.dart
Normal file
179
lib/screens/category_management_screen.dart
Normal file
@@ -0,0 +1,179 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../models/category_model.dart';
|
||||
|
||||
class CategoryManagementScreen extends StatefulWidget {
|
||||
const CategoryManagementScreen({super.key});
|
||||
|
||||
@override
|
||||
State<CategoryManagementScreen> createState() =>
|
||||
_CategoryManagementScreenState();
|
||||
}
|
||||
|
||||
class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
String _selectedColor = '#1976D2';
|
||||
String _selectedIcon = 'subscriptions';
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _addCategory() async {
|
||||
if (_formKey.currentState!.validate()) {
|
||||
await Provider.of<CategoryProvider>(context, listen: false).addCategory(
|
||||
name: _nameController.text,
|
||||
color: _selectedColor,
|
||||
icon: _selectedIcon,
|
||||
);
|
||||
_nameController.clear();
|
||||
setState(() {
|
||||
_selectedColor = '#1976D2';
|
||||
_selectedIcon = 'subscriptions';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('카테고리 관리'),
|
||||
backgroundColor: const Color(0xFF1976D2),
|
||||
),
|
||||
body: Consumer<CategoryProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return Column(
|
||||
children: [
|
||||
// 카테고리 추가 폼
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '카테고리 이름',
|
||||
),
|
||||
validator: (value) {
|
||||
if (value == null || value.isEmpty) {
|
||||
return '카테고리 이름을 입력하세요';
|
||||
}
|
||||
return null;
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedColor,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '색상 선택',
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: '#1976D2', child: Text('파란색')),
|
||||
DropdownMenuItem(
|
||||
value: '#4CAF50', child: Text('초록색')),
|
||||
DropdownMenuItem(
|
||||
value: '#FF9800', child: Text('주황색')),
|
||||
DropdownMenuItem(
|
||||
value: '#F44336', child: Text('빨간색')),
|
||||
DropdownMenuItem(
|
||||
value: '#9C27B0', child: Text('보라색')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedColor = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
DropdownButtonFormField<String>(
|
||||
value: _selectedIcon,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '아이콘 선택',
|
||||
),
|
||||
items: const [
|
||||
DropdownMenuItem(
|
||||
value: 'subscriptions', child: Text('구독')),
|
||||
DropdownMenuItem(value: 'movie', child: Text('영화')),
|
||||
DropdownMenuItem(
|
||||
value: 'music_note', child: Text('음악')),
|
||||
DropdownMenuItem(
|
||||
value: 'fitness_center', child: Text('운동')),
|
||||
DropdownMenuItem(
|
||||
value: 'shopping_cart', child: Text('쇼핑')),
|
||||
],
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_selectedIcon = value!;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _addCategory,
|
||||
child: const Text('카테고리 추가'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 카테고리 목록
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: provider.categories.length,
|
||||
itemBuilder: (context, index) {
|
||||
final category = provider.categories[index];
|
||||
return ListTile(
|
||||
leading: Icon(
|
||||
IconData(
|
||||
_getIconCode(category.icon),
|
||||
fontFamily: 'MaterialIcons',
|
||||
),
|
||||
color: Color(
|
||||
int.parse(category.color.replaceAll('#', '0xFF'))),
|
||||
),
|
||||
title: Text(category.name),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.delete),
|
||||
onPressed: () async {
|
||||
await provider.deleteCategory(category.id);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
int _getIconCode(String iconName) {
|
||||
switch (iconName) {
|
||||
case 'subscriptions':
|
||||
return 0xe8c1;
|
||||
case 'movie':
|
||||
return 0xe8c2;
|
||||
case 'music_note':
|
||||
return 0xe405;
|
||||
case 'fitness_center':
|
||||
return 0xeb43;
|
||||
case 'shopping_cart':
|
||||
return 0xe8cc;
|
||||
default:
|
||||
return 0xe8c1;
|
||||
}
|
||||
}
|
||||
}
|
||||
2232
lib/screens/detail_screen.dart
Normal file
2232
lib/screens/detail_screen.dart
Normal file
File diff suppressed because it is too large
Load Diff
442
lib/screens/main_screen.dart
Normal file
442
lib/screens/main_screen.dart
Normal file
@@ -0,0 +1,442 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'dart:math' as math;
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'add_subscription_screen.dart';
|
||||
import 'analysis_screen.dart';
|
||||
import 'app_lock_screen.dart';
|
||||
import 'settings_screen.dart';
|
||||
import '../widgets/subscription_card.dart';
|
||||
import '../widgets/skeleton_loading.dart';
|
||||
import 'sms_scan_screen.dart';
|
||||
import '../providers/category_provider.dart';
|
||||
import '../utils/subscription_category_helper.dart';
|
||||
import '../utils/animation_controller_helper.dart';
|
||||
import '../widgets/subscription_list_widget.dart';
|
||||
import '../widgets/main_summary_card.dart';
|
||||
import '../widgets/empty_state_widget.dart';
|
||||
import '../widgets/native_ad_widget.dart';
|
||||
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen>
|
||||
with WidgetsBindingObserver, TickerProviderStateMixin {
|
||||
late AnimationController _fadeController;
|
||||
late AnimationController _scaleController;
|
||||
late AnimationController _rotateController;
|
||||
late AnimationController _slideController;
|
||||
late AnimationController _pulseController;
|
||||
late AnimationController _waveController;
|
||||
late ScrollController _scrollController;
|
||||
double _scrollOffset = 0;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
_checkAppLock();
|
||||
|
||||
// 애니메이션 컨트롤러 초기화
|
||||
_fadeController = AnimationController(vsync: this);
|
||||
_scaleController = AnimationController(vsync: this);
|
||||
_rotateController = AnimationController(vsync: this);
|
||||
_slideController = AnimationController(vsync: this);
|
||||
_pulseController = AnimationController(vsync: this);
|
||||
_waveController = AnimationController(vsync: this);
|
||||
|
||||
// 헬퍼 클래스를 사용해 애니메이션 컨트롤러 초기화
|
||||
AnimationControllerHelper.initControllers(
|
||||
vsync: this,
|
||||
fadeController: _fadeController,
|
||||
scaleController: _scaleController,
|
||||
rotateController: _rotateController,
|
||||
slideController: _slideController,
|
||||
pulseController: _pulseController,
|
||||
waveController: _waveController,
|
||||
);
|
||||
|
||||
_scrollController = ScrollController()
|
||||
..addListener(() {
|
||||
setState(() {
|
||||
_scrollOffset = _scrollController.offset;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
// 헬퍼 클래스를 사용해 애니메이션 컨트롤러 해제
|
||||
AnimationControllerHelper.disposeControllers(
|
||||
fadeController: _fadeController,
|
||||
scaleController: _scaleController,
|
||||
rotateController: _rotateController,
|
||||
slideController: _slideController,
|
||||
pulseController: _pulseController,
|
||||
waveController: _waveController,
|
||||
);
|
||||
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.paused) {
|
||||
// 앱이 백그라운드로 갈 때
|
||||
final appLockProvider = context.read<AppLockProvider>();
|
||||
if (appLockProvider.isBiometricEnabled) {
|
||||
appLockProvider.lock();
|
||||
}
|
||||
} else if (state == AppLifecycleState.resumed) {
|
||||
// 앱이 포그라운드로 돌아올 때
|
||||
_checkAppLock();
|
||||
_resetAnimations();
|
||||
}
|
||||
}
|
||||
|
||||
void _resetAnimations() {
|
||||
AnimationControllerHelper.resetAnimations(
|
||||
fadeController: _fadeController,
|
||||
scaleController: _scaleController,
|
||||
slideController: _slideController,
|
||||
pulseController: _pulseController,
|
||||
waveController: _waveController,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _checkAppLock() async {
|
||||
final appLockProvider = context.read<AppLockProvider>();
|
||||
if (appLockProvider.isLocked) {
|
||||
await Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const AppLockScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToSmsScan(BuildContext context) async {
|
||||
final added = await Navigator.push<bool>(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const SmsScanScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (added == true && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
|
||||
);
|
||||
}
|
||||
|
||||
_resetAnimations();
|
||||
}
|
||||
|
||||
void _navigateToAnalysis(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const AnalysisScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToAddSubscription(BuildContext context) {
|
||||
HapticFeedback.mediumImpact();
|
||||
Navigator.of(context)
|
||||
.push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const AddSubscriptionScreen(),
|
||||
transitionsBuilder:
|
||||
(context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: Tween<double>(begin: 0.8, end: 1.0).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
.then((_) => _resetAnimations());
|
||||
}
|
||||
|
||||
void _navigateToSettings(BuildContext context) {
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const SettingsScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(1, 0),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double appBarOpacity = math.max(0, math.min(1, _scrollOffset / 100));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.backgroundColor,
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: _buildAppBar(appBarOpacity),
|
||||
body: _buildBody(context, context.watch<SubscriptionProvider>()),
|
||||
floatingActionButton: _buildFloatingActionButton(context),
|
||||
);
|
||||
}
|
||||
|
||||
PreferredSize _buildAppBar(double appBarOpacity) {
|
||||
return PreferredSize(
|
||||
preferredSize: const Size.fromHeight(60),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColor.withOpacity(appBarOpacity),
|
||||
boxShadow: appBarOpacity > 0.6
|
||||
? [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06 * appBarOpacity),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: SafeArea(
|
||||
child: AppBar(
|
||||
title: FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: _fadeController, curve: Curves.easeInOut)),
|
||||
child: const Text(
|
||||
'SubManager',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Montserrat',
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.5,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
),
|
||||
),
|
||||
elevation: 0,
|
||||
backgroundColor: Colors.transparent,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.chartPie,
|
||||
size: 20, color: Color(0xFF64748B)),
|
||||
tooltip: '분석',
|
||||
onPressed: () => _navigateToAnalysis(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.sms,
|
||||
size: 20, color: Color(0xFF64748B)),
|
||||
tooltip: 'SMS 스캔',
|
||||
onPressed: () => _navigateToSmsScan(context),
|
||||
),
|
||||
IconButton(
|
||||
icon: const FaIcon(FontAwesomeIcons.gear,
|
||||
size: 20, color: Color(0xFF64748B)),
|
||||
tooltip: '설정',
|
||||
onPressed: () => _navigateToSettings(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFloatingActionButton(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _scaleController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: Tween<double>(begin: 0.95, end: 1.0)
|
||||
.animate(CurvedAnimation(
|
||||
parent: _scaleController, curve: Curves.easeOutBack))
|
||||
.value,
|
||||
child: FloatingActionButton.extended(
|
||||
onPressed: () => _navigateToAddSubscription(context),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
label: const Text(
|
||||
'구독 추가',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
elevation: 4,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context, SubscriptionProvider provider) {
|
||||
if (provider.isLoading) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(
|
||||
valueColor: AlwaysStoppedAnimation<Color>(Color(0xFF3B82F6)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.subscriptions.isEmpty) {
|
||||
return EmptyStateWidget(
|
||||
fadeController: _fadeController,
|
||||
rotateController: _rotateController,
|
||||
slideController: _slideController,
|
||||
onAddPressed: () => _navigateToAddSubscription(context),
|
||||
);
|
||||
}
|
||||
|
||||
// 카테고리별 구독 구분
|
||||
final categoryProvider =
|
||||
Provider.of<CategoryProvider>(context, listen: false);
|
||||
final categorizedSubscriptions =
|
||||
SubscriptionCategoryHelper.categorizeSubscriptions(
|
||||
provider.subscriptions,
|
||||
categoryProvider,
|
||||
);
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async {
|
||||
await provider.refreshSubscriptions();
|
||||
_resetAnimations();
|
||||
},
|
||||
color: const Color(0xFF3B82F6),
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: MediaQuery.of(context).padding.top + 60),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: NativeAdWidget(key: UniqueKey()),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _slideController, curve: Curves.easeOutCubic)),
|
||||
child: MainScreenSummaryCard(
|
||||
provider: provider,
|
||||
fadeController: _fadeController,
|
||||
pulseController: _pulseController,
|
||||
waveController: _waveController,
|
||||
slideController: _slideController,
|
||||
onTap: () => _navigateToAnalysis(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 4),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(-0.2, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _slideController, curve: Curves.easeOutCubic)),
|
||||
child: Text(
|
||||
'나의 구독 서비스',
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
),
|
||||
SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.2, 0),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _slideController, curve: Curves.easeOutCubic)),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${provider.subscriptions.length}개',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 14,
|
||||
color: AppColors.primaryColor,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SubscriptionListWidget(
|
||||
categorizedSubscriptions: categorizedSubscriptions,
|
||||
fadeController: _fadeController,
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: 100),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
561
lib/screens/settings_screen.dart
Normal file
561
lib/screens/settings_screen.dart
Normal file
@@ -0,0 +1,561 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
import '../providers/notification_provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'dart:io';
|
||||
import 'package:path/path.dart' as path;
|
||||
import '../services/notification_service.dart';
|
||||
import '../screens/sms_scan_screen.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class SettingsScreen extends StatelessWidget {
|
||||
const SettingsScreen({super.key});
|
||||
|
||||
// 알림 시점 라디오 버튼 생성 헬퍼 메서드
|
||||
Widget _buildReminderDayRadio(BuildContext context,
|
||||
NotificationProvider provider, int value, String label) {
|
||||
final isSelected = provider.reminderDays == value;
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => provider.setReminderDays(value),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary.withOpacity(0.2)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline.withOpacity(0.5),
|
||||
width: isSelected ? 2 : 1,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
isSelected
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.outline,
|
||||
size: 24,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: isSelected ? FontWeight.bold : FontWeight.normal,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: Theme.of(context).colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _backupData(BuildContext context) async {
|
||||
try {
|
||||
final provider = context.read<SubscriptionProvider>();
|
||||
final subscriptions = provider.subscriptions;
|
||||
|
||||
// 임시 디렉토리에 백업 파일 생성
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final backupFile =
|
||||
File(path.join(tempDir.path, 'submanager_backup.json'));
|
||||
|
||||
// 구독 데이터를 JSON 형식으로 저장
|
||||
final jsonData = subscriptions
|
||||
.map((sub) => {
|
||||
'id': sub.id,
|
||||
'serviceName': sub.serviceName,
|
||||
'monthlyCost': sub.monthlyCost,
|
||||
'billingCycle': sub.billingCycle,
|
||||
'nextBillingDate': sub.nextBillingDate.toIso8601String(),
|
||||
'isAutoDetected': sub.isAutoDetected,
|
||||
'repeatCount': sub.repeatCount,
|
||||
'lastPaymentDate': sub.lastPaymentDate?.toIso8601String(),
|
||||
})
|
||||
.toList();
|
||||
|
||||
await backupFile.writeAsString(jsonData.toString());
|
||||
|
||||
// 파일 공유
|
||||
await Share.shareXFiles(
|
||||
[XFile(backupFile.path)],
|
||||
text: 'SubManager 백업 파일',
|
||||
);
|
||||
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('백업 파일이 생성되었습니다')),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('백업 중 오류가 발생했습니다: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SMS 스캔 화면으로 이동
|
||||
void _navigateToSmsScan(BuildContext context) async {
|
||||
final added = await Navigator.push<bool>(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const SmsScanScreen()),
|
||||
);
|
||||
|
||||
if (added == true && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('구독이 성공적으로 추가되었습니다')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('설정'),
|
||||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
// 앱 잠금 설정 UI 숨김
|
||||
// Card(
|
||||
// margin: const EdgeInsets.all(16),
|
||||
// child: Consumer<AppLockProvider>(
|
||||
// builder: (context, provider, child) {
|
||||
// return SwitchListTile(
|
||||
// title: const Text('앱 잠금'),
|
||||
// subtitle: const Text('생체 인증으로 앱 잠금'),
|
||||
// value: provider.isEnabled,
|
||||
// onChanged: (value) async {
|
||||
// if (value) {
|
||||
// final isAuthenticated = await provider.authenticate();
|
||||
// if (isAuthenticated) {
|
||||
// provider.enable();
|
||||
// }
|
||||
// } else {
|
||||
// provider.disable();
|
||||
// }
|
||||
// },
|
||||
// );
|
||||
// },
|
||||
// ),
|
||||
// ),
|
||||
|
||||
// 알림 설정
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Consumer<NotificationProvider>(
|
||||
builder: (context, provider, child) {
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
title: const Text('알림 권한'),
|
||||
subtitle: const Text('알림을 받으려면 권한이 필요합니다'),
|
||||
trailing: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final granted =
|
||||
await NotificationService.requestPermission();
|
||||
if (granted) {
|
||||
provider.setEnabled(true);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('알림 권한이 거부되었습니다'),
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
child: const Text('권한 요청'),
|
||||
),
|
||||
),
|
||||
const Divider(),
|
||||
// 결제 예정 알림 기본 스위치
|
||||
SwitchListTile(
|
||||
title: const Text('결제 예정 알림'),
|
||||
subtitle: const Text('결제 예정일 알림 받기'),
|
||||
value: provider.isPaymentEnabled,
|
||||
onChanged: (value) {
|
||||
provider.setPaymentEnabled(value);
|
||||
},
|
||||
),
|
||||
|
||||
// 알림 세부 설정 (알림 활성화된 경우에만 표시)
|
||||
AnimatedSize(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
child: provider.isPaymentEnabled
|
||||
? Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16.0, right: 16.0, bottom: 8.0),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant
|
||||
.withOpacity(0.3),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12.0),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 알림 시점 선택 (1일전, 2일전, 3일전)
|
||||
const Text('알림 시점',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 8.0),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_buildReminderDayRadio(
|
||||
context, provider, 1, '1일 전'),
|
||||
_buildReminderDayRadio(
|
||||
context, provider, 2, '2일 전'),
|
||||
_buildReminderDayRadio(
|
||||
context, provider, 3, '3일 전'),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 알림 시간 선택
|
||||
const Text('알림 시간',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 12),
|
||||
InkWell(
|
||||
onTap: () async {
|
||||
final TimeOfDay? picked =
|
||||
await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay(
|
||||
hour: provider.reminderHour,
|
||||
minute:
|
||||
provider.reminderMinute),
|
||||
);
|
||||
if (picked != null) {
|
||||
provider.setReminderTime(
|
||||
picked.hour, picked.minute);
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline
|
||||
.withOpacity(0.5),
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.access_time,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
size: 22,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(
|
||||
'${provider.reminderHour.toString().padLeft(2, '0')}:${provider.reminderMinute.toString().padLeft(2, '0')}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
FontWeight.bold,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurface,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.outline,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 반복 알림 스위치 (2일전, 3일전 선택 시에만 활성화)
|
||||
if (provider.reminderDays >= 2)
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(top: 16.0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 4, horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.surfaceVariant
|
||||
.withOpacity(0.3),
|
||||
borderRadius:
|
||||
BorderRadius.circular(8),
|
||||
),
|
||||
child: SwitchListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: 12),
|
||||
title: const Text('1일마다 반복 알림'),
|
||||
subtitle: const Text(
|
||||
'결제일까지 매일 알림을 받습니다'),
|
||||
value: provider
|
||||
.isDailyReminderEnabled,
|
||||
activeColor: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
onChanged: (value) {
|
||||
provider
|
||||
.setDailyReminderEnabled(
|
||||
value);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
// 미사용 서비스 알림 기능 비활성화
|
||||
// const Divider(),
|
||||
// SwitchListTile(
|
||||
// title: const Text('미사용 서비스 알림'),
|
||||
// subtitle: const Text('2개월 이상 미사용 시 알림'),
|
||||
// value: provider.isUnusedServiceNotificationEnabled,
|
||||
// onChanged: (value) {
|
||||
// provider.setUnusedServiceNotificationEnabled(value);
|
||||
// },
|
||||
// ),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// 데이터 관리
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
children: [
|
||||
// 데이터 백업 기능 비활성화
|
||||
// ListTile(
|
||||
// title: const Text('데이터 백업'),
|
||||
// subtitle: const Text('구독 데이터를 백업합니다'),
|
||||
// leading: const Icon(Icons.backup),
|
||||
// onTap: () => _backupData(context),
|
||||
// ),
|
||||
// const Divider(),
|
||||
// SMS 스캔 - 시각적으로 강조된 UI
|
||||
InkWell(
|
||||
onTap: () => _navigateToSmsScan(context),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Theme.of(context).primaryColor.withOpacity(0.1),
|
||||
Theme.of(context).primaryColor.withOpacity(0.2),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(12),
|
||||
bottomRight: Radius.circular(12),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 16.0, horizontal: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(left: 8, right: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context)
|
||||
.primaryColor
|
||||
.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.sms_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'SMS 스캔으로 구독 자동 찾기',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'추천',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'2회 이상 반복 결제된 구독 서비스를 자동으로 찾아 추가합니다',
|
||||
style: TextStyle(
|
||||
color: Colors.black54,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
size: 16,
|
||||
color: Theme.of(context).primaryColor,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// 앱 정보
|
||||
Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: ListTile(
|
||||
title: const Text('앱 정보'),
|
||||
subtitle: const Text('버전 1.0.0'),
|
||||
leading: const Icon(Icons.info),
|
||||
onTap: () async {
|
||||
// 웹 환경에서는 기본 다이얼로그 표시
|
||||
if (kIsWeb) {
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SubManager',
|
||||
applicationVersion: '1.0.0',
|
||||
applicationIcon: const FlutterLogo(size: 50),
|
||||
children: [
|
||||
const Text('구독 관리 앱'),
|
||||
const SizedBox(height: 8),
|
||||
const Text('개발자: SubManager Team'),
|
||||
],
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 앱 스토어 링크
|
||||
String storeUrl = '';
|
||||
|
||||
// 플랫폼에 따라 스토어 링크 설정
|
||||
if (Platform.isAndroid) {
|
||||
// Android - Google Play 스토어 링크
|
||||
storeUrl =
|
||||
'https://play.google.com/store/apps/details?id=com.submanager.app';
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS - App Store 링크
|
||||
storeUrl =
|
||||
'https://apps.apple.com/app/submanager/id123456789';
|
||||
}
|
||||
|
||||
if (storeUrl.isNotEmpty) {
|
||||
try {
|
||||
final Uri url = Uri.parse(storeUrl);
|
||||
await launchUrl(url, mode: LaunchMode.externalApplication);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('스토어를 열 수 없습니다')),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 스토어 링크를 열 수 없는 경우 기존 정보 다이얼로그 표시
|
||||
showAboutDialog(
|
||||
context: context,
|
||||
applicationName: 'SubManager',
|
||||
applicationVersion: '1.0.0',
|
||||
applicationIcon: const FlutterLogo(size: 50),
|
||||
children: [
|
||||
const Text('구독 관리 앱'),
|
||||
const SizedBox(height: 8),
|
||||
const Text('개발자: SubManager Team'),
|
||||
],
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
820
lib/screens/sms_scan_screen.dart
Normal file
820
lib/screens/sms_scan_screen.dart
Normal file
@@ -0,0 +1,820 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/sms_scanner.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../models/subscription.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
import 'package:intl/intl.dart'; // NumberFormat을 사용하기 위한 import 추가
|
||||
|
||||
class SmsScanScreen extends StatefulWidget {
|
||||
const SmsScanScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SmsScanScreen> createState() => _SmsScanScreenState();
|
||||
}
|
||||
|
||||
class _SmsScanScreenState extends State<SmsScanScreen> {
|
||||
bool _isLoading = false;
|
||||
String? _errorMessage;
|
||||
final SmsScanner _smsScanner = SmsScanner();
|
||||
|
||||
// 스캔한 구독 목록
|
||||
List<Subscription> _scannedSubscriptions = [];
|
||||
|
||||
// 현재 표시 중인 구독 인덱스
|
||||
int _currentIndex = 0;
|
||||
|
||||
// 웹사이트 URL 컨트롤러
|
||||
final TextEditingController _websiteUrlController = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_websiteUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// SMS 스캔 실행
|
||||
Future<void> _scanSms() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
_errorMessage = null;
|
||||
_scannedSubscriptions = [];
|
||||
_currentIndex = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
// SMS 스캔 실행
|
||||
print('SMS 스캔 시작');
|
||||
final scannedSubscriptionModels =
|
||||
await _smsScanner.scanForSubscriptions();
|
||||
print('스캔된 구독: ${scannedSubscriptionModels.length}개');
|
||||
|
||||
if (scannedSubscriptionModels.isNotEmpty) {
|
||||
print(
|
||||
'첫 번째 구독: ${scannedSubscriptionModels[0].serviceName}, 반복 횟수: ${scannedSubscriptionModels[0].repeatCount}');
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
if (scannedSubscriptionModels.isEmpty) {
|
||||
print('스캔된 구독이 없음');
|
||||
setState(() {
|
||||
_errorMessage = '구독 정보를 찾을 수 없습니다.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SubscriptionModel을 Subscription으로 변환
|
||||
final scannedSubscriptions =
|
||||
_convertModelsToSubscriptions(scannedSubscriptionModels);
|
||||
|
||||
// 2회 이상 반복 결제된 구독만 필터링
|
||||
final repeatSubscriptions =
|
||||
scannedSubscriptions.where((sub) => sub.repeatCount >= 2).toList();
|
||||
print('반복 결제된 구독: ${repeatSubscriptions.length}개');
|
||||
|
||||
if (repeatSubscriptions.isNotEmpty) {
|
||||
print(
|
||||
'첫 번째 반복 구독: ${repeatSubscriptions[0].serviceName}, 반복 횟수: ${repeatSubscriptions[0].repeatCount}');
|
||||
}
|
||||
|
||||
if (repeatSubscriptions.isEmpty) {
|
||||
print('반복 결제된 구독이 없음');
|
||||
setState(() {
|
||||
_errorMessage = '반복 결제된 구독 정보를 찾을 수 없습니다.';
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 구독 목록 가져오기
|
||||
final provider =
|
||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
final existingSubscriptions = provider.subscriptions;
|
||||
print('기존 구독: ${existingSubscriptions.length}개');
|
||||
|
||||
// 중복 구독 필터링
|
||||
final filteredSubscriptions =
|
||||
_filterDuplicates(repeatSubscriptions, existingSubscriptions);
|
||||
print('중복 제거 후 구독: ${filteredSubscriptions.length}개');
|
||||
|
||||
if (filteredSubscriptions.isNotEmpty &&
|
||||
filteredSubscriptions[0] != null) {
|
||||
print(
|
||||
'첫 번째 필터링된 구독: ${filteredSubscriptions[0].serviceName}, 반복 횟수: ${filteredSubscriptions[0].repeatCount}');
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_scannedSubscriptions = filteredSubscriptions;
|
||||
_isLoading = false;
|
||||
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||
});
|
||||
} catch (e) {
|
||||
print('SMS 스캔 중 오류 발생: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_errorMessage = 'SMS 스캔 중 오류가 발생했습니다: $e';
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SubscriptionModel 리스트를 Subscription 리스트로 변환
|
||||
List<Subscription> _convertModelsToSubscriptions(
|
||||
List<SubscriptionModel> models) {
|
||||
final result = <Subscription>[];
|
||||
|
||||
for (var model in models) {
|
||||
try {
|
||||
// 모델의 필드가 null인 경우 기본값 사용
|
||||
result.add(Subscription(
|
||||
id: model.id,
|
||||
serviceName: model.serviceName,
|
||||
monthlyCost: model.monthlyCost,
|
||||
billingCycle: model.billingCycle,
|
||||
nextBillingDate: model.nextBillingDate,
|
||||
category: model.categoryId, // categoryId를 category로 올바르게 매핑
|
||||
repeatCount: model.repeatCount > 0
|
||||
? model.repeatCount
|
||||
: 1, // 반복 횟수가 0 이하인 경우 기본값 1 사용
|
||||
lastPaymentDate: model.lastPaymentDate,
|
||||
websiteUrl: model.websiteUrl,
|
||||
currency: model.currency, // 통화 단위 정보 추가
|
||||
));
|
||||
|
||||
print(
|
||||
'모델 변환 성공: ${model.serviceName}, 카테고리ID: ${model.categoryId}, URL: ${model.websiteUrl}, 통화: ${model.currency}');
|
||||
} catch (e) {
|
||||
print('모델 변환 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// 중복 구독 필터링 (서비스명과 금액이 같으면 중복으로 간주)
|
||||
List<Subscription> _filterDuplicates(
|
||||
List<Subscription> scanned, List<SubscriptionModel> existing) {
|
||||
print(
|
||||
'_filterDuplicates: 스캔된 구독 ${scanned.length}개, 기존 구독 ${existing.length}개');
|
||||
|
||||
// 중복되지 않은 구독만 필터링
|
||||
final nonDuplicates = scanned.where((scannedSub) {
|
||||
if (scannedSub == null) {
|
||||
print('_filterDuplicates: null 구독 객체 발견');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 서비스명과 금액이 동일한 기존 구독 찾기
|
||||
final hasDuplicate = existing.any((existingSub) =>
|
||||
existingSub.serviceName.toLowerCase() ==
|
||||
scannedSub.serviceName.toLowerCase() &&
|
||||
existingSub.monthlyCost == scannedSub.monthlyCost);
|
||||
|
||||
if (hasDuplicate) {
|
||||
print('_filterDuplicates: 중복 발견 - ${scannedSub.serviceName}');
|
||||
}
|
||||
|
||||
// 중복이 없으면 true 반환
|
||||
return !hasDuplicate;
|
||||
}).toList();
|
||||
|
||||
print('_filterDuplicates: 중복 제거 후 ${nonDuplicates.length}개');
|
||||
|
||||
// 각 구독에 웹사이트 URL 자동 매칭 시도
|
||||
final result = <Subscription>[];
|
||||
|
||||
for (int i = 0; i < nonDuplicates.length; i++) {
|
||||
final subscription = nonDuplicates[i];
|
||||
if (subscription == null) {
|
||||
print('_filterDuplicates: null 구독 객체 무시');
|
||||
continue;
|
||||
}
|
||||
|
||||
String? websiteUrl = subscription.websiteUrl;
|
||||
|
||||
if (websiteUrl == null || websiteUrl.isEmpty) {
|
||||
websiteUrl =
|
||||
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
|
||||
print(
|
||||
'_filterDuplicates: URL 자동 매칭 시도 - ${subscription.serviceName}, 결과: ${websiteUrl ?? "매칭 실패"}');
|
||||
}
|
||||
|
||||
try {
|
||||
// 유효성 검사
|
||||
if (subscription.serviceName.isEmpty) {
|
||||
print('_filterDuplicates: 서비스명이 비어 있습니다. 건너뜁니다.');
|
||||
continue;
|
||||
}
|
||||
|
||||
if (subscription.monthlyCost <= 0) {
|
||||
print('_filterDuplicates: 월 비용이 0 이하입니다. 건너뜁니다.');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Subscription 객체에 URL 설정 (새 객체 생성)
|
||||
result.add(Subscription(
|
||||
id: subscription.id,
|
||||
serviceName: subscription.serviceName,
|
||||
monthlyCost: subscription.monthlyCost,
|
||||
billingCycle: subscription.billingCycle,
|
||||
nextBillingDate: subscription.nextBillingDate,
|
||||
category: subscription.category,
|
||||
notes: subscription.notes,
|
||||
repeatCount:
|
||||
subscription.repeatCount > 0 ? subscription.repeatCount : 1,
|
||||
lastPaymentDate: subscription.lastPaymentDate,
|
||||
websiteUrl: websiteUrl,
|
||||
currency: subscription.currency, // 통화 단위 정보 추가
|
||||
));
|
||||
|
||||
print(
|
||||
'_filterDuplicates: URL 설정 - ${subscription.serviceName}, URL: ${websiteUrl ?? "없음"}, 카테고리: ${subscription.category ?? "없음"}, 통화: ${subscription.currency}');
|
||||
} catch (e) {
|
||||
print('_filterDuplicates: 구독 객체 생성 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
print('_filterDuplicates: URL 설정 완료, 최종 ${result.length}개 구독');
|
||||
return result;
|
||||
}
|
||||
|
||||
// 현재 구독 추가
|
||||
Future<void> _addCurrentSubscription() async {
|
||||
if (_scannedSubscriptions.isEmpty ||
|
||||
_currentIndex >= _scannedSubscriptions.length) {
|
||||
print(
|
||||
'오류: 인덱스가 범위를 벗어났습니다. (index: $_currentIndex, size: ${_scannedSubscriptions.length})');
|
||||
return;
|
||||
}
|
||||
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
if (subscription == null) {
|
||||
print('오류: 현재 인덱스의 구독이 null입니다. (index: $_currentIndex)');
|
||||
_moveToNextSubscription();
|
||||
return;
|
||||
}
|
||||
|
||||
final provider = Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
|
||||
// 날짜가 과거면 다음 결제일을 조정
|
||||
final now = DateTime.now();
|
||||
DateTime nextBillingDate = subscription.nextBillingDate;
|
||||
|
||||
if (nextBillingDate.isBefore(now)) {
|
||||
// 주기에 따라 다음 결제일 조정
|
||||
if (subscription.billingCycle == '월간') {
|
||||
// 현재 달의 결제일
|
||||
int day = nextBillingDate.day;
|
||||
// 현재 월의 마지막 날을 초과하는 경우 조정
|
||||
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
||||
if (day > lastDay) {
|
||||
day = lastDay;
|
||||
}
|
||||
|
||||
DateTime adjustedDate = DateTime(now.year, now.month, day);
|
||||
|
||||
// 현재 날짜보다 이전이라면 다음 달로 설정
|
||||
if (adjustedDate.isBefore(now)) {
|
||||
// 다음 달의 마지막 날을 초과하는 경우 조정
|
||||
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
|
||||
if (day > nextMonthLastDay) {
|
||||
day = nextMonthLastDay;
|
||||
}
|
||||
adjustedDate = DateTime(now.year, now.month + 1, day);
|
||||
}
|
||||
|
||||
nextBillingDate = adjustedDate;
|
||||
} else if (subscription.billingCycle == '연간') {
|
||||
// 현재 년도의 결제일
|
||||
int day = nextBillingDate.day;
|
||||
// 해당 월의 마지막 날을 초과하는 경우 조정
|
||||
final lastDay = DateTime(now.year, nextBillingDate.month + 1, 0).day;
|
||||
if (day > lastDay) {
|
||||
day = lastDay;
|
||||
}
|
||||
|
||||
DateTime adjustedDate = DateTime(now.year, nextBillingDate.month, day);
|
||||
|
||||
// 현재 날짜보다 이전이라면 다음 해로 설정
|
||||
if (adjustedDate.isBefore(now)) {
|
||||
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
|
||||
final nextYearLastDay =
|
||||
DateTime(now.year + 1, nextBillingDate.month + 1, 0).day;
|
||||
if (day > nextYearLastDay) {
|
||||
day = nextYearLastDay;
|
||||
}
|
||||
adjustedDate = DateTime(now.year + 1, nextBillingDate.month, day);
|
||||
}
|
||||
|
||||
nextBillingDate = adjustedDate;
|
||||
} else if (subscription.billingCycle == '주간') {
|
||||
// 현재 날짜에서 가장 가까운 다음 주 같은 요일
|
||||
final daysUntilNext = 7 - (now.weekday - nextBillingDate.weekday) % 7;
|
||||
nextBillingDate =
|
||||
now.add(Duration(days: daysUntilNext == 0 ? 7 : daysUntilNext));
|
||||
}
|
||||
}
|
||||
|
||||
// 웹사이트 URL이 비어있으면 자동 매칭 시도
|
||||
String? websiteUrl = _websiteUrlController.text.trim();
|
||||
if (websiteUrl.isEmpty && subscription.websiteUrl != null) {
|
||||
websiteUrl = subscription.websiteUrl;
|
||||
print('구독 추가: 기존 URL 사용 - ${websiteUrl ?? "없음"}');
|
||||
} else if (websiteUrl.isEmpty) {
|
||||
try {
|
||||
websiteUrl =
|
||||
SubscriptionUrlMatcher.suggestUrl(subscription.serviceName);
|
||||
print(
|
||||
'구독 추가: URL 자동 매칭 - ${subscription.serviceName} -> ${websiteUrl ?? "매칭 실패"}');
|
||||
} catch (e) {
|
||||
print('구독 추가: URL 자동 매칭 실패 - $e');
|
||||
websiteUrl = null;
|
||||
}
|
||||
} else {
|
||||
print('구독 추가: 사용자 입력 URL 사용 - $websiteUrl');
|
||||
}
|
||||
|
||||
try {
|
||||
print(
|
||||
'구독 추가 시도 - 서비스명: ${subscription.serviceName}, 비용: ${subscription.monthlyCost}, 반복 횟수: ${subscription.repeatCount}');
|
||||
|
||||
// 반복 횟수가 0 이하인 경우 기본값 1 사용
|
||||
final int safeRepeatCount =
|
||||
subscription.repeatCount > 0 ? subscription.repeatCount : 1;
|
||||
|
||||
await provider.addSubscription(
|
||||
serviceName: subscription.serviceName,
|
||||
monthlyCost: subscription.monthlyCost,
|
||||
billingCycle: subscription.billingCycle,
|
||||
nextBillingDate: nextBillingDate,
|
||||
websiteUrl: websiteUrl,
|
||||
isAutoDetected: true,
|
||||
repeatCount: safeRepeatCount,
|
||||
lastPaymentDate: subscription.lastPaymentDate,
|
||||
categoryId: subscription.category,
|
||||
currency: subscription.currency, // 통화 단위 정보 추가
|
||||
);
|
||||
|
||||
print('구독 추가 성공');
|
||||
|
||||
// 성공 메시지 표시
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('${subscription.serviceName} 구독이 추가되었습니다.'),
|
||||
backgroundColor: Colors.green,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 다음 구독으로 이동
|
||||
_moveToNextSubscription();
|
||||
} catch (e) {
|
||||
print('구독 추가 중 오류 발생: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('구독 추가 중 오류가 발생했습니다: $e'),
|
||||
backgroundColor: Colors.red,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
|
||||
// 오류가 있어도 다음 구독으로 이동
|
||||
_moveToNextSubscription();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 현재 구독 건너뛰기
|
||||
void _skipCurrentSubscription() {
|
||||
_moveToNextSubscription();
|
||||
}
|
||||
|
||||
// 다음 구독으로 이동
|
||||
void _moveToNextSubscription() {
|
||||
setState(() {
|
||||
_currentIndex++;
|
||||
_websiteUrlController.text = ''; // URL 입력 필드 초기화
|
||||
|
||||
// 모든 구독을 처리했으면 화면 종료
|
||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 날짜 상태 텍스트 가져오기
|
||||
String _getNextBillingText(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
|
||||
if (date.isBefore(now)) {
|
||||
// 주기에 따라 다음 결제일 예측
|
||||
if (_currentIndex >= _scannedSubscriptions.length ||
|
||||
_scannedSubscriptions[_currentIndex] == null) {
|
||||
return '다음 결제일 확인 필요';
|
||||
}
|
||||
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
if (subscription.billingCycle == '월간') {
|
||||
// 이번 달 또는 다음 달 같은 날짜
|
||||
int day = date.day;
|
||||
// 현재 월의 마지막 날을 초과하는 경우 조정
|
||||
final lastDay = DateTime(now.year, now.month + 1, 0).day;
|
||||
if (day > lastDay) {
|
||||
day = lastDay;
|
||||
}
|
||||
|
||||
DateTime adjusted = DateTime(now.year, now.month, day);
|
||||
if (adjusted.isBefore(now)) {
|
||||
// 다음 달의 마지막 날을 초과하는 경우 조정
|
||||
final nextMonthLastDay = DateTime(now.year, now.month + 2, 0).day;
|
||||
if (day > nextMonthLastDay) {
|
||||
day = nextMonthLastDay;
|
||||
}
|
||||
adjusted = DateTime(now.year, now.month + 1, day);
|
||||
}
|
||||
|
||||
final daysUntil = adjusted.difference(now).inDays;
|
||||
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
|
||||
} else if (subscription.billingCycle == '연간') {
|
||||
// 올해 또는 내년 같은 날짜
|
||||
int day = date.day;
|
||||
// 해당 월의 마지막 날을 초과하는 경우 조정
|
||||
final lastDay = DateTime(now.year, date.month + 1, 0).day;
|
||||
if (day > lastDay) {
|
||||
day = lastDay;
|
||||
}
|
||||
|
||||
DateTime adjusted = DateTime(now.year, date.month, day);
|
||||
if (adjusted.isBefore(now)) {
|
||||
// 다음 해 해당 월의 마지막 날을 초과하는 경우 조정
|
||||
final nextYearLastDay = DateTime(now.year + 1, date.month + 1, 0).day;
|
||||
if (day > nextYearLastDay) {
|
||||
day = nextYearLastDay;
|
||||
}
|
||||
adjusted = DateTime(now.year + 1, date.month, day);
|
||||
}
|
||||
|
||||
final daysUntil = adjusted.difference(now).inDays;
|
||||
return '다음 예상 결제일: ${_formatDate(adjusted)} ($daysUntil일 후)';
|
||||
} else {
|
||||
return '다음 결제일 확인 필요 (과거 날짜)';
|
||||
}
|
||||
} else {
|
||||
// 미래 날짜인 경우
|
||||
final daysUntil = date.difference(now).inDays;
|
||||
return '다음 결제일: ${_formatDate(date)} ($daysUntil일 후)';
|
||||
}
|
||||
}
|
||||
|
||||
// 날짜 포맷 함수
|
||||
String _formatDate(DateTime date) {
|
||||
return '${date.year}년 ${date.month}월 ${date.day}일';
|
||||
}
|
||||
|
||||
// 결제 반복 횟수 텍스트
|
||||
String _getRepeatCountText(int count) {
|
||||
return '$count회 결제 감지됨';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('SMS 스캔'),
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: _isLoading
|
||||
? _buildLoadingState()
|
||||
: (_scannedSubscriptions.isEmpty
|
||||
? _buildInitialState()
|
||||
: _buildSubscriptionState())),
|
||||
);
|
||||
}
|
||||
|
||||
// 로딩 상태 UI
|
||||
Widget _buildLoadingState() {
|
||||
return const Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
CircularProgressIndicator(),
|
||||
SizedBox(height: 16),
|
||||
Text('SMS 메시지를 스캔 중입니다...'),
|
||||
SizedBox(height: 8),
|
||||
Text('구독 서비스를 찾고 있습니다', style: TextStyle(color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 초기 상태 UI
|
||||
Widget _buildInitialState() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (_errorMessage != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Text(
|
||||
_errorMessage!,
|
||||
style: const TextStyle(color: Colors.red),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text(
|
||||
'2회 이상 결제된 구독 서비스 찾기',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32.0),
|
||||
child: Text(
|
||||
'문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다. 서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.grey),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ElevatedButton.icon(
|
||||
onPressed: _scanSms,
|
||||
icon: const Icon(Icons.search),
|
||||
label: const Text('스캔 시작하기'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 구독 표시 상태 UI
|
||||
Widget _buildSubscriptionState() {
|
||||
if (_currentIndex >= _scannedSubscriptions.length) {
|
||||
return const Center(
|
||||
child: Text('모든 구독 처리 완료'),
|
||||
);
|
||||
}
|
||||
|
||||
final subscription = _scannedSubscriptions[_currentIndex];
|
||||
if (subscription == null) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text('오류: 구독 정보를 불러올 수 없습니다.'),
|
||||
SizedBox(height: 16),
|
||||
ElevatedButton(
|
||||
onPressed: _moveToNextSubscription,
|
||||
child: Text('건너뛰기'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 구독 리스트 카드를 표시할 때 URL 필드 자동 설정
|
||||
if (_websiteUrlController.text.isEmpty && subscription.websiteUrl != null) {
|
||||
_websiteUrlController.text = subscription.websiteUrl!;
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 진행 상태 표시
|
||||
LinearProgressIndicator(
|
||||
value: (_currentIndex + 1) / _scannedSubscriptions.length,
|
||||
backgroundColor: Colors.grey.withOpacity(0.2),
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Theme.of(context).colorScheme.primary),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'${_currentIndex + 1}/${_scannedSubscriptions.length}',
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 구독 정보 카드
|
||||
Card(
|
||||
elevation: 4,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'다음 구독을 찾았습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 서비스명
|
||||
const Text(
|
||||
'서비스명',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 금액 및 반복 횟수
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'월 비용',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(subscription.monthlyCost)
|
||||
: NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(subscription.monthlyCost),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'반복 횟수',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getRepeatCountText(subscription.repeatCount),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 결제 주기
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'결제 주기',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
subscription.billingCycle,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'결제일',
|
||||
style: TextStyle(
|
||||
color: Colors.grey,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_getNextBillingText(subscription.nextBillingDate),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 웹사이트 URL 입력 필드 추가/수정
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: TextField(
|
||||
controller: _websiteUrlController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '웹사이트 URL (자동 추출됨)',
|
||||
hintText: '웹사이트 URL을 수정하거나 비워두세요',
|
||||
prefixIcon: Icon(Icons.language),
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 작업 버튼
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _skipCurrentSubscription,
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('건너뛰기'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: _addCurrentSubscription,
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('추가하기'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_scannedSubscriptions.isNotEmpty &&
|
||||
_currentIndex < _scannedSubscriptions.length &&
|
||||
_scannedSubscriptions[_currentIndex] != null) {
|
||||
final currentSub = _scannedSubscriptions[_currentIndex];
|
||||
if (_websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
|
||||
_websiteUrlController.text = currentSub.websiteUrl!;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
371
lib/screens/splash_screen.dart
Normal file
371
lib/screens/splash_screen.dart
Normal file
@@ -0,0 +1,371 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/app_lock_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'app_lock_screen.dart';
|
||||
import 'main_screen.dart';
|
||||
|
||||
class SplashScreen extends StatefulWidget {
|
||||
const SplashScreen({super.key});
|
||||
|
||||
@override
|
||||
State<SplashScreen> createState() => _SplashScreenState();
|
||||
}
|
||||
|
||||
class _SplashScreenState extends State<SplashScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _fadeAnimation;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _slideAnimation;
|
||||
late Animation<double> _rotateAnimation;
|
||||
|
||||
// 파티클 애니메이션을 위한 변수
|
||||
final List<Map<String, dynamic>> _particles = [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 애니메이션 컨트롤러 초기화
|
||||
_animationController = AnimationController(
|
||||
duration: const Duration(milliseconds: 2500),
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_fadeAnimation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.7, curve: Curves.easeInOut),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: 0.6,
|
||||
end: 1.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.elasticOut,
|
||||
));
|
||||
|
||||
_slideAnimation = Tween<double>(
|
||||
begin: 50.0,
|
||||
end: 0.0,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.3, 0.8, curve: Curves.easeOutCubic),
|
||||
));
|
||||
|
||||
_rotateAnimation = Tween<double>(
|
||||
begin: 0.0,
|
||||
end: 0.1,
|
||||
).animate(CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: const Interval(0.0, 0.5, curve: Curves.easeOutBack),
|
||||
));
|
||||
|
||||
// 랜덤 파티클 생성
|
||||
_generateParticles();
|
||||
|
||||
_animationController.forward();
|
||||
|
||||
// 애니메이션 완료 후 메인화면으로 이동
|
||||
Timer(const Duration(seconds: 2), () {
|
||||
navigateToNextScreen();
|
||||
});
|
||||
}
|
||||
|
||||
void _generateParticles() {
|
||||
final random = DateTime.now().millisecondsSinceEpoch;
|
||||
|
||||
for (int i = 0; i < 20; i++) {
|
||||
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
|
||||
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
|
||||
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
|
||||
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
|
||||
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간
|
||||
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
||||
|
||||
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
||||
|
||||
_particles.add({
|
||||
'size': size,
|
||||
'x': x,
|
||||
'y': y,
|
||||
'opacity': opacity,
|
||||
'duration': duration,
|
||||
'delay': delay,
|
||||
'color': AppColors.blueGradient[colorIndex],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void navigateToNextScreen() {
|
||||
// 앱 잠금 기능 비활성화: 항상 MainScreen으로 이동
|
||||
Navigator.of(context).pushReplacement(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
const MainScreen(),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
transitionDuration: const Duration(milliseconds: 500),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = MediaQuery.of(context).size;
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: AppColors.blueGradient,
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 배경 파티클
|
||||
..._particles.map((particle) {
|
||||
return AnimatedPositioned(
|
||||
duration: Duration(milliseconds: particle['duration'].toInt()),
|
||||
curve: Curves.easeInOut,
|
||||
left: particle['x'] - 50 + (size.width * 0.1),
|
||||
top: particle['y'] - 50 + (size.height * 0.1),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween<double>(begin: 0.0, end: particle['opacity']),
|
||||
duration:
|
||||
Duration(milliseconds: particle['duration'].toInt()),
|
||||
builder: (context, value, child) {
|
||||
return Opacity(
|
||||
opacity: value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: Container(
|
||||
width: particle['size'],
|
||||
height: particle['size'],
|
||||
decoration: BoxDecoration(
|
||||
color: particle['color'],
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: particle['color'].withOpacity(0.3),
|
||||
blurRadius: 10,
|
||||
spreadRadius: 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
|
||||
// 상단 원형 그라데이션
|
||||
Positioned(
|
||||
top: -size.height * 0.2,
|
||||
right: -size.width * 0.2,
|
||||
child: Container(
|
||||
width: size.width * 0.8,
|
||||
height: size.width * 0.8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.1),
|
||||
Colors.white.withOpacity(0.0),
|
||||
],
|
||||
stops: const [0.2, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 하단 원형 그라데이션
|
||||
Positioned(
|
||||
bottom: -size.height * 0.1,
|
||||
left: -size.width * 0.3,
|
||||
child: Container(
|
||||
width: size.width * 0.9,
|
||||
height: size.width * 0.9,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.07),
|
||||
Colors.white.withOpacity(0.0),
|
||||
],
|
||||
stops: const [0.4, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 메인 콘텐츠
|
||||
Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// 로고 애니메이션
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: Transform.rotate(
|
||||
angle: _rotateAnimation.value,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(
|
||||
child: AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, _) {
|
||||
return ShaderMask(
|
||||
blendMode: BlendMode.srcIn,
|
||||
shaderCallback: (bounds) =>
|
||||
LinearGradient(
|
||||
colors: AppColors.blueGradient,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(bounds),
|
||||
child: Icon(
|
||||
Icons.subscriptions_outlined,
|
||||
size: 64,
|
||||
color: Theme.of(context)
|
||||
.primaryColor,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 앱 이름 텍스트
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _slideAnimation.value),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'SubManager',
|
||||
style: TextStyle(
|
||||
fontSize: 36,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
letterSpacing: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 부제목 텍스트
|
||||
AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: _fadeAnimation.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, _slideAnimation.value * 1.2),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text(
|
||||
'구독 서비스 관리를 더 쉽게',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white70,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 60),
|
||||
|
||||
// 로딩 인디케이터
|
||||
FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
valueColor:
|
||||
AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
strokeWidth: 3,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 카피라이트 텍스트
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24.0),
|
||||
child: FadeTransition(
|
||||
opacity: _fadeAnimation,
|
||||
child: const Text(
|
||||
'© 2023 CClabs. All rights reserved.',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.white60,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
134
lib/services/currency_util.dart
Normal file
134
lib/services/currency_util.dart
Normal file
@@ -0,0 +1,134 @@
|
||||
import 'package:intl/intl.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'exchange_rate_service.dart';
|
||||
|
||||
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
||||
class CurrencyUtil {
|
||||
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||
|
||||
/// 구독 목록의 총 월 비용을 계산 (원화로 환산, 이벤트 가격 반영)
|
||||
static Future<double> calculateTotalMonthlyExpense(
|
||||
List<SubscriptionModel> subscriptions) async {
|
||||
double total = 0.0;
|
||||
|
||||
for (var subscription in subscriptions) {
|
||||
// 이벤트 가격이 있으면 currentPrice 사용
|
||||
final price = subscription.currentPrice;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD인 경우 KRW로 변환
|
||||
final krwAmount = await _exchangeRateService
|
||||
.convertUsdToKrw(price);
|
||||
if (krwAmount != null) {
|
||||
total += krwAmount;
|
||||
}
|
||||
} else {
|
||||
// KRW인 경우 그대로 합산
|
||||
total += price;
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영)
|
||||
static Future<String> formatSubscriptionAmount(
|
||||
SubscriptionModel subscription) async {
|
||||
// 이벤트 가격이 있으면 currentPrice 사용
|
||||
final price = subscription.currentPrice;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD 표시 + 원화 환산 금액
|
||||
final usdFormatted = NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(price);
|
||||
|
||||
// 원화 환산 금액
|
||||
final krwAmount = await _exchangeRateService
|
||||
.getFormattedKrwAmount(price);
|
||||
|
||||
return '$usdFormatted $krwAmount';
|
||||
} else {
|
||||
// 원화 표시
|
||||
return NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(price);
|
||||
}
|
||||
}
|
||||
|
||||
/// 총액을 원화로 표시
|
||||
static String formatTotalAmount(double amount) {
|
||||
return NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(amount);
|
||||
}
|
||||
|
||||
/// 환율 정보 텍스트 가져오기
|
||||
static Future<String> getExchangeRateInfo() {
|
||||
return _exchangeRateService.getFormattedExchangeRateInfo();
|
||||
}
|
||||
|
||||
/// 이벤트로 인한 총 절약액 계산 (원화로 환산)
|
||||
static Future<double> calculateTotalEventSavings(
|
||||
List<SubscriptionModel> subscriptions) async {
|
||||
double total = 0.0;
|
||||
|
||||
for (var subscription in subscriptions) {
|
||||
if (subscription.isCurrentlyInEvent) {
|
||||
final savings = subscription.eventSavings;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD인 경우 KRW로 변환
|
||||
final krwAmount = await _exchangeRateService
|
||||
.convertUsdToKrw(savings);
|
||||
if (krwAmount != null) {
|
||||
total += krwAmount;
|
||||
}
|
||||
} else {
|
||||
// KRW인 경우 그대로 합산
|
||||
total += savings;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return total;
|
||||
}
|
||||
|
||||
/// 이벤트 절약액을 표시 형식에 맞게 변환
|
||||
static Future<String> formatEventSavings(
|
||||
SubscriptionModel subscription) async {
|
||||
if (!subscription.isCurrentlyInEvent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
final savings = subscription.eventSavings;
|
||||
|
||||
if (subscription.currency == 'USD') {
|
||||
// USD 표시 + 원화 환산 금액
|
||||
final usdFormatted = NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(savings);
|
||||
|
||||
// 원화 환산 금액
|
||||
final krwAmount = await _exchangeRateService
|
||||
.getFormattedKrwAmount(savings);
|
||||
|
||||
return '$usdFormatted $krwAmount';
|
||||
} else {
|
||||
// 원화 표시
|
||||
return NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(savings);
|
||||
}
|
||||
}
|
||||
}
|
||||
92
lib/services/exchange_rate_service.dart
Normal file
92
lib/services/exchange_rate_service.dart
Normal file
@@ -0,0 +1,92 @@
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 환율 정보 서비스 클래스
|
||||
class ExchangeRateService {
|
||||
// 싱글톤 인스턴스
|
||||
static final ExchangeRateService _instance = ExchangeRateService._internal();
|
||||
|
||||
// 팩토리 생성자
|
||||
factory ExchangeRateService() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
// 내부 생성자
|
||||
ExchangeRateService._internal();
|
||||
|
||||
// 캐싱된 환율 정보
|
||||
double? _usdToKrwRate;
|
||||
DateTime? _lastUpdated;
|
||||
|
||||
// API 요청 URL (ExchangeRate-API 사용)
|
||||
final String _apiUrl = 'https://api.exchangerate-api.com/v4/latest/USD';
|
||||
|
||||
/// 현재 USD to KRW 환율 정보를 가져옵니다.
|
||||
/// 최근 6시간 이내 조회했던 정보가 있다면 캐싱된 정보를 반환합니다.
|
||||
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;
|
||||
}
|
||||
|
||||
/// 현재 환율 정보를 포맷팅하여 텍스트로 반환합니다.
|
||||
Future<String> getFormattedExchangeRateInfo() async {
|
||||
final rate = await getUsdToKrwRate();
|
||||
if (rate != null) {
|
||||
final formattedRate = NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(rate);
|
||||
return '오늘 기준 환율 : $formattedRate';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||
Future<String> getFormattedKrwAmount(double usdAmount) async {
|
||||
final krwAmount = await convertUsdToKrw(usdAmount);
|
||||
if (krwAmount != null) {
|
||||
final formattedAmount = NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(krwAmount);
|
||||
return '($formattedAmount)';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
}
|
||||
548
lib/services/notification_service.dart
Normal file
548
lib/services/notification_service.dart
Normal file
@@ -0,0 +1,548 @@
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/data/latest_all.dart' as tz;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
|
||||
class NotificationService {
|
||||
static final FlutterLocalNotificationsPlugin _notifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
static final _secureStorage = const FlutterSecureStorage();
|
||||
|
||||
static const _notificationEnabledKey = 'notification_enabled';
|
||||
static const _paymentNotificationEnabledKey = 'payment_notification_enabled';
|
||||
static const _reminderDaysKey = 'reminder_days';
|
||||
static const _reminderHourKey = 'reminder_hour';
|
||||
static const _reminderMinuteKey = 'reminder_minute';
|
||||
static const _dailyReminderKey = 'daily_reminder_enabled';
|
||||
|
||||
// 초기화 상태를 추적하기 위한 플래그
|
||||
static bool _initialized = false;
|
||||
|
||||
// 웹 플랫폼 여부 확인 (웹에서는 flutter_local_notifications가 지원되지 않음)
|
||||
static bool get _isWeb => kIsWeb;
|
||||
|
||||
static const AndroidNotificationChannel channel = AndroidNotificationChannel(
|
||||
'subscription_channel',
|
||||
'Subscription Notifications',
|
||||
description: 'Channel for subscription reminders',
|
||||
importance: Importance.high,
|
||||
enableVibration: true,
|
||||
);
|
||||
|
||||
// 알림 초기화
|
||||
static Future<void> init() async {
|
||||
try {
|
||||
// 웹 플랫폼인 경우 초기화 건너뛰기
|
||||
if (_isWeb) {
|
||||
debugPrint('웹 플랫폼에서는 로컬 알림이 지원되지 않습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
tz.initializeTimeZones();
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
} catch (e) {
|
||||
// 타임존 찾기 실패시 UTC 사용
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
debugPrint('타임존 설정 실패, UTC 사용: $e');
|
||||
}
|
||||
const androidSettings =
|
||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
||||
const iosSettings = DarwinInitializationSettings();
|
||||
const initSettings =
|
||||
InitializationSettings(android: androidSettings, iOS: iosSettings);
|
||||
|
||||
await _notifications.initialize(initSettings);
|
||||
_initialized = true;
|
||||
debugPrint('알림 서비스 초기화 완료');
|
||||
} catch (e) {
|
||||
_initialized = false;
|
||||
debugPrint('알림 서비스 초기화 실패: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<bool> isNotificationEnabled() async {
|
||||
final value = await _secureStorage.read(key: _notificationEnabledKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
static Future<bool> isPaymentNotificationEnabled() async {
|
||||
final value =
|
||||
await _secureStorage.read(key: _paymentNotificationEnabledKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
static Future<void> setNotificationEnabled(bool value) async {
|
||||
await _secureStorage.write(
|
||||
key: _notificationEnabledKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
static Future<void> setPaymentNotificationEnabled(bool value) async {
|
||||
await _secureStorage.write(
|
||||
key: _paymentNotificationEnabledKey,
|
||||
value: value.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
// 알림 시점 설정 (1일전, 2일전, 3일전) 가져오기
|
||||
static Future<int> getReminderDays() async {
|
||||
final value = await _secureStorage.read(key: _reminderDaysKey);
|
||||
return value != null ? int.tryParse(value) ?? 3 : 3;
|
||||
}
|
||||
|
||||
// 알림 시간 가져오기
|
||||
static Future<int> getReminderHour() async {
|
||||
final value = await _secureStorage.read(key: _reminderHourKey);
|
||||
return value != null ? int.tryParse(value) ?? 10 : 10;
|
||||
}
|
||||
|
||||
static Future<int> getReminderMinute() async {
|
||||
final value = await _secureStorage.read(key: _reminderMinuteKey);
|
||||
return value != null ? int.tryParse(value) ?? 0 : 0;
|
||||
}
|
||||
|
||||
// 반복 알림 설정 가져오기
|
||||
static Future<bool> isDailyReminderEnabled() async {
|
||||
final value = await _secureStorage.read(key: _dailyReminderKey);
|
||||
return value == 'true';
|
||||
}
|
||||
|
||||
// 모든 구독의 알림 일정 다시 예약
|
||||
static Future<void> reschedulAllNotifications(
|
||||
List<SubscriptionModel> subscriptions) async {
|
||||
try {
|
||||
// 웹 플랫폼이거나 알림 초기화 실패한 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 기존 알림 모두 취소
|
||||
await cancelAllNotifications();
|
||||
|
||||
// 알림 설정 가져오기
|
||||
final isPaymentEnabled = await isPaymentNotificationEnabled();
|
||||
if (!isPaymentEnabled) return;
|
||||
|
||||
final reminderDays = await getReminderDays();
|
||||
final reminderHour = await getReminderHour();
|
||||
final reminderMinute = await getReminderMinute();
|
||||
final isDailyReminder = await isDailyReminderEnabled();
|
||||
|
||||
// 각 구독에 대해 알림 재설정
|
||||
for (final subscription in subscriptions) {
|
||||
await schedulePaymentReminder(
|
||||
id: subscription.id.hashCode,
|
||||
serviceName: subscription.serviceName,
|
||||
amount: subscription.monthlyCost,
|
||||
billingDate: subscription.nextBillingDate,
|
||||
reminderDays: reminderDays,
|
||||
reminderHour: reminderHour,
|
||||
reminderMinute: reminderMinute,
|
||||
isDailyReminder: isDailyReminder,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('알림 일정 재설정 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 서비스가 초기화되었는지 확인하는 메서드
|
||||
static bool _isInitialized() {
|
||||
// 웹 플랫폼인 경우 항상 false 반환
|
||||
if (_isWeb) return false;
|
||||
// 초기화 플래그 확인
|
||||
return _initialized;
|
||||
}
|
||||
|
||||
static Future<bool> requestPermission() async {
|
||||
final result = await _notifications
|
||||
.resolvePlatformSpecificImplementation<
|
||||
AndroidFlutterLocalNotificationsPlugin>()
|
||||
?.requestPermission();
|
||||
return result ?? false;
|
||||
}
|
||||
|
||||
// 알림 스케줄 설정
|
||||
static Future<void> scheduleNotification({
|
||||
required int id,
|
||||
required String title,
|
||||
required String body,
|
||||
required DateTime scheduledDate,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 관련 알림을 보여줍니다.',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
final iosDetails = const DarwinNotificationDetails();
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id,
|
||||
title,
|
||||
body,
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
NotificationDetails(android: androidDetails, iOS: iosDetails),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 알림 취소
|
||||
static Future<void> cancelNotification(int id) async {
|
||||
await _notifications.cancel(id);
|
||||
}
|
||||
|
||||
// 모든 알림 취소
|
||||
static Future<void> cancelAllNotifications() async {
|
||||
try {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 취소할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
await _notifications.cancelAll();
|
||||
debugPrint('모든 알림이 취소되었습니다.');
|
||||
} catch (e) {
|
||||
debugPrint('알림 취소 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> scheduleSubscriptionNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final notificationId = subscription.id.hashCode;
|
||||
|
||||
final androidDetails = AndroidNotificationDetails(
|
||||
'subscription_channel',
|
||||
'구독 알림',
|
||||
channelDescription: '구독 만료 알림을 보내는 채널입니다.',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
);
|
||||
|
||||
final iosDetails = DarwinNotificationDetails(
|
||||
presentAlert: true,
|
||||
presentBadge: true,
|
||||
presentSound: true,
|
||||
);
|
||||
|
||||
final notificationDetails = NotificationDetails(
|
||||
android: androidDetails,
|
||||
iOS: iosDetails,
|
||||
);
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
notificationId,
|
||||
'구독 만료 알림',
|
||||
'${subscription.serviceName} 구독이 ${subscription.nextBillingDate.day}일 만료됩니다.',
|
||||
tz.TZDateTime.from(subscription.nextBillingDate, location),
|
||||
notificationDetails,
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('구독 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> updateSubscriptionNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
await cancelSubscriptionNotification(subscription);
|
||||
await scheduleSubscriptionNotification(subscription);
|
||||
}
|
||||
|
||||
static Future<void> cancelSubscriptionNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
final notificationId = subscription.id.hashCode;
|
||||
await _notifications.cancel(notificationId);
|
||||
}
|
||||
|
||||
static Future<void> schedulePaymentNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final paymentDate = subscription.nextBillingDate;
|
||||
final reminderDate = paymentDate.subtract(const Duration(days: 3));
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
subscription.id.hashCode,
|
||||
'구독 결제 예정 알림',
|
||||
'${subscription.serviceName} 결제가 3일 후 예정되어 있습니다.',
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'payment_channel',
|
||||
'Payment Notifications',
|
||||
channelDescription: 'Channel for subscription payment reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> scheduleExpirationNotification(
|
||||
SubscriptionModel subscription) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final expirationDate = subscription.nextBillingDate;
|
||||
final reminderDate = expirationDate.subtract(const Duration(days: 7));
|
||||
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
(subscription.id + '_expiration').hashCode,
|
||||
'구독 만료 예정 알림',
|
||||
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
|
||||
tz.TZDateTime.from(reminderDate, location),
|
||||
const NotificationDetails(
|
||||
android: AndroidNotificationDetails(
|
||||
'expiration_channel',
|
||||
'Expiration Notifications',
|
||||
channelDescription: 'Channel for subscription expiration reminders',
|
||||
importance: Importance.high,
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
androidAllowWhileIdle: true,
|
||||
uiLocalNotificationDateInterpretation:
|
||||
UILocalNotificationDateInterpretation.absoluteTime,
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('만료 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> schedulePaymentReminder({
|
||||
required int id,
|
||||
required String serviceName,
|
||||
required double amount,
|
||||
required DateTime billingDate,
|
||||
int reminderDays = 3,
|
||||
int reminderHour = 10,
|
||||
int reminderMinute = 0,
|
||||
bool isDailyReminder = false,
|
||||
}) async {
|
||||
// 웹 플랫폼이거나 초기화되지 않은 경우 건너뛰기
|
||||
if (_isWeb || !_initialized) {
|
||||
debugPrint('웹 플랫폼이거나 알림 서비스가 초기화되지 않아 알림을 예약할 수 없습니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// tz.local 초기화 확인 및 재시도
|
||||
tz.Location location;
|
||||
try {
|
||||
location = tz.local;
|
||||
} catch (e) {
|
||||
// tz.local이 초기화되지 않은 경우 재시도
|
||||
debugPrint('tz.local 초기화되지 않음, 재시도 중...');
|
||||
try {
|
||||
tz.setLocalLocation(tz.getLocation('Asia/Seoul'));
|
||||
location = tz.local;
|
||||
} catch (_) {
|
||||
// 그래도 실패하면 UTC 사용
|
||||
debugPrint('타임존 설정 실패, UTC 사용');
|
||||
tz.setLocalLocation(tz.UTC);
|
||||
location = tz.UTC;
|
||||
}
|
||||
}
|
||||
|
||||
// 기본 알림 예약 (지정된 일수 전)
|
||||
final scheduledDate =
|
||||
billingDate.subtract(Duration(days: reminderDays)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String daysText = '$reminderDays일 후';
|
||||
if (reminderDays == 1) {
|
||||
daysText = '내일';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id,
|
||||
'구독 결제 예정 알림',
|
||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $daysText 결제 예정입니다.',
|
||||
tz.TZDateTime.from(scheduledDate, location),
|
||||
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,
|
||||
);
|
||||
|
||||
// 매일 반복 알림 설정 (2일 이상 전에 알림 시작 & 반복 알림 활성화된 경우)
|
||||
if (isDailyReminder && reminderDays >= 2) {
|
||||
// 첫 번째 알림 다음 날부터 결제일 전날까지 매일 알림 예약
|
||||
for (int i = reminderDays - 1; i >= 1; i--) {
|
||||
final dailyDate = billingDate.subtract(Duration(days: i)).copyWith(
|
||||
hour: reminderHour,
|
||||
minute: reminderMinute,
|
||||
second: 0,
|
||||
millisecond: 0,
|
||||
microsecond: 0,
|
||||
);
|
||||
|
||||
// 남은 일수에 따른 메시지 생성
|
||||
String remainingDaysText = '$i일 후';
|
||||
if (i == 1) {
|
||||
remainingDaysText = '내일';
|
||||
}
|
||||
|
||||
await _notifications.zonedSchedule(
|
||||
id + i, // 고유한 ID 생성을 위해 날짜 차이 더함
|
||||
'구독 결제 예정 알림',
|
||||
'$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 $remainingDaysText 결제 예정입니다.',
|
||||
tz.TZDateTime.from(dailyDate, location),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('결제 알림 예약 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
static String getNotificationBody(String serviceName, double amount) {
|
||||
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
|
||||
}
|
||||
}
|
||||
318
lib/services/sms_scanner.dart
Normal file
318
lib/services/sms_scanner.dart
Normal file
@@ -0,0 +1,318 @@
|
||||
import 'package:flutter/services.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../temp/test_sms_data.dart';
|
||||
import 'package:flutter/foundation.dart' show kDebugMode;
|
||||
import '../services/subscription_url_matcher.dart';
|
||||
|
||||
class SmsScanner {
|
||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||
try {
|
||||
List<dynamic> smsList;
|
||||
print('SmsScanner: 스캔 시작');
|
||||
|
||||
// 디버그 모드에서는 테스트 데이터 사용
|
||||
if (kDebugMode) {
|
||||
print('SmsScanner: 디버그 모드에서 테스트 데이터 사용');
|
||||
smsList = TestSmsData.getTestData();
|
||||
print('SmsScanner: 테스트 데이터 개수: ${smsList.length}');
|
||||
} else {
|
||||
print('SmsScanner: 실제 SMS 데이터 스캔');
|
||||
// 실제 환경에서는 네이티브 코드 호출
|
||||
const platform = MethodChannel('com.submanager/sms');
|
||||
try {
|
||||
smsList = await platform.invokeMethod('scanSubscriptions');
|
||||
print('SmsScanner: 네이티브 호출 성공, SMS 데이터 개수: ${smsList.length}');
|
||||
} catch (e) {
|
||||
print('SmsScanner: 네이티브 호출 실패: $e');
|
||||
// 오류 발생 시 빈 목록 반환
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
|
||||
final List<SubscriptionModel> subscriptions = [];
|
||||
final Map<String, List<Map<String, dynamic>>> serviceGroups = {};
|
||||
|
||||
// 서비스명별로 SMS 메시지 그룹화
|
||||
for (final sms in smsList) {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
if (!serviceGroups.containsKey(serviceName)) {
|
||||
serviceGroups[serviceName] = [];
|
||||
}
|
||||
serviceGroups[serviceName]!.add(sms);
|
||||
}
|
||||
|
||||
print('SmsScanner: 그룹화된 서비스 수: ${serviceGroups.length}');
|
||||
|
||||
// 그룹화된 데이터로 구독 분석
|
||||
for (final entry in serviceGroups.entries) {
|
||||
print('SmsScanner: 서비스 "${entry.key}" - 메시지 개수: ${entry.value.length}');
|
||||
|
||||
// 2회 이상 반복된 서비스만 구독으로 간주
|
||||
if (entry.value.length >= 2) {
|
||||
final serviceSms = entry.value[0]; // 가장 최근 SMS 사용
|
||||
final subscription = _parseSms(serviceSms, entry.value.length);
|
||||
if (subscription != null) {
|
||||
print(
|
||||
'SmsScanner: 구독 추가: ${subscription.serviceName}, 반복 횟수: ${subscription.repeatCount}');
|
||||
subscriptions.add(subscription);
|
||||
} else {
|
||||
print('SmsScanner: 구독 파싱 실패: ${entry.key}');
|
||||
}
|
||||
} else {
|
||||
print('SmsScanner: 반복 횟수 부족, 구독으로 간주하지 않음: ${entry.key}');
|
||||
}
|
||||
}
|
||||
|
||||
print('SmsScanner: 최종 구독 개수: ${subscriptions.length}');
|
||||
return subscriptions;
|
||||
} catch (e) {
|
||||
print('SmsScanner: 예외 발생: $e');
|
||||
throw Exception('SMS 스캔 중 오류 발생: $e');
|
||||
}
|
||||
}
|
||||
|
||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||
try {
|
||||
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
|
||||
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
|
||||
final billingCycle = sms['billingCycle'] as String? ?? '월간';
|
||||
final nextBillingDateStr = sms['nextBillingDate'] as String?;
|
||||
// 실제 반복 횟수 사용 (테스트 데이터에서는 이미 제공됨)
|
||||
final actualRepeatCount = repeatCount > 0 ? repeatCount : 1;
|
||||
final isRecurring = (sms['isRecurring'] as bool?) ?? (repeatCount >= 2);
|
||||
final message = sms['message'] as String? ?? '';
|
||||
|
||||
// 통화 단위 감지 - 메시지 내용과 서비스명 모두 검사
|
||||
String currency = _detectCurrency(message);
|
||||
|
||||
// 서비스명에 따라 통화 단위 재확인
|
||||
final dollarServices = [
|
||||
'GitHub',
|
||||
'GitHub Pro',
|
||||
'Netflix US',
|
||||
'Spotify',
|
||||
'Spotify Premium'
|
||||
];
|
||||
if (dollarServices.any((service) => serviceName.contains(service))) {
|
||||
print('서비스명 $serviceName으로 USD 통화 단위 확정');
|
||||
currency = 'USD';
|
||||
}
|
||||
|
||||
DateTime? nextBillingDate;
|
||||
if (nextBillingDateStr != null) {
|
||||
nextBillingDate = DateTime.tryParse(nextBillingDateStr);
|
||||
}
|
||||
|
||||
DateTime? lastPaymentDate;
|
||||
final previousPaymentDateStr = sms['previousPaymentDate'] as String?;
|
||||
if (previousPaymentDateStr != null && previousPaymentDateStr.isNotEmpty) {
|
||||
lastPaymentDate = DateTime.tryParse(previousPaymentDateStr);
|
||||
}
|
||||
|
||||
// 결제일 계산 로직 추가 - 미래 날짜가 아니면 조정
|
||||
DateTime adjustedNextBillingDate = _calculateNextBillingDate(
|
||||
nextBillingDate ?? DateTime.now().add(const Duration(days: 30)),
|
||||
billingCycle);
|
||||
|
||||
return SubscriptionModel(
|
||||
id: DateTime.now().millisecondsSinceEpoch.toString(),
|
||||
serviceName: serviceName,
|
||||
monthlyCost: monthlyCost,
|
||||
billingCycle: billingCycle,
|
||||
nextBillingDate: adjustedNextBillingDate,
|
||||
isAutoDetected: true,
|
||||
repeatCount: actualRepeatCount,
|
||||
lastPaymentDate: lastPaymentDate,
|
||||
websiteUrl: _extractWebsiteUrl(serviceName),
|
||||
currency: currency, // 통화 단위 설정
|
||||
);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 다음 결제일 계산 (현재 날짜 기준으로 조정)
|
||||
DateTime _calculateNextBillingDate(
|
||||
DateTime billingDate, String billingCycle) {
|
||||
final now = DateTime.now();
|
||||
|
||||
// 결제일이 이미 미래인 경우 그대로 반환
|
||||
if (billingDate.isAfter(now)) {
|
||||
return billingDate;
|
||||
}
|
||||
|
||||
// 결제 주기별 다음 결제일 계산
|
||||
if (billingCycle == '월간') {
|
||||
int month = now.month;
|
||||
int year = now.year;
|
||||
|
||||
// 현재 달의 결제일이 이미 지났으면 다음 달로 이동
|
||||
if (now.day >= billingDate.day) {
|
||||
month = month + 1;
|
||||
if (month > 12) {
|
||||
month = 1;
|
||||
year = year + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return DateTime(year, month, billingDate.day);
|
||||
} else if (billingCycle == '연간') {
|
||||
// 올해의 결제일이 지났는지 확인
|
||||
final thisYearBilling =
|
||||
DateTime(now.year, billingDate.month, billingDate.day);
|
||||
if (thisYearBilling.isBefore(now)) {
|
||||
return DateTime(now.year + 1, billingDate.month, billingDate.day);
|
||||
} else {
|
||||
return thisYearBilling;
|
||||
}
|
||||
} else if (billingCycle == '주간') {
|
||||
// 가장 가까운 다음 주 같은 요일 계산
|
||||
final dayDifference = billingDate.weekday - now.weekday;
|
||||
final daysToAdd = dayDifference > 0 ? dayDifference : 7 + dayDifference;
|
||||
return now.add(Duration(days: daysToAdd));
|
||||
}
|
||||
|
||||
// 기본 처리: 30일 후
|
||||
return now.add(const Duration(days: 30));
|
||||
}
|
||||
|
||||
String? _extractWebsiteUrl(String serviceName) {
|
||||
// SubscriptionUrlMatcher 서비스를 사용하여 URL 매칭 시도
|
||||
final suggestedUrl = SubscriptionUrlMatcher.findMatchingUrl(serviceName);
|
||||
|
||||
// 매칭된 URL이 있으면 반환, 없으면 기존 매핑 사용
|
||||
if (suggestedUrl != null && suggestedUrl.isNotEmpty) {
|
||||
return suggestedUrl;
|
||||
}
|
||||
|
||||
// 기존 하드코딩된 매핑 (필요한 경우 폴백으로 사용)
|
||||
final Map<String, String> serviceUrls = {
|
||||
'넷플릭스': 'https://www.netflix.com',
|
||||
'디즈니플러스': 'https://www.disneyplus.com',
|
||||
'유튜브프리미엄': 'https://www.youtube.com',
|
||||
'YouTube Premium': 'https://www.youtube.com',
|
||||
'애플 iCloud': 'https://www.icloud.com',
|
||||
'Microsoft 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'멜론': 'https://www.melon.com',
|
||||
'웨이브': 'https://www.wavve.com',
|
||||
'Apple Music': 'https://www.apple.com/apple-music',
|
||||
'Netflix': 'https://www.netflix.com',
|
||||
'Disney+': 'https://www.disneyplus.com',
|
||||
'Spotify': 'https://www.spotify.com',
|
||||
};
|
||||
|
||||
return serviceUrls[serviceName];
|
||||
}
|
||||
|
||||
bool _containsSubscriptionKeywords(String text) {
|
||||
final keywords = [
|
||||
'구독',
|
||||
'결제',
|
||||
'청구',
|
||||
'정기',
|
||||
'자동',
|
||||
'subscription',
|
||||
'payment',
|
||||
'bill',
|
||||
'invoice'
|
||||
];
|
||||
return keywords
|
||||
.any((keyword) => text.toLowerCase().contains(keyword.toLowerCase()));
|
||||
}
|
||||
|
||||
double? _extractAmount(String text) {
|
||||
final RegExp amountRegex = RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{2})?)');
|
||||
final match = amountRegex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final amountStr = match.group(1)?.replaceAll(',', '');
|
||||
return double.tryParse(amountStr ?? '');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _extractServiceName(String text) {
|
||||
final serviceNames = [
|
||||
'Netflix',
|
||||
'Spotify',
|
||||
'Disney+',
|
||||
'Apple Music',
|
||||
'YouTube Premium',
|
||||
'Amazon Prime',
|
||||
'Microsoft 365',
|
||||
'Google One',
|
||||
'iCloud',
|
||||
'Dropbox'
|
||||
];
|
||||
|
||||
for (final name in serviceNames) {
|
||||
if (text.contains(name)) {
|
||||
return name;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _extractBillingCycle(String text) {
|
||||
if (text.contains('월') || text.contains('month')) {
|
||||
return 'monthly';
|
||||
} else if (text.contains('년') || text.contains('year')) {
|
||||
return 'yearly';
|
||||
} else if (text.contains('주') || text.contains('week')) {
|
||||
return 'weekly';
|
||||
}
|
||||
return 'monthly'; // 기본값
|
||||
}
|
||||
|
||||
DateTime _extractNextBillingDate(String text) {
|
||||
final RegExp dateRegex = RegExp(r'(\d{4}[-/]\d{2}[-/]\d{2})');
|
||||
final match = dateRegex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final dateStr = match.group(1);
|
||||
if (dateStr != null) {
|
||||
final date = DateTime.tryParse(dateStr);
|
||||
if (date != null) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
}
|
||||
return DateTime.now().add(const Duration(days: 30)); // 기본값: 30일 후
|
||||
}
|
||||
|
||||
// 메시지에서 통화 단위를 감지하는 함수
|
||||
String _detectCurrency(String message) {
|
||||
final dollarKeywords = [
|
||||
'\$', 'USD', 'dollar', '달러', 'dollars', 'US\$',
|
||||
// 해외 서비스 이름
|
||||
'Netflix US', 'Spotify Premium', 'Apple US', 'Amazon US', 'GitHub'
|
||||
];
|
||||
|
||||
// 특정 서비스명으로 통화 단위 결정
|
||||
final Map<String, String> serviceCurrencyMap = {
|
||||
'Netflix US': 'USD',
|
||||
'Spotify Premium': 'USD',
|
||||
'Spotify': 'USD',
|
||||
'GitHub': 'USD',
|
||||
'GitHub Pro': 'USD',
|
||||
};
|
||||
|
||||
// 서비스명 기반 통화 단위 확인
|
||||
for (final service in serviceCurrencyMap.keys) {
|
||||
if (message.contains(service)) {
|
||||
print('_detectCurrency: ${service}는 USD 서비스로 판별됨');
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
// 메시지에 달러 관련 키워드가 있는지 확인
|
||||
for (final keyword in dollarKeywords) {
|
||||
if (message.toLowerCase().contains(keyword.toLowerCase())) {
|
||||
print('_detectCurrency: USD 키워드 발견: $keyword');
|
||||
return 'USD';
|
||||
}
|
||||
}
|
||||
|
||||
// 기본값은 원화
|
||||
return 'KRW';
|
||||
}
|
||||
}
|
||||
35
lib/services/sms_service.dart
Normal file
35
lib/services/sms_service.dart
Normal file
@@ -0,0 +1,35 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:permission_handler/permission_handler.dart' as permission;
|
||||
|
||||
class SMSService {
|
||||
static const platform = MethodChannel('com.submanager/sms');
|
||||
|
||||
static Future<bool> requestSMSPermission() async {
|
||||
if (kIsWeb) return false;
|
||||
final status = await permission.Permission.sms.request();
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<bool> hasSMSPermission() async {
|
||||
if (kIsWeb) return false;
|
||||
final status = await permission.Permission.sms.status;
|
||||
return status.isGranted;
|
||||
}
|
||||
|
||||
static Future<List<Map<String, dynamic>>> scanSubscriptions() async {
|
||||
if (kIsWeb) return [];
|
||||
|
||||
try {
|
||||
if (!await hasSMSPermission()) {
|
||||
throw Exception('SMS 권한이 없습니다.');
|
||||
}
|
||||
|
||||
final List<dynamic> result =
|
||||
await platform.invokeMethod('scanSubscriptions');
|
||||
return result.map((item) => item as Map<String, dynamic>).toList();
|
||||
} on PlatformException catch (e) {
|
||||
throw Exception('SMS 스캔 중 오류 발생: ${e.message}');
|
||||
}
|
||||
}
|
||||
}
|
||||
476
lib/services/subscription_url_matcher.dart
Normal file
476
lib/services/subscription_url_matcher.dart
Normal file
@@ -0,0 +1,476 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
|
||||
class SubscriptionUrlMatcher {
|
||||
// OTT 서비스
|
||||
static final Map<String, String> ottServices = {
|
||||
'netflix': 'https://www.netflix.com',
|
||||
'넷플릭스': 'https://www.netflix.com',
|
||||
'disney+': 'https://www.disneyplus.com',
|
||||
'디즈니플러스': 'https://www.disneyplus.com',
|
||||
'youtube premium': 'https://www.youtube.com/premium',
|
||||
'유튜브 프리미엄': 'https://www.youtube.com/premium',
|
||||
'watcha': 'https://watcha.com',
|
||||
'왓챠': 'https://watcha.com',
|
||||
'wavve': 'https://www.wavve.com',
|
||||
'웨이브': 'https://www.wavve.com',
|
||||
'apple tv+': 'https://tv.apple.com',
|
||||
'애플 티비플러스': 'https://tv.apple.com',
|
||||
'tving': 'https://www.tving.com',
|
||||
'티빙': 'https://www.tving.com',
|
||||
'prime video': 'https://www.primevideo.com',
|
||||
'프라임 비디오': 'https://www.primevideo.com',
|
||||
'amazon prime': 'https://www.amazon.com/prime',
|
||||
'아마존 프라임': 'https://www.amazon.com/prime',
|
||||
'coupang play': 'https://play.coupangplay.com',
|
||||
'쿠팡 플레이': 'https://play.coupangplay.com',
|
||||
'hulu': 'https://www.hulu.com',
|
||||
'훌루': 'https://www.hulu.com',
|
||||
};
|
||||
|
||||
// 음악 서비스
|
||||
static final Map<String, String> musicServices = {
|
||||
'spotify': 'https://www.spotify.com',
|
||||
'스포티파이': 'https://www.spotify.com',
|
||||
'apple music': 'https://music.apple.com',
|
||||
'애플 뮤직': 'https://music.apple.com',
|
||||
'melon': 'https://www.melon.com',
|
||||
'멜론': 'https://www.melon.com',
|
||||
'genie': 'https://www.genie.co.kr',
|
||||
'지니': 'https://www.genie.co.kr',
|
||||
'youtube music': 'https://music.youtube.com',
|
||||
'유튜브 뮤직': 'https://music.youtube.com',
|
||||
'bugs': 'https://music.bugs.co.kr',
|
||||
'벅스': 'https://music.bugs.co.kr',
|
||||
'flo': 'https://www.music-flo.com',
|
||||
'플로': 'https://www.music-flo.com',
|
||||
'vibe': 'https://vibe.naver.com',
|
||||
'바이브': 'https://vibe.naver.com',
|
||||
'tidal': 'https://www.tidal.com',
|
||||
'타이달': 'https://www.tidal.com',
|
||||
};
|
||||
|
||||
// AI 서비스
|
||||
static final Map<String, String> aiServices = {
|
||||
'chatgpt': 'https://chat.openai.com',
|
||||
'챗GPT': 'https://chat.openai.com',
|
||||
'openai': 'https://openai.com',
|
||||
'오픈AI': 'https://openai.com',
|
||||
'claude': 'https://claude.ai',
|
||||
'클로드': 'https://claude.ai',
|
||||
'anthropic': 'https://www.anthropic.com',
|
||||
'앤트로픽': 'https://www.anthropic.com',
|
||||
'midjourney': 'https://www.midjourney.com',
|
||||
'미드저니': 'https://www.midjourney.com',
|
||||
'perplexity': 'https://www.perplexity.ai',
|
||||
'퍼플렉시티': 'https://www.perplexity.ai',
|
||||
'copilot': 'https://copilot.microsoft.com',
|
||||
'코파일럿': 'https://copilot.microsoft.com',
|
||||
'gemini': 'https://gemini.google.com',
|
||||
'제미니': 'https://gemini.google.com',
|
||||
'google ai': 'https://ai.google',
|
||||
'구글 AI': 'https://ai.google',
|
||||
'bard': 'https://bard.google.com',
|
||||
'바드': 'https://bard.google.com',
|
||||
'dall-e': 'https://openai.com/dall-e',
|
||||
'달리': 'https://openai.com/dall-e',
|
||||
'stable diffusion': 'https://stability.ai',
|
||||
'스테이블 디퓨전': 'https://stability.ai',
|
||||
};
|
||||
|
||||
// 프로그래밍 / 개발 서비스
|
||||
static final Map<String, String> programmingServices = {
|
||||
'github': 'https://github.com',
|
||||
'깃허브': 'https://github.com',
|
||||
'cursor': 'https://cursor.com',
|
||||
'커서': 'https://cursor.com',
|
||||
'jetbrains': 'https://www.jetbrains.com',
|
||||
'제트브레인스': 'https://www.jetbrains.com',
|
||||
'intellij': 'https://www.jetbrains.com/idea',
|
||||
'인텔리제이': 'https://www.jetbrains.com/idea',
|
||||
'visual studio': 'https://visualstudio.microsoft.com',
|
||||
'비주얼 스튜디오': 'https://visualstudio.microsoft.com',
|
||||
'aws': 'https://aws.amazon.com',
|
||||
'아마존 웹서비스': 'https://aws.amazon.com',
|
||||
'azure': 'https://azure.microsoft.com',
|
||||
'애저': 'https://azure.microsoft.com',
|
||||
'google cloud': 'https://cloud.google.com',
|
||||
'구글 클라우드': 'https://cloud.google.com',
|
||||
'digitalocean': 'https://www.digitalocean.com',
|
||||
'디지털오션': 'https://www.digitalocean.com',
|
||||
'heroku': 'https://www.heroku.com',
|
||||
'헤로쿠': 'https://www.heroku.com',
|
||||
'codecademy': 'https://www.codecademy.com',
|
||||
'코드아카데미': 'https://www.codecademy.com',
|
||||
'udemy': 'https://www.udemy.com',
|
||||
'유데미': 'https://www.udemy.com',
|
||||
'coursera': 'https://www.coursera.org',
|
||||
'코세라': 'https://www.coursera.org',
|
||||
};
|
||||
|
||||
// 오피스 및 협업 툴
|
||||
static final Map<String, String> officeTools = {
|
||||
'microsoft 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'마이크로소프트 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'office 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'오피스 365': 'https://www.microsoft.com/microsoft-365',
|
||||
'google workspace': 'https://workspace.google.com',
|
||||
'구글 워크스페이스': 'https://workspace.google.com',
|
||||
'slack': 'https://slack.com',
|
||||
'슬랙': 'https://slack.com',
|
||||
'notion': 'https://www.notion.so',
|
||||
'노션': 'https://www.notion.so',
|
||||
'trello': 'https://trello.com',
|
||||
'트렐로': 'https://trello.com',
|
||||
'asana': 'https://asana.com',
|
||||
'아사나': 'https://asana.com',
|
||||
'dropbox': 'https://www.dropbox.com',
|
||||
'드롭박스': 'https://www.dropbox.com',
|
||||
'figma': 'https://www.figma.com',
|
||||
'피그마': 'https://www.figma.com',
|
||||
'adobe creative cloud': 'https://www.adobe.com/creativecloud.html',
|
||||
'어도비 크리에이티브 클라우드': 'https://www.adobe.com/creativecloud.html',
|
||||
};
|
||||
|
||||
// 기타 유명 서비스
|
||||
static final Map<String, String> otherServices = {
|
||||
'google one': 'https://one.google.com',
|
||||
'구글 원': 'https://one.google.com',
|
||||
'icloud': 'https://www.icloud.com',
|
||||
'아이클라우드': 'https://www.icloud.com',
|
||||
'nintendo switch online': 'https://www.nintendo.com/switch/online-service',
|
||||
'닌텐도 스위치 온라인': 'https://www.nintendo.com/switch/online-service',
|
||||
'playstation plus': 'https://www.playstation.com/ps-plus',
|
||||
'플레이스테이션 플러스': 'https://www.playstation.com/ps-plus',
|
||||
'xbox game pass': 'https://www.xbox.com/xbox-game-pass',
|
||||
'엑스박스 게임 패스': 'https://www.xbox.com/xbox-game-pass',
|
||||
'ea play': 'https://www.ea.com/ea-play',
|
||||
'EA 플레이': 'https://www.ea.com/ea-play',
|
||||
'ubisoft+': 'https://ubisoft.com/plus',
|
||||
'유비소프트+': 'https://ubisoft.com/plus',
|
||||
'epic games': 'https://www.epicgames.com',
|
||||
'에픽 게임즈': 'https://www.epicgames.com',
|
||||
'steam': 'https://store.steampowered.com',
|
||||
'스팀': 'https://store.steampowered.com',
|
||||
};
|
||||
|
||||
// 해지 안내 페이지 URL 목록 (공식 해지 안내 페이지가 있는 서비스들)
|
||||
static final Map<String, String> cancellationUrls = {
|
||||
// OTT 서비스 해지 안내 페이지
|
||||
'netflix': 'https://help.netflix.com/ko/node/407',
|
||||
'넷플릭스': 'https://help.netflix.com/ko/node/407',
|
||||
'disney+':
|
||||
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
|
||||
'디즈니플러스':
|
||||
'https://help.disneyplus.com/csp?id=csp_article_content&sys_kb_id=f0ddbe01db7601105d5e040ad3961979',
|
||||
'youtube premium': 'https://support.google.com/youtube/answer/6308278',
|
||||
'유튜브 프리미엄': 'https://support.google.com/youtube/answer/6308278',
|
||||
'watcha': 'https://watcha.com/settings/payment',
|
||||
'왓챠': 'https://watcha.com/settings/payment',
|
||||
'wavve': 'https://www.wavve.com/my',
|
||||
'웨이브': 'https://www.wavve.com/my',
|
||||
'apple tv+': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'애플 티비플러스': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'tving': 'https://www.tving.com/my/cancelMembership',
|
||||
'티빙': 'https://www.tving.com/my/cancelMembership',
|
||||
'amazon prime': 'https://www.amazon.com/gp/primecentral/managemembership',
|
||||
'아마존 프라임': 'https://www.amazon.com/gp/primecentral/managemembership',
|
||||
|
||||
// 음악 서비스 해지 안내 페이지
|
||||
'spotify': 'https://support.spotify.com/us/article/cancel-premium/',
|
||||
'스포티파이': 'https://support.spotify.com/us/article/cancel-premium/',
|
||||
'apple music': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'애플 뮤직': 'https://support.apple.com/ko-kr/HT202039',
|
||||
'melon':
|
||||
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
|
||||
'멜론':
|
||||
'https://faqs2.melon.com/customer/faq/informFaq.htm?no=17&faqId=QUES20150209000002&orderChk=date&SEARCH_KEY=&SEARCH_PAR_CATEGORY=CATE20130909000006&SEARCH_CATEGORY=CATE20130909000021',
|
||||
'youtube music': 'https://support.google.com/youtubemusic/answer/6308278',
|
||||
'유튜브 뮤직': 'https://support.google.com/youtubemusic/answer/6308278',
|
||||
|
||||
// AI 서비스 해지 안내 페이지
|
||||
'chatgpt':
|
||||
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
|
||||
'챗GPT':
|
||||
'https://help.openai.com/en/articles/7730211-manage-or-cancel-your-chatgpt-plus-subscription',
|
||||
'claude':
|
||||
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
|
||||
'클로드':
|
||||
'https://help.anthropic.com/en/articles/8798313-how-do-i-cancel-my-claude-subscription',
|
||||
'midjourney': 'https://docs.midjourney.com/docs/manage-subscription',
|
||||
'미드저니': 'https://docs.midjourney.com/docs/manage-subscription',
|
||||
|
||||
// 프로그래밍 / 개발 서비스 해지 안내 페이지
|
||||
'github':
|
||||
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
|
||||
'깃허브':
|
||||
'https://docs.github.com/ko/billing/managing-billing-for-your-github-account/downgrading-your-github-subscription',
|
||||
'jetbrains':
|
||||
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
|
||||
'제트브레인스':
|
||||
'https://sales.jetbrains.com/hc/en-gb/articles/207240845-How-to-cancel-an-auto-renewal-subscription-',
|
||||
|
||||
// 오피스 및 협업 툴 해지 안내 페이지
|
||||
'microsoft 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'마이크로소프트 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'office 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'오피스 365':
|
||||
'https://support.microsoft.com/en-us/office/cancel-a-microsoft-365-subscription-46e2634c-c64b-4c65-94b9-2cc9c960e91b',
|
||||
'slack':
|
||||
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
|
||||
'슬랙':
|
||||
'https://slack.com/help/articles/360003378691-Cancel-your-Slack-subscription',
|
||||
'notion':
|
||||
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
|
||||
'노션':
|
||||
'https://www.notion.so/help/billing-and-payment-settings#cancel-a-subscription',
|
||||
'dropbox': 'https://help.dropbox.com/accounts-billing/cancellation',
|
||||
'드롭박스': 'https://help.dropbox.com/accounts-billing/cancellation',
|
||||
'adobe creative cloud':
|
||||
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
|
||||
'어도비 크리에이티브 클라우드':
|
||||
'https://helpx.adobe.com/manage-account/using/cancel-subscription.html',
|
||||
|
||||
// 기타 유명 서비스 해지 안내 페이지
|
||||
'google one': 'https://support.google.com/googleone/answer/9140429',
|
||||
'구글 원': 'https://support.google.com/googleone/answer/9140429',
|
||||
'icloud': 'https://support.apple.com/ko-kr/HT207594',
|
||||
'아이클라우드': 'https://support.apple.com/ko-kr/HT207594',
|
||||
'nintendo switch online':
|
||||
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
|
||||
'닌텐도 스위치 온라인':
|
||||
'https://en-americas-support.nintendo.com/app/answers/detail/a_id/41925/~/how-to-cancel-a-nintendo-switch-online-membership',
|
||||
'playstation plus':
|
||||
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
|
||||
'플레이스테이션 플러스':
|
||||
'https://www.playstation.com/support/subscriptions/cancel-playstation-plus/',
|
||||
'xbox game pass':
|
||||
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
|
||||
'엑스박스 게임 패스':
|
||||
'https://support.xbox.com/help/subscriptions-billing/manage-subscriptions/xbox-game-pass-how-to-cancel',
|
||||
};
|
||||
|
||||
// 모든 서비스 매핑을 합친 맵
|
||||
static final Map<String, String> allServices = {
|
||||
...ottServices,
|
||||
...musicServices,
|
||||
...aiServices,
|
||||
...programmingServices,
|
||||
...officeTools,
|
||||
...otherServices,
|
||||
};
|
||||
|
||||
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환
|
||||
///
|
||||
/// [text] 검색할 텍스트 (서비스명)
|
||||
/// [usePartialMatch] 부분 일치도 허용할지 여부 (기본값: true)
|
||||
///
|
||||
/// 반환값: 매칭된 URL 또는 null (매칭 실패시)
|
||||
static String? findMatchingUrl(String text, {bool usePartialMatch = true}) {
|
||||
// 입력 텍스트가 비어있거나 null인 경우
|
||||
if (text.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = text.toLowerCase().trim();
|
||||
|
||||
// 정확히 일치하는 경우
|
||||
if (allServices.containsKey(lowerText)) {
|
||||
return allServices[lowerText];
|
||||
}
|
||||
|
||||
// 부분 일치 검색이 활성화된 경우
|
||||
if (usePartialMatch) {
|
||||
// 가장 긴 부분 매칭 찾기
|
||||
String? bestMatch;
|
||||
int maxLength = 0;
|
||||
|
||||
for (var entry in allServices.entries) {
|
||||
final String key = entry.key;
|
||||
|
||||
// 입력된 텍스트에 서비스 키워드가 포함되어 있거나, 서비스 키워드에 입력된 텍스트가 포함된 경우
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
// 더 긴 매칭을 우선시
|
||||
if (key.length > maxLength) {
|
||||
maxLength = key.length;
|
||||
bestMatch = entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 서비스 이름을 기반으로 URL 제안
|
||||
static String? suggestUrl(String serviceName) {
|
||||
if (serviceName.isEmpty) {
|
||||
print('SubscriptionUrlMatcher: 빈 serviceName');
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 비교
|
||||
final lowerName = serviceName.toLowerCase().trim();
|
||||
|
||||
try {
|
||||
// 정확한 매칭을 먼저 시도
|
||||
for (final entry in allServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print('SubscriptionUrlMatcher: 정확한 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// OTT 서비스 검사
|
||||
for (final entry in ottServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: OTT 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 음악 서비스 검사
|
||||
for (final entry in musicServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 음악 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// AI 서비스 검사
|
||||
for (final entry in aiServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: AI 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 개발 서비스 검사
|
||||
for (final entry in programmingServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 개발 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 오피스 툴 검사
|
||||
for (final entry in officeTools.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 오피스 툴 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 기타 서비스 검사
|
||||
for (final entry in otherServices.entries) {
|
||||
if (lowerName.contains(entry.key.toLowerCase())) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 기타 서비스 매칭 - $lowerName -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// 유사한 이름 검사 (퍼지 매칭) - 단어 기반으로 검색
|
||||
for (final entry in allServices.entries) {
|
||||
final serviceWords = lowerName.split(' ');
|
||||
final keyWords = entry.key.toLowerCase().split(' ');
|
||||
|
||||
// 단어 단위로 일치하는지 확인
|
||||
for (final word in serviceWords) {
|
||||
if (word.length > 2 &&
|
||||
keyWords.any((keyWord) => keyWord.contains(word))) {
|
||||
print(
|
||||
'SubscriptionUrlMatcher: 단어 기반 매칭 - $word (in $lowerName) -> ${entry.key}');
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 추출 가능한 도메인이 있는지 확인
|
||||
final domainMatch = RegExp(r'(\w+)').firstMatch(lowerName);
|
||||
if (domainMatch != null && domainMatch.group(1)!.length > 2) {
|
||||
final domain = domainMatch.group(1)!.trim();
|
||||
if (domain.length > 2 &&
|
||||
!['the', 'and', 'for', 'www'].contains(domain)) {
|
||||
final url = 'https://www.$domain.com';
|
||||
print('SubscriptionUrlMatcher: 도메인 추출 - $lowerName -> $url');
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
print('SubscriptionUrlMatcher: 매칭 실패 - $lowerName');
|
||||
return null;
|
||||
} catch (e) {
|
||||
print('SubscriptionUrlMatcher: URL 매칭 중 오류 발생: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기
|
||||
///
|
||||
/// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL
|
||||
///
|
||||
/// 반환값: 해지 안내 페이지 URL 또는 null (해지 안내 페이지가 없는 경우)
|
||||
static String? findCancellationUrl(String serviceNameOrUrl) {
|
||||
if (serviceNameOrUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 소문자로 변환하여 처리
|
||||
final String lowerText = serviceNameOrUrl.toLowerCase().trim();
|
||||
|
||||
// 직접 서비스명으로 찾기
|
||||
if (cancellationUrls.containsKey(lowerText)) {
|
||||
return cancellationUrls[lowerText];
|
||||
}
|
||||
|
||||
// 서비스명에 부분 포함으로 찾기
|
||||
for (var entry in cancellationUrls.entries) {
|
||||
final String key = entry.key.toLowerCase();
|
||||
if (lowerText.contains(key) || key.contains(lowerText)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// URL을 통해 서비스명 추출 후 찾기
|
||||
if (lowerText.startsWith('http')) {
|
||||
// URL 도메인 추출 (https://www.netflix.com 에서 netflix 추출)
|
||||
final domainRegex = RegExp(r'https?://(?:www\.)?([a-zA-Z0-9-]+)');
|
||||
final match = domainRegex.firstMatch(lowerText);
|
||||
|
||||
if (match != null && match.groupCount >= 1) {
|
||||
final domain = match.group(1)?.toLowerCase() ?? '';
|
||||
|
||||
// 도메인으로 서비스명 찾기
|
||||
for (var entry in cancellationUrls.entries) {
|
||||
if (entry.key.toLowerCase().contains(domain)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 해지 안내 페이지를 찾지 못함
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
||||
///
|
||||
/// [serviceNameOrUrl] 서비스명 또는 웹사이트 URL
|
||||
///
|
||||
/// 반환값: 해지 안내 페이지 제공 여부
|
||||
static bool hasCancellationPage(String serviceNameOrUrl) {
|
||||
return findCancellationUrl(serviceNameOrUrl) != null;
|
||||
}
|
||||
}
|
||||
342
lib/temp/test_sms_data.dart
Normal file
342
lib/temp/test_sms_data.dart
Normal file
@@ -0,0 +1,342 @@
|
||||
// 테스트용 SMS 데이터
|
||||
// 실제 SMS를 스캔하는 대신 이 테스트 데이터를 사용합니다.
|
||||
|
||||
class TestSmsData {
|
||||
static List<Map<String, dynamic>> getTestData() {
|
||||
// 현재 날짜 기준으로 미래 날짜 계산
|
||||
final now = DateTime.now();
|
||||
final nextMonth = DateTime(now.year, now.month + 1, now.day);
|
||||
final prevMonth = DateTime(now.year, now.month - 1, now.day);
|
||||
|
||||
final String formattedNextMonth =
|
||||
'${nextMonth.year}-${nextMonth.month.toString().padLeft(2, '0')}-${nextMonth.day.toString().padLeft(2, '0')}';
|
||||
final String formattedPrevMonth =
|
||||
'${prevMonth.year}-${prevMonth.month.toString().padLeft(2, '0')}-${prevMonth.day.toString().padLeft(2, '0')}';
|
||||
final String formattedNow =
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// 1년 후 날짜 계산 (연간 구독용)
|
||||
final nextYear = DateTime(now.year + 1, now.month, now.day);
|
||||
final prevYear = DateTime(now.year - 1, now.month, now.day);
|
||||
final String formattedNextYear =
|
||||
'${nextYear.year}-${nextYear.month.toString().padLeft(2, '0')}-${nextYear.day.toString().padLeft(2, '0')}';
|
||||
final String formattedPrevYear =
|
||||
'${prevYear.year}-${prevYear.month.toString().padLeft(2, '0')}-${prevYear.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// 기본 테스트 데이터 정의
|
||||
final baseTestData = [
|
||||
{
|
||||
'serviceName': '넷플릭스',
|
||||
'monthlyCost': 9900.0,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate': formattedNextMonth,
|
||||
'isRecurring': true,
|
||||
'repeatCount': 5,
|
||||
'sender': '15885000',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate': formattedPrevMonth,
|
||||
'message': '[넷플릭스] 정기 결제 완료: 9,900원이 결제되었습니다. 다음 결제일: ${nextMonth.day}일'
|
||||
},
|
||||
{
|
||||
'serviceName': '유튜브프리미엄',
|
||||
'monthlyCost': 14900.0,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 20).year}-${DateTime(now.year, now.month + 1, 20).month.toString().padLeft(2, '0')}-20',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 7,
|
||||
'sender': '15882018',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 20).year}-${DateTime(now.year, now.month - 1, 20).month.toString().padLeft(2, '0')}-20',
|
||||
'message': '[Google] 유튜브프리미엄 구독료 14,900원이 자동 결제되었습니다.'
|
||||
},
|
||||
{
|
||||
'serviceName': '디즈니플러스',
|
||||
'monthlyCost': 8900.0,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 10).year}-${DateTime(now.year, now.month + 1, 10).month.toString().padLeft(2, '0')}-10',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 3,
|
||||
'sender': '15771055',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 10).year}-${DateTime(now.year, now.month - 1, 10).month.toString().padLeft(2, '0')}-10',
|
||||
'message': '[디즈니플러스] 8,900원 정기결제가 완료되었습니다.'
|
||||
},
|
||||
{
|
||||
'serviceName': '애플 iCloud',
|
||||
'monthlyCost': 2900.0,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 5).year}-${DateTime(now.year, now.month + 1, 5).month.toString().padLeft(2, '0')}-05',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 12,
|
||||
'sender': '0802011900',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 5).year}-${DateTime(now.year, now.month - 1, 5).month.toString().padLeft(2, '0')}-05',
|
||||
'message': 'Apple: iCloud+ 50GB 월 구독(₩2,900)이 자동으로 갱신되었습니다.'
|
||||
},
|
||||
{
|
||||
'serviceName': '멜론',
|
||||
'monthlyCost': 10900.0,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 22).year}-${DateTime(now.year, now.month + 1, 22).month.toString().padLeft(2, '0')}-22',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 4,
|
||||
'sender': '16001950',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 22).year}-${DateTime(now.year, now.month - 1, 22).month.toString().padLeft(2, '0')}-22',
|
||||
'message':
|
||||
'[멜론] 스트리밍클럽 정기결제 10,900원 완료. 다음 결제일: ${DateTime(now.year, now.month + 1, 22).day}일'
|
||||
},
|
||||
{
|
||||
'serviceName': 'Microsoft 365',
|
||||
'monthlyCost': 12800.0,
|
||||
'billingCycle': '연간',
|
||||
'nextBillingDate': formattedNextYear,
|
||||
'isRecurring': true,
|
||||
'repeatCount': 2,
|
||||
'sender': '0801136532',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate': formattedPrevYear,
|
||||
'message': '[Microsoft] Microsoft 365 연간 구독료 12,800원이 결제되었습니다.'
|
||||
},
|
||||
{
|
||||
'serviceName': '웨이브',
|
||||
'monthlyCost': 7900.0,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 15).year}-${DateTime(now.year, now.month + 1, 15).month.toString().padLeft(2, '0')}-15',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 2,
|
||||
'sender': '1800-1234',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 15).year}-${DateTime(now.year, now.month - 1, 15).month.toString().padLeft(2, '0')}-15',
|
||||
'message': '[웨이브] 구독료 7,900원이 정기결제 되었습니다. 감사합니다.'
|
||||
},
|
||||
// 달러 결제 서비스 추가
|
||||
{
|
||||
'serviceName': 'Netflix US',
|
||||
'monthlyCost': 9.99,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 7).year}-${DateTime(now.year, now.month + 1, 7).month.toString().padLeft(2, '0')}-07',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 6,
|
||||
'sender': '334455',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 7).year}-${DateTime(now.year, now.month - 1, 7).month.toString().padLeft(2, '0')}-07',
|
||||
'message':
|
||||
'[Netflix US] Your subscription has been renewed. \$9.99 has been charged to your account. Next billing date: ${DateTime(now.year, now.month + 1, 7).day}'
|
||||
},
|
||||
{
|
||||
'serviceName': 'Spotify Premium',
|
||||
'monthlyCost': 10.99,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 12).year}-${DateTime(now.year, now.month + 1, 12).month.toString().padLeft(2, '0')}-12',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 4,
|
||||
'sender': '223344',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 12).year}-${DateTime(now.year, now.month - 1, 12).month.toString().padLeft(2, '0')}-12',
|
||||
'message':
|
||||
'[Spotify] Your premium subscription was automatically renewed. USD 10.99 charged to your card. Your next payment will be on ${DateTime(now.year, now.month + 1, 12).day}'
|
||||
},
|
||||
{
|
||||
'serviceName': 'GitHub Pro',
|
||||
'monthlyCost': 4.00,
|
||||
'billingCycle': '월간',
|
||||
'nextBillingDate':
|
||||
'${DateTime(now.year, now.month + 1, 3).year}-${DateTime(now.year, now.month + 1, 3).month.toString().padLeft(2, '0')}-03',
|
||||
'isRecurring': true,
|
||||
'repeatCount': 8,
|
||||
'sender': '112233',
|
||||
'messageDate': formattedNow,
|
||||
'previousPaymentDate':
|
||||
'${DateTime(now.year, now.month - 1, 3).year}-${DateTime(now.year, now.month - 1, 3).month.toString().padLeft(2, '0')}-03',
|
||||
'message':
|
||||
'[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}'
|
||||
},
|
||||
];
|
||||
|
||||
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)
|
||||
final List<Map<String, dynamic>> resultData = [];
|
||||
|
||||
// 각 기본 데이터를 복제하여 여러 개의 메시지 생성
|
||||
for (final service in baseTestData) {
|
||||
// 원본 메시지 추가
|
||||
resultData.add(Map<String, dynamic>.from(service));
|
||||
|
||||
// 각 서비스에 대해 과거 메시지 추가 (2개 이상의 메시지가 있어야 반복 구독으로 인식)
|
||||
for (int i = 2; i <= (service['repeatCount'] as int); i++) {
|
||||
// 과거 결제일 계산
|
||||
final pastMonth = DateTime(
|
||||
now.year,
|
||||
now.month - i + 1 > 0 ? now.month - i + 1 : now.month - i + 1 + 12,
|
||||
service['billingCycle'] == '월간' ? now.day : now.day);
|
||||
|
||||
final formattedPastMonth =
|
||||
'${pastMonth.year}-${pastMonth.month.toString().padLeft(2, '0')}-${pastMonth.day.toString().padLeft(2, '0')}';
|
||||
|
||||
// 과거 메시지 생성
|
||||
final pastMessage = Map<String, dynamic>.from(service);
|
||||
pastMessage['messageDate'] = formattedPastMonth;
|
||||
pastMessage['nextBillingDate'] =
|
||||
service['previousPaymentDate']; // 이전 메시지의 다음 결제일은 현재의 이전 결제일
|
||||
|
||||
// 과거 메시지의 이전 결제일 계산
|
||||
final veryPastMonth = DateTime(
|
||||
pastMonth.year,
|
||||
pastMonth.month - 1 > 0
|
||||
? pastMonth.month - 1
|
||||
: pastMonth.month - 1 + 12,
|
||||
pastMonth.day);
|
||||
|
||||
final formattedVeryPastMonth =
|
||||
'${veryPastMonth.year}-${veryPastMonth.month.toString().padLeft(2, '0')}-${veryPastMonth.day.toString().padLeft(2, '0')}';
|
||||
|
||||
pastMessage['previousPaymentDate'] = formattedVeryPastMonth;
|
||||
|
||||
resultData.add(pastMessage);
|
||||
}
|
||||
}
|
||||
|
||||
print('TestSmsData: 생성된 테스트 메시지 수: ${resultData.length}개');
|
||||
return resultData;
|
||||
}
|
||||
|
||||
// 최근 6개월의 월간 지출 데이터를 반환하는 메서드
|
||||
static List<Map<String, dynamic>> getMonthlyExpenseData() {
|
||||
final now = DateTime.now();
|
||||
final List<Map<String, dynamic>> monthlyData = [];
|
||||
|
||||
// 기본 구독 서비스와 금액 (현재 구독 중인 서비스)
|
||||
final baseServices = [
|
||||
{'serviceName': '넷플릭스', 'cost': 9900.0},
|
||||
{'serviceName': '유튜브프리미엄', 'cost': 14900.0},
|
||||
{'serviceName': '디즈니플러스', 'cost': 8900.0},
|
||||
{'serviceName': '애플 iCloud', 'cost': 2900.0},
|
||||
{'serviceName': '멜론', 'cost': 10900.0},
|
||||
{'serviceName': '웨이브', 'cost': 7900.0},
|
||||
{'serviceName': 'Netflix US', 'cost': 9.99}, // 달러 결제 서비스 추가
|
||||
{'serviceName': 'Spotify Premium', 'cost': 10.99}, // 달러 결제 서비스 추가
|
||||
{'serviceName': 'GitHub Pro', 'cost': 4.00}, // 달러 결제 서비스 추가
|
||||
];
|
||||
|
||||
// Microsoft 365는 연간 구독이므로 월별 비용으로 환산 (1년에 1번만 결제)
|
||||
final microsoftMonthlyCost = 12800.0 / 12;
|
||||
|
||||
// 최근 6개월 데이터 생성
|
||||
for (int i = 0; i < 6; i++) {
|
||||
// i개월 전 날짜
|
||||
final targetMonth = DateTime(now.month - i <= 0 ? now.year - 1 : now.year,
|
||||
now.month - i <= 0 ? now.month - i + 12 : now.month - i, 1);
|
||||
|
||||
// 해당 월의 모든 서비스 데이터 리스트
|
||||
final List<Map<String, dynamic>> servicesForMonth = [];
|
||||
|
||||
// 해당 월의 총 지출
|
||||
double totalExpense = 0.0;
|
||||
|
||||
// 기본 서비스 추가 (일부 서비스는 구독 시작 시점이 다를 수 있음)
|
||||
for (final service in baseServices) {
|
||||
// 3개월 전부터 웨이브 구독 시작
|
||||
if (service['serviceName'] == '웨이브' && i > 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2개월 전부터 디즈니플러스 구독 시작
|
||||
if (service['serviceName'] == '디즈니플러스' && i > 2) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// 서비스 정보 추가
|
||||
final Map<String, dynamic> serviceData = {
|
||||
'serviceName': service['serviceName'],
|
||||
'cost': service['cost'],
|
||||
'date':
|
||||
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-15',
|
||||
};
|
||||
|
||||
servicesForMonth.add(serviceData);
|
||||
totalExpense += service['cost'] as double;
|
||||
}
|
||||
|
||||
// Microsoft 365는 정확히 4개월 전에 결제됨
|
||||
if (i == 4) {
|
||||
servicesForMonth.add({
|
||||
'serviceName': 'Microsoft 365',
|
||||
'cost': 12800.0, // 연간 결제 비용
|
||||
'date':
|
||||
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-10',
|
||||
});
|
||||
totalExpense += 12800.0;
|
||||
} else {
|
||||
// 다른 달에는 월 환산 비용으로 계산
|
||||
totalExpense += microsoftMonthlyCost;
|
||||
}
|
||||
|
||||
// 3개월 전에는 Spotify를 일시적으로 구독했다가 해지
|
||||
if (i == 3) {
|
||||
servicesForMonth.add({
|
||||
'serviceName': 'Spotify',
|
||||
'cost': 10900.0,
|
||||
'date':
|
||||
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-05',
|
||||
});
|
||||
totalExpense += 10900.0;
|
||||
}
|
||||
|
||||
// 5개월 전과 4개월 전에는 쿠팡플레이를 구독했다가 해지
|
||||
if (i == 4 || i == 5) {
|
||||
servicesForMonth.add({
|
||||
'serviceName': '쿠팡플레이',
|
||||
'cost': 4990.0,
|
||||
'date':
|
||||
'${targetMonth.year}-${targetMonth.month.toString().padLeft(2, '0')}-18',
|
||||
});
|
||||
totalExpense += 4990.0;
|
||||
}
|
||||
|
||||
// 월별 총 지출 데이터 추가
|
||||
monthlyData.add({
|
||||
'year': targetMonth.year,
|
||||
'month': targetMonth.month,
|
||||
'totalExpense': totalExpense,
|
||||
'services': servicesForMonth,
|
||||
'monthName': _getMonthName(targetMonth.month),
|
||||
});
|
||||
}
|
||||
|
||||
// 최신 달이 먼저 오도록 reverse
|
||||
return monthlyData.reversed.toList();
|
||||
}
|
||||
|
||||
// 월 숫자를 한글 월 이름으로 변환
|
||||
static String _getMonthName(int month) {
|
||||
const monthNames = [
|
||||
'1월',
|
||||
'2월',
|
||||
'3월',
|
||||
'4월',
|
||||
'5월',
|
||||
'6월',
|
||||
'7월',
|
||||
'8월',
|
||||
'9월',
|
||||
'10월',
|
||||
'11월',
|
||||
'12월'
|
||||
];
|
||||
return monthNames[month - 1];
|
||||
}
|
||||
}
|
||||
49
lib/theme/app_colors.dart
Normal file
49
lib/theme/app_colors.dart
Normal file
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
// 메인 컬러 (Metronic Tailwind 스타일)
|
||||
static const primaryColor = Color(0xFF3B82F6); // 메트로닉 블루
|
||||
static const secondaryColor = Color(0xFF64748B); // 슬레이트 600
|
||||
static const successColor = Color(0xFF10B981); // 그린
|
||||
static const infoColor = Color(0xFF6366F1); // 인디고
|
||||
static const warningColor = Color(0xFFF59E0B); // 앰버
|
||||
static const dangerColor = Color(0xFFEF4444); // 레드
|
||||
|
||||
// 배경색
|
||||
static const backgroundColor = Color(0xFFF1F5F9); // 슬레이트 100
|
||||
static const surfaceColor = Color(0xFFFFFFFF); // 화이트
|
||||
static const surfaceColorAlt = Color(0xFFF8FAFC); // 슬레이트 50
|
||||
static const cardColor = Color(0xFFFFFFFF); // 화이트
|
||||
|
||||
// 텍스트 컬러
|
||||
static const textPrimary = Color(0xFF1E293B); // 슬레이트 800
|
||||
static const textSecondary = Color(0xFF64748B); // 슬레이트 600
|
||||
static const textMuted = Color(0xFF94A3B8); // 슬레이트 400
|
||||
static const textLight = Color(0xFFFFFFFF); // 화이트
|
||||
|
||||
// 보더 & 디바이더
|
||||
static const borderColor = Color(0xFFE2E8F0); // 슬레이트 200
|
||||
static const dividerColor = Color(0xFFE2E8F0); // 슬레이트 200
|
||||
|
||||
// 그라데이션 컬러 - 다양한 효과를 위한 조합
|
||||
static const List<Color> blueGradient = [
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFF2563EB)
|
||||
];
|
||||
static const List<Color> tealGradient = [
|
||||
Color(0xFF14B8A6),
|
||||
Color(0xFF0D9488)
|
||||
];
|
||||
static const List<Color> purpleGradient = [
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFF7C3AED)
|
||||
];
|
||||
static const List<Color> amberGradient = [
|
||||
Color(0xFFF59E0B),
|
||||
Color(0xFFD97706)
|
||||
];
|
||||
static const List<Color> roseGradient = [
|
||||
Color(0xFFF43F5E),
|
||||
Color(0xFFE11D48)
|
||||
];
|
||||
}
|
||||
356
lib/theme/app_theme.dart
Normal file
356
lib/theme/app_theme.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
class AppTheme {
|
||||
static ThemeData lightTheme = ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.light(
|
||||
primary: AppColors.primaryColor,
|
||||
onPrimary: Colors.white,
|
||||
secondary: AppColors.secondaryColor,
|
||||
tertiary: AppColors.infoColor,
|
||||
error: AppColors.dangerColor,
|
||||
background: AppColors.backgroundColor,
|
||||
surface: AppColors.surfaceColor,
|
||||
),
|
||||
|
||||
// 기본 배경색
|
||||
scaffoldBackgroundColor: AppColors.backgroundColor,
|
||||
|
||||
// 카드 스타일 - 부드러운 그림자, 둥근 모서리
|
||||
cardTheme: CardTheme(
|
||||
color: AppColors.cardColor,
|
||||
elevation: 1,
|
||||
shadowColor: Colors.black.withOpacity(0.04),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
side: BorderSide(color: AppColors.borderColor, width: 0.5),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
margin: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
|
||||
),
|
||||
|
||||
// 앱바 스타일 - 깔끔하고 투명한 디자인
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: AppColors.surfaceColor,
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: false,
|
||||
titleTextStyle: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
),
|
||||
iconTheme: IconThemeData(
|
||||
color: AppColors.secondaryColor,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
|
||||
// 타이포그래피 - Metronic Tailwind 스타일
|
||||
textTheme: TextTheme(
|
||||
// 헤드라인 - 페이지 제목
|
||||
headlineLarge: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
),
|
||||
headlineMedium: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: -0.5,
|
||||
height: 1.2,
|
||||
),
|
||||
headlineSmall: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.25,
|
||||
height: 1.3,
|
||||
),
|
||||
|
||||
// 타이틀 - 카드, 섹션 제목
|
||||
titleLarge: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.2,
|
||||
height: 1.4,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: -0.1,
|
||||
height: 1.4,
|
||||
),
|
||||
titleSmall: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0,
|
||||
height: 1.4,
|
||||
),
|
||||
|
||||
// 본문 텍스트
|
||||
bodyLarge: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.5,
|
||||
),
|
||||
bodyMedium: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.5,
|
||||
),
|
||||
bodySmall: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.5,
|
||||
),
|
||||
|
||||
// 라벨 텍스트
|
||||
labelLarge: TextStyle(
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
height: 1.4,
|
||||
),
|
||||
labelMedium: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.4,
|
||||
),
|
||||
labelSmall: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.2,
|
||||
height: 1.4,
|
||||
),
|
||||
),
|
||||
|
||||
// 입력 필드 스타일 - 깔끔하고 현대적인 디자인
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: AppColors.surfaceColorAlt,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: AppColors.borderColor, width: 1),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: AppColors.primaryColor, width: 1.5),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: AppColors.dangerColor, width: 1),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: AppColors.dangerColor, width: 1.5),
|
||||
),
|
||||
labelStyle: TextStyle(
|
||||
color: AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
hintStyle: TextStyle(
|
||||
color: AppColors.textMuted,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
errorStyle: TextStyle(
|
||||
color: AppColors.dangerColor,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
|
||||
// 버튼 스타일 - 프라이머리 버튼
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(0, 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
elevation: 0,
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 텍스트 버튼 스타일
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.primaryColor,
|
||||
minimumSize: const Size(0, 40),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 아웃라인 버튼 스타일
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
minimumSize: const Size(0, 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
side: BorderSide(color: AppColors.borderColor, width: 1),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// FAB 스타일
|
||||
floatingActionButtonTheme: FloatingActionButtonThemeData(
|
||||
backgroundColor: AppColors.primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 2,
|
||||
extendedPadding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
extendedTextStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
|
||||
// 스위치 스타일
|
||||
switchTheme: SwitchThemeData(
|
||||
thumbColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return Colors.white;
|
||||
}),
|
||||
trackColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return AppColors.primaryColor.withOpacity(0.5);
|
||||
}
|
||||
return AppColors.borderColor;
|
||||
}),
|
||||
),
|
||||
|
||||
// 체크박스 스타일
|
||||
checkboxTheme: CheckboxThemeData(
|
||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return Colors.transparent;
|
||||
}),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
side: BorderSide(color: AppColors.borderColor, width: 1.5),
|
||||
),
|
||||
|
||||
// 라디오 버튼 스타일
|
||||
radioTheme: RadioThemeData(
|
||||
fillColor: MaterialStateProperty.resolveWith<Color>((states) {
|
||||
if (states.contains(MaterialState.selected)) {
|
||||
return AppColors.primaryColor;
|
||||
}
|
||||
return AppColors.borderColor;
|
||||
}),
|
||||
),
|
||||
|
||||
// 슬라이더 스타일
|
||||
sliderTheme: SliderThemeData(
|
||||
activeTrackColor: AppColors.primaryColor,
|
||||
inactiveTrackColor: AppColors.borderColor,
|
||||
thumbColor: AppColors.primaryColor,
|
||||
overlayColor: AppColors.primaryColor.withOpacity(0.2),
|
||||
trackHeight: 4,
|
||||
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 10),
|
||||
overlayShape: const RoundSliderOverlayShape(overlayRadius: 20),
|
||||
),
|
||||
|
||||
// 탭바 스타일
|
||||
tabBarTheme: TabBarTheme(
|
||||
labelColor: AppColors.primaryColor,
|
||||
unselectedLabelColor: AppColors.textSecondary,
|
||||
indicatorColor: AppColors.primaryColor,
|
||||
labelStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
unselectedLabelStyle: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
letterSpacing: 0.1,
|
||||
),
|
||||
),
|
||||
|
||||
// 디바이더 스타일
|
||||
dividerTheme: DividerThemeData(
|
||||
color: AppColors.dividerColor,
|
||||
thickness: 1,
|
||||
space: 16,
|
||||
),
|
||||
|
||||
// 페이지 트랜지션
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
||||
TargetPlatform.iOS: CupertinoPageTransitionsBuilder(),
|
||||
TargetPlatform.macOS: CupertinoPageTransitionsBuilder(),
|
||||
},
|
||||
),
|
||||
|
||||
// 스낵바 스타일
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
backgroundColor: AppColors.textPrimary,
|
||||
contentTextStyle: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
84
lib/utils/animation_controller_helper.dart
Normal file
84
lib/utils/animation_controller_helper.dart
Normal 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();
|
||||
}
|
||||
}
|
||||
37
lib/utils/format_helper.dart
Normal file
37
lib/utils/format_helper.dart
Normal 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일 후';
|
||||
}
|
||||
}
|
||||
}
|
||||
109
lib/utils/subscription_category_helper.dart
Normal file
109
lib/utils/subscription_category_helper.dart
Normal 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)));
|
||||
}
|
||||
}
|
||||
124
lib/widgets/animated_wave_background.dart
Normal file
124
lib/widgets/animated_wave_background.dart
Normal file
@@ -0,0 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
||||
///
|
||||
/// [controller]와 [pulseController]를 통해 애니메이션을 제어합니다.
|
||||
class AnimatedWaveBackground extends StatelessWidget {
|
||||
final AnimationController controller;
|
||||
final AnimationController pulseController;
|
||||
|
||||
const AnimatedWaveBackground({
|
||||
Key? key,
|
||||
required this.controller,
|
||||
required this.pulseController,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
||||
final angle = controller.value * 2 * math.pi;
|
||||
// 사인 함수를 사용하여 부드러운 움직임 생성
|
||||
final xOffset = 20 * math.sin(angle);
|
||||
final yOffset = 10 * math.cos(angle);
|
||||
|
||||
return Positioned(
|
||||
right: -40 + xOffset,
|
||||
top: -60 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 회전도 선형적으로 변화하도록 수정
|
||||
angle: 0.2 * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(100),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
||||
final xOffset = 20 * math.cos(angle);
|
||||
final yOffset = 10 * math.sin(angle);
|
||||
|
||||
return Positioned(
|
||||
left: -80 + xOffset,
|
||||
bottom: -70 + yOffset,
|
||||
child: Transform.rotate(
|
||||
// 반대 방향으로 회전하도록 설정
|
||||
angle: -0.3 * math.sin(angle * 0.5),
|
||||
child: Container(
|
||||
width: 220,
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(110),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 배경에 추가적인 작은 원 추가하여 깊이감 증가
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, child) {
|
||||
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
||||
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
||||
final xOffset = 15 * math.sin(angle * 0.7);
|
||||
final yOffset = 8 * math.cos(angle * 0.7);
|
||||
|
||||
return Positioned(
|
||||
right: 40 + xOffset,
|
||||
bottom: -40 + yOffset,
|
||||
child: Transform.rotate(
|
||||
angle: 0.4 * math.cos(angle * 0.5),
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.08),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
// 숨쉬는 효과가 있는 작은 원
|
||||
AnimatedBuilder(
|
||||
animation: pulseController,
|
||||
builder: (context, child) {
|
||||
return Positioned(
|
||||
top: 10,
|
||||
left: 200,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(
|
||||
0.1 + 0.1 * pulseController.value,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
58
lib/widgets/category_header_widget.dart
Normal file
58
lib/widgets/category_header_widget.dart
Normal file
@@ -0,0 +1,58 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
/// 카테고리별 구독 그룹의 헤더 위젯
|
||||
///
|
||||
/// 카테고리 이름, 구독 개수, 총 비용을 표시합니다.
|
||||
/// 참고: 여러 통화 단위가 혼합된 경우 간단히 원화 표시 형식을 사용합니다.
|
||||
class CategoryHeaderWidget extends StatelessWidget {
|
||||
final String categoryName;
|
||||
final int subscriptionCount;
|
||||
final double totalCost;
|
||||
|
||||
const CategoryHeaderWidget({
|
||||
Key? key,
|
||||
required this.categoryName,
|
||||
required this.subscriptionCount,
|
||||
required this.totalCost,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
categoryName,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF374151),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${subscriptionCount}개 · ${NumberFormat.currency(locale: 'ko_KR', symbol: '₩', decimalDigits: 0).format(totalCost)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Color(0xFF6B7280),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 1,
|
||||
color: Color(0xFFEEEEEE),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
147
lib/widgets/empty_state_widget.dart
Normal file
147
lib/widgets/empty_state_widget.dart
Normal file
@@ -0,0 +1,147 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||
///
|
||||
/// 애니메이션 효과와 함께 사용자에게 구독 추가를 유도합니다.
|
||||
class EmptyStateWidget extends StatelessWidget {
|
||||
final AnimationController fadeController;
|
||||
final AnimationController rotateController;
|
||||
final AnimationController slideController;
|
||||
final VoidCallback onAddPressed;
|
||||
|
||||
const EmptyStateWidget({
|
||||
Key? key,
|
||||
required this.fadeController,
|
||||
required this.rotateController,
|
||||
required this.slideController,
|
||||
required this.onAddPressed,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Center(
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(CurvedAnimation(
|
||||
parent: slideController, curve: Curves.easeOutBack)),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(32),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.grey.withOpacity(0.08),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: rotateController,
|
||||
builder: (context, child) {
|
||||
return Transform.rotate(
|
||||
angle: rotateController.value * 2 * math.pi,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF3B82F6), Color(0xFF2563EB)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF3B82F6).withOpacity(0.3),
|
||||
spreadRadius: 0,
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.subscriptions_outlined,
|
||||
size: 48,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
ShaderMask(
|
||||
shaderCallback: (bounds) => const LinearGradient(
|
||||
colors: [Color(0xFF3B82F6), Color(0xFF0EA5E9)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
).createShader(bounds),
|
||||
child: const Text(
|
||||
'등록된 구독이 없습니다',
|
||||
style: TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
letterSpacing: -0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'새로운 구독을 추가해보세요',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
MouseRegion(
|
||||
onEnter: (_) => {},
|
||||
onExit: (_) => {},
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 32,
|
||||
vertical: 16,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
elevation: 4,
|
||||
backgroundColor: const Color(0xFF3B82F6),
|
||||
),
|
||||
onPressed: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
onAddPressed();
|
||||
},
|
||||
child: const Text(
|
||||
'구독 추가하기',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
153
lib/widgets/exchange_rate_widget.dart
Normal file
153
lib/widgets/exchange_rate_widget.dart
Normal file
@@ -0,0 +1,153 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/exchange_rate_service.dart';
|
||||
|
||||
/// 환율 정보를 표시하는 위젯
|
||||
/// 달러 금액을 입력받아 원화 금액으로 변환하여 표시합니다.
|
||||
class ExchangeRateWidget extends StatefulWidget {
|
||||
/// 달러 금액 변화 감지용 TextEditingController
|
||||
final TextEditingController costController;
|
||||
|
||||
/// 환율 정보를 보여줄지 여부 (통화가 달러일 때만 true)
|
||||
final bool showExchangeRate;
|
||||
|
||||
const ExchangeRateWidget({
|
||||
Key? key,
|
||||
required this.costController,
|
||||
required this.showExchangeRate,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<ExchangeRateWidget> createState() => _ExchangeRateWidgetState();
|
||||
}
|
||||
|
||||
class _ExchangeRateWidgetState extends State<ExchangeRateWidget> {
|
||||
final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||
String _exchangeRateInfo = '';
|
||||
String _convertedAmount = '';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadExchangeRate();
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
widget.costController.removeListener(_updateConvertedAmount);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ExchangeRateWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 통화 변경 감지(달러->원화 또는 원화->달러)되면 리스너 해제 및 재등록
|
||||
if (oldWidget.showExchangeRate != widget.showExchangeRate) {
|
||||
oldWidget.costController.removeListener(_updateConvertedAmount);
|
||||
|
||||
if (widget.showExchangeRate) {
|
||||
widget.costController.addListener(_updateConvertedAmount);
|
||||
_loadExchangeRate();
|
||||
_updateConvertedAmount();
|
||||
} else {
|
||||
setState(() {
|
||||
_exchangeRateInfo = '';
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 로드
|
||||
Future<void> _loadExchangeRate() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
final rateInfo = await _exchangeRateService.getFormattedExchangeRateInfo();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_exchangeRateInfo = rateInfo;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 달러 금액이 변경될 때 원화 금액 업데이트
|
||||
Future<void> _updateConvertedAmount() async {
|
||||
if (!widget.showExchangeRate) return;
|
||||
|
||||
try {
|
||||
// 금액 입력값에서 콤마 제거 후 숫자로 변환
|
||||
final text = widget.costController.text.replaceAll(',', '');
|
||||
if (text.isEmpty) {
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final amount = double.tryParse(text);
|
||||
if (amount != null) {
|
||||
final converted =
|
||||
await _exchangeRateService.getFormattedKrwAmount(amount);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_convertedAmount = converted;
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 오류 발생 시 빈 문자열 표시
|
||||
setState(() {
|
||||
_convertedAmount = '';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// 환율 정보 텍스트 위젯 생성
|
||||
Widget buildExchangeRateInfo() {
|
||||
if (_exchangeRateInfo.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_exchangeRateInfo,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 환산 금액 텍스트 위젯 생성
|
||||
Widget buildConvertedAmount() {
|
||||
if (_convertedAmount.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Text(
|
||||
_convertedAmount,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!widget.showExchangeRate) {
|
||||
return const SizedBox.shrink(); // 표시할 필요가 없으면 빈 위젯 반환
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// 익스포즈드 메서드: 환율 정보 문자열 가져오기
|
||||
String get exchangeRateInfo => _exchangeRateInfo;
|
||||
|
||||
// 익스포즈드 메서드: 변환된 금액 문자열 가져오기
|
||||
String get convertedAmount => _convertedAmount;
|
||||
}
|
||||
280
lib/widgets/main_summary_card.dart
Normal file
280
lib/widgets/main_summary_card.dart
Normal file
@@ -0,0 +1,280 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import '../utils/format_helper.dart';
|
||||
import 'animated_wave_background.dart';
|
||||
|
||||
/// 메인 화면 상단에 표시되는 요약 카드 위젯
|
||||
///
|
||||
/// 총 구독 수와 월별 총 지출을 표시하며, 분석 화면으로 이동하는 기능을 제공합니다.
|
||||
class MainScreenSummaryCard extends StatelessWidget {
|
||||
final SubscriptionProvider provider;
|
||||
final AnimationController fadeController;
|
||||
final AnimationController pulseController;
|
||||
final AnimationController waveController;
|
||||
final AnimationController slideController;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const MainScreenSummaryCard({
|
||||
Key? key,
|
||||
required this.provider,
|
||||
required this.fadeController,
|
||||
required this.pulseController,
|
||||
required this.waveController,
|
||||
required this.slideController,
|
||||
required this.onTap,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final double monthlyCost = provider.totalMonthlyExpense;
|
||||
final double yearlyCost = monthlyCost * 12;
|
||||
final int totalSubscriptions = provider.subscriptions.length;
|
||||
final double eventSavings = provider.totalEventSavings;
|
||||
final int activeEvents = provider.activeEventSubscriptions.length;
|
||||
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 4),
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
HapticFeedback.mediumImpact();
|
||||
onTap();
|
||||
},
|
||||
child: Card(
|
||||
elevation: 4,
|
||||
shadowColor: Colors.black12,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
constraints: BoxConstraints(
|
||||
minHeight: 180,
|
||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
AppColors.primaryColor,
|
||||
AppColors.primaryColor.withBlue(
|
||||
(AppColors.primaryColor.blue * 1.3)
|
||||
.clamp(0, 255)
|
||||
.toInt()),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: Stack(
|
||||
children: [
|
||||
// 애니메이션 웨이브 배경
|
||||
Positioned.fill(
|
||||
child: AnimatedWaveBackground(
|
||||
controller: waveController,
|
||||
pulseController: pulseController,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(24.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'이번 달 총 구독 비용',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(monthlyCost),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: -1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'원',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '연간 구독 비용',
|
||||
value: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '',
|
||||
decimalDigits: 0,
|
||||
).format(yearlyCost)}원',
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
_buildInfoBox(
|
||||
context,
|
||||
title: '총 구독 서비스',
|
||||
value: '$totalSubscriptions개',
|
||||
),
|
||||
],
|
||||
),
|
||||
// 이벤트 절약액 표시
|
||||
if (activeEvents > 0) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withOpacity(0.2),
|
||||
Colors.white.withOpacity(0.15),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: Colors.white.withOpacity(0.3),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.25),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'이벤트 할인 중',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.9),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(eventSavings),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
' 절약 ($activeEvents개)',
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
top: 16,
|
||||
child: Icon(
|
||||
Icons.arrow_forward_ios,
|
||||
color: Colors.white.withOpacity(0.7),
|
||||
size: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoBox(BuildContext context,
|
||||
{required String title, required String value}) {
|
||||
return Expanded(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
199
lib/widgets/native_ad_widget.dart
Normal file
199
lib/widgets/native_ad_widget.dart
Normal file
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
/// 구글 네이티브 광고 위젯 (AdMob NativeAd)
|
||||
/// SRP에 따라 광고 전용 위젯으로 분리
|
||||
class NativeAdWidget extends StatefulWidget {
|
||||
const NativeAdWidget({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<NativeAdWidget> createState() => _NativeAdWidgetState();
|
||||
}
|
||||
|
||||
class _NativeAdWidgetState extends State<NativeAdWidget> {
|
||||
NativeAd? _nativeAd;
|
||||
bool _isLoaded = false;
|
||||
String? _error;
|
||||
bool _isAdLoading = false; // 광고 로드 중복 방지 플래그
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// initState에서는 Theme.of(context)와 같은 InheritedWidget에 의존하지 않음
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// 위젯이 완전히 초기화된 후 광고 로드
|
||||
if (!_isAdLoading && !kIsWeb) {
|
||||
_loadAd();
|
||||
_isAdLoading = true; // 중복 로드 방지
|
||||
}
|
||||
}
|
||||
|
||||
/// 네이티브 광고 로드 함수
|
||||
void _loadAd() {
|
||||
// 웹 또는 Android/iOS가 아닌 경우 광고 로드 방지
|
||||
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
|
||||
return;
|
||||
}
|
||||
|
||||
_nativeAd = NativeAd(
|
||||
adUnitId: _testAdUnitId(), // 실제 배포 시 교체 필요
|
||||
factoryId: 'listTile', // Android/iOS 모두 동일하게 맞춰야 함
|
||||
request: const AdRequest(),
|
||||
listener: NativeAdListener(
|
||||
onAdLoaded: (ad) {
|
||||
setState(() {
|
||||
_isLoaded = true;
|
||||
});
|
||||
},
|
||||
onAdFailedToLoad: (ad, error) {
|
||||
ad.dispose();
|
||||
setState(() {
|
||||
_error = error.message;
|
||||
});
|
||||
},
|
||||
),
|
||||
)..load();
|
||||
}
|
||||
|
||||
/// 테스트 광고 단위 ID 반환 함수
|
||||
/// Theme.of(context)를 사용하지 않고 Platform 클래스 직접 사용
|
||||
String _testAdUnitId() {
|
||||
if (Platform.isAndroid) {
|
||||
// Android 테스트 네이티브 광고 ID
|
||||
return 'ca-app-pub-3940256099942544/2247696110';
|
||||
} else if (Platform.isIOS) {
|
||||
// iOS 테스트 네이티브 광고 ID
|
||||
return 'ca-app-pub-3940256099942544/3986624511';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nativeAd?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 웹용 광고 플레이스홀더 위젯
|
||||
Widget _buildWebPlaceholder() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: Container(
|
||||
height: 80,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Center(
|
||||
child: Icon(
|
||||
Icons.ad_units,
|
||||
color: Colors.grey,
|
||||
size: 32,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
height: 14,
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[300],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 10,
|
||||
width: 180,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[200],
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 60,
|
||||
height: 24,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue[100],
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'광고영역',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.blue,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 웹 환경인 경우 플레이스홀더 표시
|
||||
if (kIsWeb) {
|
||||
return _buildWebPlaceholder();
|
||||
}
|
||||
|
||||
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
|
||||
if (!(Platform.isAndroid || Platform.isIOS)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (_error != null) {
|
||||
// 광고 로드 실패 시 빈 공간 반환
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
if (!_isLoaded) {
|
||||
// 광고 로딩 중 로딩 인디케이터 표시
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 12),
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
);
|
||||
}
|
||||
|
||||
// 광고 정상 노출
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Card(
|
||||
elevation: 2,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: SizedBox(
|
||||
height: 80, // 네이티브 광고 높이 조정
|
||||
child: AdWidget(ad: _nativeAd!),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/widgets/skeleton_loading.dart
Normal file
93
lib/widgets/skeleton_loading.dart
Normal file
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SkeletonLoading extends StatelessWidget {
|
||||
const SkeletonLoading({Key? key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 요약 카드 스켈레톤
|
||||
Card(
|
||||
margin: const EdgeInsets.all(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_buildSkeletonColumn(),
|
||||
_buildSkeletonColumn(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// 구독 목록 스켈레톤
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: 5,
|
||||
itemBuilder: (context, index) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
title: Container(
|
||||
width: 200,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
subtitle: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: 150,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 180,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonColumn() {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 16,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Container(
|
||||
width: 100,
|
||||
height: 24,
|
||||
color: Colors.grey[300],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
618
lib/widgets/subscription_card.dart
Normal file
618
lib/widgets/subscription_card.dart
Normal file
@@ -0,0 +1,618 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'dart:math' as math;
|
||||
import '../models/subscription_model.dart';
|
||||
import '../screens/detail_screen.dart';
|
||||
import 'website_icon.dart';
|
||||
import '../theme/app_colors.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../providers/subscription_provider.dart';
|
||||
|
||||
class SubscriptionCard extends StatefulWidget {
|
||||
final SubscriptionModel subscription;
|
||||
|
||||
const SubscriptionCard({
|
||||
super.key,
|
||||
required this.subscription,
|
||||
});
|
||||
|
||||
@override
|
||||
State<SubscriptionCard> createState() => _SubscriptionCardState();
|
||||
}
|
||||
|
||||
class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _hoverController;
|
||||
bool _isHovering = false;
|
||||
final double _initialElevation = 1.0;
|
||||
final double _hoveredElevation = 3.0;
|
||||
late SubscriptionProvider _subscriptionProvider;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_hoverController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_subscriptionProvider =
|
||||
Provider.of<SubscriptionProvider>(context, listen: false);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_hoverController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onHover(bool isHovering) {
|
||||
setState(() {
|
||||
_isHovering = isHovering;
|
||||
if (isHovering) {
|
||||
_hoverController.forward();
|
||||
} else {
|
||||
_hoverController.reverse();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 다음 결제 예정일 정보를 생성
|
||||
String _getNextBillingText() {
|
||||
final now = DateTime.now();
|
||||
final nextBillingDate = widget.subscription.nextBillingDate;
|
||||
|
||||
// 날짜 비교를 위해 시간 제거 (날짜만 비교)
|
||||
final dateOnlyNow = DateTime(now.year, now.month, now.day);
|
||||
final dateOnlyBilling = DateTime(
|
||||
nextBillingDate.year, nextBillingDate.month, nextBillingDate.day);
|
||||
|
||||
// 오늘이 결제일인 경우
|
||||
if (dateOnlyNow.isAtSameMomentAs(dateOnlyBilling)) {
|
||||
return '오늘 결제 예정';
|
||||
}
|
||||
|
||||
// 미래 날짜인 경우 남은 일수 계산
|
||||
if (dateOnlyBilling.isAfter(dateOnlyNow)) {
|
||||
final difference = dateOnlyBilling.difference(dateOnlyNow).inDays;
|
||||
return '$difference일 후 결제 예정';
|
||||
}
|
||||
|
||||
// 과거 날짜인 경우, 다음 결제일 계산
|
||||
final billingCycle = widget.subscription.billingCycle;
|
||||
|
||||
// 월간 구독인 경우
|
||||
if (billingCycle == '월간') {
|
||||
// 결제일에 해당하는 날짜 가져오기
|
||||
int day = nextBillingDate.day;
|
||||
int nextMonth = now.month;
|
||||
int nextYear = now.year;
|
||||
|
||||
// 해당 월의 마지막 날짜 확인 (예: 31일이 없는 달)
|
||||
final lastDayOfMonth = DateTime(now.year, now.month + 1, 0).day;
|
||||
if (day > lastDayOfMonth) {
|
||||
day = lastDayOfMonth;
|
||||
}
|
||||
|
||||
// 결제일이 이번 달에서 이미 지났으면 다음 달로 설정
|
||||
if (now.day > day) {
|
||||
nextMonth++;
|
||||
if (nextMonth > 12) {
|
||||
nextMonth = 1;
|
||||
nextYear++;
|
||||
}
|
||||
|
||||
// 다음 달의 마지막 날짜 확인
|
||||
final lastDayOfNextMonth = DateTime(nextYear, nextMonth + 1, 0).day;
|
||||
if (day > lastDayOfNextMonth) {
|
||||
day = lastDayOfNextMonth;
|
||||
}
|
||||
}
|
||||
|
||||
final nextDate = DateTime(nextYear, nextMonth, day);
|
||||
final days = nextDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
}
|
||||
|
||||
// 연간 구독인 경우
|
||||
if (billingCycle == '연간') {
|
||||
// 결제일에 해당하는 날짜와 월 가져오기
|
||||
int day = nextBillingDate.day;
|
||||
int month = nextBillingDate.month;
|
||||
int year = now.year;
|
||||
|
||||
// 해당 월의 마지막 날짜 확인
|
||||
final lastDayOfMonth = DateTime(year, month + 1, 0).day;
|
||||
if (day > lastDayOfMonth) {
|
||||
day = lastDayOfMonth;
|
||||
}
|
||||
|
||||
// 올해의 결제일
|
||||
final thisYearDate = DateTime(year, month, day);
|
||||
|
||||
// 올해 결제일이 이미 지났으면 내년으로 계산
|
||||
if (thisYearDate.isBefore(dateOnlyNow) ||
|
||||
thisYearDate.isAtSameMomentAs(dateOnlyNow)) {
|
||||
year++;
|
||||
|
||||
// 내년 해당 월의 마지막 날짜 확인
|
||||
final lastDayOfNextYear = DateTime(year, month + 1, 0).day;
|
||||
if (day > lastDayOfNextYear) {
|
||||
day = lastDayOfNextYear;
|
||||
}
|
||||
|
||||
final nextYearDate = DateTime(year, month, day);
|
||||
final days = nextYearDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
} else {
|
||||
final days = thisYearDate.difference(dateOnlyNow).inDays;
|
||||
|
||||
if (days == 0) return '오늘 결제 예정';
|
||||
return '$days일 후 결제 예정';
|
||||
}
|
||||
}
|
||||
|
||||
// 주간 구독인 경우
|
||||
if (billingCycle == '주간') {
|
||||
// 결제 요일 가져오기
|
||||
final billingWeekday = nextBillingDate.weekday;
|
||||
// 현재 요일
|
||||
final currentWeekday = now.weekday;
|
||||
|
||||
// 다음 같은 요일까지 남은 일수 계산
|
||||
int daysUntilNext;
|
||||
if (currentWeekday < billingWeekday) {
|
||||
daysUntilNext = billingWeekday - currentWeekday;
|
||||
} else if (currentWeekday > billingWeekday) {
|
||||
daysUntilNext = 7 - (currentWeekday - billingWeekday);
|
||||
} else {
|
||||
// 같은 요일
|
||||
daysUntilNext = 7; // 다음 주 같은 요일
|
||||
}
|
||||
|
||||
if (daysUntilNext == 0) return '오늘 결제 예정';
|
||||
return '$daysUntilNext일 후 결제 예정';
|
||||
}
|
||||
|
||||
// 기본값 - 예상할 수 없는 경우
|
||||
return '결제일 정보 필요';
|
||||
}
|
||||
|
||||
// 결제일이 가까운지 확인 (7일 이내)
|
||||
bool _isNearBilling() {
|
||||
final text = _getNextBillingText();
|
||||
if (text == '오늘 결제 예정') return true;
|
||||
|
||||
final regex = RegExp(r'(\d+)일 후');
|
||||
final match = regex.firstMatch(text);
|
||||
if (match != null) {
|
||||
final days = int.parse(match.group(1) ?? '0');
|
||||
return days <= 7;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
Color _getCardColor() {
|
||||
return Colors.white;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isNearBilling = _isNearBilling();
|
||||
final Color cardColor = _getCardColor();
|
||||
|
||||
return Hero(
|
||||
tag: 'subscription_${widget.subscription.id}',
|
||||
child: MouseRegion(
|
||||
onEnter: (_) => _onHover(true),
|
||||
onExit: (_) => _onHover(false),
|
||||
child: AnimatedBuilder(
|
||||
animation: _hoverController,
|
||||
builder: (context, child) {
|
||||
final elevation = _initialElevation +
|
||||
(_hoveredElevation - _initialElevation) *
|
||||
_hoverController.value;
|
||||
|
||||
final scale = 1.0 + (0.02 * _hoverController.value);
|
||||
|
||||
return Transform.scale(
|
||||
scale: scale,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () async {
|
||||
final result = await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (context, animation, secondaryAnimation) =>
|
||||
DetailScreen(subscription: widget.subscription),
|
||||
transitionsBuilder:
|
||||
(context, animation, secondaryAnimation, child) {
|
||||
const begin = Offset(0.0, 0.05);
|
||||
const end = Offset.zero;
|
||||
const curve = Curves.easeOutCubic;
|
||||
|
||||
var tween = Tween(begin: begin, end: end)
|
||||
.chain(CurveTween(curve: curve));
|
||||
|
||||
var fadeAnimation =
|
||||
Tween<double>(begin: 0.6, end: 1.0)
|
||||
.chain(CurveTween(curve: curve))
|
||||
.animate(animation);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: fadeAnimation,
|
||||
child: SlideTransition(
|
||||
position: animation.drive(tween),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (result == true) {
|
||||
// 변경 사항이 있을 경우 미리 저장된 Provider 참조를 사용하여 구독 목록 갱신
|
||||
await _subscriptionProvider.refreshSubscriptions();
|
||||
|
||||
// 메인 화면의 State를 갱신하기 위해 미세한 지연 후 다시 한번 알림
|
||||
// mounted 상태를 확인하여 dispose된 위젯에서 Provider를 참조하지 않도록 합니다.
|
||||
Future.delayed(const Duration(milliseconds: 100), () {
|
||||
// 위젯이 아직 마운트 상태인지 확인하고, 미리 저장된 Provider 참조 사용
|
||||
if (mounted) {
|
||||
_subscriptionProvider.notifyListeners();
|
||||
}
|
||||
});
|
||||
}
|
||||
},
|
||||
splashColor: AppColors.primaryColor.withOpacity(0.1),
|
||||
highlightColor: AppColors.primaryColor.withOpacity(0.05),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: _isHovering
|
||||
? AppColors.primaryColor.withOpacity(0.3)
|
||||
: AppColors.borderColor,
|
||||
width: _isHovering ? 1.5 : 0.5,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryColor.withOpacity(
|
||||
0.03 + (0.05 * _hoverController.value)),
|
||||
blurRadius: 8 + (8 * _hoverController.value),
|
||||
spreadRadius: 0,
|
||||
offset: Offset(0, 4 + (2 * _hoverController.value)),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
// 그라데이션 상단 바 효과
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: widget.subscription.isCurrentlyInEvent
|
||||
? [
|
||||
const Color(0xFFFF6B6B),
|
||||
const Color(0xFFFF8787),
|
||||
]
|
||||
: isNearBilling
|
||||
? AppColors.amberGradient
|
||||
: AppColors.blueGradient,
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 서비스 아이콘
|
||||
WebsiteIcon(
|
||||
key: ValueKey(
|
||||
'subscription_icon_${widget.subscription.id}'),
|
||||
url: widget.subscription.websiteUrl,
|
||||
serviceName: widget.subscription.serviceName,
|
||||
size: 48,
|
||||
isHovered: _isHovering,
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
|
||||
// 서비스 정보
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 서비스명
|
||||
Flexible(
|
||||
child: Text(
|
||||
widget.subscription.serviceName,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 18,
|
||||
color: Color(0xFF1E293B),
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
|
||||
// 배지들
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// 이벤트 배지
|
||||
if (widget.subscription.isCurrentlyInEvent) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFFF6B6B),
|
||||
const Color(0xFFFF8787),
|
||||
],
|
||||
),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.local_offer_rounded,
|
||||
size: 11,
|
||||
color: Colors.white,
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
'이벤트',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
|
||||
// 결제 주기 배지
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
widget.subscription.billingCycle,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
// 가격 정보
|
||||
Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// 가격 표시 (이벤트 가격 반영)
|
||||
Row(
|
||||
children: [
|
||||
// 이벤트 중인 경우 원래 가격을 취소선으로 표시
|
||||
if (widget.subscription.isCurrentlyInEvent) ...[
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget
|
||||
.subscription.monthlyCost)
|
||||
: NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget
|
||||
.subscription.monthlyCost),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
decoration: TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
// 현재 가격 (이벤트 또는 정상 가격)
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget
|
||||
.subscription.currentPrice)
|
||||
: NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget
|
||||
.subscription.currentPrice),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.subscription.isCurrentlyInEvent
|
||||
? const Color(0xFFFF6B6B)
|
||||
: AppColors.primaryColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 결제 예정일 정보
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
.withOpacity(0.1)
|
||||
: AppColors.successColor
|
||||
.withOpacity(0.1),
|
||||
borderRadius:
|
||||
BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
isNearBilling
|
||||
? Icons
|
||||
.access_time_filled_rounded
|
||||
: Icons
|
||||
.check_circle_rounded,
|
||||
size: 12,
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
: AppColors.successColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
_getNextBillingText(),
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isNearBilling
|
||||
? AppColors.warningColor
|
||||
: AppColors.successColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// 이벤트 절약액 표시
|
||||
if (widget.subscription.isCurrentlyInEvent &&
|
||||
widget.subscription.eventSavings > 0) ...[
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFF6B6B).withOpacity(0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.savings_rounded,
|
||||
size: 14,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
widget.subscription.currency == 'USD'
|
||||
? '${NumberFormat.currency(
|
||||
locale: 'en_US',
|
||||
symbol: '\$',
|
||||
decimalDigits: 2,
|
||||
).format(widget.subscription.eventSavings)} 절약'
|
||||
: '${NumberFormat.currency(
|
||||
locale: 'ko_KR',
|
||||
symbol: '₩',
|
||||
decimalDigits: 0,
|
||||
).format(widget.subscription.eventSavings)} 절약',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFFFF6B6B),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
// 이벤트 종료일까지 남은 일수
|
||||
if (widget.subscription.eventEndDate != null) ...[
|
||||
Text(
|
||||
'${widget.subscription.eventEndDate!.difference(DateTime.now()).inDays}일 남음',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
117
lib/widgets/subscription_list_widget.dart
Normal file
117
lib/widgets/subscription_list_widget.dart
Normal file
@@ -0,0 +1,117 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../models/subscription_model.dart';
|
||||
import '../widgets/subscription_card.dart';
|
||||
import '../widgets/category_header_widget.dart';
|
||||
|
||||
/// 카테고리별로 구독 목록을 표시하는 위젯
|
||||
class SubscriptionListWidget extends StatelessWidget {
|
||||
final Map<String, List<SubscriptionModel>> categorizedSubscriptions;
|
||||
final AnimationController fadeController;
|
||||
|
||||
const SubscriptionListWidget({
|
||||
Key? key,
|
||||
required this.categorizedSubscriptions,
|
||||
required this.fadeController,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 카테고리 키 목록 (정렬된)
|
||||
final categories = categorizedSubscriptions.keys.toList();
|
||||
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
final category = categories[index];
|
||||
final subscriptions = categorizedSubscriptions[category]!;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 카테고리 헤더
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
||||
child: CategoryHeaderWidget(
|
||||
categoryName: category,
|
||||
subscriptionCount: subscriptions.length,
|
||||
totalCost: subscriptions.fold(
|
||||
0.0,
|
||||
(sum, sub) => sum + sub.monthlyCost,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 카테고리별 구독 목록
|
||||
FadeTransition(
|
||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(
|
||||
parent: fadeController, curve: Curves.easeIn)),
|
||||
child: ListView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: subscriptions.length,
|
||||
itemBuilder: (context, subIndex) {
|
||||
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
|
||||
final delay = 0.05 * subIndex;
|
||||
final animationBegin = 0.2;
|
||||
final animationEnd = 1.0;
|
||||
final intervalStart = delay;
|
||||
final intervalEnd = intervalStart + 0.4;
|
||||
|
||||
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
|
||||
final intervalStartNormalized =
|
||||
intervalStart.clamp(0.0, 0.9);
|
||||
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
|
||||
|
||||
return FadeTransition(
|
||||
opacity: Tween<double>(
|
||||
begin: animationBegin, end: animationEnd)
|
||||
.animate(CurvedAnimation(
|
||||
parent: fadeController,
|
||||
curve: Interval(intervalStartNormalized,
|
||||
intervalEndNormalized,
|
||||
curve: Curves.easeOut))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12.0),
|
||||
child: SubscriptionCard(
|
||||
subscription: subscriptions[subIndex],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: categories.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 여러 Sliver 위젯을 하나의 위젯으로 감싸는 도우미 위젯
|
||||
class MultiSliver extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const MultiSliver({
|
||||
Key? key,
|
||||
required this.children,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= children.length) return null;
|
||||
return children[index];
|
||||
},
|
||||
childCount: children.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
748
lib/widgets/website_icon.dart
Normal file
748
lib/widgets/website_icon.dart
Normal file
@@ -0,0 +1,748 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
import 'package:html/parser.dart' as html_parser;
|
||||
import 'package:html/dom.dart' as html_dom;
|
||||
import '../theme/app_colors.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:crypto/crypto.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
|
||||
// 파비콘 캐시 관리 클래스
|
||||
class FaviconCache {
|
||||
// 메모리 캐시 (앱 전체에서 공유)
|
||||
static final Map<String, String> _memoryCache = {};
|
||||
|
||||
// 로딩 상태 추적 (동시에 동일한 URL에 대한 중복 요청 방지)
|
||||
static final Set<String> _loadingKeys = {};
|
||||
|
||||
// 메모리 캐시에서 파비콘 URL 가져오기
|
||||
static String? getFromMemory(String serviceKey) {
|
||||
return _memoryCache[serviceKey];
|
||||
}
|
||||
|
||||
// 메모리 캐시에 파비콘 URL 저장
|
||||
static void saveToMemory(String serviceKey, String logoUrl) {
|
||||
_memoryCache[serviceKey] = logoUrl;
|
||||
// 로딩 완료 표시
|
||||
_loadingKeys.remove(serviceKey);
|
||||
}
|
||||
|
||||
// 로딩 시작 표시 (중복 요청 방지용)
|
||||
static bool markAsLoading(String serviceKey) {
|
||||
if (_loadingKeys.contains(serviceKey)) {
|
||||
// 이미 로딩 중이면 false 반환
|
||||
return false;
|
||||
}
|
||||
_loadingKeys.add(serviceKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 로딩 취소 표시
|
||||
static void cancelLoading(String serviceKey) {
|
||||
_loadingKeys.remove(serviceKey);
|
||||
}
|
||||
|
||||
// SharedPreferences에서 파비콘 URL 로드
|
||||
static Future<String?> getFromPrefs(String serviceKey) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 로드 오류: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// SharedPreferences에 파비콘 URL 저장
|
||||
static Future<void> saveToPrefs(String serviceKey, String logoUrl) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('favicon_$serviceKey', logoUrl);
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 저장 오류: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 파비콘 캐시 삭제
|
||||
static Future<void> remove(String serviceKey) async {
|
||||
_memoryCache.remove(serviceKey);
|
||||
_loadingKeys.remove(serviceKey);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.remove('favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('파비콘 캐시 삭제 오류: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// 앱에서 로컬 파비콘 파일 경로 가져오기
|
||||
static Future<String?> getLocalFaviconPath(String serviceKey) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString('local_favicon_$serviceKey');
|
||||
} catch (e) {
|
||||
print('로컬 파비콘 경로 로드 오류: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 앱에서 로컬 파비콘 파일 경로 저장
|
||||
static Future<void> saveLocalFaviconPath(
|
||||
String serviceKey, String filePath) async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('local_favicon_$serviceKey', filePath);
|
||||
} catch (e) {
|
||||
print('로컬 파비콘 경로 저장 오류: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 구글 파비콘 API 서비스
|
||||
class GoogleFaviconService {
|
||||
// CORS 프록시 서버 목록
|
||||
static final List<String> _corsProxies = [
|
||||
'https://corsproxy.io/?',
|
||||
'https://api.allorigins.win/raw?url=',
|
||||
'https://cors-anywhere.herokuapp.com/',
|
||||
];
|
||||
|
||||
// 현재 사용 중인 프록시 인덱스
|
||||
static int _currentProxyIndex = 0;
|
||||
|
||||
// 프록시를 사용하여 URL 생성
|
||||
static String _getProxiedUrl(String url) {
|
||||
// 앱 환경에서는 프록시 없이 직접 URL 반환
|
||||
if (!kIsWeb) {
|
||||
return url;
|
||||
}
|
||||
|
||||
// 웹 환경에서는 CORS 프록시 사용
|
||||
final proxy = _corsProxies[_currentProxyIndex];
|
||||
_currentProxyIndex =
|
||||
(_currentProxyIndex + 1) % _corsProxies.length; // 다음 요청은 다른 프록시 사용
|
||||
|
||||
// URL 인코딩
|
||||
final encodedUrl = Uri.encodeComponent(url);
|
||||
return '$proxy$encodedUrl';
|
||||
}
|
||||
|
||||
// 구글 파비콘 API URL 생성
|
||||
static String getFaviconUrl(String domain, int size) {
|
||||
final directUrl =
|
||||
'https://t1.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://$domain&size=$size';
|
||||
return directUrl;
|
||||
}
|
||||
|
||||
// DuckDuckGo 파비콘 API URL 생성 (CORS 친화적)
|
||||
static String getDuckDuckGoFaviconUrl(String domain) {
|
||||
return 'https://icons.duckduckgo.com/ip3/$domain.ico';
|
||||
}
|
||||
|
||||
// 웹 환경용 이미지 URL 생성 (다양한 파비콘 서비스 시도)
|
||||
static String getWebFaviconUrl(String domain, int size) {
|
||||
// 다양한 파비콘 서비스 URL 목록
|
||||
final List<String> faviconServices = [
|
||||
// DuckDuckGo의 파비콘 서비스 (CORS 친화적)
|
||||
'https://icons.duckduckgo.com/ip3/$domain.ico',
|
||||
|
||||
// Google의 S2 파비콘 서비스
|
||||
'https://www.google.com/s2/favicons?domain=$domain&sz=$size',
|
||||
];
|
||||
|
||||
// 첫 번째 서비스 사용 (DuckDuckGo 파비콘)
|
||||
return faviconServices[0];
|
||||
}
|
||||
|
||||
// Base64로 인코딩된 기본 파비콘 (웹 환경 CORS 문제 완전 우회용)
|
||||
static String getBase64PlaceholderIcon(String serviceName, Color color) {
|
||||
// 간단한 SVG 생성 (서비스 이름의 첫 글자를 원 안에 표시)
|
||||
final initial = serviceName.isNotEmpty ? serviceName[0].toUpperCase() : '?';
|
||||
final colorHex = color.value.toRadixString(16).padLeft(8, '0').substring(2);
|
||||
|
||||
// 공백 없이 SVG 생성 (공백이 있으면 Base64 디코딩 후 이미지 로드 시 문제 발생)
|
||||
final svgContent =
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">'
|
||||
'<rect width="64" height="64" rx="12" fill="#$colorHex"/>'
|
||||
'<text x="32" y="40" font-family="Arial, sans-serif" font-size="32" font-weight="bold" text-anchor="middle" fill="white">$initial</text>'
|
||||
'</svg>';
|
||||
|
||||
// SVG를 Base64로 인코딩
|
||||
final base64 = base64Encode(utf8.encode(svgContent));
|
||||
return 'data:image/svg+xml;base64,$base64';
|
||||
}
|
||||
}
|
||||
|
||||
class WebsiteIcon extends StatefulWidget {
|
||||
final String? url;
|
||||
final String serviceName;
|
||||
final double size;
|
||||
final bool isHovered;
|
||||
|
||||
const WebsiteIcon({
|
||||
super.key,
|
||||
this.url,
|
||||
required this.serviceName,
|
||||
required this.size,
|
||||
this.isHovered = false,
|
||||
});
|
||||
|
||||
@override
|
||||
State<WebsiteIcon> createState() => _WebsiteIconState();
|
||||
}
|
||||
|
||||
class _WebsiteIconState extends State<WebsiteIcon>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String? _logoUrl; // 웹에서 사용할 로고 URL
|
||||
String? _localLogoPath; // 앱에서 사용할 로컬 파일 경로
|
||||
bool _isLoading = true;
|
||||
late AnimationController _animationController;
|
||||
late Animation<double> _scaleAnimation;
|
||||
late Animation<double> _opacityAnimation;
|
||||
// 각 인스턴스에 대한 고유 식별자 추가
|
||||
final String _uniqueId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
// 서비스와 URL 조합으로 캐시 키 생성
|
||||
String get _serviceKey => '${widget.serviceName}_${widget.url ?? ''}';
|
||||
// 이전에 사용한 서비스 키 (URL이 변경됐는지 확인용)
|
||||
String? _previousServiceKey;
|
||||
// 로드 시작된 시점
|
||||
DateTime? _loadStartTime;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
// 애니메이션 컨트롤러 초기화
|
||||
_animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
|
||||
CurvedAnimation(
|
||||
parent: _animationController, curve: Curves.easeOutCubic));
|
||||
|
||||
_opacityAnimation = Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||
CurvedAnimation(parent: _animationController, curve: Curves.easeOut));
|
||||
|
||||
// 초기 _previousServiceKey 설정
|
||||
_previousServiceKey = _serviceKey;
|
||||
|
||||
// 로드 시작 시간 기록
|
||||
_loadStartTime = DateTime.now();
|
||||
|
||||
// 최초 로딩
|
||||
_loadFaviconWithCache();
|
||||
}
|
||||
|
||||
// 캐시를 활용해 파비콘 로드
|
||||
Future<void> _loadFaviconWithCache() async {
|
||||
setState(() {
|
||||
_isLoading = true;
|
||||
});
|
||||
|
||||
if (kIsWeb) {
|
||||
// 웹 환경: 메모리 캐시 확인 후 Google API 사용
|
||||
String? cachedLogo = FaviconCache.getFromMemory(_serviceKey);
|
||||
if (cachedLogo != null) {
|
||||
setState(() {
|
||||
_logoUrl = cachedLogo;
|
||||
_isLoading = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 이미 로딩 중인지 확인
|
||||
if (!FaviconCache.markAsLoading(_serviceKey)) {
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
cachedLogo = FaviconCache.getFromMemory(_serviceKey);
|
||||
if (cachedLogo != null) {
|
||||
setState(() {
|
||||
_logoUrl = cachedLogo;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
_fetchLogoForWeb();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SharedPreferences 확인
|
||||
cachedLogo = await FaviconCache.getFromPrefs(_serviceKey);
|
||||
if (cachedLogo != null) {
|
||||
FaviconCache.saveToMemory(_serviceKey, cachedLogo);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logoUrl = cachedLogo;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 웹용 로고 가져오기
|
||||
_fetchLogoForWeb();
|
||||
} else {
|
||||
// 앱 환경: 로컬 파일 확인 후 다운로드
|
||||
|
||||
// 1. 로컬 파일 경로 확인
|
||||
String? localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
|
||||
if (localPath != null) {
|
||||
final file = File(localPath);
|
||||
if (await file.exists()) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localLogoPath = localPath;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 이미 로딩 중인지 확인
|
||||
if (!FaviconCache.markAsLoading(_serviceKey)) {
|
||||
await Future.delayed(Duration(milliseconds: 500));
|
||||
localPath = await FaviconCache.getLocalFaviconPath(_serviceKey);
|
||||
if (localPath != null) {
|
||||
final file = File(localPath);
|
||||
if (await file.exists()) {
|
||||
setState(() {
|
||||
_localLogoPath = localPath;
|
||||
_isLoading = false;
|
||||
});
|
||||
} else {
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
_fetchLogoForApp();
|
||||
}
|
||||
} else {
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
_fetchLogoForApp();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// 3. 앱용 로고 다운로드
|
||||
_fetchLogoForApp();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(WebsiteIcon oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// 서비스명이나 URL이 변경된 경우에만 다시 로드
|
||||
final currentServiceKey = _serviceKey;
|
||||
if (_previousServiceKey != currentServiceKey) {
|
||||
print('서비스 키 변경 감지: $_previousServiceKey -> $currentServiceKey');
|
||||
_previousServiceKey = currentServiceKey;
|
||||
|
||||
// 로드 시작 시간 기록
|
||||
_loadStartTime = DateTime.now();
|
||||
|
||||
// 변경된 서비스 정보로 파비콘 로드
|
||||
_loadFaviconWithCache();
|
||||
}
|
||||
|
||||
// 호버 상태 변경 처리
|
||||
if (widget.isHovered != oldWidget.isHovered) {
|
||||
if (widget.isHovered) {
|
||||
_animationController.forward();
|
||||
} else {
|
||||
_animationController.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
// 서비스 이름에서 초기 문자 추출
|
||||
String _getInitials() {
|
||||
if (widget.serviceName.isEmpty) return '?';
|
||||
|
||||
final words = widget.serviceName.split(' ');
|
||||
if (words.length == 1) {
|
||||
return words[0][0].toUpperCase();
|
||||
} else if (words.length > 1) {
|
||||
return (words[0][0] + words[1][0]).toUpperCase();
|
||||
} else {
|
||||
return widget.serviceName[0].toUpperCase();
|
||||
}
|
||||
}
|
||||
|
||||
// 서비스 이름을 기반으로 색상 선택
|
||||
Color _getColorFromName() {
|
||||
final int hash = widget.serviceName.hashCode.abs();
|
||||
final List<Color> colors = [
|
||||
AppColors.primaryColor,
|
||||
AppColors.successColor,
|
||||
AppColors.infoColor,
|
||||
AppColors.warningColor,
|
||||
AppColors.dangerColor,
|
||||
];
|
||||
|
||||
return colors[hash % colors.length];
|
||||
}
|
||||
|
||||
// 도메인 추출 메서드
|
||||
String? _extractDomain() {
|
||||
if (widget.url == null || widget.url!.isEmpty) return null;
|
||||
|
||||
// URL 형식 처리 개선
|
||||
String processedUrl = widget.url!;
|
||||
|
||||
// URL에 http:// 또는 https:// 접두사가 없는 경우 추가
|
||||
if (!processedUrl.startsWith('http://') &&
|
||||
!processedUrl.startsWith('https://')) {
|
||||
processedUrl = 'https://$processedUrl';
|
||||
}
|
||||
|
||||
try {
|
||||
final uri = Uri.parse(processedUrl);
|
||||
if (uri.host.isEmpty) return processedUrl; // 파싱 실패 시 원본 URL 반환
|
||||
return uri.host;
|
||||
} catch (e) {
|
||||
// URL 파싱 실패 시 도메인만 있는 경우를 처리
|
||||
if (processedUrl.contains('.')) {
|
||||
// 간단한 도메인 형식 검사 (예: netflix.com)
|
||||
final domainPattern = RegExp(
|
||||
r'^(https?:\/\/)?(www\.)?([a-zA-Z0-9-]+(\.[a-zA-Z0-9-]+)+)',
|
||||
);
|
||||
final match = domainPattern.firstMatch(processedUrl);
|
||||
if (match != null && match.group(3) != null) {
|
||||
return match.group(3);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 웹 환경용 파비콘 가져오기
|
||||
Future<void> _fetchLogoForWeb() async {
|
||||
try {
|
||||
final domain = _extractDomain();
|
||||
if (domain == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 시작 시간 기록
|
||||
final loadStartTime = DateTime.now();
|
||||
|
||||
// 1. DuckDuckGo Favicon API 시도 (CORS 친화적)
|
||||
final ddgFaviconUrl =
|
||||
GoogleFaviconService.getDuckDuckGoFaviconUrl(domain);
|
||||
|
||||
try {
|
||||
// 이미지 존재 여부 확인을 위한 HEAD 요청
|
||||
final response = await http.head(Uri.parse(ddgFaviconUrl));
|
||||
|
||||
if (response.statusCode == 200) {
|
||||
// DuckDuckGo로부터 파비콘을 성공적으로 가져옴
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logoUrl = ddgFaviconUrl;
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
|
||||
// 캐시에 저장
|
||||
FaviconCache.saveToMemory(_serviceKey, ddgFaviconUrl);
|
||||
FaviconCache.saveToPrefs(_serviceKey, ddgFaviconUrl);
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
print('DuckDuckGo 파비콘 API 요청 실패: $e');
|
||||
// 실패 시 백업 방법으로 진행
|
||||
}
|
||||
|
||||
// 2. DuckDuckGo API가 실패하면 Base64 인코딩된 SVG 이미지를 로고로 사용 (CORS 회피)
|
||||
final color = _getColorFromName();
|
||||
final base64Logo = GoogleFaviconService.getBase64PlaceholderIcon(
|
||||
widget.serviceName, color);
|
||||
|
||||
// 최소 로딩 시간 보장 (깜박임 방지)
|
||||
final processingTime =
|
||||
DateTime.now().difference(loadStartTime).inMilliseconds;
|
||||
if (processingTime < 300) {
|
||||
await Future.delayed(Duration(milliseconds: 300 - processingTime));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_logoUrl = base64Logo;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 캐시에 저장
|
||||
FaviconCache.saveToMemory(_serviceKey, base64Logo);
|
||||
FaviconCache.saveToPrefs(_serviceKey, base64Logo);
|
||||
}
|
||||
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
} catch (e) {
|
||||
print('웹용 파비콘 가져오기 오류: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
}
|
||||
}
|
||||
|
||||
// 앱 환경용 파비콘 다운로드 및 로컬 저장
|
||||
Future<void> _fetchLogoForApp() async {
|
||||
try {
|
||||
final domain = _extractDomain();
|
||||
if (domain == null) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
return;
|
||||
}
|
||||
|
||||
// 로딩 시작 시간 기록
|
||||
final loadStartTime = DateTime.now();
|
||||
|
||||
// 1. Google API를 통해 파비콘 URL 생성
|
||||
final faviconUrl =
|
||||
GoogleFaviconService.getFaviconUrl(domain, widget.size.toInt() * 2);
|
||||
|
||||
// 2. http.get()으로 이미지 다운로드
|
||||
final response = await http.get(Uri.parse(faviconUrl));
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('파비콘 다운로드 실패: ${response.statusCode}');
|
||||
}
|
||||
|
||||
// 3. Uint8List로 변환
|
||||
final Uint8List imageBytes = response.bodyBytes;
|
||||
|
||||
// 4. 고유한 파일명 생성 (서비스명 + URL의 해시)
|
||||
final String hash =
|
||||
md5.convert(utf8.encode(_serviceKey)).toString().substring(0, 8);
|
||||
final String fileName =
|
||||
'favicon_${widget.serviceName.replaceAll(' ', '_')}_$hash.png';
|
||||
|
||||
// 5. 임시 디렉토리 가져오기
|
||||
final appDir = await getApplicationDocumentsDirectory();
|
||||
final faviconDir = Directory('${appDir.path}/favicons');
|
||||
if (!await faviconDir.exists()) {
|
||||
await faviconDir.create(recursive: true);
|
||||
}
|
||||
|
||||
// 6. PNG 파일로 저장
|
||||
final File file = File('${faviconDir.path}/$fileName');
|
||||
await file.writeAsBytes(imageBytes);
|
||||
final String localFilePath = file.path;
|
||||
|
||||
// 최소 로딩 시간 보장 (깜박임 방지)
|
||||
final processingTime =
|
||||
DateTime.now().difference(loadStartTime).inMilliseconds;
|
||||
if (processingTime < 300) {
|
||||
await Future.delayed(Duration(milliseconds: 300 - processingTime));
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_localLogoPath = localFilePath;
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
// 로컬 파일 경로 캐시에 저장
|
||||
FaviconCache.saveLocalFaviconPath(_serviceKey, localFilePath);
|
||||
}
|
||||
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
} catch (e) {
|
||||
print('앱용 파비콘 다운로드 오류: $e');
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoading = false;
|
||||
});
|
||||
}
|
||||
FaviconCache.cancelLoading(_serviceKey);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _animationController,
|
||||
builder: (context, child) {
|
||||
return Transform.scale(
|
||||
scale: _scaleAnimation.value,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
key: ValueKey(
|
||||
'icon_container_${widget.serviceName}_${widget.url ?? ''}_$_uniqueId'),
|
||||
duration: const Duration(milliseconds: 200),
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
boxShadow: widget.isHovered
|
||||
? [
|
||||
BoxShadow(
|
||||
color: _getColorFromName().withAlpha(76), // 약 0.3 알파값
|
||||
blurRadius: 12,
|
||||
spreadRadius: 0,
|
||||
offset: const Offset(0, 4),
|
||||
)
|
||||
]
|
||||
: null,
|
||||
),
|
||||
child: _buildIconContent(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconContent() {
|
||||
// 로딩 중 표시
|
||||
if (_isLoading) {
|
||||
return Container(
|
||||
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
border: Border.all(
|
||||
color: AppColors.borderColor,
|
||||
width: 0.5,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: widget.size * 0.4,
|
||||
height: widget.size * 0.4,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primaryColor.withAlpha(179)),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (kIsWeb) {
|
||||
// 웹 환경: 파비콘 API에서 가져온 이미지 또는 Base64 인코딩된 이미지 사용
|
||||
if (_logoUrl != null) {
|
||||
if (_logoUrl!.startsWith('data:image/svg+xml;base64')) {
|
||||
// Base64 인코딩된 SVG 이미지인 경우
|
||||
return ClipRRect(
|
||||
key: ValueKey('web_svg_logo_${_logoUrl!.hashCode}'),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
child: SvgPicture.string(
|
||||
utf8.decode(base64.decode(_logoUrl!.split(',')[1])),
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// DuckDuckGo나 다른 파비콘 서비스에서 가져온 URL인 경우
|
||||
return ClipRRect(
|
||||
key: ValueKey('web_url_logo_${_logoUrl!.hashCode}'),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
child: CachedNetworkImage(
|
||||
imageUrl: _logoUrl!,
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (context, url) => Container(
|
||||
color: AppColors.surfaceColorAlt,
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: widget.size * 0.4,
|
||||
height: widget.size * 0.4,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
AppColors.primaryColor.withAlpha(179)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
errorWidget: (context, url, error) => _buildFallbackIcon(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
return _buildFallbackIcon();
|
||||
} else {
|
||||
// 앱 환경: 로컬 파일 표시
|
||||
if (_localLogoPath == null) {
|
||||
return _buildFallbackIcon();
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
key: ValueKey('local_logo_${_localLogoPath}'),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
child: Image.file(
|
||||
File(_localLogoPath!),
|
||||
width: widget.size,
|
||||
height: widget.size,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
return _buildFallbackIcon();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFallbackIcon() {
|
||||
final color = _getColorFromName();
|
||||
|
||||
return Container(
|
||||
key: ValueKey('fallback_${widget.serviceName}_$_uniqueId'),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
color,
|
||||
color.withAlpha(204), // 약 0.8 알파값
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(widget.size * 0.2),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getInitials(),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: widget.size * 0.4,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user