Compare commits
7 Commits
codex/sms-
...
codex/perf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cd3b9720f | ||
|
|
5a7ef8039e | ||
|
|
10069a1800 | ||
|
|
b034f60510 | ||
|
|
eb6691ce6a | ||
|
|
10491af55b | ||
|
|
4673aed281 |
@@ -13,6 +13,7 @@ Guardrails
|
|||||||
- Safety: avoid destructive actions (file deletions, rewrites, config changes) unless explicitly requested.
|
- Safety: avoid destructive actions (file deletions, rewrites, config changes) unless explicitly requested.
|
||||||
- Responses: be concise; code first, short rationale after. If uncertain, prefix with "Uncertain:". If multiple viable solutions, show the top 2 briefly.
|
- Responses: be concise; code first, short rationale after. If uncertain, prefix with "Uncertain:". If multiple viable solutions, show the top 2 briefly.
|
||||||
- Planning: for multi‑step tasks, maintain an update_plan with exactly one in_progress step.
|
- Planning: for multi‑step tasks, maintain an update_plan with exactly one in_progress step.
|
||||||
|
- Language: 기본적으로 한국어로 응답합니다. (필요 시 코드/로그/명령어는 원문 유지)
|
||||||
|
|
||||||
Coding Standards
|
Coding Standards
|
||||||
- Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline).
|
- Language: Dart/Flutter (SDK >= 3.0). Respect `analysis_options.yaml` (flutter_lints baseline).
|
||||||
@@ -66,4 +67,3 @@ References & External Facts
|
|||||||
Notes from ~/.claude (adapted)
|
Notes from ~/.claude (adapted)
|
||||||
- Few‑shot examples improve accuracy; include small before/after or sample input→output when helpful.
|
- Few‑shot examples improve accuracy; include small before/after or sample input→output when helpful.
|
||||||
- Use structured thinking internally; present only concise, actionable outputs here.
|
- Use structured thinking internally; present only concise, actionable outputs here.
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../services/sms_service.dart';
|
|||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
import '../routes/app_routes.dart';
|
import '../routes/app_routes.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
class SplashScreen extends StatefulWidget {
|
class SplashScreen extends StatefulWidget {
|
||||||
const SplashScreen({super.key});
|
const SplashScreen({super.key});
|
||||||
@@ -65,7 +66,8 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
));
|
));
|
||||||
|
|
||||||
// 랜덤 파티클 생성
|
// 랜덤 파티클 생성
|
||||||
_generateParticles();
|
// 접근성(모션 축소) 고려한 파티클 생성
|
||||||
|
_generateParticles(reduced: ReduceMotion.platform());
|
||||||
|
|
||||||
_animationController.forward();
|
_animationController.forward();
|
||||||
|
|
||||||
@@ -75,15 +77,17 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void _generateParticles() {
|
void _generateParticles({bool reduced = false}) {
|
||||||
final random = DateTime.now().millisecondsSinceEpoch;
|
final random = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final total = reduced ? 6 : 20;
|
||||||
|
|
||||||
for (int i = 0; i < 20; i++) {
|
for (int i = 0; i < total; i++) {
|
||||||
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
|
final size = (random % 10) / 10 * 8 + 2; // 2-10 사이의 크기
|
||||||
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
|
final x = (random % 100) / 100 * 300; // 랜덤 X 위치
|
||||||
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
|
final y = (random % 100) / 100 * 500; // 랜덤 Y 위치
|
||||||
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
|
final opacity = (random % 10) / 10 * 0.4 + 0.1; // 0.1-0.5 사이의 투명도
|
||||||
final duration = (random % 10) / 10 * 3000 + 2000; // 2-5초 사이의 지속시간
|
final duration = (random % 10) / 10 * (reduced ? 1800 : 3000) +
|
||||||
|
(reduced ? 1200 : 2000); // 축소 시 더 짧게
|
||||||
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
final delay = (random % 10) / 10 * 2000; // 0-2초 사이의 지연시간
|
||||||
|
|
||||||
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
int colorIndex = (random + i) % AppColors.blueGradient.length;
|
||||||
@@ -257,7 +261,14 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
BorderRadius.circular(30),
|
BorderRadius.circular(30),
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(
|
filter: ImageFilter.blur(
|
||||||
sigmaX: 20, sigmaY: 20),
|
sigmaX: ReduceMotion.scale(
|
||||||
|
context,
|
||||||
|
normal: 20,
|
||||||
|
reduced: 8),
|
||||||
|
sigmaY: ReduceMotion.scale(
|
||||||
|
context,
|
||||||
|
normal: 20,
|
||||||
|
reduced: 8)),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
@@ -277,13 +288,17 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
.withValues(alpha: 0.3),
|
.withValues(alpha: 0.3),
|
||||||
width: 1.5,
|
width: 1.5,
|
||||||
),
|
),
|
||||||
boxShadow: const [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color:
|
color:
|
||||||
AppColors.shadowBlack,
|
AppColors.shadowBlack,
|
||||||
spreadRadius: 0,
|
spreadRadius: 0,
|
||||||
blurRadius: 30,
|
blurRadius:
|
||||||
offset: Offset(0, 10),
|
ReduceMotion.scale(
|
||||||
|
context,
|
||||||
|
normal: 30,
|
||||||
|
reduced: 12),
|
||||||
|
offset: const Offset(0, 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
97
lib/services/cache_manager.dart
Normal file
97
lib/services/cache_manager.dart
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
class _CacheEntry<T> {
|
||||||
|
final T value;
|
||||||
|
final DateTime expiresAt;
|
||||||
|
final int size;
|
||||||
|
|
||||||
|
_CacheEntry(
|
||||||
|
{required this.value, required this.expiresAt, required this.size});
|
||||||
|
|
||||||
|
bool get isExpired => DateTime.now().isAfter(expiresAt);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 간단한 메모리 기반 캐시 매니저 (TTL/최대 개수/용량 제한)
|
||||||
|
class SimpleCacheManager<T> {
|
||||||
|
final int maxEntries;
|
||||||
|
final int maxBytes;
|
||||||
|
final Duration ttl;
|
||||||
|
|
||||||
|
final Map<String, _CacheEntry<T>> _store = <String, _CacheEntry<T>>{};
|
||||||
|
int _currentBytes = 0;
|
||||||
|
|
||||||
|
// 간단한 메트릭
|
||||||
|
int _hits = 0;
|
||||||
|
int _misses = 0;
|
||||||
|
int _puts = 0;
|
||||||
|
int _evictions = 0;
|
||||||
|
|
||||||
|
SimpleCacheManager({
|
||||||
|
this.maxEntries = 128,
|
||||||
|
this.maxBytes = 1024 * 1024, // 1MB
|
||||||
|
this.ttl = const Duration(minutes: 30),
|
||||||
|
});
|
||||||
|
|
||||||
|
T? get(String key) {
|
||||||
|
final entry = _store.remove(key);
|
||||||
|
if (entry == null) return null;
|
||||||
|
if (entry.isExpired) {
|
||||||
|
_currentBytes -= entry.size;
|
||||||
|
_misses++;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
// LRU 갱신: 재삽입으로 가장 최근으로 이동
|
||||||
|
_store[key] = entry;
|
||||||
|
_hits++;
|
||||||
|
return entry.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
void set(String key, T value, {int size = 1, Duration? customTtl}) {
|
||||||
|
final expiresAt = DateTime.now().add(customTtl ?? ttl);
|
||||||
|
final existing = _store.remove(key);
|
||||||
|
if (existing != null) {
|
||||||
|
_currentBytes -= existing.size;
|
||||||
|
}
|
||||||
|
_store[key] = _CacheEntry(value: value, expiresAt: expiresAt, size: size);
|
||||||
|
_currentBytes += size;
|
||||||
|
_puts++;
|
||||||
|
_evictIfNeeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
void invalidate(String key) {
|
||||||
|
final removed = _store.remove(key);
|
||||||
|
if (removed != null) {
|
||||||
|
_currentBytes -= removed.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_store.clear();
|
||||||
|
_currentBytes = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _evictIfNeeded() {
|
||||||
|
// 개수/용량 제한을 넘으면 오래된 것부터 제거
|
||||||
|
while (_store.length > maxEntries || _currentBytes > maxBytes) {
|
||||||
|
if (_store.isEmpty) break;
|
||||||
|
final firstKey = _store.keys.first;
|
||||||
|
final removed = _store.remove(firstKey);
|
||||||
|
if (removed != null) {
|
||||||
|
_currentBytes -= removed.size;
|
||||||
|
_evictions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, num> dumpMetrics() {
|
||||||
|
final totalGets = _hits + _misses;
|
||||||
|
final hitRate = totalGets == 0 ? 0 : _hits / totalGets;
|
||||||
|
return {
|
||||||
|
'entries': _store.length,
|
||||||
|
'bytes': _currentBytes,
|
||||||
|
'hits': _hits,
|
||||||
|
'misses': _misses,
|
||||||
|
'hitRate': hitRate,
|
||||||
|
'puts': _puts,
|
||||||
|
'evictions': _evictions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,17 @@
|
|||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import 'exchange_rate_service.dart';
|
import 'exchange_rate_service.dart';
|
||||||
|
import 'cache_manager.dart';
|
||||||
|
|
||||||
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
/// 통화 단위 변환 및 포맷팅을 위한 유틸리티 클래스
|
||||||
class CurrencyUtil {
|
class CurrencyUtil {
|
||||||
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
static final ExchangeRateService _exchangeRateService = ExchangeRateService();
|
||||||
|
static final SimpleCacheManager<String> _fmtCache =
|
||||||
|
SimpleCacheManager<String>(
|
||||||
|
maxEntries: 256,
|
||||||
|
maxBytes: 256 * 1024,
|
||||||
|
ttl: const Duration(minutes: 15),
|
||||||
|
);
|
||||||
|
|
||||||
/// 언어에 따른 기본 통화 반환
|
/// 언어에 따른 기본 통화 반환
|
||||||
static String getDefaultCurrency(String locale) {
|
static String getDefaultCurrency(String locale) {
|
||||||
@@ -80,11 +87,19 @@ class CurrencyUtil {
|
|||||||
String currency,
|
String currency,
|
||||||
String locale,
|
String locale,
|
||||||
) async {
|
) async {
|
||||||
|
// 캐시 조회
|
||||||
|
final decimals = (currency == 'KRW' || currency == 'JPY') ? 0 : 2;
|
||||||
|
final key = 'fmt:$locale:$currency:${amount.toStringAsFixed(decimals)}';
|
||||||
|
final cached = _fmtCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
final defaultCurrency = getDefaultCurrency(locale);
|
final defaultCurrency = getDefaultCurrency(locale);
|
||||||
|
|
||||||
// 입력 통화가 기본 통화인 경우
|
// 입력 통화가 기본 통화인 경우
|
||||||
if (currency == defaultCurrency) {
|
if (currency == defaultCurrency) {
|
||||||
return _formatSingleCurrency(amount, currency);
|
final result = _formatSingleCurrency(amount, currency);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
// USD 입력인 경우 - 기본 통화로 변환하여 표시
|
||||||
@@ -95,17 +110,23 @@ class CurrencyUtil {
|
|||||||
final primaryFormatted =
|
final primaryFormatted =
|
||||||
_formatSingleCurrency(convertedAmount, defaultCurrency);
|
_formatSingleCurrency(convertedAmount, defaultCurrency);
|
||||||
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
final usdFormatted = _formatSingleCurrency(amount, 'USD');
|
||||||
return '$primaryFormatted ($usdFormatted)';
|
final result = '$primaryFormatted ($usdFormatted)';
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 영어 사용자가 KRW 선택한 경우
|
// 영어 사용자가 KRW 선택한 경우
|
||||||
if (locale == 'en' && currency == 'KRW') {
|
if (locale == 'en' && currency == 'KRW') {
|
||||||
return _formatSingleCurrency(amount, currency);
|
final result = _formatSingleCurrency(amount, currency);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 기타 통화 입력인 경우
|
// 기타 통화 입력인 경우
|
||||||
return _formatSingleCurrency(amount, currency);
|
final result = _formatSingleCurrency(amount, currency);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
|
/// 구독 목록의 총 월 비용을 계산 (언어별 기본 통화로)
|
||||||
@@ -141,7 +162,20 @@ class CurrencyUtil {
|
|||||||
static Future<String> formatSubscriptionAmountWithLocale(
|
static Future<String> formatSubscriptionAmountWithLocale(
|
||||||
SubscriptionModel subscription, String locale) async {
|
SubscriptionModel subscription, String locale) async {
|
||||||
final price = subscription.currentPrice;
|
final price = subscription.currentPrice;
|
||||||
return formatAmountWithLocale(price, subscription.currency, locale);
|
// 구독 단위 캐시 키 (통화/가격/locale + id)
|
||||||
|
final decimals =
|
||||||
|
(subscription.currency == 'KRW' || subscription.currency == 'JPY')
|
||||||
|
? 0
|
||||||
|
: 2;
|
||||||
|
final key =
|
||||||
|
'subfmt:$locale:${subscription.currency}:${price.toStringAsFixed(decimals)}:${subscription.id}';
|
||||||
|
final cached = _fmtCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
final result =
|
||||||
|
await formatAmountWithLocale(price, subscription.currency, locale);
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
|
/// 구독의 월 비용을 표시 형식에 맞게 변환 (원화 변환 포함, 이벤트 가격 반영) - 기존 호환성 유지
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:http/http.dart' as http;
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
|
import 'cache_manager.dart';
|
||||||
|
|
||||||
/// 환율 정보 서비스 클래스
|
/// 환율 정보 서비스 클래스
|
||||||
class ExchangeRateService {
|
class ExchangeRateService {
|
||||||
@@ -16,6 +17,14 @@ class ExchangeRateService {
|
|||||||
// 내부 생성자
|
// 내부 생성자
|
||||||
ExchangeRateService._internal();
|
ExchangeRateService._internal();
|
||||||
|
|
||||||
|
// 포맷된 환율 문자열 캐시 (언어별)
|
||||||
|
static final SimpleCacheManager<String> _fmtCache =
|
||||||
|
SimpleCacheManager<String>(
|
||||||
|
maxEntries: 64,
|
||||||
|
maxBytes: 64 * 1024,
|
||||||
|
ttl: const Duration(minutes: 30),
|
||||||
|
);
|
||||||
|
|
||||||
// 캐싱된 환율 정보
|
// 캐싱된 환율 정보
|
||||||
double? _usdToKrwRate;
|
double? _usdToKrwRate;
|
||||||
double? _usdToJpyRate;
|
double? _usdToJpyRate;
|
||||||
@@ -62,6 +71,8 @@ class ExchangeRateService {
|
|||||||
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
|
_usdToJpyRate = (data['rates']['JPY'] as num?)?.toDouble();
|
||||||
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
|
_usdToCnyRate = (data['rates']['CNY'] as num?)?.toDouble();
|
||||||
_lastUpdated = DateTime.now();
|
_lastUpdated = DateTime.now();
|
||||||
|
// 환율 갱신 시 포맷 캐시 무효화
|
||||||
|
_fmtCache.clear();
|
||||||
Log.d(
|
Log.d(
|
||||||
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
|
'환율 갱신 완료: USD→KRW=$_usdToKrwRate, JPY=$_usdToJpyRate, CNY=$_usdToCnyRate');
|
||||||
return;
|
return;
|
||||||
@@ -177,32 +188,45 @@ class ExchangeRateService {
|
|||||||
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
/// 언어별 환율 정보를 포맷팅하여 반환합니다.
|
||||||
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
Future<String> getFormattedExchangeRateInfoForLocale(String locale) async {
|
||||||
await _fetchAllRatesIfNeeded();
|
await _fetchAllRatesIfNeeded();
|
||||||
|
// 캐시 키 (locale 기준)
|
||||||
|
final key = 'fx:fmt:$locale';
|
||||||
|
final cached = _fmtCache.get(key);
|
||||||
|
if (cached != null) return cached;
|
||||||
|
|
||||||
|
String result = '';
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
case 'ko':
|
case 'ko':
|
||||||
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
final rate = _usdToKrwRate ?? DEFAULT_USD_TO_KRW_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'ko_KR',
|
locale: 'ko_KR',
|
||||||
symbol: '₩',
|
symbol: '₩',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
case 'ja':
|
case 'ja':
|
||||||
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
|
final rate = _usdToJpyRate ?? DEFAULT_USD_TO_JPY_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'ja_JP',
|
locale: 'ja_JP',
|
||||||
symbol: '¥',
|
symbol: '¥',
|
||||||
decimalDigits: 0,
|
decimalDigits: 0,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
case 'zh':
|
case 'zh':
|
||||||
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
|
final rate = _usdToCnyRate ?? DEFAULT_USD_TO_CNY_RATE;
|
||||||
return NumberFormat.currency(
|
result = NumberFormat.currency(
|
||||||
locale: 'zh_CN',
|
locale: 'zh_CN',
|
||||||
symbol: '¥',
|
symbol: '¥',
|
||||||
decimalDigits: 2,
|
decimalDigits: 2,
|
||||||
).format(rate);
|
).format(rate);
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
return '';
|
result = '';
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 대략적인 사이즈(문자 길이)로 캐시 저장
|
||||||
|
_fmtCache.set(key, result, size: result.length);
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
/// USD 금액을 KRW로 변환하여 포맷팅된 문자열로 반환합니다.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb, compute;
|
||||||
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
import 'package:flutter_sms_inbox/flutter_sms_inbox.dart';
|
||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
@@ -7,38 +7,6 @@ import '../services/subscription_url_matcher.dart';
|
|||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
|
|
||||||
class SmsScanner {
|
class SmsScanner {
|
||||||
// 반복 사용되는 리소스 상수화로 파싱 성능 최적화
|
|
||||||
static const List<String> _subscriptionKeywords = [
|
|
||||||
'구독',
|
|
||||||
'결제',
|
|
||||||
'정기결제',
|
|
||||||
'자동결제',
|
|
||||||
'월정액',
|
|
||||||
'subscription',
|
|
||||||
'payment',
|
|
||||||
'billing',
|
|
||||||
'charge',
|
|
||||||
'넷플릭스',
|
|
||||||
'Netflix',
|
|
||||||
'유튜브',
|
|
||||||
'YouTube',
|
|
||||||
'Spotify',
|
|
||||||
'멜론',
|
|
||||||
'웨이브',
|
|
||||||
'Disney+',
|
|
||||||
'디즈니플러스',
|
|
||||||
'Apple',
|
|
||||||
'Microsoft',
|
|
||||||
'GitHub',
|
|
||||||
'Adobe',
|
|
||||||
'Amazon'
|
|
||||||
];
|
|
||||||
static final List<RegExp> _amountPatterns = <RegExp>[
|
|
||||||
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
|
||||||
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
|
||||||
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
|
||||||
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
|
||||||
];
|
|
||||||
final SmsQuery _query = SmsQuery();
|
final SmsQuery _query = SmsQuery();
|
||||||
|
|
||||||
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
Future<List<SubscriptionModel>> scanForSubscriptions() async {
|
||||||
@@ -114,16 +82,21 @@ class SmsScanner {
|
|||||||
Future<List<dynamic>> _scanAndroidSms() async {
|
Future<List<dynamic>> _scanAndroidSms() async {
|
||||||
try {
|
try {
|
||||||
final messages = await _query.getAllSms;
|
final messages = await _query.getAllSms;
|
||||||
final smsList = <Map<String, dynamic>>[];
|
|
||||||
|
|
||||||
// SMS 메시지를 분석하여 구독 서비스 감지
|
// Isolate로 넘길 수 있도록 직렬화 (plugin 객체 직접 전달 불가)
|
||||||
|
final serialized = <Map<String, dynamic>>[];
|
||||||
for (final message in messages) {
|
for (final message in messages) {
|
||||||
final parsedData = _parseRawSms(message);
|
serialized.add({
|
||||||
if (parsedData != null) {
|
'body': message.body ?? '',
|
||||||
smsList.add(parsedData);
|
'address': message.address ?? '',
|
||||||
}
|
'dateMillis': (message.date ?? DateTime.now()).millisecondsSinceEpoch,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 대량 파싱은 별도 Isolate로 오프로딩
|
||||||
|
final List<Map<String, dynamic>> smsList =
|
||||||
|
await compute(_parseRawSmsBatch, serialized);
|
||||||
|
|
||||||
return smsList;
|
return smsList;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Log.e('SmsScanner: Android SMS 스캔 실패', e);
|
Log.e('SmsScanner: Android SMS 스캔 실패', e);
|
||||||
@@ -131,131 +104,7 @@ class SmsScanner {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 실제 SMS 메시지를 파싱하여 구독 정보 추출
|
// (사용되지 않음) 기존 단일 메시지 파서 제거됨 - Isolate 배치 파싱으로 대체
|
||||||
Map<String, dynamic>? _parseRawSms(SmsMessage message) {
|
|
||||||
try {
|
|
||||||
final body = message.body ?? '';
|
|
||||||
final sender = message.address ?? '';
|
|
||||||
final date = message.date ?? DateTime.now();
|
|
||||||
|
|
||||||
// 구독 관련 키워드가 있는지 확인
|
|
||||||
bool isSubscription = _subscriptionKeywords.any((keyword) =>
|
|
||||||
body.toLowerCase().contains(keyword.toLowerCase()) ||
|
|
||||||
sender.toLowerCase().contains(keyword.toLowerCase()));
|
|
||||||
|
|
||||||
if (!isSubscription) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서비스명 추출
|
|
||||||
String serviceName = _extractServiceName(body, sender);
|
|
||||||
|
|
||||||
// 금액 추출
|
|
||||||
double? amount = _extractAmount(body);
|
|
||||||
|
|
||||||
// 결제 주기 추출
|
|
||||||
String billingCycle = _extractBillingCycle(body);
|
|
||||||
|
|
||||||
return {
|
|
||||||
'serviceName': serviceName,
|
|
||||||
'monthlyCost': amount ?? 0.0,
|
|
||||||
'billingCycle': billingCycle,
|
|
||||||
'message': body,
|
|
||||||
'nextBillingDate':
|
|
||||||
_calculateNextBillingFromDate(date, billingCycle).toIso8601String(),
|
|
||||||
'previousPaymentDate': date.toIso8601String(),
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
Log.e('SmsScanner: SMS 파싱 실패', e);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서비스명 추출 로직
|
|
||||||
String _extractServiceName(String body, String sender) {
|
|
||||||
// 알려진 서비스 매핑
|
|
||||||
final servicePatterns = {
|
|
||||||
'netflix': '넷플릭스',
|
|
||||||
'youtube': '유튜브 프리미엄',
|
|
||||||
'spotify': 'Spotify',
|
|
||||||
'disney': '디즈니플러스',
|
|
||||||
'apple': 'Apple',
|
|
||||||
'microsoft': 'Microsoft',
|
|
||||||
'github': 'GitHub',
|
|
||||||
'adobe': 'Adobe',
|
|
||||||
'멜론': '멜론',
|
|
||||||
'웨이브': '웨이브',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 메시지나 발신자에서 서비스명 찾기
|
|
||||||
final combinedText = '$body $sender'.toLowerCase();
|
|
||||||
|
|
||||||
for (final entry in servicePatterns.entries) {
|
|
||||||
if (combinedText.contains(entry.key)) {
|
|
||||||
return entry.value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 찾지 못한 경우
|
|
||||||
return _extractServiceNameFromSender(sender);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 발신자 정보에서 서비스명 추출
|
|
||||||
String _extractServiceNameFromSender(String sender) {
|
|
||||||
// 숫자만 있으면 제거
|
|
||||||
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
|
||||||
return '알 수 없는 서비스';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 특수문자 제거하고 서비스명으로 사용
|
|
||||||
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 금액 추출 로직
|
|
||||||
double? _extractAmount(String body) {
|
|
||||||
// 다양한 금액 패턴 매칭(사전 컴파일)
|
|
||||||
for (final pattern in _amountPatterns) {
|
|
||||||
final match = pattern.firstMatch(body);
|
|
||||||
if (match != null) {
|
|
||||||
String amountStr = match.group(1) ?? '';
|
|
||||||
amountStr = amountStr.replaceAll(',', '');
|
|
||||||
return double.tryParse(amountStr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 결제 주기 추출 로직
|
|
||||||
String _extractBillingCycle(String body) {
|
|
||||||
if (body.contains('월') || body.contains('monthly') || body.contains('매월')) {
|
|
||||||
return 'monthly';
|
|
||||||
} else if (body.contains('년') ||
|
|
||||||
body.contains('yearly') ||
|
|
||||||
body.contains('annual')) {
|
|
||||||
return 'yearly';
|
|
||||||
} else if (body.contains('주') || body.contains('weekly')) {
|
|
||||||
return 'weekly';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본값
|
|
||||||
return 'monthly';
|
|
||||||
}
|
|
||||||
|
|
||||||
// 다음 결제일 계산
|
|
||||||
DateTime _calculateNextBillingFromDate(
|
|
||||||
DateTime lastDate, String billingCycle) {
|
|
||||||
switch (billingCycle) {
|
|
||||||
case 'monthly':
|
|
||||||
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
|
|
||||||
case 'yearly':
|
|
||||||
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
|
|
||||||
case 'weekly':
|
|
||||||
return lastDate.add(const Duration(days: 7));
|
|
||||||
default:
|
|
||||||
return lastDate.add(const Duration(days: 30));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
SubscriptionModel? _parseSms(Map<String, dynamic> sms, int repeatCount) {
|
||||||
try {
|
try {
|
||||||
@@ -427,3 +276,148 @@ class SmsScanner {
|
|||||||
return 'KRW';
|
return 'KRW';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Isolate 오프로딩용 Top-level 파서 =====
|
||||||
|
|
||||||
|
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
|
||||||
|
List<Map<String, dynamic>> _parseRawSmsBatch(
|
||||||
|
List<Map<String, dynamic>> messages) {
|
||||||
|
// 키워드/정규식은 Isolate 내에서 재생성 (간단 복제)
|
||||||
|
const subscriptionKeywords = [
|
||||||
|
'구독',
|
||||||
|
'결제',
|
||||||
|
'정기결제',
|
||||||
|
'자동결제',
|
||||||
|
'월정액',
|
||||||
|
'subscription',
|
||||||
|
'payment',
|
||||||
|
'billing',
|
||||||
|
'charge',
|
||||||
|
'넷플릭스',
|
||||||
|
'Netflix',
|
||||||
|
'유튜브',
|
||||||
|
'YouTube',
|
||||||
|
'Spotify',
|
||||||
|
'멜론',
|
||||||
|
'웨이브',
|
||||||
|
'Disney+',
|
||||||
|
'디즈니플러스',
|
||||||
|
'Apple',
|
||||||
|
'Microsoft',
|
||||||
|
'GitHub',
|
||||||
|
'Adobe',
|
||||||
|
'Amazon'
|
||||||
|
];
|
||||||
|
|
||||||
|
final amountPatterns = <RegExp>[
|
||||||
|
RegExp(r'(\d{1,3}(?:,\d{3})*)(?:원|₩)'), // 원화
|
||||||
|
RegExp(r'\$(\d+(?:\.\d{2})?)'), // 달러
|
||||||
|
RegExp(r'(\d+(?:\.\d{2})?)\s*USD'), // USD
|
||||||
|
RegExp(r'결제.*?(\d{1,3}(?:,\d{3})*)'), // 결제 금액
|
||||||
|
];
|
||||||
|
|
||||||
|
final results = <Map<String, dynamic>>[];
|
||||||
|
|
||||||
|
for (final m in messages) {
|
||||||
|
final body = (m['body'] as String?) ?? '';
|
||||||
|
final sender = (m['address'] as String?) ?? '';
|
||||||
|
final dateMillis =
|
||||||
|
(m['dateMillis'] as int?) ?? DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
|
||||||
|
|
||||||
|
final lowerBody = body.toLowerCase();
|
||||||
|
final lowerSender = sender.toLowerCase();
|
||||||
|
final isSubscription = subscriptionKeywords.any((k) =>
|
||||||
|
lowerBody.contains(k.toLowerCase()) ||
|
||||||
|
lowerSender.contains(k.toLowerCase()));
|
||||||
|
|
||||||
|
if (!isSubscription) continue;
|
||||||
|
|
||||||
|
final serviceName = _isoExtractServiceName(body, sender);
|
||||||
|
final amount = _isoExtractAmount(body, amountPatterns) ?? 0.0;
|
||||||
|
final billingCycle = _isoExtractBillingCycle(body);
|
||||||
|
final nextBillingDate =
|
||||||
|
_isoCalculateNextBillingFromDate(date, billingCycle);
|
||||||
|
|
||||||
|
results.add({
|
||||||
|
'serviceName': serviceName,
|
||||||
|
'monthlyCost': amount,
|
||||||
|
'billingCycle': billingCycle,
|
||||||
|
'message': body,
|
||||||
|
'nextBillingDate': nextBillingDate.toIso8601String(),
|
||||||
|
'previousPaymentDate': date.toIso8601String(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _isoExtractServiceName(String body, String sender) {
|
||||||
|
final servicePatterns = {
|
||||||
|
'netflix': '넷플릭스',
|
||||||
|
'youtube': '유튜브 프리미엄',
|
||||||
|
'spotify': 'Spotify',
|
||||||
|
'disney': '디즈니플러스',
|
||||||
|
'apple': 'Apple',
|
||||||
|
'microsoft': 'Microsoft',
|
||||||
|
'github': 'GitHub',
|
||||||
|
'adobe': 'Adobe',
|
||||||
|
'멜론': '멜론',
|
||||||
|
'웨이브': '웨이브',
|
||||||
|
};
|
||||||
|
|
||||||
|
final combined = '$body $sender'.toLowerCase();
|
||||||
|
for (final e in servicePatterns.entries) {
|
||||||
|
if (combined.contains(e.key)) return e.value;
|
||||||
|
}
|
||||||
|
return _isoExtractServiceNameFromSender(sender);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _isoExtractServiceNameFromSender(String sender) {
|
||||||
|
if (RegExp(r'^\d+$').hasMatch(sender)) {
|
||||||
|
return '알 수 없는 서비스';
|
||||||
|
}
|
||||||
|
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _isoExtractAmount(String body, List<RegExp> patterns) {
|
||||||
|
for (final pattern in patterns) {
|
||||||
|
final match = pattern.firstMatch(body);
|
||||||
|
if (match != null) {
|
||||||
|
var amountStr = match.group(1) ?? '';
|
||||||
|
amountStr = amountStr.replaceAll(',', '');
|
||||||
|
final parsed = double.tryParse(amountStr);
|
||||||
|
if (parsed != null) return parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _isoExtractBillingCycle(String body) {
|
||||||
|
if (body.contains('월') ||
|
||||||
|
body.toLowerCase().contains('monthly') ||
|
||||||
|
body.contains('매월')) {
|
||||||
|
return 'monthly';
|
||||||
|
} else if (body.contains('년') ||
|
||||||
|
body.toLowerCase().contains('yearly') ||
|
||||||
|
body.toLowerCase().contains('annual')) {
|
||||||
|
return 'yearly';
|
||||||
|
} else if (body.contains('주') || body.toLowerCase().contains('weekly')) {
|
||||||
|
return 'weekly';
|
||||||
|
}
|
||||||
|
return 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
DateTime _isoCalculateNextBillingFromDate(
|
||||||
|
DateTime lastDate, String billingCycle) {
|
||||||
|
switch (billingCycle) {
|
||||||
|
case 'monthly':
|
||||||
|
return DateTime(lastDate.year, lastDate.month + 1, lastDate.day);
|
||||||
|
case 'yearly':
|
||||||
|
return DateTime(lastDate.year + 1, lastDate.month, lastDate.day);
|
||||||
|
case 'weekly':
|
||||||
|
return lastDate.add(const Duration(days: 7));
|
||||||
|
default:
|
||||||
|
return lastDate.add(const Duration(days: 30));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,941 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// 서비스 정보를 담는 데이터 클래스
|
|
||||||
class ServiceInfo {
|
|
||||||
final String serviceId;
|
|
||||||
final String serviceName;
|
|
||||||
final String? serviceUrl;
|
|
||||||
final String? cancellationUrl;
|
|
||||||
final String categoryId;
|
|
||||||
final String categoryNameKr;
|
|
||||||
final String categoryNameEn;
|
|
||||||
|
|
||||||
ServiceInfo({
|
|
||||||
required this.serviceId,
|
|
||||||
required this.serviceName,
|
|
||||||
this.serviceUrl,
|
|
||||||
this.cancellationUrl,
|
|
||||||
required this.categoryId,
|
|
||||||
required this.categoryNameKr,
|
|
||||||
required this.categoryNameEn,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 구독 서비스와 웹사이트 URL 매칭을 처리하는 서비스 클래스
|
|
||||||
class SubscriptionUrlMatcher {
|
|
||||||
static Map<String, dynamic>? _servicesData;
|
|
||||||
static bool _isInitialized = false;
|
|
||||||
|
|
||||||
// 레거시 데이터 (JSON 로드 실패시 폴백)
|
|
||||||
// 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',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 저장 (클라우드/파일) 서비스
|
|
||||||
static final Map<String, String> storageServices = {
|
|
||||||
'google drive': 'https://www.google.com/drive/',
|
|
||||||
'구글 드라이브': 'https://www.google.com/drive/',
|
|
||||||
'dropbox': 'https://www.dropbox.com',
|
|
||||||
'드롭박스': 'https://www.dropbox.com',
|
|
||||||
'onedrive': 'https://www.onedrive.com',
|
|
||||||
'원드라이브': 'https://www.onedrive.com',
|
|
||||||
'icloud': 'https://www.icloud.com',
|
|
||||||
'아이클라우드': 'https://www.icloud.com',
|
|
||||||
'box': 'https://www.box.com',
|
|
||||||
'박스': 'https://www.box.com',
|
|
||||||
'pcloud': 'https://www.pcloud.com',
|
|
||||||
'mega': 'https://mega.nz',
|
|
||||||
'메가': 'https://mega.nz',
|
|
||||||
'naver mybox': 'https://mybox.naver.com',
|
|
||||||
'네이버 마이박스': 'https://mybox.naver.com',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 통신 · 인터넷 · TV 서비스
|
|
||||||
static final Map<String, String> telecomServices = {
|
|
||||||
'skt': 'https://www.sktelecom.com',
|
|
||||||
'sk텔레콤': 'https://www.sktelecom.com',
|
|
||||||
'kt': 'https://www.kt.com',
|
|
||||||
'lgu+': 'https://www.lguplus.com',
|
|
||||||
'lg유플러스': 'https://www.lguplus.com',
|
|
||||||
'olleh tv': 'https://www.kt.com/olleh_tv',
|
|
||||||
'올레 tv': 'https://www.kt.com/olleh_tv',
|
|
||||||
'b tv': 'https://www.skbroadband.com',
|
|
||||||
'비티비': 'https://www.skbroadband.com',
|
|
||||||
'u+모바일tv': 'https://www.lguplus.com',
|
|
||||||
'유플러스모바일tv': 'https://www.lguplus.com',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 생활/라이프스타일 서비스
|
|
||||||
static final Map<String, String> lifestyleServices = {
|
|
||||||
'네이버 플러스': 'https://plus.naver.com',
|
|
||||||
'naver plus': 'https://plus.naver.com',
|
|
||||||
'카카오 구독': 'https://subscribe.kakao.com',
|
|
||||||
'kakao subscribe': 'https://subscribe.kakao.com',
|
|
||||||
'쿠팡 와우': 'https://www.coupang.com/np/coupangplus',
|
|
||||||
'coupang wow': 'https://www.coupang.com/np/coupangplus',
|
|
||||||
'스타벅스 버디': 'https://www.starbucks.co.kr',
|
|
||||||
'starbucks buddy': 'https://www.starbucks.co.kr',
|
|
||||||
'cu 구독': 'https://cu.bgfretail.com',
|
|
||||||
'gs25 구독': 'https://gs25.gsretail.com',
|
|
||||||
'현대차 구독': 'https://www.hyundai.com/kr/ko/eco/vehicle-subscription',
|
|
||||||
'lg전자 구독': 'https://www.lge.co.kr',
|
|
||||||
'삼성전자 구독': 'https://www.samsung.com/sec',
|
|
||||||
'다이슨 케어': 'https://www.dyson.co.kr',
|
|
||||||
'dyson care': 'https://www.dyson.co.kr',
|
|
||||||
'마켓컬리': 'https://www.kurly.com',
|
|
||||||
'kurly': 'https://www.kurly.com',
|
|
||||||
'헬로네이처': 'https://www.hellonature.com',
|
|
||||||
'hello nature': 'https://www.hellonature.com',
|
|
||||||
'이마트 트레이더스': 'https://www.emarttraders.co.kr',
|
|
||||||
'홈플러스': 'https://www.homeplus.co.kr',
|
|
||||||
'hellofresh': 'https://www.hellofresh.com',
|
|
||||||
'헬로프레시': 'https://www.hellofresh.com',
|
|
||||||
'bespoke post': 'https://www.bespokepost.com',
|
|
||||||
};
|
|
||||||
|
|
||||||
// 쇼핑/이커머스 서비스
|
|
||||||
static final Map<String, String> shoppingServices = {
|
|
||||||
'amazon prime': 'https://www.amazon.com/prime',
|
|
||||||
'아마존 프라임': 'https://www.amazon.com/prime',
|
|
||||||
'walmart+': 'https://www.walmart.com/plus',
|
|
||||||
'월마트플러스': 'https://www.walmart.com/plus',
|
|
||||||
'chewy': 'https://www.chewy.com',
|
|
||||||
'츄이': 'https://www.chewy.com',
|
|
||||||
'dollar shave club': 'https://www.dollarshaveclub.com',
|
|
||||||
'달러셰이브클럽': 'https://www.dollarshaveclub.com',
|
|
||||||
'instacart': 'https://www.instacart.com',
|
|
||||||
'인스타카트': 'https://www.instacart.com',
|
|
||||||
'shipt': 'https://www.shipt.com',
|
|
||||||
'십트': 'https://www.shipt.com',
|
|
||||||
'grove': 'https://grove.co',
|
|
||||||
'그로브': 'https://grove.co',
|
|
||||||
'cratejoy': 'https://www.cratejoy.com',
|
|
||||||
'shopify': 'https://www.shopify.com',
|
|
||||||
'쇼피파이': 'https://www.shopify.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,
|
|
||||||
...storageServices,
|
|
||||||
...aiServices,
|
|
||||||
...programmingServices,
|
|
||||||
...officeTools,
|
|
||||||
...lifestyleServices,
|
|
||||||
...shoppingServices,
|
|
||||||
...telecomServices,
|
|
||||||
...otherServices,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// JSON 데이터 초기화
|
|
||||||
static Future<void> initialize() async {
|
|
||||||
if (_isInitialized) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
final jsonString = await rootBundle.loadString('assets/data/subscription_services.json');
|
|
||||||
_servicesData = json.decode(jsonString);
|
|
||||||
_isInitialized = true;
|
|
||||||
print('SubscriptionUrlMatcher: JSON 데이터 로드 완료');
|
|
||||||
} catch (e) {
|
|
||||||
print('SubscriptionUrlMatcher: JSON 로드 실패 - $e');
|
|
||||||
// 로드 실패시 기존 하드코딩 데이터 사용
|
|
||||||
_isInitialized = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 도메인 추출 (www와 TLD 제외)
|
|
||||||
static String? extractDomain(String url) {
|
|
||||||
try {
|
|
||||||
final uri = Uri.parse(url);
|
|
||||||
final host = uri.host.toLowerCase();
|
|
||||||
|
|
||||||
// 도메인 부분 추출
|
|
||||||
var parts = host.split('.');
|
|
||||||
|
|
||||||
// www 제거
|
|
||||||
if (parts.isNotEmpty && parts[0] == 'www') {
|
|
||||||
parts = parts.sublist(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 서브도메인 처리 (예: music.youtube.com)
|
|
||||||
if (parts.length >= 3) {
|
|
||||||
// 서브도메인 포함 전체 도메인 반환
|
|
||||||
return parts.sublist(0, parts.length - 1).join('.');
|
|
||||||
} else if (parts.length >= 2) {
|
|
||||||
// 메인 도메인만 반환
|
|
||||||
return parts[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
} catch (e) {
|
|
||||||
print('SubscriptionUrlMatcher: 도메인 추출 실패 - $e');
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// URL로 서비스 찾기
|
|
||||||
static Future<ServiceInfo?> findServiceByUrl(String url) async {
|
|
||||||
await initialize();
|
|
||||||
|
|
||||||
final domain = extractDomain(url);
|
|
||||||
if (domain == null) return null;
|
|
||||||
|
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
|
||||||
if (_servicesData != null) {
|
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final categoryEntry in categories.entries) {
|
|
||||||
final categoryId = categoryEntry.key;
|
|
||||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
|
||||||
final services = categoryData['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceEntry in services.entries) {
|
|
||||||
final serviceId = serviceEntry.key;
|
|
||||||
final serviceData = serviceEntry.value as Map<String, dynamic>;
|
|
||||||
final domains = List<String>.from(serviceData['domains'] ?? []);
|
|
||||||
|
|
||||||
// 도메인이 일치하는지 확인
|
|
||||||
for (final serviceDomain in domains) {
|
|
||||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
|
||||||
final names = List<String>.from(serviceData['names'] ?? []);
|
|
||||||
final urls = serviceData['urls'] as Map<String, dynamic>?;
|
|
||||||
|
|
||||||
return ServiceInfo(
|
|
||||||
serviceId: serviceId,
|
|
||||||
serviceName: names.isNotEmpty ? names[0] : serviceId,
|
|
||||||
serviceUrl: urls?['kr'] ?? urls?['en'],
|
|
||||||
cancellationUrl: null,
|
|
||||||
categoryId: _getCategoryIdByKey(categoryId),
|
|
||||||
categoryNameKr: categoryData['nameKr'] ?? '',
|
|
||||||
categoryNameEn: categoryData['nameEn'] ?? '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
|
||||||
for (final entry in allServices.entries) {
|
|
||||||
final serviceUrl = entry.value;
|
|
||||||
final serviceDomain = extractDomain(serviceUrl);
|
|
||||||
|
|
||||||
if (serviceDomain != null &&
|
|
||||||
(domain.contains(serviceDomain) || serviceDomain.contains(domain))) {
|
|
||||||
return ServiceInfo(
|
|
||||||
serviceId: entry.key,
|
|
||||||
serviceName: entry.key,
|
|
||||||
serviceUrl: serviceUrl,
|
|
||||||
cancellationUrl: null,
|
|
||||||
categoryId: _getCategoryForLegacyService(entry.key),
|
|
||||||
categoryNameKr: '',
|
|
||||||
categoryNameEn: '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 서비스명으로 URL 찾기 (기존 suggestUrl 메서드 유지)
|
|
||||||
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 찾기 (개선된 버전)
|
|
||||||
static Future<String?> findCancellationUrl({
|
|
||||||
String? serviceName,
|
|
||||||
String? websiteUrl,
|
|
||||||
String locale = 'kr',
|
|
||||||
}) async {
|
|
||||||
await initialize();
|
|
||||||
|
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
|
||||||
if (_servicesData != null) {
|
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// 1. 서비스명으로 찾기
|
|
||||||
if (serviceName != null && serviceName.isNotEmpty) {
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
|
||||||
|
|
||||||
for (final categoryData in categories.values) {
|
|
||||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
|
||||||
|
|
||||||
for (final name in names) {
|
|
||||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
|
||||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
|
||||||
if (cancellationUrls != null) {
|
|
||||||
// 요청한 언어의 URL이 있으면 반환, 없으면 다른 언어 URL 반환
|
|
||||||
return cancellationUrls[locale] ??
|
|
||||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. URL로 찾기
|
|
||||||
if (websiteUrl != null && websiteUrl.isNotEmpty) {
|
|
||||||
final domain = extractDomain(websiteUrl);
|
|
||||||
if (domain != null) {
|
|
||||||
for (final categoryData in categories.values) {
|
|
||||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final domains = List<String>.from((serviceData as Map<String, dynamic>)['domains'] ?? []);
|
|
||||||
|
|
||||||
for (final serviceDomain in domains) {
|
|
||||||
if (domain.contains(serviceDomain) || serviceDomain.contains(domain)) {
|
|
||||||
final cancellationUrls = serviceData['cancellationUrls'] as Map<String, dynamic>?;
|
|
||||||
if (cancellationUrls != null) {
|
|
||||||
return cancellationUrls[locale] ??
|
|
||||||
cancellationUrls[locale == 'kr' ? 'en' : 'kr'];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 찾기
|
|
||||||
return _findCancellationUrlLegacy(serviceName ?? websiteUrl ?? '');
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 서비스명 또는 웹사이트 URL을 기반으로 해지 안내 페이지 URL 찾기 (레거시)
|
|
||||||
static String? _findCancellationUrlLegacy(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;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 서비스에 공식 해지 안내 페이지가 있는지 확인
|
|
||||||
static Future<bool> hasCancellationPage(String serviceNameOrUrl) async {
|
|
||||||
// 새로운 JSON 기반 방식으로 확인
|
|
||||||
final cancellationUrl = await findCancellationUrl(
|
|
||||||
serviceName: serviceNameOrUrl,
|
|
||||||
websiteUrl: serviceNameOrUrl,
|
|
||||||
);
|
|
||||||
return cancellationUrl != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 서비스명으로 카테고리 찾기
|
|
||||||
static Future<String?> findCategoryByServiceName(String serviceName) async {
|
|
||||||
await initialize();
|
|
||||||
if (serviceName.isEmpty) return null;
|
|
||||||
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
|
||||||
|
|
||||||
// JSON 데이터가 있으면 JSON에서 찾기
|
|
||||||
if (_servicesData != null) {
|
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final categoryEntry in categories.entries) {
|
|
||||||
final categoryId = categoryEntry.key;
|
|
||||||
final categoryData = categoryEntry.value as Map<String, dynamic>;
|
|
||||||
final services = categoryData['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final names = List<String>.from((serviceData as Map<String, dynamic>)['names'] ?? []);
|
|
||||||
|
|
||||||
for (final name in names) {
|
|
||||||
if (lowerName.contains(name.toLowerCase()) || name.toLowerCase().contains(lowerName)) {
|
|
||||||
return _getCategoryIdByKey(categoryId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// JSON에서 못 찾았으면 레거시 방식으로 카테고리 추측
|
|
||||||
return _getCategoryForLegacyService(serviceName);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 현재 로케일에 따라 서비스 표시명 가져오기
|
|
||||||
static Future<String> getServiceDisplayName({
|
|
||||||
required String serviceName,
|
|
||||||
required String locale,
|
|
||||||
}) async {
|
|
||||||
await initialize();
|
|
||||||
|
|
||||||
if (_servicesData == null) {
|
|
||||||
return serviceName;
|
|
||||||
}
|
|
||||||
|
|
||||||
final lowerName = serviceName.toLowerCase().trim();
|
|
||||||
final categories = _servicesData!['categories'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
// JSON에서 서비스 찾기
|
|
||||||
for (final categoryData in categories.values) {
|
|
||||||
final services = (categoryData as Map<String, dynamic>)['services'] as Map<String, dynamic>;
|
|
||||||
|
|
||||||
for (final serviceData in services.values) {
|
|
||||||
final data = serviceData as Map<String, dynamic>;
|
|
||||||
final names = List<String>.from(data['names'] ?? []);
|
|
||||||
|
|
||||||
// names 배열에 있는지 확인
|
|
||||||
for (final name in names) {
|
|
||||||
if (lowerName == name.toLowerCase() ||
|
|
||||||
lowerName.contains(name.toLowerCase()) ||
|
|
||||||
name.toLowerCase().contains(lowerName)) {
|
|
||||||
// 로케일에 따라 적절한 이름 반환
|
|
||||||
if (locale == 'ko' || locale == 'kr') {
|
|
||||||
return data['nameKr'] ?? serviceName;
|
|
||||||
} else {
|
|
||||||
return data['nameEn'] ?? serviceName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// nameKr/nameEn에 직접 매칭 확인
|
|
||||||
final nameKr = (data['nameKr'] ?? '').toString().toLowerCase();
|
|
||||||
final nameEn = (data['nameEn'] ?? '').toString().toLowerCase();
|
|
||||||
|
|
||||||
if (lowerName == nameKr || lowerName == nameEn) {
|
|
||||||
if (locale == 'ko' || locale == 'kr') {
|
|
||||||
return data['nameKr'] ?? serviceName;
|
|
||||||
} else {
|
|
||||||
return data['nameEn'] ?? serviceName;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 찾지 못한 경우 원래 이름 반환
|
|
||||||
return serviceName;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 카테고리 키를 실제 카테고리 ID로 매핑
|
|
||||||
static String _getCategoryIdByKey(String key) {
|
|
||||||
// 여기에 실제 앱의 카테고리 ID 매핑을 추가
|
|
||||||
// 임시로 카테고리명 기반 매핑
|
|
||||||
switch (key) {
|
|
||||||
case 'music':
|
|
||||||
return 'music_streaming';
|
|
||||||
case 'ott':
|
|
||||||
return 'ott_services';
|
|
||||||
case 'storage':
|
|
||||||
return 'cloud_storage';
|
|
||||||
case 'ai':
|
|
||||||
return 'ai_services';
|
|
||||||
case 'programming':
|
|
||||||
return 'dev_tools';
|
|
||||||
case 'office':
|
|
||||||
return 'office_tools';
|
|
||||||
case 'lifestyle':
|
|
||||||
return 'lifestyle';
|
|
||||||
case 'shopping':
|
|
||||||
return 'shopping';
|
|
||||||
case 'gaming':
|
|
||||||
return 'gaming';
|
|
||||||
case 'telecom':
|
|
||||||
return 'telecom';
|
|
||||||
default:
|
|
||||||
return 'other';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 레거시 서비스명으로 카테고리 추측
|
|
||||||
static String _getCategoryForLegacyService(String serviceName) {
|
|
||||||
final lowerName = serviceName.toLowerCase();
|
|
||||||
|
|
||||||
if (ottServices.containsKey(lowerName)) return 'ott_services';
|
|
||||||
if (musicServices.containsKey(lowerName)) return 'music_streaming';
|
|
||||||
if (storageServices.containsKey(lowerName)) return 'cloud_storage';
|
|
||||||
if (aiServices.containsKey(lowerName)) return 'ai_services';
|
|
||||||
if (programmingServices.containsKey(lowerName)) return 'dev_tools';
|
|
||||||
if (officeTools.containsKey(lowerName)) return 'office_tools';
|
|
||||||
if (lifestyleServices.containsKey(lowerName)) return 'lifestyle';
|
|
||||||
if (shoppingServices.containsKey(lowerName)) return 'shopping';
|
|
||||||
if (telecomServices.containsKey(lowerName)) return 'telecom';
|
|
||||||
|
|
||||||
return 'other';
|
|
||||||
}
|
|
||||||
|
|
||||||
/// SMS에서 URL과 서비스 정보 추출
|
|
||||||
static Future<ServiceInfo?> extractServiceFromSms(String smsText) async {
|
|
||||||
await initialize();
|
|
||||||
|
|
||||||
// URL 패턴 찾기
|
|
||||||
final urlPattern = RegExp(
|
|
||||||
r'https?://(?:www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b(?:[-a-zA-Z0-9()@:%_\+.~#?&//=]*)',
|
|
||||||
caseSensitive: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
final matches = urlPattern.allMatches(smsText);
|
|
||||||
|
|
||||||
for (final match in matches) {
|
|
||||||
final url = match.group(0);
|
|
||||||
if (url != null) {
|
|
||||||
final serviceInfo = await findServiceByUrl(url);
|
|
||||||
if (serviceInfo != null) {
|
|
||||||
return serviceInfo;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// URL로 못 찾았으면 서비스명으로 시도
|
|
||||||
final lowerSms = smsText.toLowerCase();
|
|
||||||
|
|
||||||
// 모든 서비스명 검사
|
|
||||||
for (final entry in allServices.entries) {
|
|
||||||
if (lowerSms.contains(entry.key.toLowerCase())) {
|
|
||||||
final categoryId = await findCategoryByServiceName(entry.key) ?? 'other';
|
|
||||||
|
|
||||||
return ServiceInfo(
|
|
||||||
serviceId: entry.key,
|
|
||||||
serviceName: entry.key,
|
|
||||||
serviceUrl: entry.value,
|
|
||||||
cancellationUrl: null,
|
|
||||||
categoryId: categoryId,
|
|
||||||
categoryNameKr: '',
|
|
||||||
categoryNameEn: '',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// URL이 알려진 서비스 URL인지 확인
|
|
||||||
static Future<bool> isKnownServiceUrl(String url) async {
|
|
||||||
final serviceInfo = await findServiceByUrl(url);
|
|
||||||
return serviceInfo != null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 입력된 서비스 이름이나 문자열에서 매칭되는 URL을 찾아 반환 (레거시 호환성)
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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일 후';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
34
lib/utils/reduce_motion.dart
Normal file
34
lib/utils/reduce_motion.dart
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
/// 접근성 설정에 따른 모션 축소 여부 헬퍼
|
||||||
|
class ReduceMotion {
|
||||||
|
/// 플랫폼 접근성 설정을 기반으로 모션 축소 여부 반환 (context 없이 사용)
|
||||||
|
static bool platform() {
|
||||||
|
final features =
|
||||||
|
WidgetsBinding.instance.platformDispatcher.accessibilityFeatures;
|
||||||
|
// disableAnimations 신뢰
|
||||||
|
return features.disableAnimations;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// MediaQuery/플랫폼 정보를 활용해 런타임에서 모션 축소 여부 반환
|
||||||
|
static bool isEnabled(BuildContext context) {
|
||||||
|
final mq = MediaQuery.maybeOf(context);
|
||||||
|
if (mq != null) {
|
||||||
|
// accessibleNavigation == 사용자가 단순한 네비게이션/애니메이션 선호
|
||||||
|
if (mq.accessibleNavigation) return true;
|
||||||
|
}
|
||||||
|
return platform();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 모션 강도 스케일 유틸리티
|
||||||
|
static double scale(BuildContext context,
|
||||||
|
{required double normal, required double reduced}) {
|
||||||
|
return isEnabled(context) ? reduced : normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 파티클 개수 등 정수 스케일링
|
||||||
|
static int count(BuildContext context,
|
||||||
|
{required int normal, required int reduced}) {
|
||||||
|
return isEnabled(context) ? reduced : normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import '../../theme/app_colors.dart';
|
|||||||
import '../glassmorphism_card.dart';
|
import '../glassmorphism_card.dart';
|
||||||
import '../themed_text.dart';
|
import '../themed_text.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
|
import '../../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
/// 월별 지출 현황을 차트로 보여주는 카드 위젯
|
||||||
class MonthlyExpenseChartCard extends StatelessWidget {
|
class MonthlyExpenseChartCard extends StatelessWidget {
|
||||||
@@ -250,6 +251,10 @@ class MonthlyExpenseChartCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
swapAnimationDuration: ReduceMotion.isEnabled(context)
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
swapAnimationCurve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../themed_text.dart';
|
|||||||
import 'analysis_badge.dart';
|
import 'analysis_badge.dart';
|
||||||
import '../../l10n/app_localizations.dart';
|
import '../../l10n/app_localizations.dart';
|
||||||
import '../../providers/locale_provider.dart';
|
import '../../providers/locale_provider.dart';
|
||||||
|
import '../../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
/// 구독 서비스 비율을 파이 차트로 보여주는 카드 위젯
|
||||||
class SubscriptionPieChartCard extends StatefulWidget {
|
class SubscriptionPieChartCard extends StatefulWidget {
|
||||||
@@ -312,58 +313,69 @@ class _SubscriptionPieChartCardState extends State<SubscriptionPieChartCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return PieChart(
|
return RepaintBoundary(
|
||||||
PieChartData(
|
child: PieChart(
|
||||||
borderData: FlBorderData(show: false),
|
PieChartData(
|
||||||
sectionsSpace: 2,
|
borderData: FlBorderData(show: false),
|
||||||
centerSpaceRadius: 60,
|
sectionsSpace: 2,
|
||||||
sections:
|
centerSpaceRadius: 60,
|
||||||
_applyTouchedState(snapshot.data!),
|
sections:
|
||||||
pieTouchData: PieTouchData(
|
_applyTouchedState(snapshot.data!),
|
||||||
enabled: true,
|
pieTouchData: PieTouchData(
|
||||||
touchCallback: (FlTouchEvent event,
|
enabled: true,
|
||||||
pieTouchResponse) {
|
touchCallback: (FlTouchEvent event,
|
||||||
// 터치 응답이 없거나 섹션이 없는 경우
|
pieTouchResponse) {
|
||||||
if (pieTouchResponse == null ||
|
// 터치 응답이 없거나 섹션이 없는 경우
|
||||||
pieTouchResponse.touchedSection ==
|
if (pieTouchResponse == null ||
|
||||||
null) {
|
pieTouchResponse
|
||||||
// 차트 밖으로 나갔을 때만 리셋
|
.touchedSection ==
|
||||||
if (_touchedIndex != -1) {
|
null) {
|
||||||
setState(() {
|
// 차트 밖으로 나갔을 때만 리셋
|
||||||
_touchedIndex = -1;
|
if (_touchedIndex != -1) {
|
||||||
});
|
setState(() {
|
||||||
|
_touchedIndex = -1;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final touchedIndex = pieTouchResponse
|
final touchedIndex =
|
||||||
.touchedSection!
|
pieTouchResponse.touchedSection!
|
||||||
.touchedSectionIndex;
|
.touchedSectionIndex;
|
||||||
|
|
||||||
// 탭 이벤트 처리 (토글)
|
// 탭 이벤트 처리 (토글)
|
||||||
if (event is FlTapUpEvent) {
|
if (event is FlTapUpEvent) {
|
||||||
setState(() {
|
|
||||||
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
|
||||||
_touchedIndex = (_touchedIndex ==
|
|
||||||
touchedIndex)
|
|
||||||
? -1
|
|
||||||
: touchedIndex;
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// hover 이벤트 처리 (단순 표시)
|
|
||||||
if (event is FlPointerHoverEvent ||
|
|
||||||
event is FlPointerEnterEvent) {
|
|
||||||
// 현재 인덱스와 다른 경우만 업데이트
|
|
||||||
if (_touchedIndex != touchedIndex) {
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_touchedIndex = touchedIndex;
|
// 동일 섹션 탭하면 선택 해제, 아니면 선택
|
||||||
|
_touchedIndex =
|
||||||
|
(_touchedIndex ==
|
||||||
|
touchedIndex)
|
||||||
|
? -1
|
||||||
|
: touchedIndex;
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
// hover 이벤트 처리 (단순 표시)
|
||||||
|
if (event is FlPointerHoverEvent ||
|
||||||
|
event is FlPointerEnterEvent) {
|
||||||
|
// 현재 인덱스와 다른 경우만 업데이트
|
||||||
|
if (_touchedIndex !=
|
||||||
|
touchedIndex) {
|
||||||
|
setState(() {
|
||||||
|
_touchedIndex = touchedIndex;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
swapAnimationDuration:
|
||||||
|
ReduceMotion.isEnabled(context)
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(
|
||||||
|
milliseconds: 300),
|
||||||
|
swapAnimationCurve: Curves.easeOut,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -43,201 +43,204 @@ class TotalExpenseSummaryCard extends StatelessWidget {
|
|||||||
parent: animationController,
|
parent: animationController,
|
||||||
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
curve: const Interval(0.2, 0.8, curve: Curves.easeOut),
|
||||||
)),
|
)),
|
||||||
child: GlassmorphismCard(
|
child: RepaintBoundary(
|
||||||
blur: 10,
|
child: GlassmorphismCard(
|
||||||
opacity: 0.1,
|
blur: 10,
|
||||||
borderRadius: 16,
|
opacity: 0.1,
|
||||||
child: Padding(
|
borderRadius: 16,
|
||||||
padding: const EdgeInsets.all(16),
|
child: Padding(
|
||||||
child: Column(
|
padding: const EdgeInsets.all(16),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
child: Column(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Row(
|
||||||
children: [
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
ThemedText.headline(
|
children: [
|
||||||
text:
|
ThemedText.headline(
|
||||||
AppLocalizations.of(context).totalExpenseSummary,
|
text: AppLocalizations.of(context)
|
||||||
style: const TextStyle(
|
.totalExpenseSummary,
|
||||||
fontSize: 18,
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
IconButton(
|
||||||
IconButton(
|
icon: const Icon(Icons.content_copy),
|
||||||
icon: const Icon(Icons.content_copy),
|
iconSize: 20,
|
||||||
iconSize: 20,
|
padding: EdgeInsets.zero,
|
||||||
padding: EdgeInsets.zero,
|
constraints: const BoxConstraints(),
|
||||||
constraints: const BoxConstraints(),
|
onPressed: () async {
|
||||||
onPressed: () async {
|
final totalExpenseText =
|
||||||
final totalExpenseText =
|
CurrencyUtil.formatTotalAmountWithLocale(
|
||||||
CurrencyUtil.formatTotalAmountWithLocale(
|
totalExpense, locale);
|
||||||
totalExpense, locale);
|
await Clipboard.setData(
|
||||||
await Clipboard.setData(
|
ClipboardData(text: totalExpenseText));
|
||||||
ClipboardData(text: totalExpenseText));
|
HapticFeedbackHelper.lightImpact();
|
||||||
HapticFeedbackHelper.lightImpact();
|
if (!context.mounted) return;
|
||||||
if (!context.mounted) return;
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
SnackBar(
|
||||||
SnackBar(
|
content: Text(AppLocalizations.of(context)
|
||||||
content: Text(AppLocalizations.of(context)
|
.totalExpenseCopied(totalExpenseText)),
|
||||||
.totalExpenseCopied(totalExpenseText)),
|
duration: const Duration(seconds: 2),
|
||||||
duration: const Duration(seconds: 2),
|
behavior: SnackBarBehavior.floating,
|
||||||
behavior: SnackBarBehavior.floating,
|
shape: RoundedRectangleBorder(
|
||||||
shape: RoundedRectangleBorder(
|
borderRadius: BorderRadius.circular(8),
|
||||||
borderRadius: BorderRadius.circular(8),
|
),
|
||||||
|
backgroundColor: AppColors.glassBackground
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
margin: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 8,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
backgroundColor: AppColors.glassBackground
|
);
|
||||||
.withValues(alpha: 0.3),
|
},
|
||||||
margin: const EdgeInsets.symmetric(
|
),
|
||||||
horizontal: 16,
|
],
|
||||||
vertical: 8,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ThemedText.subtitle(
|
|
||||||
text: AppLocalizations.of(context).monthlyTotalAmount,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(height: 8),
|
||||||
const SizedBox(height: 16),
|
ThemedText.subtitle(
|
||||||
Row(
|
text: AppLocalizations.of(context).monthlyTotalAmount,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
style: const TextStyle(
|
||||||
children: [
|
fontSize: 14,
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ThemedText.caption(
|
|
||||||
text: AppLocalizations.of(context).totalExpense,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
ThemedText(
|
|
||||||
CurrencyUtil.formatTotalAmountWithLocale(
|
|
||||||
totalExpense, locale),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
),
|
||||||
Expanded(
|
const SizedBox(height: 16),
|
||||||
child: Column(
|
Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
Row(
|
children: [
|
||||||
children: [
|
Expanded(
|
||||||
Container(
|
child: Column(
|
||||||
padding: const EdgeInsets.all(8),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
color: AppColors.glassBackground
|
ThemedText.caption(
|
||||||
.withValues(alpha: 0.3),
|
text:
|
||||||
borderRadius: BorderRadius.circular(8),
|
AppLocalizations.of(context).totalExpense,
|
||||||
border: Border.all(
|
style: const TextStyle(
|
||||||
color: AppColors.glassBorder
|
fontSize: 12,
|
||||||
.withValues(alpha: 0.2),
|
fontWeight: FontWeight.bold,
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const FaIcon(
|
|
||||||
FontAwesomeIcons.listCheck,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.primaryColor,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
),
|
||||||
Column(
|
const SizedBox(height: 4),
|
||||||
crossAxisAlignment:
|
ThemedText(
|
||||||
CrossAxisAlignment.start,
|
CurrencyUtil.formatTotalAmountWithLocale(
|
||||||
children: [
|
totalExpense, locale),
|
||||||
ThemedText.caption(
|
style: const TextStyle(
|
||||||
text: AppLocalizations.of(context)
|
fontSize: 26,
|
||||||
.totalServices,
|
fontWeight: FontWeight.bold,
|
||||||
style: const TextStyle(
|
letterSpacing: -0.5,
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
ThemedText(
|
|
||||||
AppLocalizations.of(context)
|
|
||||||
.subscriptionCount(
|
|
||||||
subscriptions.length),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
const SizedBox(height: 12),
|
),
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.glassBackground
|
|
||||||
.withValues(alpha: 0.3),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.glassBorder
|
|
||||||
.withValues(alpha: 0.2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const FaIcon(
|
|
||||||
FontAwesomeIcons.chartLine,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.successColor,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment:
|
|
||||||
CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
ThemedText.caption(
|
|
||||||
text: AppLocalizations.of(context)
|
|
||||||
.averageCost,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
ThemedText(
|
|
||||||
CurrencyUtil
|
|
||||||
.formatTotalAmountWithLocale(
|
|
||||||
subscriptions.isEmpty
|
|
||||||
? 0
|
|
||||||
: totalExpense /
|
|
||||||
subscriptions.length,
|
|
||||||
locale),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 16),
|
||||||
],
|
Expanded(
|
||||||
),
|
child: Column(
|
||||||
],
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.glassBackground
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const FaIcon(
|
||||||
|
FontAwesomeIcons.listCheck,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.primaryColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.caption(
|
||||||
|
text: AppLocalizations.of(context)
|
||||||
|
.totalServices,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ThemedText(
|
||||||
|
AppLocalizations.of(context)
|
||||||
|
.subscriptionCount(
|
||||||
|
subscriptions.length),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: AppColors.glassBackground
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.glassBorder
|
||||||
|
.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: const FaIcon(
|
||||||
|
FontAwesomeIcons.chartLine,
|
||||||
|
size: 16,
|
||||||
|
color: AppColors.successColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
ThemedText.caption(
|
||||||
|
text: AppLocalizations.of(context)
|
||||||
|
.averageCost,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
ThemedText(
|
||||||
|
CurrencyUtil
|
||||||
|
.formatTotalAmountWithLocale(
|
||||||
|
subscriptions.isEmpty
|
||||||
|
? 0
|
||||||
|
: totalExpense /
|
||||||
|
subscriptions.length,
|
||||||
|
locale),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 슬라이드 + 페이드 전환
|
/// 슬라이드 + 페이드 전환
|
||||||
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
||||||
@@ -11,8 +12,12 @@ class SlidePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
this.direction = AxisDirection.right,
|
this.direction = AxisDirection.right,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
Offset begin;
|
Offset begin;
|
||||||
switch (direction) {
|
switch (direction) {
|
||||||
@@ -64,8 +69,12 @@ class ScalePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
this.alignment = Alignment.center,
|
this.alignment = Alignment.center,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 400),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 400),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 400),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 400),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
const curve = Curves.elasticOut;
|
const curve = Curves.elasticOut;
|
||||||
|
|
||||||
@@ -98,8 +107,12 @@ class RotatePageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
RotatePageRoute({required this.page})
|
RotatePageRoute({required this.page})
|
||||||
: super(
|
: super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 500),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
const curve = Curves.easeInOut;
|
const curve = Curves.easeInOut;
|
||||||
|
|
||||||
@@ -135,8 +148,12 @@ class FlipPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
this.horizontal = true,
|
this.horizontal = true,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 800),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 800),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 800),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 800),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
final isAnimatingForward =
|
final isAnimatingForward =
|
||||||
animation.status == AnimationStatus.forward;
|
animation.status == AnimationStatus.forward;
|
||||||
@@ -189,8 +206,12 @@ class ContainerTransformPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
this.borderRadius,
|
this.borderRadius,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 500),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 500),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 500),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -260,8 +281,12 @@ class SharedAxisPageRoute<T> extends PageRouteBuilder<T> {
|
|||||||
required this.transitionType,
|
required this.transitionType,
|
||||||
}) : super(
|
}) : super(
|
||||||
pageBuilder: (context, animation, secondaryAnimation) => page,
|
pageBuilder: (context, animation, secondaryAnimation) => page,
|
||||||
transitionDuration: const Duration(milliseconds: 300),
|
transitionDuration: ReduceMotion.platform()
|
||||||
reverseTransitionDuration: const Duration(milliseconds: 300),
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
|
reverseTransitionDuration: ReduceMotion.platform()
|
||||||
|
? Duration.zero
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||||
late final Offset begin;
|
late final Offset begin;
|
||||||
late final Offset end;
|
late final Offset end;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
/// 웨이브 애니메이션 배경 효과를 제공하는 위젯
|
||||||
///
|
///
|
||||||
@@ -16,6 +17,8 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final reduce = ReduceMotion.isEnabled(context);
|
||||||
|
final amp = reduce ? 0.3 : 1.0; // 효과 강도 스케일
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
// 웨이브 애니메이션 배경 요소 - 사인/코사인 함수 대신 더 부드러운 곡선 사용
|
||||||
@@ -25,15 +28,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
// 0~1 사이의 값을 0~2π 사이의 값으로 변환하여 부드러운 주기 생성
|
||||||
final angle = controller.value * 2 * math.pi;
|
final angle = controller.value * 2 * math.pi;
|
||||||
// 사인 함수를 사용하여 부드러운 움직임 생성
|
// 사인 함수를 사용하여 부드러운 움직임 생성
|
||||||
final xOffset = 20 * math.sin(angle);
|
final xOffset = 20 * amp * math.sin(angle);
|
||||||
final yOffset = 10 * math.cos(angle);
|
final yOffset = 10 * amp * math.cos(angle);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: -40 + xOffset,
|
right: -40 + xOffset,
|
||||||
top: -60 + yOffset,
|
top: -60 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
// 회전도 선형적으로 변화하도록 수정
|
// 회전도 선형적으로 변화하도록 수정
|
||||||
angle: 0.2 * math.sin(angle * 0.5),
|
angle: 0.2 * amp * math.sin(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 200,
|
width: 200,
|
||||||
height: 200,
|
height: 200,
|
||||||
@@ -51,15 +54,15 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
// 첫 번째 원과 약간 다른 위상을 가지도록 설정
|
||||||
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
final angle = (controller.value * 2 * math.pi) + (math.pi / 3);
|
||||||
final xOffset = 20 * math.cos(angle);
|
final xOffset = 20 * amp * math.cos(angle);
|
||||||
final yOffset = 10 * math.sin(angle);
|
final yOffset = 10 * amp * math.sin(angle);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
left: -80 + xOffset,
|
left: -80 + xOffset,
|
||||||
bottom: -70 + yOffset,
|
bottom: -70 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
// 반대 방향으로 회전하도록 설정
|
// 반대 방향으로 회전하도록 설정
|
||||||
angle: -0.3 * math.sin(angle * 0.5),
|
angle: -0.3 * amp * math.sin(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 220,
|
width: 220,
|
||||||
height: 220,
|
height: 220,
|
||||||
@@ -78,14 +81,14 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
// 세 번째 원은 다른 위상으로 움직이도록 설정
|
||||||
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
final angle = (controller.value * 2 * math.pi) + (math.pi * 2 / 3);
|
||||||
final xOffset = 15 * math.sin(angle * 0.7);
|
final xOffset = 15 * amp * math.sin(angle * 0.7);
|
||||||
final yOffset = 8 * math.cos(angle * 0.7);
|
final yOffset = 8 * amp * math.cos(angle * 0.7);
|
||||||
|
|
||||||
return Positioned(
|
return Positioned(
|
||||||
right: 40 + xOffset,
|
right: 40 + xOffset,
|
||||||
bottom: -40 + yOffset,
|
bottom: -40 + yOffset,
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: 0.4 * math.cos(angle * 0.5),
|
angle: 0.4 * amp * math.cos(angle * 0.5),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 120,
|
width: 120,
|
||||||
height: 120,
|
height: 120,
|
||||||
@@ -110,8 +113,7 @@ class AnimatedWaveBackground extends StatelessWidget {
|
|||||||
height: 30,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(
|
color: Colors.white.withValues(
|
||||||
alpha: 0.1 + 0.1 * pulseController.value,
|
alpha: reduce ? 0.08 : 0.1 + 0.1 * pulseController.value),
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(15),
|
borderRadius: BorderRadius.circular(15),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,173 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import '../../../theme/app_colors.dart';
|
|
||||||
|
|
||||||
/// 위험한 액션에 사용되는 Danger 버튼
|
|
||||||
/// 삭제, 취소, 종료 등의 위험한 액션에 사용됩니다.
|
|
||||||
class DangerButton extends StatefulWidget {
|
|
||||||
final String text;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
final bool requireConfirmation;
|
|
||||||
final String? confirmationTitle;
|
|
||||||
final String? confirmationMessage;
|
|
||||||
final IconData? icon;
|
|
||||||
final double? width;
|
|
||||||
final double height;
|
|
||||||
final double fontSize;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
final double borderRadius;
|
|
||||||
final bool enableHoverEffect;
|
|
||||||
|
|
||||||
const DangerButton({
|
|
||||||
super.key,
|
|
||||||
required this.text,
|
|
||||||
this.onPressed,
|
|
||||||
this.requireConfirmation = false,
|
|
||||||
this.confirmationTitle,
|
|
||||||
this.confirmationMessage,
|
|
||||||
this.icon,
|
|
||||||
this.width,
|
|
||||||
this.height = 60,
|
|
||||||
this.fontSize = 18,
|
|
||||||
this.padding,
|
|
||||||
this.borderRadius = 16,
|
|
||||||
this.enableHoverEffect = true,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DangerButton> createState() => _DangerButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DangerButtonState extends State<DangerButton> {
|
|
||||||
bool _isHovered = false;
|
|
||||||
|
|
||||||
static const Color _dangerColor = AppColors.dangerColor;
|
|
||||||
|
|
||||||
Future<void> _handlePress() async {
|
|
||||||
if (widget.requireConfirmation) {
|
|
||||||
final confirmed = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
title: Text(
|
|
||||||
widget.confirmationTitle ?? widget.text,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 20,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
content: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: _dangerColor.withValues(alpha: 0.1),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
widget.icon ?? Icons.warning_amber_rounded,
|
|
||||||
color: _dangerColor,
|
|
||||||
size: 48,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
widget.confirmationMessage ?? '이 작업은 되돌릴 수 없습니다.\n계속하시겠습니까?',
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
height: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(false),
|
|
||||||
child: const Text('취소'),
|
|
||||||
),
|
|
||||||
ElevatedButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(true),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: _dangerColor,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
widget.text,
|
|
||||||
style: const TextStyle(color: AppColors.pureWhite),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (confirmed == true) {
|
|
||||||
widget.onPressed?.call();
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
widget.onPressed?.call();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Widget button = AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
width: widget.width ?? double.infinity,
|
|
||||||
height: widget.height,
|
|
||||||
transform: widget.enableHoverEffect && _isHovered
|
|
||||||
? (Matrix4.identity()..scale(1.02))
|
|
||||||
: Matrix4.identity(),
|
|
||||||
child: ElevatedButton(
|
|
||||||
onPressed: widget.onPressed != null ? _handlePress : null,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: _dangerColor,
|
|
||||||
foregroundColor: AppColors.pureWhite,
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(widget.borderRadius),
|
|
||||||
),
|
|
||||||
padding: widget.padding ?? const EdgeInsets.symmetric(vertical: 16),
|
|
||||||
elevation: widget.enableHoverEffect && _isHovered ? 2 : 0,
|
|
||||||
shadowColor: Colors.black.withValues(alpha: 0.08),
|
|
||||||
disabledBackgroundColor: _dangerColor.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (widget.icon != null) ...[
|
|
||||||
Icon(
|
|
||||||
widget.icon,
|
|
||||||
color: AppColors.pureWhite,
|
|
||||||
size: _isHovered ? 24 : 20,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
],
|
|
||||||
Text(
|
|
||||||
widget.text,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: widget.fontSize,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.pureWhite,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (widget.enableHoverEffect) {
|
|
||||||
return MouseRegion(
|
|
||||||
onEnter: (_) => setState(() => _isHovered = true),
|
|
||||||
onExit: (_) => setState(() => _isHovered = false),
|
|
||||||
child: button,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return button;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,230 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// 섹션별 컨텐츠를 감싸는 기본 카드 위젯
|
|
||||||
/// 폼 섹션, 정보 표시 섹션 등에 사용됩니다.
|
|
||||||
class SectionCard extends StatelessWidget {
|
|
||||||
final String? title;
|
|
||||||
final Widget child;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
final EdgeInsetsGeometry? margin;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final double borderRadius;
|
|
||||||
final List<BoxShadow>? boxShadow;
|
|
||||||
final Border? border;
|
|
||||||
final double? height;
|
|
||||||
final double? width;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const SectionCard({
|
|
||||||
super.key,
|
|
||||||
this.title,
|
|
||||||
required this.child,
|
|
||||||
this.padding,
|
|
||||||
this.margin,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.borderRadius = 20,
|
|
||||||
this.boxShadow,
|
|
||||||
this.border,
|
|
||||||
this.height,
|
|
||||||
this.width,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
final effectiveBackgroundColor = backgroundColor ?? Colors.white;
|
|
||||||
final effectiveShadow = boxShadow ??
|
|
||||||
[
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.05),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
Widget card = Container(
|
|
||||||
height: height,
|
|
||||||
width: width,
|
|
||||||
margin: margin,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: effectiveBackgroundColor,
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
boxShadow: effectiveShadow,
|
|
||||||
border: border,
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: padding ?? const EdgeInsets.all(20),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (title != null) ...[
|
|
||||||
Text(
|
|
||||||
title!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
],
|
|
||||||
child,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onTap != null) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
child: card,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 투명한 배경의 섹션 카드
|
|
||||||
/// 어두운 배경 위에서 사용하기 적합합니다.
|
|
||||||
class TransparentSectionCard extends StatelessWidget {
|
|
||||||
final String? title;
|
|
||||||
final Widget child;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
final EdgeInsetsGeometry? margin;
|
|
||||||
final double opacity;
|
|
||||||
final double borderRadius;
|
|
||||||
final Color? borderColor;
|
|
||||||
final VoidCallback? onTap;
|
|
||||||
|
|
||||||
const TransparentSectionCard({
|
|
||||||
super.key,
|
|
||||||
this.title,
|
|
||||||
required this.child,
|
|
||||||
this.padding,
|
|
||||||
this.margin,
|
|
||||||
this.opacity = 0.15,
|
|
||||||
this.borderRadius = 16,
|
|
||||||
this.borderColor,
|
|
||||||
this.onTap,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
Widget card = Container(
|
|
||||||
margin: margin,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: opacity),
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
border: borderColor != null
|
|
||||||
? Border.all(color: borderColor!, width: 1)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Padding(
|
|
||||||
padding: padding ?? const EdgeInsets.all(16),
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
if (title != null) ...[
|
|
||||||
Text(
|
|
||||||
title!,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: Colors.white.withValues(alpha: 0.9),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
],
|
|
||||||
child,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (onTap != null) {
|
|
||||||
return InkWell(
|
|
||||||
onTap: onTap,
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
child: card,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 정보 표시용 카드
|
|
||||||
/// 읽기 전용 정보를 표시할 때 사용합니다.
|
|
||||||
class InfoCard extends StatelessWidget {
|
|
||||||
final String label;
|
|
||||||
final String value;
|
|
||||||
final IconData? icon;
|
|
||||||
final Color? iconColor;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
final double borderRadius;
|
|
||||||
|
|
||||||
const InfoCard({
|
|
||||||
super.key,
|
|
||||||
required this.label,
|
|
||||||
required this.value,
|
|
||||||
this.icon,
|
|
||||||
this.iconColor,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.padding,
|
|
||||||
this.borderRadius = 12,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final theme = Theme.of(context);
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
padding: padding ?? const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: backgroundColor ?? theme.colorScheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
if (icon != null) ...[
|
|
||||||
Icon(
|
|
||||||
icon,
|
|
||||||
size: 24,
|
|
||||||
color: iconColor ?? theme.colorScheme.primary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
],
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
label,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
color: theme.colorScheme.onSurface.withValues(alpha: 0.6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
value,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: theme.colorScheme.onSurface,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,240 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// 로딩 오버레이 위젯
|
|
||||||
/// 비동기 작업 중 화면을 덮는 로딩 인디케이터를 표시합니다.
|
|
||||||
class LoadingOverlay extends StatelessWidget {
|
|
||||||
final bool isLoading;
|
|
||||||
final Widget child;
|
|
||||||
final String? message;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final Color? indicatorColor;
|
|
||||||
final double opacity;
|
|
||||||
|
|
||||||
const LoadingOverlay({
|
|
||||||
super.key,
|
|
||||||
required this.isLoading,
|
|
||||||
required this.child,
|
|
||||||
this.message,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.indicatorColor,
|
|
||||||
this.opacity = 0.7,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
child,
|
|
||||||
if (isLoading)
|
|
||||||
Container(
|
|
||||||
color: (backgroundColor ?? Colors.black).withValues(alpha: opacity),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withValues(alpha: 0.1),
|
|
||||||
blurRadius: 10,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color: indicatorColor ?? Theme.of(context).primaryColor,
|
|
||||||
),
|
|
||||||
if (message != null) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
message!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 로딩 다이얼로그
|
|
||||||
/// 모달 형태의 로딩 인디케이터를 표시합니다.
|
|
||||||
class LoadingDialog {
|
|
||||||
static Future<void> show({
|
|
||||||
required BuildContext context,
|
|
||||||
String? message,
|
|
||||||
Color? barrierColor,
|
|
||||||
bool barrierDismissible = false,
|
|
||||||
}) {
|
|
||||||
return showDialog(
|
|
||||||
context: context,
|
|
||||||
barrierDismissible: barrierDismissible,
|
|
||||||
barrierColor: barrierColor ?? Colors.black54,
|
|
||||||
builder: (context) => PopScope(
|
|
||||||
canPop: barrierDismissible,
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
CircularProgressIndicator(
|
|
||||||
color: Theme.of(context).primaryColor,
|
|
||||||
),
|
|
||||||
if (message != null) ...[
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Text(
|
|
||||||
message,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
static void hide(BuildContext context) {
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 커스텀 로딩 인디케이터
|
|
||||||
/// 다양한 스타일의 로딩 애니메이션을 제공합니다.
|
|
||||||
class CustomLoadingIndicator extends StatefulWidget {
|
|
||||||
final double size;
|
|
||||||
final Color? color;
|
|
||||||
final LoadingStyle style;
|
|
||||||
|
|
||||||
const CustomLoadingIndicator({
|
|
||||||
super.key,
|
|
||||||
this.size = 50,
|
|
||||||
this.color,
|
|
||||||
this.style = LoadingStyle.circular,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<CustomLoadingIndicator> createState() => _CustomLoadingIndicatorState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _CustomLoadingIndicatorState extends State<CustomLoadingIndicator>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
late Animation<double> _animation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(seconds: 1),
|
|
||||||
vsync: this,
|
|
||||||
)..repeat();
|
|
||||||
|
|
||||||
_animation = CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final effectiveColor = widget.color ?? Theme.of(context).primaryColor;
|
|
||||||
|
|
||||||
switch (widget.style) {
|
|
||||||
case LoadingStyle.circular:
|
|
||||||
return SizedBox(
|
|
||||||
width: widget.size,
|
|
||||||
height: widget.size,
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: effectiveColor,
|
|
||||||
strokeWidth: 3,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
case LoadingStyle.dots:
|
|
||||||
return SizedBox(
|
|
||||||
width: widget.size,
|
|
||||||
height: widget.size / 3,
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
|
||||||
children: List.generate(3, (index) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _animation,
|
|
||||||
builder: (context, child) {
|
|
||||||
final delay = index * 0.2;
|
|
||||||
final value = (_animation.value - delay).clamp(0.0, 1.0);
|
|
||||||
return Container(
|
|
||||||
width: widget.size / 5,
|
|
||||||
height: widget.size / 5,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color:
|
|
||||||
effectiveColor.withValues(alpha: 0.3 + value * 0.7),
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
case LoadingStyle.pulse:
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _animation,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Container(
|
|
||||||
width: widget.size,
|
|
||||||
height: widget.size,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: effectiveColor.withValues(alpha: 0.3),
|
|
||||||
),
|
|
||||||
child: Center(
|
|
||||||
child: Container(
|
|
||||||
width: widget.size * (0.3 + _animation.value * 0.5),
|
|
||||||
height: widget.size * (0.3 + _animation.value * 0.5),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color:
|
|
||||||
effectiveColor.withValues(alpha: 1 - _animation.value),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum LoadingStyle {
|
|
||||||
circular,
|
|
||||||
dots,
|
|
||||||
pulse,
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
import '../../utils/reduce_motion.dart';
|
||||||
import '../../theme/app_colors.dart';
|
import '../../theme/app_colors.dart';
|
||||||
import '../common/buttons/primary_button.dart';
|
import '../common/buttons/primary_button.dart';
|
||||||
import '../common/buttons/secondary_button.dart';
|
import '../common/buttons/secondary_button.dart';
|
||||||
@@ -27,7 +28,10 @@ class DeleteConfirmationDialog extends StatelessWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: ReduceMotion.scale(context, normal: 10, reduced: 4),
|
||||||
|
sigmaY: ReduceMotion.scale(context, normal: 10, reduced: 4),
|
||||||
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.glassCard.withValues(alpha: 0.8),
|
color: AppColors.glassCard.withValues(alpha: 0.8),
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import 'glassmorphism_card.dart';
|
|||||||
import 'themed_text.dart';
|
import 'themed_text.dart';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
/// 구독이 없을 때 표시되는 빈 화면 위젯
|
||||||
///
|
///
|
||||||
@@ -25,102 +26,110 @@ class EmptyStateWidget extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final beginOffset = ReduceMotion.isEnabled(context)
|
||||||
|
? const Offset(0, 0.05)
|
||||||
|
: const Offset(0, 0.2);
|
||||||
return FadeTransition(
|
return FadeTransition(
|
||||||
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
|
||||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: SlideTransition(
|
child: SlideTransition(
|
||||||
position: Tween<Offset>(
|
position: Tween<Offset>(
|
||||||
begin: const Offset(0, 0.2),
|
begin: beginOffset,
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(CurvedAnimation(
|
).animate(CurvedAnimation(
|
||||||
parent: slideController, curve: Curves.easeOutBack)),
|
parent: slideController, curve: Curves.easeOutBack)),
|
||||||
child: GlassmorphismCard(
|
child: RepaintBoundary(
|
||||||
width: null,
|
child: GlassmorphismCard(
|
||||||
margin: const EdgeInsets.all(16),
|
width: null,
|
||||||
padding: const EdgeInsets.all(32),
|
margin: const EdgeInsets.all(16),
|
||||||
child: Column(
|
padding: const EdgeInsets.all(32),
|
||||||
mainAxisSize: MainAxisSize.min,
|
child: Column(
|
||||||
children: [
|
mainAxisSize: MainAxisSize.min,
|
||||||
AnimatedBuilder(
|
children: [
|
||||||
animation: rotateController,
|
AnimatedBuilder(
|
||||||
builder: (context, child) {
|
animation: rotateController,
|
||||||
return Transform.rotate(
|
builder: (context, child) {
|
||||||
angle: rotateController.value * 2 * math.pi,
|
final angleScale =
|
||||||
child: Container(
|
ReduceMotion.isEnabled(context) ? 0.2 : 1.0;
|
||||||
padding: const EdgeInsets.all(24),
|
return Transform.rotate(
|
||||||
decoration: BoxDecoration(
|
angle:
|
||||||
gradient: const LinearGradient(
|
angleScale * rotateController.value * 2 * math.pi,
|
||||||
colors: AppColors.blueGradient,
|
child: Container(
|
||||||
begin: Alignment.topLeft,
|
padding: const EdgeInsets.all(24),
|
||||||
end: Alignment.bottomRight,
|
decoration: BoxDecoration(
|
||||||
),
|
gradient: const LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(16),
|
colors: AppColors.blueGradient,
|
||||||
boxShadow: [
|
begin: Alignment.topLeft,
|
||||||
BoxShadow(
|
end: Alignment.bottomRight,
|
||||||
color:
|
|
||||||
AppColors.primaryColor.withValues(alpha: 0.3),
|
|
||||||
spreadRadius: 0,
|
|
||||||
blurRadius: 16,
|
|
||||||
offset: const Offset(0, 8),
|
|
||||||
),
|
),
|
||||||
],
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.primaryColor
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
spreadRadius: 0,
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.subscriptions_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: AppColors.pureWhite,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
);
|
||||||
Icons.subscriptions_outlined,
|
},
|
||||||
size: 48,
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
ThemedText(
|
||||||
|
AppLocalizations.of(context).noSubscriptions,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
letterSpacing: -0.5,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
ThemedText(
|
||||||
|
AppLocalizations.of(context).addSubscriptionNow,
|
||||||
|
fontSize: 16,
|
||||||
|
opacity: 0.7,
|
||||||
|
),
|
||||||
|
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: AppColors.primaryColor,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
onAddPressed();
|
||||||
|
},
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context).addSubscription,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.5,
|
||||||
color: AppColors.pureWhite,
|
color: AppColors.pureWhite,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 32),
|
|
||||||
ThemedText(
|
|
||||||
AppLocalizations.of(context).noSubscriptions,
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
ThemedText(
|
|
||||||
AppLocalizations.of(context).addSubscriptionNow,
|
|
||||||
fontSize: 16,
|
|
||||||
opacity: 0.7,
|
|
||||||
),
|
|
||||||
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: AppColors.primaryColor,
|
|
||||||
),
|
|
||||||
onPressed: () {
|
|
||||||
HapticFeedback.mediumImpact();
|
|
||||||
onAddPressed();
|
|
||||||
},
|
|
||||||
child: Text(
|
|
||||||
AppLocalizations.of(context).addSubscription,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: 0.5,
|
|
||||||
color: AppColors.pureWhite,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
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 const Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.end,
|
|
||||||
children: [
|
|
||||||
// 이 위젯은 이제 환율 정보만 제공하고, 실제 UI는 스크린에서 구성
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 익스포즈드 메서드: 환율 정보 문자열 가져오기
|
|
||||||
String get exchangeRateInfo => _exchangeRateInfo;
|
|
||||||
|
|
||||||
// 익스포즈드 메서드: 변환된 금액 문자열 가져오기
|
|
||||||
String get convertedAmount => _convertedAmount;
|
|
||||||
}
|
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'dart:math' as math;
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import '../utils/haptic_feedback_helper.dart';
|
|
||||||
import 'glassmorphism_card.dart';
|
|
||||||
|
|
||||||
class ExpandableFab extends StatefulWidget {
|
|
||||||
final List<FabAction> actions;
|
|
||||||
final double distance;
|
|
||||||
|
|
||||||
const ExpandableFab({
|
|
||||||
super.key,
|
|
||||||
required this.actions,
|
|
||||||
this.distance = 100.0,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<ExpandableFab> createState() => _ExpandableFabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _ExpandableFabState extends State<ExpandableFab>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
late Animation<double> _expandAnimation;
|
|
||||||
late Animation<double> _rotateAnimation;
|
|
||||||
bool _isExpanded = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_expandAnimation = CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.easeOutBack,
|
|
||||||
reverseCurve: Curves.easeInBack,
|
|
||||||
);
|
|
||||||
|
|
||||||
_rotateAnimation = Tween<double>(
|
|
||||||
begin: 0.0,
|
|
||||||
end: math.pi / 4,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _toggle() {
|
|
||||||
setState(() {
|
|
||||||
_isExpanded = !_isExpanded;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (_isExpanded) {
|
|
||||||
HapticFeedbackHelper.mediumImpact();
|
|
||||||
_controller.forward();
|
|
||||||
} else {
|
|
||||||
HapticFeedbackHelper.lightImpact();
|
|
||||||
_controller.reverse();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Stack(
|
|
||||||
alignment: Alignment.bottomRight,
|
|
||||||
children: [
|
|
||||||
// 배경 오버레이 (확장 시)
|
|
||||||
if (_isExpanded)
|
|
||||||
GestureDetector(
|
|
||||||
onTap: _toggle,
|
|
||||||
child: AnimatedBuilder(
|
|
||||||
animation: _expandAnimation,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Container(
|
|
||||||
color: AppColors.shadowBlack
|
|
||||||
.withValues(alpha: 3.75 * _expandAnimation.value),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
|
|
||||||
// 액션 버튼들
|
|
||||||
...widget.actions.asMap().entries.map((entry) {
|
|
||||||
final index = entry.key;
|
|
||||||
final action = entry.value;
|
|
||||||
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
|
||||||
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _expandAnimation,
|
|
||||||
builder: (context, child) {
|
|
||||||
final distance = widget.distance * _expandAnimation.value;
|
|
||||||
final x = distance * math.cos(angle);
|
|
||||||
final y = distance * math.sin(angle);
|
|
||||||
|
|
||||||
return Transform.translate(
|
|
||||||
offset: Offset(-x, -y),
|
|
||||||
child: ScaleTransition(
|
|
||||||
scale: _expandAnimation,
|
|
||||||
child: FloatingActionButton.small(
|
|
||||||
heroTag: 'fab_action_$index',
|
|
||||||
onPressed: _isExpanded
|
|
||||||
? () {
|
|
||||||
HapticFeedbackHelper.lightImpact();
|
|
||||||
_toggle();
|
|
||||||
action.onPressed();
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
backgroundColor: action.color ?? AppColors.primaryColor,
|
|
||||||
child: Icon(
|
|
||||||
action.icon,
|
|
||||||
size: 20,
|
|
||||||
color: AppColors.pureWhite,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
|
|
||||||
// 메인 FAB
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: _rotateAnimation,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.rotate(
|
|
||||||
angle: _rotateAnimation.value,
|
|
||||||
child: FloatingActionButton(
|
|
||||||
onPressed: _toggle,
|
|
||||||
backgroundColor: AppColors.primaryColor,
|
|
||||||
child: Icon(
|
|
||||||
_isExpanded ? Icons.close : Icons.add,
|
|
||||||
size: 28,
|
|
||||||
color: Colors.white,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
|
|
||||||
// 라벨 표시
|
|
||||||
if (_isExpanded)
|
|
||||||
...widget.actions.asMap().entries.map((entry) {
|
|
||||||
final index = entry.key;
|
|
||||||
final action = entry.value;
|
|
||||||
final angle = (index + 1) * (math.pi / 2 / widget.actions.length);
|
|
||||||
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _expandAnimation,
|
|
||||||
builder: (context, child) {
|
|
||||||
final distance = widget.distance * _expandAnimation.value;
|
|
||||||
final x = distance * math.cos(angle);
|
|
||||||
final y = distance * math.sin(angle);
|
|
||||||
|
|
||||||
return Transform.translate(
|
|
||||||
offset: Offset(-x - 80, -y),
|
|
||||||
child: FadeTransition(
|
|
||||||
opacity: _expandAnimation,
|
|
||||||
child: GlassmorphismCard(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 12,
|
|
||||||
vertical: 6,
|
|
||||||
),
|
|
||||||
borderRadius: 8,
|
|
||||||
blur: 10,
|
|
||||||
child: Text(
|
|
||||||
action.label,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.darkNavy,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class FabAction {
|
|
||||||
final IconData icon;
|
|
||||||
final String label;
|
|
||||||
final VoidCallback onPressed;
|
|
||||||
final Color? color;
|
|
||||||
|
|
||||||
const FabAction({
|
|
||||||
required this.icon,
|
|
||||||
required this.label,
|
|
||||||
required this.onPressed,
|
|
||||||
this.color,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 드래그 가능한 FAB
|
|
||||||
class DraggableFab extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final EdgeInsets? padding;
|
|
||||||
|
|
||||||
const DraggableFab({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.padding,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<DraggableFab> createState() => _DraggableFabState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _DraggableFabState extends State<DraggableFab> {
|
|
||||||
Offset _position = const Offset(20, 20);
|
|
||||||
bool _isDragging = false;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final screenSize = MediaQuery.of(context).size;
|
|
||||||
final padding = widget.padding ?? const EdgeInsets.all(20);
|
|
||||||
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Positioned(
|
|
||||||
right: _position.dx,
|
|
||||||
bottom: _position.dy,
|
|
||||||
child: GestureDetector(
|
|
||||||
onPanStart: (_) {
|
|
||||||
setState(() => _isDragging = true);
|
|
||||||
HapticFeedbackHelper.lightImpact();
|
|
||||||
},
|
|
||||||
onPanUpdate: (details) {
|
|
||||||
setState(() {
|
|
||||||
_position = Offset(
|
|
||||||
(_position.dx - details.delta.dx).clamp(
|
|
||||||
padding.right,
|
|
||||||
screenSize.width - 100 - padding.left,
|
|
||||||
),
|
|
||||||
(_position.dy - details.delta.dy).clamp(
|
|
||||||
padding.bottom,
|
|
||||||
screenSize.height - 200 - padding.top,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onPanEnd: (_) {
|
|
||||||
setState(() => _isDragging = false);
|
|
||||||
HapticFeedbackHelper.lightImpact();
|
|
||||||
},
|
|
||||||
child: AnimatedScale(
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
scale: _isDragging ? 0.9 : 1.0,
|
|
||||||
child: widget.child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ import '../theme/app_colors.dart';
|
|||||||
import 'glassmorphism_card.dart';
|
import 'glassmorphism_card.dart';
|
||||||
import '../l10n/app_localizations.dart';
|
import '../l10n/app_localizations.dart';
|
||||||
import '../utils/platform_helper.dart';
|
import '../utils/platform_helper.dart';
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
class FloatingNavigationBar extends StatefulWidget {
|
class FloatingNavigationBar extends StatefulWidget {
|
||||||
final int selectedIndex;
|
final int selectedIndex;
|
||||||
@@ -30,7 +31,9 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: ReduceMotion.platform()
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_animation = CurvedAnimation(
|
_animation = CurvedAnimation(
|
||||||
@@ -72,9 +75,13 @@ class _FloatingNavigationBarState extends State<FloatingNavigationBar>
|
|||||||
right: 16,
|
right: 16,
|
||||||
height: 88,
|
height: 88,
|
||||||
child: Transform.translate(
|
child: Transform.translate(
|
||||||
offset: Offset(0, 100 * (1 - _animation.value)),
|
offset: Offset(
|
||||||
|
0,
|
||||||
|
ReduceMotion.isEnabled(context)
|
||||||
|
? 0
|
||||||
|
: 100 * (1 - _animation.value)),
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
opacity: _animation.value,
|
opacity: ReduceMotion.isEnabled(context) ? 1 : _animation.value,
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.all(4), // 그림자 공간 확보
|
margin: const EdgeInsets.all(4), // 그림자 공간 확보
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -220,12 +227,14 @@ class _AddButtonState extends State<_AddButton>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 150),
|
duration: ReduceMotion.platform()
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 150),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_scaleAnimation = Tween<double>(
|
_scaleAnimation = Tween<double>(
|
||||||
begin: 1.0,
|
begin: 1.0,
|
||||||
end: 0.9,
|
end: ReduceMotion.platform() ? 1.0 : 0.9,
|
||||||
).animate(CurvedAnimation(
|
).animate(CurvedAnimation(
|
||||||
parent: _controller,
|
parent: _controller,
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
|
|||||||
@@ -1,320 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'dart:ui';
|
|
||||||
import '../theme/app_colors.dart';
|
|
||||||
import 'themed_text.dart';
|
|
||||||
import '../l10n/app_localizations.dart';
|
|
||||||
|
|
||||||
/// 글래스모피즘 효과가 적용된 통일된 앱바
|
|
||||||
class GlassmorphicAppBar extends StatelessWidget
|
|
||||||
implements PreferredSizeWidget {
|
|
||||||
final String title;
|
|
||||||
final List<Widget>? actions;
|
|
||||||
final Widget? leading;
|
|
||||||
final bool automaticallyImplyLeading;
|
|
||||||
final double elevation;
|
|
||||||
final Color? backgroundColor;
|
|
||||||
final double blur;
|
|
||||||
final double opacity;
|
|
||||||
final PreferredSizeWidget? bottom;
|
|
||||||
final bool centerTitle;
|
|
||||||
final double? titleSpacing;
|
|
||||||
final VoidCallback? onBackPressed;
|
|
||||||
|
|
||||||
const GlassmorphicAppBar({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
this.actions,
|
|
||||||
this.leading,
|
|
||||||
this.automaticallyImplyLeading = true,
|
|
||||||
this.elevation = 0,
|
|
||||||
this.backgroundColor,
|
|
||||||
this.blur = 20,
|
|
||||||
this.opacity = 0.1,
|
|
||||||
this.bottom,
|
|
||||||
this.centerTitle = false,
|
|
||||||
this.titleSpacing,
|
|
||||||
this.onBackPressed,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Size get preferredSize => Size.fromHeight(
|
|
||||||
kToolbarHeight + (bottom?.preferredSize.height ?? 0.0) + 0.5);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
final canPop = Navigator.of(context).canPop();
|
|
||||||
|
|
||||||
return ClipRRect(
|
|
||||||
child: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
(backgroundColor ??
|
|
||||||
(isDarkMode
|
|
||||||
? AppColors.glassBackgroundDark
|
|
||||||
: AppColors.glassBackground))
|
|
||||||
.withValues(alpha: opacity),
|
|
||||||
(backgroundColor ??
|
|
||||||
(isDarkMode
|
|
||||||
? AppColors.glassSurfaceDark
|
|
||||||
: AppColors.glassSurface))
|
|
||||||
.withValues(alpha: opacity * 0.8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: isDarkMode
|
|
||||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
|
||||||
: AppColors.glassBorder.withValues(alpha: 0.5),
|
|
||||||
width: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: SafeArea(
|
|
||||||
bottom: false,
|
|
||||||
child: ClipRect(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
Flexible(
|
|
||||||
child: SizedBox(
|
|
||||||
height: kToolbarHeight,
|
|
||||||
child: NavigationToolbar(
|
|
||||||
leading: leading ??
|
|
||||||
(automaticallyImplyLeading &&
|
|
||||||
(canPop || onBackPressed != null)
|
|
||||||
? _buildBackButton(context)
|
|
||||||
: null),
|
|
||||||
middle: _buildTitle(context),
|
|
||||||
trailing: actions != null
|
|
||||||
? Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: actions!,
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
centerMiddle: centerTitle,
|
|
||||||
middleSpacing:
|
|
||||||
titleSpacing ?? NavigationToolbar.kMiddleSpacing,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (bottom != null) bottom!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildBackButton(BuildContext context) {
|
|
||||||
return IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
|
||||||
onPressed: onBackPressed ??
|
|
||||||
() {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
splashRadius: 24,
|
|
||||||
tooltip: AppLocalizations.of(context).back,
|
|
||||||
color: ThemedText.getContrastColor(context),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTitle(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
child: ThemedText.headline(
|
|
||||||
text: title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 투명 스타일 팩토리
|
|
||||||
static GlassmorphicAppBar transparent({
|
|
||||||
required String title,
|
|
||||||
List<Widget>? actions,
|
|
||||||
Widget? leading,
|
|
||||||
VoidCallback? onBackPressed,
|
|
||||||
}) {
|
|
||||||
return GlassmorphicAppBar(
|
|
||||||
title: title,
|
|
||||||
actions: actions,
|
|
||||||
leading: leading,
|
|
||||||
blur: 30,
|
|
||||||
opacity: 0.05,
|
|
||||||
onBackPressed: onBackPressed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 반투명 스타일 팩토리
|
|
||||||
static GlassmorphicAppBar translucent({
|
|
||||||
required String title,
|
|
||||||
List<Widget>? actions,
|
|
||||||
Widget? leading,
|
|
||||||
VoidCallback? onBackPressed,
|
|
||||||
}) {
|
|
||||||
return GlassmorphicAppBar(
|
|
||||||
title: title,
|
|
||||||
actions: actions,
|
|
||||||
leading: leading,
|
|
||||||
blur: 20,
|
|
||||||
opacity: 0.15,
|
|
||||||
onBackPressed: onBackPressed,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 확장된 글래스모피즘 앱바 (이미지나 추가 콘텐츠 포함)
|
|
||||||
class GlassmorphicSliverAppBar extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final List<Widget>? actions;
|
|
||||||
final Widget? leading;
|
|
||||||
final double expandedHeight;
|
|
||||||
final bool floating;
|
|
||||||
final bool pinned;
|
|
||||||
final bool snap;
|
|
||||||
final Widget? flexibleSpace;
|
|
||||||
final double blur;
|
|
||||||
final double opacity;
|
|
||||||
final bool automaticallyImplyLeading;
|
|
||||||
final VoidCallback? onBackPressed;
|
|
||||||
final bool centerTitle;
|
|
||||||
|
|
||||||
const GlassmorphicSliverAppBar({
|
|
||||||
super.key,
|
|
||||||
required this.title,
|
|
||||||
this.actions,
|
|
||||||
this.leading,
|
|
||||||
this.expandedHeight = kToolbarHeight,
|
|
||||||
this.floating = false,
|
|
||||||
this.pinned = true,
|
|
||||||
this.snap = false,
|
|
||||||
this.flexibleSpace,
|
|
||||||
this.blur = 20,
|
|
||||||
this.opacity = 0.1,
|
|
||||||
this.automaticallyImplyLeading = true,
|
|
||||||
this.onBackPressed,
|
|
||||||
this.centerTitle = false,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
||||||
final canPop = Navigator.of(context).canPop();
|
|
||||||
|
|
||||||
return SliverAppBar(
|
|
||||||
expandedHeight: expandedHeight,
|
|
||||||
floating: floating,
|
|
||||||
pinned: pinned,
|
|
||||||
snap: snap,
|
|
||||||
backgroundColor: Colors.transparent,
|
|
||||||
elevation: 0,
|
|
||||||
automaticallyImplyLeading: false,
|
|
||||||
leading: leading ??
|
|
||||||
(automaticallyImplyLeading && (canPop || onBackPressed != null)
|
|
||||||
? IconButton(
|
|
||||||
icon: const Icon(Icons.arrow_back_ios_new_rounded),
|
|
||||||
onPressed: onBackPressed ??
|
|
||||||
() {
|
|
||||||
HapticFeedback.lightImpact();
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
|
||||||
splashRadius: 24,
|
|
||||||
tooltip: AppLocalizations.of(context).back,
|
|
||||||
)
|
|
||||||
: null),
|
|
||||||
actions: actions,
|
|
||||||
centerTitle: centerTitle,
|
|
||||||
flexibleSpace: LayoutBuilder(
|
|
||||||
builder: (BuildContext context, BoxConstraints constraints) {
|
|
||||||
final top = constraints.biggest.height;
|
|
||||||
final isCollapsed =
|
|
||||||
top <= kToolbarHeight + MediaQuery.of(context).padding.top;
|
|
||||||
|
|
||||||
return FlexibleSpaceBar(
|
|
||||||
title: isCollapsed
|
|
||||||
? ThemedText.headline(
|
|
||||||
text: title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
letterSpacing: -0.2,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
centerTitle: centerTitle,
|
|
||||||
titlePadding:
|
|
||||||
const EdgeInsets.only(left: 16, bottom: 16, right: 16),
|
|
||||||
background: Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
// 글래스모피즘 배경
|
|
||||||
ClipRRect(
|
|
||||||
child: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
|
||||||
child: Container(
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
(isDarkMode
|
|
||||||
? AppColors.glassBackgroundDark
|
|
||||||
: AppColors.glassBackground)
|
|
||||||
.withValues(alpha: opacity),
|
|
||||||
(isDarkMode
|
|
||||||
? AppColors.glassSurfaceDark
|
|
||||||
: AppColors.glassSurface)
|
|
||||||
.withValues(alpha: opacity * 0.8),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
border: Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: isDarkMode
|
|
||||||
? AppColors.primaryColor.withValues(alpha: 0.3)
|
|
||||||
: AppColors.glassBorder.withValues(alpha: 0.5),
|
|
||||||
width: 0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 확장 상태에서만 보이는 타이틀
|
|
||||||
if (!isCollapsed)
|
|
||||||
Positioned(
|
|
||||||
left: 16,
|
|
||||||
right: 16,
|
|
||||||
bottom: 16,
|
|
||||||
child: ThemedText.headline(
|
|
||||||
text: title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 28,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
letterSpacing: -0.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 커스텀 flexibleSpace가 있으면 추가
|
|
||||||
if (flexibleSpace != null) flexibleSpace!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import '../utils/logger.dart';
|
import '../utils/logger.dart';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
import '../theme/app_colors.dart';
|
import '../theme/app_colors.dart';
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
import 'themed_text.dart';
|
import 'themed_text.dart';
|
||||||
|
|
||||||
class GlassmorphismCard extends StatelessWidget {
|
class GlassmorphismCard extends StatelessWidget {
|
||||||
@@ -52,7 +53,12 @@ class GlassmorphismCard extends StatelessWidget {
|
|||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
borderRadius: BorderRadius.circular(borderRadius),
|
||||||
child: BackdropFilter(
|
child: BackdropFilter(
|
||||||
filter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
filter: ImageFilter.blur(
|
||||||
|
sigmaX: ReduceMotion.scale(context,
|
||||||
|
normal: blur, reduced: blur * 0.4),
|
||||||
|
sigmaY: ReduceMotion.scale(context,
|
||||||
|
normal: blur, reduced: blur * 0.4),
|
||||||
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: padding,
|
padding: padding,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -75,12 +81,13 @@ class GlassmorphismCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
boxShadow: boxShadow ??
|
boxShadow: boxShadow ??
|
||||||
[
|
[
|
||||||
const BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors
|
color: AppColors
|
||||||
.shadowBlack, // color.md 가이드: rgba(0,0,0,0.08)
|
.shadowBlack, // color.md: rgba(0,0,0,0.08)
|
||||||
blurRadius: 20,
|
blurRadius: ReduceMotion.scale(context,
|
||||||
|
normal: 20, reduced: 10),
|
||||||
spreadRadius: -5,
|
spreadRadius: -5,
|
||||||
offset: Offset(0, 10),
|
offset: const Offset(0, 10),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -209,15 +216,18 @@ class _AnimatedGlassmorphismCardState extends State<AnimatedGlassmorphismCard>
|
|||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
animation: _controller,
|
animation: _controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
final scaleValue = ReduceMotion.scale(context,
|
||||||
|
normal: _scaleAnimation.value, reduced: 1.0);
|
||||||
return Transform.scale(
|
return Transform.scale(
|
||||||
scale: _scaleAnimation.value,
|
scale: scaleValue,
|
||||||
child: GlassmorphismCard(
|
child: GlassmorphismCard(
|
||||||
padding: widget.padding,
|
padding: widget.padding,
|
||||||
margin: widget.margin,
|
margin: widget.margin,
|
||||||
width: widget.width,
|
width: widget.width,
|
||||||
height: widget.height,
|
height: widget.height,
|
||||||
borderRadius: widget.borderRadius,
|
borderRadius: widget.borderRadius,
|
||||||
blur: _blurAnimation.value,
|
blur: ReduceMotion.scale(context,
|
||||||
|
normal: _blurAnimation.value, reduced: widget.blur),
|
||||||
opacity: widget.opacity,
|
opacity: widget.opacity,
|
||||||
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
|
onTap: null, // GlassmorphismCard의 onTap은 사용하지 않음
|
||||||
child: widget.child,
|
child: widget.child,
|
||||||
|
|||||||
@@ -42,315 +42,321 @@ class MainScreenSummaryCard extends StatelessWidget {
|
|||||||
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
CurvedAnimation(parent: fadeController, curve: Curves.easeIn)),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
|
padding: const EdgeInsets.fromLTRB(16, 23, 16, 12),
|
||||||
child: GlassmorphismCard(
|
child: RepaintBoundary(
|
||||||
borderRadius: 16,
|
child: GlassmorphismCard(
|
||||||
blur: 15,
|
borderRadius: 16,
|
||||||
backgroundColor: AppColors.glassCard,
|
blur: 15,
|
||||||
gradient: LinearGradient(
|
backgroundColor: AppColors.glassCard,
|
||||||
begin: Alignment.topLeft,
|
gradient: LinearGradient(
|
||||||
end: Alignment.bottomRight,
|
begin: Alignment.topLeft,
|
||||||
colors: AppColors.mainGradient
|
end: Alignment.bottomRight,
|
||||||
.map((color) => color.withValues(alpha: 0.2))
|
colors: AppColors.mainGradient
|
||||||
.toList(),
|
.map((color) => color.withValues(alpha: 0.2))
|
||||||
),
|
.toList(),
|
||||||
border: Border.all(
|
|
||||||
color: AppColors.glassBorder,
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
child: Container(
|
|
||||||
width: double.infinity,
|
|
||||||
constraints: BoxConstraints(
|
|
||||||
minHeight: 180,
|
|
||||||
maxHeight: activeEvents > 0 ? 300 : 240,
|
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
border: Border.all(
|
||||||
borderRadius: BorderRadius.circular(24),
|
color: AppColors.glassBorder,
|
||||||
color: Colors.transparent,
|
width: 1,
|
||||||
),
|
),
|
||||||
child: ClipRRect(
|
child: Container(
|
||||||
borderRadius: BorderRadius.circular(24),
|
width: double.infinity,
|
||||||
child: Stack(
|
constraints: BoxConstraints(
|
||||||
children: [
|
minHeight: 180,
|
||||||
// 애니메이션 웨이브 배경
|
maxHeight: activeEvents > 0 ? 300 : 240,
|
||||||
Positioned.fill(
|
),
|
||||||
child: AnimatedWaveBackground(
|
decoration: BoxDecoration(
|
||||||
controller: waveController,
|
borderRadius: BorderRadius.circular(24),
|
||||||
pulseController: pulseController,
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(24),
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// 애니메이션 웨이브 배경
|
||||||
|
Positioned.fill(
|
||||||
|
child: AnimatedWaveBackground(
|
||||||
|
controller: waveController,
|
||||||
|
pulseController: pulseController,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
Padding(
|
||||||
Padding(
|
padding: const EdgeInsets.all(24.0),
|
||||||
padding: const EdgeInsets.all(24.0),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisSize: MainAxisSize.min,
|
children: [
|
||||||
children: [
|
Row(
|
||||||
Row(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
children: [
|
||||||
children: [
|
Text(
|
||||||
Text(
|
AppLocalizations.of(context)
|
||||||
AppLocalizations.of(context)
|
.monthlyTotalSubscriptionCost,
|
||||||
.monthlyTotalSubscriptionCost,
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
color: AppColors
|
||||||
color: AppColors
|
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
fontSize: 15,
|
||||||
fontSize: 15,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.w500,
|
),
|
||||||
),
|
),
|
||||||
|
// 환율 정보 표시 (영어 사용자는 표시 안함)
|
||||||
|
if (locale != 'en')
|
||||||
|
FutureBuilder<String>(
|
||||||
|
future:
|
||||||
|
CurrencyUtil.getExchangeRateInfoForLocale(
|
||||||
|
locale),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (snapshot.hasData &&
|
||||||
|
snapshot.data!.isNotEmpty) {
|
||||||
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 4,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFE5F2FF),
|
||||||
|
borderRadius:
|
||||||
|
BorderRadius.circular(4),
|
||||||
|
border: Border.all(
|
||||||
|
color: const Color(0xFFBFDBFE),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
AppLocalizations.of(context)
|
||||||
|
.exchangeRateDisplay
|
||||||
|
.replaceAll('@', snapshot.data!),
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFF3B82F6),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
// 월별 총 비용 표시 (언어별 기본 통화)
|
||||||
|
FutureBuilder<double>(
|
||||||
|
future: CurrencyUtil
|
||||||
|
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||||
|
provider.subscriptions,
|
||||||
|
locale,
|
||||||
),
|
),
|
||||||
// 환율 정보 표시 (영어 사용자는 표시 안함)
|
builder: (context, snapshot) {
|
||||||
if (locale != 'en')
|
if (!snapshot.hasData) {
|
||||||
FutureBuilder<String>(
|
return const CircularProgressIndicator();
|
||||||
future:
|
}
|
||||||
CurrencyUtil.getExchangeRateInfoForLocale(
|
final monthlyCost = snapshot.data!;
|
||||||
locale),
|
final decimals = (defaultCurrency == 'KRW' ||
|
||||||
builder: (context, snapshot) {
|
defaultCurrency == 'JPY')
|
||||||
if (snapshot.hasData &&
|
? 0
|
||||||
snapshot.data!.isNotEmpty) {
|
: 2;
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 8,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFE5F2FF),
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
border: Border.all(
|
|
||||||
color: const Color(0xFFBFDBFE),
|
|
||||||
width: 1,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Text(
|
|
||||||
AppLocalizations.of(context)
|
|
||||||
.exchangeRateDisplay
|
|
||||||
.replaceAll('@', snapshot.data!),
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
color: Color(0xFF3B82F6),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
// 월별 총 비용 표시 (언어별 기본 통화)
|
|
||||||
FutureBuilder<double>(
|
|
||||||
future: CurrencyUtil
|
|
||||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
|
||||||
provider.subscriptions,
|
|
||||||
locale,
|
|
||||||
),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const CircularProgressIndicator();
|
|
||||||
}
|
|
||||||
final monthlyCost = snapshot.data!;
|
|
||||||
final decimals = (defaultCurrency == 'KRW' ||
|
|
||||||
defaultCurrency == 'JPY')
|
|
||||||
? 0
|
|
||||||
: 2;
|
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||||
textBaseline: TextBaseline.alphabetic,
|
textBaseline: TextBaseline.alphabetic,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
NumberFormat.currency(
|
NumberFormat.currency(
|
||||||
locale: defaultCurrency == 'KRW'
|
locale: defaultCurrency == 'KRW'
|
||||||
? 'ko_KR'
|
? 'ko_KR'
|
||||||
: defaultCurrency == 'JPY'
|
: defaultCurrency == 'JPY'
|
||||||
? 'ja_JP'
|
? 'ja_JP'
|
||||||
: defaultCurrency == 'CNY'
|
: defaultCurrency == 'CNY'
|
||||||
? 'zh_CN'
|
? 'zh_CN'
|
||||||
: 'en_US',
|
: 'en_US',
|
||||||
symbol: '',
|
symbol: '',
|
||||||
decimalDigits: decimals,
|
decimalDigits: decimals,
|
||||||
).format(monthlyCost),
|
).format(monthlyCost),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: AppColors.darkNavy,
|
color: AppColors.darkNavy,
|
||||||
fontSize: 32,
|
fontSize: 32,
|
||||||
fontWeight: FontWeight.bold,
|
fontWeight: FontWeight.bold,
|
||||||
letterSpacing: -1,
|
letterSpacing: -1,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 4),
|
||||||
const SizedBox(width: 4),
|
Text(
|
||||||
Text(
|
currencySymbol,
|
||||||
currencySymbol,
|
style: const TextStyle(
|
||||||
style: const TextStyle(
|
color: AppColors.darkNavy,
|
||||||
color: AppColors.darkNavy,
|
fontSize: 16,
|
||||||
fontSize: 16,
|
fontWeight: FontWeight.w500,
|
||||||
fontWeight: FontWeight.w500,
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
// 연간 비용 및 총 구독 수 표시
|
|
||||||
FutureBuilder<double>(
|
|
||||||
future: CurrencyUtil
|
|
||||||
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
|
||||||
provider.subscriptions,
|
|
||||||
locale,
|
|
||||||
),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
final monthlyCost = snapshot.data!;
|
|
||||||
final yearlyCost = monthlyCost * 12;
|
|
||||||
final decimals = (defaultCurrency == 'KRW' ||
|
|
||||||
defaultCurrency == 'JPY')
|
|
||||||
? 0
|
|
||||||
: 2;
|
|
||||||
|
|
||||||
return Row(
|
|
||||||
children: [
|
|
||||||
_buildInfoBox(
|
|
||||||
context,
|
|
||||||
title: AppLocalizations.of(context)
|
|
||||||
.estimatedAnnualCost,
|
|
||||||
value: NumberFormat.currency(
|
|
||||||
locale: defaultCurrency == 'KRW'
|
|
||||||
? 'ko_KR'
|
|
||||||
: defaultCurrency == 'JPY'
|
|
||||||
? 'ja_JP'
|
|
||||||
: defaultCurrency == 'CNY'
|
|
||||||
? 'zh_CN'
|
|
||||||
: 'en_US',
|
|
||||||
symbol: currencySymbol,
|
|
||||||
decimalDigits: decimals,
|
|
||||||
).format(yearlyCost),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 16),
|
|
||||||
_buildInfoBox(
|
|
||||||
context,
|
|
||||||
title: AppLocalizations.of(context)
|
|
||||||
.totalSubscriptionServices,
|
|
||||||
value:
|
|
||||||
'$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
// 이벤트 절약액 표시
|
|
||||||
if (activeEvents > 0) ...[
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
vertical: 10, horizontal: 14),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
colors: [
|
|
||||||
Colors.white.withValues(alpha: 0.2),
|
|
||||||
Colors.white.withValues(alpha: 0.15),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
borderRadius: BorderRadius.circular(12),
|
},
|
||||||
border: Border.all(
|
),
|
||||||
color: AppColors.primaryColor
|
const SizedBox(height: 16),
|
||||||
.withValues(alpha: 0.3),
|
// 연간 비용 및 총 구독 수 표시
|
||||||
width: 1,
|
FutureBuilder<double>(
|
||||||
),
|
future: CurrencyUtil
|
||||||
|
.calculateTotalMonthlyExpenseInDefaultCurrency(
|
||||||
|
provider.subscriptions,
|
||||||
|
locale,
|
||||||
),
|
),
|
||||||
child: Row(
|
builder: (context, snapshot) {
|
||||||
mainAxisSize: MainAxisSize.min,
|
if (!snapshot.hasData) {
|
||||||
children: [
|
return const SizedBox();
|
||||||
Container(
|
}
|
||||||
padding: const EdgeInsets.all(6),
|
final monthlyCost = snapshot.data!;
|
||||||
decoration: BoxDecoration(
|
final yearlyCost = monthlyCost * 12;
|
||||||
color: Colors.white.withValues(alpha: 0.25),
|
final decimals = (defaultCurrency == 'KRW' ||
|
||||||
shape: BoxShape.circle,
|
defaultCurrency == 'JPY')
|
||||||
),
|
? 0
|
||||||
child: const Icon(
|
: 2;
|
||||||
Icons.local_offer_rounded,
|
|
||||||
size: 14,
|
|
||||||
color: AppColors
|
|
||||||
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 10),
|
|
||||||
Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
AppLocalizations.of(context)
|
|
||||||
.eventDiscountActive,
|
|
||||||
style: const TextStyle(
|
|
||||||
color: AppColors
|
|
||||||
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
|
||||||
fontSize: 11,
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
// 이벤트 절약액 표시 (언어별 기본 통화)
|
|
||||||
FutureBuilder<double>(
|
|
||||||
future: CurrencyUtil
|
|
||||||
.calculateTotalEventSavingsInDefaultCurrency(
|
|
||||||
provider.subscriptions,
|
|
||||||
locale,
|
|
||||||
),
|
|
||||||
builder: (context, snapshot) {
|
|
||||||
if (!snapshot.hasData) {
|
|
||||||
return const SizedBox();
|
|
||||||
}
|
|
||||||
final eventSavings = snapshot.data!;
|
|
||||||
final decimals =
|
|
||||||
(defaultCurrency == 'KRW' ||
|
|
||||||
defaultCurrency == 'JPY')
|
|
||||||
? 0
|
|
||||||
: 2;
|
|
||||||
|
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
_buildInfoBox(
|
||||||
NumberFormat.currency(
|
context,
|
||||||
locale: defaultCurrency == 'KRW'
|
title: AppLocalizations.of(context)
|
||||||
? 'ko_KR'
|
.estimatedAnnualCost,
|
||||||
: defaultCurrency == 'JPY'
|
value: NumberFormat.currency(
|
||||||
? 'ja_JP'
|
locale: defaultCurrency == 'KRW'
|
||||||
: defaultCurrency ==
|
? 'ko_KR'
|
||||||
'CNY'
|
: defaultCurrency == 'JPY'
|
||||||
? 'zh_CN'
|
? 'ja_JP'
|
||||||
: 'en_US',
|
: defaultCurrency == 'CNY'
|
||||||
symbol: currencySymbol,
|
? 'zh_CN'
|
||||||
decimalDigits: decimals,
|
: 'en_US',
|
||||||
).format(eventSavings),
|
symbol: currencySymbol,
|
||||||
style: const TextStyle(
|
decimalDigits: decimals,
|
||||||
color: AppColors.primaryColor,
|
).format(yearlyCost),
|
||||||
fontSize: 14,
|
),
|
||||||
fontWeight: FontWeight.bold,
|
const SizedBox(width: 16),
|
||||||
),
|
_buildInfoBox(
|
||||||
),
|
context,
|
||||||
Text(
|
title: AppLocalizations.of(context)
|
||||||
' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
|
.totalSubscriptionServices,
|
||||||
style: const TextStyle(
|
value:
|
||||||
color: AppColors.navyGray,
|
'$totalSubscriptions${AppLocalizations.of(context).servicesUnit}',
|
||||||
fontSize: 12,
|
),
|
||||||
fontWeight: FontWeight.w500,
|
],
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
],
|
),
|
||||||
);
|
// 이벤트 절약액 표시
|
||||||
},
|
if (activeEvents > 0) ...[
|
||||||
),
|
const SizedBox(height: 12),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: 10, horizontal: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.white.withValues(alpha: 0.2),
|
||||||
|
Colors.white.withValues(alpha: 0.15),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
border: Border.all(
|
||||||
|
color: AppColors.primaryColor
|
||||||
|
.withValues(alpha: 0.3),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color:
|
||||||
|
Colors.white.withValues(alpha: 0.25),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: const Icon(
|
||||||
|
Icons.local_offer_rounded,
|
||||||
|
size: 14,
|
||||||
|
color: AppColors
|
||||||
|
.primaryColor, // color.md 가이드: 밝은 배경 위 딥 블루 아이콘
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
AppLocalizations.of(context)
|
||||||
|
.eventDiscountActive,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors
|
||||||
|
.darkNavy, // color.md 가이드: 밝은 배경 위 어두운 텍스트
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
// 이벤트 절약액 표시 (언어별 기본 통화)
|
||||||
|
FutureBuilder<double>(
|
||||||
|
future: CurrencyUtil
|
||||||
|
.calculateTotalEventSavingsInDefaultCurrency(
|
||||||
|
provider.subscriptions,
|
||||||
|
locale,
|
||||||
|
),
|
||||||
|
builder: (context, snapshot) {
|
||||||
|
if (!snapshot.hasData) {
|
||||||
|
return const SizedBox();
|
||||||
|
}
|
||||||
|
final eventSavings = snapshot.data!;
|
||||||
|
final decimals =
|
||||||
|
(defaultCurrency == 'KRW' ||
|
||||||
|
defaultCurrency == 'JPY')
|
||||||
|
? 0
|
||||||
|
: 2;
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
NumberFormat.currency(
|
||||||
|
locale: defaultCurrency ==
|
||||||
|
'KRW'
|
||||||
|
? 'ko_KR'
|
||||||
|
: defaultCurrency == 'JPY'
|
||||||
|
? 'ja_JP'
|
||||||
|
: defaultCurrency ==
|
||||||
|
'CNY'
|
||||||
|
? 'zh_CN'
|
||||||
|
: 'en_US',
|
||||||
|
symbol: currencySymbol,
|
||||||
|
decimalDigits: decimals,
|
||||||
|
).format(eventSavings),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.primaryColor,
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
' ${AppLocalizations.of(context).saving} ($activeEvents${AppLocalizations.of(context).servicesUnit})',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: AppColors.navyGray,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,159 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'glassmorphism_card.dart';
|
|
||||||
|
|
||||||
class SkeletonLoading extends StatelessWidget {
|
|
||||||
final double? width;
|
|
||||||
final double? height;
|
|
||||||
final double borderRadius;
|
|
||||||
|
|
||||||
const SkeletonLoading({
|
|
||||||
Key? key,
|
|
||||||
this.width,
|
|
||||||
this.height,
|
|
||||||
this.borderRadius = 8.0,
|
|
||||||
}) : super(key: key);
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
// 단일 스켈레톤 아이템이 요청된 경우
|
|
||||||
if (width != null || height != null) {
|
|
||||||
return _buildSingleSkeleton();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 기본 전체 화면 스켈레톤
|
|
||||||
return Column(
|
|
||||||
children: [
|
|
||||||
// 요약 카드 스켈레톤
|
|
||||||
GlassmorphismCard(
|
|
||||||
margin: const EdgeInsets.all(16),
|
|
||||||
padding: const EdgeInsets.all(16.0),
|
|
||||||
blur: 10,
|
|
||||||
opacity: 0.1,
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 100,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 16),
|
|
||||||
Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
_buildSkeletonColumn(),
|
|
||||||
_buildSkeletonColumn(),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
// 구독 목록 스켈레톤
|
|
||||||
Expanded(
|
|
||||||
child: ListView.builder(
|
|
||||||
itemCount: 5,
|
|
||||||
itemBuilder: (context, index) {
|
|
||||||
return GlassmorphismCard(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
blur: 10,
|
|
||||||
opacity: 0.1,
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 200,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Container(
|
|
||||||
width: 150,
|
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Container(
|
|
||||||
width: 180,
|
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSingleSkeleton() {
|
|
||||||
return Container(
|
|
||||||
width: width,
|
|
||||||
height: height,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
),
|
|
||||||
child: AnimatedContainer(
|
|
||||||
duration: const Duration(milliseconds: 1500),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
borderRadius: BorderRadius.circular(borderRadius),
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.centerLeft,
|
|
||||||
end: Alignment.centerRight,
|
|
||||||
colors: [
|
|
||||||
Colors.grey[300]!,
|
|
||||||
Colors.grey[100]!,
|
|
||||||
Colors.grey[300]!,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildSkeletonColumn() {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 80,
|
|
||||||
height: 16,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Container(
|
|
||||||
width: 100,
|
|
||||||
height: 24,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.grey[300],
|
|
||||||
borderRadius: BorderRadius.circular(4),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,340 +0,0 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
|
|
||||||
/// 물리 기반 스프링 애니메이션을 적용하는 위젯
|
|
||||||
class SpringAnimationWidget extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final Duration delay;
|
|
||||||
final SpringDescription spring;
|
|
||||||
final Offset? initialOffset;
|
|
||||||
final double? initialScale;
|
|
||||||
final double? initialRotation;
|
|
||||||
|
|
||||||
const SpringAnimationWidget({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.delay = Duration.zero,
|
|
||||||
this.spring = const SpringDescription(
|
|
||||||
mass: 1,
|
|
||||||
stiffness: 100,
|
|
||||||
damping: 10,
|
|
||||||
),
|
|
||||||
this.initialOffset,
|
|
||||||
this.initialScale,
|
|
||||||
this.initialRotation,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<SpringAnimationWidget> createState() => _SpringAnimationWidgetState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SpringAnimationWidgetState extends State<SpringAnimationWidget>
|
|
||||||
with TickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
late Animation<Offset> _offsetAnimation;
|
|
||||||
late Animation<double> _scaleAnimation;
|
|
||||||
late Animation<double> _rotationAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(seconds: 2),
|
|
||||||
);
|
|
||||||
|
|
||||||
// 오프셋 애니메이션
|
|
||||||
_offsetAnimation = Tween<Offset>(
|
|
||||||
begin: widget.initialOffset ?? const Offset(0, 50),
|
|
||||||
end: Offset.zero,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.elasticOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
// 스케일 애니메이션
|
|
||||||
_scaleAnimation = Tween<double>(
|
|
||||||
begin: widget.initialScale ?? 0.5,
|
|
||||||
end: 1.0,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.elasticOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
// 회전 애니메이션
|
|
||||||
_rotationAnimation = Tween<double>(
|
|
||||||
begin: widget.initialRotation ?? 0.0,
|
|
||||||
end: 0.0,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.elasticOut,
|
|
||||||
));
|
|
||||||
|
|
||||||
// 지연 후 애니메이션 시작
|
|
||||||
Future.delayed(widget.delay, () {
|
|
||||||
if (mounted) {
|
|
||||||
_controller.forward();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return AnimatedBuilder(
|
|
||||||
animation: _controller,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.translate(
|
|
||||||
offset: _offsetAnimation.value,
|
|
||||||
child: Transform.scale(
|
|
||||||
scale: _scaleAnimation.value,
|
|
||||||
child: Transform.rotate(
|
|
||||||
angle: _rotationAnimation.value,
|
|
||||||
child: child,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 바운스 효과가 있는 버튼
|
|
||||||
class BouncyButton extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final VoidCallback? onPressed;
|
|
||||||
final EdgeInsetsGeometry? padding;
|
|
||||||
final BoxDecoration? decoration;
|
|
||||||
|
|
||||||
const BouncyButton({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.onPressed,
|
|
||||||
this.padding,
|
|
||||||
this.decoration,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<BouncyButton> createState() => _BouncyButtonState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _BouncyButtonState extends State<BouncyButton>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
late Animation<double> _scaleAnimation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: const Duration(milliseconds: 200),
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_scaleAnimation = Tween<double>(
|
|
||||||
begin: 1.0,
|
|
||||||
end: 0.95,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.easeInOut,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTapDown(TapDownDetails details) {
|
|
||||||
_controller.forward();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTapUp(TapUpDetails details) {
|
|
||||||
_controller.reverse();
|
|
||||||
widget.onPressed?.call();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTapCancel() {
|
|
||||||
_controller.reverse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTapDown: _handleTapDown,
|
|
||||||
onTapUp: _handleTapUp,
|
|
||||||
onTapCancel: _handleTapCancel,
|
|
||||||
child: AnimatedBuilder(
|
|
||||||
animation: _scaleAnimation,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Transform.scale(
|
|
||||||
scale: _scaleAnimation.value,
|
|
||||||
child: Container(
|
|
||||||
padding: widget.padding,
|
|
||||||
decoration: widget.decoration,
|
|
||||||
child: widget.child,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 중력 효과 애니메이션
|
|
||||||
class GravityAnimation extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final double gravity;
|
|
||||||
final double bounceFactor;
|
|
||||||
final double initialVelocity;
|
|
||||||
|
|
||||||
const GravityAnimation({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.gravity = 9.8,
|
|
||||||
this.bounceFactor = 0.8,
|
|
||||||
this.initialVelocity = 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<GravityAnimation> createState() => _GravityAnimationState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _GravityAnimationState extends State<GravityAnimation>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
double _position = 0;
|
|
||||||
double _velocity = 0;
|
|
||||||
final double _floor = 300;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_velocity = widget.initialVelocity;
|
|
||||||
_controller = AnimationController(
|
|
||||||
vsync: this,
|
|
||||||
duration: const Duration(seconds: 10),
|
|
||||||
)..addListener(_updatePhysics);
|
|
||||||
|
|
||||||
_controller.repeat();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _updatePhysics() {
|
|
||||||
setState(() {
|
|
||||||
// 속도 업데이트 (중력 적용)
|
|
||||||
_velocity += widget.gravity * 0.016; // 60fps 가정
|
|
||||||
|
|
||||||
// 위치 업데이트
|
|
||||||
_position += _velocity;
|
|
||||||
|
|
||||||
// 바닥 충돌 감지
|
|
||||||
if (_position >= _floor) {
|
|
||||||
_position = _floor;
|
|
||||||
_velocity = -_velocity * widget.bounceFactor;
|
|
||||||
|
|
||||||
// 너무 작은 바운스는 멈춤
|
|
||||||
if (_velocity.abs() < 1) {
|
|
||||||
_velocity = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Transform.translate(
|
|
||||||
offset: Offset(0, _position),
|
|
||||||
child: widget.child,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 물결 효과 애니메이션
|
|
||||||
class RippleAnimation extends StatefulWidget {
|
|
||||||
final Widget child;
|
|
||||||
final Color rippleColor;
|
|
||||||
final Duration duration;
|
|
||||||
|
|
||||||
const RippleAnimation({
|
|
||||||
super.key,
|
|
||||||
required this.child,
|
|
||||||
this.rippleColor = Colors.blue,
|
|
||||||
this.duration = const Duration(milliseconds: 600),
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<RippleAnimation> createState() => _RippleAnimationState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RippleAnimationState extends State<RippleAnimation>
|
|
||||||
with SingleTickerProviderStateMixin {
|
|
||||||
late AnimationController _controller;
|
|
||||||
late Animation<double> _animation;
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
_controller = AnimationController(
|
|
||||||
duration: widget.duration,
|
|
||||||
vsync: this,
|
|
||||||
);
|
|
||||||
|
|
||||||
_animation = Tween<double>(
|
|
||||||
begin: 0.0,
|
|
||||||
end: 1.0,
|
|
||||||
).animate(CurvedAnimation(
|
|
||||||
parent: _controller,
|
|
||||||
curve: Curves.easeOut,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_controller.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleTap() {
|
|
||||||
_controller.forward(from: 0.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return GestureDetector(
|
|
||||||
onTap: _handleTap,
|
|
||||||
child: Stack(
|
|
||||||
alignment: Alignment.center,
|
|
||||||
children: [
|
|
||||||
AnimatedBuilder(
|
|
||||||
animation: _animation,
|
|
||||||
builder: (context, child) {
|
|
||||||
return Container(
|
|
||||||
width: 100 + 200 * _animation.value,
|
|
||||||
height: 100 + 200 * _animation.value,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
color: widget.rippleColor.withValues(
|
|
||||||
alpha: (1 - _animation.value) * 0.3,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
),
|
|
||||||
widget.child,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
/// 스태거 애니메이션이 적용된 리스트 위젯
|
/// 스태거 애니메이션이 적용된 리스트 위젯
|
||||||
class StaggeredListAnimation extends StatefulWidget {
|
class StaggeredListAnimation extends StatefulWidget {
|
||||||
@@ -95,13 +96,14 @@ class _StaggeredListAnimationState extends State<StaggeredListAnimation>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (ReduceMotion.platform()) {
|
||||||
|
return widget.direction == Axis.vertical
|
||||||
|
? Column(children: widget.children)
|
||||||
|
: Row(children: widget.children);
|
||||||
|
}
|
||||||
return widget.direction == Axis.vertical
|
return widget.direction == Axis.vertical
|
||||||
? Column(
|
? Column(children: _buildAnimatedChildren())
|
||||||
children: _buildAnimatedChildren(),
|
: Row(children: _buildAnimatedChildren());
|
||||||
)
|
|
||||||
: Row(
|
|
||||||
children: _buildAnimatedChildren(),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildAnimatedChildren() {
|
List<Widget> _buildAnimatedChildren() {
|
||||||
@@ -156,8 +158,9 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
final reduced = ReduceMotion.platform();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: widget.duration,
|
duration: reduced ? Duration.zero : widget.duration,
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -170,7 +173,9 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
|||||||
));
|
));
|
||||||
|
|
||||||
_slideAnimation = Tween<Offset>(
|
_slideAnimation = Tween<Offset>(
|
||||||
begin: const Offset(0, 0.3),
|
begin: ReduceMotion.platform()
|
||||||
|
? const Offset(0, 0.05)
|
||||||
|
: const Offset(0, 0.3),
|
||||||
end: Offset.zero,
|
end: Offset.zero,
|
||||||
).animate(CurvedAnimation(
|
).animate(CurvedAnimation(
|
||||||
parent: _controller,
|
parent: _controller,
|
||||||
@@ -185,11 +190,11 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
|||||||
curve: widget.curve,
|
curve: widget.curve,
|
||||||
));
|
));
|
||||||
|
|
||||||
// 지연 후 애니메이션 시작
|
// 지연 후 애니메이션 시작 (모션 축소 시 지연 없음)
|
||||||
Future.delayed(widget.delay * widget.index, () {
|
final startDelay =
|
||||||
if (mounted) {
|
ReduceMotion.platform() ? Duration.zero : widget.delay * widget.index;
|
||||||
_controller.forward();
|
Future.delayed(startDelay, () {
|
||||||
}
|
if (mounted) _controller.forward();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -201,6 +206,7 @@ class _StaggeredAnimationItemState extends State<StaggeredAnimationItem>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (ReduceMotion.platform()) return widget.child;
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
animation: _controller,
|
animation: _controller,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||||
|
cacheExtent: 500,
|
||||||
prototypeItem: const SizedBox(height: 156),
|
prototypeItem: const SizedBox(height: 156),
|
||||||
itemCount: subscriptions.length,
|
itemCount: subscriptions.length,
|
||||||
itemBuilder: (context, subIndex) {
|
itemBuilder: (context, subIndex) {
|
||||||
@@ -102,6 +103,7 @@ class SubscriptionListWidget extends StatelessWidget {
|
|||||||
child: RepaintBoundary(
|
child: RepaintBoundary(
|
||||||
child: SwipeableSubscriptionCard(
|
child: SwipeableSubscriptionCard(
|
||||||
subscription: subscriptions[subIndex],
|
subscription: subscriptions[subIndex],
|
||||||
|
keepAlive: true,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Log.d(
|
Log.d(
|
||||||
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
|
||||||
|
|||||||
@@ -2,12 +2,14 @@ import 'package:flutter/material.dart';
|
|||||||
import '../models/subscription_model.dart';
|
import '../models/subscription_model.dart';
|
||||||
import '../utils/haptic_feedback_helper.dart';
|
import '../utils/haptic_feedback_helper.dart';
|
||||||
import 'subscription_card.dart';
|
import 'subscription_card.dart';
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
class SwipeableSubscriptionCard extends StatefulWidget {
|
class SwipeableSubscriptionCard extends StatefulWidget {
|
||||||
final SubscriptionModel subscription;
|
final SubscriptionModel subscription;
|
||||||
final VoidCallback? onEdit;
|
final VoidCallback? onEdit;
|
||||||
final Future<void> Function()? onDelete;
|
final Future<void> Function()? onDelete;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
|
final bool keepAlive;
|
||||||
|
|
||||||
const SwipeableSubscriptionCard({
|
const SwipeableSubscriptionCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -15,6 +17,7 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
|||||||
this.onEdit,
|
this.onEdit,
|
||||||
this.onDelete,
|
this.onDelete,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.keepAlive = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -23,7 +26,7 @@ class SwipeableSubscriptionCard extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin, AutomaticKeepAliveClientMixin {
|
||||||
// 상수 정의
|
// 상수 정의
|
||||||
static const double _tapTolerance = 20.0; // 탭 허용 범위
|
static const double _tapTolerance = 20.0; // 탭 허용 범위
|
||||||
static const double _actionThresholdPercent = 0.15;
|
static const double _actionThresholdPercent = 0.15;
|
||||||
@@ -49,7 +52,9 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_controller = AnimationController(
|
_controller = AnimationController(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: ReduceMotion.platform()
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
_animation = Tween<double>(
|
_animation = Tween<double>(
|
||||||
@@ -215,10 +220,14 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
|||||||
right: isLeft ? 0 : 24,
|
right: isLeft ? 0 : 24,
|
||||||
),
|
),
|
||||||
child: AnimatedOpacity(
|
child: AnimatedOpacity(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: ReduceMotion.platform()
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 200),
|
||||||
opacity: showIcon ? 1.0 : 0.0,
|
opacity: showIcon ? 1.0 : 0.0,
|
||||||
child: AnimatedScale(
|
child: AnimatedScale(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: ReduceMotion.platform()
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 200),
|
||||||
scale: showIcon ? 1.0 : 0.5,
|
scale: showIcon ? 1.0 : 0.5,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
isDeleteThreshold
|
isDeleteThreshold
|
||||||
@@ -236,9 +245,10 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
|||||||
return Transform.translate(
|
return Transform.translate(
|
||||||
offset: Offset(_currentOffset, 0),
|
offset: Offset(_currentOffset, 0),
|
||||||
child: Transform.scale(
|
child: Transform.scale(
|
||||||
scale: 1.0 - (_currentOffset.abs() / 2000),
|
scale:
|
||||||
|
ReduceMotion.platform() ? 1.0 : 1.0 - (_currentOffset.abs() / 2000),
|
||||||
child: Transform.rotate(
|
child: Transform.rotate(
|
||||||
angle: _currentOffset / 2000,
|
angle: ReduceMotion.platform() ? 0.0 : _currentOffset / 2000,
|
||||||
child: SubscriptionCard(
|
child: SubscriptionCard(
|
||||||
subscription: widget.subscription,
|
subscription: widget.subscription,
|
||||||
onTap: widget
|
onTap: widget
|
||||||
@@ -251,6 +261,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
super.build(context);
|
||||||
// 웹과 모바일 모두 동일한 스와이프 기능 제공
|
// 웹과 모바일 모두 동일한 스와이프 기능 제공
|
||||||
return Stack(
|
return Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -266,4 +277,7 @@ class _SwipeableSubscriptionCardState extends State<SwipeableSubscriptionCard>
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool get wantKeepAlive => widget.keepAlive;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
|||||||
import 'package:path_provider/path_provider.dart';
|
import 'package:path_provider/path_provider.dart';
|
||||||
import 'package:crypto/crypto.dart';
|
import 'package:crypto/crypto.dart';
|
||||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||||
|
import '../utils/reduce_motion.dart';
|
||||||
|
|
||||||
// 파비콘 캐시 관리 클래스
|
// 파비콘 캐시 관리 클래스
|
||||||
class FaviconCache {
|
class FaviconCache {
|
||||||
@@ -190,12 +191,15 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
|||||||
// 애니메이션 컨트롤러 초기화
|
// 애니메이션 컨트롤러 초기화
|
||||||
_animationController = AnimationController(
|
_animationController = AnimationController(
|
||||||
vsync: this,
|
vsync: this,
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: ReduceMotion.platform()
|
||||||
|
? const Duration(milliseconds: 0)
|
||||||
|
: const Duration(milliseconds: 300),
|
||||||
);
|
);
|
||||||
|
|
||||||
_scaleAnimation = Tween<double>(begin: 1.0, end: 1.08).animate(
|
_scaleAnimation =
|
||||||
CurvedAnimation(
|
Tween<double>(begin: 1.0, end: ReduceMotion.platform() ? 1.0 : 1.08)
|
||||||
parent: _animationController, curve: Curves.easeOutCubic));
|
.animate(CurvedAnimation(
|
||||||
|
parent: _animationController, curve: Curves.easeOutCubic));
|
||||||
|
|
||||||
// 초기 _previousServiceKey 설정
|
// 초기 _previousServiceKey 설정
|
||||||
_previousServiceKey = _serviceKey;
|
_previousServiceKey = _serviceKey;
|
||||||
@@ -548,11 +552,14 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return AnimatedBuilder(
|
return RepaintBoundary(
|
||||||
|
child: AnimatedBuilder(
|
||||||
animation: _animationController,
|
animation: _animationController,
|
||||||
builder: (context, child) {
|
builder: (context, child) {
|
||||||
|
final scale =
|
||||||
|
ReduceMotion.isEnabled(context) ? 1.0 : _scaleAnimation.value;
|
||||||
return Transform.scale(
|
return Transform.scale(
|
||||||
scale: _scaleAnimation.value,
|
scale: scale,
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -578,12 +585,25 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
|||||||
),
|
),
|
||||||
child: _buildIconContent(),
|
child: _buildIconContent(),
|
||||||
),
|
),
|
||||||
);
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildIconContent() {
|
Widget _buildIconContent() {
|
||||||
// 로딩 중 표시
|
// 로딩 중 표시
|
||||||
if (_isLoading) {
|
if (_isLoading) {
|
||||||
|
if (ReduceMotion.isEnabled(context)) {
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
return Container(
|
return Container(
|
||||||
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
key: ValueKey('loading_${widget.serviceName}_$_uniqueId'),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@@ -633,20 +653,31 @@ class _WebsiteIconState extends State<WebsiteIcon>
|
|||||||
width: widget.size,
|
width: widget.size,
|
||||||
height: widget.size,
|
height: widget.size,
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
placeholder: (context, url) => Container(
|
fadeInDuration: ReduceMotion.isEnabled(context)
|
||||||
color: AppColors.surfaceColorAlt,
|
? const Duration(milliseconds: 0)
|
||||||
child: Center(
|
: const Duration(milliseconds: 300),
|
||||||
child: SizedBox(
|
fadeOutDuration: ReduceMotion.isEnabled(context)
|
||||||
width: widget.size * 0.4,
|
? const Duration(milliseconds: 0)
|
||||||
height: widget.size * 0.4,
|
: const Duration(milliseconds: 300),
|
||||||
child: CircularProgressIndicator(
|
placeholder: (context, url) {
|
||||||
strokeWidth: 2,
|
if (ReduceMotion.isEnabled(context)) {
|
||||||
valueColor: AlwaysStoppedAnimation<Color>(
|
return Container(color: AppColors.surfaceColorAlt);
|
||||||
AppColors.primaryColor.withValues(alpha: 0.7)),
|
}
|
||||||
|
return 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.withValues(alpha: 0.7)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
),
|
},
|
||||||
errorWidget: (context, url, error) => _buildFallbackIcon(),
|
errorWidget: (context, url, error) => _buildFallbackIcon(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import webview_flutter_wkwebview
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin"))
|
||||||
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
|
||||||
FLALocalAuthPlugin.register(with: registry.registrar(forPlugin: "FLALocalAuthPlugin"))
|
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
|
||||||
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
|
||||||
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
189
pubspec.lock
189
pubspec.lock
@@ -5,23 +5,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: _fe_analyzer_shared
|
name: _fe_analyzer_shared
|
||||||
sha256: "16e298750b6d0af7ce8a3ba7c18c69c3785d11b15ec83f6dcd0ad2a0009b3cab"
|
sha256: "0b2f2bd91ba804e53a61d757b986f89f1f9eaed5b11e4b2f5a2468d86d6c9fc7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "76.0.0"
|
version: "67.0.0"
|
||||||
_macros:
|
|
||||||
dependency: transitive
|
|
||||||
description: dart
|
|
||||||
source: sdk
|
|
||||||
version: "0.3.3"
|
|
||||||
analyzer:
|
analyzer:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: analyzer
|
name: analyzer
|
||||||
sha256: "1f14db053a8c23e260789e9b0980fa27f2680dd640932cae5e1137cce0e46e1e"
|
sha256: "37577842a27e4338429a1cbc32679d508836510b056f1eedf0c8d20e39c1383d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.11.0"
|
version: "6.4.1"
|
||||||
ansicolor:
|
ansicolor:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -66,10 +61,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build
|
name: build
|
||||||
sha256: cef23f1eda9b57566c81e2133d196f8e3df48f244b317368d65c5943d91148f0
|
sha256: "80184af8b6cb3e5c1c4ec6d8544d27711700bc3e6d2efad04238c7b5290889f0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.2"
|
version: "2.4.1"
|
||||||
build_config:
|
build_config:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -90,26 +85,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_resolvers
|
name: build_resolvers
|
||||||
sha256: b9e4fda21d846e192628e7a4f6deda6888c36b5b69ba02ff291a01fd529140f0
|
sha256: "339086358431fa15d7eca8b6a36e5d783728cf025e559b834f4609a1fcfb7b0a"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.4"
|
version: "2.4.2"
|
||||||
build_runner:
|
build_runner:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
name: build_runner
|
name: build_runner
|
||||||
sha256: "058fe9dce1de7d69c4b84fada934df3e0153dd000758c4d65964d0166779aa99"
|
sha256: "028819cfb90051c6b5440c7e574d1896f8037e3c96cf17aaeb054c9311cfbf4d"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.15"
|
version: "2.4.13"
|
||||||
build_runner_core:
|
build_runner_core:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: build_runner_core
|
name: build_runner_core
|
||||||
sha256: "22e3aa1c80e0ada3722fe5b63fd43d9c8990759d0a2cf489c8c5d7b2bdebc021"
|
sha256: f8126682b87a7282a339b871298cc12009cb67109cfa1614d6436fb0289193e0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.0.0"
|
version: "7.3.2"
|
||||||
built_collection:
|
built_collection:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -122,10 +117,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: built_value
|
name: built_value
|
||||||
sha256: ea90e81dc4a25a043d9bee692d20ed6d1c4a1662a28c03a96417446c093ed6b4
|
sha256: a30f0a0e38671e89a492c44d005b5545b830a961575bbd8336d42869ff71066d
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "8.9.5"
|
version: "8.12.0"
|
||||||
cached_network_image:
|
cached_network_image:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -162,10 +157,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: checked_yaml
|
name: checked_yaml
|
||||||
sha256: feb6bed21949061731a7a75fc5d2aa727cf160b91af9a3e464c5e3a32e28b5ff
|
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.3"
|
version: "2.0.4"
|
||||||
cli_util:
|
cli_util:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -242,10 +237,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_style
|
name: dart_style
|
||||||
sha256: "7306ab8a2359a48d22310ad823521d723acfed60ee1f7e37388e8986853b6820"
|
sha256: "99e066ce75c89d6b29903d788a7bb9369cf754f7b24bf70bf4b6d6d6b26853b9"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.8"
|
version: "2.3.6"
|
||||||
dbus:
|
dbus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -380,10 +375,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: flutter_plugin_android_lifecycle
|
name: flutter_plugin_android_lifecycle
|
||||||
sha256: f948e346c12f8d5480d2825e03de228d0eb8c3a737e4cdaa122267b89c022b5e
|
sha256: b0694b7fb1689b0e6cc193b3f1fcac6423c4f93c74fb20b806c6b6f196db0c31
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.28"
|
version: "2.0.30"
|
||||||
flutter_secure_storage:
|
flutter_secure_storage:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -444,10 +439,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: flutter_svg
|
name: flutter_svg
|
||||||
sha256: d44bf546b13025ec7353091516f6881f1d4c633993cb109c3916c3a0159dadf1
|
sha256: b9c2ad5872518a27507ab432d1fb97e8813b05f0fc693f9d40fad06d073e0678
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
version: "2.2.1"
|
||||||
flutter_test:
|
flutter_test:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -462,10 +457,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: font_awesome_flutter
|
name: font_awesome_flutter
|
||||||
sha256: d3a89184101baec7f4600d58840a764d2ef760fe1c5a20ef9e6b0e9b24a07a3a
|
sha256: "27af5982e6c510dec1ba038eff634fa284676ee84e3fd807225c80c4ad869177"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.8.0"
|
version: "10.10.0"
|
||||||
frontend_server_client:
|
frontend_server_client:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -534,10 +529,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: http
|
name: http
|
||||||
sha256: "2c11f3f94c687ee9bad77c171151672986360b2b001d109814ee7140b2cf261b"
|
sha256: bb2ce4590bc2667c96f318d68cac1b5a7987ec819351d32b1c987239a815e007
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.5.0"
|
||||||
http_multi_server:
|
http_multi_server:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -598,26 +593,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker
|
name: leak_tracker
|
||||||
sha256: "6bb818ecbdffe216e81182c2f0714a2e62b593f4a4f13098713ff1685dfb6ab0"
|
sha256: "8dcda04c3fc16c14f48a7bb586d4be1f0d1572731b6d81d51772ef47c02081e0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "10.0.9"
|
version: "11.0.1"
|
||||||
leak_tracker_flutter_testing:
|
leak_tracker_flutter_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_flutter_testing
|
name: leak_tracker_flutter_testing
|
||||||
sha256: f8b613e7e6a13ec79cfdc0e97638fddb3ab848452eff057653abd3edba760573
|
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.9"
|
version: "3.0.10"
|
||||||
leak_tracker_testing:
|
leak_tracker_testing:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: leak_tracker_testing
|
name: leak_tracker_testing
|
||||||
sha256: "6ba465d5d76e67ddf503e1161d1f4a6bc42306f9d66ca1e8f079a47290fb06d3"
|
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.1"
|
version: "3.0.2"
|
||||||
lints:
|
lints:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -638,18 +633,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: local_auth_android
|
name: local_auth_android
|
||||||
sha256: "63ad7ca6396290626dc0cb34725a939e4cfe965d80d36112f08d49cf13a8136e"
|
sha256: "1ee0e63fb8b5c6fa286796b5fb1570d256857c2f4a262127e728b36b80a570cf"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.49"
|
version: "1.0.53"
|
||||||
local_auth_darwin:
|
local_auth_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: local_auth_darwin
|
name: local_auth_darwin
|
||||||
sha256: "630996cd7b7f28f5ab92432c4b35d055dd03a747bc319e5ffbb3c4806a3e50d2"
|
sha256: "0e9706a8543a4a2eee60346294d6a633dd7c3ee60fae6b752570457c4ff32055"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.3"
|
version: "1.6.0"
|
||||||
local_auth_platform_interface:
|
local_auth_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -674,14 +669,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
macros:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: macros
|
|
||||||
sha256: "1d9e801cd66f7ea3663c45fc708450db1fa57f988142c64289142c9b7ee80656"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "0.1.3-main.0"
|
|
||||||
matcher:
|
matcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -766,18 +753,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_android
|
name: path_provider_android
|
||||||
sha256: d0d310befe2c8ab9e7f393288ccbb11b60c019c6b5afc21973eeee4dda2b35e9
|
sha256: "993381400e94d18469750e5b9dcb8206f15bc09f9da86b9e44a9b0092a0066db"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.17"
|
version: "2.2.18"
|
||||||
path_provider_foundation:
|
path_provider_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: path_provider_foundation
|
name: path_provider_foundation
|
||||||
sha256: "4843174df4d288f5e29185bd6e72a6fbdf5a4a4602717eed565497429f179942"
|
sha256: "16eef174aacb07e09c351502740fa6254c165757638eba1e9116b0a781201bbd"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2"
|
||||||
path_provider_linux:
|
path_provider_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -854,10 +841,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: petitparser
|
name: petitparser
|
||||||
sha256: "07c8f0b1913bcde1ff0d26e57ace2f3012ccbf2b204e070290dad3bb22797646"
|
sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.0"
|
version: "7.0.1"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -886,18 +873,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: posix
|
name: posix
|
||||||
sha256: f0d7856b6ca1887cfa6d1d394056a296ae33489db914e365e2044fdada449e62
|
sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.0.2"
|
version: "6.0.3"
|
||||||
provider:
|
provider:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: provider
|
name: provider
|
||||||
sha256: "489024f942069c2920c844ee18bb3d467c69e48955a4f32d1677f71be103e310"
|
sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.1.4"
|
version: "6.1.5+1"
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -966,10 +953,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shared_preferences_android
|
name: shared_preferences_android
|
||||||
sha256: "20cbd561f743a342c76c151d6ddb93a9ce6005751e7aa458baad3858bfbfb6ac"
|
sha256: a2608114b1ffdcbc9c120eb71a0e207c71da56202852d4aab8a5e30a82269e74
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.10"
|
version: "2.4.12"
|
||||||
shared_preferences_foundation:
|
shared_preferences_foundation:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1022,10 +1009,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: shelf_web_socket
|
name: shelf_web_socket
|
||||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
sha256: cc36c297b52866d203dbf9332263c94becc2fe0ceaa9681d07b6ef9807023b67
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
version: "2.0.1"
|
||||||
sky_engine:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -1075,18 +1062,18 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_android
|
name: sqflite_android
|
||||||
sha256: "2b3070c5fa881839f8b402ee4a39c1b4d561704d4ebbbcfb808a119bc2a1701b"
|
sha256: ecd684501ebc2ae9a83536e8b15731642b9570dc8623e0073d227d0ee2bfea88
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.4.1"
|
version: "2.4.2+2"
|
||||||
sqflite_common:
|
sqflite_common:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: sqflite_common
|
name: sqflite_common
|
||||||
sha256: "84731e8bfd8303a3389903e01fb2141b6e59b5973cacbb0929021df08dddbe8b"
|
sha256: "6ef422a4525ecc601db6c0a2233ff448c731307906e92cabc9ba292afaae16a6"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.5.5"
|
version: "2.5.6"
|
||||||
sqflite_darwin:
|
sqflite_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1139,10 +1126,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: synchronized
|
name: synchronized
|
||||||
sha256: "0669c70faae6270521ee4f05bffd2919892d42d1276e6c495be80174b6bc0ef6"
|
sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.3.1"
|
version: "3.4.0"
|
||||||
term_glyph:
|
term_glyph:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1155,10 +1142,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: test_api
|
name: test_api
|
||||||
sha256: fb31f383e2ee25fbbfe06b40fe21e1e458d14080e3c67e7ba0acfde4df4e0bbd
|
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.7.4"
|
version: "0.7.6"
|
||||||
timezone:
|
timezone:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1195,26 +1182,26 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: url_launcher
|
name: url_launcher
|
||||||
sha256: "9d06212b1362abc2f0f0d78e6f09f726608c74e3b9462e8368bb03314aa8d603"
|
sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.1"
|
version: "6.3.2"
|
||||||
url_launcher_android:
|
url_launcher_android:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "8582d7f6fe14d2652b4c45c9b6c14c0b678c2af2d083a11b604caeba51930d79"
|
sha256: dbdd48a5b6a6fe9c7d75099bd2d03f9da9393f8d51a0d250301debbcecd552d2
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.16"
|
version: "6.3.19"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_ios
|
name: url_launcher_ios
|
||||||
sha256: "7f2022359d4c099eea7df3fdf739f7d3d3b9faf3166fb1dd390775176e0b76cb"
|
sha256: d80b3f567a617cb923546034cc94bfe44eb15f989fe670b37f26abdb9d939cb7
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.3"
|
version: "6.3.4"
|
||||||
url_launcher_linux:
|
url_launcher_linux:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1227,10 +1214,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_macos
|
name: url_launcher_macos
|
||||||
sha256: "17ba2000b847f334f16626a574c702b196723af2a289e7a93ffcb79acff855c2"
|
sha256: c043a77d6600ac9c38300567f33ef12b0ef4f4783a2c1f00231d2b1941fea13f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.2.2"
|
version: "3.2.3"
|
||||||
url_launcher_platform_interface:
|
url_launcher_platform_interface:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1267,10 +1254,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_graphics
|
name: vector_graphics
|
||||||
sha256: "44cc7104ff32563122a929e4620cf3efd584194eec6d1d913eb5ba593dbcf6de"
|
sha256: a4f059dc26fc8295b5921376600a194c4ec7d55e72f2fe4c7d2831e103d461e6
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.18"
|
version: "1.1.19"
|
||||||
vector_graphics_codec:
|
vector_graphics_codec:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1283,34 +1270,34 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_graphics_compiler
|
name: vector_graphics_compiler
|
||||||
sha256: "1b4b9e706a10294258727674a340ae0d6e64a7231980f9f9a3d12e4b42407aad"
|
sha256: d354a7ec6931e6047785f4db12a1f61ec3d43b207fc0790f863818543f8ff0dc
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.16"
|
version: "1.1.19"
|
||||||
vector_math:
|
vector_math:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vector_math
|
name: vector_math
|
||||||
sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803"
|
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
version: "2.2.0"
|
||||||
vm_service:
|
vm_service:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: vm_service
|
name: vm_service
|
||||||
sha256: ddfa8d30d89985b96407efce8acbdd124701f96741f2d981ca860662f1c0dc02
|
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "15.0.0"
|
version: "15.0.2"
|
||||||
watcher:
|
watcher:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: watcher
|
name: watcher
|
||||||
sha256: "69da27e49efa56a15f8afe8f4438c4ec02eff0a117df1b22ea4aad194fe1c104"
|
sha256: "5bf046f41320ac97a469d506261797f35254fa61c641741ef32dacda98b7d39c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.1"
|
version: "1.1.3"
|
||||||
web:
|
web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1323,10 +1310,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: web_socket
|
name: web_socket
|
||||||
sha256: bfe6f435f6ec49cb6c01da1e275ae4228719e59a6b067048c51e72d9d63bcc4b
|
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.1"
|
||||||
web_socket_channel:
|
web_socket_channel:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1355,26 +1342,26 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_platform_interface
|
name: webview_flutter_platform_interface
|
||||||
sha256: "7cb32b21825bd65569665c32bb00a34ded5779786d6201f5350979d2d529940d"
|
sha256: "63d26ee3aca7256a83ccb576a50272edd7cfc80573a4305caa98985feb493ee0"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.14.0"
|
||||||
webview_flutter_wkwebview:
|
webview_flutter_wkwebview:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: webview_flutter_wkwebview
|
name: webview_flutter_wkwebview
|
||||||
sha256: a3d461fe3467014e05f3ac4962e5fdde2a4bf44c561cb53e9ae5c586600fdbc3
|
sha256: fb46db8216131a3e55bcf44040ca808423539bc6732e7ed34fb6d8044e3d512f
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.22.0"
|
version: "3.23.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: dc6ecaa00a7c708e5b4d10ee7bec8c270e9276dfcab1783f57e9962d7884305f
|
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.12.0"
|
version: "5.14.0"
|
||||||
xdg_directories:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1387,10 +1374,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: xml
|
name: xml
|
||||||
sha256: b015a8ad1c488f66851d762d3090a21c600e479dc75e68328c52774040cf9226
|
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.5.0"
|
version: "6.6.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1400,5 +1387,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.7.0 <4.0.0"
|
dart: ">=3.9.0 <4.0.0"
|
||||||
flutter: ">=3.27.0"
|
flutter: ">=3.35.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user