Files
fanuzu/app/lib/features/my/payment_screens.dart

478 lines
15 KiB
Dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_radio.dart';
import '../../core/monetization/store.dart';
import '../../core/ui/app_icons.dart';
import 'my_data.dart';
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// 서비스 결제 내역 — 화면 대조 원장 F6 · Figma `374:15185`.
///
/// 명세 `554:23841` — *결제 일시 · 결제 아이템명 · 결제 아이템 가격 · 최신순 소팅*
class PaymentHistoryScreen extends StatefulWidget {
const PaymentHistoryScreen({super.key, required this.repository});
final MyRepository repository;
@override
State<PaymentHistoryScreen> createState() => _PaymentHistoryScreenState();
}
class _PaymentHistoryScreenState extends State<PaymentHistoryScreen> {
List<PaymentEntry>? _payments;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final payments = await widget.repository.payments();
if (mounted) setState(() => _payments = payments);
}
Future<void> _restore() async {
try {
await Store.instance?.restore();
} on Exception {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
content: Text(context.l10n.storeConnectFailed),
),
);
return;
}
await _load();
}
@override
Widget build(BuildContext context) {
final payments = _payments;
if (payments == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.settingsServicePayments),
actions: [
// 구매 복원 — 재설치 · 기기 변경 뒤 Focus 구독을 되살린다. 스토어가 돌려준
// 구매는 서버가 거래 ID 로 멱등 검증하므로 두 번 지급되지 않는다.
if (Store.instance != null)
TextButton(
onPressed: _restore,
child: Text(context.l10n.payRestore),
),
],
),
body: Column(
children: [
Expanded(
child: payments.isEmpty
? Center(
child: Text(
context.l10n.payEmpty,
style: myText(14, FontWeight.w500),
),
)
: ListView.builder(
itemCount: payments.length,
itemBuilder: (context, i) {
final p = payments[i];
// 시안 `374:15185` — 줄 높이 58 · 안쪽 20/16 · 날짜 14/500 #777 · 상품 16/600 · 금액 18/800.
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
await Navigator.of(
context,
rootNavigator: true,
).push<void>(
MaterialPageRoute(
builder: (_) => PaymentDetailScreen(
paymentId: p.id,
repository: widget.repository,
),
),
);
await _load();
},
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 20,
),
child: Row(
children: [
Text(
'${p.at.month.toString().padLeft(2, '0')}.${p.at.day.toString().padLeft(2, '0')}',
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1,
),
),
const SizedBox(width: 12),
Expanded(
child: Text(
// 결제 완료가 아니면 상태를 붙인다(명세 `554:23961` 다섯 상태).
p.state == PaymentState.paid
? p.itemName
: '${p.itemName} · ${p.state.label}',
overflow: TextOverflow.ellipsis,
style: myText(16, FontWeight.w600, height: 1),
),
),
Text('₩ ${_won(p.price)}', style: _amount),
],
),
),
);
},
),
),
],
),
);
}
}
/// 금액 18/800 — 시안 결제 내역 · 상세.
const _amount = TextStyle(
fontFamily: AppFonts.body,
fontSize: 18,
fontWeight: FontWeight.w800,
height: 1,
color: AppColors.onCosmic,
);
/// 결제 내역 상세 — Figma `506:14308`.
///
/// 명세 `554:23961` — *결제 아이템명 · 결제 번호 · 일시 · 방식 · 상태 · 금액*.
/// 상태는 다섯이며 **결제 완료일 때만 환불을 신청**할 수 있다.
class PaymentDetailScreen extends StatefulWidget {
const PaymentDetailScreen({
super.key,
required this.paymentId,
required this.repository,
});
final String paymentId;
final MyRepository repository;
/// 시안 `506:14308` 「환불 안내」 문구 그대로.
static String get refundGuide => appL10n.payRefundGuide;
@override
State<PaymentDetailScreen> createState() => _PaymentDetailScreenState();
}
class _PaymentDetailScreenState extends State<PaymentDetailScreen> {
PaymentEntry? _payment;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final payment = await widget.repository.payment(widget.paymentId);
if (mounted) setState(() => _payment = payment);
}
@override
Widget build(BuildContext context) {
final p = _payment;
if (p == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
// 시안 `506:14308`(환불 가능) · `571:18672`(환불 불가 — 회색 「환불 불가」).
return Scaffold(
appBar: AppBar(title: Text(context.l10n.payDetailTitle)),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
p.itemName,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 24),
const SizedBox(
height: 1,
child: ColoredBox(color: AppColors.onCosmicMuted),
),
const SizedBox(height: 24),
_Row(label: context.l10n.payNumber, value: p.id),
_Row(
label: context.l10n.payDate,
value:
'${p.at.year}.${p.at.month.toString().padLeft(2, '0')}.${p.at.day.toString().padLeft(2, '0')}',
),
_Row(label: context.l10n.payMethod, value: p.method),
_Row(label: context.l10n.payStatus, value: p.state.label),
_Row(
label: context.l10n.payAmount,
value: '₩ ${_won(p.price)}',
amount: true,
),
const SizedBox(height: 8),
Text(context.l10n.payRefundInfo, style: _guide),
const SizedBox(height: 6),
Text(PaymentDetailScreen.refundGuide, style: _guide),
],
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: p.state.canRequestRefund ? () => _requestRefund(p) : null,
style: FilledButton.styleFrom(
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
disabledBackgroundColor: const Color(0xFF2C2C2C),
disabledForegroundColor: AppColors.onCosmicMuted,
),
child: Text(
p.state.canRequestRefund
? context.l10n.payRefundApply
: context.l10n.payRefundUnavailable,
),
),
),
);
}
Future<void> _requestRefund(PaymentEntry payment) async {
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute(
builder: (_) => RefundRequestScreen(
paymentId: payment.id,
repository: widget.repository,
),
),
);
if (reason == null || !mounted) return;
await _load();
}
}
/// 환불 요청 — Figma `506:14437`.
///
/// 명세 `554:23972` — *요청 디폴트 비활성화, 환불 사유 선택 시 활성화*
/// 명세 `554:23978` — *디폴트 선택 X · 단일 선택*
/// 명세 `554:24008` — *확인 시 결제 완료 → 환불 요청으로 update*
class RefundRequestScreen extends StatefulWidget {
const RefundRequestScreen({
super.key,
required this.paymentId,
required this.repository,
});
final String paymentId;
final MyRepository repository;
/// 환불 사유. Admin 에 등록될 목록의 표본이다.
static List<String> reasonsOf(AppLocalizations l10n) => [
l10n.refundReasonMistake,
l10n.refundReasonExpectation,
l10n.refundReasonError,
l10n.refundReasonDuplicate,
l10n.commonEtc,
];
static List<String> get reasons => reasonsOf(appL10n);
@override
State<RefundRequestScreen> createState() => _RefundRequestScreenState();
}
class _RefundRequestScreenState extends State<RefundRequestScreen> {
String? _reason;
// 시안 `506:14437` — 닫기 · 제목 「환불」 · 오른쪽 「요청」(비활성 #474747) ·
// 머리 띠 #232325 「환불 사유」 · 줄 48(밑줄 #313131) · 라디오 20 · 글자 16/600 #d1d1d2.
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(
leading: IconButton(
tooltip: context.l10n.commonClose,
icon: const ImageIcon(AppIcons.x, size: 24),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(context.l10n.refundTitle),
actions: [
GestureDetector(
// 디폴트 비활성화 · 사유 선택 시 활성화.
onTap: _reason == null ? null : _confirm,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
child: Text(
context.l10n.refundSubmit,
style: myText(
14,
FontWeight.w600,
height: 1,
color: _reason == null
? const Color(0xFF474747)
: AppColors.cosmicAccent,
),
),
),
),
],
),
// 디폴트 미선택 · 단수 선택.
body: ListView(
children: [
Container(
height: 32,
color: const Color(0xFF232325),
padding: const EdgeInsets.symmetric(horizontal: 16),
alignment: Alignment.centerLeft,
child: Text(
context.l10n.refundReason,
style: myText(
12,
FontWeight.w600,
color: const Color(0xFF909091),
height: 1,
),
),
),
for (final reason in RefundRequestScreen.reasons)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => setState(() => _reason = reason),
child: Container(
height: 48,
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: const BoxDecoration(
border: Border(bottom: BorderSide(color: Color(0xFF313131))),
),
child: Row(
children: [
AppRadio(selected: _reason == reason),
const SizedBox(width: 8),
Text(
reason,
style: myText(
16,
FontWeight.w600,
color: const Color(0xFFD1D1D2),
height: 1,
),
),
],
),
),
),
],
),
);
/// 확인 — 시안 `506:14615` iOS 경고창 「정말 환불 신청을 할까요?」.
Future<void> _confirm() async {
final ok = await showCupertinoDialog<bool>(
context: context,
builder: (context) => CupertinoAlertDialog(
title: Text(context.l10n.refundConfirm),
actions: [
CupertinoDialogAction(
onPressed: () => Navigator.of(context).pop(false),
child: Text(context.l10n.commonCancel),
),
CupertinoDialogAction(
onPressed: () => Navigator.of(context).pop(true),
child: Text(context.l10n.commonConfirm),
),
],
),
);
if (ok != true || !mounted) return;
// 서버·Admin 에는 기준 언어(ko) 사유를 남긴다 — 화면 언어에 따라 원장 값이 갈리지 않게.
await widget.repository.requestRefund(
widget.paymentId,
RefundRequestScreen.reasonsOf(koL10n)[RefundRequestScreen.reasons.indexOf(
_reason!,
)],
);
if (mounted) Navigator.of(context).pop(_reason);
}
}
class _Row extends StatelessWidget {
const _Row({required this.label, required this.value, this.amount = false});
final String label;
final String value;
final bool amount;
/// 시안 — 라벨 14/600 #cdcdcd · 값 14/600 · 줄 24 · 간격 16.
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: SizedBox(
height: 24,
child: Row(
children: [
Text(
label,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
color: Color(0xFFCDCDCD),
),
),
const Spacer(),
Text(
value,
style: amount
? _amount
: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.onCosmic,
),
),
],
),
),
);
}
/// 환불 안내 12/400 #767676 행간 170%.
const _guide = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.7,
color: AppColors.onCosmicMuted,
);
/// 원화 표기. POP 과 다른 단위라 함수를 나눈다.
String _won(int value) {
final s = value.toString();
final buffer = StringBuffer();
for (var i = 0; i < s.length; i++) {
if (i > 0 && (s.length - i) % 3 == 0) buffer.write(',');
buffer.write(s[i]);
}
return buffer.toString();
}