POP 내역 총 보유 · 예약 · 사용 가능 3구분 · 온보딩 추천인 코드

This commit is contained in:
Sn-Kinos
2026-10-06 20:05:18 +09:00
parent 363c74736c
commit a04626b003
20 changed files with 235 additions and 29 deletions
+7 -1
View File
@@ -164,7 +164,13 @@ class SampleOnboardingRepository implements OnboardingRepository {
}) async => profile = (nickname: nickname, country: country, locale: locale);
@override
Future<void> attributeReferral(String code) async => referral = code;
Future<void> attributeReferral(String code) async {
// 서버 `fnReferralAttribute` 처럼 1인 1회 — 두 번째는 거절(E-POP-03).
if (referral != null) {
throw const BackendException('E-POP-03', '이미 처리된 추천입니다.');
}
referral = code;
}
@override
Future<List<OnboardingFandom>> fandoms({String query = ''}) async => [
+26 -4
View File
@@ -165,6 +165,10 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
/// ② 에서 서버에 기록한 동의 항목. ③ 에서 뒤로 오면 이 상태로 체크해 보여 준다.
Set<String>? _agreed;
/// ③ 에서 서버에 귀속한 추천인 코드. ④ 에서 뒤로 와 다시 「다음」이면 또 부르지 않는다
/// (서버는 1인 1회라 두 번째 호출을 거절한다).
String? _referred;
/// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary).
final _picked = <OnboardingFandom>[];
@@ -241,6 +245,8 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
),
_Step.profile => _ProfileStep(
repository: widget.repository,
referred: _referred,
onReferred: (code) => _referred = code,
onBack: _back,
onDone: () => setState(() => _step = _Step.artist),
),
@@ -287,11 +293,17 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
class _ProfileStep extends StatefulWidget {
const _ProfileStep({
required this.repository,
required this.referred,
required this.onReferred,
required this.onDone,
required this.onBack,
});
final OnboardingRepository repository;
/// 이미 귀속한 추천인 코드(뒤로 왔을 때 채워 보여 준다).
final String? referred;
final ValueChanged<String> onReferred;
final VoidCallback onDone;
final VoidCallback onBack;
@@ -301,7 +313,7 @@ class _ProfileStep extends StatefulWidget {
class _ProfileStepState extends State<_ProfileStep> {
final _nickname = TextEditingController();
final _referral = TextEditingController();
late final _referral = TextEditingController(text: widget.referred);
String? _country = _deviceCountry();
Locale? _locale;
String? _nicknameError;
@@ -360,11 +372,20 @@ class _ProfileStepState extends State<_ProfileStep> {
);
if (_locale != null) await scope?.select(_locale!);
final code = _referral.text.trim().toUpperCase();
if (code.isNotEmpty) {
if (code.isNotEmpty && code != widget.referred) {
try {
await widget.repository.attributeReferral(code);
widget.onReferred(code);
} on BackendException catch (e) {
if (mounted) setState(() => _referralError = e.message);
// 거절 사유(형식 · 없는 코드 · 본인 · 상호 · 이미 등록 · 7일 경과)는 Figma `511:86845`
// 문구 하나로 보인다(C-119 「오류 문구 재사용」). 통신 실패만 서버 문구 그대로.
if (mounted) {
setState(
() => _referralError = e.code == 'E-SYS-01'
? e.message
: l10n.onbReferralInvalid,
);
}
return;
}
}
@@ -440,6 +461,8 @@ class _ProfileStepState extends State<_ProfileStep> {
onChanged: (v) => setState(() => _locale = v),
),
const SizedBox(height: AppSpace.s3),
// 추천인 코드 입력칸 — Figma 이메일 가입 `333:18620`(입력칸) · `511:86845`(오류) ·
// `511:86809`(선택 입력). 10/3 통합회신 7항으로 가입 직후 온보딩에 둔다(C-119).
TextField(
controller: _referral,
textCapitalization: TextCapitalization.characters,
@@ -447,7 +470,6 @@ class _ProfileStepState extends State<_ProfileStep> {
onChanged: (_) => setState(() => _referralError = null),
decoration: InputDecoration(
labelText: l10n.onbReferral,
helperText: l10n.onbReferralHint,
errorText: _referralError,
),
),
+8 -1
View File
@@ -322,6 +322,7 @@ class MyProfile {
this.userLevel = 1,
this.passportTier = PassportTier.silver,
required this.balancePop,
this.reservedPop = 0,
required this.steps,
required this.completedQuests,
required this.planetTiers,
@@ -346,9 +347,15 @@ class MyProfile {
/// 대표 팬덤의 Passport Tier(B-65 · 10/3 #10 사용자 × 팬덤). 팬덤별 값은 [planetTiers].
final PassportTier passportTier;
/// 보유 POP(활동).
/// 총 보유 POP — 일반 + 기여 + 예약(G-05 `총 보유 = 예약 + 사용 가능`).
final int balancePop;
/// 예약 — 검토 대기 보상처럼 묶여 있어 아직 쓸 수 없는 몫(서버 `pendingActivity`).
final int reservedPop;
/// 사용 가능 = 총 보유 − 예약.
int get availablePop => balancePop - reservedPop;
/// 누적 걸음. 명세 `549:22058` 의 프로필 카드 항목.
final int steps;
final int completedQuests;
@@ -23,7 +23,7 @@ import '../../core/i18n/app_locale.dart';
/// - Passport `users/{uid}/passports/{fandomId}.tier` — **팬덤별**(10/3 #10 · 9/21 5절). 없으면 SILVER.
/// 탈퇴한 팬덤의 Passport 도 지우지 않고 보인다(ARCHIVED).
/// - Badge `users/{uid}/badges`(상세정의 4장 · 별첨 1 ②-5)
/// - 잔액 `users/{uid}/wallet/main.activityBalance`
/// - 잔액 `users/{uid}/wallet/main` — 사용 가능 = `activityBalance + supportBalance`, 예약 = `pendingActivity`(G-05)
/// - 행성별 기여 `fandoms/{fandomId}` + `fandoms/{fandomId}/members/{uid}.contributedPop`
/// - POP 원장 `users/{uid}/popTransactions` (최신순 · 커서 `createdAt`)
/// - 공지 `notices`
@@ -126,7 +126,8 @@ class FirestoreMyRepository extends SampleMyRepository {
userExp: user.intOr('userExp'),
userLevel: user.intOr('userLevel').clamp(1, 1 << 31),
passportTier: primaryTier,
balancePop: wallet.intOr('activityBalance'),
balancePop: wallet.intOr('activityBalance') + wallet.intOr('supportBalance') + wallet.intOr('pendingActivity'),
reservedPop: wallet.intOr('pendingActivity'),
steps: user.intOr('steps'),
completedQuests: await completedF,
planetTiers: tiers,
+27 -5
View File
@@ -109,7 +109,7 @@ class _PopHistoryScreenState extends State<PopHistoryScreen> {
itemCount:
1 + (_entries.isEmpty ? 1 : _entries.length) + (_hasMore ? 1 : 0),
itemBuilder: (context, i) {
if (i == 0) return _Balance(pop: profile.balancePop);
if (i == 0) return _Balance(profile: profile);
i -= 1;
if (_entries.isEmpty && i == 0) {
return ColoredBox(
@@ -148,11 +148,14 @@ class _PopHistoryScreenState extends State<PopHistoryScreen> {
}
}
/// 보유 POP — 번개 20 + 「POP」 18/500 민트 · 값 36/900.
/// 보유 POP — 번개 20 + 「POP」 18/500 민트 · 값 36/900(명세 `554:23714` 현재 보유 POP = 총 보유).
///
/// 그 아래 「총 보유 · 예약 · 사용 가능」 세 값(정책 설계서 잔액 구분 · G-05 · QA 2-29).
/// Figma 에는 이 줄이 없어 배치는 해석 확인 대상이다.
class _Balance extends StatelessWidget {
const _Balance({required this.pop});
const _Balance({required this.profile});
final int pop;
final MyProfile profile;
@override
Widget build(BuildContext context) => Container(
@@ -182,7 +185,7 @@ class _Balance extends StatelessWidget {
child: OverflowBox(
maxHeight: 48,
child: Text(
formatPop(pop),
formatPop(profile.balancePop),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 36,
@@ -193,6 +196,25 @@ class _Balance extends StatelessWidget {
),
),
),
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)),
],
),
),
],
),
],
),
);