478 lines
15 KiB
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();
|
|
}
|