feat: improve sms scan review and detail layouts
This commit is contained in:
@@ -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 == '알 수 없는 서비스';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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?,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -41,6 +41,7 @@ class SubscriptionConverter {
|
||||
currency: model.currency,
|
||||
paymentCardId: model.paymentCardId,
|
||||
paymentCardSuggestion: result.cardSuggestion,
|
||||
rawMessage: result.rawMessage,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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}'
|
||||
},
|
||||
];
|
||||
|
||||
// 각 서비스별로 여러 개의 메시지 생성 (그룹화를 위해)
|
||||
|
||||
@@ -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,42 +201,47 @@ class DetailHeaderSection extends StatelessWidget {
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment:
|
||||
MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_InfoColumn(
|
||||
label: AppLocalizations.of(context)
|
||||
.nextBillingDate,
|
||||
value: AppLocalizations.of(context)
|
||||
.formatDate(
|
||||
controller.nextBillingDate),
|
||||
Expanded(
|
||||
child: _InfoColumn(
|
||||
label: AppLocalizations.of(context)
|
||||
.nextBillingDate,
|
||||
value: AppLocalizations.of(context)
|
||||
.formatDate(
|
||||
controller.nextBillingDate),
|
||||
),
|
||||
),
|
||||
FutureBuilder<String>(
|
||||
future: () async {
|
||||
final locale = context
|
||||
.read<LocaleProvider>()
|
||||
.locale
|
||||
.languageCode;
|
||||
final amount = double.tryParse(
|
||||
controller
|
||||
.monthlyCostController.text
|
||||
.replaceAll(',', '')) ??
|
||||
0;
|
||||
return CurrencyUtil
|
||||
.formatAmountWithLocale(
|
||||
amount,
|
||||
controller.currency,
|
||||
locale,
|
||||
);
|
||||
}(),
|
||||
builder: (context, snapshot) {
|
||||
return _InfoColumn(
|
||||
label: AppLocalizations.of(context)
|
||||
.monthlyExpense,
|
||||
value: snapshot.data ?? '-',
|
||||
alignment: CrossAxisAlignment.end,
|
||||
);
|
||||
},
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FutureBuilder<String>(
|
||||
future: () async {
|
||||
final locale = context
|
||||
.read<LocaleProvider>()
|
||||
.locale
|
||||
.languageCode;
|
||||
final amount = double.tryParse(
|
||||
controller
|
||||
.monthlyCostController
|
||||
.text
|
||||
.replaceAll(',', '')) ??
|
||||
0;
|
||||
return CurrencyUtil
|
||||
.formatAmountWithLocale(
|
||||
amount,
|
||||
controller.currency,
|
||||
locale,
|
||||
);
|
||||
}(),
|
||||
builder: (context, snapshot) {
|
||||
return _InfoColumn(
|
||||
label: AppLocalizations.of(context)
|
||||
.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,14 +416,27 @@ class _InfoColumn extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
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(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,11 +243,20 @@ class _SubscriptionCardWidgetState extends State<SubscriptionCardWidget> {
|
||||
opacity: 0.7,
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
ThemedText(
|
||||
widget.subscription.serviceName,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
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,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 금액 및 결제 주기
|
||||
|
||||
@@ -474,67 +474,75 @@ class _SubscriptionCardState extends State<SubscriptionCard>
|
||||
|
||||
// 가격 정보
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 가격 표시 (이벤트 가격 반영)
|
||||
// 가격 표시 (언어별 통화)
|
||||
FutureBuilder<String>(
|
||||
future: _getFormattedPrice(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
Expanded(
|
||||
child: FutureBuilder<String>(
|
||||
future: _getFormattedPrice(),
|
||||
builder: (context, snapshot) {
|
||||
if (!snapshot.hasData) {
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
if (widget
|
||||
.subscription.isCurrentlyInEvent &&
|
||||
snapshot.data!.contains('|')) {
|
||||
final prices = snapshot.data!.split('|');
|
||||
return Row(
|
||||
children: [
|
||||
Text(
|
||||
prices[0],
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
decoration:
|
||||
TextDecoration.lineThrough,
|
||||
if (widget.subscription
|
||||
.isCurrentlyInEvent &&
|
||||
snapshot.data!.contains('|')) {
|
||||
final prices =
|
||||
snapshot.data!.split('|');
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 4,
|
||||
crossAxisAlignment:
|
||||
WrapCrossAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
prices[0],
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSurfaceVariant,
|
||||
decoration:
|
||||
TextDecoration.lineThrough,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
prices[1],
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.error,
|
||||
Text(
|
||||
prices[1],
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
snapshot.data!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.subscription
|
||||
.isCurrentlyInEvent
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.error
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return Text(
|
||||
snapshot.data!,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: widget.subscription
|
||||
.isCurrentlyInEvent
|
||||
? Theme.of(context)
|
||||
.colorScheme
|
||||
.error
|
||||
: Theme.of(context)
|
||||
.colorScheme
|
||||
.primary,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// 결제 예정일 정보
|
||||
Container(
|
||||
|
||||
@@ -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,12 +71,18 @@ class SubscriptionGroupHeader extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_buildCostDisplay(context),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: scheme.onSurfaceVariant,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user