Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림

This commit is contained in:
Sn-Kinos
2026-10-06 03:03:20 +09:00
parent 64825e72b0
commit ecf5998725
77 changed files with 2703 additions and 640 deletions
@@ -3,10 +3,11 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/cosmic_void" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- 스플래시 로고 — Figma `510:19047` 「화면 중앙 정렬 · 앱 실행 시 노출」. 발주사 인수 자산
`고객사 준비 데모/LOGO/fanuzu logo only.png` 의 마크만 잘라 쓴다. -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_logo" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

@@ -3,10 +3,11 @@
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@color/cosmic_void" />
<!-- You can insert your own image assets here -->
<!-- <item>
<!-- 스플래시 로고 — Figma `510:19047` 「화면 중앙 정렬 · 앱 실행 시 노출」. 발주사 인수 자산
`고객사 준비 데모/LOGO/fanuzu logo only.png` 의 마크만 잘라 쓴다. -->
<item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
android:src="@drawable/launch_logo" />
</item>
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

+7
View File
@@ -309,6 +309,13 @@ service cloud.firestore {
// 타인 배지가 실제로 보이는 곳은 S-M-HERO 히어로 랭킹 하나뿐이고,
// 그것은 /fandoms/{f}/members/{uid}.honorBadgeIds 비정규화 필드로 읽는다(설계서 §2.3·§4.4).
// 랭킹 하나 보여주려고 타인 문서를 통째로 여는 경로를 닫는다.
// 성운 일 1회 기록(`fn-nebula-create` 가 쓴다). GALAXY 「성운 생성」 버튼이 오늘 이미 만들었는지
// 보고 비활성화 · 초과 안내를 띄운다(Figma `540:19592`). 쓰기는 서버만.
match /users/{uid}/nebulaCreates/{day} {
allow read: if isSelf(uid);
allow write: if false;
}
match /users/{uid}/badges/{badgeId} {
allow read: if isSelf(uid) || isViewer();
allow write: if false;
@@ -105,6 +105,9 @@ export const fnAdminWrite = onCall(async (req) => {
await ref.set({
...(patch as Record<string, unknown>),
// 처음 쓰는 문서에는 생성 시각을 남긴다 — 퀘스트 목록 *퀘스트 생성 최신순 소팅*(Figma `518:88395`)의
// 정렬 키. 예전 문서는 `updatedAt` 만 있어 수정할 때마다 순서가 바뀌었다.
...(before.exists ? {} : { createdAt: FieldValue.serverTimestamp() }),
updatedAt: FieldValue.serverTimestamp(),
}, { merge: true });
@@ -67,3 +67,13 @@ it('master 도 원장 · 지표 · 감사 기록과 서버 산출 필드는 못
expect(doc.get('fpiScore')).toBeUndefined();
await getFirestore().doc('fandoms/admin-write-test').delete();
});
// 퀘스트 목록 *퀘스트 생성 최신순*(Figma `518:88395`) — 처음 쓴 문서만 생성 시각을 받고, 수정은 그대로 둔다.
it('처음 쓰는 문서에는 createdAt 을 찍고 다시 써도 바꾸지 않는다', async () => {
const ref = getFirestore().doc('notices/admin-write-test');
await run('ops', ref.path, { title: '첫' });
const first = (await ref.get()).get('createdAt');
expect(first).toBeDefined();
await run('ops', ref.path, { title: '둘' });
expect((await ref.get()).get('createdAt').isEqual(first)).toBe(true);
});
@@ -1772,3 +1772,15 @@ describe('테스트 자가 점검', () => {
expect(testEnvironment.projectId).toMatch(/^demo-/);
});
});
describe('성운 일 1회 기록 — GALAXY 「성운 생성」 버튼(Figma 540:19592)', () => {
test('본인 기록은 읽고, 남의 기록은 못 읽고, 아무도 쓰지 못한다', async () => {
await seedDocuments(testEnvironment, {
'users/alice/nebulaCreates/2026-10-06': { fandomId: 'f1' },
});
await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-06')));
await assertSucceeds(getDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-07')));
await assertFails(getDoc(doc(dbFor('bob'), 'users/alice/nebulaCreates/2026-10-06')));
await assertFails(setDoc(doc(dbFor('alice'), 'users/alice/nebulaCreates/2026-10-08'), { fandomId: 'f2' }));
});
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 B

After

Width:  |  Height:  |  Size: 151 KiB

@@ -33,6 +33,6 @@
</scene>
</scenes>
<resources>
<image name="LaunchImage" width="168" height="185"/>
<image name="LaunchImage" width="120" height="120"/>
</resources>
</document>
+37
View File
@@ -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);
+33 -1
View File
@@ -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;
}
+43 -2
View File
@@ -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();
}
+14
View File
@@ -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,
),
);
+25
View File
@@ -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,
);
}
+3
View File
@@ -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();
+9 -6
View File
@@ -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),
),
+126 -11
View File
@@ -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 {
+13 -2
View File
@@ -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(
eclipseDefendRoute(
repository: repository,
fandomId: state.fandomId,
),
nebula: nebula,
),
),
child: Column(
+6
View File
@@ -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,
);
}
}
+228 -31
View File
@@ -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,9 +236,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
],
),
),
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s9),
children: [
// 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱
// 워닝 배너 병행」. 대상은 내 행성이다.
if (mine.where((p) => p.isMine).firstOrNull case final my?)
if (mine.where((p) => p.isMine).firstOrNull
case final my?)
EclipseBanner(
repository: widget.eclipse,
fandomId: my.id,
@@ -259,15 +274,24 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
),
),
const Spacer(),
// 격자 ↔ 리스트(Figma `540:19580`). 궤도 맵은 선택된 카드를 눌러 연다.
InkResponse(
onTap: _openMap,
onTap: () => setState(() => _list = !_list),
radius: 20,
child: Image.asset(
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),
semanticLabel: context.l10n.galaxyMap,
),
),
),
],
@@ -275,24 +299,14 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
),
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),
child: _list
? _PlanetList(
planets: mine,
imageOf: GalaxyHomeScreen.cardImageOf,
onTap: _choose,
)
: _PlanetGrid(planets: mine, onTap: _choose),
),
),
],
// 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다.
for (var i = mine.length; i < 3; i++) ...[
if (i > 0) const SizedBox(width: AppSpace.s2),
const Expanded(child: _EmptySlot()),
],
],
),
),
@@ -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,13 +446,16 @@ 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(
return Opacity(
opacity: disabled ? 0.4 : 1,
child: Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(AppRadius.full),
child: InkWell(
@@ -469,6 +492,180 @@ class _NebulaPill extends StatelessWidget {
),
),
),
),
);
}
}
/// 격자 — 세 칸씩 줄을 채운다. 행성이 셋보다 적으면 빈 칸으로 한 줄을 맞춘다(시안 세 칸).
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'),
+1 -3
View File
@@ -573,14 +573,12 @@ class _Map extends StatelessWidget {
state: e,
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => EclipseDefendScreen(
eclipseDefendRoute(
repository: eclipseRepository,
fandomId: e.fandomId,
),
),
),
),
if (selected case final target?)
HeroTimeBillboard(
fandomId: target.id,
+285 -29
View File
@@ -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(
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: AppSpace.s2),
itemBuilder: (context, i) => _Hit(
planet: hits[i],
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!(hits[i]),
: () => widget.onOpen!(p),
);
},
),
),
],
);
},
);
@@ -290,14 +381,20 @@ class _Recent extends StatelessWidget {
),
)
else
Wrap(
// Figma `543:19637` 「검색 결과 칩의 최대 높이는 3줄」 — 칩 33 · 줄 간격 10.
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 33 * 3 + 10 * 2),
child: ClipRect(
child: Wrap(
spacing: AppSpace.s2,
runSpacing: 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),
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),
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(planet.name, style: t.bodyMedium),
Text(
context.l10n.galaxyMembers(formatPop(planet.members)),
style: t.labelSmall,
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),
),
],
],
),
],
),
),
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
],
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),
),
),
),
],
),
);
}
+9 -1
View File
@@ -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,
+85 -40
View File
@@ -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,7 +307,37 @@ class HomeHeader extends StatelessWidget {
final List<ItemProduct> items;
@override
Widget build(BuildContext context) => Padding(
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(
@@ -358,13 +389,27 @@ class HomeHeader extends StatelessWidget {
padding: EdgeInsets.zero,
// 사본 `icon` 42 칸 안 lucide/bell 20.
iconSize: 20,
icon: const ImageIcon(AppIcons.bell, color: AppColors.onCosmic),
// 배지 모양은 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: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => NotificationScreen(repository: notifications),
),
onPressed: () async {
await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<Object?>(
builder: (_) =>
NotificationScreen(repository: notifications),
),
);
await _checkUnread();
},
tooltip: context.l10n.homeNotificationsTooltip,
),
],
@@ -372,6 +417,7 @@ class HomeHeader extends StatelessWidget {
),
);
}
}
class _HomeBody extends StatelessWidget {
const _HomeBody({
@@ -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 {
+26 -11
View File
@@ -314,24 +314,34 @@ 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)],
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: Text(
),
child: growth.photoUrl != null
? null
: Text(
growth.contributorName.characters.firstOrNull ?? '—',
style: const TextStyle(
fontFamily: AppFonts.body,
@@ -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
+3 -18
View File
@@ -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,19 +98,6 @@ 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 ?? ''),
],
),
@@ -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({
+2
View File
@@ -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,7 +70,14 @@ class _NotificationScreenState extends State<NotificationScreen> {
}
final groups = groupNotifications(items);
return Scaffold(
// 명세 `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),
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
@@ -108,6 +115,7 @@ class _NotificationScreenState extends State<NotificationScreen> {
),
],
),
),
);
}
@@ -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,
+136 -18
View File
@@ -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,7 +264,12 @@ class _RankingBody extends StatelessWidget {
}
final podium = scopeLabel != null && data.rows.length >= 3;
final rows = podium ? data.rows.skip(3).toList() : data.rows;
return ListView(
return NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.extentAfter < 200) onEnd?.call();
return false;
},
child: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다.
@@ -207,15 +287,31 @@ class _RankingBody extends StatelessWidget {
const SizedBox(height: AppSpace.s3),
],
if (data.mine case final me?) ...[
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
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(
Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(8),
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,
),
),
),
child: _RankRow(row: me, index: -1, planet: planets, mark: false),
),
const SizedBox(height: 24),
],
@@ -258,15 +354,25 @@ class _RankingBody extends StatelessWidget {
onTap: onTapRow == null ? null : () => onTapRow!(row),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: _RankRow(row: row, index: i),
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(
+31
View File
@@ -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,6 +226,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
),
);
}, destructive: false),
if (!mine) ...[
ActionSheetItem(
context.l10n.reportTitle,
() => _moderate(context, 'report', name),
@@ -230,6 +235,7 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
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,14 +118,18 @@ class PlanetDetailHero extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (ownerName case final owner?) ...[
Row(
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? when p.startsWith('http') => NetworkImage(
p,
),
final p? => AssetImage(p),
null => null,
},
@@ -137,6 +148,7 @@ class PlanetDetailHero extends StatelessWidget {
),
],
),
),
const SizedBox(height: 25),
],
LivePlanet25D(
@@ -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,6 +226,7 @@ class PlanetDetailHero extends StatelessWidget {
),
const SizedBox(height: 12),
],
if (!isOwner)
Semantics(
button: onJoin != null,
child: GestureDetector(
+71 -19
View File
@@ -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(
+26 -2
View File
@@ -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)),
+46 -3
View File
@@ -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
+35 -10
View File
@@ -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,6 +150,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
value: 'cancel',
child: Text(context.l10n.questCancel),
),
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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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": {
+73 -1
View File
@@ -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.
+40 -1
View File
@@ -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
+40 -1
View File
@@ -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
+40 -1
View File
@@ -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
+40 -1
View File
@@ -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
+39 -1
View File
@@ -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
View File
@@ -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;
+16
View File
@@ -1028,6 +1028,22 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
package_info_plus:
dependency: "direct main"
description:
name: package_info_plus
sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20"
url: "https://pub.dev"
source: hosted
version: "9.0.1"
package_info_plus_platform_interface:
dependency: transitive
description:
name: package_info_plus_platform_interface
sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
url: "https://pub.dev"
source: hosted
version: "3.2.1"
path:
dependency: transitive
description:
+5
View File
@@ -132,6 +132,9 @@ dependencies:
mobile_scanner: ^7.4.2
# 생성자 상세의 「현장에 붙일 인증 QR」 그리기. 순수 Dart 라 네이티브 하한이 없다.
qr_flutter: ^4.1.0
# 스플래시 업데이트 확인(Figma `510:19053`) — 설치된 앱 버전 읽기.
# 최소 OS 하한 영향 없음: Android minSdk 19(앱 29) · iOS 12.0(앱 15.0).
package_info_plus: ^9.0.1
dev_dependencies:
flutter_test:
@@ -196,3 +199,5 @@ flutter:
- assets/galaxy/
# Figma Iconset 아이콘(lib/core/ui/app_icons.dart).
- assets/icons/
# 발주사 로고 마크(인수 자산 `fanuzu logo only.png` 에서 마크만 자름) — 로그인 · 가입 완료 · 소개.
- assets/brand/fanuzu-mark.png
+23
View File
@@ -0,0 +1,23 @@
import 'package:fancake/core/config/feature_flags.dart';
import 'package:flutter_test/flutter_test.dart';
/// 스플래시 업데이트 확인(Figma `510:19053`) — 설치 버전이 운영 최신 버전보다 낮을 때만 안내.
void main() {
test('버전 비교', () {
expect(isOlderVersion('0.5.0', '0.5.1'), isTrue);
expect(isOlderVersion('0.5.0+7', '0.10.0'), isTrue);
expect(isOlderVersion('0.5.0', '0.5.0'), isFalse);
expect(isOlderVersion('0.6.0', '0.5.9'), isFalse);
expect(isOlderVersion('1.0', '1.0.0'), isFalse);
expect(isOlderVersion('0.5.0', 'abc'), isFalse);
});
test('config/features.latestAppVersion 을 읽고, 없으면 null', () async {
final flags = FeatureFlags(
source: Stream.value({FeatureFlags.latestVersionKey: ' 0.6.0 '}),
);
await Future<void>.delayed(Duration.zero);
expect(flags.latestVersion, '0.6.0');
expect(FeatureFlags().latestVersion, isNull);
});
}
+15 -6
View File
@@ -198,7 +198,9 @@ void main() {
expect(snapshot.popBalance, 12540);
expect(snapshot.liveTicker, contains('버프'));
expect(snapshot.growth.fandomName, 'BTS');
expect(snapshot.growth.contributorName, 'BTS_Hong');
// 설명 프레임 `2097:16391` — 인사 줄은 로그인 사용자 기준. 팬덤의 직전 기여자가 아니다.
expect(snapshot.growth.contributorName, isNot('BTS_Hong'));
expect(snapshot.growth.contributedPop, 0);
expect(snapshot.growth.currentPop, 6270);
// 서버가 growthPercent 를 주지 않으면 진행도로 낸다.
expect(snapshot.growth.growthPercent, 63);
@@ -827,14 +829,21 @@ void main() {
expect(profile.nickname, '팬');
});
test('홈 기여자명에 이메일이 남아 있어도 가린다', () async {
test('홈 인사 줄은 로그인 사용자의 닉네임 · 사진이고 이메일 형태는 가린다', () async {
final db = await _seed();
await db.doc('fandoms/bts').set({
'name': 'BTS', 'lastContributorName': 'test123@naver.com',
'lastContributedPop': 10,
await db.doc('users/$_uid').set({
'nickname': 'ARMY_Kim', 'photoUrl': 'https://example.com/p.png',
}, SetOptions(merge: true));
final snapshot = await FirestoreHomeRepository(_backend(db)).load();
var snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.growth.contributorName, 'ARMY_Kim');
expect(snapshot.growth.photoUrl, 'https://example.com/p.png');
await db.doc('users/$_uid').set({
'nickname': 'test123@naver.com', 'photoUrl': '',
}, SetOptions(merge: true));
snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.growth.contributorName, '팬');
expect(snapshot.growth.photoUrl, isNull);
});
});
+6 -6
View File
@@ -118,7 +118,7 @@ void main() {
testWidgets('이름을 넣고 만들면 기본 행성 변경을 되묻는다', (tester) async {
await _pump(tester, const NebulaCreateScreen());
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
@@ -131,13 +131,13 @@ void main() {
testWidgets('변경을 누르면 fn-nebula-create 로 만들고 새 성운 ID 를 들고 닫힌다', (tester) async {
final repo = _CreateRepo();
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
await tester.tap(find.text('변경'));
await tester.pumpAndSettle();
expect(repo.calls, ['BTS_RM']);
expect(repo.calls, ['BTS RM']);
expect(results, ['new-nebula']);
expect(find.byType(NebulaCreateScreen), findsNothing);
});
@@ -147,7 +147,7 @@ void main() {
error: const BackendException('E-FDM-02', '이미 오늘 성운을 생성하셨습니다.'),
);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
@@ -161,7 +161,7 @@ void main() {
testWidgets('대표 변경 30일 대기 중이면 성운만 생겼다고 알리고 닫힌다(C-15)', (tester) async {
final repo = _CreateRepo(primaryChanged: false);
final results = await _pushed(tester, NebulaCreateScreen(repository: repo));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
@@ -180,7 +180,7 @@ void main() {
testWidgets('오늘 이미 만들었으면 사유를 알리고 진행하지 않는다', (tester) async {
await _pump(tester, const NebulaCreateScreen(alreadyCreatedToday: true));
await tester.enterText(find.byType(TextField), 'BTS_RM');
await tester.enterText(find.byType(TextField), 'BTS RM');
await tester.pumpAndSettle();
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
+74 -2
View File
@@ -121,6 +121,20 @@ class _PrimaryRepository extends SampleGalaxyRepository {
}
}
/// 오늘 성운을 이미 만든 계정.
class _CreatedTodayRepository extends SampleGalaxyRepository {
@override
Future<GalaxySnapshot> load() async {
final s = await super.load();
return GalaxySnapshot(
planets: s.planets,
myPop: s.myPop,
memberships: s.memberships,
nebulaCreatedToday: true,
);
}
}
void main() {
group('GALAXY 모아보기 (§2.56 · §2.58) — 기본 행성 변경', () {
Future<void> pumpHome(WidgetTester tester, GalaxyRepository repo) async {
@@ -146,6 +160,24 @@ void main() {
expect(repo.changes, ['newjeans']);
});
testWidgets('그리드 변경 — 격자 ↔ 리스트(540:19580)', (tester) async {
await pumpHome(tester, _PrimaryRepository());
expect(find.text('NewJeans'), findsNothing); // 격자는 그림만
await tester.tap(find.bySemanticsLabel('리스트 그리드'));
await tester.pumpAndSettle();
expect(find.text('NewJeans'), findsOneWidget);
await tester.tap(find.bySemanticsLabel('격자 그리드'));
await tester.pumpAndSettle();
expect(find.text('NewJeans'), findsNothing);
});
testWidgets('오늘 성운을 만들었으면 성운 생성은 초과 안내 모달만(540:19592 · 543:19609)', (tester) async {
await pumpHome(tester, _CreatedTodayRepository());
await tester.tap(find.text('성운 생성'));
await tester.pumpAndSettle();
expect(find.text('이미 오늘 성운을 생성하셨습니다.'), findsOneWidget);
});
testWidgets('30일 대기 중이면 서버 사유(E-FDM-04)를 알린다', (tester) async {
final repo = _PrimaryRepository(
error: const BackendException('E-FDM-04', '기본 행성은 변경 후 30일이 지나야 다시 바꿀 수 있습니다.'),
@@ -465,7 +497,10 @@ void main() {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'BTS');
await tester.pumpAndSettle();
expect(find.textContaining('멤버 12,840명'), findsOneWidget);
// 결과 줄 — Figma `353:218091` 가입자 수 · 「FPI : n」 · 규모 칩.
expect(find.text('12,840'), findsOneWidget);
expect(find.text('FPI : 284,310'), findsOneWidget);
expect(find.text('중형 팬덤'), findsOneWidget);
await tester.testTextInput.receiveAction(TextInputAction.search);
await tester.pumpAndSettle();
@@ -479,10 +514,47 @@ void main() {
await _pump(tester, GalaxySearchScreen(onOpen: (p) => opened = p));
await tester.enterText(find.byType(TextField), 'BTS');
await tester.pumpAndSettle();
await tester.tap(find.textContaining('멤버 12,840명'));
await tester.tap(find.text('12,840'));
expect(opened?.name, 'BTS');
});
testWidgets('탭(행성 · 성운) · 필터(전체 · 가입자 수 · FPI) — 543:19672 · 543:19683', (tester) async {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'e');
await tester.pumpAndSettle();
expect(find.text('행성'), findsOneWidget);
expect(find.text('성운'), findsOneWidget);
// 가입자 수 순 — NewJeans(11,230) 가 SEVENTEEN(10,450) 보다 위.
await tester.tap(find.text('가입자 수'));
await tester.pumpAndSettle();
expect(
tester.getTopLeft(find.text('NewJeans')).dy,
lessThan(tester.getTopLeft(find.text('SEVENTEEN')).dy),
);
// 성운 탭 — 표본은 모두 정식 행성이라 비고, FPI 필터는 없다.
await tester.tap(find.text('성운'));
await tester.pumpAndSettle();
expect(find.text('NewJeans'), findsNothing);
expect(find.text('FPI'), findsNothing);
});
testWidgets('가입 → 가입 처리 후 가입중(543:19694)', (tester) async {
final joined = <String>[];
await _pump(
tester,
GalaxySearchScreen(
repository: const SampleGalaxyRepository(),
onJoin: (p) async => joined.add(p.id),
),
);
await tester.enterText(find.byType(TextField), 'IVE');
await tester.pumpAndSettle();
await tester.tap(find.text('가입'));
await tester.pumpAndSettle();
expect(joined, ['ive']);
expect(find.text('가입중'), findsOneWidget);
});
testWidgets('없는 이름이면 사유를 알린다', (tester) async {
await _pump(tester, const GalaxySearchScreen());
await tester.enterText(find.byType(TextField), 'zzzz');
+45
View File
@@ -11,6 +11,7 @@ import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/planet/lounge_post_screens.dart';
import 'package:fancake/features/planet/moderation.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'package:fancake/features/planet/widgets/planet_detail_hero.dart';
import 'eclipse_stubs.dart';
@@ -588,6 +589,50 @@ void _fpiRankingTests() {
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
expect(find.text('나의 BPM 랭킹'), findsOneWidget);
});
testWidgets('소속 행성 카드를 눌러도 사용자 랭킹으로 간다 (546:14629)', (tester) async {
await pump(tester);
// 맨 위 내 줄 카드는 「(나)」 없이 이름만 둔다.
await tester.tap(find.text('BTS'));
await tester.pumpAndSettle();
expect(find.byType(FpiUserRankingScreen), findsOneWidget);
});
});
testWidgets('내가 만든 행성은 가입 버튼을 숨긴다 (544:19765)', (tester) async {
Widget hero(bool owner) => MaterialApp(
theme: buildAppTheme(),
home: const Scaffold(body: SizedBox()),
builder: (_, _) => SizedBox(
height: 543,
child: PlanetDetailHero(
name: 'BTS',
members: 850,
totalFpi: 1220,
joined: true,
isOwner: owner,
),
),
);
await tester.pumpWidget(hero(false));
await tester.pump();
expect(find.text('가입중'), findsOneWidget);
expect(find.text('소형 팬덤'), findsOneWidget);
await tester.pumpWidget(hero(true));
await tester.pump();
expect(find.text('가입중'), findsNothing);
});
test('팬덤 규모 — 1,000명 미만 소형 · 50,000명 미만 중형 · 이상 대형 (544:19765)', () {
expect(FandomSize.of(999), FandomSize.small);
expect(FandomSize.of(1000), FandomSize.medium);
expect(FandomSize.of(49999), FandomSize.medium);
expect(FandomSize.of(50000), FandomSize.large);
});
test('국기 — 국가 코드 → 국기, 미지정은 흰 깃발 (546:14713)', () {
expect(flagOf('KR'), '🇰🇷');
expect(flagOf('ZZ'), '🏳');
});
group('사용자 지표 랭킹 화면 (Figma 546:14646)', () {
+50 -7
View File
@@ -69,7 +69,7 @@ void main() {
final repo = _AdRepo();
await watchAd(tester, repo);
expect(repo.claimed, ['imp-1']);
expect(find.text('광고 추가 보상 +20 POP'), findsOneWidget);
expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget);
});
testWidgets('불러오지 못하면 기본 보상만 받는다', (tester) async {
@@ -193,8 +193,11 @@ void main() {
}
});
test('완료 탭은 진행 완료와 보상 완료를 함께 받는다', () {
expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isTrue);
// 명세 `518:88405` — 진행완료는 진행중 탭, 보상완료만 완료 탭.
test('진행완료는 진행중 탭, 보상완료만 완료 탭이 받는다', () {
expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.completed)), isTrue);
expect(QuestTab.inProgress.accepts(_stage(card, QuestStage.inProgress)), isTrue);
expect(QuestTab.done.accepts(_stage(card, QuestStage.completed)), isFalse);
expect(QuestTab.done.accepts(_stage(card, QuestStage.rewarded)), isTrue);
expect(QuestTab.done.accepts(_stage(card, QuestStage.inProgress)), isFalse);
});
@@ -328,10 +331,10 @@ void main() {
// 시청 후 대기(482:15084)는 지급이 끝나면 걷힌다.
expect(find.byType(QuestAdWarpScreen), findsNothing);
expect(find.byType(QuestRewardScreen), findsOneWidget);
// 별첨 B **B-28** 이 배수형(×2/×3)을 철회했다. 기본 보상과 광고 추가 보상은
// 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다(`fnAdReward.rewarded`).
expect(find.text('+10 받았어요!'), findsOneWidget);
expect(find.text('광고 추가 보상 +10 POP'), findsOneWidget);
// 기본 보상과 광고 추가 보상은 별개 원장 행이고, 화면은 **서버가 돌려준 지급액만** 띄운다
// (`fnAdReward.rewarded`). 운영 Quest 는 명세 `518:88685` 대로 둘의 합계(× 배수)를 크게 보인다.
expect(find.text('+20 받았어요!'), findsOneWidget);
expect(find.text('광고 추가 보상 +10 기여 POP'), findsOneWidget);
});
testWidgets('스트리밍은 무보상 참여로 열려 있고 보상 · 광고 화면 없이 참여만 알린다(C-04)', (tester) async {
@@ -530,6 +533,46 @@ void main() {
}
}
});
group('Figma 설명 프레임 대조 (2026-10-06)', () {
QuestCard ugc({int participants = 0, int? capacity, QuestStage stage = QuestStage.notStarted}) =>
QuestCard(
id: 'u', title: 't', categoryName: 'c', author: QuestAuthor.user,
stage: stage, goal: 1, progress: 0, rewardPop: 10,
participants: participants, capacity: capacity,
);
// 명세 `523:18176` 퀘스트 종료 시점 1 — 참여 인원 충족 시 종료.
test('사용자 퀘스트는 모집 인원이 차면 마감이다', () {
expect(questFull(ugc(participants: 5, capacity: 5)), isTrue);
expect(questFull(ugc(participants: 4, capacity: 5)), isFalse);
expect(questFull(ugc(participants: 3)), isFalse);
});
// 명세 `518:88395` — 카테고리 값은 등록된 분류(건강 · 커뮤니티 …)다. 활동명(걷기)이 아니다.
test('카드 카테고리는 활동이 아니라 분류 이름이다', () {
expect(QuestType.steps.category, '건강');
expect(QuestType.community.category, '커뮤니티');
expect(QuestType.vote.category, '행성 활동');
});
// 명세 `518:88685` — 광고 시청 POP = 보상 POP × 배수. 운영 Quest 는 합계를 크게 보인다.
testWidgets('운영 퀘스트는 광고를 보면 기본 + 증분 합계를 띄운다', (tester) async {
const card = QuestCard(
id: 'a', title: 't', categoryName: 'c', author: QuestAuthor.admin,
stage: QuestStage.completed, goal: 1, progress: 1, rewardPop: 20, participants: 0,
);
await pumpWide(
tester,
const QuestRewardScreen(
detail: QuestDetail(card: card, imageLabel: '', description: ''),
reward: QuestReward(rewardedPop: 20, balanceAfter: 0),
adBonusPop: 20,
),
);
expect(find.text('+40 받았어요!'), findsOneWidget);
expect(find.text('광고 추가 보상 +20 기여 POP'), findsOneWidget);
});
});
}
/// 지갑이 서버 값으로 바뀐 표본 — 생성 예치 · 수수료 뒤 헤더가 다시 맞는가.
+58
View File
@@ -14,6 +14,9 @@ import 'package:fancake/features/home/widgets/quest_row.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show QuestIconBox;
import 'package:fancake/core/router/app_router.dart' show scrollTabToTop;
import 'package:fancake/features/notification/notification_screen.dart';
import 'package:fancake/features/home/nebula_create_screen.dart' show isValidNebulaName;
import 'eclipse_stubs.dart';
class _LiveGrowthRepo extends SampleHomeRepository {
@@ -379,4 +382,59 @@ void main() {
await tester.pumpAndSettle();
expect(opened, ['https://example.com/a']);
});
group('설명 프레임 대조(홈 일반)', () {
Future<bool> badgeShown(WidgetTester tester, List<AppNotification> items) async {
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: Scaffold(
body: HomeHeader(
key: UniqueKey(),
pop: 0,
notifications: SampleNotificationRepository(items: items),
),
),
),
);
await tester.pumpAndSettle();
return tester.widget<Badge>(find.byKey(const ValueKey('home-bell-badge'))).isLabelVisible;
}
AppNotification n(bool read) => AppNotification(
id: 'n$read', kind: NotificationKind.quest, title: 't', at: DateTime(2026), read: read,
);
testWidgets('2097:16385 — 안 읽은 알림이 있으면 알림 아이콘에 배지', (tester) async {
expect(await badgeShown(tester, [n(false), n(true)]), isTrue);
expect(await badgeShown(tester, [n(true)]), isFalse);
});
testWidgets('2097:16427 — 열린 탭을 다시 누르면 루트 화면이 맨 위로', (tester) async {
final key = GlobalKey<NavigatorState>();
await tester.pumpWidget(
MaterialApp(
navigatorKey: key,
home: Scaffold(
body: ListView(children: [for (var i = 0; i < 50; i++) SizedBox(height: 100, child: Text('$i'))]),
),
),
);
await tester.drag(find.byType(ListView), const Offset(0, -1500));
await tester.pumpAndSettle();
expect(find.text('0'), findsNothing);
scrollTabToTop(key);
await tester.pumpAndSettle();
expect(find.text('0'), findsOneWidget);
});
test('516:87397 — 성운명 규칙은 서버와 같다', () {
for (final ok in ['방탄', 'BTS Army 7', '가' * 20]) {
expect(isValidNebulaName(ok), isTrue, reason: ok);
}
for (final bad in ['가', '가' * 21, ' 방탄', '방탄 ', '방 탄', '방탄!', '방탄😀']) {
expect(isValidNebulaName(bad), isFalse, reason: bad);
}
});
});
}
+68
View File
@@ -110,6 +110,74 @@ void main() {
);
});
testWidgets('프로필 국가 — 사본 국가 리스트 모달(178:23489) 대로 국가명 · 단수 선택 · 「선택」 후 적용', (
tester,
) async {
final (auth, repo) = await boot(tester, needs: true);
await auth.signIn(SocialProvider.google);
await tester.pumpAndSettle();
for (final label in [
'서비스 이용약관 (필수)',
'개인정보 수집·이용 및 처리방침 동의 (필수)',
'위치기반 서비스 이용약관 (필수)',
'만 14세 이상입니다. (필수)',
]) {
await tester.tap(find.text(label));
}
await tester.pump();
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
await tester.tap(find.text('국가/지역').first);
await tester.pumpAndSettle();
await tester.scrollUntilVisible(
find.text('대한민국'),
300,
scrollable: find.byType(Scrollable).last,
);
await tester.tap(find.text('대한민국'));
await tester.pump();
await tester.tap(find.widgetWithText(FilledButton, '선택'));
await tester.pumpAndSettle();
expect(find.textContaining('대한민국'), findsOneWidget);
await tester.enterText(find.byType(TextField).first, '우주팬');
await tester.pump();
await tester.tap(find.text('다음'));
await tester.pumpAndSettle();
expect(repo.profile?.country, 'KR');
});
testWidgets('프로필 뒤로가기(511:86825) — 약관으로 돌아가 동의가 체크된 채 보이고 다시 기록하지 않는다', (
tester,
) async {
final (auth, repo) = await boot(tester, needs: true);
await auth.signIn(SocialProvider.google);
await tester.pumpAndSettle();
for (final label in [
'서비스 이용약관 (필수)',
'개인정보 수집·이용 및 처리방침 동의 (필수)',
'위치기반 서비스 이용약관 (필수)',
'만 14세 이상입니다. (필수)',
]) {
await tester.tap(find.text(label));
}
await tester.pump();
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
final first = repo.agreed;
await tester.tap(find.byType(BackButton));
await tester.pumpAndSettle();
// 필수 4항이 이미 체크돼 있어 「완료」가 바로 켜져 있다.
final done = find.widgetWithText(FilledButton, '완료');
expect(tester.widget<FilledButton>(done).onPressed, isNotNull);
await tester.tap(done);
await tester.pumpAndSettle();
expect(identical(repo.agreed, first), isTrue);
expect(find.byType(BackButton), findsOneWidget); // ③ 프로필로 돌아왔다.
});
testWidgets('가입 모드에서 받은 약관은 다시 묻지 않고 기록만 한다', (tester) async {
final (auth, repo) = await boot(tester, needs: true);
auth.pendingConsent = {
+3
View File
@@ -29,7 +29,10 @@ void main() {
await tester.pumpWidget(MaterialApp(theme: buildAppTheme(), home: Scaffold(body: widget)));
await tester.pumpAndSettle();
expect(find.textContaining('0 POP', findRichText: true), findsNothing);
// HOME 히어로의 「{닉네임} 님의」는 로그인 사용자 인사 줄이라 늘 있다(설명 프레임 `2097:16391`).
if (widget is PlanetGrowthCard) {
expect(find.textContaining('님의', findRichText: true), findsNothing);
}
expect(find.text('함께 키워가는 팬덤 행성'), findsOneWidget);
}
});