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
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(
repository: repository,
fandomId: state.fandomId,
),
// 명세 `516:88164` 「닫기 — 하단 move out」 — 아래에서 올라오는 전체 화면.
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,
);
}
}
+300 -103
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,73 +236,77 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
],
),
),
// 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱
// 워닝 배너 병행」. 대상은 내 행성이다.
if (mine.where((p) => p.isMine).firstOrNull case final my?)
EclipseBanner(
repository: widget.eclipse,
fandomId: my.id,
margin: const EdgeInsets.fromLTRB(
16,
AppSpace.s3,
16,
0,
),
onTap: _openMap,
),
// 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: Row(
Expanded(
child: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s9),
children: [
Text(
context.l10n.galaxyMyPlanets,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
fontSize: 26,
height: 1.4,
color: AppColors.onCosmic,
),
),
const Spacer(),
InkResponse(
onTap: _openMap,
radius: 20,
child: Image.asset(
'assets/figma/icon-list.png',
width: 24,
height: 24,
color: const Color(0xFF777777),
semanticLabel: context.l10n.galaxyMap,
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
for (final (i, p) in mine.indexed) ...[
if (i > 0) const SizedBox(width: AppSpace.s2),
Expanded(
child: _PlanetCard(
planet: p,
image: GalaxyHomeScreen.cardImageOf(p, i),
selected: p.isMine,
onTap: () => _choose(p),
// 이클립스 인앱 경보 — 별첨 1 ① 「푸시 미수신 단말 — 인앱
// 워닝 배너 병행」. 대상은 내 행성이다.
if (mine.where((p) => p.isMine).firstOrNull
case final my?)
EclipseBanner(
repository: widget.eclipse,
fandomId: my.id,
margin: const EdgeInsets.fromLTRB(
16,
AppSpace.s3,
16,
0,
),
onTap: _openMap,
),
],
// 시안은 항상 세 칸이다. 행성이 모자라면 빈 칸을 둔다.
for (var i = mine.length; i < 3; i++) ...[
if (i > 0) const SizedBox(width: AppSpace.s2),
const Expanded(child: _EmptySlot()),
],
// 「내 행성」 ABeeZee 26 · 우측 목록 아이콘 24 → 궤도 맵 (줄 44 · 위아래 4)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
child: Row(
children: [
Text(
context.l10n.galaxyMyPlanets,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
fontSize: 26,
height: 1.4,
color: AppColors.onCosmic,
),
),
const Spacer(),
// 격자 ↔ 리스트(Figma `540:19580`). 궤도 맵은 선택된 카드를 눌러 연다.
InkResponse(
onTap: () => setState(() => _list = !_list),
radius: 20,
child: Semantics(
container: true,
button: true,
label: _list
? context.l10n.galaxyGridView
: context.l10n.galaxyListView,
child: _list
? const _GridIcon()
: Image.asset(
'assets/figma/icon-list.png',
width: 24,
height: 24,
color: const Color(0xFF777777),
),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: _list
? _PlanetList(
planets: mine,
imageOf: GalaxyHomeScreen.cardImageOf,
onTap: _choose,
)
: _PlanetGrid(planets: mine, onTap: _choose),
),
],
),
),
@@ -303,7 +317,13 @@ class _GalaxyHomeScreenState extends State<GalaxyHomeScreen> {
left: 0,
right: 0,
bottom: 16,
child: Center(child: _NebulaPill(onTap: _createNebula)),
child: Center(
child: _NebulaPill(
onTap: _createNebula,
// 일 1회 생성 뒤 비활성화(Figma `540:19592`). 눌러도 초과 안내 모달만 뜬다.
disabled: data.nebulaCreatedToday,
),
),
),
],
);
@@ -426,44 +446,221 @@ class _EmptySlot extends StatelessWidget {
}
class _NebulaPill extends StatelessWidget {
const _NebulaPill({required this.onTap});
const _NebulaPill({required this.onTap, this.disabled = false});
final VoidCallback onTap;
final bool disabled;
@override
Widget build(BuildContext context) {
return Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(AppRadius.full),
child: InkWell(
onTap: onTap,
return Opacity(
opacity: disabled ? 0.4 : 1,
child: Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.cosmicSurfaceRaised),
),
// 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const ImageIcon(
AppIcons.badge,
size: 16,
color: AppColors.cosmicAccent,
),
const SizedBox(width: 6),
Text(
context.l10n.nebulaCreate,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.full),
child: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
border: Border.all(color: AppColors.cosmicSurfaceRaised),
),
// 시안 — Iconset/badge 16 · 간격 6 · 「성운 생성」 14/500 #a3a2a0
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const ImageIcon(
AppIcons.badge,
size: 16,
color: AppColors.cosmicAccent,
),
const SizedBox(width: 6),
Text(
context.l10n.nebulaCreate,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
),
],
),
),
),
),
);
}
}
/// 격자 — 세 칸씩 줄을 채운다. 행성이 셋보다 적으면 빈 칸으로 한 줄을 맞춘다(시안 세 칸).
class _PlanetGrid extends StatelessWidget {
const _PlanetGrid({required this.planets, required this.onTap});
final List<PlanetNode> planets;
final ValueChanged<PlanetNode> onTap;
@override
Widget build(BuildContext context) {
final cells = planets.length < 3 ? 3 : (planets.length + 2) ~/ 3 * 3;
return Column(
children: [
for (var row = 0; row < cells; row += 3) ...[
if (row > 0) const SizedBox(height: AppSpace.s2),
Row(
children: [
for (var i = row; i < row + 3; i++) ...[
if (i > row) const SizedBox(width: AppSpace.s2),
Expanded(
child: i < planets.length
? _PlanetCard(
planet: planets[i],
image: GalaxyHomeScreen.cardImageOf(planets[i], i),
selected: planets[i].isMine,
onTap: () => onTap(planets[i]),
)
: const _EmptySlot(),
),
],
],
),
],
],
);
}
}
/// 리스트 그리드 — 시안 `354:221533`: 80×79 #2c2c2c r8 칸(그림 64) · 간격 15 · 이름 16/600,
/// 줄 사이 1px #2c2c2c. 선택된 행성은 칸 위에 「선택」 알약.
class _PlanetList extends StatelessWidget {
const _PlanetList({
required this.planets,
required this.imageOf,
required this.onTap,
});
final List<PlanetNode> planets;
final String Function(PlanetNode, [int]) imageOf;
final ValueChanged<PlanetNode> onTap;
@override
Widget build(BuildContext context) => Column(
children: [
for (final (i, p) in planets.indexed) ...[
if (i > 0)
const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: Divider(height: 1, thickness: 1, color: Color(0xFF2C2C2C)),
),
InkWell(
onTap: () => onTap(p),
child: Row(
children: [
SizedBox(
width: 80,
height: 79,
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: const Color(0xFF2C2C2C),
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(8),
child: PlanetArt(
fallback: imageOf(p, i),
planetType: p.stage == 0 ? null : p.planetType,
colorKey: p.colorKey,
semanticLabel: p.name,
),
),
),
),
if (p.isMine)
Positioned(
top: 0,
right: 0,
child: Container(
width: 32,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: AppColors.cosmicAccent,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
context.l10n.commonSelect,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
),
),
],
),
),
const SizedBox(width: 15),
Expanded(
child: Text(
p.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 22 / 16,
color: AppColors.onCosmic,
),
),
),
],
),
),
],
],
);
}
/// 리스트 상태의 「격자로」 아이콘 — 시안 9×9 r1 #777777 네 칸(20×20).
class _GridIcon extends StatelessWidget {
const _GridIcon();
@override
Widget build(BuildContext context) {
Widget sq() => Container(
width: 9,
height: 9,
decoration: BoxDecoration(
color: const Color(0xFF777777),
borderRadius: BorderRadius.circular(1),
),
);
return SizedBox.square(
dimension: 24,
child: Center(
child: SizedBox.square(
dimension: 20,
child: Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [sq(), sq()],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [sq(), sq()],
),
],
),
@@ -26,13 +26,20 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
final user = userSnap.data() ?? const <String, dynamic>{};
// 대표 팬덤(C-15). `primaryFandomId` 가 없던 계정은 옛 `fandomId` 를 쓴다.
final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
// 「가입 또는 생성한 행성/성운」 — 최신 가입순. 시안 그리드가 세 칸이라 셋만 읽는다.
// ponytail: 모아보기 리스트 그리드(명세 §2.58-2)를 붙이면 limit 과 페이지를 연다.
// 「가입 또는 생성한 행성/성운」 — 최신 가입순 전부(Figma `540:19586`). 세 칸은 한 줄의 칸 수다.
final membershipsF = db
.collection('users/$uid/memberships')
.orderBy('joinedAt', descending: true)
.limit(3)
.get();
// 오늘(KST) 성운 생성 기록 — 서버 `dayKey`(UTC+9)와 같은 키.
final kst = DateTime.now().toUtc().add(const Duration(hours: 9));
final todayKey =
'${kst.year.toString().padLeft(4, '0')}-${kst.month.toString().padLeft(2, '0')}-${kst.day.toString().padLeft(2, '0')}';
// 규칙 배포 전이면 읽기가 거부된다 — 그때는 버튼을 막지 않고 서버 판정(E-FDM-02)에 맡긴다.
final createdTodayF = db
.doc('users/$uid/nebulaCreates/$todayKey')
.get()
.then((d) => d.exists, onError: (_) => false);
final fandomsF = db
.collection('fandoms')
@@ -102,6 +109,7 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
// Zap 은 가입·생성한 사용자에게만 보인다(Figma 「Zap 버튼」 규칙).
joined: myFandomId.isNotEmpty,
memberships: [for (final id in membershipIds) ?byId[id]],
nebulaCreatedToday: await createdTodayF,
joinedIds: {
...?(user['fandomIds'] as List?)?.whereType<String>(),
if (myFandomId.isNotEmpty) myFandomId,
@@ -118,7 +126,8 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
if (q.isEmpty) return const [];
final uid = _backend.requireUid;
final db = _backend.db;
final user = (await db.doc('users/$uid').get()).data() ?? const <String, dynamic>{};
final user =
(await db.doc('users/$uid').get()).data() ?? const <String, dynamic>{};
final myFandomId = user.strOr('primaryFandomId', user.strOr('fandomId'));
final names = await db
.collection('fandomNames')
@@ -130,7 +139,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
for (final d in names.docs)
if (d.data()['fandomId'] case final String id when id.isNotEmpty) id,
};
final docs = await Future.wait([for (final id in ids) db.doc('fandoms/$id').get()]);
final docs = await Future.wait([
for (final id in ids) db.doc('fandoms/$id').get(),
]);
return [
for (final doc in docs)
if (doc.data() case final data?)
@@ -152,7 +163,9 @@ class FirestoreGalaxyRepository implements GalaxyRepository {
_ => null,
},
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
planetType: data['planetType'] is String ? data['planetType'] as String : null,
planetType: data['planetType'] is String
? data['planetType'] as String
: null,
name: data.strOr('name', id),
rank: rank,
fpi: data.intOr('fpiScore'),
+3 -5
View File
@@ -573,11 +573,9 @@ class _Map extends StatelessWidget {
state: e,
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => EclipseDefendScreen(
repository: eclipseRepository,
fandomId: e.fandomId,
),
eclipseDefendRoute(
repository: eclipseRepository,
fandomId: e.fandomId,
),
),
),
+306 -50
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(
padding: const EdgeInsets.all(AppSpace.s4),
itemCount: hits.length,
separatorBuilder: (_, _) =>
const SizedBox(height: AppSpace.s2),
itemBuilder: (context, i) => _Hit(
planet: hits[i],
onTap: widget.onOpen == null
? null
: () => widget.onOpen!(hits[i]),
),
final all = result.data ?? const <PlanetNode>[];
final joinedIds = {
...(snap.data?.joinedIds ?? const <String>{}),
..._joined,
};
final hits = [
for (final p in all)
if ((p.stage == 0) == _nebulaTab) p,
];
switch (_sort) {
case _Sort.all:
break;
case _Sort.members:
hits.sort((a, b) => b.members.compareTo(a.members));
case _Sort.fpi:
hits.sort((a, b) => b.fpi.compareTo(a.fpi));
}
return Column(
children: [
_Tabs(
nebula: _nebulaTab,
onPick: (v) => setState(() {
_nebulaTab = v;
if (v && _sort == _Sort.fpi) _sort = _Sort.all;
}),
),
_Filters(
nebula: _nebulaTab,
sort: _sort,
onPick: (v) => setState(() => _sort = v),
),
Expanded(
child: hits.isEmpty
? const _NoHit()
: ListView.separated(
padding: const EdgeInsets.all(AppSpace.s4),
itemCount: hits.length,
separatorBuilder: (_, _) =>
const SizedBox(height: 24),
itemBuilder: (context, i) {
final p = hits[i];
final joined =
p.isMine || joinedIds.contains(p.id);
return _Hit(
planet: p,
joined: joined,
busy: _joining.contains(p.id),
onJoin: joined || widget.onJoin == null
? null
: () => _join(p),
onTap: widget.onOpen == null
? null
: () => widget.onOpen!(p),
);
},
),
),
],
);
},
);
@@ -290,13 +381,19 @@ class _Recent extends StatelessWidget {
),
)
else
Wrap(
spacing: AppSpace.s2,
runSpacing: AppSpace.s2,
children: [
for (final term in terms)
ActionChip(label: Text(term), onPressed: () => onPick(term)),
],
// Figma `543:19637` 「검색 결과 칩의 최대 높이는 3줄」 — 칩 33 · 줄 간격 10.
ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 33 * 3 + 10 * 2),
child: ClipRect(
child: Wrap(
spacing: AppSpace.s2,
runSpacing: 10,
children: [
for (final term in terms)
ActionChip(label: Text(term), onPressed: () => onPick(term)),
],
),
),
),
],
);
@@ -318,46 +415,205 @@ class _NoHit extends StatelessWidget {
);
}
enum _Sort { all, members, fpi }
/// 탭 — 시안 `353:218091` 375×44 · 칸 171 · 16/500(선택 #4bc2ae 밑줄).
class _Tabs extends StatelessWidget {
const _Tabs({required this.nebula, required this.onPick});
final bool nebula;
final ValueChanged<bool> onPick;
@override
Widget build(BuildContext context) => SizedBox(
height: 44,
child: Row(
children: [
for (final (v, label) in [
(false, context.l10n.galaxyTabPlanet),
(true, context.l10n.galaxyTabNebula),
])
Expanded(
child: InkWell(
onTap: () => onPick(v),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: v == nebula
? AppColors.cosmicTeal
: const Color(0xFF1A1A1A),
width: v == nebula ? 2 : 1,
),
),
),
child: Text(
label,
style: _t(
16,
v == nebula
? AppColors.cosmicTeal
: const Color(0xFFBBBBBB),
FontWeight.w500,
1,
),
),
),
),
),
],
),
);
}
/// 필터 칩 — 시안 32 높이 r8 · 선택 #ffffff(글자 검정) · 비선택 #1a1a1a · 12/500.
class _Filters extends StatelessWidget {
const _Filters({
required this.nebula,
required this.sort,
required this.onPick,
});
final bool nebula;
final _Sort sort;
final ValueChanged<_Sort> onPick;
@override
Widget build(BuildContext context) => SizedBox(
height: 48,
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
children: [
for (final (v, label) in [
(_Sort.all, context.l10n.commonAll),
(_Sort.members, context.l10n.galaxySortMembers),
if (!nebula) (_Sort.fpi, 'FPI'),
]) ...[
GestureDetector(
onTap: () => onPick(v),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
alignment: Alignment.center,
decoration: BoxDecoration(
color: v == sort ? Colors.white : const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: _t(
12,
v == sort ? Colors.black : AppColors.onCosmic,
FontWeight.w500,
20 / 12,
),
),
),
),
const SizedBox(width: 4),
],
],
),
);
}
/// 결과 한 줄 — 시안 `353:218091`: 그림 70 · 간격 15 · 이름 16/600 · 규모 칩 · 사람 14 + 수 12/500 ·
/// 「FPI : n」 12/500, 오른쪽 「가입」(#ff7ddd) / 「가입중」(#1a1a1a) 알약 30 높이.
class _Hit extends StatelessWidget {
const _Hit({required this.planet, this.onTap});
const _Hit({
required this.planet,
required this.joined,
this.busy = false,
this.onJoin,
this.onTap,
});
final PlanetNode planet;
final bool joined;
final bool busy;
final VoidCallback? onJoin;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final nebula = planet.stage == 0;
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Row(
children: [
Text(
planet.rankLabel,
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
child: Row(
children: [
SizedBox.square(
dimension: 70,
child: PlanetArt(
fallback: nebula
? 'assets/figma/nebula-card.png'
: 'assets/figma/planet-detail-hero.png',
planetType: nebula ? null : planet.planetType,
colorKey: planet.colorKey,
semanticLabel: planet.name,
),
const SizedBox(width: AppSpace.s4),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(planet.name, style: t.bodyMedium),
Text(
context.l10n.galaxyMembers(formatPop(planet.members)),
style: t.labelSmall,
),
],
),
const SizedBox(width: 15),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
planet.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: _t(16, AppColors.onCosmic, FontWeight.w600, 1),
),
const SizedBox(height: 6),
Row(
children: [
if (!nebula) ...[
FandomSizeBadge(members: planet.members),
const SizedBox(width: 8),
],
const Icon(
Icons.person,
size: 14,
color: Color(0xFF777777),
),
const SizedBox(width: 3),
Text(
formatPop(planet.members),
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
),
if (!nebula) ...[
const SizedBox(width: 8),
Text(
'FPI : ${formatPop(planet.fpi)}',
style: _t(12, AppColors.onCosmic, FontWeight.w500, 1),
),
],
],
),
],
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: busy ? null : onJoin,
child: Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 12),
alignment: Alignment.center,
decoration: BoxDecoration(
color: joined
? const Color(0xFF1A1A1A)
: AppColors.cosmicAccent,
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
joined ? context.l10n.planetJoined : context.l10n.galaxyJoin,
style: _t(14, AppColors.onCosmic, FontWeight.w500, 1),
),
),
Text('FPI ${formatPop(planet.fpi)}', style: t.bodySmall),
],
),
),
],
),
);
}
+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,
+141 -96
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,71 +307,116 @@ class HomeHeader extends StatelessWidget {
final List<ItemProduct> items;
@override
Widget build(BuildContext context) => Padding(
// 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개.
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6),
child: SizedBox(
height: 42,
child: Row(
children: [
const Text(
'FANUZU',
style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 20,
fontWeight: FontWeight.w900,
height: 1.4,
letterSpacing: -0.5,
color: AppColors.cosmicAccent,
),
),
const Spacer(),
_PopBalance(value: pop),
IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
style: IconButton.styleFrom(
minimumSize: const Size.square(42),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
padding: EdgeInsets.zero,
icon: const Image(
image: AssetImage('assets/figma/icon-wallet.png'),
width: 42,
height: 42,
),
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
onPressed: () => showItemSheet(
context,
items: items,
purchase: true,
fandomId: fandomId,
),
tooltip: context.l10n.homeItemsTooltip,
),
IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
style: IconButton.styleFrom(
minimumSize: const Size.square(42),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
padding: EdgeInsets.zero,
// 사본 `icon` 42 칸 안 lucide/bell 20.
iconSize: 20,
icon: const ImageIcon(AppIcons.bell, color: AppColors.onCosmic),
// 알림 — 화면 대조 원장 F0 · `229:11265`.
onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => NotificationScreen(repository: notifications),
State<HomeHeader> createState() => _HomeHeaderState();
}
class _HomeHeaderState extends State<HomeHeader> {
/// 안 읽은 알림이 있는가 — 설명 프레임 `2097:16385` 「알림 - 알림 아이콘 / 알림 상태값 ·
/// 알림 존재 시 배찌 알림으로 변경」. 알림함은 닫힐 때 읽음 상태를 돌려주므로(`518:88347`)
/// 돌아오면 다시 읽는다.
bool _unread = false;
@override
void initState() {
super.initState();
_checkUnread();
}
Future<void> _checkUnread() async {
try {
final items = await widget.notifications.load();
if (mounted) setState(() => _unread = items.any((i) => !i.read));
} catch (_) {
// 읽지 못하면 배지를 띄우지 않는다 — 알림함 자체는 열린다.
}
}
@override
Widget build(BuildContext context) {
final pop = widget.pop;
final items = widget.items;
final fandomId = widget.fandomId;
final notifications = widget.notifications;
return Padding(
// 사본 `Top Navigation` — 높이 54 · 패딩 6/16 · 오른쪽 묶음은 알약 + 42 아이콘 두 개.
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4, vertical: 6),
child: SizedBox(
height: 42,
child: Row(
children: [
const Text(
'FANUZU',
style: TextStyle(
fontFamily: 'Pretendard',
fontSize: 20,
fontWeight: FontWeight.w900,
height: 1.4,
letterSpacing: -0.5,
color: AppColors.cosmicAccent,
),
),
tooltip: context.l10n.homeNotificationsTooltip,
),
],
const Spacer(),
_PopBalance(value: pop),
IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
style: IconButton.styleFrom(
minimumSize: const Size.square(42),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
padding: EdgeInsets.zero,
icon: const Image(
image: AssetImage('assets/figma/icon-wallet.png'),
width: 42,
height: 42,
),
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
onPressed: () => showItemSheet(
context,
items: items,
purchase: true,
fandomId: fandomId,
),
tooltip: context.l10n.homeItemsTooltip,
),
IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
// 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
style: IconButton.styleFrom(
minimumSize: const Size.square(42),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
padding: EdgeInsets.zero,
// 사본 `icon` 42 칸 안 lucide/bell 20.
iconSize: 20,
// 배지 모양은 Figma 에 그림이 없어 테마 기본 점 배지(강조색)를 쓴다.
icon: Badge(
key: const ValueKey('home-bell-badge'),
isLabelVisible: _unread,
smallSize: 6,
backgroundColor: AppColors.cosmicAccent,
child: const ImageIcon(
AppIcons.bell,
color: AppColors.onCosmic,
),
),
// 알림 — 화면 대조 원장 F0 · `229:11265`.
onPressed: () async {
await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<Object?>(
builder: (_) =>
NotificationScreen(repository: notifications),
),
);
await _checkUnread();
},
tooltip: context.l10n.homeNotificationsTooltip,
),
],
),
),
),
);
);
}
}
class _HomeBody extends StatelessWidget {
@@ -488,35 +534,7 @@ class _HomeBody extends StatelessWidget {
fallback: data.liveTicker,
),
onClose: () => onMap(false),
onSearch: () =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(
repository: zap!.repository,
onOpen: (p) =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) =>
PlanetDetailScreen(
repository:
planetRepository!(
p.id,
),
watchPlanet:
watchPlanet,
eclipse: eclipse,
),
),
),
),
),
),
onSearch: () => _openSearch(context),
)
: HomeHero(
key: const ValueKey('home-hero'),
@@ -604,9 +622,8 @@ class _HomeBody extends StatelessWidget {
myPop: data.popBalance,
),
),
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
onJoin: () =>
GoRouter.maybeOf(context)?.go('/galaxy'),
// 설명 프레임 `516:87335` 「행성 가입 버튼 - 클릭 시 Galaxy 검색 화면 이동」.
onJoin: () => _openSearch(context),
zapButtonKey: zapKey,
onHeroParticipate: _heroParticipate(context),
eclipseButton: data.fandomId.isEmpty
@@ -614,6 +631,7 @@ class _HomeBody extends StatelessWidget {
: EclipseEventButton(
repository: eclipse,
fandomId: data.fandomId,
nebula: (growth ?? data.growth).isNebula,
),
),
),
@@ -842,6 +860,33 @@ class _HomeBody extends StatelessWidget {
);
}
/// 갤럭시 검색 — 확장 맵 돋보기(`2008:14560`)와 첫 사용자 시트 「행성 가입」(`516:87335`).
/// 지도 저장소가 없으면(표본 모드) GALAXY 탭으로 물러난다.
void _openSearch(BuildContext context) {
final galaxyRepository = zap?.repository;
if (galaxyRepository == null || planetRepository == null) {
GoRouter.maybeOf(context)?.go('/galaxy');
return;
}
final navigator = Navigator.of(context, rootNavigator: true);
navigator.push(
MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(
repository: galaxyRepository,
onOpen: (p) => navigator.push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(p.id),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
),
),
);
}
VoidCallback? _heroParticipate(BuildContext context) =>
planetRepository == null || data.fandomId.isEmpty
? null
@@ -13,7 +13,9 @@ import '../../core/i18n/app_locale.dart';
/// 틀은 사본(이름 입력 → 성운 → 목표 영역 → 「성운 생성」)이고 숫자는 정책 원장이다.
/// B-10 — 성운은 **10명 이상 + 1,000 POP 이상**을 기본 목표 **7일** 안에 채우면 정식
/// 행성이 된다. 목표가 고정값이라 사본의 목표 슬라이더 자리에는 조작할 수 없는 고정
/// 표시를 둔다(사본 슬라이더 ↔ 원장 고정값, 해석 확인 문항). B-19 — 진화 참여자에게는
/// 표시를 둔다. 근거는 발주사 10/3 통합회신(원장 C-112) 「성운 생성은 10 명 + 1,000 POP +
/// 기본 목표 7 일, 사용자 목표 POP 조작 없음」 — Figma `516:87407` 목표 POP · 창립멤버 핸들러를
/// 이 회신이 대체한다. B-19 — 진화 참여자에게는
/// 기념 배지만 지급하므로 사본의 「1T 등급 · 거버넌스」 문장은 그 내용으로 바꾼다.
///
/// 사본이 함께 그린 다이얼로그 두 개를 그대로 쓴다.
@@ -52,6 +54,10 @@ class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
String? _takenName;
bool get _taken => _takenName != null && _takenName == _value;
/// 설명 프레임 `516:87397` 「성운명 입력 필드 - 유효성 검증 오류 메시지」 — 서버
/// `isValidNebulaName` 과 같은 규칙을 입력 칸에서 먼저 보인다(중복 · 금칙어는 서버 판정).
bool get _invalid => _name.text.isNotEmpty && !isValidNebulaName(_name.text);
@override
void initState() {
super.initState();
@@ -129,13 +135,15 @@ class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
),
),
const SizedBox(height: AppSpace.s5),
_NameInput(controller: _name, error: _taken),
_NameInput(controller: _name, error: _taken || _invalid),
const SizedBox(height: 6),
SizedBox(
height: 17,
child: _taken
child: _taken || _invalid
? Text(
context.l10n.nebulaNameTaken,
_taken
? context.l10n.nebulaNameTaken
: context.l10n.nebulaNameInvalid,
textAlign: TextAlign.center,
style: TextStyle(
fontFamily: AppFonts.display,
@@ -171,7 +179,7 @@ class _NebulaCreateScreenState extends State<NebulaCreateScreen> {
0,
),
child: FilledButton(
onPressed: _taken ? null : _confirm,
onPressed: _taken || _invalid ? null : _confirm,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
@@ -407,3 +415,10 @@ class _Target extends StatelessWidget {
],
);
}
/// 성운명 규칙 — 서버 `isValidNebulaName`(`account.ts`)과 같다: 2~20자 · 한글/영문/숫자/공백 ·
/// 앞뒤 공백 · 연속 공백 불가.
bool isValidNebulaName(String name) =>
RegExp(r'^[가-힣A-Za-z0-9 ]{2,20}$').hasMatch(name) &&
name == name.trim() &&
!name.contains(' ');
@@ -13,7 +13,9 @@ import '../../core/i18n/app_locale.dart';
///
/// 정책 원장 C-50 — 생성 때 고르는 것은 **최초 기본 모델의 형태와 컬러 두 가지**뿐이고,
/// 생성 뒤 사용자가 외형을 바꾸는 입구는 없다. 그래서 사본의 「오브젝트 색 선택」 줄과
/// 직접 색 고르기(스포이트) 칩은 두지 않는다.
/// 직접 색 고르기(스포이트) 칩은 두지 않는다. 사용자화(컬러 팔레트)를 빼는 근거는 발주사
/// 10/3 통합회신 #6(원장 C-115) 「사용자 자유 HEX/RGB 컬러피커는 적용하지 않음」 — Figma
/// `2066:17910` 의 「사용자화」를 이 회신이 대체한다. 「오브젝트 색」 줄은 Q-A14 회신 대기.
///
/// 형태 칸 · 미리보기는 행성 형태 시트 그림이다([planetShapeImage] — 6번 칸만 자리표시, Q-A14).
class PlanetCreateScreen extends StatefulWidget {
+34 -19
View File
@@ -314,32 +314,42 @@ class _Contribution extends StatelessWidget {
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 성운·첫 사용자 상태의 「{닉네임} 님의」는 기여 여부와 무관한 인사 줄이다.
if (growth.hasRecentContribution ||
((!joined || growth.isNebula) &&
growth.contributorName.trim().isNotEmpty)) ...[
// 「{닉네임} 님의」는 로그인 사용자의 인사 줄이다(설명 프레임 `2097:16391`).
if (growth.contributorName.trim().isNotEmpty) ...[
Row(
children: [
// 프로필 사진 자산이 데이터에 없어 이니셜로 그린다.
// 로그인 사용자의 프로필 사진(`2097:16391` 「프로필 - 이미지」). 없으면 이니셜.
Container(
width: 26,
height: 26,
alignment: Alignment.center,
decoration: const BoxDecoration(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
color: Color(0xFF9BF8A8),
boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)],
),
child: Text(
growth.contributorName.characters.firstOrNull ?? '—',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black,
),
color: const Color(0xFF9BF8A8),
boxShadow: const [
BoxShadow(color: Color(0xB3000000), blurRadius: 4),
],
image: growth.photoUrl == null
? null
: DecorationImage(
image: NetworkImage(growth.photoUrl!),
fit: BoxFit.cover,
onError: (_, _) {},
),
),
child: growth.photoUrl != null
? null
: Text(
growth.contributorName.characters.firstOrNull ?? '—',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
),
const SizedBox(width: 9),
Text(
@@ -686,8 +696,10 @@ class _Nebula extends StatelessWidget {
Semantics(
button: ready && onBirth != null,
label: context.l10n.heroNebula,
child: GestureDetector(
onTap: ready ? onBirth : null,
// Figma `2013:15377` 「성운 터치시 흔들림」 — 조건 전에도 흔들림 · 가벼운 햅틱으로 반응하고,
// 탄생 조건을 채웠으면 흔들린 뒤 행성 생성으로 간다.
child: _TapShake(
onTap: ready && onBirth != null ? onBirth : () {},
child: Image.asset(
'assets/figma/nebula-home.png',
width: 212,
@@ -1018,6 +1030,9 @@ class HomeZap extends StatelessWidget {
onItem: onItem,
zapPop: data.zapPop,
myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop,
// Figma `516:88102` Zap_버프(아이템 배수 · 지속시간)는 별첨 B B-22 「배수 기능 제거」 ·
// 10/3 통합회신(C-124) 「사용 시 Drop Pool 발생 후 HERO TIME 발동」이 대체한다 —
// 같은 팬덤의 아이템 사용으로 HERO TIME 이 열리면 ZAP 이 Drop POP 참여로 바뀐다.
isHeroTime: hero,
blockedReason: zap.sending
? context.l10n.zapApplying
+4 -19
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,20 +98,7 @@ class QuestRow extends StatelessWidget {
),
),
const SizedBox(height: 4),
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
if (quest.endsAt case final endsAt? when !done)
TimeAttackCountdown(
endsAt: endsAt,
builder: (context, left) => _sub(
left == null
? context.l10n.questTimeAttackClosed
: context.l10n.questTimeAttackLeft(
formatLeft(left),
),
),
)
else
_sub(quest.category ?? ''),
_sub(quest.category ?? ''),
],
),
),
@@ -12,6 +12,9 @@ import '../../core/ui/bottom_inset.dart';
/// 명세 `549:22136` — *완료 디폴트 비활성화 · 변경 사항 있을 경우 활성화 ·*
/// ***중복된 닉네임일 경우 닉네임 필드 아래 헬프 텍스트로 오류 메시지 노출***
///
/// 중복 오류는 **별첨 B 「닉네임」(2~20자 · 중복 허용 · 내부 UID 식별, 원장 B-52)** 이 대체했다 —
/// 중복을 이유로 막지 않는다. 헬프 텍스트 자리는 길이 · 예약어 오류에 쓴다.
///
/// 사진은 시안대로 MY 의 카메라 배지 → [ProfilePhotoScreen] 에서 바꾼다.
class ProfileEditScreen extends StatefulWidget {
const ProfileEditScreen({
+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,44 +70,52 @@ class _NotificationScreenState extends State<NotificationScreen> {
}
final groups = groupNotifications(items);
return Scaffold(
appBar: AppBar(
title: Text(context.l10n.homeNotificationsTooltip),
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
leading: BackButton(
onPressed: () => Navigator.of(context).pop({..._read}),
// 명세 `518:88347` — 뒤로가기는 *이전 화면 이동 및 읽음 상태값 반환*. 시스템 뒤로가기(Android
// 제스처 · iOS 스와이프)도 같은 값을 돌려준다.
return PopScope<Object?>(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (!didPop) Navigator.of(context).pop({..._read});
},
child: Scaffold(
appBar: AppBar(
title: Text(context.l10n.homeNotificationsTooltip),
// 명세 — 뒤로가기 시 읽음 상태값을 돌려준다.
leading: BackButton(
onPressed: () => Navigator.of(context).pop({..._read}),
),
),
),
body: items.isEmpty
? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium))
: ListView(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
children: [
for (final group in groups)
// 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄.
Container(
padding: const EdgeInsets.symmetric(vertical: 20),
foregroundDecoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFF1A1A1A)),
body: items.isEmpty
? Center(child: Text(context.l10n.notifEmpty, style: t.bodyMedium))
: ListView(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
children: [
for (final group in groups)
// 시안 날짜 묶음 — 위아래 20 · 머리글과 항목 사이 20 · 항목 사이 24 · 아래 #1a1a1a 줄.
Container(
padding: const EdgeInsets.symmetric(vertical: 20),
foregroundDecoration: const BoxDecoration(
border: Border(
bottom: BorderSide(color: Color(0xFF1A1A1A)),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
group.label,
style: _style(_grey, height: 24 / 14),
),
for (final (i, item) in group.items.indexed) ...[
SizedBox(height: i == 0 ? 20 : 24),
_tile(item),
],
],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
group.label,
style: _style(_grey, height: 24 / 14),
),
for (final (i, item) in group.items.indexed) ...[
SizedBox(height: i == 0 ? 20 : 24),
_tile(item),
],
],
),
),
],
),
],
),
),
);
}
@@ -187,8 +195,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
}
final screen = _screenFor(item);
if (screen == null || !mounted) return;
await Navigator.of(context)
.push<void>(MaterialPageRoute(builder: (_) => screen));
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => screen));
}
}
@@ -211,6 +220,7 @@ TextStyle _style(Color color, {double height = 1.3}) => TextStyle(
NotificationKind.campaign => (Colors.black, _icon('message_circle')),
NotificationKind.like => (const Color(0xFFFF5151), _icon('heart')),
NotificationKind.eclipse => (Colors.black, _icon('planet')),
NotificationKind.evolution => (Colors.black, _icon('planet')),
NotificationKind.itemUsed => (const Color(0xFFCEBF3B), _icon('water_ec')),
NotificationKind.quest => (
AppColors.cosmicSurfaceContainer,
+196 -78
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,84 +264,115 @@ class _RankingBody extends StatelessWidget {
}
final podium = scopeLabel != null && data.rows.length >= 3;
final rows = podium ? data.rows.skip(3).toList() : data.rows;
return ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다.
if (data.refreshMinutes case final minutes?) ...[
Text(
[
if (data.updatedAt case final at?)
context.l10n.planetLastUpdated(
'${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}',
),
context.l10n.rankRefreshEvery('$minutes'),
].join(' · '),
style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500),
),
const SizedBox(height: AppSpace.s3),
],
if (data.mine case final me?) ...[
Text(myLabel, style: _rt(24, AppColors.onCosmic, FontWeight.w500)),
const SizedBox(height: AppSpace.s4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
return NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n.metrics.extentAfter < 200) onEnd?.call();
return false;
},
child: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
// 별첨 1 ② 예외처리 — 갱신 주기 · 마지막 갱신 시각을 화면에서 안내한다.
if (data.refreshMinutes case final minutes?) ...[
Text(
[
if (data.updatedAt case final at?)
context.l10n.planetLastUpdated(
'${at.toLocal().hour.toString().padLeft(2, '0')}:${at.toLocal().minute.toString().padLeft(2, '0')}',
),
context.l10n.rankRefreshEvery('$minutes'),
].join(' · '),
style: _rt(12, AppColors.onCosmicMuted, FontWeight.w500),
),
const SizedBox(height: AppSpace.s3),
],
if (data.mine case final me?) ...[
Text(
myLabel,
style: _rt(24, AppColors.onCosmic, FontWeight.w500),
),
const SizedBox(height: AppSpace.s4),
Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(8),
),
child: _RankRow(row: me, index: -1, planet: planets, mark: false),
),
const SizedBox(height: 24),
],
if (scopeLabel case final label?) ...[
InkWell(
onTap: onPickScope,
child: Row(
children: [
Text(
label,
style: _rt(
18,
AppColors.onCosmic,
FontWeight.w600,
height: null,
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: onTapMine == null ? null : () => onTapMine!(me),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: _RankRow(
row: me,
index: -1,
planet: planets,
flag: flags,
mark: false,
),
),
const SizedBox(width: 4),
const Icon(
Icons.keyboard_arrow_down,
size: 20,
color: AppColors.onCosmic,
),
],
),
),
),
const SizedBox(height: 12),
],
if (data.rows.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Center(child: Text(emptyLabel)),
),
if (podium) ...[
_Podium(rows: data.rows.take(3).toList()),
const SizedBox(height: 12),
],
for (final (i, row) in rows.indexed)
InkWell(
onTap: onTapRow == null ? null : () => onTapRow!(row),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: _RankRow(row: row, index: i),
const SizedBox(height: 24),
],
if (scopeLabel case final label?) ...[
InkWell(
onTap: onPickScope,
child: Row(
children: [
Text(
label,
style: _rt(
18,
AppColors.onCosmic,
FontWeight.w600,
height: null,
),
),
const SizedBox(width: 4),
const Icon(
Icons.keyboard_arrow_down,
size: 20,
color: AppColors.onCosmic,
),
],
),
),
),
],
const SizedBox(height: 12),
],
if (data.rows.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 48),
child: Center(child: Text(emptyLabel)),
),
if (podium) ...[
_Podium(rows: data.rows.take(3).toList()),
const SizedBox(height: 12),
],
for (final (i, row) in rows.indexed)
InkWell(
onTap: onTapRow == null ? null : () => onTapRow!(row),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: _RankRow(row: row, index: i, planet: planets, flag: flags),
),
),
],
),
);
},
);
}
/// 국가 코드 → 국기 이모지. 미지정(`ZZ`) · 형식 밖은 흰 깃발.
String flagOf(String code) {
final c = code.toUpperCase();
if (c.length != 2 || c == 'ZZ' || !RegExp(r'^[A-Z]{2}$').hasMatch(c)) {
return '\u{1F3F3}';
}
return String.fromCharCodes(c.codeUnits.map((u) => 0x1F1E6 + u - 65));
}
TextStyle _rt(
double size,
Color color,
@@ -302,11 +408,14 @@ const _avatarColors = [
Color(0xFFD2F5B8),
];
Widget _avatar(int index, double size, {bool planet = false}) => planet
? Image.asset(
'assets/figma/planet-detail-hero.png',
width: size,
height: size,
Widget _avatar(int index, double size, {bool planet = false, FpiRankRow? row}) => planet
? SizedBox.square(
dimension: size,
child: PlanetArt(
fallback: 'assets/figma/planet-detail-hero.png',
planetType: row?.planetType,
colorKey: row?.colorKey,
),
)
: Container(
width: size,
@@ -348,12 +457,14 @@ class _RankRow extends StatelessWidget {
required this.row,
required this.index,
this.planet = false,
this.flag = false,
this.mark = true,
});
final FpiRankRow row;
final int index;
final bool planet;
final bool flag;
/// 목록 안 내 행에 「(나)」를 붙인다. 위 내 줄 카드는 시안대로 이름만 둔다.
final bool mark;
@@ -381,7 +492,14 @@ class _RankRow extends StatelessWidget {
// 사본 — 40 프레임 가운데 지름 36 원.
SizedBox.square(
dimension: 40,
child: Center(child: _avatar(index, 36, planet: planet)),
child: Center(
child: flag
? Text(
flagOf(row.id),
style: const TextStyle(fontSize: 28, height: 1),
)
: _avatar(index, 36, planet: planet, row: row),
),
),
const SizedBox(width: 6),
Expanded(
+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,14 +226,16 @@ class _PlanetDetailScreenState extends State<PlanetDetailScreen> {
),
);
}, destructive: false),
ActionSheetItem(
context.l10n.reportTitle,
() => _moderate(context, 'report', name),
),
ActionSheetItem(
context.l10n.commonBlockAction,
() => _moderate(context, 'block', name),
),
if (!mine) ...[
ActionSheetItem(
context.l10n.reportTitle,
() => _moderate(context, 'report', name),
),
ActionSheetItem(
context.l10n.commonBlockAction,
() => _moderate(context, 'block', name),
),
],
]);
Future<void> _moderate(
@@ -459,6 +465,7 @@ class _FpiTab extends StatelessWidget {
EclipseBanner(
repository: eclipse,
fandomId: detail.fandomId,
nebula: detail.isNebula,
margin: const EdgeInsets.only(bottom: AppSpace.s3),
),
Container(
@@ -177,6 +177,12 @@ class FirestorePlanetRepository implements PlanetRepository {
final member = memberSnap.data() ?? const <String, dynamic>{};
final posts = await postsF;
final targets = nebulaTargetsFrom((await policyF).data());
// 생성자 = `founderUid`(성운 생성자 · 탄생 전 탈퇴 시 승계자, C-100). 프로필 · 닉네임은 그 멤버 문서에서
// 읽는다(Figma `544:19765` 「생성자 프로필 · 닉네임」). 떠났거나 시드 행성이면 줄을 그리지 않는다.
final founderUid = fandom.strOr('founderUid');
final founder = founderUid.isEmpty
? null
: (await db.doc('fandoms/$fandomId/members/$founderUid').get()).data();
final axes = _map(fandom, 'fpiAxes');
final ranks = _map(fandom, 'fpiRanks');
@@ -199,6 +205,8 @@ class FirestorePlanetRepository implements PlanetRepository {
_ => null,
};
return PlanetDetail(
isOwner: founderUid.isNotEmpty && founderUid == uid,
ownerName: founder == null ? null : publicName(founder['nickname']),
joined: memberSnap.exists,
stage: stage,
fandomId: fandomId,
@@ -325,6 +333,8 @@ class FirestorePlanetRepository implements PlanetRepository {
// 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다.
change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank,
isMine: id == fandomId,
planetType: data['planetType'] is String ? data['planetType'] as String : null,
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
);
}
@@ -335,7 +345,11 @@ class FirestorePlanetRepository implements PlanetRepository {
required String fandomId,
int limit = 30,
}) async {
final ranking = await _userRanking(metric: metric, fandomId: fandomId, limit: limit);
final ranking = await _userRanking(
metric: metric,
fandomId: fandomId,
limit: limit,
);
final fandom = (await _backend.db.doc('fandoms/$fandomId').get()).data();
return ranking.withUpdate(_time(fandom?['memberRankedAt']), 5);
}
@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../planet_data.dart';
/// 팬덤 규모 칩 — Figma 「대형 팬덤」 `#AEC054` · 「중형 / 소형 팬덤」 `#54C07D`(바탕 10%, r4).
/// 행성 상세 히어로는 12/500 + 1px 테두리, GALAXY 목록은 10/500.
class FandomSizeBadge extends StatelessWidget {
const FandomSizeBadge({super.key, required this.members, this.large = false});
final int members;
/// 행성 상세 히어로 크기(12 · 테두리).
final bool large;
@override
Widget build(BuildContext context) {
final size = FandomSize.of(members);
final color = size == FandomSize.large
? const Color(0xFFAEC054)
: const Color(0xFF54C07D);
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.1),
border: large ? Border.all(color: color) : null,
borderRadius: BorderRadius.circular(4),
),
child: Text(
size.label,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: large ? 12 : 10,
fontWeight: FontWeight.w500,
height: 1,
color: color,
),
),
);
}
}
@@ -7,6 +7,7 @@ import '../../../core/ui/live_planet.dart';
import '../../../core/data/planet_visual_state.dart';
import '../../home/home_screen.dart' show formatPop;
import '../../../core/i18n/app_locale.dart';
import 'fandom_size_badge.dart';
/// 행성 상세 히어로 — 시안 `373:441117` 의 `Frame 1984081117`(375×543).
///
@@ -24,15 +25,20 @@ class PlanetDetailHero extends StatelessWidget {
this.nebula = false,
this.onHeroParticipate,
this.onJoin,
this.isOwner = false,
this.onOpenHistory,
this.fandomId = '',
this.watchPlanet = noPlanetWatch,
this.stage,
this.ownerName,
this.ownerPhoto,
this.sizeLabel,
this.onOpenOwner,
});
/// 개설자 줄을 눌렀을 때 — Figma `544:19765` 「프로필 및 닉네임 클릭 시 해당 사용자의 MY로 이동」.
/// 타인 프로필 화면(`549:21969`)이 생기면 연결한다(3차 MY). null 이면 누르지 않는다.
final VoidCallback? onOpenOwner;
final VoidCallback? onOpenHistory;
final String fandomId;
final PlanetWatch watchPlanet;
@@ -46,6 +52,9 @@ class PlanetDetailHero extends StatelessWidget {
/// 알약을 눌렀을 때 — 「가입하기」면 가입, 「가입중」이면 탈퇴 시트. null 이면 상태만 보인다.
final VoidCallback? onJoin;
/// 내가 만든 행성이면 가입 알약을 숨긴다(Figma `544:19765` 「가입 버튼 - 내가 생성했을 경우 hide」).
final bool isOwner;
/// 성운 상세(`373:442708`)는 규모 칩과 FPI 줄이 없다.
final bool nebula;
@@ -56,9 +65,6 @@ class PlanetDetailHero extends StatelessWidget {
/// 개설자 사진(URL 또는 에셋). 없으면 회색 원.
final String? ownerPhoto;
/// 팬덤 규모 라벨. 정책 §P-08 등급명이 붙기 전까지 시안 문구를 쓴다.
final String? sizeLabel;
static const double designHeight = 543;
/// 시안의 상태 표시줄 + 내비 높이. 본문은 그 아래 439 안에 가운데 놓인다.
@@ -74,7 +80,8 @@ class PlanetDetailHero extends StatelessWidget {
@override
Widget build(BuildContext context) {
// 1인당 화력 — 시안 「1.44」. 총 FPI 를 멤버 수로 나눈 값이며 정책 확정 전 표시용이다.
// 1인당 화력 — 시안 「1.44」. Figma `544:19765` 정의(「모든 포인트 획득, 소비 활동 및 1인당 활동 값을
// 집계한 값」)와 FPI 원천(C-34 활동 기준)이 갈려 해석 확인 중(전수대조 영역A Q-A23). 회신 전 임시로 총 FPI ÷ 멤버 수.
final perHead = members == 0 ? 0.0 : totalFpi / members;
const info = Color(0xFFDEDEDE);
@@ -111,31 +118,36 @@ class PlanetDetailHero extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (ownerName case final owner?) ...[
Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 13,
backgroundColor: const Color(0xFFD9D9D9),
backgroundImage: switch (ownerPhoto) {
final p? when p.startsWith('http') => NetworkImage(p),
final p? => AssetImage(p),
null => null,
},
),
const SizedBox(width: 9),
Text(
owner,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
fontSize: 16,
height: 1.3,
letterSpacing: -0.4,
color: AppColors.onCosmic,
GestureDetector(
onTap: onOpenOwner,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
CircleAvatar(
radius: 13,
backgroundColor: const Color(0xFFD9D9D9),
backgroundImage: switch (ownerPhoto) {
final p? when p.startsWith('http') => NetworkImage(
p,
),
final p? => AssetImage(p),
null => null,
},
),
),
],
const SizedBox(width: 9),
Text(
owner,
style: const TextStyle(
fontFamily: AppFonts.display,
fontWeight: FontWeight.w400,
fontSize: 16,
height: 1.3,
letterSpacing: -0.4,
color: AppColors.onCosmic,
),
),
],
),
),
const SizedBox(height: 25),
],
@@ -159,21 +171,8 @@ class PlanetDetailHero extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
if (!nebula) ...[
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 4,
),
decoration: BoxDecoration(
color: const Color(0x1A54C07D),
border: Border.all(color: const Color(0xFF54C07D)),
borderRadius: BorderRadius.circular(4),
),
child: Text(
sizeLabel ?? context.l10n.planetSizeMedium,
style: _body(12, const Color(0xFF54C07D)),
),
),
// 소형 · 중형 · 대형 — 가입자 수 기준(Figma `544:19765`).
FandomSizeBadge(members: members, large: true),
const SizedBox(width: 4),
],
Image.asset(
@@ -227,31 +226,32 @@ class PlanetDetailHero extends StatelessWidget {
),
const SizedBox(height: 12),
],
Semantics(
button: onJoin != null,
child: GestureDetector(
onTap: onJoin,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
joined
? context.l10n.planetJoined
: context.l10n.planetJoin,
style: _body(
14,
const Color(0xFFD5D7D9),
).copyWith(letterSpacing: -0.35),
if (!isOwner)
Semantics(
button: onJoin != null,
child: GestureDetector(
onTap: onJoin,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 8,
),
decoration: BoxDecoration(
color: const Color(0xFF1A1A1A),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
joined
? context.l10n.planetJoined
: context.l10n.planetJoin,
style: _body(
14,
const Color(0xFFD5D7D9),
).copyWith(letterSpacing: -0.35),
),
),
),
),
),
],
),
),
+73 -21
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
+39 -14
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,10 +150,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
value: 'cancel',
child: Text(context.l10n.questCancel),
),
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
),
if (stage != QuestStage.mine)
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportTitle),
),
],
),
],
@@ -406,9 +411,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
detail,
stage,
blocked,
// 정원이 찬 퀘스트도 종료다(명세 `523:18176` 종료 시점 1) — 시작 전인 사람만 막힌다.
closed:
detail.endsAt != null &&
!ServerClock.now().isBefore(detail.endsAt!),
(detail.endsAt != null &&
!ServerClock.now().isBefore(detail.endsAt!)) ||
(questFull(card) && stage == QuestStage.notStarted),
)
: TimeAttackCountdown(
endsAt: endsAt,
@@ -959,9 +966,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
// 기본 보상은 이미 원장에 있다. 광고는 그 위의 선택적 증분이다(원장 B-03 · C-122) —
// 광고가 실패해도 기본 보상은 그대로다.
final bonus =
await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ??
false;
// 일 한도를 다 쓴 사람에게는 모달을 띄우지 않는다(별첨 1 ⑥ 「일일 한도 도달 시 노출 제한」) —
// 띄우면 광고를 끝까지 보고도 증분 0 이다.
final adOpen = await widget.repository.adAvailable();
if (!mounted) return;
final bonus = adOpen &&
(await showQuestAdBonus(context, bonusPop: detail.card.adBonusPop) ??
false);
if (!mounted) return;
int? adPop;
if (bonus) {
@@ -979,6 +990,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
if (mounted) _toast(error.message);
}
if (!mounted) return;
} else if (_adShown) {
// 명세 `518:88645` — 광고 종료(100% 시청 또는 skip) 후 *광고 시청 후 애니메이션*. 끝까지
// 보지 않았으니 증분은 없다(B-03) — 애니메이션만 거쳐 완료 화면으로 간다.
await _warp(QuestAdWarpScreen.after(
until: Future<void>.delayed(const Duration(milliseconds: 1200)),
));
if (!mounted) return;
}
}
@@ -1003,7 +1021,11 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
/// 끝까지 봤으면 노출 ID, 아니면 null 이다. 불러오지 못했거나 중간에 닫았으면
/// 그 사실을 알리고 **기본 보상으로 넘어간다**(원장 B-03 — 실패 시 기본 보상 지급 ·
/// 횟수 미차감). 대기 중 「건너뛰기」를 누르면 늦게 온 광고는 띄우지 않고 버린다.
/// 이번 보상에서 광고가 실제로 화면에 떴는가 — skip 으로 닫았을 때도 시청 후 애니메이션을 거친다.
bool _adShown = false;
Future<String?> _watchAd(QuestReward reward) async {
_adShown = false;
RewardedAdHandle? ad;
BackendException? failure;
final loading = widget.repository
@@ -1030,6 +1052,7 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
}
return null;
}
_adShown = true;
final impression = await loaded.show();
if (impression == null && mounted) {
_toast(context.l10n.adNotFinished);
@@ -1065,14 +1088,16 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
rootNavigator: true,
).push<void>(MaterialPageRoute(builder: (_) => screen));
Future<void> _report(String title) async {
Future<void> _report(QuestCard card) async {
final reason = await Navigator.of(context, rootNavigator: true)
.push<String>(
MaterialPageRoute(
// 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다.
SheetRoute(
builder: (_) => ReportScreen(
// 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*. 작성자 칸에 제목이 들어가던 것을 바로잡음.
target: ModerationTarget(
authorName: title,
title: context.l10n.questNamed(title),
authorName: card.authorName,
title: card.title,
),
),
),
@@ -9,6 +9,7 @@ import 'quest_detail_screen.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart';
import '../../core/ui/sheet_route.dart';
/// 퀘스트 목록 — 화면 대조 원장 F4.
///
@@ -174,9 +175,11 @@ class _QuestListScreenState extends State<QuestListScreen> {
Future<void> _report(QuestCard card) async {
final reason = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
// 명세 `548:21389` — 신고 화면은 *페이지 아래 move out* 으로 닫힌다.
SheetRoute(
builder: (_) => ReportScreen(
target: ModerationTarget(authorName: card.title, title: context.l10n.questNamed(card.title)),
// 신고 정보 영역 — *작성자 닉네임 · 게시물 제목*(작성자 칸에 제목이 들어가던 것을 바로잡음).
target: ModerationTarget(authorName: card.authorName, title: card.title),
),
),
);
@@ -381,11 +384,14 @@ class _QuestListScreenState extends State<QuestListScreen> {
Future<void> _create() async {
final id = await Navigator.of(context, rootNavigator: true).push<String>(
MaterialPageRoute(
// 명세 `523:18123` — 닫기는 *하단 move out 페이지 종료*. 아래에서 올라와 아래로 닫힌다.
SheetRoute(
builder: (_) => QuestCreateScreen(repository: widget.repository),
),
);
if (id != null && mounted) {
// 명세 `523:18176` 생성 안내 모달 — *완료 - 퀘스트 Create 후 이전 퀘스트의 내 퀘스트 탭으로 이동*.
setState(() => _tab = QuestTab.mine);
// 공개 요청은 곧 공개가 아니다 — 운영 검수 뒤 공개된다(1주차 4항 · Q8).
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
@@ -133,7 +133,12 @@ class FirestoreQuestRepository implements QuestRepository {
} on Exception {
progress = const {};
}
cards.add(_card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest)));
final card = _card(doc.id, quest, progress, uid, policy, await _submission(doc.id, quest));
// 명세 `523:18176` 퀘스트 종료 시점 1 — *참여 인원 충족 시 퀘스트 종료*. 서버는 정원이 차면
// 참여를 거부하므로(미배정 Pool 0), 아직 참여하지 않은 사람에게는 열린 퀘스트로 보이지 않게 한다.
// 이미 참여한 사람(진행중 · 완료 탭)과 생성자(내 퀘스트)에게는 그대로 남긴다.
if (questFull(card) && card.stage == QuestStage.notStarted) continue;
cards.add(card);
}
return cards;
}
@@ -198,7 +203,7 @@ class FirestoreQuestRepository implements QuestRepository {
return QuestCard(
id: id,
title: questTitle(quest),
categoryName: type.label,
categoryName: type.category,
type: type,
author: authorUid.isEmpty ? QuestAuthor.admin : QuestAuthor.user,
stage: authorUid == uid
@@ -223,6 +228,7 @@ class FirestoreQuestRepository implements QuestRepository {
// 열린 TIME ATTACK 회차의 마감. 있으면 카드 · 상세가 남은 시간을 세고 참여는 회차로 간다.
endsAt: openTimeAttack(quest)?.endsAt,
publishState: authorUid == uid ? quest.strOr('status') : '',
authorName: quest.strOr('authorName'),
);
}
@@ -513,6 +519,29 @@ class FirestoreQuestRepository implements QuestRepository {
return wallet.data()?.intOr('activityBalance');
}
@override
Future<bool> adAvailable() async {
try {
final db = _backend.db;
final ad = (await db.doc('users/${_backend.requireUid}/questProgress/ad').get()).data();
// 서버 `progress(uid, 'ad', dayKey(now))` 와 같은 규약 — KST 날짜가 다르면 오늘 0회.
final today = ServerClock.now().toUtc().add(const Duration(hours: 9)).toIso8601String().substring(0, 10);
if (ad == null || ad['dayKey'] != today) return true;
final limit = (await _policy()).intOr('adDailyLimit', 5);
return ad.intOr('count') < limit;
} on Exception {
return true;
}
}
@override
Future<int?> spendableBalance() async {
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
final data = wallet.data();
if (data == null) return null;
return data.intOr('activityBalance') + data.intOr('supportBalance');
}
@override
Stream<int> progressStream(QuestCard card) {
if (card.type != QuestType.steps) return const Stream<int>.empty();
@@ -59,7 +59,16 @@ class _QuestRewardScreenState extends State<QuestRewardScreen> {
QuestDetail get detail => widget.detail;
int? get adBonusPop => widget.adBonusPop;
int? get rewardPop => widget.reward?.rewardedPop ?? detail.card.rewardPop;
/// 큰 숫자 — 운영 Quest 에서 광고를 봤으면 기본 보상 + 광고 증분 합계(명세 `518:88685` *광고 시청
/// POP = 보상 POP × 2 or ×3*, 배수는 B-03 운영값). 사용자 생성 Quest 는 배수가 아니라 고정 증분
/// (B-28 · C-02)이라 기본 보상만 크게 둔다. 두 값 모두 서버 지급액이고, 증분은 아래 줄에 기여 POP 으로
/// 따로 적는다(C-67 · C-122 유형 구분).
int? get rewardPop {
final base = widget.reward?.rewardedPop ?? detail.card.rewardPop;
final ad = widget.adBonusPop;
if (base == null || ad == null || detail.card.author != QuestAuthor.admin) return base;
return base + ad;
}
/// 결과 카드를 PNG 로 굽고 문구 · 해시태그(B-78)와 함께 OS 공유 시트로 보낸다.
Future<void> _share(BuildContext button) async {
@@ -198,7 +207,10 @@ class _QuestRewardScreenState extends State<QuestRewardScreen> {
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: FilledButton(
onPressed: () => Navigator.of(context).pop(true),
// 설명 프레임 `518:88326` 「확인 버튼 - 클릭 시, 홈 화면 이동」 — 퀘스트 상세 · 목록까지
// 닫고 첫 화면(탭 셸)으로 돌아간다. 퀘스트는 HOME 에서만 열린다.
onPressed: () =>
Navigator.of(context).popUntil((route) => route.isFirst),
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(