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 createState() => _PopHistoryScreenState(); } class _PopHistoryScreenState extends State { MyProfile? _profile; final List _entries = []; bool _loadingMore = false; /// 더 읽을 것이 남았는가. /// /// **장의 길이로 판정하지 않는다.** Zap 을 당일 한 줄로 접으므로 원장에서 20건을 /// 읽어도 화면 줄이 그보다 적게 나온다. 길이로 끝을 판정하면 접힌 장 뒤에 남은 /// 내역이 조용히 사라진다. 빈 장이 올 때까지 열어 둔다. bool _hasMore = true; @override void initState() { super.initState(); _load(); } Future _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 _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 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(pop: profile.balancePop); 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. class _Balance extends StatelessWidget { const _Balance({required this.pop}); final int pop; @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(pop), style: const TextStyle( fontFamily: AppFonts.body, fontSize: 36, fontWeight: FontWeight.w900, height: 1, color: AppColors.onCosmic, ), ), ), ), ], ), ); } /// 민트 #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, ), ), ], ), ], ), ); } }