i8n과 광고 수정

This commit is contained in:
JiWoong Sul
2025-12-07 21:14:54 +09:00
parent 64da0c5fd3
commit bac4acf9a3
25 changed files with 640 additions and 382 deletions

View File

@@ -525,7 +525,7 @@ class AddSubscriptionController {
if (context.mounted) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}
}

View File

@@ -454,7 +454,7 @@ class DetailScreenController extends ChangeNotifier {
if (adjustedNext.isAfter(originalDateOnly)) {
AppSnackBar.showInfo(
context: context,
message: '다음 결제 예정일로 저장됨',
message: AppLocalizations.of(context).nextBillingDateAdjusted,
);
}

View File

@@ -14,6 +14,8 @@ import '../providers/navigation_provider.dart';
import '../providers/category_provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/payment_card_provider.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
import 'dart:io' show Platform;
class SmsScanController extends ChangeNotifier {
// 상태 관리
@@ -47,6 +49,8 @@ class SmsScanController extends ChangeNotifier {
final SubscriptionFilter _filter = SubscriptionFilter();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
bool _isAdInProgress = false;
bool get isAdInProgress => _isAdInProgress;
@override
void dispose() {
@@ -73,15 +77,79 @@ class SmsScanController extends ChangeNotifier {
serviceNameController.text = '';
}
void updateCurrentServiceName(String value) {
void updateCurrentServiceName(BuildContext context, String value) {
if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim();
final unknownLabel = _unknownServiceLabel(context);
final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed);
.copyWith(serviceName: trimmed.isEmpty ? unknownLabel : trimmed);
_scannedSubscriptions[_currentIndex] = updated;
notifyListeners();
}
Future<void> startScan(BuildContext context) async {
if (_isLoading) return;
_isAdInProgress = true;
notifyListeners();
// 웹/비지원 플랫폼은 바로 스캔
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) {
_isAdInProgress = false;
notifyListeners();
await scanSms(context);
return;
}
// 전면 광고 로드 및 노출 후 스캔 진행
try {
await InterstitialAd.load(
adUnitId: _interstitialAdUnitId(),
request: const AdRequest(),
adLoadCallback: InterstitialAdLoadCallback(
onAdLoaded: (ad) {
ad.fullScreenContentCallback = FullScreenContentCallback(
onAdDismissedFullScreenContent: (ad) {
ad.dispose();
_startSmsScanIfMounted(context);
},
onAdFailedToShowFullScreenContent: (ad, error) {
ad.dispose();
_fallbackAfterDelay(context);
},
);
ad.show();
},
onAdFailedToLoad: (error) {
_fallbackAfterDelay(context);
},
),
);
} catch (e) {
Log.e('전면 광고 로드 중 오류, 바로 스캔 진행', e);
if (!context.mounted) return;
_fallbackAfterDelay(context);
}
}
String _interstitialAdUnitId() {
if (Platform.isAndroid || Platform.isIOS) {
return 'ca-app-pub-6691216385521068~6638409932';
}
return '';
}
Future<void> _startSmsScanIfMounted(BuildContext context) async {
if (!context.mounted) return;
_isAdInProgress = false;
notifyListeners();
await scanSms(context);
}
Future<void> _fallbackAfterDelay(BuildContext context) async {
await Future.delayed(const Duration(seconds: 5));
if (!context.mounted) return;
await _startSmsScanIfMounted(context);
}
Future<void> scanSms(BuildContext context) async {
_isLoading = true;
_errorMessage = null;
@@ -366,8 +434,10 @@ class SmsScanController extends ChangeNotifier {
}
final current = _scannedSubscriptions[_currentIndex];
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
final unknownLabel = _unknownServiceLabel(context);
_forceServiceNameEditing =
_shouldEnableServiceNameEditing(current, unknownLabel);
if (_forceServiceNameEditing && current.serviceName == unknownLabel) {
serviceNameController.clear();
} else {
serviceNameController.text = current.serviceName;
@@ -429,8 +499,13 @@ class SmsScanController extends ChangeNotifier {
return null;
}
bool _shouldEnableServiceNameEditing(Subscription subscription) {
bool _shouldEnableServiceNameEditing(
Subscription subscription, String unknownLabel) {
final name = subscription.serviceName.trim();
return name.isEmpty || name == '알 수 없는 서비스';
return name.isEmpty || name == unknownLabel;
}
String _unknownServiceLabel(BuildContext context) {
return AppLocalizations.of(context).unknownService;
}
}

View File

@@ -68,11 +68,13 @@ class AppLocalizations {
String get selectIcon => _localizedStrings['selectIcon'] ?? 'Select Icon';
String get addCategory => _localizedStrings['addCategory'] ?? 'Add Category';
String get settings => _localizedStrings['settings'] ?? 'Settings';
String get theme => _localizedStrings['theme'] ?? 'Theme';
String get darkMode => _localizedStrings['darkMode'] ?? 'Dark Mode';
String get language => _localizedStrings['language'] ?? 'Language';
String get notifications =>
_localizedStrings['notifications'] ?? 'Notifications';
String get appLock => _localizedStrings['appLock'] ?? 'App Lock';
String get appLocked => _localizedStrings['appLocked'] ?? 'App is locked';
String get paymentCard => _localizedStrings['paymentCard'] ?? 'Payment Card';
String get paymentCardManagement =>
_localizedStrings['paymentCardManagement'] ?? 'Payment Card Management';
@@ -173,6 +175,8 @@ class AppLocalizations {
String get notificationPermissionDenied =>
_localizedStrings['notificationPermissionDenied'] ??
'Notification permission denied';
String get permissionGranted =>
_localizedStrings['permissionGranted'] ?? 'Permission granted.';
// 앱 정보
String get appInfo => _localizedStrings['appInfo'] ?? 'App Info';
String get version => _localizedStrings['version'] ?? 'Version';
@@ -207,6 +211,8 @@ class AppLocalizations {
String get requiredFieldsError =>
_localizedStrings['requiredFieldsError'] ??
'Please fill in all required fields';
String get categoryNameRequired =>
_localizedStrings['categoryNameRequired'] ?? 'Please enter category name';
String get subscriptionUpdated =>
_localizedStrings['subscriptionUpdated'] ??
'Subscription information has been updated';
@@ -259,6 +265,9 @@ class AppLocalizations {
String get authenticationFailed =>
_localizedStrings['authenticationFailed'] ??
'Authentication failed. Please try again.';
String get nextBillingDateAdjusted =>
_localizedStrings['nextBillingDateAdjusted'] ??
'Saved as the next billing date';
String get smsPermissionRequired =>
_localizedStrings['smsPermissionRequired'] ?? 'SMS permission required';
String get noSubscriptionSmsFound =>
@@ -467,6 +476,8 @@ class AppLocalizations {
String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get unknownService =>
_localizedStrings['unknownService'] ?? 'Unknown service';
String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) {
@@ -669,6 +680,49 @@ class AppLocalizations {
_localizedStrings['invalidAmount'] ?? 'Please enter a valid amount';
String get featureComingSoon =>
_localizedStrings['featureComingSoon'] ?? 'This feature is coming soon';
String get exactAlarmPermission =>
_localizedStrings['exactAlarmPermission'] ??
'Exact alarm permission (Alarms & Reminders)';
String get exactAlarmPermissionDesc =>
_localizedStrings['exactAlarmPermissionDesc'] ??
'We need permission to guarantee precise alarms.';
String get allowAlarmsInSettings =>
_localizedStrings['allowAlarmsInSettings'] ??
'Please allow "Alarms & reminders" in Settings.';
String get testNotification =>
_localizedStrings['testNotification'] ?? 'Test notification';
String testSubscriptionBody(String amountText) {
final template =
_localizedStrings['testSubscriptionBody'] ?? 'Test subscription • @';
return template.replaceAll('@', amountText);
}
String expirationReminderBody(String serviceName, int days) {
final template = _localizedStrings['expirationReminderBody'] ??
'@ subscription expires in # days.';
return template
.replaceAll('@', serviceName)
.replaceAll('#', days.toString());
}
String get eventEndNotificationTitle =>
_localizedStrings['eventEndNotificationTitle'] ??
'Event end notification';
String eventEndNotificationBody(String serviceName) {
final template = _localizedStrings['eventEndNotificationBody'] ??
"@'s discount event has ended.";
return template.replaceAll('@', serviceName);
}
String paymentChargeNotification(String serviceName, String amountText) {
final template = _localizedStrings['paymentChargeNotification'] ??
'@ subscription charge @ was completed.';
return template
.replaceFirst('@', serviceName)
.replaceFirst('@', amountText);
}
// 결제 주기를 키값으로 변환하여 번역된 이름 반환
String getBillingCycleName(String billingCycleKey) {

View File

@@ -6,6 +6,8 @@ import '../services/notification_service.dart';
import '../providers/subscription_provider.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class AppLockProvider extends ChangeNotifier {
final Box<bool> _appLockBox;
@@ -72,8 +74,11 @@ class AppLockProvider extends ChangeNotifier {
return true;
}
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final authenticated = await _localAuth.authenticate(
localizedReason: '생체 인증을 사용하여 앱 잠금을 해제하세요.',
localizedReason:
loc?.unlockWithBiometric ?? 'Unlock with biometric authentication.',
options: const AuthenticationOptions(
stickyAuth: true,
biometricOnly: true,

View File

@@ -8,6 +8,8 @@ import '../services/notification_service.dart';
import '../services/exchange_rate_service.dart';
import '../services/currency_util.dart';
import 'category_provider.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SubscriptionProvider extends ChangeNotifier {
late Box<SubscriptionModel> _subscriptionBox;
@@ -239,10 +241,13 @@ class SubscriptionProvider extends ChangeNotifier {
SubscriptionModel subscription) async {
if (subscription.eventEndDate != null &&
subscription.eventEndDate!.isAfter(DateTime.now())) {
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
await NotificationService.scheduleNotification(
id: '${subscription.id}_event_end'.hashCode,
title: '이벤트 종료 알림',
body: '${subscription.serviceName}의 할인 이벤트가 종료되었습니다.',
title: loc?.eventEndNotificationTitle ?? 'Event end notification',
body: loc?.eventEndNotificationBody(subscription.serviceName) ??
"${subscription.serviceName}'s discount event has ended.",
scheduledDate: subscription.eventEndDate!,
channelId: NotificationService.expirationChannelId,
);

View File

@@ -9,6 +9,7 @@ import 'package:submanager/screens/splash_screen.dart';
import 'package:submanager/screens/sms_permission_screen.dart';
import 'package:submanager/models/subscription_model.dart';
import 'package:submanager/screens/payment_card_management_screen.dart';
import '../l10n/app_localizations.dart';
class AppRoutes {
static const String splash = '/splash';
@@ -81,9 +82,9 @@ class AppRoutes {
static Route<dynamic> _errorRoute() {
return MaterialPageRoute(
builder: (_) => const Scaffold(
builder: (context) => Scaffold(
body: Center(
child: Text('페이지를 찾을 수 없습니다'),
child: Text(AppLocalizations.of(context).pageNotFound),
),
),
);

View File

@@ -13,6 +13,7 @@ import '../widgets/analysis/subscription_pie_chart_card.dart';
import '../widgets/analysis/total_expense_summary_card.dart';
import '../widgets/analysis/monthly_expense_chart_card.dart';
import '../widgets/analysis/event_analysis_card.dart';
import '../theme/ui_constants.dart';
enum AnalysisCardFilterType { all, unassigned, card }
@@ -324,21 +325,11 @@ class _AnalysisScreenState extends State<AnalysisScreen>
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: <Widget>[
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
SliverPadding(
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
sliver: _buildCardFilterSection(context, cardProvider),
),
// 네이티브 광고 위젯
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
_buildCardFilterSection(context, cardProvider),
const AnalysisScreenSpacer(),
// 1. 구독 비율 파이 차트
@@ -349,6 +340,13 @@ class _AnalysisScreenState extends State<AnalysisScreen>
const AnalysisScreenSpacer(),
// 네이티브 광고 위젯 (구독 비율 차트 하단)
SliverToBoxAdapter(
child: _buildAnimatedAd(),
),
const AnalysisScreenSpacer(),
// 2. 총 지출 요약 카드
TotalExpenseSummaryCard(
key: ValueKey('total_expense_$_lastDataHash'),

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../providers/app_lock_provider.dart';
// import '../theme/app_colors.dart';
@@ -8,6 +10,7 @@ class AppLockScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
body: Center(
child: Column(
@@ -20,7 +23,7 @@ class AppLockScreen extends StatelessWidget {
),
const SizedBox(height: 24),
Text(
'앱이 잠겨 있습니다',
loc.appLocked,
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
@@ -29,7 +32,7 @@ class AppLockScreen extends StatelessWidget {
),
const SizedBox(height: 16),
Text(
'생체 인증으로 잠금을 해제하세요',
loc.appLockDesc,
style: TextStyle(
fontSize: 16,
color: Theme.of(context).colorScheme.onSurfaceVariant,
@@ -45,7 +48,7 @@ class AppLockScreen extends StatelessWidget {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'인증에 실패했습니다. 다시 시도해주세요.',
loc.authenticationFailed,
style: TextStyle(
color: cs.onPrimary,
),
@@ -56,7 +59,7 @@ class AppLockScreen extends StatelessWidget {
}
},
icon: const Icon(Icons.fingerprint),
label: const Text('생체 인증으로 잠금 해제'),
label: Text(loc.unlockWithBiometric),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
horizontal: 24,

View File

@@ -41,10 +41,11 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context);
return Scaffold(
appBar: AppBar(
title: Text(
'카테고리 관리',
loc.categoryManagement,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
color: Theme.of(context).colorScheme.onPrimary,
),
@@ -67,7 +68,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
TextFormField(
controller: _nameController,
decoration: InputDecoration(
labelText: '카테고리 이름',
labelText: loc.categoryName,
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -76,7 +77,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
),
validator: (value) {
if (value == null || value.isEmpty) {
return '카테고리 이름을 입력하세요';
return loc.categoryNameRequired;
}
return null;
},
@@ -85,7 +86,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
DropdownButtonFormField<String>(
initialValue: _selectedColor,
decoration: InputDecoration(
labelText: '색상 선택',
labelText: loc.selectColor,
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -144,7 +145,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
DropdownButtonFormField<String>(
initialValue: _selectedIcon,
decoration: InputDecoration(
labelText: '아이콘 선택',
labelText: loc.selectIcon,
labelStyle: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -154,35 +155,35 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
items: [
DropdownMenuItem(
value: 'subscriptions',
child: Text('구독',
child: Text(loc.subscription,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'movie',
child: Text('영화',
child: Text(loc.movie,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'music_note',
child: Text('음악',
child: Text(loc.music,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'fitness_center',
child: Text('운동',
child: Text(loc.exercise,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface))),
DropdownMenuItem(
value: 'shopping_cart',
child: Text('쇼핑',
child: Text(loc.shopping,
style: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -197,7 +198,7 @@ class _CategoryManagementScreenState extends State<CategoryManagementScreen> {
const SizedBox(height: 16),
ElevatedButton(
onPressed: _addCategory,
child: const Text('카테고리 추가'),
child: Text(loc.addCategory),
),
],
),

View File

@@ -6,7 +6,6 @@ import 'dart:io';
import '../services/notification_service.dart';
// import '../widgets/glassmorphism_card.dart';
// import '../theme/app_colors.dart';
import '../widgets/native_ad_widget.dart';
import '../widgets/common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../providers/locale_provider.dart';
@@ -17,6 +16,7 @@ import '../theme/adaptive_theme.dart';
import '../widgets/common/layout/page_container.dart';
import '../theme/color_scheme_ext.dart';
import '../widgets/app_navigator.dart';
import '../theme/ui_constants.dart';
class SettingsScreen extends StatelessWidget {
const SettingsScreen({super.key});
@@ -86,23 +86,16 @@ class SettingsScreen extends StatelessWidget {
child: PageContainer(
padding: EdgeInsets.zero,
child: ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.fromLTRB(
16,
UIConstants.pageTopPadding,
16,
0,
),
children: [
// toolbar 높이 추가
SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
// 광고 위젯 추가
const NativeAdWidget(
key: ValueKey('settings_ad'),
useOuterPadding: true,
),
const SizedBox(height: 16),
// 테마 모드 설정
Card(
margin:
const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
margin: const EdgeInsets.fromLTRB(16, 0, 16, 8),
elevation: 1,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
@@ -191,7 +184,7 @@ class SettingsScreen extends StatelessWidget {
leading: Icon(Icons.color_lens,
color: cs.onSurfaceVariant),
title: Text(
'테마',
loc.theme,
style: TextStyle(color: cs.onSurface),
),
),
@@ -360,14 +353,14 @@ class SettingsScreen extends StatelessWidget {
.colorScheme
.onSurfaceVariant),
title: Text(
'정확 알람 권한(알람 및 리마인더)',
loc.exactAlarmPermission,
style: TextStyle(
color: Theme.of(context)
.colorScheme
.onSurface),
),
subtitle: Text(
'정확한 시각에 알림을 보장하려면 권한이 필요합니다.',
loc.exactAlarmPermissionDesc,
style: TextStyle(
color: Theme.of(context)
.colorScheme
@@ -385,19 +378,19 @@ class SettingsScreen extends StatelessWidget {
if (ok || recheck) {
AppSnackBar.showSuccess(
context: context,
message: '권한이 허용되었습니다.',
message: loc.permissionGranted,
);
} else {
AppSnackBar.showInfo(
context: context,
message:
'설정에서 "알람 및 리마인더"를 허용해 주세요.',
loc.allowAlarmsInSettings,
);
}
(context as Element).markNeedsBuild();
}
},
child: const Text('허용 요청'),
child: Text(loc.requestPermission),
),
);
},
@@ -747,8 +740,8 @@ class SettingsScreen extends StatelessWidget {
child: OutlinedButton.icon(
icon: const Icon(Icons
.notifications_active),
label:
const Text('테스트 알림'),
label: Text(
loc.testNotification),
onPressed: () {
NotificationService
.showTestPaymentNotification();

View File

@@ -9,6 +9,7 @@ import '../l10n/app_localizations.dart';
import '../widgets/payment_card/payment_card_form_sheet.dart';
import '../routes/app_routes.dart';
import '../models/payment_card_suggestion.dart';
import '../theme/ui_constants.dart';
class SmsScanScreen extends StatefulWidget {
const SmsScanScreen({super.key});
@@ -56,7 +57,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
if (_controller.scannedSubscriptions.isEmpty) {
return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context),
onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage,
);
}
@@ -75,7 +76,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
}
});
return ScanInitialWidget(
onScanPressed: () => _controller.scanSms(context),
onScanPressed: () => _controller.startScan(context),
errorMessage: _controller.errorMessage,
);
}
@@ -104,7 +105,7 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable
? _controller.updateCurrentServiceName
? (value) => _controller.updateCurrentServiceName(context, value)
: null,
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
@@ -160,22 +161,83 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
@override
Widget build(BuildContext context) {
return SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero,
child: Column(
children: [
// toolbar 높이 추가
SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
return Stack(
children: [
SingleChildScrollView(
controller: _scrollController,
padding: EdgeInsets.zero,
child: Padding(
padding: const EdgeInsets.only(top: UIConstants.pageTopPadding),
child: Column(
children: [
_buildContent(),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
],
),
),
_buildContent(),
// FloatingNavigationBar를 위한 충분한 하단 여백
SizedBox(
height: 120 + MediaQuery.of(context).padding.bottom,
),
if (_controller.isAdInProgress)
Positioned.fill(
child: IgnorePointer(
child: Stack(
children: [
Container(
color: Theme.of(context)
.colorScheme
.surface
.withValues(alpha: 0.4),
),
Center(
child: DecoratedBox(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.surfaceContainerHighest
.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(16),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20, vertical: 16),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
),
const SizedBox(width: 12),
Text(
AppLocalizations.of(context).scanningMessages,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
color:
Theme.of(context).colorScheme.onSurface,
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
),
],
),
),
),
),
],
),
),
),
],
),
],
);
}
}

View File

@@ -635,6 +635,8 @@ class NotificationService {
try {
final expirationDate = subscription.nextBillingDate;
final reminderDate = expirationDate.subtract(const Duration(days: 7));
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
// tz.local 초기화 확인 및 재시도
tz.Location location;
@@ -656,8 +658,9 @@ class NotificationService {
await _notifications.zonedSchedule(
('${subscription.id}_expiration').hashCode,
'구독 만료 예정 알림',
'${subscription.serviceName} 구독이 7일 후 만료됩니다.',
loc?.expirationReminder ?? _paymentReminderTitle(_getLocaleCode()),
loc?.expirationReminderBody(subscription.serviceName, 7) ??
'${subscription.serviceName} subscription expires in 7 days.',
tz.TZDateTime.from(reminderDate, location),
const NotificationDetails(
android: AndroidNotificationDetails(
@@ -849,11 +852,14 @@ class NotificationService {
if (_isWeb || !_initialized) return;
try {
final locale = _getLocaleCode();
final title = _paymentReminderTitle(locale);
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final title = loc?.paymentReminder ?? _paymentReminderTitle(locale);
final amountText =
await CurrencyUtil.formatAmountWithLocale(10000.0, 'KRW', locale);
final body = '테스트 구독 • $amountText';
final body = loc?.testSubscriptionBody(amountText) ??
'Test subscription • $amountText';
await _notifications.show(
DateTime.now().millisecondsSinceEpoch.remainder(1 << 31),
@@ -880,7 +886,11 @@ class NotificationService {
}
static String getNotificationBody(String serviceName, double amount) {
return '$serviceName 구독료 ${amount.toStringAsFixed(0)}원이 결제되었습니다.';
final ctx = navigatorKey.currentContext;
final loc = ctx != null ? AppLocalizations.of(ctx) : null;
final amountText = amount.toStringAsFixed(0);
return loc?.paymentChargeNotification(serviceName, amountText) ??
'$serviceName subscription charge $amountText was completed.';
}
static Future<String> _buildPaymentBody(
@@ -925,6 +935,10 @@ class NotificationService {
}
static String _paymentReminderTitle(String locale) {
final ctx = navigatorKey.currentContext;
if (ctx != null) {
return AppLocalizations.of(ctx).paymentReminder;
}
switch (locale) {
case 'ko':
return '결제 예정 알림';

View File

@@ -10,6 +10,8 @@ import '../utils/platform_helper.dart';
import '../utils/business_day_util.dart';
import '../services/sms_scan/sms_scan_result.dart';
import '../models/payment_card_suggestion.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SmsScanner {
final SmsQuery _query = SmsQuery();
@@ -82,7 +84,9 @@ class SmsScanner {
return subscriptions;
} catch (e) {
Log.e('SmsScanner: 예외 발생', e);
throw Exception('SMS 스캔 중 오류 발생: $e');
final loc = _loc();
throw Exception(loc?.smsScanErrorWithMessage(e.toString()) ??
'Error occurred during SMS scan: $e');
}
}
@@ -116,7 +120,13 @@ class SmsScanner {
SmsScanResult? _parseSms(Map<String, dynamic> sms, int repeatCount) {
try {
final serviceName = sms['serviceName'] as String? ?? '알 수 없는 서비스';
final loc = _loc();
final unknownLabel = loc?.unknownService ?? 'Unknown service';
final serviceNameRaw = sms['serviceName'] as String?;
final serviceName =
(serviceNameRaw == null || serviceNameRaw.trim().isEmpty)
? unknownLabel
: serviceNameRaw;
final monthlyCost = (sms['monthlyCost'] as num?)?.toDouble() ?? 0.0;
final billingCycle = SubscriptionModel.normalizeBillingCycle(
sms['billingCycle'] as String? ?? 'monthly');
@@ -196,8 +206,9 @@ class SmsScanner {
if (issuer == null && last4 == null) {
return null;
}
final loc = _loc();
return PaymentCardSuggestion(
issuerName: issuer ?? '결제수단',
issuerName: issuer ?? loc?.paymentCard ?? 'Payment card',
last4: last4,
source: 'sms',
);
@@ -366,6 +377,12 @@ class SmsScanner {
// 기본값은 원화
return 'KRW';
}
AppLocalizations? _loc() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return null;
return AppLocalizations.of(ctx);
}
}
const List<String> _paymentLikeKeywords = [
@@ -501,7 +518,7 @@ String _isoExtractServiceName(String body, String sender) {
String _isoExtractServiceNameFromSender(String sender) {
if (RegExp(r'^\d+$').hasMatch(sender)) {
return '알 수 없는 서비스';
return _unknownServiceLabel();
}
return sender.replaceAll(RegExp(r'[^\w\s가-힣]'), '').trim();
}
@@ -576,13 +593,14 @@ Map<String, List<Map<String, dynamic>>> _groupMessagesByIdentifier(
final address = (sms['address'] as String?)?.trim();
final sender = (sms['sender'] as String?)?.trim();
final unknownLabel = _unknownServiceLabel();
String key = (serviceName != null &&
serviceName.isNotEmpty &&
serviceName != '알 수 없는 서비스')
serviceName != unknownLabel)
? serviceName
: (address?.isNotEmpty == true
? address!
: (sender?.isNotEmpty == true ? sender! : 'unknown'));
: (sender?.isNotEmpty == true ? sender! : unknownLabel));
groups.putIfAbsent(key, () => []).add(sms);
}
@@ -602,6 +620,12 @@ class _RepeatDetectionResult {
enum _MatchType { none, monthly, yearly, identical }
String _unknownServiceLabel() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return 'Unknown service';
return AppLocalizations.of(ctx).unknownService;
}
class _MatchedPair {
_MatchedPair(this.first, this.second, this.type);

View File

@@ -2,6 +2,8 @@ import 'package:flutter/foundation.dart' show kIsWeb;
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart' as permission;
import '../utils/platform_helper.dart';
import '../l10n/app_localizations.dart';
import '../navigator_key.dart';
class SMSService {
static const platform = MethodChannel('com.submanager/sms');
@@ -37,14 +39,24 @@ class SMSService {
try {
if (!await hasSMSPermission()) {
throw Exception('SMS 권한이 없습니다.');
final loc = _loc();
throw Exception(
loc?.smsPermissionRequired ?? 'SMS permission required.');
}
final List<dynamic> result =
await platform.invokeMethod('scanSubscriptions');
return result.map((item) => item as Map<String, dynamic>).toList();
} on PlatformException catch (e) {
throw Exception('SMS 스캔 중 오류 발생: ${e.message}');
final loc = _loc();
throw Exception(loc?.smsScanErrorWithMessage(e.message ?? '') ??
'Error occurred during SMS scan: ${e.message}');
}
}
static AppLocalizations? _loc() {
final ctx = navigatorKey.currentContext;
if (ctx == null) return null;
return AppLocalizations.of(ctx);
}
}

View File

@@ -1,7 +1,10 @@
class UIConstants {
static const double pageHorizontalPadding = 16;
static const double adVerticalPadding = 12;
static const double adCardHeight = 88;
static const double nativeAdWidth = 320;
static const double nativeAdHeight = 300;
static const double nativeAdAspectRatio = nativeAdWidth / nativeAdHeight;
static const double pageTopPadding = 40;
static const double cardRadius = 16;
static const double cardOutlineAlpha = 0.5; // for outline color alpha
}

View File

@@ -35,7 +35,7 @@ class SmsDateFormatter {
);
}
return '다음 결제일 확인 필요 (과거 날짜)';
return AppLocalizations.of(context).nextBillingDatePastRequired;
}
// 미래 날짜 처리

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import '../../controllers/add_subscription_controller.dart';
import '../../l10n/app_localizations.dart';
import '../common/form_fields/currency_input_field.dart';
import '../common/form_fields/date_picker_field.dart';
// import '../../theme/app_colors.dart';
@@ -72,23 +73,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
const SizedBox(width: 12),
Builder(
builder: (context) {
final locale = Localizations.localeOf(context);
String titleText;
switch (locale.languageCode) {
case 'ko':
titleText = '이벤트 가격';
break;
case 'ja':
titleText = 'イベント価格';
break;
case 'zh':
titleText = '活动价格';
break;
default:
titleText = 'Event Price';
}
final loc = AppLocalizations.of(context);
return Text(
titleText,
loc.eventPrice,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
@@ -157,23 +144,8 @@ class AddSubscriptionEventSection extends StatelessWidget {
Expanded(
child: Builder(
builder: (context) {
final locale =
Localizations.localeOf(context);
String infoText;
switch (locale.languageCode) {
case 'ko':
infoText = '할인 또는 프로모션 가격을 설정하세요';
break;
case 'ja':
infoText = '割引またはプロモーション価格を設定してください';
break;
case 'zh':
infoText = '设置折扣或促销价格';
break;
default:
infoText =
'Set up discount or promotion price';
}
final loc = AppLocalizations.of(context);
final infoText = loc.eventPriceHint;
return Text(
infoText,
style: TextStyle(
@@ -195,26 +167,9 @@ class AddSubscriptionEventSection extends StatelessWidget {
// 이벤트 기간
Builder(
builder: (context) {
final locale = Localizations.localeOf(context);
String startLabel;
String endLabel;
switch (locale.languageCode) {
case 'ko':
startLabel = '시작일';
endLabel = '종료일';
break;
case 'ja':
startLabel = '開始日';
endLabel = '終了日';
break;
case 'zh':
startLabel = '开始日期';
endLabel = '结束日期';
break;
default:
startLabel = 'Start Date';
endLabel = 'End Date';
}
final loc = AppLocalizations.of(context);
final startLabel = loc.startDate;
final endLabel = loc.endDate;
return DateRangePickerField(
startDate: controller.eventStartDate,
endDate: controller.eventEndDate,
@@ -245,37 +200,13 @@ class AddSubscriptionEventSection extends StatelessWidget {
// 이벤트 가격
Builder(
builder: (BuildContext innerContext) {
// 현재 로케일 확인
final currentLocale =
Localizations.localeOf(innerContext);
// 로케일에 따라 직접 텍스트 설정
String eventPriceLabel;
String eventPriceHint;
switch (currentLocale.languageCode) {
case 'ko':
eventPriceLabel = '이벤트 가격';
eventPriceHint = '할인된 가격을 입력하세요';
break;
case 'ja':
eventPriceLabel = 'イベント価格';
eventPriceHint = '割引価格を入力してください';
break;
case 'zh':
eventPriceLabel = '活动价格';
eventPriceHint = '输入折扣价格';
break;
default:
eventPriceLabel = 'Event Price';
eventPriceHint = 'Enter discounted price';
}
final loc = AppLocalizations.of(innerContext);
return CurrencyInputField(
controller: controller.eventPriceController,
currency: controller.currency,
label: eventPriceLabel,
hintText: eventPriceHint,
label: loc.eventPrice,
hintText: loc.eventPriceHint,
enabled: controller.isEventActive,
// 이벤트 비활성화 시 검증을 건너뛰어 저장이 막히지 않도록 처리
validator:

View File

@@ -9,7 +9,7 @@ import '../providers/subscription_provider.dart';
import '../utils/subscription_grouping_helper.dart';
import '../widgets/empty_state_widget.dart';
import '../widgets/main_summary_card.dart';
import '../widgets/native_ad_widget.dart';
import '../theme/ui_constants.dart';
import '../widgets/subscription_list_widget.dart';
class HomeContent extends StatefulWidget {
@@ -115,13 +115,8 @@ class _HomeContentState extends State<HomeContent> {
controller: widget.scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: SizedBox(
height: kToolbarHeight + MediaQuery.of(context).padding.top,
),
),
const SliverToBoxAdapter(
child: NativeAdWidget(key: ValueKey('home_ad')),
child: SizedBox(height: UIConstants.pageTopPadding),
),
SliverToBoxAdapter(
child: SlideTransition(

View File

@@ -11,7 +11,16 @@ import '../theme/ui_constants.dart';
/// SRP에 따라 광고 전용 위젯으로 분리
class NativeAdWidget extends StatefulWidget {
final bool useOuterPadding; // true이면 외부에서 페이지 패딩을 제공
const NativeAdWidget({super.key, this.useOuterPadding = false});
final TemplateType? templateTypeOverride;
final double? aspectRatioOverride;
final MediaAspectRatio? mediaAspectRatioOverride;
const NativeAdWidget({
super.key,
this.useOuterPadding = false,
this.templateTypeOverride,
this.aspectRatioOverride,
this.mediaAspectRatioOverride,
});
@override
State<NativeAdWidget> createState() => _NativeAdWidgetState();
@@ -58,10 +67,14 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
adUnitId: _testAdUnitId(), // 실제 광고 단위 ID
// 네이티브 템플릿을 사용하면 NativeAdFactory 등록 없이도 동작합니다.
nativeTemplateStyle: NativeTemplateStyle(
templateType: TemplateType.small,
templateType: widget.templateTypeOverride ?? TemplateType.medium,
mainBackgroundColor: const Color(0x00000000),
cornerRadius: 12,
),
nativeAdOptions: NativeAdOptions(
mediaAspectRatio:
widget.mediaAspectRatioOverride ?? MediaAspectRatio.square,
),
request: const AdRequest(),
listener: NativeAdListener(
onAdLoaded: (ad) {
@@ -129,12 +142,19 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
super.dispose();
}
double _adSlotHeight(double availableWidth) {
final safeWidth =
availableWidth > 0 ? availableWidth : UIConstants.nativeAdWidth;
final aspectRatio =
widget.aspectRatioOverride ?? UIConstants.nativeAdAspectRatio;
return safeWidth / aspectRatio;
}
/// 웹용 광고 플레이스홀더 위젯
Widget _buildWebPlaceholder() {
Widget _buildWebPlaceholder(double slotHeight, double horizontalPadding) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal:
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
horizontal: horizontalPadding,
vertical: UIConstants.adVerticalPadding,
),
child: Card(
@@ -143,7 +163,7 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
borderRadius: BorderRadius.zero,
),
child: Container(
height: UIConstants.adCardHeight,
height: slotHeight,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Row(
children: [
@@ -232,43 +252,54 @@ class _NativeAdWidgetState extends State<NativeAdWidget> {
return const SizedBox.shrink();
}
// 웹 환경인 경우 플레이스홀더 표시
if (kIsWeb) {
return _buildWebPlaceholder();
}
return LayoutBuilder(
builder: (context, constraints) {
final double horizontalPadding =
widget.useOuterPadding ? 0.0 : UIConstants.pageHorizontalPadding;
final availableWidth = (constraints.maxWidth.isFinite
? constraints.maxWidth
: MediaQuery.of(context).size.width) -
(horizontalPadding * 2);
final double slotHeight = _adSlotHeight(availableWidth);
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
if (!(Platform.isAndroid || Platform.isIOS)) {
return const SizedBox.shrink();
}
// 웹 환경인 경우 플레이스홀더 표시
if (kIsWeb) {
return _buildWebPlaceholder(slotHeight, horizontalPadding);
}
if (_error != null) {
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방지
return _buildWebPlaceholder();
}
// Android/iOS가 아닌 경우 광고 위젯을 렌더링하지 않음
if (!(Platform.isAndroid || Platform.isIOS)) {
return const SizedBox.shrink();
}
if (!_isLoaded) {
// 로딩 중에도 실제 광고와 동일 높이의 스켈레톤을 유
return _buildWebPlaceholder();
}
if (_error != null) {
// 실패 시에도 동일 높이의 플레이스홀더를 유지하여 레이아웃 점프 방
return _buildWebPlaceholder(slotHeight, horizontalPadding);
}
// 광고 정상 노출
return Padding(
padding: EdgeInsets.symmetric(
horizontal:
widget.useOuterPadding ? 0 : UIConstants.pageHorizontalPadding,
vertical: UIConstants.adVerticalPadding,
),
child: Card(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: SizedBox(
height: UIConstants.adCardHeight,
child: AdWidget(ad: _nativeAd!),
),
),
if (!_isLoaded) {
// 로딩 중에도 실제 광고와 동일한 높이의 스켈레톤을 유지
return _buildWebPlaceholder(slotHeight, horizontalPadding);
}
// 광고 정상 노출
return Padding(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
vertical: UIConstants.adVerticalPadding,
),
child: Card(
elevation: 1,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.zero,
),
child: SizedBox(
height: slotHeight,
child: AdWidget(ad: _nativeAd!),
),
),
);
},
);
}
}

View File

@@ -19,9 +19,6 @@ class ScanInitialWidget extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
const SizedBox(height: 48),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
@@ -64,6 +61,8 @@ class ScanInitialWidget extends StatelessWidget {
],
),
),
const SizedBox(height: 32),
const NativeAdWidget(key: ValueKey('sms_scan_start_ad')),
],
);
}

View File

@@ -1,5 +1,4 @@
import 'package:flutter/material.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/themed_text.dart';
import '../../l10n/app_localizations.dart';
@@ -8,33 +7,32 @@ class ScanLoadingWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Column(
children: [
const NativeAdWidget(key: ValueKey('sms_scan_loading_ad')),
const SizedBox(height: 48),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
ThemedText(
AppLocalizations.of(context).scanningMessages,
forceDark: true,
),
const SizedBox(height: 8),
ThemedText(
AppLocalizations.of(context).findingSubscriptions,
opacity: 0.7,
forceDark: true,
),
],
),
return SizedBox.expand(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Theme.of(context).colorScheme.primary,
),
const SizedBox(height: 16),
ThemedText(
AppLocalizations.of(context).scanningMessages,
forceDark: true,
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
ThemedText(
AppLocalizations.of(context).findingSubscriptions,
opacity: 0.7,
forceDark: true,
textAlign: TextAlign.center,
),
],
),
],
),
);
}
}

View File

@@ -10,7 +10,6 @@ import '../../widgets/common/buttons/secondary_button.dart';
import '../../widgets/common/form_fields/base_text_field.dart';
import '../../widgets/common/form_fields/category_selector.dart';
import '../../widgets/common/snackbar/app_snackbar.dart';
import '../../widgets/native_ad_widget.dart';
import '../../widgets/payment_card/payment_card_selector.dart';
import '../../services/currency_util.dart';
import '../../utils/sms_scan/date_formatter.dart';
@@ -87,9 +86,6 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
if (_hasRawSmsMessage)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),

View File

@@ -13,6 +13,8 @@ import './common/snackbar/app_snackbar.dart';
import '../l10n/app_localizations.dart';
import '../utils/logger.dart';
import '../utils/subscription_grouping_helper.dart';
import 'native_ad_widget.dart';
import 'package:google_mobile_ads/google_mobile_ads.dart';
/// 카테고리별로 구독 목록을 표시하는 위젯
class SubscriptionListWidget extends StatelessWidget {
@@ -28,134 +30,132 @@ class SubscriptionListWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final sections = groups;
int itemCounter = 0;
final List<Widget> children = [];
return SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final group = sections[index];
final subscriptions = group.subscriptions;
for (final group in sections) {
final subscriptions = group.subscriptions;
final List<Widget> subscriptionItems = [];
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SubscriptionGroupHeader(
group: group,
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
),
// 카테고리별 구독 목록
FadeTransition(
opacity: Tween<double>(begin: 0.0, end: 1.0).animate(
CurvedAnimation(
parent: fadeController, curve: Curves.easeIn)),
child: ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16),
cacheExtent: 500,
itemCount: subscriptions.length,
itemBuilder: (context, subIndex) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
final delay = 0.05 * subIndex;
const animationBegin = 0.2;
const animationEnd = 1.0;
final intervalStart = delay;
final intervalEnd = intervalStart + 0.4;
for (var subIndex = 0; subIndex < subscriptions.length; subIndex++) {
// 각 구독의 지연값 계산 (순차적으로 나타나도록)
final delay = 0.05 * subIndex;
const animationBegin = 0.2;
const animationEnd = 1.0;
final intervalStart = delay;
final intervalEnd = intervalStart + 0.4;
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
final intervalStartNormalized =
intervalStart.clamp(0.0, 0.9);
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
// 간격 계산 (0.0~1.0 사이의 값으로 정규화)
final intervalStartNormalized = intervalStart.clamp(0.0, 0.9);
final intervalEndNormalized = intervalEnd.clamp(0.1, 1.0);
return FadeTransition(
opacity: Tween<double>(
begin: animationBegin, end: animationEnd)
.animate(CurvedAnimation(
parent: fadeController,
curve: Interval(intervalStartNormalized,
intervalEndNormalized,
curve: Curves.easeOut))),
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: StaggeredAnimationItem(
index: subIndex,
delay: const Duration(milliseconds: 50),
child: RepaintBoundary(
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
keepAlive: true,
onTap: () {
Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(
context, subscriptions[subIndex]);
},
onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기
final localeProvider =
Provider.of<LocaleProvider>(
context,
listen: false,
);
final locale =
localeProvider.locale.languageCode;
final displayName =
await SubscriptionUrlMatcher
.getServiceDisplayName(
serviceName:
subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete =
await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (!context.mounted) return;
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider =
Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context)
.subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
}
}
},
),
),
),
),
subscriptionItems.add(
FadeTransition(
opacity: Tween<double>(begin: animationBegin, end: animationEnd)
.animate(CurvedAnimation(
parent: fadeController,
curve: Interval(
intervalStartNormalized, intervalEndNormalized,
curve: Curves.easeOut))),
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 6.0),
child: StaggeredAnimationItem(
index: subIndex,
delay: const Duration(milliseconds: 50),
child: RepaintBoundary(
child: SwipeableSubscriptionCard(
subscription: subscriptions[subIndex],
keepAlive: true,
onTap: () {
Log.d(
'[SubscriptionListWidget] SwipeableSubscriptionCard onTap 호출됨');
AppNavigator.toDetail(context, subscriptions[subIndex]);
},
onDelete: () async {
// 현재 로케일에 맞는 서비스명 가져오기
final localeProvider = Provider.of<LocaleProvider>(
context,
listen: false,
);
final locale = localeProvider.locale.languageCode;
final displayName =
await SubscriptionUrlMatcher.getServiceDisplayName(
serviceName: subscriptions[subIndex].serviceName,
locale: locale,
);
// 삭제 확인 다이얼로그 표시
if (!context.mounted) return;
final shouldDelete = await DeleteConfirmationDialog.show(
context: context,
serviceName: displayName,
);
if (!context.mounted) return;
if (shouldDelete) {
// 사용자가 확인한 경우에만 삭제 진행
final provider = Provider.of<SubscriptionProvider>(
context,
listen: false,
);
await provider.deleteSubscription(
subscriptions[subIndex].id,
);
if (context.mounted) {
AppSnackBar.showError(
context: context,
message: AppLocalizations.of(context)
.subscriptionDeleted(displayName),
icon: Icons.delete_forever_rounded,
);
}
}
},
),
),
],
),
),
),
);
itemCounter++;
if ((itemCounter - 1) % 10 == 0) {
subscriptionItems.add(
NativeAdWidget(
key: ValueKey('home_list_ad_$itemCounter'),
aspectRatioOverride: 320 / 80,
mediaAspectRatioOverride: MediaAspectRatio.landscape,
templateTypeOverride: TemplateType.small,
),
);
},
childCount: sections.length,
),
}
}
children.add(
Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SubscriptionGroupHeader(
group: group,
subscriptionCount: subscriptions.length,
totalCostUSD: _calculateTotalByCurrency(subscriptions, 'USD'),
totalCostKRW: _calculateTotalByCurrency(subscriptions, 'KRW'),
totalCostJPY: _calculateTotalByCurrency(subscriptions, 'JPY'),
totalCostCNY: _calculateTotalByCurrency(subscriptions, 'CNY'),
),
...subscriptionItems,
],
),
),
);
}
return SliverList(
delegate: SliverChildListDelegate(children),
);
}