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
+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 ?? ''),
],
),
),