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 createState() => _PaymentHistoryScreenState(); } class _PaymentHistoryScreenState extends State { List? _payments; @override void initState() { super.initState(); _load(); } Future _load() async { final payments = await widget.repository.payments(); if (mounted) setState(() => _payments = payments); } Future _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( 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 createState() => _PaymentDetailScreenState(); } class _PaymentDetailScreenState extends State { PaymentEntry? _payment; @override void initState() { super.initState(); _load(); } Future _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 _requestRefund(PaymentEntry payment) async { final reason = await Navigator.of(context, rootNavigator: true) .push( 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 reasonsOf(AppLocalizations l10n) => [ l10n.refundReasonMistake, l10n.refundReasonExpectation, l10n.refundReasonError, l10n.refundReasonDuplicate, l10n.commonEtc, ]; static List get reasons => reasonsOf(appL10n); @override State createState() => _RefundRequestScreenState(); } class _RefundRequestScreenState extends State { 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 _confirm() async { final ok = await showCupertinoDialog( 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(); }