번역 평가 버튼 · 내 퀘스트 검토 버튼 · MY 질문 상세 · MY 완료 퀘스트 수 · 행성/성운 칸 · 라운지 빈 상태 수정
This commit is contained in:
+116
-116
@@ -71,120 +71,115 @@ enum PopTxType {
|
||||
/// Functions 의 `ReasonCode`(`firebase/functions/src/ledger/ledger.ts`)와 짝이며,
|
||||
/// 모르는 코드는 원문을 그대로 내보이지 않고 중립 문구로 접는다 — 사용자 화면에
|
||||
/// `QUEST_MUSIC_SHAKE` 같은 내부 식별자가 새면 고장으로 읽힌다.
|
||||
({String title, String type}) popReasonLabel(String reasonCode) =>
|
||||
switch (reasonCode) {
|
||||
'QUEST_STEPS' => (
|
||||
title: appL10n.popReasonQuestSteps,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_MUSIC_SHAKE' => (
|
||||
title: appL10n.popReasonQuestMusicShake,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_PLACE' => (
|
||||
title: appL10n.popReasonQuestPlace,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_ATTEND' => (
|
||||
title: appL10n.popReasonQuestAttend,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_UGC' => (
|
||||
title: appL10n.popReasonQuestUgc,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_FEE' => (
|
||||
title: appL10n.popReasonQuestFee,
|
||||
type: appL10n.popTypeFee,
|
||||
),
|
||||
'QUEST_POOL_DEPOSIT' => (
|
||||
title: appL10n.popReasonQuestPoolDeposit,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
// 정상 종료 반환(C-09)과 공개 전 실패 환불은 사용자에게 같은 「반환」이다.
|
||||
'QUEST_POOL_RETURN' || 'QUEST_POOL_REFUND' => (
|
||||
title: appL10n.popReasonQuestPoolReturn,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'QUEST_TIMEATTACK' => (
|
||||
title: appL10n.popReasonQuestTimeAttack,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'AD_REWARD' => (
|
||||
title: appL10n.popReasonAdReward,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'HERO_DROP' => (
|
||||
title: appL10n.popReasonHeroDrop,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'HERO_DROP_TRIGGER' => (
|
||||
title: appL10n.popReasonHeroDropTrigger,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
'TRANSLATION' => (
|
||||
title: appL10n.popReasonTranslation,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'IAP_PURCHASE' => (
|
||||
title: appL10n.popReasonIapPurchase,
|
||||
type: appL10n.popTypePayment,
|
||||
),
|
||||
'FOCUS_DAILY' => (
|
||||
title: appL10n.popReasonFocusDaily,
|
||||
type: appL10n.popTypeSubscription,
|
||||
),
|
||||
'PREREG_BONUS' => (
|
||||
title: appL10n.popReasonPreregBonus,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'REFERRAL_REWARD' => (
|
||||
// 사본 POP 내역 `554:23742` 「친구 초대 · 레퍼럴」.
|
||||
title: appL10n.popReasonReferral,
|
||||
type: appL10n.popTypeReferral,
|
||||
),
|
||||
'ADMIN_GRANT' => (
|
||||
title: appL10n.popReasonAdminGrant,
|
||||
type: appL10n.popTypeAdmin,
|
||||
),
|
||||
'ADMIN_CLAWBACK' => (
|
||||
title: appL10n.popReasonAdminClawback,
|
||||
type: appL10n.popTypeAdmin,
|
||||
),
|
||||
'CAMPAIGN_FUND' => (
|
||||
title: appL10n.popReasonCampaignFund,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
'CAMPAIGN_REFUND' => (
|
||||
title: appL10n.popReasonCampaignRefund,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'CAMPAIGN_FEE_REFUND' => (
|
||||
title: appL10n.popReasonCampaignFeeRefund,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'CAMPAIGN_FEE' => (
|
||||
title: appL10n.popReasonCampaignFee,
|
||||
type: appL10n.popTypeFee,
|
||||
),
|
||||
'QNA_BOUNTY' => (
|
||||
title: appL10n.popReasonQnaBounty,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
'QNA_REFUND' => (
|
||||
title: appL10n.popReasonQnaRefund,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'QNA_REWARD' => (
|
||||
title: appL10n.popReasonQnaReward,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'ECLIPSE_DEFENSE' => (
|
||||
title: appL10n.popReasonEclipseDefense,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
_ => (title: appL10n.popReasonOther, type: appL10n.commonEtc),
|
||||
};
|
||||
({String title, String type}) popReasonLabel(
|
||||
String reasonCode,
|
||||
) => switch (reasonCode) {
|
||||
'QUEST_STEPS' => (
|
||||
title: appL10n.popReasonQuestSteps,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_MUSIC_SHAKE' => (
|
||||
title: appL10n.popReasonQuestMusicShake,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_PLACE' => (
|
||||
title: appL10n.popReasonQuestPlace,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_ATTEND' => (
|
||||
title: appL10n.popReasonQuestAttend,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'QUEST_UGC' => (title: appL10n.popReasonQuestUgc, type: appL10n.popTypeQuest),
|
||||
'QUEST_FEE' => (title: appL10n.popReasonQuestFee, type: appL10n.popTypeFee),
|
||||
'QUEST_POOL_DEPOSIT' => (
|
||||
title: appL10n.popReasonQuestPoolDeposit,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
// 정상 종료 반환(C-09)과 공개 전 실패 환불은 사용자에게 같은 「반환」이다.
|
||||
'QUEST_POOL_RETURN' || 'QUEST_POOL_REFUND' => (
|
||||
title: appL10n.popReasonQuestPoolReturn,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'QUEST_TIMEATTACK' => (
|
||||
title: appL10n.popReasonQuestTimeAttack,
|
||||
type: appL10n.popTypeQuest,
|
||||
),
|
||||
'AD_REWARD' => (
|
||||
title: appL10n.popReasonAdReward,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'HERO_DROP' => (
|
||||
title: appL10n.popReasonHeroDrop,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'HERO_DROP_TRIGGER' => (
|
||||
title: appL10n.popReasonHeroDropTrigger,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
'TRANSLATION' => (
|
||||
title: appL10n.popReasonTranslation,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'IAP_PURCHASE' => (
|
||||
title: appL10n.popReasonIapPurchase,
|
||||
type: appL10n.popTypePayment,
|
||||
),
|
||||
'FOCUS_DAILY' => (
|
||||
title: appL10n.popReasonFocusDaily,
|
||||
type: appL10n.popTypeSubscription,
|
||||
),
|
||||
'PREREG_BONUS' => (
|
||||
title: appL10n.popReasonPreregBonus,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'REFERRAL_REWARD' => (
|
||||
// 사본 POP 내역 `554:23742` 「친구 초대 · 레퍼럴」.
|
||||
title: appL10n.popReasonReferral,
|
||||
type: appL10n.popTypeReferral,
|
||||
),
|
||||
'ADMIN_GRANT' => (
|
||||
title: appL10n.popReasonAdminGrant,
|
||||
type: appL10n.popTypeAdmin,
|
||||
),
|
||||
'ADMIN_CLAWBACK' => (
|
||||
title: appL10n.popReasonAdminClawback,
|
||||
type: appL10n.popTypeAdmin,
|
||||
),
|
||||
'CAMPAIGN_FUND' => (
|
||||
title: appL10n.popReasonCampaignFund,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
'CAMPAIGN_REFUND' => (
|
||||
title: appL10n.popReasonCampaignRefund,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'CAMPAIGN_FEE_REFUND' => (
|
||||
title: appL10n.popReasonCampaignFeeRefund,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'CAMPAIGN_FEE' => (
|
||||
title: appL10n.popReasonCampaignFee,
|
||||
type: appL10n.popTypeFee,
|
||||
),
|
||||
'QNA_BOUNTY' => (
|
||||
title: appL10n.popReasonQnaBounty,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
'QNA_REFUND' => (
|
||||
title: appL10n.popReasonQnaRefund,
|
||||
type: appL10n.popTxRefund,
|
||||
),
|
||||
'QNA_REWARD' => (
|
||||
title: appL10n.popReasonQnaReward,
|
||||
type: appL10n.popTypeReward,
|
||||
),
|
||||
'ECLIPSE_DEFENSE' => (
|
||||
title: appL10n.popReasonEclipseDefense,
|
||||
type: appL10n.commonContribute,
|
||||
),
|
||||
_ => (title: appL10n.popReasonOther, type: appL10n.commonEtc),
|
||||
};
|
||||
|
||||
/// 레벨 칭호 — 정책 원장 **C-18 · B-66**.
|
||||
///
|
||||
@@ -586,7 +581,11 @@ const int nicknameMaxLength = 20;
|
||||
/// 운영 사칭으로 읽힐 수 있는 이름 — 대소문자 무시 부분 일치로 막는다.
|
||||
/// `firestore.rules` 의 `nicknameOk` 가 같은 목록을 본다.
|
||||
const List<String> reservedNicknameWords = [
|
||||
'fanuzu', '운영팀', '관리자', 'admin', 'official',
|
||||
'fanuzu',
|
||||
'운영팀',
|
||||
'관리자',
|
||||
'admin',
|
||||
'official',
|
||||
];
|
||||
|
||||
/// 닉네임 없는 작성자 표기([storedFanName] 「팬」)와 그 번역 — 정확히 일치하면 막는다.
|
||||
@@ -603,7 +602,8 @@ String normalizeNickname(String nickname) => nickname
|
||||
|
||||
bool nicknameReserved(String nickname) {
|
||||
final key = normalizeNickname(nickname);
|
||||
return reservedFanNames.contains(key) || reservedNicknameWords.any(key.contains);
|
||||
return reservedFanNames.contains(key) ||
|
||||
reservedNicknameWords.any(key.contains);
|
||||
}
|
||||
|
||||
/// 차단한 계정 한 건 — 설계서 `users/{uid}/blocks/{targetUid}`.
|
||||
|
||||
@@ -5,6 +5,7 @@ import '../campaign/campaign_detail_screen.dart';
|
||||
import '../campaign/widgets/campaign_list_card.dart';
|
||||
import '../inside/inside_data.dart';
|
||||
import '../inside/inside_detail_screen.dart';
|
||||
import '../translation/translation_data.dart' show TranslationRepository;
|
||||
import '../inside/inside_screen.dart' show InsideQuestionCard;
|
||||
import 'widgets/my_cards.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -38,6 +39,7 @@ class MyListsScreen extends StatefulWidget {
|
||||
required this.kind,
|
||||
this.campaigns,
|
||||
this.insides,
|
||||
this.translation,
|
||||
});
|
||||
|
||||
final MyListKind kind;
|
||||
@@ -45,6 +47,9 @@ class MyListsScreen extends StatefulWidget {
|
||||
/// 내 인사이드의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
|
||||
final InsideRepository? insides;
|
||||
|
||||
/// 내 질문 상세의 번역 원천. null 이면 번역 기능을 끈다.
|
||||
final TranslationRepository? translation;
|
||||
|
||||
/// 내 캠페인의 원천. null 이면 표본(목 모드). 저장소 선택은 repositories.dart.
|
||||
final CampaignRepository? campaigns;
|
||||
|
||||
@@ -119,8 +124,9 @@ class _MyListsScreenState extends State<MyListsScreen> {
|
||||
|
||||
Widget _insideItems(BuildContext context, List<InsideQuestion> mine) {
|
||||
final items = switch (_sort) {
|
||||
_MySort.pop => [...mine]
|
||||
..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
|
||||
_MySort.pop => [
|
||||
...mine,
|
||||
]..sort((a, b) => b.bountyPop.compareTo(a.bountyPop)),
|
||||
_MySort.solved => mine.where((q) => q.isSolved).toList(),
|
||||
_ => mine,
|
||||
};
|
||||
@@ -144,7 +150,11 @@ class _MyListsScreenState extends State<MyListsScreen> {
|
||||
question: items[i],
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => InsideDetailScreen(question: items[i]),
|
||||
builder: (_) => InsideDetailScreen(
|
||||
question: items[i],
|
||||
repository: widget.insides,
|
||||
translation: widget.translation,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
|
||||
@@ -14,7 +16,8 @@ import '../../core/i18n/app_locale.dart';
|
||||
/// 쓰면 어느 것이 진짜인지 화면에서 알 수 없다.
|
||||
///
|
||||
/// 읽는 곳 — **서버가 실제로 쓰는 필드**를 읽는다.
|
||||
/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps · completedQuests)
|
||||
/// - 프로필 `users/{uid}` (nickname · photoUrl · originNo · steps)
|
||||
/// - 완료 퀘스트 `users/{uid}/verifiedCompletions` 표식 수(무효 제외)
|
||||
/// - 누적 기여 POP `users/{uid}.totalContributedPop`(`fn-pop-spend` 가 갱신, 설계서 §3.6)
|
||||
/// - 레벨 `users/{uid}.{userExp, userLevel}` · Passport `users/{uid}.passportTier`
|
||||
/// (C-18 · B-65). 필드가 없는 기존 사용자는 Lv.1 · SILVER 로 둔다.
|
||||
@@ -46,6 +49,18 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
|
||||
final userF = db.doc('users/$uid').get();
|
||||
final walletF = db.doc('users/$uid/wallet/main').get();
|
||||
// 완료 퀘스트 = 검증 활동 표식 수 − 무효 표식 수. 서버 `verifiedQuestCountOf`(fpi.ts)와 같은 식.
|
||||
// `users.completedQuests` 는 쓰는 서버 코드가 없어 늘 0 이었다.
|
||||
final done = db.collection('users/$uid/verifiedCompletions');
|
||||
// 규칙 배포 전 서버에서는 이 읽기가 거부된다 — MY 전체를 떨어뜨리지 않고 0 으로 둔다.
|
||||
final completedF =
|
||||
Future.wait([
|
||||
done.count().get(),
|
||||
done.where('voided', isEqualTo: true).count().get(),
|
||||
]).then(
|
||||
(c) => math.max(0, (c[0].count ?? 0) - (c[1].count ?? 0)),
|
||||
onError: (Object _) => 0,
|
||||
);
|
||||
final user = (await userF).data() ?? const <String, dynamic>{};
|
||||
final wallet = (await walletF).data() ?? const <String, dynamic>{};
|
||||
|
||||
@@ -83,7 +98,7 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
passportTier: PassportTier.parse(user.strOr('passportTier')),
|
||||
balancePop: wallet.intOr('activityBalance'),
|
||||
steps: user.intOr('steps'),
|
||||
completedQuests: user.intOr('completedQuests'),
|
||||
completedQuests: await completedF,
|
||||
planetTiers: tiers,
|
||||
photoUrl: photoUrl.isEmpty ? null : photoUrl,
|
||||
originNo: user.containsKey('originNo') ? user.intOr('originNo') : null,
|
||||
@@ -127,10 +142,9 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
@override
|
||||
Future<void> updateNickname(String nickname) async {
|
||||
final uid = _backend.requireUid;
|
||||
await _backend.db.doc('users/$uid').set(
|
||||
{'nickname': nickname},
|
||||
SetOptions(merge: true),
|
||||
);
|
||||
await _backend.db.doc('users/$uid').set({
|
||||
'nickname': nickname,
|
||||
}, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
/// POP 원장 — S-M-WALLET · 명세 `554:23732`.
|
||||
@@ -174,22 +188,24 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
final reasonCode = data.strOr('reasonCode');
|
||||
final label = popReasonLabel(reasonCode);
|
||||
final at = data.dateOr('createdAt', _epoch);
|
||||
rows.add(_LedgerRow(
|
||||
reasonCode: reasonCode,
|
||||
// Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로
|
||||
// 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다.
|
||||
count: data.intOr('count', 1).clamp(1, 1 << 30),
|
||||
entry: PopLedgerEntry(
|
||||
at: at,
|
||||
title: label.title,
|
||||
type: label.type,
|
||||
kind: PopKind.parse(data.strOr('popType')),
|
||||
txType: PopTxType.parse(data.strOr('type', 'grant')),
|
||||
delta: data.intOr('amount'),
|
||||
balanceAfter: data.intOr('balanceAfter'),
|
||||
cursor: PopCursor(at: at, id: doc.id),
|
||||
rows.add(
|
||||
_LedgerRow(
|
||||
reasonCode: reasonCode,
|
||||
// Zap 한 행이 몇 번분인가. `fn-eclipse-defend` 가 `count` 를 묶어 받으므로
|
||||
// 행 하나가 여러 번일 수 있다. 없는 문서는 한 번으로 본다.
|
||||
count: data.intOr('count', 1).clamp(1, 1 << 30),
|
||||
entry: PopLedgerEntry(
|
||||
at: at,
|
||||
title: label.title,
|
||||
type: label.type,
|
||||
kind: PopKind.parse(data.strOr('popType')),
|
||||
txType: PopTxType.parse(data.strOr('type', 'grant')),
|
||||
delta: data.intOr('amount'),
|
||||
balanceAfter: data.intOr('balanceAfter'),
|
||||
cursor: PopCursor(at: at, id: doc.id),
|
||||
),
|
||||
),
|
||||
));
|
||||
);
|
||||
}
|
||||
return _foldZap(rows);
|
||||
}
|
||||
@@ -262,7 +278,10 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
PaymentEntry _payment(String id, Map<String, dynamic> data) => PaymentEntry(
|
||||
id: id,
|
||||
at: data.dateOr('purchasedAt', _epoch),
|
||||
itemName: data.strOr('itemName', data.strOr('productId', appL10n.payItemFallback)),
|
||||
itemName: data.strOr(
|
||||
'itemName',
|
||||
data.strOr('productId', appL10n.payItemFallback),
|
||||
),
|
||||
price: data.intOr('priceAmount'),
|
||||
method: data.strOr('method', appL10n.payMethodInApp),
|
||||
state: paymentStateOf(data.strOr('state')),
|
||||
@@ -416,10 +435,9 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
);
|
||||
}
|
||||
final url = await ref.getDownloadURL();
|
||||
await _backend.db.doc('users/$uid').set(
|
||||
{'photoUrl': url},
|
||||
SetOptions(merge: true),
|
||||
);
|
||||
await _backend.db.doc('users/$uid').set({
|
||||
'photoUrl': url,
|
||||
}, SetOptions(merge: true));
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,10 @@ import '../../core/ui/app_icons.dart';
|
||||
import '../auth/auth.dart';
|
||||
import '../auth/auth_repository.dart';
|
||||
import '../campaign/campaign_data.dart' show CampaignRepository;
|
||||
import '../galaxy/galaxy_data.dart';
|
||||
import '../galaxy/galaxy_home_screen.dart' show GalaxyHomeScreen;
|
||||
import '../inside/inside_data.dart' show InsideRepository;
|
||||
import '../translation/translation_data.dart' show TranslationRepository;
|
||||
import 'my_data.dart';
|
||||
import 'my_lists_screen.dart';
|
||||
import 'profile_edit_screen.dart';
|
||||
@@ -31,16 +34,24 @@ class MyScreen extends StatefulWidget {
|
||||
this.auth,
|
||||
this.campaigns,
|
||||
this.insides,
|
||||
this.translation,
|
||||
this.galaxy = const SampleGalaxyRepository(),
|
||||
});
|
||||
|
||||
final MyRepository repository;
|
||||
|
||||
/// 「행성/성운」 칸 — GALAXY 「내 행성」과 같은 가입 목록(`users/{uid}/memberships`)을 읽는다.
|
||||
final GalaxyRepository galaxy;
|
||||
|
||||
/// 「내 캠페인」이 읽는 저장소. null 이면 표본.
|
||||
final CampaignRepository? campaigns;
|
||||
|
||||
/// 「내 인사이드」가 읽는 저장소. null 이면 표본.
|
||||
final InsideRepository? insides;
|
||||
|
||||
/// 내 질문 상세의 번역 원천.
|
||||
final TranslationRepository? translation;
|
||||
|
||||
/// 설정 > SNS 연동으로 그대로 내려간다. null 이면 설정 화면이 표본을 쓴다.
|
||||
final AccountLinker? linker;
|
||||
|
||||
@@ -58,6 +69,7 @@ class MyScreen extends StatefulWidget {
|
||||
class _MyScreenState extends State<MyScreen> {
|
||||
MyProfile? _profile;
|
||||
FocusStatus? _focus;
|
||||
List<PlanetNode> _planets = const [];
|
||||
bool _claiming = false;
|
||||
|
||||
@override
|
||||
@@ -70,10 +82,16 @@ class _MyScreenState extends State<MyScreen> {
|
||||
final profile = await widget.repository.profile();
|
||||
// 구독 조회 실패로 MY 전체가 멈추지 않게 한다 — 카드만 빠진다.
|
||||
final focus = await widget.repository.focus().catchError((_) => null);
|
||||
// 행성 목록 실패도 칸만 비운다.
|
||||
final planets = await widget.galaxy
|
||||
.load()
|
||||
.then((s) => s.memberships.take(3).toList())
|
||||
.catchError((_) => const <PlanetNode>[]);
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_profile = profile;
|
||||
_focus = focus;
|
||||
_planets = planets;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -214,11 +232,9 @@ class _MyScreenState extends State<MyScreen> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
for (final (i, img) in const [
|
||||
'assets/figma/my-planet-1.png',
|
||||
'assets/figma/my-planet-2.png',
|
||||
'assets/figma/my-planet-3.png',
|
||||
].indexed) ...[
|
||||
// 시안 세 칸 — 가입하거나 만든 행성/성운(최신 가입순). 그림은
|
||||
// GALAXY 「내 행성」과 같은 기준(팬덤 stage)이고, 모자라면 빈 칸이다.
|
||||
for (var i = 0; i < 3; i++) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: AspectRatio(
|
||||
@@ -227,12 +243,23 @@ class _MyScreenState extends State<MyScreen> {
|
||||
// 칸 109 안에 행성 88 — 안쪽 10.5.
|
||||
padding: const EdgeInsets.all(10.5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2C),
|
||||
color: i < _planets.length
|
||||
? const Color(0xFF2C2C2C)
|
||||
: const Color(0x802C2C2C),
|
||||
borderRadius: BorderRadius.circular(
|
||||
AppRadius.md,
|
||||
),
|
||||
),
|
||||
child: Image.asset(img, fit: BoxFit.contain),
|
||||
child: i < _planets.length
|
||||
? Image.asset(
|
||||
GalaxyHomeScreen.cardImageOf(
|
||||
_planets[i],
|
||||
i,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: _planets[i].name,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -259,6 +286,7 @@ class _MyScreenState extends State<MyScreen> {
|
||||
MyListsScreen(
|
||||
kind: MyListKind.inside,
|
||||
insides: widget.insides,
|
||||
translation: widget.translation,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -337,7 +365,9 @@ class _MyScreenState extends State<MyScreen> {
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
messenger.showSnackBar(SnackBar(content: Text(l10n.commonRequestFailed)));
|
||||
messenger.showSnackBar(
|
||||
SnackBar(content: Text(l10n.commonRequestFailed)),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
@@ -464,7 +494,11 @@ class _MyHeader extends StatelessWidget {
|
||||
/// `518:88748` 은 B-04 가 대체한 과거 안). 반영 화면이 `MY` 로 정해져 있어 통계 카드 아래에 둔다.
|
||||
/// 구독 중일 때만 보인다.
|
||||
class _FocusCard extends StatelessWidget {
|
||||
const _FocusCard({required this.focus, required this.busy, required this.onClaim});
|
||||
const _FocusCard({
|
||||
required this.focus,
|
||||
required this.busy,
|
||||
required this.onClaim,
|
||||
});
|
||||
|
||||
final FocusStatus focus;
|
||||
final bool busy;
|
||||
|
||||
@@ -40,10 +40,12 @@ class _PaymentHistoryScreenState extends State<PaymentHistoryScreen> {
|
||||
await Store.instance?.restore();
|
||||
} on Exception {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(context.l10n.storeConnectFailed),
|
||||
));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(context.l10n.storeConnectFailed),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await _load();
|
||||
@@ -63,7 +65,10 @@ class _PaymentHistoryScreenState extends State<PaymentHistoryScreen> {
|
||||
// 구매 복원 — 재설치 · 기기 변경 뒤 Focus 구독을 되살린다. 스토어가 돌려준
|
||||
// 구매는 서버가 거래 ID 로 멱등 검증하므로 두 번 지급되지 않는다.
|
||||
if (Store.instance != null)
|
||||
TextButton(onPressed: _restore, child: Text(context.l10n.payRestore)),
|
||||
TextButton(
|
||||
onPressed: _restore,
|
||||
child: Text(context.l10n.payRestore),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
@@ -219,7 +224,11 @@ class _PaymentDetailScreenState extends State<PaymentDetailScreen> {
|
||||
),
|
||||
_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),
|
||||
_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),
|
||||
@@ -328,51 +337,51 @@ class _RefundRequestScreenState extends State<RefundRequestScreen> {
|
||||
),
|
||||
// 디폴트 미선택 · 단수 선택.
|
||||
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,
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
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 경고창 「정말 환불 신청을 할까요?」.
|
||||
@@ -397,9 +406,9 @@ class _RefundRequestScreenState extends State<RefundRequestScreen> {
|
||||
// 서버·Admin 에는 기준 언어(ko) 사유를 남긴다 — 화면 언어에 따라 원장 값이 갈리지 않게.
|
||||
await widget.repository.requestRefund(
|
||||
widget.paymentId,
|
||||
RefundRequestScreen.reasonsOf(
|
||||
koL10n,
|
||||
)[RefundRequestScreen.reasons.indexOf(_reason!)],
|
||||
RefundRequestScreen.reasonsOf(koL10n)[RefundRequestScreen.reasons.indexOf(
|
||||
_reason!,
|
||||
)],
|
||||
);
|
||||
if (mounted) Navigator.of(context).pop(_reason);
|
||||
}
|
||||
|
||||
@@ -117,7 +117,10 @@ class _PopHistoryScreenState extends State<PopHistoryScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s6),
|
||||
child: Center(
|
||||
child: Text(context.l10n.commonNoHistory, style: myText(14, FontWeight.w500)),
|
||||
child: Text(
|
||||
context.l10n.commonNoHistory,
|
||||
style: myText(14, FontWeight.w500),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -131,7 +134,10 @@ class _PopHistoryScreenState extends State<PopHistoryScreen> {
|
||||
padding: EdgeInsets.all(AppSpace.s3),
|
||||
child: CircularProgressIndicator(),
|
||||
)
|
||||
: TextButton(onPressed: _more, child: Text(context.l10n.commonMore)),
|
||||
: TextButton(
|
||||
onPressed: _more,
|
||||
child: Text(context.l10n.commonMore),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,10 @@ PreferredSizeWidget myTextActionBar(
|
||||
}) => AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leadingWidth: 72,
|
||||
leading: _BarText(label: context.l10n.commonCancel, onTap: () => Navigator.of(context).pop()),
|
||||
leading: _BarText(
|
||||
label: context.l10n.commonCancel,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
title: Text(title),
|
||||
actions: [_BarText(label: context.l10n.commonDone, onTap: onDone)],
|
||||
);
|
||||
@@ -237,7 +240,10 @@ class _ProfilePhotoScreenState extends State<ProfilePhotoScreen> {
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 20),
|
||||
child: Text(context.l10n.albumItems, style: myText(16, FontWeight.w600, height: 1)),
|
||||
child: Text(
|
||||
context.l10n.albumItems,
|
||||
style: myText(16, FontWeight.w600, height: 1),
|
||||
),
|
||||
),
|
||||
// 시안 4×3 격자 — 카메라 · 앨범 선택기 다음 열 칸은 #000 빈 칸(사진 목록은 기기 선택기가 그린다).
|
||||
Wrap(
|
||||
|
||||
@@ -51,7 +51,10 @@ class _InquiryScreenState extends State<InquiryScreen> {
|
||||
appBar: AppBar(title: Text(context.l10n.inquiryListTitle)),
|
||||
body: items.isEmpty
|
||||
? Center(
|
||||
child: Text(context.l10n.inquiryEmpty, style: myText(14, FontWeight.w500)),
|
||||
child: Text(
|
||||
context.l10n.inquiryEmpty,
|
||||
style: myText(14, FontWeight.w500),
|
||||
),
|
||||
)
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(16),
|
||||
@@ -347,9 +350,7 @@ class _InquiryComposeScreenState extends State<InquiryComposeScreen> {
|
||||
if (!mounted) return;
|
||||
_toast(
|
||||
context,
|
||||
edit == null
|
||||
? context.l10n.inquirySubmitted
|
||||
: context.l10n.inquiryEdited,
|
||||
edit == null ? context.l10n.inquirySubmitted : context.l10n.inquiryEdited,
|
||||
);
|
||||
Navigator.of(context).pop(true);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user