INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,468 @@
|
||||
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();
|
||||
}
|
||||
Reference in New Issue
Block a user