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

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

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,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),
// 금액 및 결제 주기

View File

@@ -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(

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,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,
),
),
),
],