274 lines
8.7 KiB
Dart
274 lines
8.7 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(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,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|