Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림
This commit is contained in:
@@ -4,11 +4,14 @@ import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
import 'core/config/feature_flags.dart' show isOlderVersion;
|
||||
import 'core/data/repositories.dart';
|
||||
import 'core/i18n/app_locale.dart';
|
||||
import 'core/ui/adaptive_planet_effects.dart';
|
||||
import 'core/push/push_messaging.dart';
|
||||
import 'core/ui/app_dialog.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'features/home/home_screen.dart' show homeRefreshSignal;
|
||||
import 'core/theme/app_theme.dart';
|
||||
@@ -123,9 +126,42 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
}
|
||||
}
|
||||
|
||||
/// 업데이트 확인을 마쳤는가 — 앱 실행당 한 번만 안내한다.
|
||||
bool _updateChecked = false;
|
||||
bool _updateChecking = false;
|
||||
|
||||
/// 스플래시 업데이트 확인 — Figma `510:19053` 「앱 버전 정보 · 최신 버전 여부 확인 ·
|
||||
/// 업데이트 안내 노출」. 최신 버전은 운영값 `config/features.latestAppVersion` 이고,
|
||||
/// 값이 없거나 설치 버전을 못 읽으면 안내하지 않는다.
|
||||
Future<void> _checkUpdate() async {
|
||||
final latest = _repos.flags.latestVersion;
|
||||
if (_updateChecked || _updateChecking || latest == null) return;
|
||||
_updateChecking = true;
|
||||
try {
|
||||
final installed = (await PackageInfo.fromPlatform()).version;
|
||||
_updateChecked = true;
|
||||
if (!isOlderVersion(installed, latest)) return;
|
||||
final context = _router.routerDelegate.navigatorKey.currentContext;
|
||||
if (context == null || !context.mounted) {
|
||||
_updateChecked = false; // 화면이 아직 없으면 다음 알림 때 다시 본다.
|
||||
return;
|
||||
}
|
||||
await showAlertDialog(
|
||||
context,
|
||||
context.l10n.updateAvailableTitle,
|
||||
body: context.l10n.updateAvailableBody(latest),
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[FancakeApp] update check: ${e.runtimeType}');
|
||||
} finally {
|
||||
_updateChecking = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_repos.flags.addListener(_checkUpdate);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
widget.auth.addListener(_onAuthChanged);
|
||||
_onAuthChanged();
|
||||
@@ -133,6 +169,7 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_repos.flags.removeListener(_checkUpdate);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_presence?.cancel();
|
||||
widget.auth.removeListener(_onAuthChanged);
|
||||
|
||||
@@ -27,9 +27,18 @@ class FeatureFlags extends ChangeNotifier {
|
||||
/// STATION 탭 노출 스위치. 문서의 `stationEnabled`(bool) 하나를 읽는다.
|
||||
static const String stationKey = 'stationEnabled';
|
||||
|
||||
/// 운영 중인 최신 앱 버전(문자열 `x.y.z`). 스플래시 업데이트 확인 — Figma `510:19053`
|
||||
/// 「앱 버전 정보 · 최신 버전 여부 확인 · 업데이트 안내 노출」. 로그인 전에 읽어야 해서
|
||||
/// 같은 공개 문서에 둔다. 값이 없으면 안내를 띄우지 않는다.
|
||||
static const String latestVersionKey = 'latestAppVersion';
|
||||
|
||||
StreamSubscription<Map<String, Object?>>? _subscription;
|
||||
|
||||
bool _stationTab = false;
|
||||
String? _latestVersion;
|
||||
|
||||
/// 운영 최신 버전. 문서에 없거나 문자열이 아니면 null.
|
||||
String? get latestVersion => _latestVersion;
|
||||
|
||||
/// 하단 탭에 STATION 을 그릴 것인가. 기본 false.
|
||||
bool get stationTab => _stationTab;
|
||||
@@ -37,8 +46,13 @@ class FeatureFlags extends ChangeNotifier {
|
||||
void _apply(Map<String, Object?> data) {
|
||||
// 타입까지 확인한다. 누가 문자열 "true" 를 넣어도 켜지지 않는다.
|
||||
final next = data[stationKey] == true;
|
||||
if (next == _stationTab) return;
|
||||
final latest = switch (data[latestVersionKey]) {
|
||||
final String v when v.trim().isNotEmpty => v.trim(),
|
||||
_ => null,
|
||||
};
|
||||
if (next == _stationTab && latest == _latestVersion) return;
|
||||
_stationTab = next;
|
||||
_latestVersion = latest;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -54,3 +68,21 @@ class FeatureFlags extends ChangeNotifier {
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// [installed] 가 [latest] 보다 낮은가 — 점으로 나눈 숫자를 앞에서부터 비교한다(`0.5.0` < `0.10.0`).
|
||||
/// 빌드 번호(`+7`)와 숫자가 아닌 꼬리는 무시한다. 읽을 수 없는 값이면 false(안내하지 않음).
|
||||
bool isOlderVersion(String installed, String latest) {
|
||||
List<int>? parse(String v) {
|
||||
final parts = v.split('+').first.split('.');
|
||||
final nums = [for (final p in parts) int.tryParse(p.trim())];
|
||||
return nums.any((n) => n == null) ? null : nums.cast<int>();
|
||||
}
|
||||
|
||||
final a = parse(installed), b = parse(latest);
|
||||
if (a == null || b == null) return false;
|
||||
for (var i = 0; i < a.length || i < b.length; i++) {
|
||||
final x = i < a.length ? a[i] : 0, y = i < b.length ? b[i] : 0;
|
||||
if (x != y) return x < y;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -263,12 +263,21 @@ class AppShell extends StatelessWidget {
|
||||
selectedIndex: selected < 0 ? 0 : selected,
|
||||
onDestinationSelected: (i) {
|
||||
// 다른 탭에서 HOME 으로 돌아오면 다시 읽는다(HOME 은 탭 전환으로 다시 만들어지지 않는다).
|
||||
if (branches[i] == 0 && shell.currentIndex != 0) homeRefreshSignal.value++;
|
||||
if (branches[i] == 0 && shell.currentIndex != 0)
|
||||
homeRefreshSignal.value++;
|
||||
final again = branches[i] == shell.currentIndex;
|
||||
shell.goBranch(
|
||||
branches[i],
|
||||
// initialLocation: true 를 주면 이미 열려 있는 탭을 다시 누를 때 루트로 되돌아간다.
|
||||
initialLocation: branches[i] == shell.currentIndex,
|
||||
initialLocation: again,
|
||||
);
|
||||
// 설명 프레임 `2097:16427` 「HOME 화면에서 클릭 시, Y스크롤값 초기화」(INSIDE · GALAXY ·
|
||||
// MY 동일) — 열려 있는 탭을 다시 누르면 그 탭 루트 화면을 맨 위로 올린다.
|
||||
if (again) {
|
||||
scrollTabToTop(
|
||||
shell.route.branches[branches[i]].navigatorKey,
|
||||
);
|
||||
}
|
||||
},
|
||||
destinations: [
|
||||
for (final tab in tabs)
|
||||
@@ -287,3 +296,35 @@ class AppShell extends StatelessWidget {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 탭 루트 화면의 세로 목록을 맨 위로. 루트 라우트의 [PrimaryScrollController] 를 쓴다 —
|
||||
/// 각 탭 화면이 따로 컨트롤러를 들고 있지 않아도 된다. 다시 누름으로 위에 쌓인 화면이 닫힌
|
||||
/// 다음 프레임에 움직인다.
|
||||
void scrollTabToTop(GlobalKey<NavigatorState> navigatorKey) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ScrollController? found;
|
||||
void visit(Element e) {
|
||||
if (found != null) return;
|
||||
final w = e.widget;
|
||||
// 컨트롤러 없는 칸(`PrimaryScrollController.none`)과 붙은 목록이 없는 라우트는 건너뛴다.
|
||||
if (w is PrimaryScrollController && (w.controller?.hasClients ?? false)) {
|
||||
found = w.controller;
|
||||
return;
|
||||
}
|
||||
e.visitChildElements(visit);
|
||||
}
|
||||
|
||||
navigatorKey.currentContext?.visitChildElements(visit);
|
||||
for (final p in found?.positions.toList() ?? const <ScrollPosition>[]) {
|
||||
if (p.axis == Axis.vertical && p.pixels != p.minScrollExtent) {
|
||||
p.animateTo(
|
||||
p.minScrollExtent,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
// 탭이 이미 루트면 다시 그릴 일이 없어 위 콜백이 돌지 않는다 — 한 프레임을 청한다.
|
||||
WidgetsBinding.instance.ensureVisualUpdate();
|
||||
}
|
||||
|
||||
@@ -176,3 +176,17 @@ Future<void> showAlertDialog(
|
||||
confirmLabel: confirmLabel ?? context.l10n.commonConfirm,
|
||||
),
|
||||
);
|
||||
|
||||
/// 아래에서 올라오고 닫으면 아래로 내려가는 전체 화면 — Figma 설명 「닫기 버튼 클릭 시, 하단
|
||||
/// move out 페이지 종료」(`516:87382` 성운 생성 · `516:87457` 행성 생성 · `516:88164` 이클립스 상세).
|
||||
/// 안드로이드 기본 전환(확대)과 iOS 기본 전환(옆으로)이 모두 이 동작이 아니라 따로 둔다.
|
||||
Route<T> slideUpRoute<T>(WidgetBuilder builder) => PageRouteBuilder<T>(
|
||||
pageBuilder: (context, _, _) => builder(context),
|
||||
transitionsBuilder: (_, animation, _, child) => SlideTransition(
|
||||
position: Tween(
|
||||
begin: const Offset(0, 1),
|
||||
end: Offset.zero,
|
||||
).chain(CurveTween(curve: Curves.easeOutCubic)).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 아래에서 올라와 아래로 닫히는 전체 화면 — Figma 설명 프레임의 「닫기 버튼 클릭 시, 하단 move out
|
||||
/// 페이지 종료 후 이전 화면 이동」(퀘스트 생성 `523:18123` · 위치 선택 `523:18207` · 신고하기 `548:21389`
|
||||
/// · 이클립스 상세 `516:88158`).
|
||||
///
|
||||
/// `MaterialPageRoute(fullscreenDialog: true)` 는 iOS 에서만 아래로 내려간다(Android 는 기본 전환).
|
||||
/// 두 플랫폼 모두 같은 방향으로 움직이게 전환만 바꾼다.
|
||||
class SheetRoute<T> extends MaterialPageRoute<T> {
|
||||
SheetRoute({required super.builder}) : super(fullscreenDialog: true);
|
||||
|
||||
@override
|
||||
Widget buildTransitions(
|
||||
BuildContext context,
|
||||
Animation<double> animation,
|
||||
Animation<double> secondaryAnimation,
|
||||
Widget child,
|
||||
) => SlideTransition(
|
||||
position: animation.drive(
|
||||
Tween(begin: const Offset(0, 1), end: Offset.zero)
|
||||
.chain(CurveTween(curve: Curves.easeOutCubic)),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
@@ -138,6 +138,9 @@ abstract interface class AuthSession {
|
||||
/// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다.
|
||||
/// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이
|
||||
/// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false.
|
||||
///
|
||||
/// Figma 예시(앞부분 같으면 O)는 **2026-09-16 회신 3절 7항**(「다른 도메인 이메일의 앞부분만으로
|
||||
/// 동일 소유자를 판단하지 않습니다」)이 대체했다. 이메일을 주지 않는 제공자의 연결은 해석 확인 Q-E8.
|
||||
bool sameAccountIdentity(String a, String b) {
|
||||
final x = a.trim().toLowerCase();
|
||||
return x.contains('@') && x == b.trim().toLowerCase();
|
||||
|
||||
@@ -102,6 +102,8 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
void _open({required bool signUp}) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
// 명세 `510:86374` 「닫기 — 아래 방향 Move Out 으로 이전 화면」 — 아래에서 올라오는 전체 화면.
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => SimpleLoginScreen(auth: widget.auth, signUp: signUp),
|
||||
),
|
||||
);
|
||||
@@ -114,20 +116,20 @@ class _LoginScreenState extends State<LoginScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// 시안 173:1905 — 상단 386 영역 가운데에 핑크 원(120) 하나. 로고 원본 미확정(Q6).
|
||||
// 시안 173:1905 — 상단 386 영역 가운데 로고(120). 명세 `510:86330` 「컨테인 중앙 정렬」.
|
||||
// 로고는 발주사 인수 자산 `fanuzu logo only.png` 의 마크(공통 기준 4항 FANUZU).
|
||||
SizedBox(
|
||||
height: 386,
|
||||
child: Center(
|
||||
child: GestureDetector(
|
||||
key: loginLogoKey,
|
||||
onLongPress: _toggleDevPanel,
|
||||
child: Container(
|
||||
child: Image.asset(
|
||||
'assets/brand/fanuzu-mark.png',
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
fit: BoxFit.contain,
|
||||
semanticLabel: 'FANUZU',
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -247,6 +249,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
||||
void _switchMode() {
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
fullscreenDialog: true,
|
||||
builder: (_) =>
|
||||
SimpleLoginScreen(auth: widget.auth, signUp: !widget.signUp),
|
||||
),
|
||||
|
||||
@@ -4,11 +4,13 @@ import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/push/push_messaging.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../home/coach_mark.dart';
|
||||
import '../home/home_data.dart';
|
||||
import '../home/nebula_create_screen.dart';
|
||||
import '../my/my_data.dart'
|
||||
show nicknameMaxLength, nicknameMinLength, nicknameReserved;
|
||||
import '../planet/planet_data.dart' show countryName;
|
||||
import 'auth_repository.dart';
|
||||
import 'onboarding_data.dart';
|
||||
import 'signup_screens.dart';
|
||||
@@ -159,6 +161,9 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
|
||||
_Step _step = _Step.terms;
|
||||
bool _busy = false;
|
||||
|
||||
/// ② 에서 서버에 기록한 동의 항목. ③ 에서 뒤로 오면 이 상태로 체크해 보여 준다.
|
||||
Set<String>? _agreed;
|
||||
|
||||
/// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary).
|
||||
final _picked = <OnboardingFandom>[];
|
||||
|
||||
@@ -189,14 +194,25 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
|
||||
|
||||
void _agree(Set<String> agreed) {
|
||||
if (_busy) return;
|
||||
// ③ 에서 뒤로 왔다가 그대로 「완료」면 기록을 다시 남기지 않는다(서버 동의 기록 유지).
|
||||
final prev = _agreed;
|
||||
if (prev != null &&
|
||||
prev.length == agreed.length &&
|
||||
prev.containsAll(agreed)) {
|
||||
setState(() => _step = _Step.profile);
|
||||
return;
|
||||
}
|
||||
_run(() async {
|
||||
await widget.repository.agree(agreed, signupTermsVersion);
|
||||
_agreed = agreed;
|
||||
if (mounted) setState(() => _step = _Step.profile);
|
||||
});
|
||||
}
|
||||
|
||||
void _back() => setState(
|
||||
() => _step = switch (_step) {
|
||||
// 명세 `511:86825` 「상단 뒤로가기 — 이전 화면」. 이전 = ② 약관(동의 체크 유지).
|
||||
_Step.profile => _Step.terms,
|
||||
_Step.artist => _Step.profile,
|
||||
_Step.planet => _Step.artist,
|
||||
final s => s,
|
||||
@@ -216,9 +232,15 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = switch (_step) {
|
||||
_Step.terms => TermsScreen(onAgreed: _agree, onBack: widget.auth.signOut),
|
||||
_Step.terms => TermsScreen(
|
||||
key: ValueKey(_agreed),
|
||||
initial: _agreed ?? const {},
|
||||
onAgreed: _agree,
|
||||
onBack: widget.auth.signOut,
|
||||
),
|
||||
_Step.profile => _ProfileStep(
|
||||
repository: widget.repository,
|
||||
onBack: _back,
|
||||
onDone: () => setState(() => _step = _Step.artist),
|
||||
),
|
||||
_Step.artist => _ArtistStep(
|
||||
@@ -262,10 +284,15 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
|
||||
/// 사본 `MY/프로필 설정` 구성을 따른다. 국가 기본값은 기기 지역(Figma `554:24126` 「앱 기동 단계에서
|
||||
/// 조회된 국가/언어를 디폴트」).
|
||||
class _ProfileStep extends StatefulWidget {
|
||||
const _ProfileStep({required this.repository, required this.onDone});
|
||||
const _ProfileStep({
|
||||
required this.repository,
|
||||
required this.onDone,
|
||||
required this.onBack,
|
||||
});
|
||||
|
||||
final OnboardingRepository repository;
|
||||
final VoidCallback onDone;
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
State<_ProfileStep> createState() => _ProfileStepState();
|
||||
@@ -348,12 +375,23 @@ class _ProfileStepState extends State<_ProfileStep> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickCountry() async {
|
||||
final picked = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: const Color(0xFF101010),
|
||||
builder: (_) => _CountrySheet(current: _country),
|
||||
);
|
||||
if (picked != null && mounted) setState(() => _country = picked);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final current = _locale ?? LocaleScope.maybeOf(context)?.locale;
|
||||
return _StepScaffold(
|
||||
title: l10n.onbProfileTitle,
|
||||
onBack: widget.onBack,
|
||||
action: _PrimaryButton(
|
||||
label: l10n.commonNext,
|
||||
onPressed: _valid && !_busy ? _save : null,
|
||||
@@ -369,15 +407,20 @@ class _ProfileStepState extends State<_ProfileStep> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _country,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: l10n.onbCountry),
|
||||
items: [
|
||||
for (final c in isoCountries)
|
||||
DropdownMenuItem(value: c, child: Text('${countryFlag(c)} $c')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _country = v ?? _country),
|
||||
// 사본 국가 리스트 모달 `178:23489`(명세 `511:86786` · `511:86796`) — 국기 · 국가명 ·
|
||||
// 단수 선택 체크, 「선택」을 눌러야 적용하고 닫힌다.
|
||||
InkWell(
|
||||
onTap: _pickCountry,
|
||||
child: InputDecorator(
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.onbCountry,
|
||||
suffixIcon: const Icon(Icons.expand_more),
|
||||
),
|
||||
isEmpty: _country == null,
|
||||
child: _country == null
|
||||
? null
|
||||
: Text('${countryFlag(_country!)} ${countryName(_country!)}'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
DropdownButtonFormField<Locale>(
|
||||
@@ -407,6 +450,78 @@ class _ProfileStepState extends State<_ProfileStep> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 국가 리스트 모달 — 사본 `178:23489`.
|
||||
///
|
||||
/// 명세 `511:86762` 타이틀 좌측 · 닫기 우측(누르면 모달 종료) / `511:86786` 국기 좌측 · 국가명 ·
|
||||
/// 선택한 국가에만 오른쪽 체크(단수) / `511:86796` 국가를 골라야 「선택」이 켜지고, 누르면 적용 후 닫힘.
|
||||
class _CountrySheet extends StatefulWidget {
|
||||
const _CountrySheet({required this.current});
|
||||
|
||||
final String? current;
|
||||
|
||||
@override
|
||||
State<_CountrySheet> createState() => _CountrySheetState();
|
||||
}
|
||||
|
||||
class _CountrySheetState extends State<_CountrySheet> {
|
||||
late String? _picked = widget.current;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SafeArea(
|
||||
child: SizedBox(
|
||||
height: MediaQuery.sizeOf(context).height * 0.8,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 4, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(child: Text(context.l10n.onbCountry, style: _title)),
|
||||
IconButton(
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const ImageIcon(AppIcons.x, size: 24),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: ListView(
|
||||
children: [
|
||||
for (final c in isoCountries)
|
||||
ListTile(
|
||||
onTap: () => setState(() => _picked = c),
|
||||
leading: Text(
|
||||
countryFlag(c),
|
||||
style: const TextStyle(fontSize: 22),
|
||||
),
|
||||
title: Text(countryName(c)),
|
||||
trailing: c == _picked
|
||||
? const Icon(
|
||||
Icons.check_circle_outline,
|
||||
color: AppColors.cosmicAccent,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: _PrimaryButton(
|
||||
label: context.l10n.commonSelect,
|
||||
onPressed: _picked == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(_picked),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// ④ 관심 아티스트 / Primary Fandom — 최소 1개(FR-CMN-06). 처음 고른 팬덤이 대표가 된다.
|
||||
/// 찾는 아티스트가 없으면 ⑤ 에서 성운을 만든다.
|
||||
class _ArtistStep extends StatefulWidget {
|
||||
|
||||
@@ -14,7 +14,15 @@ import '../../core/theme/tokens.dart';
|
||||
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와
|
||||
/// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
|
||||
class TermsScreen extends StatefulWidget {
|
||||
const TermsScreen({super.key, this.onAgreed, this.onBack});
|
||||
const TermsScreen({
|
||||
super.key,
|
||||
this.onAgreed,
|
||||
this.onBack,
|
||||
this.initial = const {},
|
||||
});
|
||||
|
||||
/// 처음부터 체크해 둘 항목 키 — 온보딩 ③ 에서 뒤로 왔을 때 이미 동의한 항목.
|
||||
final Set<String> initial;
|
||||
|
||||
/// 온보딩 안에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다.
|
||||
final ValueChanged<Set<String>>? onAgreed;
|
||||
@@ -58,7 +66,10 @@ const _body =
|
||||
const _stroke = Color(0xFF545454);
|
||||
|
||||
class _TermsScreenState extends State<TermsScreen> {
|
||||
final _agreed = <_Term>{};
|
||||
late final _agreed = <_Term>{
|
||||
for (final t in _Term.values)
|
||||
if (widget.initial.contains(t.name)) t,
|
||||
};
|
||||
|
||||
bool get _all => _agreed.length == _Term.values.length;
|
||||
bool get _ready =>
|
||||
|
||||
@@ -14,6 +14,7 @@ import '../home/home_screen.dart' show formatPop;
|
||||
import '../quest/quest_evidence.dart' show ShakeProbe, deviceShakes;
|
||||
import 'eclipse_data.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/sheet_route.dart';
|
||||
|
||||
/// 「흔들어 방어」 — 팬덤 공동 Shake 참여 화면.
|
||||
///
|
||||
@@ -35,10 +36,15 @@ class EclipseDefendScreen extends StatefulWidget {
|
||||
required this.repository,
|
||||
required this.fandomId,
|
||||
this.shakes = deviceShakes,
|
||||
this.nebula = false,
|
||||
});
|
||||
|
||||
final EclipseRepository repository;
|
||||
|
||||
/// 대상 팬덤이 아직 성운인가 — 명세 `516:88158` *온보딩 행성/성운 - 이미지* · `516:88185`
|
||||
/// *행성/성운 - 사용자 행성/성운 이미지*. 성운이면 성운 그림으로 그린다.
|
||||
final bool nebula;
|
||||
|
||||
/// 흔들기 입력. 테스트는 가짜 스트림을 넣는다 — 방어 수단은 **Shake 하나**다(FX §5).
|
||||
final ShakeProbe shakes;
|
||||
|
||||
@@ -326,8 +332,8 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Flexible(
|
||||
child: SizedBox(height: 400, child: _ErodingPlanet()),
|
||||
Flexible(
|
||||
child: SizedBox(height: 400, child: _ErodingPlanet(image: _imageOf(widget.nebula))),
|
||||
),
|
||||
const Spacer(),
|
||||
// `516:88184` 남은 시간 노출 영역 — 하단 정렬, 끝나면 방어 화면으로.
|
||||
@@ -435,7 +441,11 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
|
||||
Flexible(
|
||||
child: SizedBox(
|
||||
height: 400,
|
||||
child: _DefendPlanet(remaining: remaining, pending: _pending),
|
||||
child: _DefendPlanet(
|
||||
remaining: remaining,
|
||||
pending: _pending,
|
||||
image: _imageOf(widget.nebula),
|
||||
),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -620,6 +630,23 @@ class _EclipseTickerState extends State<EclipseTicker> {
|
||||
}
|
||||
|
||||
const _planet = AssetImage('assets/figma/planet-home.png');
|
||||
const _nebula = AssetImage('assets/figma/nebula-home.png');
|
||||
|
||||
AssetImage _imageOf(bool nebula) => nebula ? _nebula : _planet;
|
||||
|
||||
/// 이클립스 상세로 가는 길 — 아래에서 올라와 아래로 닫힌다(명세 `516:88158` *하단 move out*).
|
||||
/// HOME 버튼 · 배너(HOME · 행성 상세 · GALAXY)가 모두 이 경로를 쓴다.
|
||||
Route<void> eclipseDefendRoute({
|
||||
required EclipseRepository repository,
|
||||
required String fandomId,
|
||||
bool nebula = false,
|
||||
}) => SheetRoute<void>(
|
||||
builder: (_) => EclipseDefendScreen(
|
||||
repository: repository,
|
||||
fandomId: fandomId,
|
||||
nebula: nebula,
|
||||
),
|
||||
);
|
||||
|
||||
// 사본 좌표(프레임 원점 x10 · y220 기준).
|
||||
Widget _at(double x, double y, double w, double h, Widget child) =>
|
||||
@@ -629,9 +656,11 @@ Widget _at(double x, double y, double w, double h, Widget child) =>
|
||||
///
|
||||
/// 행성(240) 양옆에 흐린 사본(`image 9~12`, 흐림 4)을 겹쳐 그림자가 번지는 모습이다.
|
||||
class _ErodingPlanet extends StatelessWidget {
|
||||
const _ErodingPlanet();
|
||||
const _ErodingPlanet({required this.image});
|
||||
|
||||
static Widget _shadow(double x, double y, double size, {double blur = 0}) =>
|
||||
final AssetImage image;
|
||||
|
||||
Widget _shadow(double x, double y, double size, {double blur = 0}) =>
|
||||
_at(
|
||||
x,
|
||||
y,
|
||||
@@ -639,9 +668,9 @@ class _ErodingPlanet extends StatelessWidget {
|
||||
size,
|
||||
ImageFiltered(
|
||||
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
|
||||
child: const ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop),
|
||||
child: Image(image: _planet),
|
||||
child: ColorFiltered(
|
||||
colorFilter: const ColorFilter.mode(Color(0xB3141414), BlendMode.srcATop),
|
||||
child: Image(image: image),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -658,7 +687,7 @@ class _ErodingPlanet extends StatelessWidget {
|
||||
_shadow(27, 347, 192, blur: 2),
|
||||
_shadow(47, 320, 220),
|
||||
_shadow(108, 320, 220),
|
||||
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
||||
_at(67.5, 300, 240, 240, Image(image: image)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -671,7 +700,13 @@ class _ErodingPlanet extends StatelessWidget {
|
||||
/// `Group 572` 그라디언트 원 · `516:88231`)는 잔여 시간 비율 [remaining] 이고, 운석은
|
||||
/// 시간이 흐를수록(1 − [remaining]) 행성 쪽으로 다가온다.
|
||||
class _DefendPlanet extends StatelessWidget {
|
||||
const _DefendPlanet({required this.remaining, required this.pending});
|
||||
const _DefendPlanet({
|
||||
required this.remaining,
|
||||
required this.pending,
|
||||
required this.image,
|
||||
});
|
||||
|
||||
final AssetImage image;
|
||||
|
||||
/// 남은 시간 비율 1 → 0.
|
||||
final double remaining;
|
||||
@@ -719,7 +754,7 @@ class _DefendPlanet extends StatelessWidget {
|
||||
280,
|
||||
CustomPaint(painter: _RemainingRing(remaining)),
|
||||
),
|
||||
_at(67.5, 300, 240, 240, const Image(image: _planet)),
|
||||
_at(67.5, 300, 240, 240, Image(image: image)),
|
||||
// 좌상단 운석(`Group 573`)은 내보낸 자산이 없어 `Group 575` 를 뒤집어 쓴다.
|
||||
_meteor(
|
||||
38,
|
||||
|
||||
@@ -26,6 +26,7 @@ class EclipseBanner extends StatelessWidget {
|
||||
required this.repository,
|
||||
this.fandomId,
|
||||
this.onTap,
|
||||
this.nebula = false,
|
||||
this.margin = const EdgeInsets.fromLTRB(
|
||||
AppSpace.s4,
|
||||
AppSpace.s2,
|
||||
@@ -42,6 +43,9 @@ class EclipseBanner extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final EdgeInsets margin;
|
||||
|
||||
/// 대상 팬덤이 아직 성운인가 — 상세 · 방어 화면이 성운 그림을 그린다.
|
||||
final bool nebula;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
|
||||
// 대상 팬덤이 바뀌면 구독을 갈아탄다 — [LiveStreamBuilder] 주석 참고.
|
||||
@@ -58,9 +62,11 @@ class EclipseBanner extends StatelessWidget {
|
||||
child: EclipseBannerCard(
|
||||
state: state,
|
||||
onTap: onTap ?? () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(builder: (_) => EclipseDefendScreen(
|
||||
repository: repository, fandomId: state.fandomId,
|
||||
)),
|
||||
eclipseDefendRoute(
|
||||
repository: repository,
|
||||
fandomId: state.fandomId,
|
||||
nebula: nebula,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -20,11 +20,15 @@ class EclipseEventButton extends StatelessWidget {
|
||||
super.key,
|
||||
required this.repository,
|
||||
required this.fandomId,
|
||||
this.nebula = false,
|
||||
});
|
||||
|
||||
final EclipseRepository repository;
|
||||
final String fandomId;
|
||||
|
||||
/// 내 팬덤이 아직 성운인가 — 상세 · 방어 화면이 성운 그림을 그린다.
|
||||
final bool nebula;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<EclipseState?>(
|
||||
key: ValueKey('eclipse-button-$fandomId'),
|
||||
@@ -49,13 +53,11 @@ class EclipseEventButton extends StatelessWidget {
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
// 명세 `516:88164` 「닫기 — 하단 move out」 — 아래에서 올라오는 전체 화면.
|
||||
fullscreenDialog: true,
|
||||
builder: (_) => EclipseDefendScreen(
|
||||
repository: repository,
|
||||
fandomId: state.fandomId,
|
||||
),
|
||||
// 명세 `516:88164` 「닫기 — 하단 move out」 — 아래에서 올라오는 전체 화면.
|
||||
eclipseDefendRoute(
|
||||
repository: repository,
|
||||
fandomId: state.fandomId,
|
||||
nebula: nebula,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
|
||||
@@ -90,8 +90,13 @@ class GalaxySnapshot {
|
||||
this.joined = true,
|
||||
this.memberships = const [],
|
||||
this.joinedIds = const {},
|
||||
this.nebulaCreatedToday = false,
|
||||
});
|
||||
|
||||
/// 오늘(KST) 이미 성운을 만들었는가 — Figma `540:19592` 「일1회 생성 가능(이후 비활성화) ·
|
||||
/// 횟수 초과 시 초과 안내 모달」. 서버 `fn-nebula-create` 의 일 1회 판정(E-FDM-02)과 같은 문서를 본다.
|
||||
final bool nebulaCreatedToday;
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
|
||||
/// 내가 가입한 모든 팬덤 ID(`users.fandomIds` + 대표). 10/3 통합회신 #10 「다른 가입 팬덤에서도 Zap」 —
|
||||
@@ -155,6 +160,7 @@ class GalaxySnapshot {
|
||||
joined: joined,
|
||||
memberships: memberships,
|
||||
joinedIds: joinedIds,
|
||||
nebulaCreatedToday: nebulaCreatedToday,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,6 +70,9 @@ class GalaxyHomeScreen extends StatefulWidget {
|
||||
class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
late Future<GalaxySnapshot> _snapshot = widget.repository.load();
|
||||
|
||||
/// Figma `540:19580` 그리드 변경 영역 — 디폴트 격자, 누르면 격자 ↔ 리스트(`354:221533`).
|
||||
bool _list = false;
|
||||
|
||||
/// 명세 §2.58-3 — *행성/성운 클릭 시 기본 행성 변경 안내 모달 노출*. 이미 대표면 궤도 맵으로.
|
||||
Future<void> _choose(PlanetNode p) async {
|
||||
if (p.isMine) return _openMap();
|
||||
@@ -104,7 +107,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
/// 만든 성운으로 바로 들어간다. 돌아오면 「내 행성」 그리드에 새 성운이 선다.
|
||||
Future<void> _createNebula() async {
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
final myPop = (await _snapshot).myPop;
|
||||
final snapshot = await _snapshot;
|
||||
// Figma `540:19592` · `543:19609` — 일 1회. 오늘 이미 만들었으면 화면을 열지 않고 초과 안내 모달.
|
||||
if (snapshot.nebulaCreatedToday) {
|
||||
if (mounted)
|
||||
await showAlertDialog(context, context.l10n.nebulaAlreadyToday);
|
||||
return;
|
||||
}
|
||||
final myPop = snapshot.myPop;
|
||||
final fandomId = await navigator.push(
|
||||
MaterialPageRoute<String>(
|
||||
builder: (_) =>
|
||||
@@ -155,16 +165,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
// 시안은 내 행성 셋을 보여 준다 — 가입하거나 만든 행성/성운, 최신 가입순(§2.58).
|
||||
// 「선택」 알약은 대표 팬덤(C-15)이다.
|
||||
final mine = data.memberships.take(3).toList();
|
||||
// 가입하거나 만든 행성/성운 전부, 최신 가입순(Figma `540:19586`). 「선택」 알약은 대표 팬덤(C-15).
|
||||
final mine = data.memberships;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s9),
|
||||
Column(
|
||||
children: [
|
||||
// 상단 — 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42
|
||||
// 상단 — 상단 고정 노출(Figma `540:19574`). 시안 `Top Navigation`(54 · 좌우 16) 「GALAXY」 ABeeZee 20 · 검색 · 알림 42
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
@@ -191,6 +199,8 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxySearchScreen(
|
||||
repository: widget.repository,
|
||||
onJoin: (p) =>
|
||||
widget.planetRepository(p.id).join(),
|
||||
onOpen: (p) =>
|
||||
Navigator.of(
|
||||
context,
|
||||
@@ -226,73 +236,77 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
// 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱
|
||||
// 워닝 배너 병행」. 대상은 내 행성이다.
|
||||
if (mine.where((p) => p.isMine).firstOrNull case final my?)
|
||||
EclipseBanner(
|
||||
repository: widget.eclipse,
|
||||
fandomId: my.id,
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
AppSpace.s3,
|
||||
16,
|
||||
0,
|
||||
),
|
||||
onTap: _openMap,
|
||||
),
|
||||
// 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.only(bottom: AppSpace.s9),
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.galaxyMyPlanets,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 26,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
InkResponse(
|
||||
onTap: _openMap,
|
||||
radius: 20,
|
||||
child: Image.asset(
|
||||
'assets/figma/icon-list.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: const Color(0xFF777777),
|
||||
semanticLabel: context.l10n.galaxyMap,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (i, p) in mine.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: AppSpace.s2),
|
||||
Expanded(
|
||||
child: _PlanetCard(
|
||||
planet: p,
|
||||
image: GalaxyHomeScreen.cardImageOf(p, i),
|
||||
selected: p.isMine,
|
||||
onTap: () => _choose(p),
|
||||
// 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱
|
||||
// 워닝 배너 병행」. 대상은 내 행성이다.
|
||||
if (mine.where((p) => p.isMine).firstOrNull
|
||||
case final my?)
|
||||
EclipseBanner(
|
||||
repository: widget.eclipse,
|
||||
fandomId: my.id,
|
||||
margin: const EdgeInsets.fromLTRB(
|
||||
16,
|
||||
AppSpace.s3,
|
||||
16,
|
||||
0,
|
||||
),
|
||||
onTap: _openMap,
|
||||
),
|
||||
],
|
||||
// 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다.
|
||||
for (var i = mine.length; i < 3; i++) ...[
|
||||
if (i > 0) const SizedBox(width: AppSpace.s2),
|
||||
const Expanded(child: _EmptySlot()),
|
||||
],
|
||||
// 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.galaxyMyPlanets,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 26,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
// 격자 ↔ 리스트(Figma `540:19580`). 궤도 맵은 선택된 카드를 눌러 연다.
|
||||
InkResponse(
|
||||
onTap: () => setState(() => _list = !_list),
|
||||
radius: 20,
|
||||
child: Semantics(
|
||||
container: true,
|
||||
button: true,
|
||||
label: _list
|
||||
? context.l10n.galaxyGridView
|
||||
: context.l10n.galaxyListView,
|
||||
child: _list
|
||||
? const _GridIcon()
|
||||
: Image.asset(
|
||||
'assets/figma/icon-list.png',
|
||||
width: 24,
|
||||
height: 24,
|
||||
color: const Color(0xFF777777),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: _list
|
||||
? _PlanetList(
|
||||
planets: mine,
|
||||
imageOf: GalaxyHomeScreen.cardImageOf,
|
||||
onTap: _choose,
|
||||
)
|
||||
: _PlanetGrid(planets: mine, onTap: _choose),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -303,7 +317,13 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 16,
|
||||
child: Center(child: _NebulaPill(onTap: _createNebula)),
|
||||
child: Center(
|
||||
child: _NebulaPill(
|
||||
onTap: _createNebula,
|
||||
// 일 1회 생성 뒤 비활성화(Figma `540:19592`). 눌러도 초과 안내 모달만 뜬다.
|
||||
disabled: data.nebulaCreatedToday,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -426,44 +446,221 @@ class _EmptySlot extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _NebulaPill extends StatelessWidget {
|
||||
const _NebulaPill({required this.onTap});
|
||||
const _NebulaPill({required this.onTap, this.disabled = false});
|
||||
|
||||
final VoidCallback onTap;
|
||||
final bool disabled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: const Color(0xFF19191C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
return Opacity(
|
||||
opacity: disabled ? 0.4 : 1,
|
||||
child: Material(
|
||||
color: const Color(0xFF19191C),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.cosmicSurfaceRaised),
|
||||
),
|
||||
// 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ImageIcon(
|
||||
AppIcons.badge,
|
||||
size: 16,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.nebulaCreate,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 20 / 14,
|
||||
color: AppColors.onCosmicVariant,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
child: Container(
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
border: Border.all(color: AppColors.cosmicSurfaceRaised),
|
||||
),
|
||||
// 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const ImageIcon(
|
||||
AppIcons.badge,
|
||||
size: 16,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
context.l10n.nebulaCreate,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 20 / 14,
|
||||
color: AppColors.onCosmicVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 격자 — 세 칸씩 줄을 채운다. 행성이 셋보다 적으면 빈 칸으로 한 줄을 맞춘다(시안 세 칸).
|
||||
class _PlanetGrid extends StatelessWidget {
|
||||
const _PlanetGrid({required this.planets, required this.onTap});
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
final ValueChanged<PlanetNode> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cells = planets.length < 3 ? 3 : (planets.length + 2) ~/ 3 * 3;
|
||||
return Column(
|
||||
children: [
|
||||
for (var row = 0; row < cells; row += 3) ...[
|
||||
if (row > 0) const SizedBox(height: AppSpace.s2),
|
||||
Row(
|
||||
children: [
|
||||
for (var i = row; i < row + 3; i++) ...[
|
||||
if (i > row) const SizedBox(width: AppSpace.s2),
|
||||
Expanded(
|
||||
child: i < planets.length
|
||||
? _PlanetCard(
|
||||
planet: planets[i],
|
||||
image: GalaxyHomeScreen.cardImageOf(planets[i], i),
|
||||
selected: planets[i].isMine,
|
||||
onTap: () => onTap(planets[i]),
|
||||
)
|
||||
: const _EmptySlot(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 리스트 그리드 — 시안 `354:221533`: 80×79 #2c2c2c r8 칸(그림 64) · 간격 15 · 이름 16/600,
|
||||
/// 줄 사이 1px #2c2c2c. 선택된 행성은 칸 위에 「선택」 알약.
|
||||
class _PlanetList extends StatelessWidget {
|
||||
const _PlanetList({
|
||||
required this.planets,
|
||||
required this.imageOf,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final List<PlanetNode> planets;
|
||||
final String Function(PlanetNode, [int]) imageOf;
|
||||
final ValueChanged<PlanetNode> onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Column(
|
||||
children: [
|
||||
for (final (i, p) in planets.indexed) ...[
|
||||
if (i > 0)
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 8),
|
||||
child: Divider(height: 1, thickness: 1, color: Color(0xFF2C2C2C)),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () => onTap(p),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 80,
|
||||
height: 79,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2C2C2C),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: PlanetArt(
|
||||
fallback: imageOf(p, i),
|
||||
planetType: p.stage == 0 ? null : p.planetType,
|
||||
colorKey: p.colorKey,
|
||||
semanticLabel: p.name,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (p.isMine)
|
||||
Positioned(
|
||||
top: 0,
|
||||
right: 0,
|
||||
child: Container(
|
||||
width: 32,
|
||||
height: 24,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
context.l10n.commonSelect,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Text(
|
||||
p.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 22 / 16,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 리스트 상태의 「격자로」 아이콘 — 시안 9×9 r1 #777777 네 칸(20×20).
|
||||
class _GridIcon extends StatelessWidget {
|
||||
const _GridIcon();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget sq() => Container(
|
||||
width: 9,
|
||||
height: 9,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF777777),
|
||||
borderRadius: BorderRadius.circular(1),
|
||||
),
|
||||
);
|
||||
return SizedBox.square(
|
||||
dimension: 24,
|
||||
child: Center(
|
||||
child: SizedBox.square(
|
||||
dimension: 20,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [sq(), sq()],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [sq(), sq()],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -26,13 +26,20 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
final user = userSnap.data() ?? const <String, dynamic>{};
|
||||
// 대표 팬덤(C-15). `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다.
|
||||
final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
|
||||
// 「가입 또는 생성한 행성/성운」 — 최신 가입순. 시안 그리드가 세 칸이라 셋만 읽는다.
|
||||
// ponytail: 모아보기 리스트 그리드(명세 §2.58-2)를 붙이면 limit 과 페이지를 연다.
|
||||
// 「가입 또는 생성한 행성/성운」 — 최신 가입순 전부(Figma `540:19586`). 세 칸은 한 줄의 칸 수다.
|
||||
final membershipsF = db
|
||||
.collection('users/$uid/memberships')
|
||||
.orderBy('joinedAt', descending: true)
|
||||
.limit(3)
|
||||
.get();
|
||||
// 오늘(KST) 성운 생성 기록 — 서버 `dayKey`(UTC+9)와 같은 키.
|
||||
final kst = DateTime.now().toUtc().add(const Duration(hours: 9));
|
||||
final todayKey =
|
||||
'${kst.year.toString().padLeft(4, '0')}-${kst.month.toString().padLeft(2, '0')}-${kst.day.toString().padLeft(2, '0')}';
|
||||
// 규칙 배포 전이면 읽기가 거부된다 — 그때는 버튼을 막지 않고 서버 판정(E-FDM-02)에 맡긴다.
|
||||
final createdTodayF = db
|
||||
.doc('users/$uid/nebulaCreates/$todayKey')
|
||||
.get()
|
||||
.then((d) => d.exists, onError: (_) => false);
|
||||
|
||||
final fandomsF = db
|
||||
.collection('fandoms')
|
||||
@@ -102,6 +109,7 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
// Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙).
|
||||
joined: myFandomId.isNotEmpty,
|
||||
memberships: [for (final id in membershipIds) ?byId[id]],
|
||||
nebulaCreatedToday: await createdTodayF,
|
||||
joinedIds: {
|
||||
...?(user['fandomIds'] as List?)?.whereType<String>(),
|
||||
if (myFandomId.isNotEmpty) myFandomId,
|
||||
@@ -118,7 +126,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
if (q.isEmpty) return const [];
|
||||
final uid = _backend.requireUid;
|
||||
final db = _backend.db;
|
||||
final user = (await db.doc('users/$uid').get()).data() ?? const <String, dynamic>{};
|
||||
final user =
|
||||
(await db.doc('users/$uid').get()).data() ?? const <String, dynamic>{};
|
||||
final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
|
||||
final names = await db
|
||||
.collection('fandomNames')
|
||||
@@ -130,7 +139,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
for (final d in names.docs)
|
||||
if (d.data()['fandomId'] case final String id when id.isNotEmpty) id,
|
||||
};
|
||||
final docs = await Future.wait([for (final id in ids) db.doc('fandoms/$id').get()]);
|
||||
final docs = await Future.wait([
|
||||
for (final id in ids) db.doc('fandoms/$id').get(),
|
||||
]);
|
||||
return [
|
||||
for (final doc in docs)
|
||||
if (doc.data() case final data?)
|
||||
@@ -152,7 +163,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
|
||||
_ => null,
|
||||
},
|
||||
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
|
||||
planetType: data['planetType'] is String ? data['planetType'] as String : null,
|
||||
planetType: data['planetType'] is String
|
||||
? data['planetType'] as String
|
||||
: null,
|
||||
name: data.strOr('name', id),
|
||||
rank: rank,
|
||||
fpi: data.intOr('fpiScore'),
|
||||
|
||||
@@ -573,11 +573,9 @@ class _Map extends StatelessWidget {
|
||||
state: e,
|
||||
onTap: () =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => EclipseDefendScreen(
|
||||
repository: eclipseRepository,
|
||||
fandomId: e.fandomId,
|
||||
),
|
||||
eclipseDefendRoute(
|
||||
repository: eclipseRepository,
|
||||
fandomId: e.fandomId,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,7 +4,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/ui/planet_25d.dart' show PlanetArt;
|
||||
import '../../core/ui/play_banner.dart';
|
||||
import '../planet/widgets/fandom_size_badge.dart';
|
||||
import 'galaxy_data.dart';
|
||||
import '../../core/format.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -20,10 +23,14 @@ class GalaxySearchScreen extends StatefulWidget {
|
||||
super.key,
|
||||
this.repository = const SampleGalaxyRepository(),
|
||||
this.onOpen,
|
||||
this.onJoin,
|
||||
});
|
||||
|
||||
final GalaxyRepository repository;
|
||||
|
||||
/// 결과 줄의 「가입」 — Figma `543:19694` 「가입 처리 후 가입중 버튼스타일로 변경」.
|
||||
final Future<void> Function(PlanetNode planet)? onJoin;
|
||||
|
||||
/// 결과 줄을 누르면 그 행성 상세로 — 상세 화면 저장소는 부르는 쪽이 안다.
|
||||
final void Function(PlanetNode planet)? onOpen;
|
||||
|
||||
@@ -41,6 +48,17 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
|
||||
String _query = '';
|
||||
|
||||
/// Figma `543:19672` 탭 — 디폴트 행성(false) · 성운(true).
|
||||
bool _nebulaTab = false;
|
||||
|
||||
/// Figma `543:19683` 필터 — 디폴트 전체. 행성: 전체 · 가입자 수 · FPI / 성운: 전체 · 가입자 수.
|
||||
/// 성운의 「기여율」 필터 · 칩(`543:19704` · `543:19714`)은 기여율의 정의가 자료에 없어 해석 확인 후 붙인다.
|
||||
_Sort _sort = _Sort.all;
|
||||
|
||||
/// 이 화면에서 가입한 행성 — 서버 확인 뒤 「가입중」으로 바꾼다.
|
||||
final _joined = <String>{};
|
||||
final _joining = <String>{};
|
||||
|
||||
/// 전체 행성 검색 결과 — 입력이 멈춘 뒤 서버에 묻는다.
|
||||
Future<List<PlanetNode>>? _hits;
|
||||
Timer? _debounce;
|
||||
@@ -55,8 +73,15 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
|
||||
Future<void> _loadRecent() async {
|
||||
try {
|
||||
final saved = (await SharedPreferences.getInstance()).getStringList(_recentKey);
|
||||
if (mounted && saved != null) setState(() => _recent..clear()..addAll(saved));
|
||||
final saved = (await SharedPreferences.getInstance()).getStringList(
|
||||
_recentKey,
|
||||
);
|
||||
if (mounted && saved != null)
|
||||
setState(
|
||||
() => _recent
|
||||
..clear()
|
||||
..addAll(saved),
|
||||
);
|
||||
} catch (_) {
|
||||
// 기기 저장소를 못 쓰면 이번 화면 안에서만 기억한다.
|
||||
}
|
||||
@@ -64,7 +89,10 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
|
||||
Future<void> _saveRecent() async {
|
||||
try {
|
||||
await (await SharedPreferences.getInstance()).setStringList(_recentKey, _recent);
|
||||
await (await SharedPreferences.getInstance()).setStringList(
|
||||
_recentKey,
|
||||
_recent,
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -95,6 +123,24 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _join(PlanetNode p) async {
|
||||
final join = widget.onJoin;
|
||||
if (join == null || _joining.contains(p.id)) return;
|
||||
setState(() => _joining.add(p.id));
|
||||
try {
|
||||
await join(p);
|
||||
if (mounted) setState(() => _joined.add(p.id));
|
||||
} on BackendException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(e.message)));
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _joining.remove(p.id));
|
||||
}
|
||||
}
|
||||
|
||||
void _submit(String q) {
|
||||
final term = q.trim();
|
||||
if (term.isEmpty) return;
|
||||
@@ -201,19 +247,64 @@ class _GalaxySearchScreenState extends State<GalaxySearchScreen> {
|
||||
result.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final hits = result.data ?? const <PlanetNode>[];
|
||||
if (hits.isEmpty) return const _NoHit();
|
||||
return ListView.separated(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
itemCount: hits.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
itemBuilder: (context, i) => _Hit(
|
||||
planet: hits[i],
|
||||
onTap: widget.onOpen == null
|
||||
? null
|
||||
: () => widget.onOpen!(hits[i]),
|
||||
),
|
||||
final all = result.data ?? const <PlanetNode>[];
|
||||
final joinedIds = {
|
||||
...(snap.data?.joinedIds ?? const <String>{}),
|
||||
..._joined,
|
||||
};
|
||||
final hits = [
|
||||
for (final p in all)
|
||||
if ((p.stage == 0) == _nebulaTab) p,
|
||||
];
|
||||
switch (_sort) {
|
||||
case _Sort.all:
|
||||
break;
|
||||
case _Sort.members:
|
||||
hits.sort((a, b) => b.members.compareTo(a.members));
|
||||
case _Sort.fpi:
|
||||
hits.sort((a, b) => b.fpi.compareTo(a.fpi));
|
||||
}
|
||||
return Column(
|
||||
children: [
|
||||
_Tabs(
|
||||
nebula: _nebulaTab,
|
||||
onPick: (v) => setState(() {
|
||||
_nebulaTab = v;
|
||||
if (v && _sort == _Sort.fpi) _sort = _Sort.all;
|
||||
}),
|
||||
),
|
||||
_Filters(
|
||||
nebula: _nebulaTab,
|
||||
sort: _sort,
|
||||
onPick: (v) => setState(() => _sort = v),
|
||||
),
|
||||
Expanded(
|
||||
child: hits.isEmpty
|
||||
? const _NoHit()
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
itemCount: hits.length,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(height: 24),
|
||||
itemBuilder: (context, i) {
|
||||
final p = hits[i];
|
||||
final joined =
|
||||
p.isMine || joinedIds.contains(p.id);
|
||||
return _Hit(
|
||||
planet: p,
|
||||
joined: joined,
|
||||
busy: _joining.contains(p.id),
|
||||
onJoin: joined || widget.onJoin == null
|
||||
? null
|
||||
: () => _join(p),
|
||||
onTap: widget.onOpen == null
|
||||
? null
|
||||
: () => widget.onOpen!(p),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
@@ -290,13 +381,19 @@ class _Recent extends StatelessWidget {
|
||||
),
|
||||
)
|
||||
else
|
||||
Wrap(
|
||||
spacing: AppSpace.s2,
|
||||
runSpacing: AppSpace.s2,
|
||||
children: [
|
||||
for (final term in terms)
|
||||
ActionChip(label: Text(term), onPressed: () => onPick(term)),
|
||||
],
|
||||
// Figma `543:19637` 「검색 결과 칩의 최대 높이는 3줄」 — 칩 33 · 줄 간격 10.
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 33 * 3 + 10 * 2),
|
||||
child: ClipRect(
|
||||
child: Wrap(
|
||||
spacing: AppSpace.s2,
|
||||
runSpacing: 10,
|
||||
children: [
|
||||
for (final term in terms)
|
||||
ActionChip(label: Text(term), onPressed: () => onPick(term)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -318,46 +415,205 @@ class _NoHit extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
enum _Sort { all, members, fpi }
|
||||
|
||||
/// 탭 — 시안 `353:218091` 375×44 · 칸 171 · 16/500(선택 #4bc2ae 밑줄).
|
||||
class _Tabs extends StatelessWidget {
|
||||
const _Tabs({required this.nebula, required this.onPick});
|
||||
|
||||
final bool nebula;
|
||||
final ValueChanged<bool> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 44,
|
||||
child: Row(
|
||||
children: [
|
||||
for (final (v, label) in [
|
||||
(false, context.l10n.galaxyTabPlanet),
|
||||
(true, context.l10n.galaxyTabNebula),
|
||||
])
|
||||
Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => onPick(v),
|
||||
child: Container(
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: v == nebula
|
||||
? AppColors.cosmicTeal
|
||||
: const Color(0xFF1A1A1A),
|
||||
width: v == nebula ? 2 : 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: _t(
|
||||
16,
|
||||
v == nebula
|
||||
? AppColors.cosmicTeal
|
||||
: const Color(0xFFBBBBBB),
|
||||
FontWeight.w500,
|
||||
1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 필터 칩 — 시안 32 높이 r8 · 선택 #ffffff(글자 검정) · 비선택 #1a1a1a · 12/500.
|
||||
class _Filters extends StatelessWidget {
|
||||
const _Filters({
|
||||
required this.nebula,
|
||||
required this.sort,
|
||||
required this.onPick,
|
||||
});
|
||||
|
||||
final bool nebula;
|
||||
final _Sort sort;
|
||||
final ValueChanged<_Sort> onPick;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SizedBox(
|
||||
height: 48,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
children: [
|
||||
for (final (v, label) in [
|
||||
(_Sort.all, context.l10n.commonAll),
|
||||
(_Sort.members, context.l10n.galaxySortMembers),
|
||||
if (!nebula) (_Sort.fpi, 'FPI'),
|
||||
]) ...[
|
||||
GestureDetector(
|
||||
onTap: () => onPick(v),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: v == sort ? Colors.white : const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: _t(
|
||||
12,
|
||||
v == sort ? Colors.black : AppColors.onCosmic,
|
||||
FontWeight.w500,
|
||||
20 / 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 결과 한 줄 — 시안 `353:218091`: 그림 70 · 간격 15 · 이름 16/600 · 규모 칩 · 사람 14 + 수 12/500 ·
|
||||
/// 「FPI : n」 12/500, 오른쪽 「가입」(#ff7ddd) / 「가입중」(#1a1a1a) 알약 30 높이.
|
||||
class _Hit extends StatelessWidget {
|
||||
const _Hit({required this.planet, this.onTap});
|
||||
const _Hit({
|
||||
required this.planet,
|
||||
required this.joined,
|
||||
this.busy = false,
|
||||
this.onJoin,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
final PlanetNode planet;
|
||||
final bool joined;
|
||||
final bool busy;
|
||||
final VoidCallback? onJoin;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = Theme.of(context).textTheme;
|
||||
final nebula = planet.stage == 0;
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
planet.rankLabel,
|
||||
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox.square(
|
||||
dimension: 70,
|
||||
child: PlanetArt(
|
||||
fallback: nebula
|
||||
? 'assets/figma/nebula-card.png'
|
||||
: 'assets/figma/planet-detail-hero.png',
|
||||
planetType: nebula ? null : planet.planetType,
|
||||
colorKey: planet.colorKey,
|
||||
semanticLabel: planet.name,
|
||||
),
|
||||
const SizedBox(width: AppSpace.s4),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(planet.name, style: t.bodyMedium),
|
||||
Text(
|
||||
context.l10n.galaxyMembers(formatPop(planet.members)),
|
||||
style: t.labelSmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
planet.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _t(16, AppColors.onCosmic, FontWeight.w600, 1),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
if (!nebula) ...[
|
||||
FandomSizeBadge(members: planet.members),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
const Icon(
|
||||
Icons.person,
|
||||
size: 14,
|
||||
color: Color(0xFF777777),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
formatPop(planet.members),
|
||||
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
|
||||
),
|
||||
if (!nebula) ...[
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'FPI : ${formatPop(planet.fpi)}',
|
||||
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: busy ? null : onJoin,
|
||||
child: Container(
|
||||
height: 30,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: joined
|
||||
? const Color(0xFF1A1A1A)
|
||||
: AppColors.cosmicAccent,
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
joined ? context.l10n.planetJoined : context.l10n.galaxyJoin,
|
||||
style: _t(14, AppColors.onCosmic, FontWeight.w500, 1),
|
||||
),
|
||||
),
|
||||
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ class PlanetGrowth {
|
||||
this.requiredMembers,
|
||||
this.nebulaGoalPassed = false,
|
||||
this.isFounder = false,
|
||||
this.photoUrl,
|
||||
required this.fandomName,
|
||||
required this.contributorName,
|
||||
required this.contributedPop,
|
||||
@@ -36,8 +37,15 @@ class PlanetGrowth {
|
||||
final int? stage;
|
||||
final String fandomName;
|
||||
|
||||
/// 직전 기여자. 「BTS_Hong 님의 1,230 POP 기여로 …」 문구에 쓴다.
|
||||
/// 히어로 인사 줄의 이름 — 설명 프레임 `2097:16391` 「프로필 · 닉네임 · 타이틀 - **로그인
|
||||
/// 사용자 기준** 노출」이라 로그인한 사용자다(팬덤의 직전 기여자가 아니다).
|
||||
final String contributorName;
|
||||
|
||||
/// 인사 줄 아바타 — 로그인 사용자의 프로필 사진(`users/{uid}.photoUrl`). 없으면 이니셜.
|
||||
final String? photoUrl;
|
||||
|
||||
/// 「{N} POP 기여로 …」의 N. 로그인 사용자의 어느 기여량인지 자료에 정의가 없어(해석 확인
|
||||
/// Q-A17) 실 서버는 0 으로 두고 숫자 없는 문구를 쓴다.
|
||||
final int contributedPop;
|
||||
|
||||
bool get hasRecentContribution =>
|
||||
|
||||
@@ -26,6 +26,10 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
/// 히어로 인사 줄의 로그인 사용자(설명 프레임 `2097:16391`). [load] 가 채우고 [watchGrowth] 도 쓴다.
|
||||
String _myName = '';
|
||||
String? _myPhoto;
|
||||
|
||||
@override
|
||||
Stream<PlanetGrowth> watchGrowth(String fandomId) {
|
||||
if (fandomId.isEmpty) return const Stream.empty();
|
||||
@@ -107,6 +111,8 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
final userSnap = await db.doc('users/$uid').get();
|
||||
final user = userSnap.data() ?? const <String, dynamic>{};
|
||||
final fandomId = user.strOr('fandomId');
|
||||
_myName = publicName(user['nickname']);
|
||||
_myPhoto = user.strOr('photoUrl').isEmpty ? null : user.strOr('photoUrl');
|
||||
|
||||
// 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다.
|
||||
final walletF = db.doc('users/$uid/wallet/main').get();
|
||||
@@ -366,11 +372,12 @@ class FirestoreHomeRepository implements HomeRepository {
|
||||
return PlanetGrowth(
|
||||
stage: stage,
|
||||
fandomName: fandom.strOr('name', fandomId),
|
||||
// 기존 데이터에 이메일 형태가 남아 있어도 가린다.
|
||||
contributorName: fandom.containsKey('lastContributorName')
|
||||
? publicName(fandom['lastContributorName'])
|
||||
: '',
|
||||
contributedPop: fandom.intOr('lastContributedPop'),
|
||||
// 설명 프레임 `2097:16391` — 프로필 · 닉네임은 로그인 사용자 기준. 팬덤 문서의 직전
|
||||
// 기여자(`lastContributorName`)를 쓰면 남의 이름이 「님의 … 기여로」에 올라 내 것처럼 읽힌다.
|
||||
contributorName: _myName,
|
||||
photoUrl: _myPhoto,
|
||||
// 「N POP 기여로」의 N 이 어느 기여량인지 정의가 없다(Q-A17) — 회신 전에는 숫자 없는 문구.
|
||||
contributedPop: 0,
|
||||
growthPercent: current != null && goal != null && goal > 0
|
||||
? ((current / goal) * 100).clamp(0, 100).round()
|
||||
: null,
|
||||
|
||||
@@ -16,6 +16,7 @@ import 'package:go_router/go_router.dart';
|
||||
import '../../core/format.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/app_dialog.dart' show slideUpRoute;
|
||||
import '../../core/ui/app_icons.dart';
|
||||
import '../../core/data/planet_visual_state.dart';
|
||||
import '../../core/ui/live_stream.dart';
|
||||
@@ -167,7 +168,7 @@ class _HomeScreenState extends State<HomeScreen> with WidgetsBindingObserver {
|
||||
final created = await Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(MaterialPageRoute<String>(builder: (_) => screen));
|
||||
).push(slideUpRoute<String>((_) => screen));
|
||||
if (created == null || !mounted) return;
|
||||
if (widget.galaxyRepository case final repository?) {
|
||||
setState(() {
|
||||
@@ -287,7 +288,7 @@ class _LiveTickerSlot extends StatelessWidget {
|
||||
}
|
||||
|
||||
/// 홈 헤더 — 성장 완료 · 첫 사용자 상태 화면도 같은 헤더를 쓴다.
|
||||
class HomeHeader extends StatelessWidget {
|
||||
class HomeHeader extends StatefulWidget {
|
||||
const HomeHeader({
|
||||
super.key,
|
||||
required this.pop,
|
||||
@@ -306,71 +307,116 @@ class HomeHeader extends StatelessWidget {
|
||||
final List<ItemProduct> items;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
// 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개.
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6),
|
||||
child: SizedBox(
|
||||
height: 42,
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'FANUZU',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.5,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_PopBalance(value: pop),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
|
||||
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(42),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Image(
|
||||
image: AssetImage('assets/figma/icon-wallet.png'),
|
||||
width: 42,
|
||||
height: 42,
|
||||
),
|
||||
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
|
||||
onPressed: () => showItemSheet(
|
||||
context,
|
||||
items: items,
|
||||
purchase: true,
|
||||
fandomId: fandomId,
|
||||
),
|
||||
tooltip: context.l10n.homeItemsTooltip,
|
||||
),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
|
||||
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(42),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
// 사본 `icon` 42 칸 안 lucide/bell 20.
|
||||
iconSize: 20,
|
||||
icon: const ImageIcon(AppIcons.bell, color: AppColors.onCosmic),
|
||||
// 알림 — 화면 대조 원장 F0 · `229:11265`.
|
||||
onPressed: () => Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => NotificationScreen(repository: notifications),
|
||||
State<HomeHeader> createState() => _HomeHeaderState();
|
||||
}
|
||||
|
||||
class _HomeHeaderState extends State<HomeHeader> {
|
||||
/// 안 읽은 알림이 있는가 — 설명 프레임 `2097:16385` 「알림 - 알림 아이콘 / 알림 상태값 ·
|
||||
/// 알림 존재 시 배찌 알림으로 변경」. 알림함은 닫힐 때 읽음 상태를 돌려주므로(`518:88347`)
|
||||
/// 돌아오면 다시 읽는다.
|
||||
bool _unread = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_checkUnread();
|
||||
}
|
||||
|
||||
Future<void> _checkUnread() async {
|
||||
try {
|
||||
final items = await widget.notifications.load();
|
||||
if (mounted) setState(() => _unread = items.any((i) => !i.read));
|
||||
} catch (_) {
|
||||
// 읽지 못하면 배지를 띄우지 않는다 — 알림함 자체는 열린다.
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pop = widget.pop;
|
||||
final items = widget.items;
|
||||
final fandomId = widget.fandomId;
|
||||
final notifications = widget.notifications;
|
||||
return Padding(
|
||||
// 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개.
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6),
|
||||
child: SizedBox(
|
||||
height: 42,
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'FANUZU',
|
||||
style: TextStyle(
|
||||
fontFamily: 'Pretendard',
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1.4,
|
||||
letterSpacing: -0.5,
|
||||
color: AppColors.cosmicAccent,
|
||||
),
|
||||
),
|
||||
tooltip: context.l10n.homeNotificationsTooltip,
|
||||
),
|
||||
],
|
||||
const Spacer(),
|
||||
_PopBalance(value: pop),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
|
||||
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(42),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
icon: const Image(
|
||||
image: AssetImage('assets/figma/icon-wallet.png'),
|
||||
width: 42,
|
||||
height: 42,
|
||||
),
|
||||
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
|
||||
onPressed: () => showItemSheet(
|
||||
context,
|
||||
items: items,
|
||||
purchase: true,
|
||||
fandomId: fandomId,
|
||||
),
|
||||
tooltip: context.l10n.homeItemsTooltip,
|
||||
),
|
||||
IconButton(
|
||||
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
|
||||
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
|
||||
style: IconButton.styleFrom(
|
||||
minimumSize: const Size.square(42),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
padding: EdgeInsets.zero,
|
||||
// 사본 `icon` 42 칸 안 lucide/bell 20.
|
||||
iconSize: 20,
|
||||
// 배지 모양은 Figma 에 그림이 없어 테마 기본 점 배지(강조색)를 쓴다.
|
||||
icon: Badge(
|
||||
key: const ValueKey('home-bell-badge'),
|
||||
isLabelVisible: _unread,
|
||||
smallSize: 6,
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
child: const ImageIcon(
|
||||
AppIcons.bell,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
// 알림 — 화면 대조 원장 F0 · `229:11265`.
|
||||
onPressed: () async {
|
||||
await Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<Object?>(
|
||||
builder: (_) =>
|
||||
NotificationScreen(repository: notifications),
|
||||
),
|
||||
);
|
||||
await _checkUnread();
|
||||
},
|
||||
tooltip: context.l10n.homeNotificationsTooltip,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HomeBody extends StatelessWidget {
|
||||
@@ -488,35 +534,7 @@ class _HomeBody extends StatelessWidget {
|
||||
fallback: data.liveTicker,
|
||||
),
|
||||
onClose: () => onMap(false),
|
||||
onSearch: () =>
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxySearchScreen(
|
||||
repository: zap!.repository,
|
||||
onOpen: (p) =>
|
||||
Navigator.of(
|
||||
context,
|
||||
rootNavigator: true,
|
||||
).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) =>
|
||||
PlanetDetailScreen(
|
||||
repository:
|
||||
planetRepository!(
|
||||
p.id,
|
||||
),
|
||||
watchPlanet:
|
||||
watchPlanet,
|
||||
eclipse: eclipse,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
onSearch: () => _openSearch(context),
|
||||
)
|
||||
: HomeHero(
|
||||
key: const ValueKey('home-hero'),
|
||||
@@ -604,9 +622,8 @@ class _HomeBody extends StatelessWidget {
|
||||
myPop: data.popBalance,
|
||||
),
|
||||
),
|
||||
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
|
||||
onJoin: () =>
|
||||
GoRouter.maybeOf(context)?.go('/galaxy'),
|
||||
// 설명 프레임 `516:87335` 「행성 가입 버튼 - 클릭 시 Galaxy 검색 화면 이동」.
|
||||
onJoin: () => _openSearch(context),
|
||||
zapButtonKey: zapKey,
|
||||
onHeroParticipate: _heroParticipate(context),
|
||||
eclipseButton: data.fandomId.isEmpty
|
||||
@@ -614,6 +631,7 @@ class _HomeBody extends StatelessWidget {
|
||||
: EclipseEventButton(
|
||||
repository: eclipse,
|
||||
fandomId: data.fandomId,
|
||||
nebula: (growth ?? data.growth).isNebula,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -842,6 +860,33 @@ class _HomeBody extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 갤럭시 검색 — 확장 맵 돋보기(`2008:14560`)와 첫 사용자 시트 「행성 가입」(`516:87335`).
|
||||
/// 지도 저장소가 없으면(표본 모드) GALAXY 탭으로 물러난다.
|
||||
void _openSearch(BuildContext context) {
|
||||
final galaxyRepository = zap?.repository;
|
||||
if (galaxyRepository == null || planetRepository == null) {
|
||||
GoRouter.maybeOf(context)?.go('/galaxy');
|
||||
return;
|
||||
}
|
||||
final navigator = Navigator.of(context, rootNavigator: true);
|
||||
navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GalaxySearchScreen(
|
||||
repository: galaxyRepository,
|
||||
onOpen: (p) => navigator.push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => PlanetDetailScreen(
|
||||
repository: planetRepository!(p.id),
|
||||
watchPlanet: watchPlanet,
|
||||
eclipse: eclipse,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
VoidCallback? _heroParticipate(BuildContext context) =>
|
||||
planetRepository == null || data.fandomId.isEmpty
|
||||
? null
|
||||
|
||||
@@ -13,7 +13,9 @@ import '../../core/i18n/app_locale.dart';
|
||||
/// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다.
|
||||
/// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식
|
||||
/// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정
|
||||
/// 표시를 둔다(사본 슬라이더 ↔ 원장 고정값, 해석 확인 문항). B-19 — 진화 참여자에게는
|
||||
/// 표시를 둔다. 근거는 발주사 10/3 통합회신(원장 C-112) 「성운 생성은 10 명 + 1,000 POP +
|
||||
/// 기본 목표 7 일, 사용자 목표 POP 조작 없음」 — Figma `516:87407` 목표 POP · 창립멤버 핸들러를
|
||||
/// 이 회신이 대체한다. B-19 — 진화 참여자에게는
|
||||
/// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다.
|
||||
///
|
||||
/// 사본이 함께 그린 다이얼로그 두 개를 그대로 쓴다.
|
||||
@@ -52,6 +54,10 @@ class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
|
||||
String? _takenName;
|
||||
bool get _taken => _takenName != null && _takenName == _value;
|
||||
|
||||
/// 설명 프레임 `516:87397` 「성운명 입력 필드 - 유효성 검증 오류 메시지」 — 서버
|
||||
/// `isValidNebulaName` 과 같은 규칙을 입력 칸에서 먼저 보인다(중복 · 금칙어는 서버 판정).
|
||||
bool get _invalid => _name.text.isNotEmpty && !isValidNebulaName(_name.text);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -129,13 +135,15 @@ class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
_NameInput(controller: _name, error: _taken),
|
||||
_NameInput(controller: _name, error: _taken || _invalid),
|
||||
const SizedBox(height: 6),
|
||||
SizedBox(
|
||||
height: 17,
|
||||
child: _taken
|
||||
child: _taken || _invalid
|
||||
? Text(
|
||||
context.l10n.nebulaNameTaken,
|
||||
_taken
|
||||
? context.l10n.nebulaNameTaken
|
||||
: context.l10n.nebulaNameInvalid,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
@@ -171,7 +179,7 @@ class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
|
||||
0,
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: _taken ? null : _confirm,
|
||||
onPressed: _taken || _invalid ? null : _confirm,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
@@ -407,3 +415,10 @@ class _Target extends StatelessWidget {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// 성운명 규칙 — 서버 `isValidNebulaName`(`account.ts`)과 같다: 2~20자 · 한글/영문/숫자/공백 ·
|
||||
/// 앞뒤 공백 · 연속 공백 불가.
|
||||
bool isValidNebulaName(String name) =>
|
||||
RegExp(r'^[가-힣A-Za-z0-9 ]{2,20}$').hasMatch(name) &&
|
||||
name == name.trim() &&
|
||||
!name.contains(' ');
|
||||
|
||||
@@ -13,7 +13,9 @@ import '../../core/i18n/app_locale.dart';
|
||||
///
|
||||
/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고,
|
||||
/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과
|
||||
/// 직접 색 고르기(스포이트) 칩은 두지 않는다.
|
||||
/// 직접 색 고르기(스포이트) 칩은 두지 않는다. 사용자화(컬러 팔레트)를 빼는 근거는 발주사
|
||||
/// 10/3 통합회신 #6(원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma
|
||||
/// `2066:17910` 의 「사용자화」를 이 회신이 대체한다. 「오브젝트 색」 줄은 Q-A14 회신 대기.
|
||||
///
|
||||
/// 형태 칸 · 미리보기는 행성 형태 시트 그림이다([planetShapeImage] — 6번 칸만 자리표시, Q-A14).
|
||||
class PlanetCreateScreen extends StatefulWidget {
|
||||
|
||||
@@ -314,32 +314,42 @@ class _Contribution extends StatelessWidget {
|
||||
Widget build(BuildContext context) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 성운·첫 사용자 상태의 「{닉네임} 님의」는 기여 여부와 무관한 인사 줄이다.
|
||||
if (growth.hasRecentContribution ||
|
||||
((!joined || growth.isNebula) &&
|
||||
growth.contributorName.trim().isNotEmpty)) ...[
|
||||
// 「{닉네임} 님의」는 로그인 사용자의 인사 줄이다(설명 프레임 `2097:16391`).
|
||||
if (growth.contributorName.trim().isNotEmpty) ...[
|
||||
Row(
|
||||
children: [
|
||||
// 프로필 사진 자산이 데이터에 없어 이니셜로 그린다.
|
||||
// 로그인 사용자의 프로필 사진(`2097:16391` 「프로필 - 이미지」). 없으면 이니셜.
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
alignment: Alignment.center,
|
||||
decoration: const BoxDecoration(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
// 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
|
||||
color: Color(0xFF9BF8A8),
|
||||
boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)],
|
||||
),
|
||||
child: Text(
|
||||
growth.contributorName.characters.firstOrNull ?? '—',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
),
|
||||
color: const Color(0xFF9BF8A8),
|
||||
boxShadow: const [
|
||||
BoxShadow(color: Color(0xB3000000), blurRadius: 4),
|
||||
],
|
||||
image: growth.photoUrl == null
|
||||
? null
|
||||
: DecorationImage(
|
||||
image: NetworkImage(growth.photoUrl!),
|
||||
fit: BoxFit.cover,
|
||||
onError: (_, _) {},
|
||||
),
|
||||
),
|
||||
child: growth.photoUrl != null
|
||||
? null
|
||||
: Text(
|
||||
growth.contributorName.characters.firstOrNull ?? '—',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
@@ -686,8 +696,10 @@ class _Nebula extends StatelessWidget {
|
||||
Semantics(
|
||||
button: ready && onBirth != null,
|
||||
label: context.l10n.heroNebula,
|
||||
child: GestureDetector(
|
||||
onTap: ready ? onBirth : null,
|
||||
// Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고,
|
||||
// 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다.
|
||||
child: _TapShake(
|
||||
onTap: ready && onBirth != null ? onBirth : () {},
|
||||
child: Image.asset(
|
||||
'assets/figma/nebula-home.png',
|
||||
width: 212,
|
||||
@@ -1018,6 +1030,9 @@ class HomeZap extends StatelessWidget {
|
||||
onItem: onItem,
|
||||
zapPop: data.zapPop,
|
||||
myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop,
|
||||
// Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 ·
|
||||
// 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 —
|
||||
// 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다.
|
||||
isHeroTime: hero,
|
||||
blockedReason: zap.sending
|
||||
? context.l10n.zapApplying
|
||||
|
||||
@@ -2,12 +2,10 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../../../core/ui/app_icons.dart';
|
||||
import '../../quest/widgets/quest_card_tile.dart'
|
||||
show QuestIconBox, questImageFor;
|
||||
import '../../quest/widgets/time_attack_countdown.dart';
|
||||
import '../home_data.dart';
|
||||
|
||||
/// 홈 「TODAY'S QUEST」 묶음 — Figma 「홈」 `2097:16428`: 343×270 · r12 안에 343×90 줄
|
||||
@@ -45,8 +43,9 @@ class QuestRows extends StatelessWidget {
|
||||
/// 오른쪽 chevron 24 #4F4F4F.
|
||||
///
|
||||
/// 보조 줄은 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」)다. 「홈」 프레임에는 보상 숫자 ·
|
||||
/// 진행 상태가 없어 그리지 않는다. 예외는 긴급 타임어택의 남은 시간 하나 — FR-QST-06
|
||||
/// 「홈 카운트다운」이 요구한다.
|
||||
/// 진행 상태가 없어 그리지 않는다. 타임어택 남은 시간도 그리지 않는다 — 설명 프레임
|
||||
/// `2097:16397` 데이터는 「어드민에서 등록된 퀘스트」뿐이고, FR-QST-06 · 원장 C-94 의 카운트다운
|
||||
/// 표시 위치는 퀘스트 목록 · 상세다.
|
||||
class QuestRow extends StatelessWidget {
|
||||
const QuestRow({
|
||||
super.key,
|
||||
@@ -65,7 +64,6 @@ class QuestRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final done = quest.state == QuestState.done;
|
||||
return Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
shape: divider
|
||||
@@ -100,20 +98,7 @@ class QuestRow extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
|
||||
if (quest.endsAt case final endsAt? when !done)
|
||||
TimeAttackCountdown(
|
||||
endsAt: endsAt,
|
||||
builder: (context, left) => _sub(
|
||||
left == null
|
||||
? context.l10n.questTimeAttackClosed
|
||||
: context.l10n.questTimeAttackLeft(
|
||||
formatLeft(left),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
_sub(quest.category ?? ''),
|
||||
_sub(quest.category ?? ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,6 +12,9 @@ import '../../core/ui/bottom_inset.dart';
|
||||
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
|
||||
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
|
||||
///
|
||||
/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 원장 B-52)** 이 대체했다 —
|
||||
/// 중복을 이유로 막지 않는다. 헬프 텍스트 자리는 길이 · 예약어 오류에 쓴다.
|
||||
///
|
||||
/// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다.
|
||||
class ProfileEditScreen extends StatefulWidget {
|
||||
const ProfileEditScreen({
|
||||
|
||||
@@ -353,6 +353,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
/// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 ·
|
||||
/// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다.
|
||||
/// 서버도 같은 조건을 다시 본다(E-AUTH-14).
|
||||
/// 「연동 상태값」을 로그인 제공자 **외 추가 연결**로 읽는다 — 별첨 B 로 소셜 전용 로그인이라
|
||||
/// 문자 그대로면 모든 계정이 비정상 탈퇴가 된다. 해석 확인 Q-E9 회신 대기(임시값).
|
||||
Future<void> _withdraw() async {
|
||||
final l10n = context.l10n;
|
||||
if (_linked.values.where((e) => e != null).length > 1) {
|
||||
|
||||
@@ -17,7 +17,11 @@ enum NotificationKind {
|
||||
like(Icons.favorite_border),
|
||||
comment(Icons.mode_comment_outlined),
|
||||
campaign(Icons.campaign_outlined),
|
||||
quest(Icons.checklist_rtl);
|
||||
quest(Icons.checklist_rtl),
|
||||
|
||||
/// 행성 진화 참여 알림(9/21 회신 「당신도 이 진화에 함께했습니다」). Figma `518:88353` 6종 밖이라
|
||||
/// 이동하지 않는다 — 예전에는 아이템 사용으로 떨어져 노란 아이템 배지 · 「아이템 사용」으로 보였다.
|
||||
evolution(Icons.public);
|
||||
|
||||
const NotificationKind(this.icon);
|
||||
|
||||
@@ -30,12 +34,13 @@ enum NotificationKind {
|
||||
comment => appL10n.notifKindComment,
|
||||
campaign => appL10n.notifKindCampaign,
|
||||
quest => appL10n.notifKindQuest,
|
||||
evolution => appL10n.notifKindEvolution,
|
||||
};
|
||||
|
||||
/// 눌러서 갈 곳. `null` 이면 이동하지 않는다 — 명세가 이클립스와 아이템
|
||||
/// 사용을 *클릭 이동 X* 로 정했다.
|
||||
String? get target => switch (this) {
|
||||
eclipse || itemUsed => null,
|
||||
eclipse || itemUsed || evolution => null,
|
||||
like || comment => appL10n.notifTargetPost,
|
||||
campaign => appL10n.notifTargetCampaign,
|
||||
quest => appL10n.notifTargetQuest,
|
||||
@@ -66,6 +71,7 @@ enum NotificationKind {
|
||||
'comment' => NotificationKind.comment,
|
||||
'campaign' => NotificationKind.campaign,
|
||||
'quest' => NotificationKind.quest,
|
||||
'evolution' => NotificationKind.evolution,
|
||||
_ => NotificationKind.itemUsed,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -70,44 +70,52 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
}
|
||||
final groups = groupNotifications(items);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.homeNotificationsTooltip),
|
||||
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
|
||||
leading: BackButton(
|
||||
onPressed: () => Navigator.of(context).pop({..._read}),
|
||||
// 명세 `518:88347` — 뒤로가기는 *이전 화면 이동 및 읽음 상태값 반환*. 시스템 뒤로가기(Android
|
||||
// 제스처 · iOS 스와이프)도 같은 값을 돌려준다.
|
||||
return PopScope<Object?>(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) Navigator.of(context).pop({..._read});
|
||||
},
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.homeNotificationsTooltip),
|
||||
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
|
||||
leading: BackButton(
|
||||
onPressed: () => Navigator.of(context).pop({..._read}),
|
||||
),
|
||||
),
|
||||
),
|
||||
body: items.isEmpty
|
||||
? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
children: [
|
||||
for (final group in groups)
|
||||
// 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
foregroundDecoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFF1A1A1A)),
|
||||
body: items.isEmpty
|
||||
? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium))
|
||||
: ListView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
|
||||
children: [
|
||||
for (final group in groups)
|
||||
// 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄.
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 20),
|
||||
foregroundDecoration: const BoxDecoration(
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Color(0xFF1A1A1A)),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
group.label,
|
||||
style: _style(_grey, height: 24 / 14),
|
||||
),
|
||||
for (final (i, item) in group.items.indexed) ...[
|
||||
SizedBox(height: i == 0 ? 20 : 24),
|
||||
_tile(item),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
group.label,
|
||||
style: _style(_grey, height: 24 / 14),
|
||||
),
|
||||
for (final (i, item) in group.items.indexed) ...[
|
||||
SizedBox(height: i == 0 ? 20 : 24),
|
||||
_tile(item),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -187,8 +195,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
}
|
||||
final screen = _screenFor(item);
|
||||
if (screen == null || !mounted) return;
|
||||
await Navigator.of(context)
|
||||
.push<void>(MaterialPageRoute(builder: (_) => screen));
|
||||
await Navigator.of(
|
||||
context,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => screen));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -211,6 +220,7 @@ TextStyle _style(Color color, {double height = 1.3}) => TextStyle(
|
||||
NotificationKind.campaign => (Colors.black, _icon('message_circle')),
|
||||
NotificationKind.like => (const Color(0xFFFF5151), _icon('heart')),
|
||||
NotificationKind.eclipse => (Colors.black, _icon('planet')),
|
||||
NotificationKind.evolution => (Colors.black, _icon('planet')),
|
||||
NotificationKind.itemUsed => (const Color(0xFFCEBF3B), _icon('water_ec')),
|
||||
NotificationKind.quest => (
|
||||
AppColors.cosmicSurfaceContainer,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/ui/planet_25d.dart' show PlanetArt;
|
||||
import 'planet_data.dart';
|
||||
import 'widgets/action_sheet.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
@@ -32,10 +33,38 @@ class FpiRankingScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _FpiRankingScreenState extends State<FpiRankingScreen> {
|
||||
late final Future<FpiRanking> _ranking = widget.repository.planetRanking(
|
||||
/// 명세 `546:14639` — 최초 30개, 스크롤 시 10개씩.
|
||||
int _limit = 30;
|
||||
late Future<FpiRanking> _ranking = widget.repository.planetRanking(
|
||||
metric: widget.metric,
|
||||
limit: _limit,
|
||||
);
|
||||
|
||||
Future<FpiRanking> _load(int limit) =>
|
||||
widget.repository.planetRanking(metric: widget.metric, limit: limit);
|
||||
|
||||
bool _loadingMore = false;
|
||||
|
||||
/// 지금 장이 꽉 찼을 때만 10개 더 읽는다(덜 찼으면 끝이다).
|
||||
Future<void> _next() async {
|
||||
if (_loadingMore) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final now = await _ranking;
|
||||
if (now.rows.length < _limit || !mounted) return;
|
||||
final next = _load(_limit + 10);
|
||||
setState(() {
|
||||
_limit += 10;
|
||||
_ranking = next;
|
||||
});
|
||||
await next;
|
||||
} catch (_) {
|
||||
// 다음 장 실패는 앞 장을 그대로 둔다.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _openUsers(FpiRankRow planet) =>
|
||||
Navigator.of(context, rootNavigator: true).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -57,6 +86,9 @@ class _FpiRankingScreenState extends State<FpiRankingScreen> {
|
||||
myLabel: context.l10n.rankMyPlanet(widget.metric.label),
|
||||
emptyLabel: context.l10n.rankPlanetEmpty,
|
||||
onTapRow: _openUsers,
|
||||
// 명세 `546:14629` — 소속 행성 랭킹 영역도 「사용자 랭킹 화면 이동」.
|
||||
onTapMine: _openUsers,
|
||||
onEnd: _next,
|
||||
planets: true,
|
||||
),
|
||||
);
|
||||
@@ -102,18 +134,45 @@ class FpiUserRankingScreen extends StatefulWidget {
|
||||
|
||||
class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
|
||||
FpiRankScope _scope = FpiRankScope.live;
|
||||
late Future<FpiRanking> _ranking = _load();
|
||||
int _limit = 30;
|
||||
late Future<FpiRanking> _ranking = _load(_limit);
|
||||
|
||||
Future<FpiRanking> _load() => switch (_scope) {
|
||||
Future<FpiRanking> _load(int limit) => switch (_scope) {
|
||||
FpiRankScope.live => widget.repository.userRanking(
|
||||
metric: widget.metric,
|
||||
fandomId: widget.fandomId,
|
||||
limit: limit,
|
||||
),
|
||||
FpiRankScope.byCountry => widget.repository.countryRanking(
|
||||
metric: widget.metric,
|
||||
limit: limit,
|
||||
),
|
||||
};
|
||||
|
||||
/// 국가별은 명세 `546:14713` 「최초 30개 노출 스크롤시 10개씩」. 실시간 사용자 랭킹은 명세에 수가 없어
|
||||
/// 같은 방식으로 이어 읽는다.
|
||||
bool _loadingMore = false;
|
||||
|
||||
/// 지금 장이 꽉 찼을 때만 10개 더 읽는다(덜 찼으면 끝이다).
|
||||
Future<void> _next() async {
|
||||
if (_loadingMore) return;
|
||||
_loadingMore = true;
|
||||
try {
|
||||
final now = await _ranking;
|
||||
if (now.rows.length < _limit || !mounted) return;
|
||||
final next = _load(_limit + 10);
|
||||
setState(() {
|
||||
_limit += 10;
|
||||
_ranking = next;
|
||||
});
|
||||
await next;
|
||||
} catch (_) {
|
||||
// 다음 장 실패는 앞 장을 그대로 둔다.
|
||||
} finally {
|
||||
_loadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
// 시안 `373:436695` — 제목 「USER RANKING」, 범위 필터는 본문 「실시간 랭킹 ⌄」.
|
||||
@@ -130,6 +189,8 @@ class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
|
||||
emptyLabel: context.l10n.rankEmpty,
|
||||
scopeLabel: context.l10n.rankScope(_scope.label),
|
||||
onPickScope: _pickScope,
|
||||
onEnd: _next,
|
||||
flags: _scope == FpiRankScope.byCountry,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -143,7 +204,8 @@ class _FpiUserRankingScreenState extends State<FpiUserRankingScreen> {
|
||||
if (next != null && next != _scope && mounted) {
|
||||
setState(() {
|
||||
_scope = next;
|
||||
_ranking = _load();
|
||||
_limit = 30;
|
||||
_ranking = _load(_limit);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -162,8 +224,20 @@ class _RankingBody extends StatelessWidget {
|
||||
this.planets = false,
|
||||
this.scopeLabel,
|
||||
this.onPickScope,
|
||||
this.onTapMine,
|
||||
this.onEnd,
|
||||
this.flags = false,
|
||||
});
|
||||
|
||||
/// 내 줄 카드를 눌렀을 때.
|
||||
final ValueChanged<FpiRankRow>? onTapMine;
|
||||
|
||||
/// 목록 끝 가까이 스크롤했을 때 — 다음 10개.
|
||||
final VoidCallback? onEnd;
|
||||
|
||||
/// 국가 랭킹 — 아바타 자리에 국기(명세 `546:14713` 「국기 이미지」).
|
||||
final bool flags;
|
||||
|
||||
final Future<FpiRanking> future;
|
||||
final String myLabel;
|
||||
final String emptyLabel;
|
||||
@@ -180,7 +254,8 @@ class _RankingBody extends StatelessWidget {
|
||||
Widget build(BuildContext context) => FutureBuilder<FpiRanking>(
|
||||
future: future,
|
||||
builder: (context, snap) {
|
||||
if (snap.connectionState != ConnectionState.done) {
|
||||
// 다음 장을 읽는 동안에는 앞 장을 그대로 둔다 — 바퀴로 바꾸면 스크롤 자리가 사라진다.
|
||||
if (!snap.hasData && snap.connectionState != ConnectionState.done) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
final data = snap.data;
|
||||
@@ -189,84 +264,115 @@ class _RankingBody extends StatelessWidget {
|
||||
}
|
||||
final podium = scopeLabel != null && data.rows.length >= 3;
|
||||
final rows = podium ? data.rows.skip(3).toList() : data.rows;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
// 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다.
|
||||
if (data.refreshMinutes case final minutes?) ...[
|
||||
Text(
|
||||
[
|
||||
if (data.updatedAt case final at?)
|
||||
context.l10n.planetLastUpdated(
|
||||
'${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
context.l10n.rankRefreshEvery('$minutes'),
|
||||
].join(' · '),
|
||||
style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
if (data.mine case final me?) ...[
|
||||
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n.metrics.extentAfter < 200) onEnd?.call();
|
||||
return false;
|
||||
},
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
// 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다.
|
||||
if (data.refreshMinutes case final minutes?) ...[
|
||||
Text(
|
||||
[
|
||||
if (data.updatedAt case final at?)
|
||||
context.l10n.planetLastUpdated(
|
||||
'${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
context.l10n.rankRefreshEvery('$minutes'),
|
||||
].join(' · '),
|
||||
style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
],
|
||||
if (data.mine case final me?) ...[
|
||||
Text(
|
||||
myLabel,
|
||||
style: _rt(24, AppColors.onCosmic, FontWeight.w500),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s4),
|
||||
Material(
|
||||
color: const Color(0xFF19191C),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: _RankRow(row: me, index: -1, planet: planets, mark: false),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (scopeLabel case final label?) ...[
|
||||
InkWell(
|
||||
onTap: onPickScope,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: _rt(
|
||||
18,
|
||||
AppColors.onCosmic,
|
||||
FontWeight.w600,
|
||||
height: null,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onTapMine == null ? null : () => onTapMine!(me),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
child: _RankRow(
|
||||
row: me,
|
||||
index: -1,
|
||||
planet: planets,
|
||||
flag: flags,
|
||||
mark: false,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 20,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (data.rows.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Center(child: Text(emptyLabel)),
|
||||
),
|
||||
if (podium) ...[
|
||||
_Podium(rows: data.rows.take(3).toList()),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
for (final (i, row) in rows.indexed)
|
||||
InkWell(
|
||||
onTap: onTapRow == null ? null : () => onTapRow!(row),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: _RankRow(row: row, index: i),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
if (scopeLabel case final label?) ...[
|
||||
InkWell(
|
||||
onTap: onPickScope,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: _rt(
|
||||
18,
|
||||
AppColors.onCosmic,
|
||||
FontWeight.w600,
|
||||
height: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
size: 20,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
if (data.rows.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 48),
|
||||
child: Center(child: Text(emptyLabel)),
|
||||
),
|
||||
if (podium) ...[
|
||||
_Podium(rows: data.rows.take(3).toList()),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
for (final (i, row) in rows.indexed)
|
||||
InkWell(
|
||||
onTap: onTapRow == null ? null : () => onTapRow!(row),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: _RankRow(row: row, index: i, planet: planets, flag: flags),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 국가 코드 → 국기 이모지. 미지정(`ZZ`) · 형식 밖은 흰 깃발.
|
||||
String flagOf(String code) {
|
||||
final c = code.toUpperCase();
|
||||
if (c.length != 2 || c == 'ZZ' || !RegExp(r'^[A-Z]{2}$').hasMatch(c)) {
|
||||
return '\u{1F3F3}';
|
||||
}
|
||||
return String.fromCharCodes(c.codeUnits.map((u) => 0x1F1E6 + u - 65));
|
||||
}
|
||||
|
||||
TextStyle _rt(
|
||||
double size,
|
||||
Color color,
|
||||
@@ -302,11 +408,14 @@ const _avatarColors = [
|
||||
Color(0xFFD2F5B8),
|
||||
];
|
||||
|
||||
Widget _avatar(int index, double size, {bool planet = false}) => planet
|
||||
? Image.asset(
|
||||
'assets/figma/planet-detail-hero.png',
|
||||
width: size,
|
||||
height: size,
|
||||
Widget _avatar(int index, double size, {bool planet = false, FpiRankRow? row}) => planet
|
||||
? SizedBox.square(
|
||||
dimension: size,
|
||||
child: PlanetArt(
|
||||
fallback: 'assets/figma/planet-detail-hero.png',
|
||||
planetType: row?.planetType,
|
||||
colorKey: row?.colorKey,
|
||||
),
|
||||
)
|
||||
: Container(
|
||||
width: size,
|
||||
@@ -348,12 +457,14 @@ class _RankRow extends StatelessWidget {
|
||||
required this.row,
|
||||
required this.index,
|
||||
this.planet = false,
|
||||
this.flag = false,
|
||||
this.mark = true,
|
||||
});
|
||||
|
||||
final FpiRankRow row;
|
||||
final int index;
|
||||
final bool planet;
|
||||
final bool flag;
|
||||
|
||||
/// 목록 안 내 행에 「(나)」를 붙인다. 위 내 줄 카드는 시안대로 이름만 둔다.
|
||||
final bool mark;
|
||||
@@ -381,7 +492,14 @@ class _RankRow extends StatelessWidget {
|
||||
// 사본 — 40 프레임 가운데 지름 36 원.
|
||||
SizedBox.square(
|
||||
dimension: 40,
|
||||
child: Center(child: _avatar(index, 36, planet: planet)),
|
||||
child: Center(
|
||||
child: flag
|
||||
? Text(
|
||||
flagOf(row.id),
|
||||
style: const TextStyle(fontSize: 28, height: 1),
|
||||
)
|
||||
: _avatar(index, 36, planet: planet, row: row),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
|
||||
@@ -32,6 +32,26 @@ enum FpiMetric {
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// 팬덤 규모 — Figma 행성 정보 영역 `544:19765` 「소형 : 1,000명 미만 · 중형 : 50,000명 미만 ·
|
||||
/// 대형 : 50,000명 이상」. 기준은 가입자 수다(행성 상세 · GALAXY 검색 결과가 같이 쓴다).
|
||||
enum FandomSize {
|
||||
small,
|
||||
medium,
|
||||
large;
|
||||
|
||||
static FandomSize of(int members) => members < 1000
|
||||
? small
|
||||
: members < 50000
|
||||
? medium
|
||||
: large;
|
||||
|
||||
String get label => switch (this) {
|
||||
small => appL10n.planetSizeSmall,
|
||||
medium => appL10n.planetSizeMedium,
|
||||
large => appL10n.planetSizeLarge,
|
||||
};
|
||||
}
|
||||
|
||||
/// 지표별 값. 명세 — *값 · 전체 순위 · 최근 동향 · 내 기여도 상위 값*.
|
||||
class FpiScore {
|
||||
const FpiScore({
|
||||
@@ -93,8 +113,14 @@ class FpiRankRow {
|
||||
required this.value,
|
||||
this.change = 0,
|
||||
this.isMine = false,
|
||||
this.planetType,
|
||||
this.colorKey,
|
||||
});
|
||||
|
||||
/// 행성 랭킹 줄의 행성 그림(Figma `546:14629` 「행성 이미지」) — 생성 때 고른 형태 · 색. 없으면 기본 그림.
|
||||
final String? planetType;
|
||||
final String? colorKey;
|
||||
|
||||
/// 행성 랭킹이면 `fandomId`, 사용자 랭킹이면 `uid`, 국가 랭킹이면 국가 코드.
|
||||
final String id;
|
||||
|
||||
@@ -272,8 +298,13 @@ class PlanetDetail {
|
||||
this.growthPop = 0,
|
||||
this.nebulaTargetPop = 1000,
|
||||
this.nebulaTargetMembers = 10,
|
||||
this.isOwner = false,
|
||||
});
|
||||
|
||||
/// 내가 만든 행성/성운인가(`fandoms.founderUid`). Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우
|
||||
/// hide」 · `543:19721` 「(타인 행성) 신고/차단 · (내 행성) 편집」 — 편집은 C-50 으로 두지 않는다.
|
||||
final bool isOwner;
|
||||
|
||||
/// 성운 → 정식 행성 전환 조건 — 서버와 같은 `config/policy`(원장 B-10 · `nebulaMinPop` · `nebulaMinMembers`).
|
||||
final int nebulaTargetPop;
|
||||
final int nebulaTargetMembers;
|
||||
|
||||
@@ -148,7 +148,8 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
actions: [
|
||||
IconButton(
|
||||
tooltip: context.l10n.commonMore,
|
||||
onPressed: () => _showMore(context, d.name),
|
||||
onPressed: () =>
|
||||
_showMore(context, d.name, mine: d.isOwner),
|
||||
icon: const ImageIcon(
|
||||
AppIcons.ellipsisVertical,
|
||||
size: 24,
|
||||
@@ -169,6 +170,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
ownerName: d.ownerName,
|
||||
ownerPhoto: d.ownerPhoto,
|
||||
joined: d.joined,
|
||||
isOwner: d.isOwner,
|
||||
onJoin: d.joined ? _leave : _join,
|
||||
onOpenHistory: () => Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
@@ -209,7 +211,9 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
},
|
||||
);
|
||||
|
||||
void _showMore(BuildContext context, String name) =>
|
||||
/// Figma `543:19721` — 더보기: (타인 행성) 신고/차단 · (내 행성) 편집. 편집은 행성 사용자 커스텀이라
|
||||
/// 두지 않는다(원장 C-50 「생성 후 사용자가 외형을 바꾸는 진입점 0건」). 내 행성에는 신고 · 차단도 없다.
|
||||
void _showMore(BuildContext context, String name, {required bool mine}) =>
|
||||
showActionSheet(context, [
|
||||
// 팬덤 역사는 시안 밖 기능이라 남기되 중립색으로 둔다.
|
||||
ActionSheetItem(context.l10n.planetPassportHistory, () {
|
||||
@@ -222,14 +226,16 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
|
||||
),
|
||||
);
|
||||
}, destructive: false),
|
||||
ActionSheetItem(
|
||||
context.l10n.reportTitle,
|
||||
() => _moderate(context, 'report', name),
|
||||
),
|
||||
ActionSheetItem(
|
||||
context.l10n.commonBlockAction,
|
||||
() => _moderate(context, 'block', name),
|
||||
),
|
||||
if (!mine) ...[
|
||||
ActionSheetItem(
|
||||
context.l10n.reportTitle,
|
||||
() => _moderate(context, 'report', name),
|
||||
),
|
||||
ActionSheetItem(
|
||||
context.l10n.commonBlockAction,
|
||||
() => _moderate(context, 'block', name),
|
||||
),
|
||||
],
|
||||
]);
|
||||
|
||||
Future<void> _moderate(
|
||||
@@ -459,6 +465,7 @@ class _FpiTab extends StatelessWidget {
|
||||
EclipseBanner(
|
||||
repository: eclipse,
|
||||
fandomId: detail.fandomId,
|
||||
nebula: detail.isNebula,
|
||||
margin: const EdgeInsets.only(bottom: AppSpace.s3),
|
||||
),
|
||||
Container(
|
||||
|
||||
@@ -177,6 +177,12 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
final member = memberSnap.data() ?? const <String, dynamic>{};
|
||||
final posts = await postsF;
|
||||
final targets = nebulaTargetsFrom((await policyF).data());
|
||||
// 생성자 = `founderUid`(성운 생성자 · 탄생 전 탈퇴 시 승계자, C-100). 프로필 · 닉네임은 그 멤버 문서에서
|
||||
// 읽는다(Figma `544:19765` 「생성자 프로필 · 닉네임」). 떠났거나 시드 행성이면 줄을 그리지 않는다.
|
||||
final founderUid = fandom.strOr('founderUid');
|
||||
final founder = founderUid.isEmpty
|
||||
? null
|
||||
: (await db.doc('fandoms/$fandomId/members/$founderUid').get()).data();
|
||||
|
||||
final axes = _map(fandom, 'fpiAxes');
|
||||
final ranks = _map(fandom, 'fpiRanks');
|
||||
@@ -199,6 +205,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
_ => null,
|
||||
};
|
||||
return PlanetDetail(
|
||||
isOwner: founderUid.isNotEmpty && founderUid == uid,
|
||||
ownerName: founder == null ? null : publicName(founder['nickname']),
|
||||
joined: memberSnap.exists,
|
||||
stage: stage,
|
||||
fandomId: fandomId,
|
||||
@@ -325,6 +333,8 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
// 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다.
|
||||
change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank,
|
||||
isMine: id == fandomId,
|
||||
planetType: data['planetType'] is String ? data['planetType'] as String : null,
|
||||
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -335,7 +345,11 @@ class FirestorePlanetRepository implements PlanetRepository {
|
||||
required String fandomId,
|
||||
int limit = 30,
|
||||
}) async {
|
||||
final ranking = await _userRanking(metric: metric, fandomId: fandomId, limit: limit);
|
||||
final ranking = await _userRanking(
|
||||
metric: metric,
|
||||
fandomId: fandomId,
|
||||
limit: limit,
|
||||
);
|
||||
final fandom = (await _backend.db.doc('fandoms/$fandomId').get()).data();
|
||||
return ranking.withUpdate(_time(fandom?['memberRankedAt']), 5);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../core/theme/tokens.dart';
|
||||
import '../planet_data.dart';
|
||||
|
||||
/// 팬덤 규모 칩 — Figma 「대형 팬덤」 `#AEC054` · 「중형 / 소형 팬덤」 `#54C07D`(바탕 10%, r4).
|
||||
/// 행성 상세 히어로는 12/500 + 1px 테두리, GALAXY 목록은 10/500.
|
||||
class FandomSizeBadge extends StatelessWidget {
|
||||
const FandomSizeBadge({super.key, required this.members, this.large = false});
|
||||
|
||||
final int members;
|
||||
|
||||
/// 행성 상세 히어로 크기(12 · 테두리).
|
||||
final bool large;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final size = FandomSize.of(members);
|
||||
final color = size == FandomSize.large
|
||||
? const Color(0xFFAEC054)
|
||||
: const Color(0xFF54C07D);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
border: large ? Border.all(color: color) : null,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
size.label,
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: large ? 12 : 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import '../../../core/ui/live_planet.dart';
|
||||
import '../../../core/data/planet_visual_state.dart';
|
||||
import '../../home/home_screen.dart' show formatPop;
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import 'fandom_size_badge.dart';
|
||||
|
||||
/// 행성 상세 히어로 — 시안 `373:441117` 의 `Frame 1984081117`(375×543).
|
||||
///
|
||||
@@ -24,15 +25,20 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
this.nebula = false,
|
||||
this.onHeroParticipate,
|
||||
this.onJoin,
|
||||
this.isOwner = false,
|
||||
this.onOpenHistory,
|
||||
this.fandomId = '',
|
||||
this.watchPlanet = noPlanetWatch,
|
||||
this.stage,
|
||||
this.ownerName,
|
||||
this.ownerPhoto,
|
||||
this.sizeLabel,
|
||||
this.onOpenOwner,
|
||||
});
|
||||
|
||||
/// 개설자 줄을 눌렀을 때 — Figma `544:19765` 「프로필 및 닉네임 클릭 시 해당 사용자의 MY로 이동」.
|
||||
/// 타인 프로필 화면(`549:21969`)이 생기면 연결한다(3차 MY). null 이면 누르지 않는다.
|
||||
final VoidCallback? onOpenOwner;
|
||||
|
||||
final VoidCallback? onOpenHistory;
|
||||
final String fandomId;
|
||||
final PlanetWatch watchPlanet;
|
||||
@@ -46,6 +52,9 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
/// 알약을 눌렀을 때 — 「가입하기」면 가입, 「가입중」이면 탈퇴 시트. null 이면 상태만 보인다.
|
||||
final VoidCallback? onJoin;
|
||||
|
||||
/// 내가 만든 행성이면 가입 알약을 숨긴다(Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우 hide」).
|
||||
final bool isOwner;
|
||||
|
||||
/// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다.
|
||||
final bool nebula;
|
||||
|
||||
@@ -56,9 +65,6 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
/// 개설자 사진(URL 또는 에셋). 없으면 회색 원.
|
||||
final String? ownerPhoto;
|
||||
|
||||
/// 팬덤 규모 라벨. 정책 §P-08 등급명이 붙기 전까지 시안 문구를 쓴다.
|
||||
final String? sizeLabel;
|
||||
|
||||
static const double designHeight = 543;
|
||||
|
||||
/// 시안의 상태 표시줄 + 내비 높이. 본문은 그 아래 439 안에 가운데 놓인다.
|
||||
@@ -74,7 +80,8 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// 1인당 화력 — 시안 「1.44」. 총 FPI 를 멤버 수로 나눈 값이며 정책 확정 전 표시용이다.
|
||||
// 1인당 화력 — 시안 「1.44」. Figma `544:19765` 정의(「모든 포인트 획득, 소비 활동 및 1인당 활동 값을
|
||||
// 집계한 값」)와 FPI 원천(C-34 활동 기준)이 갈려 해석 확인 중(전수대조 영역A Q-A23). 회신 전 임시로 총 FPI ÷ 멤버 수.
|
||||
final perHead = members == 0 ? 0.0 : totalFpi / members;
|
||||
const info = Color(0xFFDEDEDE);
|
||||
|
||||
@@ -111,31 +118,36 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (ownerName case final owner?) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 13,
|
||||
backgroundColor: const Color(0xFFD9D9D9),
|
||||
backgroundImage: switch (ownerPhoto) {
|
||||
final p? when p.startsWith('http') => NetworkImage(p),
|
||||
final p? => AssetImage(p),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
owner,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.onCosmic,
|
||||
GestureDetector(
|
||||
onTap: onOpenOwner,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 13,
|
||||
backgroundColor: const Color(0xFFD9D9D9),
|
||||
backgroundImage: switch (ownerPhoto) {
|
||||
final p? when p.startsWith('http') => NetworkImage(
|
||||
p,
|
||||
),
|
||||
final p? => AssetImage(p),
|
||||
null => null,
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
owner,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
height: 1.3,
|
||||
letterSpacing: -0.4,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 25),
|
||||
],
|
||||
@@ -159,21 +171,8 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (!nebula) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0x1A54C07D),
|
||||
border: Border.all(color: const Color(0xFF54C07D)),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
sizeLabel ?? context.l10n.planetSizeMedium,
|
||||
style: _body(12, const Color(0xFF54C07D)),
|
||||
),
|
||||
),
|
||||
// 소형 · 중형 · 대형 — 가입자 수 기준(Figma `544:19765`).
|
||||
FandomSizeBadge(members: members, large: true),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Image.asset(
|
||||
@@ -227,31 +226,32 @@ class PlanetDetailHero extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
Semantics(
|
||||
button: onJoin != null,
|
||||
child: GestureDetector(
|
||||
onTap: onJoin,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
joined
|
||||
? context.l10n.planetJoined
|
||||
: context.l10n.planetJoin,
|
||||
style: _body(
|
||||
14,
|
||||
const Color(0xFFD5D7D9),
|
||||
).copyWith(letterSpacing: -0.35),
|
||||
if (!isOwner)
|
||||
Semantics(
|
||||
button: onJoin != null,
|
||||
child: GestureDetector(
|
||||
onTap: onJoin,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1A1A1A),
|
||||
borderRadius: BorderRadius.circular(AppRadius.full),
|
||||
),
|
||||
child: Text(
|
||||
joined
|
||||
? context.l10n.planetJoined
|
||||
: context.l10n.planetJoin,
|
||||
style: _body(
|
||||
14,
|
||||
const Color(0xFFD5D7D9),
|
||||
).copyWith(letterSpacing: -0.35),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -8,10 +9,13 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import 'quest_data.dart';
|
||||
import 'quest_evidence.dart';
|
||||
|
||||
/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`(지도 + 핀 주소 + 「선택 완료」).
|
||||
///
|
||||
/// 지도를 누르면 그 자리에 핀을 꽂고 주소를 보여 준다. 운영이 등록한 장소는 지도 위 표시와
|
||||
/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
|
||||
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
|
||||
/// 운영이 등록한 장소는 지도 위 표시와
|
||||
/// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안:
|
||||
/// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
|
||||
///
|
||||
@@ -42,6 +46,10 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
String _query = '';
|
||||
QuestPlace? _selected;
|
||||
|
||||
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
|
||||
bool _resolving = false;
|
||||
Timer? _settle;
|
||||
|
||||
List<QuestPlace> get _visible => widget.places
|
||||
.where((p) => _query.isEmpty || p.name.contains(_query))
|
||||
.toList();
|
||||
@@ -53,25 +61,65 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
return PlacePickerScreen.fallbackCenter;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_locate();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_settle?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(등록 장소 · 서울시청)에 둔다 —
|
||||
/// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
|
||||
Future<void> _locate() async {
|
||||
try {
|
||||
final fix = await devicePlaceFix();
|
||||
if (!mounted || _selected != null) return;
|
||||
final here = LatLng(fix.lat, fix.lng);
|
||||
_map.move(here, _map.camera.zoom);
|
||||
await _drop(here);
|
||||
} catch (_) {
|
||||
// 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지.
|
||||
}
|
||||
}
|
||||
|
||||
void _choose(QuestPlace place) {
|
||||
setState(() => _selected = place);
|
||||
_settle?.cancel();
|
||||
setState(() {
|
||||
_selected = place;
|
||||
_resolving = false;
|
||||
});
|
||||
if (place.lat != null && place.lng != null) {
|
||||
_map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom);
|
||||
}
|
||||
}
|
||||
|
||||
/// 지도를 누른 자리에 핀 — 주소는 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
|
||||
/// 사용자가 지도를 움직이면 멈춘 뒤 가운데 좌표로 위치를 정한다(제스처가 아닌 이동 — 등록 장소
|
||||
/// 선택 · 현재 위치 이동 — 은 무시).
|
||||
void _moved(MapCamera camera, bool hasGesture) {
|
||||
if (!hasGesture) return;
|
||||
_settle?.cancel();
|
||||
setState(() => _resolving = true);
|
||||
_settle = Timer(const Duration(milliseconds: 400), () => _drop(camera.center));
|
||||
}
|
||||
|
||||
/// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
|
||||
Future<void> _drop(LatLng point) async {
|
||||
final coords =
|
||||
'${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}';
|
||||
setState(
|
||||
() => _selected = QuestPlace(
|
||||
setState(() {
|
||||
_resolving = true;
|
||||
_selected = QuestPlace(
|
||||
id: '',
|
||||
name: coords,
|
||||
lat: point.latitude,
|
||||
lng: point.longitude,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
var address = coords;
|
||||
try {
|
||||
final marks = await Geocoding().placemarkFromCoordinates(
|
||||
@@ -86,14 +134,15 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
|
||||
}
|
||||
if (!mounted || _selected?.lat != point.latitude) return;
|
||||
setState(
|
||||
() => _selected = QuestPlace(
|
||||
setState(() {
|
||||
_resolving = false;
|
||||
_selected = QuestPlace(
|
||||
id: '',
|
||||
name: address,
|
||||
lat: point.latitude,
|
||||
lng: point.longitude,
|
||||
),
|
||||
);
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -140,7 +189,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
options: MapOptions(
|
||||
initialCenter: _center,
|
||||
initialZoom: 15,
|
||||
onTap: (_, point) => _drop(point),
|
||||
onPositionChanged: _moved,
|
||||
),
|
||||
children: [
|
||||
TileLayer(
|
||||
@@ -163,17 +212,20 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (pinned != null &&
|
||||
pinned.lat != null &&
|
||||
pinned.lng != null)
|
||||
Marker(
|
||||
point: LatLng(pinned.lat!, pinned.lng!),
|
||||
],
|
||||
),
|
||||
// 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다.
|
||||
const IgnorePointer(
|
||||
child: Align(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 44),
|
||||
child: SizedBox(
|
||||
width: 38,
|
||||
height: 44,
|
||||
alignment: Alignment.topCenter,
|
||||
child: const CustomPaint(painter: _PinPainter()),
|
||||
child: CustomPaint(painter: _PinPainter()),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
// OSM 타일 이용 조건 — 출처 표기.
|
||||
const Align(
|
||||
@@ -227,7 +279,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: FilledButton(
|
||||
onPressed: pinned == null
|
||||
onPressed: pinned == null || _resolving
|
||||
? null
|
||||
: () => Navigator.of(context).pop(pinned),
|
||||
style: FilledButton.styleFrom(
|
||||
|
||||
@@ -277,11 +277,25 @@ class QuestAdWarpScreen extends StatefulWidget {
|
||||
State<QuestAdWarpScreen> createState() => _QuestAdWarpScreenState();
|
||||
}
|
||||
|
||||
class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
|
||||
class _QuestAdWarpScreenState extends State<QuestAdWarpScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _message(BuildContext context) =>
|
||||
widget.message ??
|
||||
(widget._after ? context.l10n.adWarpAfter : context.l10n.adWarpBefore);
|
||||
|
||||
/// 명세 `518:88645` — 시청 전 *광고 시청을 북돋을 수 있는 짧은 애니메이션*, 시청 후 *서비스에
|
||||
/// 재몰입 할 수 있는 애니메이션*. 행성이 한 번 커지며 반 바퀴 돈다(한 번만 — 반복하지 않는다).
|
||||
late final AnimationController _spin = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..forward();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_spin.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -307,7 +321,17 @@ class _QuestAdWarpScreenState extends State<QuestAdWarpScreen> {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212),
|
||||
AnimatedBuilder(
|
||||
animation: _spin,
|
||||
builder: (_, child) {
|
||||
final t = Curves.easeOutBack.transform(_spin.value);
|
||||
return Transform.rotate(
|
||||
angle: (widget._after ? -1 : 1) * (1 - _spin.value) * 3.14159,
|
||||
child: Transform.scale(scale: 0.8 + 0.2 * t, child: child),
|
||||
);
|
||||
},
|
||||
child: Image.asset('assets/figma/quest-warp-planet.png', width: 212, height: 212),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
// 시안은 405 폭 한 줄이라 375 화면 밖으로 잘린다 — 좌우 16 안에서 가운데 두 줄로.
|
||||
// 행간은 한 줄이면 사본 값 1, 두 줄로 넘어갈 때만 1.4.
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'quest_data.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
import '../../core/ui/sheet_route.dart';
|
||||
|
||||
/// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`.
|
||||
///
|
||||
@@ -51,6 +52,7 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
QuestLink? _link;
|
||||
bool _checkingLink = false;
|
||||
bool _saving = false;
|
||||
int? _myPop;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -75,6 +77,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
widget.repository.spendableBalance().then((v) {
|
||||
if (mounted) setState(() => _myPop = v);
|
||||
}, onError: (_) {});
|
||||
final catalog = await widget.repository.catalog();
|
||||
if (mounted) setState(() => _catalog = catalog);
|
||||
}
|
||||
@@ -107,6 +112,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
? context.l10n.questPreviewHint
|
||||
: _title.text.trim(),
|
||||
categoryName: _activity?.name ?? _category?.name ?? context.l10n.questNoCategory,
|
||||
// 명세 `523:18123` 미리보기 — *입력 상태에 따라 실시간 반영*. 고른 활동의 아이콘을 바로 보인다.
|
||||
type: _activity == null ? QuestType.unknown : QuestType.parse(_activity!.key),
|
||||
author: QuestAuthor.user,
|
||||
stage: QuestStage.mine,
|
||||
goal: _needs('goal') ? _intOf(_goal) : 1,
|
||||
@@ -303,7 +310,12 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.place_outlined),
|
||||
title: Text(_place?.name ?? context.l10n.questPickPlace),
|
||||
// 명세 `523:18270` — *텍스트 넘칠 경우 ... 처리*.
|
||||
title: Text(
|
||||
_place?.name ?? context.l10n.questPickPlace,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _pickPlace,
|
||||
),
|
||||
@@ -380,6 +392,20 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant),
|
||||
),
|
||||
// 명세 `523:18176` 완료 보상 설정 영역 — *보유 POP 값*.
|
||||
if (_myPop != null)
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
text: context.l10n.commonMyPop,
|
||||
children: [
|
||||
TextSpan(
|
||||
text: formatPop(_myPop!),
|
||||
style: const TextStyle(color: AppColors.cosmicBlue),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.onCosmicVariant),
|
||||
),
|
||||
] else
|
||||
Text(
|
||||
context.l10n.questNoReward,
|
||||
@@ -422,7 +448,8 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
_places = places;
|
||||
}
|
||||
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
|
||||
MaterialPageRoute(builder: (_) => PlacePickerScreen(places: _places)),
|
||||
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
|
||||
SheetRoute(builder: (_) => PlacePickerScreen(places: _places)),
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
setState(() => _place = picked);
|
||||
@@ -448,8 +475,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
||||
final go = await showConfirmDialog(
|
||||
context,
|
||||
context.l10n.questCreateConfirm,
|
||||
cancelLabel: context.l10n.commonKeepEditing,
|
||||
confirmLabel: context.l10n.questCreate,
|
||||
// 명세 `523:18176` 생성 안내 모달 — *취소 / 완료*(시안 `548:19548` · `548:19552`).
|
||||
cancelLabel: context.l10n.commonCancel,
|
||||
confirmLabel: context.l10n.commonDone,
|
||||
body: context.l10n.questCreateCost(
|
||||
_preview.title,
|
||||
formatPop(_intOf(_reward)),
|
||||
|
||||
@@ -105,6 +105,18 @@ enum QuestType {
|
||||
_ => QuestType.unknown,
|
||||
};
|
||||
|
||||
/// 목록 · 상세의 **카테고리** 값 — 명세 `518:88395` · `518:88417` *퀘스트 카테고리 - 등록된 카테고리값*
|
||||
/// (시안 「건강」 · 「커뮤니티」 · 「출석체크」). 활동이 속한 분류는 `config/questCatalog` 기본값
|
||||
/// (`functions/src/quest/ugc.ts` — walk · shake → health, vote · sns · streaming → planet …)과 같다.
|
||||
/// 출석 · 타임어택은 카탈로그 분류가 없어 유형 이름을 쓴다.
|
||||
String get category => switch (this) {
|
||||
steps || musicShake => appL10n.questCatHealth,
|
||||
streaming || vote || sns => appL10n.questCatPlanet,
|
||||
community => appL10n.questCatCommunity,
|
||||
place => appL10n.questTypePlace,
|
||||
_ => label,
|
||||
};
|
||||
|
||||
/// 사람 검토형(C-08) — 참여가 곧 지급이 아니라 제출이다.
|
||||
bool get reviewed => this == QuestType.vote || this == QuestType.sns;
|
||||
|
||||
@@ -315,6 +327,14 @@ class QuestSubmission {
|
||||
now.difference(submittedAt!) > Duration(hours: hours);
|
||||
}
|
||||
|
||||
/// 사용자 생성 Quest 의 모집 인원이 다 찼는가 — 명세 `523:18176` *참여 인원 충족 시 퀘스트 종료*.
|
||||
/// 서버 참여 수(`participantCount`)는 지급 · 검토 예약 때 오르고 최종 반려 때 내려간다.
|
||||
bool questFull(QuestCard card) =>
|
||||
card.author == QuestAuthor.user &&
|
||||
card.capacity != null &&
|
||||
card.capacity! > 0 &&
|
||||
card.participants >= card.capacity!;
|
||||
|
||||
/// 목록 카드 하나.
|
||||
class QuestCard {
|
||||
const QuestCard({
|
||||
@@ -333,12 +353,17 @@ class QuestCard {
|
||||
this.ugcAdPop = 10,
|
||||
this.endsAt,
|
||||
this.publishState = '',
|
||||
this.authorName = '',
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String title;
|
||||
final String categoryName;
|
||||
|
||||
/// 만든 사람의 공개 닉네임(`quests.authorName`). 운영 Quest 는 빈 값이다.
|
||||
/// 신고하기 「신고 정보 영역 — 작성자 닉네임」(Figma `548:21389`)에 쓴다.
|
||||
final String authorName;
|
||||
|
||||
/// 내가 만든 Quest 의 공개 상태(`quests.status`) — `review` · `published` · `rejected` 등.
|
||||
/// 「내 퀘스트」 카드가 검수 중 · 반려를 공개된 것과 구분해 보인다(10/6 실기기 4-2).
|
||||
final String publishState;
|
||||
@@ -398,6 +423,7 @@ class QuestCard {
|
||||
adMultiplier: adMultiplier,
|
||||
ugcAdPop: ugcAdPop,
|
||||
endsAt: endsAt,
|
||||
authorName: authorName,
|
||||
publishState: publishState,
|
||||
);
|
||||
}
|
||||
@@ -508,9 +534,11 @@ enum QuestTab {
|
||||
|
||||
bool accepts(QuestCard card) => switch (this) {
|
||||
QuestTab.all => true,
|
||||
QuestTab.inProgress => card.stage == QuestStage.inProgress,
|
||||
QuestTab.done =>
|
||||
card.stage == QuestStage.completed || card.stage == QuestStage.rewarded,
|
||||
// 명세 `518:88405` — *진행중 - 진행중 탭 / 진행완료 - 진행중 탭 / 보상완료 - 완료 탭*.
|
||||
// 보상을 받기 전(진행완료)까지는 진행중 탭에 두고, 받은 뒤에 완료 탭으로 옮긴다.
|
||||
QuestTab.inProgress =>
|
||||
card.stage == QuestStage.inProgress || card.stage == QuestStage.completed,
|
||||
QuestTab.done => card.stage == QuestStage.rewarded,
|
||||
QuestTab.mine => card.stage == QuestStage.mine,
|
||||
};
|
||||
}
|
||||
@@ -610,6 +638,15 @@ abstract interface class QuestRepository {
|
||||
/// 모르면 null(헤더는 받은 값을 유지한다).
|
||||
Future<int?> popBalance();
|
||||
|
||||
/// 퀘스트 생성에 쓸 수 있는 보유 POP(일반 + 기여 — 서버 예치 `spendSplit` 과 같은 합). 명세
|
||||
/// `523:18176` 완료 보상 설정 영역 *보유 POP 값*. 모르면 null.
|
||||
Future<int?> spendableBalance();
|
||||
|
||||
/// 오늘 광고 추가 보상을 더 받을 수 있는가(일 한도 — B-03 · 10/3 통합회신 4절 「일 5회」).
|
||||
/// 별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」 — 다 쓴 사람에게는 보상 안내 모달을 띄우지 않는다.
|
||||
/// 판정은 서버(`fnAdReward`)가 다시 한다. 읽지 못하면 true(모달을 막지 않는다).
|
||||
Future<bool> adAvailable();
|
||||
|
||||
/// 진행 중 실측 진행도. 걸음 퀘스트만 값을 낸다.
|
||||
///
|
||||
/// 명세 `518:88441` 의 *「시작하기」 버튼 진행중으로 변경 후 실시간 목표 실행률
|
||||
@@ -852,6 +889,12 @@ class SampleQuestRepository implements QuestRepository {
|
||||
@override
|
||||
Future<int?> popBalance() async => null;
|
||||
|
||||
@override
|
||||
Future<int?> spendableBalance() async => null;
|
||||
|
||||
@override
|
||||
Future<bool> adAvailable() async => true;
|
||||
|
||||
/// **표본은 센서를 읽지 않는다.** 여기서 걸음 수를 지어내면 화면이 카드에 적힌
|
||||
/// 진행도 대신 만들어 낸 값을 그리게 되고, 목 모드 확인물이 실측처럼 보인다.
|
||||
@override
|
||||
|
||||
@@ -26,6 +26,7 @@ import 'quest_reward_screen.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import 'widgets/time_attack_countdown.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/sheet_route.dart';
|
||||
|
||||
/// 퀘스트 상세 — 화면 대조 원장 F4.
|
||||
///
|
||||
@@ -118,6 +119,9 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
// 명세 `548:19275` — 더보기는 *사용자 퀘스트 중 타인 퀘스트에서만 노출*. 내 퀘스트의
|
||||
// 수정 · 취소는 확정 정책(C-07 · B-30)이 더한 항목이라 생성자에게도 둔다. 운영 Quest 는 없음.
|
||||
if (ugc)
|
||||
PopupMenuButton<String>(
|
||||
tooltip: context.l10n.commonMore,
|
||||
icon: const ImageIcon(
|
||||
@@ -128,7 +132,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
onSelected: (value) => switch (value) {
|
||||
'edit' => _edit(detail),
|
||||
'cancel' => _cancel(detail),
|
||||
_ => _report(card.title),
|
||||
_ => _report(card),
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
// 공개 후 수정은 생성자의 제목 · 설명뿐이다(C-07).
|
||||
@@ -146,10 +150,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
value: 'cancel',
|
||||
child: Text(context.l10n.questCancel),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'report',
|
||||
child: Text(context.l10n.reportTitle),
|
||||
),
|
||||
if (stage != QuestStage.mine)
|
||||
PopupMenuItem(
|
||||
value: 'report',
|
||||
child: Text(context.l10n.reportTitle),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -406,9 +411,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
detail,
|
||||
stage,
|
||||
blocked,
|
||||
// 정원이 찬 퀘스트도 종료다(명세 `523:18176` 종료 시점 1) — 시작 전인 사람만 막힌다.
|
||||
closed:
|
||||
detail.endsAt != null &&
|
||||
!ServerClock.now().isBefore(detail.endsAt!),
|
||||
(detail.endsAt != null &&
|
||||
!ServerClock.now().isBefore(detail.endsAt!)) ||
|
||||
(questFull(card) && stage == QuestStage.notStarted),
|
||||
)
|
||||
: TimeAttackCountdown(
|
||||
endsAt: endsAt,
|
||||
@@ -959,9 +966,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
|
||||
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
|
||||
// 광고가 실패해도 기본 보상은 그대로다.
|
||||
final bonus =
|
||||
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ??
|
||||
false;
|
||||
// 일 한도를 다 쓴 사람에게는 모달을 띄우지 않는다(별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」) —
|
||||
// 띄우면 광고를 끝까지 보고도 증분 0 이다.
|
||||
final adOpen = await widget.repository.adAvailable();
|
||||
if (!mounted) return;
|
||||
final bonus = adOpen &&
|
||||
(await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ??
|
||||
false);
|
||||
if (!mounted) return;
|
||||
int? adPop;
|
||||
if (bonus) {
|
||||
@@ -979,6 +990,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
if (mounted) _toast(error.message);
|
||||
}
|
||||
if (!mounted) return;
|
||||
} else if (_adShown) {
|
||||
// 명세 `518:88645` — 광고 종료(100% 시청 또는 skip) 후 *광고 시청 후 애니메이션*. 끝까지
|
||||
// 보지 않았으니 증분은 없다(B-03) — 애니메이션만 거쳐 완료 화면으로 간다.
|
||||
await _warp(QuestAdWarpScreen.after(
|
||||
until: Future<void>.delayed(const Duration(milliseconds: 1200)),
|
||||
));
|
||||
if (!mounted) return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1003,7 +1021,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
|
||||
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
|
||||
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
|
||||
/// 이번 보상에서 광고가 실제로 화면에 떴는가 — skip 으로 닫았을 때도 시청 후 애니메이션을 거친다.
|
||||
bool _adShown = false;
|
||||
|
||||
Future<String?> _watchAd(QuestReward reward) async {
|
||||
_adShown = false;
|
||||
RewardedAdHandle? ad;
|
||||
BackendException? failure;
|
||||
final loading = widget.repository
|
||||
@@ -1030,6 +1052,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
_adShown = true;
|
||||
final impression = await loaded.show();
|
||||
if (impression == null && mounted) {
|
||||
_toast(context.l10n.adNotFinished);
|
||||
@@ -1065,14 +1088,16 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
|
||||
rootNavigator: true,
|
||||
).push<void>(MaterialPageRoute(builder: (_) => screen));
|
||||
|
||||
Future<void> _report(String title) async {
|
||||
Future<void> _report(QuestCard card) async {
|
||||
final reason = await Navigator.of(context, rootNavigator: true)
|
||||
.push<String>(
|
||||
MaterialPageRoute(
|
||||
// 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다.
|
||||
SheetRoute(
|
||||
builder: (_) => ReportScreen(
|
||||
// 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*. 작성자 칸에 제목이 들어가던 것을 바로잡음.
|
||||
target: ModerationTarget(
|
||||
authorName: title,
|
||||
title: context.l10n.questNamed(title),
|
||||
authorName: card.authorName,
|
||||
title: card.title,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -9,6 +9,7 @@ import 'quest_detail_screen.dart';
|
||||
import 'widgets/quest_card_tile.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/ui/bottom_inset.dart';
|
||||
import '../../core/ui/sheet_route.dart';
|
||||
|
||||
/// 퀘스트 목록 — 화면 대조 원장 F4.
|
||||
///
|
||||
@@ -174,9 +175,11 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
|
||||
Future<void> _report(QuestCard card) async {
|
||||
final reason = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
// 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다.
|
||||
SheetRoute(
|
||||
builder: (_) => ReportScreen(
|
||||
target: ModerationTarget(authorName: card.title, title: context.l10n.questNamed(card.title)),
|
||||
// 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*(작성자 칸에 제목이 들어가던 것을 바로잡음).
|
||||
target: ModerationTarget(authorName: card.authorName, title: card.title),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -381,11 +384,14 @@ class _QuestListScreenState extends State<QuestListScreen> {
|
||||
|
||||
Future<void> _create() async {
|
||||
final id = await Navigator.of(context, rootNavigator: true).push<String>(
|
||||
MaterialPageRoute(
|
||||
// 명세 `523:18123` — 닫기는 *하단 move out 페이지 종료*. 아래에서 올라와 아래로 닫힌다.
|
||||
SheetRoute(
|
||||
builder: (_) => QuestCreateScreen(repository: widget.repository),
|
||||
),
|
||||
);
|
||||
if (id != null && mounted) {
|
||||
// 명세 `523:18176` 생성 안내 모달 — *완료 - 퀘스트 Create 후 이전 퀘스트의 내 퀘스트 탭으로 이동*.
|
||||
setState(() => _tab = QuestTab.mine);
|
||||
// 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(1주차 4항 · Q8).
|
||||
ScaffoldMessenger.of(context)
|
||||
..hideCurrentSnackBar()
|
||||
|
||||
@@ -133,7 +133,12 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
} on Exception {
|
||||
progress = const {};
|
||||
}
|
||||
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
|
||||
final card = _card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest));
|
||||
// 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면
|
||||
// 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다.
|
||||
// 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다.
|
||||
if (questFull(card) && card.stage == QuestStage.notStarted) continue;
|
||||
cards.add(card);
|
||||
}
|
||||
return cards;
|
||||
}
|
||||
@@ -198,7 +203,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
return QuestCard(
|
||||
id: id,
|
||||
title: questTitle(quest),
|
||||
categoryName: type.label,
|
||||
categoryName: type.category,
|
||||
type: type,
|
||||
author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user,
|
||||
stage: authorUid == uid
|
||||
@@ -223,6 +228,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
// 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다.
|
||||
endsAt: openTimeAttack(quest)?.endsAt,
|
||||
publishState: authorUid == uid ? quest.strOr('status') : '',
|
||||
authorName: quest.strOr('authorName'),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -513,6 +519,29 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
return wallet.data()?.intOr('activityBalance');
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> adAvailable() async {
|
||||
try {
|
||||
final db = _backend.db;
|
||||
final ad = (await db.doc('users/${_backend.requireUid}/questProgress/ad').get()).data();
|
||||
// 서버 `progress(uid, 'ad', dayKey(now))` 와 같은 규약 — KST 날짜가 다르면 오늘 0회.
|
||||
final today = ServerClock.now().toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
|
||||
if (ad == null || ad['dayKey'] != today) return true;
|
||||
final limit = (await _policy()).intOr('adDailyLimit', 5);
|
||||
return ad.intOr('count') < limit;
|
||||
} on Exception {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<int?> spendableBalance() async {
|
||||
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
|
||||
final data = wallet.data();
|
||||
if (data == null) return null;
|
||||
return data.intOr('activityBalance') + data.intOr('supportBalance');
|
||||
}
|
||||
|
||||
@override
|
||||
Stream<int> progressStream(QuestCard card) {
|
||||
if (card.type != QuestType.steps) return const Stream<int>.empty();
|
||||
|
||||
@@ -59,7 +59,16 @@ class _QuestRewardScreenState extends State<QuestRewardScreen> {
|
||||
|
||||
QuestDetail get detail => widget.detail;
|
||||
int? get adBonusPop => widget.adBonusPop;
|
||||
int? get rewardPop => widget.reward?.rewardedPop ?? detail.card.rewardPop;
|
||||
/// 큰 숫자 — 운영 Quest 에서 광고를 봤으면 기본 보상 + 광고 증분 합계(명세 `518:88685` *광고 시청
|
||||
/// POP = 보상 POP × 2 or ×3*, 배수는 B-03 운영값). 사용자 생성 Quest 는 배수가 아니라 고정 증분
|
||||
/// (B-28 · C-02)이라 기본 보상만 크게 둔다. 두 값 모두 서버 지급액이고, 증분은 아래 줄에 기여 POP 으로
|
||||
/// 따로 적는다(C-67 · C-122 유형 구분).
|
||||
int? get rewardPop {
|
||||
final base = widget.reward?.rewardedPop ?? detail.card.rewardPop;
|
||||
final ad = widget.adBonusPop;
|
||||
if (base == null || ad == null || detail.card.author != QuestAuthor.admin) return base;
|
||||
return base + ad;
|
||||
}
|
||||
|
||||
/// 결과 카드를 PNG 로 굽고 문구 · 해시태그(B-78)와 함께 OS 공유 시트로 보낸다.
|
||||
Future<void> _share(BuildContext button) async {
|
||||
@@ -198,7 +207,10 @@ class _QuestRewardScreenState extends State<QuestRewardScreen> {
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
// 설명 프레임 `518:88326` 「확인 버튼 - 클릭 시, 홈 화면 이동」 — 퀘스트 상세 · 목록까지
|
||||
// 닫고 첫 화면(탭 셸)으로 돌아간다. 퀘스트는 HOME 에서만 열린다.
|
||||
onPressed: () =>
|
||||
Navigator.of(context).popUntil((route) => route.isFirst),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(56),
|
||||
shape: RoundedRectangleBorder(
|
||||
|
||||
+13
-1
@@ -65,6 +65,8 @@
|
||||
"settingsInquiry": "1:1 inquiry",
|
||||
"settingsAppVersion": "App version",
|
||||
"settingsLatest": "Latest",
|
||||
"updateAvailableTitle": "A new version is available",
|
||||
"updateAvailableBody": "Please update FANUZU to the latest version ({version}) from the store.",
|
||||
"settingsLogout": "Log out",
|
||||
"settingsLogoutConfirm": "Do you want to log out?",
|
||||
"settingsWithdraw": "Delete account",
|
||||
@@ -246,6 +248,7 @@
|
||||
"nebulaHeadline": "Name your fandom planet.",
|
||||
"nebulaBody": "If founding members reach the POP goal within {days} days,\nit's born as an official planet!\nMembers who joined the evolution get a commemorative badge.",
|
||||
"nebulaNameTaken": "This name is already taken.",
|
||||
"nebulaNameInvalid": "Use 2–20 letters, numbers or spaces (no symbols, emoji, or double/leading/trailing spaces).",
|
||||
"nebulaNameHint": "Fandom name",
|
||||
"nebulaGoal": "Goal ",
|
||||
"nebulaFounders": " / Founders",
|
||||
@@ -443,6 +446,7 @@
|
||||
"notifKindComment": "Comments",
|
||||
"notifKindCampaign": "Campaign goal",
|
||||
"notifKindQuest": "Quest complete",
|
||||
"notifKindEvolution": "Planet evolution",
|
||||
"notifTargetPost": "post",
|
||||
"notifTargetCampaign": "campaign",
|
||||
"notifTargetQuest": "quest details",
|
||||
@@ -568,6 +572,14 @@
|
||||
"blockUnavailable": "This author can't be blocked.",
|
||||
"commonMe": "Me",
|
||||
"planetSizeMedium": "Mid-size fandom",
|
||||
"planetSizeSmall": "Small fandom",
|
||||
"planetSizeLarge": "Large fandom",
|
||||
"galaxyTabPlanet": "Planets",
|
||||
"galaxyTabNebula": "Nebulae",
|
||||
"galaxySortMembers": "Members",
|
||||
"galaxyJoin": "Join",
|
||||
"galaxyGridView": "Grid view",
|
||||
"galaxyListView": "List view",
|
||||
"planetPerHead": "Power per member: {value}",
|
||||
"planetJoined": "Joined",
|
||||
"planetJoin": "Join",
|
||||
@@ -634,7 +646,7 @@
|
||||
"permLocationNeeded": "Location permission is needed to verify your visit.",
|
||||
"permLocationDenied": "Location permission is off. Allow location access in Settings.",
|
||||
"questRewardReceived": " received!",
|
||||
"questAdBonus": "Ad bonus +{pop} POP",
|
||||
"questAdBonus": "Ad bonus +{pop} Contribution POP",
|
||||
"questParticipantsCount": "Participants: {count}",
|
||||
"questParticipantsCap": "Participants: {count} / {cap}",
|
||||
"nebulaCreateFailed": "Couldn't create the nebula. Please try again later.",
|
||||
|
||||
+13
-1
@@ -65,6 +65,8 @@
|
||||
"settingsInquiry": "Consulta 1:1",
|
||||
"settingsAppVersion": "Versión de la app",
|
||||
"settingsLatest": "Última versión",
|
||||
"updateAvailableTitle": "Hay una nueva versión",
|
||||
"updateAvailableBody": "Actualiza FANUZU a la última versión ({version}) desde la tienda.",
|
||||
"settingsLogout": "Cerrar sesión",
|
||||
"settingsLogoutConfirm": "¿Quieres cerrar sesión?",
|
||||
"settingsWithdraw": "Eliminar cuenta",
|
||||
@@ -246,6 +248,7 @@
|
||||
"nebulaHeadline": "Ponle nombre al planeta de tu fandom.",
|
||||
"nebulaBody": "Si los miembros fundadores alcanzan la meta de POP en {days} días,\n¡nacerá como planeta oficial!\nQuienes participen en la evolución recibirán una insignia.",
|
||||
"nebulaNameTaken": "Este nombre ya está en uso.",
|
||||
"nebulaNameInvalid": "Usa de 2 a 20 letras, números o espacios (sin símbolos, emojis ni espacios dobles o al inicio/final).",
|
||||
"nebulaNameHint": "Nombre del fandom",
|
||||
"nebulaGoal": "Meta ",
|
||||
"nebulaFounders": " / Fundadores",
|
||||
@@ -443,6 +446,7 @@
|
||||
"notifKindComment": "Comentarios",
|
||||
"notifKindCampaign": "Meta de campaña",
|
||||
"notifKindQuest": "Misión completada",
|
||||
"notifKindEvolution": "Evolución del planeta",
|
||||
"notifTargetPost": "la publicación",
|
||||
"notifTargetCampaign": "la campaña",
|
||||
"notifTargetQuest": "el detalle de la misión",
|
||||
@@ -568,6 +572,14 @@
|
||||
"blockUnavailable": "No se puede bloquear a este autor.",
|
||||
"commonMe": "Yo",
|
||||
"planetSizeMedium": "Fandom mediano",
|
||||
"planetSizeSmall": "Fandom pequeño",
|
||||
"planetSizeLarge": "Fandom grande",
|
||||
"galaxyTabPlanet": "Planetas",
|
||||
"galaxyTabNebula": "Nebulosas",
|
||||
"galaxySortMembers": "Miembros",
|
||||
"galaxyJoin": "Unirse",
|
||||
"galaxyGridView": "Vista de cuadrícula",
|
||||
"galaxyListView": "Vista de lista",
|
||||
"planetPerHead": "Poder por miembro: {value}",
|
||||
"planetJoined": "Unido",
|
||||
"planetJoin": "Unirse",
|
||||
@@ -634,7 +646,7 @@
|
||||
"permLocationNeeded": "Se necesita el permiso de ubicación para verificar tu visita.",
|
||||
"permLocationDenied": "El permiso de ubicación está desactivado. Permite el acceso en Ajustes.",
|
||||
"questRewardReceived": " ¡recibidos!",
|
||||
"questAdBonus": "Extra por anuncio +{pop} POP",
|
||||
"questAdBonus": "Extra por anuncio +{pop} POP de contribución",
|
||||
"questParticipantsCount": "Participantes: {count}",
|
||||
"questParticipantsCap": "Participantes: {count} / {cap}",
|
||||
"nebulaCreateFailed": "No se pudo crear la nebulosa. Inténtalo más tarde.",
|
||||
|
||||
+13
-1
@@ -65,6 +65,8 @@
|
||||
"settingsInquiry": "1:1お問い合わせ",
|
||||
"settingsAppVersion": "アプリバージョン",
|
||||
"settingsLatest": "最新バージョン",
|
||||
"updateAvailableTitle": "新しいバージョンがあります",
|
||||
"updateAvailableBody": "ストアでFANUZUを最新バージョン({version})にアップデートしてください。",
|
||||
"settingsLogout": "ログアウト",
|
||||
"settingsLogoutConfirm": "ログアウトしますか?",
|
||||
"settingsWithdraw": "退会",
|
||||
@@ -246,6 +248,7 @@
|
||||
"nebulaHeadline": "ファンダム惑星の名前を決めよう。",
|
||||
"nebulaBody": "創立メンバーが{days}日以内に目標POPを達成すると\n正式な惑星として誕生します!\n進化に参加したメンバーには記念バッジが贈られます。",
|
||||
"nebulaNameTaken": "すでに使用されている名前です。",
|
||||
"nebulaNameInvalid": "2〜20文字のハングル・英字・数字で入力してください(記号・絵文字・連続/前後の空白は不可)。",
|
||||
"nebulaNameHint": "ファンダム名",
|
||||
"nebulaGoal": "目標 ",
|
||||
"nebulaFounders": " / 創立メンバー",
|
||||
@@ -443,6 +446,7 @@
|
||||
"notifKindComment": "コメント",
|
||||
"notifKindCampaign": "キャンペーン達成",
|
||||
"notifKindQuest": "クエスト完了",
|
||||
"notifKindEvolution": "惑星の進化",
|
||||
"notifTargetPost": "投稿",
|
||||
"notifTargetCampaign": "キャンペーン",
|
||||
"notifTargetQuest": "クエスト詳細",
|
||||
@@ -568,6 +572,14 @@
|
||||
"blockUnavailable": "ブロックできない投稿者です。",
|
||||
"commonMe": "自分",
|
||||
"planetSizeMedium": "中規模ファンダム",
|
||||
"planetSizeSmall": "小規模ファンダム",
|
||||
"planetSizeLarge": "大規模ファンダム",
|
||||
"galaxyTabPlanet": "惑星",
|
||||
"galaxyTabNebula": "星雲",
|
||||
"galaxySortMembers": "メンバー数",
|
||||
"galaxyJoin": "参加",
|
||||
"galaxyGridView": "グリッド表示",
|
||||
"galaxyListView": "リスト表示",
|
||||
"planetPerHead": "1人あたり火力:{value}",
|
||||
"planetJoined": "参加中",
|
||||
"planetJoin": "参加する",
|
||||
@@ -634,7 +646,7 @@
|
||||
"permLocationNeeded": "訪問を確認するには位置情報の権限が必要です。",
|
||||
"permLocationDenied": "位置情報の権限がオフです。設定で位置情報へのアクセスを許可してください。",
|
||||
"questRewardReceived": " 受け取りました!",
|
||||
"questAdBonus": "広告追加報酬 +{pop} POP",
|
||||
"questAdBonus": "広告追加報酬 +{pop} 貢献POP",
|
||||
"questParticipantsCount": "参加人数:{count}",
|
||||
"questParticipantsCap": "参加人数:{count} / {cap}",
|
||||
"nebulaCreateFailed": "星雲を作成できませんでした。しばらくしてから再度お試しください。",
|
||||
|
||||
+16
-1
@@ -120,6 +120,11 @@
|
||||
"settingsInquiry": "1:1 문의접수",
|
||||
"settingsAppVersion": "앱 버전",
|
||||
"settingsLatest": "최신 버전",
|
||||
"updateAvailableTitle": "새 버전이 있어요",
|
||||
"updateAvailableBody": "스토어에서 FANUZU 를 최신 버전({version})으로 업데이트해 주세요.",
|
||||
"@updateAvailableBody": {
|
||||
"placeholders": { "version": { "type": "String" } }
|
||||
},
|
||||
"settingsLogout": "로그아웃",
|
||||
"settingsLogoutConfirm": "로그아웃 하시겠습니까?",
|
||||
"settingsWithdraw": "서비스 탈퇴",
|
||||
@@ -577,6 +582,7 @@
|
||||
}
|
||||
},
|
||||
"nebulaNameTaken": "이미 사용중인 이름입니다.",
|
||||
"nebulaNameInvalid": "2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).",
|
||||
"nebulaNameHint": "팬덤 이름",
|
||||
"nebulaGoal": "목표 ",
|
||||
"nebulaFounders": " / 창립멤버",
|
||||
@@ -888,6 +894,7 @@
|
||||
"notifKindComment": "댓글",
|
||||
"notifKindCampaign": "캠페인 달성",
|
||||
"notifKindQuest": "퀘스트 완료",
|
||||
"notifKindEvolution": "행성 진화",
|
||||
"notifTargetPost": "게시물",
|
||||
"notifTargetCampaign": "캠페인",
|
||||
"notifTargetQuest": "퀘스트 상세",
|
||||
@@ -1135,6 +1142,14 @@
|
||||
"blockUnavailable": "차단할 수 없는 작성자입니다.",
|
||||
"commonMe": "나",
|
||||
"planetSizeMedium": "중형 팬덤",
|
||||
"planetSizeSmall": "소형 팬덤",
|
||||
"planetSizeLarge": "대형 팬덤",
|
||||
"galaxyTabPlanet": "행성",
|
||||
"galaxyTabNebula": "성운",
|
||||
"galaxySortMembers": "가입자 수",
|
||||
"galaxyJoin": "가입",
|
||||
"galaxyGridView": "격자 그리드",
|
||||
"galaxyListView": "리스트 그리드",
|
||||
"planetPerHead": "1인당 화력 : {value}",
|
||||
"@planetPerHead": {
|
||||
"placeholders": {
|
||||
@@ -1242,7 +1257,7 @@
|
||||
"permLocationNeeded": "방문을 확인하려면 위치 권한이 필요합니다.",
|
||||
"permLocationDenied": "위치 권한이 꺼져 있습니다. 설정에서 위치 접근을 허용해 주세요.",
|
||||
"questRewardReceived": " 받았어요!",
|
||||
"questAdBonus": "광고 추가 보상 +{pop} POP",
|
||||
"questAdBonus": "광고 추가 보상 +{pop} 기여 POP",
|
||||
"@questAdBonus": {
|
||||
"placeholders": {
|
||||
"pop": {
|
||||
|
||||
@@ -494,6 +494,18 @@ abstract class AppLocalizations {
|
||||
/// **'최신 버전'**
|
||||
String get settingsLatest;
|
||||
|
||||
/// No description provided for @updateAvailableTitle.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'새 버전이 있어요'**
|
||||
String get updateAvailableTitle;
|
||||
|
||||
/// No description provided for @updateAvailableBody.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'스토어에서 FANUZU 를 최신 버전({version})으로 업데이트해 주세요.'**
|
||||
String updateAvailableBody(String version);
|
||||
|
||||
/// No description provided for @settingsLogout.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
@@ -1580,6 +1592,12 @@ abstract class AppLocalizations {
|
||||
/// **'이미 사용중인 이름입니다.'**
|
||||
String get nebulaNameTaken;
|
||||
|
||||
/// No description provided for @nebulaNameInvalid.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).'**
|
||||
String get nebulaNameInvalid;
|
||||
|
||||
/// No description provided for @nebulaNameHint.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
@@ -2762,6 +2780,12 @@ abstract class AppLocalizations {
|
||||
/// **'퀘스트 완료'**
|
||||
String get notifKindQuest;
|
||||
|
||||
/// No description provided for @notifKindEvolution.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'행성 진화'**
|
||||
String get notifKindEvolution;
|
||||
|
||||
/// No description provided for @notifTargetPost.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
@@ -3512,6 +3536,54 @@ abstract class AppLocalizations {
|
||||
/// **'중형 팬덤'**
|
||||
String get planetSizeMedium;
|
||||
|
||||
/// No description provided for @planetSizeSmall.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'소형 팬덤'**
|
||||
String get planetSizeSmall;
|
||||
|
||||
/// No description provided for @planetSizeLarge.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'대형 팬덤'**
|
||||
String get planetSizeLarge;
|
||||
|
||||
/// No description provided for @galaxyTabPlanet.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'행성'**
|
||||
String get galaxyTabPlanet;
|
||||
|
||||
/// No description provided for @galaxyTabNebula.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'성운'**
|
||||
String get galaxyTabNebula;
|
||||
|
||||
/// No description provided for @galaxySortMembers.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'가입자 수'**
|
||||
String get galaxySortMembers;
|
||||
|
||||
/// No description provided for @galaxyJoin.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'가입'**
|
||||
String get galaxyJoin;
|
||||
|
||||
/// No description provided for @galaxyGridView.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'격자 그리드'**
|
||||
String get galaxyGridView;
|
||||
|
||||
/// No description provided for @galaxyListView.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'리스트 그리드'**
|
||||
String get galaxyListView;
|
||||
|
||||
/// No description provided for @planetPerHead.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
@@ -3911,7 +3983,7 @@ abstract class AppLocalizations {
|
||||
/// No description provided for @questAdBonus.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'광고 추가 보상 +{pop} POP'**
|
||||
/// **'광고 추가 보상 +{pop} 기여 POP'**
|
||||
String questAdBonus(String pop);
|
||||
|
||||
/// No description provided for @questParticipantsCount.
|
||||
|
||||
@@ -234,6 +234,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get settingsLatest => 'Latest';
|
||||
|
||||
@override
|
||||
String get updateAvailableTitle => 'A new version is available';
|
||||
|
||||
@override
|
||||
String updateAvailableBody(String version) {
|
||||
return 'Please update FANUZU to the latest version ($version) from the store.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsLogout => 'Log out';
|
||||
|
||||
@@ -894,6 +902,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get nebulaNameTaken => 'This name is already taken.';
|
||||
|
||||
@override
|
||||
String get nebulaNameInvalid =>
|
||||
'Use 2–20 letters, numbers or spaces (no symbols, emoji, or double/leading/trailing spaces).';
|
||||
|
||||
@override
|
||||
String get nebulaNameHint => 'Fandom name';
|
||||
|
||||
@@ -1533,6 +1545,9 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get notifKindQuest => 'Quest complete';
|
||||
|
||||
@override
|
||||
String get notifKindEvolution => 'Planet evolution';
|
||||
|
||||
@override
|
||||
String get notifTargetPost => 'post';
|
||||
|
||||
@@ -1977,6 +1992,30 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
@override
|
||||
String get planetSizeMedium => 'Mid-size fandom';
|
||||
|
||||
@override
|
||||
String get planetSizeSmall => 'Small fandom';
|
||||
|
||||
@override
|
||||
String get planetSizeLarge => 'Large fandom';
|
||||
|
||||
@override
|
||||
String get galaxyTabPlanet => 'Planets';
|
||||
|
||||
@override
|
||||
String get galaxyTabNebula => 'Nebulae';
|
||||
|
||||
@override
|
||||
String get galaxySortMembers => 'Members';
|
||||
|
||||
@override
|
||||
String get galaxyJoin => 'Join';
|
||||
|
||||
@override
|
||||
String get galaxyGridView => 'Grid view';
|
||||
|
||||
@override
|
||||
String get galaxyListView => 'List view';
|
||||
|
||||
@override
|
||||
String planetPerHead(String value) {
|
||||
return 'Power per member: $value';
|
||||
@@ -2199,7 +2238,7 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String questAdBonus(String pop) {
|
||||
return 'Ad bonus +$pop POP';
|
||||
return 'Ad bonus +$pop Contribution POP';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -236,6 +236,14 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get settingsLatest => 'Última versión';
|
||||
|
||||
@override
|
||||
String get updateAvailableTitle => 'Hay una nueva versión';
|
||||
|
||||
@override
|
||||
String updateAvailableBody(String version) {
|
||||
return 'Actualiza FANUZU a la última versión ($version) desde la tienda.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsLogout => 'Cerrar sesión';
|
||||
|
||||
@@ -904,6 +912,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get nebulaNameTaken => 'Este nombre ya está en uso.';
|
||||
|
||||
@override
|
||||
String get nebulaNameInvalid =>
|
||||
'Usa de 2 a 20 letras, números o espacios (sin símbolos, emojis ni espacios dobles o al inicio/final).';
|
||||
|
||||
@override
|
||||
String get nebulaNameHint => 'Nombre del fandom';
|
||||
|
||||
@@ -1541,6 +1553,9 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get notifKindQuest => 'Misión completada';
|
||||
|
||||
@override
|
||||
String get notifKindEvolution => 'Evolución del planeta';
|
||||
|
||||
@override
|
||||
String get notifTargetPost => 'la publicación';
|
||||
|
||||
@@ -1989,6 +2004,30 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
@override
|
||||
String get planetSizeMedium => 'Fandom mediano';
|
||||
|
||||
@override
|
||||
String get planetSizeSmall => 'Fandom pequeño';
|
||||
|
||||
@override
|
||||
String get planetSizeLarge => 'Fandom grande';
|
||||
|
||||
@override
|
||||
String get galaxyTabPlanet => 'Planetas';
|
||||
|
||||
@override
|
||||
String get galaxyTabNebula => 'Nebulosas';
|
||||
|
||||
@override
|
||||
String get galaxySortMembers => 'Miembros';
|
||||
|
||||
@override
|
||||
String get galaxyJoin => 'Unirse';
|
||||
|
||||
@override
|
||||
String get galaxyGridView => 'Vista de cuadrícula';
|
||||
|
||||
@override
|
||||
String get galaxyListView => 'Vista de lista';
|
||||
|
||||
@override
|
||||
String planetPerHead(String value) {
|
||||
return 'Poder por miembro: $value';
|
||||
@@ -2213,7 +2252,7 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String questAdBonus(String pop) {
|
||||
return 'Extra por anuncio +$pop POP';
|
||||
return 'Extra por anuncio +$pop POP de contribución';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -231,6 +231,14 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get settingsLatest => '最新バージョン';
|
||||
|
||||
@override
|
||||
String get updateAvailableTitle => '新しいバージョンがあります';
|
||||
|
||||
@override
|
||||
String updateAvailableBody(String version) {
|
||||
return 'ストアでFANUZUを最新バージョン($version)にアップデートしてください。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsLogout => 'ログアウト';
|
||||
|
||||
@@ -860,6 +868,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get nebulaNameTaken => 'すでに使用されている名前です。';
|
||||
|
||||
@override
|
||||
String get nebulaNameInvalid =>
|
||||
'2〜20文字のハングル・英字・数字で入力してください(記号・絵文字・連続/前後の空白は不可)。';
|
||||
|
||||
@override
|
||||
String get nebulaNameHint => 'ファンダム名';
|
||||
|
||||
@@ -1483,6 +1495,9 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get notifKindQuest => 'クエスト完了';
|
||||
|
||||
@override
|
||||
String get notifKindEvolution => '惑星の進化';
|
||||
|
||||
@override
|
||||
String get notifTargetPost => '投稿';
|
||||
|
||||
@@ -1917,6 +1932,30 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
@override
|
||||
String get planetSizeMedium => '中規模ファンダム';
|
||||
|
||||
@override
|
||||
String get planetSizeSmall => '小規模ファンダム';
|
||||
|
||||
@override
|
||||
String get planetSizeLarge => '大規模ファンダム';
|
||||
|
||||
@override
|
||||
String get galaxyTabPlanet => '惑星';
|
||||
|
||||
@override
|
||||
String get galaxyTabNebula => '星雲';
|
||||
|
||||
@override
|
||||
String get galaxySortMembers => 'メンバー数';
|
||||
|
||||
@override
|
||||
String get galaxyJoin => '参加';
|
||||
|
||||
@override
|
||||
String get galaxyGridView => 'グリッド表示';
|
||||
|
||||
@override
|
||||
String get galaxyListView => 'リスト表示';
|
||||
|
||||
@override
|
||||
String planetPerHead(String value) {
|
||||
return '1人あたり火力:$value';
|
||||
@@ -2128,7 +2167,7 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String questAdBonus(String pop) {
|
||||
return '広告追加報酬 +$pop POP';
|
||||
return '広告追加報酬 +$pop 貢献POP';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -231,6 +231,14 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get settingsLatest => '최신 버전';
|
||||
|
||||
@override
|
||||
String get updateAvailableTitle => '새 버전이 있어요';
|
||||
|
||||
@override
|
||||
String updateAvailableBody(String version) {
|
||||
return '스토어에서 FANUZU 를 최신 버전($version)으로 업데이트해 주세요.';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsLogout => '로그아웃';
|
||||
|
||||
@@ -860,6 +868,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get nebulaNameTaken => '이미 사용중인 이름입니다.';
|
||||
|
||||
@override
|
||||
String get nebulaNameInvalid =>
|
||||
'2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).';
|
||||
|
||||
@override
|
||||
String get nebulaNameHint => '팬덤 이름';
|
||||
|
||||
@@ -1483,6 +1495,9 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get notifKindQuest => '퀘스트 완료';
|
||||
|
||||
@override
|
||||
String get notifKindEvolution => '행성 진화';
|
||||
|
||||
@override
|
||||
String get notifTargetPost => '게시물';
|
||||
|
||||
@@ -1918,6 +1933,30 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
@override
|
||||
String get planetSizeMedium => '중형 팬덤';
|
||||
|
||||
@override
|
||||
String get planetSizeSmall => '소형 팬덤';
|
||||
|
||||
@override
|
||||
String get planetSizeLarge => '대형 팬덤';
|
||||
|
||||
@override
|
||||
String get galaxyTabPlanet => '행성';
|
||||
|
||||
@override
|
||||
String get galaxyTabNebula => '성운';
|
||||
|
||||
@override
|
||||
String get galaxySortMembers => '가입자 수';
|
||||
|
||||
@override
|
||||
String get galaxyJoin => '가입';
|
||||
|
||||
@override
|
||||
String get galaxyGridView => '격자 그리드';
|
||||
|
||||
@override
|
||||
String get galaxyListView => '리스트 그리드';
|
||||
|
||||
@override
|
||||
String planetPerHead(String value) {
|
||||
return '1인당 화력 : $value';
|
||||
@@ -2129,7 +2168,7 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String questAdBonus(String pop) {
|
||||
return '광고 추가 보상 +$pop POP';
|
||||
return '광고 추가 보상 +$pop 기여 POP';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
@@ -231,6 +231,14 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get settingsLatest => '最新版本';
|
||||
|
||||
@override
|
||||
String get updateAvailableTitle => '有新版本';
|
||||
|
||||
@override
|
||||
String updateAvailableBody(String version) {
|
||||
return '请在应用商店将 FANUZU 更新到最新版本($version)。';
|
||||
}
|
||||
|
||||
@override
|
||||
String get settingsLogout => '退出登录';
|
||||
|
||||
@@ -860,6 +868,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get nebulaNameTaken => '该名称已被使用。';
|
||||
|
||||
@override
|
||||
String get nebulaNameInvalid => '请输入2~20个韩文、英文或数字(不可使用特殊字符、表情符号、连续或首尾空格)。';
|
||||
|
||||
@override
|
||||
String get nebulaNameHint => '粉丝圈名称';
|
||||
|
||||
@@ -1482,6 +1493,9 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get notifKindQuest => '任务完成';
|
||||
|
||||
@override
|
||||
String get notifKindEvolution => '星球进化';
|
||||
|
||||
@override
|
||||
String get notifTargetPost => '帖子';
|
||||
|
||||
@@ -1916,6 +1930,30 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
@override
|
||||
String get planetSizeMedium => '中型粉丝圈';
|
||||
|
||||
@override
|
||||
String get planetSizeSmall => '小型粉丝圈';
|
||||
|
||||
@override
|
||||
String get planetSizeLarge => '大型粉丝圈';
|
||||
|
||||
@override
|
||||
String get galaxyTabPlanet => '行星';
|
||||
|
||||
@override
|
||||
String get galaxyTabNebula => '星云';
|
||||
|
||||
@override
|
||||
String get galaxySortMembers => '成员数';
|
||||
|
||||
@override
|
||||
String get galaxyJoin => '加入';
|
||||
|
||||
@override
|
||||
String get galaxyGridView => '网格视图';
|
||||
|
||||
@override
|
||||
String get galaxyListView => '列表视图';
|
||||
|
||||
@override
|
||||
String planetPerHead(String value) {
|
||||
return '人均火力:$value';
|
||||
@@ -2126,7 +2164,7 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String questAdBonus(String pop) {
|
||||
return '广告额外奖励 +$pop POP';
|
||||
return '广告额外奖励 +$pop 贡献 POP';
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
+13
-1
@@ -65,6 +65,8 @@
|
||||
"settingsInquiry": "1:1咨询",
|
||||
"settingsAppVersion": "应用版本",
|
||||
"settingsLatest": "最新版本",
|
||||
"updateAvailableTitle": "有新版本",
|
||||
"updateAvailableBody": "请在应用商店将 FANUZU 更新到最新版本({version})。",
|
||||
"settingsLogout": "退出登录",
|
||||
"settingsLogoutConfirm": "确定要退出登录吗?",
|
||||
"settingsWithdraw": "注销账号",
|
||||
@@ -246,6 +248,7 @@
|
||||
"nebulaHeadline": "为我们的粉丝星球取个名字吧。",
|
||||
"nebulaBody": "创始成员在 {days} 天内达成目标 POP,\n即可诞生为正式星球!\n参与进化的成员将获得纪念徽章。",
|
||||
"nebulaNameTaken": "该名称已被使用。",
|
||||
"nebulaNameInvalid": "请输入2~20个韩文、英文或数字(不可使用特殊字符、表情符号、连续或首尾空格)。",
|
||||
"nebulaNameHint": "粉丝圈名称",
|
||||
"nebulaGoal": "目标 ",
|
||||
"nebulaFounders": " / 创始成员",
|
||||
@@ -443,6 +446,7 @@
|
||||
"notifKindComment": "评论",
|
||||
"notifKindCampaign": "活动达成",
|
||||
"notifKindQuest": "任务完成",
|
||||
"notifKindEvolution": "星球进化",
|
||||
"notifTargetPost": "帖子",
|
||||
"notifTargetCampaign": "活动",
|
||||
"notifTargetQuest": "任务详情",
|
||||
@@ -568,6 +572,14 @@
|
||||
"blockUnavailable": "无法屏蔽该作者。",
|
||||
"commonMe": "我",
|
||||
"planetSizeMedium": "中型粉丝圈",
|
||||
"planetSizeSmall": "小型粉丝圈",
|
||||
"planetSizeLarge": "大型粉丝圈",
|
||||
"galaxyTabPlanet": "行星",
|
||||
"galaxyTabNebula": "星云",
|
||||
"galaxySortMembers": "成员数",
|
||||
"galaxyJoin": "加入",
|
||||
"galaxyGridView": "网格视图",
|
||||
"galaxyListView": "列表视图",
|
||||
"planetPerHead": "人均火力:{value}",
|
||||
"planetJoined": "已加入",
|
||||
"planetJoin": "加入",
|
||||
@@ -634,7 +646,7 @@
|
||||
"permLocationNeeded": "需要定位权限以确认到访。",
|
||||
"permLocationDenied": "定位权限已关闭,请在设置中允许访问位置。",
|
||||
"questRewardReceived": " 已领取!",
|
||||
"questAdBonus": "广告额外奖励 +{pop} POP",
|
||||
"questAdBonus": "广告额外奖励 +{pop} 贡献 POP",
|
||||
"questParticipantsCount": "参与人数:{count}",
|
||||
"questParticipantsCap": "参与人数:{count} / {cap}",
|
||||
"nebulaCreateFailed": "无法创建星云,请稍后再试。",
|
||||
|
||||
@@ -727,6 +727,15 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String galaxyErosion(String rate, String progress) => _fill('galaxyErosion', {'rate': rate, 'progress': progress}) ?? _base.galaxyErosion(rate, progress);
|
||||
|
||||
@override
|
||||
String get galaxyGridView => _remote['galaxyGridView'] ?? _base.galaxyGridView;
|
||||
|
||||
@override
|
||||
String get galaxyJoin => _remote['galaxyJoin'] ?? _base.galaxyJoin;
|
||||
|
||||
@override
|
||||
String get galaxyListView => _remote['galaxyListView'] ?? _base.galaxyListView;
|
||||
|
||||
@override
|
||||
String get galaxyLoadFailed => _remote['galaxyLoadFailed'] ?? _base.galaxyLoadFailed;
|
||||
|
||||
@@ -757,6 +766,15 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get galaxySetPrimaryConfirm => _remote['galaxySetPrimaryConfirm'] ?? _base.galaxySetPrimaryConfirm;
|
||||
|
||||
@override
|
||||
String get galaxySortMembers => _remote['galaxySortMembers'] ?? _base.galaxySortMembers;
|
||||
|
||||
@override
|
||||
String get galaxyTabNebula => _remote['galaxyTabNebula'] ?? _base.galaxyTabNebula;
|
||||
|
||||
@override
|
||||
String get galaxyTabPlanet => _remote['galaxyTabPlanet'] ?? _base.galaxyTabPlanet;
|
||||
|
||||
@override
|
||||
String get growthCheckConditions => _remote['growthCheckConditions'] ?? _base.growthCheckConditions;
|
||||
|
||||
@@ -1294,6 +1312,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get nebulaNameTaken => _remote['nebulaNameTaken'] ?? _base.nebulaNameTaken;
|
||||
|
||||
@override
|
||||
String get nebulaNameInvalid => _remote['nebulaNameInvalid'] ?? _base.nebulaNameInvalid;
|
||||
|
||||
@override
|
||||
String get nicknameEdit => _remote['nicknameEdit'] ?? _base.nicknameEdit;
|
||||
|
||||
@@ -1330,6 +1351,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get notifKindEclipse => _remote['notifKindEclipse'] ?? _base.notifKindEclipse;
|
||||
|
||||
@override
|
||||
String get notifKindEvolution => _remote['notifKindEvolution'] ?? _base.notifKindEvolution;
|
||||
|
||||
@override
|
||||
String get notifKindItemUsed => _remote['notifKindItemUsed'] ?? _base.notifKindItemUsed;
|
||||
|
||||
@@ -1630,9 +1654,15 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String planetShape(String n) => _fill('planetShape', {'n': n}) ?? _base.planetShape(n);
|
||||
|
||||
@override
|
||||
String get planetSizeLarge => _remote['planetSizeLarge'] ?? _base.planetSizeLarge;
|
||||
|
||||
@override
|
||||
String get planetSizeMedium => _remote['planetSizeMedium'] ?? _base.planetSizeMedium;
|
||||
|
||||
@override
|
||||
String get planetSizeSmall => _remote['planetSizeSmall'] ?? _base.planetSizeSmall;
|
||||
|
||||
@override
|
||||
String get planetStageUnknown => _remote['planetStageUnknown'] ?? _base.planetStageUnknown;
|
||||
|
||||
@@ -2629,6 +2659,12 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get unknownUser => _remote['unknownUser'] ?? _base.unknownUser;
|
||||
|
||||
@override
|
||||
String updateAvailableBody(String version) => _fill('updateAvailableBody', {'version': version}) ?? _base.updateAvailableBody(version);
|
||||
|
||||
@override
|
||||
String get updateAvailableTitle => _remote['updateAvailableTitle'] ?? _base.updateAvailableTitle;
|
||||
|
||||
@override
|
||||
String get zapApplying => _remote['zapApplying'] ?? _base.zapApplying;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user