feat: improve sms scan review and detail layouts

This commit is contained in:
JiWoong Sul
2025-11-14 19:33:32 +09:00
parent a9f42f6f01
commit 2cd46a303e
13 changed files with 455 additions and 115 deletions

View File

@@ -152,6 +152,8 @@
"scanTextMessages": "Scan text messages to automatically find repeatedly paid subscriptions.\nService names and amounts can be extracted for easy subscription addition.\nThis auto-detection feature is still under development, and might miss or misidentify some subscriptions.\nPlease review the detected results and add or edit subscriptions manually if needed.",
"startScanning": "Start Scanning",
"foundSubscription": "Found subscription",
"latestSmsMessage": "Latest SMS message",
"smsDetectedDate": "Detected on @",
"serviceName": "Service Name",
"nextBillingDateLabel": "Next Billing Date",
"category": "Category",
@@ -406,6 +408,8 @@
"scanTextMessages": "문자 메시지를 스캔하여 반복적으로 결제된 구독 서비스를 자동으로 찾습니다.\n서비스명과 금액을 추출하여 쉽게 구독을 추가할 수 있습니다.\n이 자동 감지 기능은 일부 구독 서비스를 놓치거나 잘못 인식할 수 있습니다.\n감지 결과를 확인하신 후 필요에 따라 수동으로 추가하거나 수정해 주세요.",
"startScanning": "스캔 시작하기",
"foundSubscription": "다음 구독을 찾았습니다",
"latestSmsMessage": "최신 SMS 메시지",
"smsDetectedDate": "SMS 수신일: @",
"serviceName": "서비스명",
"nextBillingDateLabel": "다음 결제일",
"category": "카테고리",
@@ -660,6 +664,8 @@
"scanTextMessages": "テキストメッセージをスキャンして、繰り返し決済されたサブスクリプションを自動的に検出します。\nサービス名と金額を抽出して簡単にサブスクリプションを追加できます。\nこの自動検出機能は、一部のサブスクリプションを見落としたり誤検出する可能性があります。\n検出結果を確認し、必要に応じて手動で追加または修正してください。",
"startScanning": "スキャン開始",
"foundSubscription": "サブスクリプションが見つかりました",
"latestSmsMessage": "最新のSMSメッセージ",
"smsDetectedDate": "SMS受信日: @",
"serviceName": "サービス名",
"nextBillingDateLabel": "次回請求日",
"category": "カテゴリー",
@@ -903,6 +909,8 @@
"scanTextMessages": "扫描短信以自动查找重复付款的订阅。\n可以提取服务名称和金额轻松添加订阅。\n该自动检测功能可能会遗漏或误识别某些订阅。\n请检查检测结果并在需要时手动添加或修改。",
"startScanning": "开始扫描",
"foundSubscription": "找到订阅",
"latestSmsMessage": "最新短信内容",
"smsDetectedDate": "短信接收日期:@",
"serviceName": "服务名称",
"nextBillingDateLabel": "下次付款日期",
"category": "类别",

View File

@@ -39,14 +39,18 @@ class SmsScanController extends ChangeNotifier {
String? get selectedPaymentCardId => _selectedPaymentCardId;
final TextEditingController websiteUrlController = TextEditingController();
final TextEditingController serviceNameController = TextEditingController();
// 의존성
final SmsScanner _smsScanner = SmsScanner();
final SubscriptionConverter _converter = SubscriptionConverter();
final SubscriptionFilter _filter = SubscriptionFilter();
bool _forceServiceNameEditing = false;
bool get isServiceNameEditable => _forceServiceNameEditing;
@override
void dispose() {
serviceNameController.dispose();
websiteUrlController.dispose();
super.dispose();
}
@@ -66,6 +70,16 @@ class SmsScanController extends ChangeNotifier {
void resetWebsiteUrl() {
websiteUrlController.text = '';
serviceNameController.text = '';
}
void updateCurrentServiceName(String value) {
if (_currentIndex >= _scannedSubscriptions.length) return;
final trimmed = value.trim();
final updated = _scannedSubscriptions[_currentIndex]
.copyWith(serviceName: trimmed.isEmpty ? '알 수 없는 서비스' : trimmed);
_scannedSubscriptions[_currentIndex] = updated;
notifyListeners();
}
Future<void> scanSms(BuildContext context) async {
@@ -215,6 +229,9 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) return;
final subscription = _scannedSubscriptions[_currentIndex];
final inputName = serviceNameController.text.trim();
final resolvedServiceName =
inputName.isNotEmpty ? inputName : subscription.serviceName;
try {
final provider =
@@ -240,7 +257,7 @@ class SmsScanController extends ChangeNotifier {
// addSubscription 호출
await provider.addSubscription(
serviceName: subscription.serviceName,
serviceName: resolvedServiceName,
monthlyCost: subscription.monthlyCost,
billingCycle: subscription.billingCycle,
nextBillingDate: subscription.nextBillingDate,
@@ -273,7 +290,9 @@ class SmsScanController extends ChangeNotifier {
void moveToNextSubscription(BuildContext context) {
_currentIndex++;
websiteUrlController.text = '';
serviceNameController.text = '';
_selectedCategoryId = null;
_forceServiceNameEditing = false;
_prepareCurrentSelection(context);
// 모든 구독을 처리했으면 홈 화면으로 이동
@@ -298,6 +317,8 @@ class SmsScanController extends ChangeNotifier {
_selectedPaymentCardId = null;
_currentSuggestion = null;
_shouldSuggestCardCreation = false;
serviceNameController.clear();
_forceServiceNameEditing = false;
notifyListeners();
}
@@ -316,6 +337,13 @@ class SmsScanController extends ChangeNotifier {
if (websiteUrlController.text.isEmpty && currentSub.websiteUrl != null) {
websiteUrlController.text = currentSub.websiteUrl!;
}
if (_shouldEnableServiceNameEditing(currentSub)) {
if (serviceNameController.text != currentSub.serviceName) {
serviceNameController.clear();
}
} else {
serviceNameController.text = currentSub.serviceName;
}
}
}
@@ -332,10 +360,18 @@ class SmsScanController extends ChangeNotifier {
if (_currentIndex >= _scannedSubscriptions.length) {
_selectedPaymentCardId = null;
_currentSuggestion = null;
_forceServiceNameEditing = false;
serviceNameController.clear();
return;
}
final current = _scannedSubscriptions[_currentIndex];
_forceServiceNameEditing = _shouldEnableServiceNameEditing(current);
if (_forceServiceNameEditing && current.serviceName == '알 수 없는 서비스') {
serviceNameController.clear();
} else {
serviceNameController.text = current.serviceName;
}
// URL 기본값
if (current.websiteUrl != null && current.websiteUrl!.isNotEmpty) {
@@ -392,4 +428,9 @@ class SmsScanController extends ChangeNotifier {
}
return null;
}
bool _shouldEnableServiceNameEditing(Subscription subscription) {
final name = subscription.serviceName.trim();
return name.isEmpty || name == '알 수 없는 서비스';
}
}

View File

@@ -457,6 +457,13 @@ class AppLocalizations {
String get foundSubscription =>
_localizedStrings['foundSubscription'] ?? 'Found subscription';
String get serviceName => _localizedStrings['serviceName'] ?? 'Service Name';
String get latestSmsMessage =>
_localizedStrings['latestSmsMessage'] ?? 'Latest SMS message';
String smsDetectedDate(String date) {
final template = _localizedStrings['smsDetectedDate'] ?? 'Detected on @';
return template.replaceAll('@', date);
}
String get nextBillingDateLabel =>
_localizedStrings['nextBillingDateLabel'] ?? 'Next Billing Date';
String get category => _localizedStrings['category'] ?? 'Category';

View File

@@ -14,6 +14,7 @@ class Subscription {
final String currency;
final String? paymentCardId;
final PaymentCardSuggestion? paymentCardSuggestion;
final String? rawMessage;
Subscription({
required this.id,
@@ -29,8 +30,50 @@ class Subscription {
this.currency = 'KRW',
this.paymentCardId,
this.paymentCardSuggestion,
this.rawMessage,
});
Subscription copyWith({
String? id,
String? serviceName,
double? monthlyCost,
String? billingCycle,
DateTime? nextBillingDate,
String? category,
String? notes,
int? repeatCount,
DateTime? lastPaymentDate,
String? websiteUrl,
String? currency,
String? paymentCardId,
PaymentCardSuggestion? paymentCardSuggestion,
String? rawMessage,
}) {
return Subscription(
id: id ?? this.id,
serviceName: serviceName ?? this.serviceName,
monthlyCost: monthlyCost ?? this.monthlyCost,
billingCycle: billingCycle ?? this.billingCycle,
nextBillingDate: nextBillingDate ?? this.nextBillingDate,
category: category ?? this.category,
notes: notes ?? this.notes,
repeatCount: repeatCount ?? this.repeatCount,
lastPaymentDate: lastPaymentDate ?? this.lastPaymentDate,
websiteUrl: websiteUrl ?? this.websiteUrl,
currency: currency ?? this.currency,
paymentCardId: paymentCardId ?? this.paymentCardId,
paymentCardSuggestion: paymentCardSuggestion ??
(this.paymentCardSuggestion != null
? PaymentCardSuggestion(
issuerName: this.paymentCardSuggestion!.issuerName,
last4: this.paymentCardSuggestion!.last4,
source: this.paymentCardSuggestion!.source,
)
: null),
rawMessage: rawMessage ?? this.rawMessage,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
@@ -48,6 +91,7 @@ class Subscription {
'paymentCardSuggestionIssuer': paymentCardSuggestion?.issuerName,
'paymentCardSuggestionLast4': paymentCardSuggestion?.last4,
'paymentCardSuggestionSource': paymentCardSuggestion?.source,
'rawMessage': rawMessage,
};
}
@@ -74,6 +118,7 @@ class Subscription {
source: map['paymentCardSuggestionSource'] as String?,
)
: null,
rawMessage: map['rawMessage'] as String?,
);
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/navigation_provider.dart';
import '../routes/app_routes.dart';
class AppNavigationObserver extends NavigatorObserver {
@override
@@ -47,6 +48,12 @@ class AppNavigationObserver extends NavigatorObserver {
final routeName = route.settings.name;
if (routeName == null) return;
// 메인 화면('/')은 하단 탭으로 상태를 관리하므로
// 모달 닫힘 등으로 인해 홈 탭으로 강제 전환하지 않도록 무시한다.
if (routeName == AppRoutes.main || routeName == '/') {
return;
}
// build 완료 후 업데이트하도록 변경
WidgetsBinding.instance.addPostFrameCallback((_) {
if (navigator?.context == null) return;

View File

@@ -96,11 +96,16 @@ class _SmsScanScreenState extends State<SmsScanScreen> {
const SizedBox(height: 24),
SubscriptionCardWidget(
subscription: currentSubscription,
serviceNameController: _controller.serviceNameController,
websiteUrlController: _controller.websiteUrlController,
selectedCategoryId: _controller.selectedCategoryId,
onCategoryChanged: _controller.setSelectedCategoryId,
selectedPaymentCardId: _controller.selectedPaymentCardId,
onPaymentCardChanged: _controller.setSelectedPaymentCardId,
enableServiceNameEditing: _controller.isServiceNameEditable,
onServiceNameChanged: _controller.isServiceNameEditable
? _controller.updateCurrentServiceName
: null,
onAddCard: () async {
final newCardId = await PaymentCardFormSheet.show(context);
if (newCardId != null) {

View File

@@ -41,6 +41,7 @@ class SubscriptionConverter {
currency: model.currency,
paymentCardId: model.paymentCardId,
paymentCardSuggestion: result.cardSuggestion,
rawMessage: result.rawMessage,
);
}

View File

@@ -40,9 +40,22 @@ class SmsScanner {
return [];
}
final filteredSms = smsList
.whereType<Map<String, dynamic>>()
.where(_isEligibleSubscriptionSms)
.toList();
Log.d(
'SmsScanner: 유효 결제 SMS ${filteredSms.length}건 / 전체 ${smsList.length}');
if (filteredSms.isEmpty) {
Log.w('SmsScanner: 결제 패턴 SMS 미검출');
return [];
}
// SMS 데이터를 분석하여 반복 결제되는 구독 식별
final List<SmsScanResult> subscriptions = [];
final serviceGroups = _groupMessagesByIdentifier(smsList);
final serviceGroups = _groupMessagesByIdentifier(filteredSms);
Log.d('SmsScanner: 그룹화된 키 수: ${serviceGroups.length}');
@@ -355,16 +368,80 @@ class SmsScanner {
}
}
const List<String> _paymentLikeKeywords = [
'승인',
'결제',
'청구',
'charged',
'charge',
'payment',
'billed',
'purchase',
];
const List<String> _blockedKeywords = [
'otp',
'인증',
'보안',
'verification',
'code',
'코드',
'password',
'pw',
'일회성',
'1회용',
'보안문자',
];
bool _containsPaymentKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _paymentLikeKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _containsBlockedKeyword(String message) {
if (message.isEmpty) return false;
final normalized = message.toLowerCase();
return _blockedKeywords.any(
(keyword) => normalized.contains(keyword.toLowerCase()),
);
}
bool _isEligibleSubscriptionSms(Map<String, dynamic> sms) {
final amount = (sms['monthlyCost'] as num?)?.toDouble();
if (amount == null || amount <= 0) {
return false;
}
final message = sms['message'] as String? ?? '';
final isPaymentLike =
(sms['isPaymentLike'] as bool?) ?? _containsPaymentKeyword(message);
final isBlocked =
(sms['isBlocked'] as bool?) ?? _containsBlockedKeyword(message);
if (!isPaymentLike || isBlocked) {
return false;
}
return true;
}
// ===== Isolate 오프로딩용 Top-level 파서 =====
// 대량 SMS 파싱: plugin 객체를 직렬화한 맵 리스트를 받아 구독 후보 맵 리스트로 변환
List<Map<String, dynamic>> _parseRawSmsBatch(
List<Map<String, dynamic>> messages) {
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})*)'), // 결제 금액
RegExp(r'(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:원|₩)'),
RegExp(r'(?:원|₩)\s*(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
RegExp(r'(?:(?:US)?\$)\s*(\d+(?:\.\d{1,2})?)', caseSensitive: false),
RegExp(r'(\d+(?:,\d{3})*(?:\.\d{1,2})?)\s*(?:USD|KRW)',
caseSensitive: false),
RegExp(r'(?:USD|KRW)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)',
caseSensitive: false),
RegExp(r'(?:결제|승인)[^0-9]{0,12}(\d{1,3}(?:,\d{3})*(?:\.\d{1,2})?)'),
];
final results = <Map<String, dynamic>>[];
@@ -377,6 +454,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
final date = DateTime.fromMillisecondsSinceEpoch(dateMillis);
final serviceName = _isoExtractServiceName(body, sender);
final amount = _isoExtractAmount(body, amountPatterns);
final isPaymentLike = _containsPaymentKeyword(body);
final isBlocked = _containsBlockedKeyword(body);
final billingCycle = _isoExtractBillingCycle(body);
final nextBillingDate =
_isoCalculateNextBillingFromDate(date, billingCycle);
@@ -391,6 +470,8 @@ List<Map<String, dynamic>> _parseRawSmsBatch(
'normalizedBody': normalizedBody,
'nextBillingDate': nextBillingDate.toIso8601String(),
'previousPaymentDate': date.toIso8601String(),
'isPaymentLike': isPaymentLike,
'isBlocked': isBlocked,
});
}
@@ -557,6 +638,10 @@ _RepeatDetectionResult? _detectRepeatingSubscriptions(
if (matchedIndices.length < 2) return null;
final hasValidInterval = matchedPairs.any((pair) =>
pair.type == _MatchType.monthly || pair.type == _MatchType.yearly);
if (!hasValidInterval) return null;
final baseIndex = matchedIndices
.reduce((value, element) => value < element ? value : element);
final baseMessage = Map<String, dynamic>.from(sorted[baseIndex]);

View File

@@ -166,6 +166,21 @@ class TestSmsData {
'message':
'[GitHub] Your Pro plan has been renewed for \$4.00 USD. View your receipt at github.com/receipt. Next bill on ${DateTime(now.year, now.month + 1, 3).day}'
},
{
'serviceName': 'Enterprise Cloud Suite',
'monthlyCost': 990.0,
'billingCycle': '월간',
'nextBillingDate':
'${DateTime(now.year, now.month + 1, 25).year}-${DateTime(now.year, now.month + 1, 25).month.toString().padLeft(2, '0')}-25',
'isRecurring': true,
'repeatCount': 3,
'sender': '445566',
'messageDate': formattedNow,
'previousPaymentDate':
'${DateTime(now.year, now.month - 1, 25).year}-${DateTime(now.year, now.month - 1, 25).month.toString().padLeft(2, '0')}-25',
'message':
'[Enterprise Cloud] Your enterprise tier has been renewed. \$990.00 USD charged to your card. Next billing date: ${DateTime(now.year, now.month + 1, 25).day}'
},
];
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)

View File

@@ -41,7 +41,7 @@ class DetailHeaderSection extends StatelessWidget {
);
return Container(
height: 320,
constraints: const BoxConstraints(minHeight: 320),
decoration: BoxDecoration(color: baseColor),
child: Stack(
children: [
@@ -78,6 +78,7 @@ class DetailHeaderSection extends StatelessWidget {
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 뒤로가기 버튼
@@ -100,7 +101,7 @@ class DetailHeaderSection extends StatelessWidget {
),
],
),
const Spacer(),
const SizedBox(height: 16),
// 서비스 정보
FadeTransition(
opacity: fadeAnimation,
@@ -200,17 +201,19 @@ class DetailHeaderSection extends StatelessWidget {
borderRadius: BorderRadius.circular(16),
),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
_InfoColumn(
Expanded(
child: _InfoColumn(
label: AppLocalizations.of(context)
.nextBillingDate,
value: AppLocalizations.of(context)
.formatDate(
controller.nextBillingDate),
),
FutureBuilder<String>(
),
const SizedBox(width: 12),
Expanded(
child: FutureBuilder<String>(
future: () async {
final locale = context
.read<LocaleProvider>()
@@ -218,7 +221,8 @@ class DetailHeaderSection extends StatelessWidget {
.languageCode;
final amount = double.tryParse(
controller
.monthlyCostController.text
.monthlyCostController
.text
.replaceAll(',', '')) ??
0;
return CurrencyUtil
@@ -234,9 +238,11 @@ class DetailHeaderSection extends StatelessWidget {
.monthlyExpense,
value: snapshot.data ?? '-',
alignment: CrossAxisAlignment.end,
wrapValue: true,
);
},
),
),
],
),
),
@@ -387,11 +393,13 @@ class _InfoColumn extends StatelessWidget {
final String label;
final String value;
final CrossAxisAlignment alignment;
final bool wrapValue;
const _InfoColumn({
required this.label,
required this.value,
this.alignment = CrossAxisAlignment.start,
this.wrapValue = false,
});
@override
@@ -408,6 +416,19 @@ class _InfoColumn extends StatelessWidget {
),
),
const SizedBox(height: 4),
if (wrapValue)
Text(
value,
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: Colors.white,
),
)
else
Text(
value,
style: const TextStyle(

View File

@@ -19,6 +19,7 @@ import '../../l10n/app_localizations.dart';
class SubscriptionCardWidget extends StatefulWidget {
final Subscription subscription;
final TextEditingController serviceNameController;
final TextEditingController websiteUrlController;
final String? selectedCategoryId;
final Function(String?) onCategoryChanged;
@@ -32,10 +33,13 @@ class SubscriptionCardWidget extends StatefulWidget {
final bool showDetectedCardShortcut;
final Future<void> Function(PaymentCardSuggestion suggestion)?
onAddDetectedCard;
final bool enableServiceNameEditing;
final ValueChanged<String>? onServiceNameChanged;
const SubscriptionCardWidget({
super.key,
required this.subscription,
required this.serviceNameController,
required this.websiteUrlController,
this.selectedCategoryId,
required this.onCategoryChanged,
@@ -48,6 +52,8 @@ class SubscriptionCardWidget extends StatefulWidget {
this.detectedCardSuggestion,
this.showDetectedCardShortcut = false,
this.onAddDetectedCard,
this.enableServiceNameEditing = false,
this.onServiceNameChanged,
});
@override
@@ -84,6 +90,12 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
// 광고 위젯 추가
const NativeAdWidget(key: ValueKey('sms_scan_result_ad')),
const SizedBox(height: 16),
if (_hasRawSmsMessage)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: _buildSmsPreviewCard(context),
),
if (_hasRawSmsMessage) const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
@@ -143,6 +155,75 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
);
}
bool get _hasRawSmsMessage {
return widget.subscription.rawMessage != null &&
widget.subscription.rawMessage!.trim().isNotEmpty;
}
Widget _buildSmsPreviewCard(BuildContext context) {
final theme = Theme.of(context);
final loc = AppLocalizations.of(context);
final rawMessage = widget.subscription.rawMessage?.trim() ?? '';
final lastDate = widget.subscription.lastPaymentDate;
return Card(
elevation: 0,
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.4),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.sms_outlined, color: theme.colorScheme.primary),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ThemedText(
loc.latestSmsMessage,
fontWeight: FontWeight.bold,
),
if (lastDate != null)
ThemedText(
loc.smsDetectedDate(loc.formatDate(lastDate)),
opacity: 0.7,
fontSize: 13,
),
],
),
),
],
),
const SizedBox(height: 12),
Container(
width: double.infinity,
decoration: BoxDecoration(
color: theme.colorScheme.surface,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: theme.colorScheme.outline.withValues(alpha: 0.3),
),
),
padding: const EdgeInsets.all(12),
child: SelectableText(
rawMessage,
style: TextStyle(
fontSize: 15,
height: 1.4,
color: theme.colorScheme.onSurface,
),
),
),
],
),
),
);
}
// 정보 섹션 (클릭 가능)
Widget _buildInfoSection(CategoryProvider categoryProvider) {
return Column(
@@ -162,6 +243,15 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
opacity: 0.7,
),
const SizedBox(height: 4),
if (widget.enableServiceNameEditing)
BaseTextField(
controller: widget.serviceNameController,
hintText: AppLocalizations.of(context).serviceNameRequired,
onChanged: widget.onServiceNameChanged,
textInputAction: TextInputAction.done,
maxLines: 1,
)
else
ThemedText(
widget.subscription.serviceName,
fontSize: 22,

View File

@@ -474,22 +474,27 @@ class _SubscriptionCardState extends State<SubscriptionCard>
// 가격 정보
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 가격 표시 (이벤트 가격 반영)
// 가격 표시 (언어별 통화)
FutureBuilder<String>(
Expanded(
child: FutureBuilder<String>(
future: _getFormattedPrice(),
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const SizedBox();
}
if (widget
.subscription.isCurrentlyInEvent &&
if (widget.subscription
.isCurrentlyInEvent &&
snapshot.data!.contains('|')) {
final prices = snapshot.data!.split('|');
return Row(
final prices =
snapshot.data!.split('|');
return Wrap(
spacing: 8,
runSpacing: 4,
crossAxisAlignment:
WrapCrossAlignment.center,
children: [
Text(
prices[0],
@@ -503,7 +508,6 @@ class _SubscriptionCardState extends State<SubscriptionCard>
TextDecoration.lineThrough,
),
),
const SizedBox(width: 8),
Text(
prices[1],
style: TextStyle(
@@ -519,6 +523,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
} else {
return Text(
snapshot.data!,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
@@ -535,6 +541,8 @@ class _SubscriptionCardState extends State<SubscriptionCard>
}
},
),
),
const SizedBox(width: 12),
// 결제 예정일 정보
Container(

View File

@@ -32,10 +32,11 @@ class SubscriptionGroupHeader extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (group.mode == SubscriptionGroupingMode.paymentCard &&
group.paymentCard != null)
@@ -70,14 +71,20 @@ class SubscriptionGroupHeader extends StatelessWidget {
],
),
),
Text(
const SizedBox(width: 12),
Flexible(
child: Text(
_buildCostDisplay(context),
textAlign: TextAlign.end,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: scheme.onSurfaceVariant,
),
),
),
],
),
const SizedBox(height: 8),