Files
fanuzu/app/lib/features/my/pop_history_screen.dart
T

302 lines
9.8 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import 'my_data.dart';
import 'widgets/my_cards.dart';
import '../../core/i18n/app_locale.dart';
/// POP 내역 — 화면 대조 원장 F6 · Figma `374:15090`.
///
/// 명세 `554:23714` — 현재 보유 POP
/// 명세 `554:23732` — *디폴트 최신순으로 20개 노출 · 날짜 · 활동명 · 활동 타입 ·*
/// *증감 POP 값 · 누적 POP 값*. Zap 은 건별이 아니라 **당일 합산 한 줄**이다.
///
/// 한 줄에 일반 POP / 기여 POP 을 함께 적는 것이 정책 원장 **B-01** 과 별첨 1 ②
/// 「무료/유료 POP 원장 분리 유지」를 화면에서 보이는 지점이다.
class PopHistoryScreen extends StatefulWidget {
const PopHistoryScreen({super.key, required this.repository});
final MyRepository repository;
/// 명세가 정한 한 장의 크기.
static const int pageSize = 20;
@override
State<PopHistoryScreen> createState() => _PopHistoryScreenState();
}
class _PopHistoryScreenState extends State<PopHistoryScreen> {
MyProfile? _profile;
final List<PopLedgerEntry> _entries = [];
bool _loadingMore = false;
/// 더 읽을 것이 남았는가.
///
/// **장의 길이로 판정하지 않는다.** Zap 을 당일 한 줄로 접으므로 원장에서 20건을
/// 읽어도 화면 줄이 그보다 적게 나온다. 길이로 끝을 판정하면 접힌 장 뒤에 남은
/// 내역이 조용히 사라진다. 빈 장이 올 때까지 열어 둔다.
bool _hasMore = true;
@override
void initState() {
super.initState();
_load();
}
Future<void> _load() async {
final profile = await widget.repository.profile();
final entries = await widget.repository.popHistory(
limit: PopHistoryScreen.pageSize,
);
if (!mounted) return;
setState(() {
_profile = profile;
_entries
..clear()
..addAll(entries);
_hasMore = entries.isNotEmpty;
});
}
/// 다음 장. 커서는 마지막 줄이 들고 있는 [PopLedgerEntry.cursor] 다.
///
/// 표시용 시각을 그대로 쓰면 접힌 Zap 줄의 안쪽 행을 다시 읽어 같은 줄이 두 번 나온다.
///
/// **장 경계를 이어 붙인다.** 저장소는 한 장 안에서만 접으므로, 당일 Zap 이 한 장
/// 크기를 넘으면 「Zap 기여」 줄이 장마다 하나씩 생긴다. 새 장의 첫 줄이 직전 줄과
/// 접히는 짝이면 여기서 합친다. 판정과 합치는 규칙은 저장소와 같은 것을 쓴다.
Future<void> _more() async {
final cursor = _entries.isEmpty ? null : _entries.last.cursor;
if (_loadingMore || !_hasMore || cursor == null) return;
setState(() => _loadingMore = true);
final next = await widget.repository.popHistory(
limit: PopHistoryScreen.pageSize,
after: cursor,
);
if (!mounted) return;
setState(() {
_append(next);
_hasMore = next.isNotEmpty;
_loadingMore = false;
});
}
/// 새 장을 목록 끝에 붙인다. 경계에서 접힐 짝이면 한 줄로 합친다.
void _append(List<PopLedgerEntry> next) {
if (next.isEmpty) return;
if (_entries.isNotEmpty && _entries.last.foldsWith(next.first)) {
_entries[_entries.length - 1] = _entries.last.fold(next.first);
_entries.addAll(next.skip(1));
return;
}
_entries.addAll(next);
}
@override
Widget build(BuildContext context) {
final profile = _profile;
if (profile == null) {
return const Scaffold(body: Center(child: CircularProgressIndicator()));
}
// 시안 `374:15090` — 머리(#141414 · 위아래 60) 아래 #19191c 띠 8, 그 아래 목록(#141414).
return Scaffold(
appBar: AppBar(title: Text(context.l10n.myPop)),
backgroundColor: MyColors.card,
body: ListView.builder(
// 0 은 보유 POP, 마지막 칸은 「더 보기」 자리다.
itemCount:
1 + (_entries.isEmpty ? 1 : _entries.length) + (_hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == 0) return _Balance(profile: profile);
i -= 1;
if (_entries.isEmpty && i == 0) {
return ColoredBox(
color: AppColors.cosmicVoid,
child: Padding(
padding: const EdgeInsets.all(AppSpace.s6),
child: Center(
child: Text(
context.l10n.commonNoHistory,
style: myText(14, FontWeight.w500),
),
),
),
);
}
if (i >= _entries.length) {
return ColoredBox(
color: AppColors.cosmicVoid,
child: Center(
child: _loadingMore
? const Padding(
padding: EdgeInsets.all(AppSpace.s3),
child: CircularProgressIndicator(),
)
: TextButton(
onPressed: _more,
child: Text(context.l10n.commonMore),
),
),
);
}
return _EntryRow(entry: _entries[i]);
},
),
);
}
}
/// 보유 POP — 번개 20 + 「POP」 18/500 민트 · 값 36/900(명세 `554:23714` 현재 보유 POP = 총 보유).
///
/// 그 아래 「총 보유 · 예약 · 사용 가능」 세 값(정책 설계서 잔액 구분 · G-05 · QA 2-29).
/// Figma 에는 이 줄이 없어 배치는 해석 확인 대상이다.
class _Balance extends StatelessWidget {
const _Balance({required this.profile});
final MyProfile profile;
@override
Widget build(BuildContext context) => Container(
color: AppColors.cosmicVoid,
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.symmetric(vertical: 60),
child: Column(
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
Image.asset(
'assets/figma/icon-pop-small.png',
width: 20,
height: 20.8,
),
const SizedBox(width: 4),
Text(
'POP',
style: myText(18, FontWeight.w500, color: _mint, height: 1),
),
],
),
const SizedBox(height: 16),
SizedBox(
height: 24,
child: OverflowBox(
maxHeight: 48,
child: Text(
formatPop(profile.balancePop),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 36,
fontWeight: FontWeight.w900,
height: 1,
color: AppColors.onCosmic,
),
),
),
),
const SizedBox(height: 24),
Row(
children: [
for (final (label, value) in [
(context.l10n.popTotal, profile.balancePop),
(context.l10n.popReserved, profile.reservedPop),
(context.l10n.popAvailable, profile.availablePop),
])
Expanded(
child: Column(
children: [
Text(label, style: myText(12, FontWeight.w500, color: MyColors.muted)),
const SizedBox(height: 4),
Text(formatPop(value), style: myText(14, FontWeight.w700)),
],
),
),
],
),
],
),
);
}
/// 민트 #67d3a9 — 활동 타입 · 적립 값.
const _mint = Color(0xFF67D3A9);
/// 한 줄 — 높이 79 · 안쪽 20/16 · 날짜 14/500 #777 · 활동명 16/600 · 타입 12 민트 ·
/// 증감 18/800(적립 민트, 차감 흰색) · 누적 14/500 #777.
///
/// 타입 자리에 거래 종류와 POP 종류(일반/기여)를 함께 적는다(B-01).
class _EntryRow extends StatelessWidget {
const _EntryRow({required this.entry});
final PopLedgerEntry entry;
String get _date =>
'${entry.at.month.toString().padLeft(2, '0')}.${entry.at.day.toString().padLeft(2, '0')}';
@override
Widget build(BuildContext context) {
final up = entry.delta >= 0;
return Container(
color: AppColors.cosmicVoid,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_date,
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.displayTitle,
style: myText(16, FontWeight.w600, height: 1),
),
const SizedBox(height: 6),
Text(
entry.displaySubtitle,
style: myText(12, FontWeight.w400, color: _mint),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${up ? '+' : '-'}${formatPop(entry.delta.abs())}',
style: myText(
18,
FontWeight.w800,
color: up ? _mint : AppColors.onCosmic,
height: 1,
).copyWith(letterSpacing: 0),
),
const SizedBox(height: 6),
Text(
formatPop(entry.balanceAfter),
style: myText(
14,
FontWeight.w500,
color: MyColors.muted,
height: 1,
),
),
],
),
],
),
);
}
}