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

@@ -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),
);
}