HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE

This commit is contained in:
Sn-Kinos
2026-10-05 16:29:41 +09:00
parent f84d696c21
commit 54d0b23260
45 changed files with 3448 additions and 1111 deletions
@@ -30,7 +30,22 @@ class GalaxyMiniMap extends StatefulWidget {
required this.data,
required this.onOpen,
this.size = 140,
});
}) : expanded = false,
onOpenPlanet = null;
/// 「GALAXY 맵 확장」 — Figma 「홈_초기사용자」(`343:211127`)의 히어로 전체 갤럭시 맵.
/// 부모 크기를 채우고 모든 행성에 프로토타입 HUD 를 그린다. 설명 프레임 「갤럭시맵 화면」
/// (`2008:14486`)의 동작 — 끌어 회전(프로토타입 View Rotation ±180° · 관성 없음) ·
/// 두 손가락 줌인/줌아웃 · 행성 탭 = 화면 중심 전환 · HUD 탭 = [onOpenPlanet](FPI 상세).
const GalaxyMiniMap.expanded({
super.key,
required this.data,
required ValueChanged<PlanetNode> this.onOpenPlanet,
}) : expanded = true,
onOpen = _noop,
size = 0;
static void _noop() {}
final GalaxySnapshot data;
final VoidCallback onOpen;
@@ -38,13 +53,18 @@ class GalaxyMiniMap extends StatefulWidget {
/// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
final double size;
final bool expanded;
final ValueChanged<PlanetNode>? onOpenPlanet;
@override
State<GalaxyMiniMap> createState() => _GalaxyMiniMapState();
}
class _GalaxyMiniMapState extends State<GalaxyMiniMap>
with SingleTickerProviderStateMixin {
final _painter = GalaxyMiniMapPainter();
late final _painter = widget.expanded
? GalaxyMiniMapPainter.figmaScale()
: GalaxyMiniMapPainter();
late final Ticker _ticker = createTicker(_tick);
Duration _last = Duration.zero;
@@ -92,8 +112,58 @@ class _GalaxyMiniMapState extends State<GalaxyMiniMap>
super.dispose();
}
double _zoomStart = 1;
Widget _expanded(BuildContext context) {
final mine = widget.data.mine;
return LayoutBuilder(
builder: (context, box) => Semantics(
label: [
if (mine != null)
...GalaxyMiniMapPainter.hudLines(mine)
else
'GALAXY',
].join(' · '),
onTap: mine == null ? null : () => widget.onOpenPlanet!(mine),
child: GestureDetector(
excludeFromSemantics: true,
onScaleStart: (_) => _zoomStart = _painter.zoom,
onScaleUpdate: (d) {
if (d.pointerCount > 1) {
_painter.zoomTo(_zoomStart * d.scale);
} else {
_painter.turn(d.focalPointDelta.dx / box.maxWidth * math.pi);
}
if (!_ticker.isActive) _painter.settle();
},
onTapUp: (d) {
final hit = _painter.hitAt(d.localPosition);
if (hit == null) return;
if (hit.hud) {
widget.onOpenPlanet!(hit.planet);
} else {
_painter.focus(hit.planet.id);
if (!_ticker.isActive) _painter.settle();
}
},
child: ExcludeSemantics(
child: RepaintBoundary(
child: CustomPaint(
size: box.biggest,
painter: _painter,
isComplex: true,
willChange: true,
),
),
),
),
),
);
}
@override
Widget build(BuildContext context) {
if (widget.expanded) return _expanded(context);
final mine = widget.data.mine;
return Semantics(
button: true,
@@ -153,7 +223,7 @@ const _emerald = Color(0xFF10B981);
/// 행성 색 — 팬덤 `colorKey` 를 행성 색 팔레트([planetColors])로 칠한다.
/// 키가 없거나 모르는 값이면 FPI 순번으로 프로토타입 팔레트를 돈다.
Color _planetColor(PlanetNode p, int fpiIndex) =>
Color galaxyPlanetColor(PlanetNode p, int fpiIndex) =>
planetColorOf(p.colorKey) ?? _palette[fpiIndex % _palette.length];
/// 프로토타입 게이지 기본값. 조절 UI 는 없다(C-51).
@@ -201,6 +271,10 @@ class _Body {
double wz = 0;
double depth = 0;
double hudW = 0;
/// 마지막 프레임의 화면 좌표·반지름·HUD 상자(탭 판정용).
double sx = 0, sy = 0, sr = 0;
Rect hudRect = Rect.zero;
ui.Picture? hud;
ui.Picture? sphere;
Float32List sat = Float32List(0);
@@ -273,6 +347,8 @@ ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
text: text,
style: TextStyle(
fontFamily: 'monospace',
// 프로토타입 JetBrains Mono 대신 기기 고정폭(Android monospace · iOS Menlo).
fontFamilyFallback: const ['Menlo', 'Courier', AppFonts.body],
fontSize: size,
fontWeight: weight,
color: c,
@@ -343,8 +419,27 @@ ui.Picture _spherePicture(Color c) {
/// 미니맵 장면 상태와 그리기. 상태는 위젯이 [advance]·[settle] 로 움직인다.
class GalaxyMiniMapPainter extends CustomPainter {
GalaxyMiniMapPainter() : this._(ValueNotifier<int>(0));
GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame);
GalaxyMiniMapPainter() : this._(ValueNotifier<int>(0), null);
/// HOME 「GALAXY 맵 확장」 — Figma `343:211127` 축척. 화면 크기와 무관하게 초점 거리를
/// [figmaFocal] 로 고정하고, HUD 카드는 [figmaHud] 고정 크기로 그린다.
GalaxyMiniMapPainter.figmaScale() : this._(ValueNotifier<int>(0), figmaFocal);
GalaxyMiniMapPainter._(this._frame, this.fixedFocal) : super(repaint: _frame);
/// 고정 초점 거리(pt). null 이면 프로토타입처럼 세로 시야각 70°(`focal = h/2/tan35°`).
final double? fixedFocal;
/// Figma `343:211127` 은 히어로(375)에 프로토타입 캡처(801×555)를 원래 픽셀 크기로 넣었다
/// (y124 · 가로 가운데 x−213). 그 캡처의 내 행성(NewJeans, FPI 3200 → 반지름 31 × Mass 1.5)
/// 지름이 38pt 다(Figma F1-05 · 343:211127 실측).
/// `지름 = 2 × 46.5 × focal / 4031(카메라 거리)` → focal ≈ 1650pt. 같은 시야각이면
/// 창 높이 약 2,310px 의 장면이다 — 556pt 창의 프로토타입보다 약 4.2배 크다.
static const figmaFocal = 1650.0;
/// Figma 캡처의 내 행성 HUD 카드 실측 약 144×68pt → 텍스처 비율 2:1 로 144×72.
/// 글자는 텍스처(512) 기준 이름 50 → 14pt · FPI 40 → 11pt · RANK/LIVE 24 → 6.75pt.
static const figmaHud = Size(144, 72);
final ValueNotifier<int> _frame;
@@ -367,6 +462,43 @@ class GalaxyMiniMapPainter extends CustomPainter {
double _gx = 0;
double _gz = 0;
/// 확장 맵의 줌(초점 거리 배율). 별밭은 그대로 두고 장면만 키운다.
double zoom = 1;
static const minZoom = 0.5, maxZoom = 3.0;
void zoomTo(double z) {
zoom = z.clamp(minZoom, maxZoom);
_frame.value++;
}
/// 확장 맵에서 고른 행성 — 카메라가 이 행성을 가운데로 따라간다(없으면 내 행성).
_Body? _focus;
void focus(String id) => _focus = _bodies[id] ?? _focus;
@visibleForTesting
String? get focusId => (_focus ?? _mine)?.id;
/// 마지막 프레임 기준 탭 판정 — HUD 카드가 위(프로토타입 depthTest: false)라 먼저 본다.
({PlanetNode planet, bool hud})? hitAt(Offset p) {
for (final b in _order.reversed) {
if (b.hudRect.contains(p)) return (planet: b.node, hud: true);
}
for (final b in _order.reversed) {
// 작은 행성도 손끝으로 잡히게 최소 반경 24.
if ((Offset(b.sx, b.sy) - p).distance <= math.max(b.sr, 24)) {
return (planet: b.node, hud: false);
}
}
return null;
}
/// 행성의 마지막 화면 좌표 — 테스트가 탭할 자리.
@visibleForTesting
Offset? screenOf(String id) =>
_bodies[id] == null ? null : Offset(_bodies[id]!.sx, _bodies[id]!.sy);
@visibleForTesting
Rect? hudRectOf(String id) => _bodies[id]?.hudRect;
/// 그리는 행성 수(내 행성은 순위 밖이어도 더한다).
static const maxPlanets = 10;
@@ -512,7 +644,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
(_bodies[p.id] ??= _Body(
p.id,
_bodies.length + 1.0,
)).set(p, _planetColor(p, i), share);
)).set(p, galaxyPlanetColor(p, i), share);
}
final ids = {for (final p in shown) p.id};
_bodies.removeWhere((id, b) {
@@ -524,6 +656,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
..clear()
..addAll(_bodies.values);
_mine = _order.where((b) => b.node.isMine).firstOrNull;
if (_focus != null && !_bodies.containsValue(_focus)) _focus = null;
_place(0);
_frame.value++;
}
@@ -559,7 +692,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
void _place(double follow) {
final c = math.cos(_rot);
final s = math.sin(_rot);
if (_mine case final m?) {
if (_focus ?? _mine case final m?) {
final a = _angle(m);
final lx = math.cos(a) * m.rad;
final lz = math.sin(a) * m.rad;
@@ -679,9 +812,10 @@ class GalaxyMiniMapPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final focal = size.height / 2 / _tanHalfFov;
if (size != _bgSize) _buildBackground(size, focal);
final bgFocal = size.height / 2 / _tanHalfFov;
if (size != _bgSize) _buildBackground(size, bgFocal);
canvas.drawPicture(_bg!);
final focal = (fixedFocal ?? bgFocal) * zoom;
// 궤도 링 — 갤럭시 원점 중심, 두께 = 반지름/100, 흰색 15%.
_project(_gx, 0, _gz, focal, size);
@@ -747,6 +881,10 @@ class GalaxyMiniMapPainter extends CustomPainter {
// 광륜 + 구체.
final rPx = r * focal / d;
b
..sx = px
..sy = py
..sr = rPx;
canvas.save();
canvas.translate(px, py);
canvas.scale(rPx);
@@ -778,17 +916,41 @@ class GalaxyMiniMapPainter extends CustomPainter {
final hudHeight = 57.5 * f;
// [fullHudMin] 보다 작으면 프로토타입 카드는 몇 px 라 읽히지 않는다 — 내 행성만
// 간략 카드로 그리고 다른 행성 카드는 뺀다(궤도·행성만).
final full = size.width >= fullHudMin;
final full = fixedFocal != null || size.width >= fullHudMin;
for (final b in _order) {
b.hudW = 0;
b.hudRect = Rect.zero;
if (!full && !identical(b, _mine)) continue;
if (b.depth < b.baseR * _scale * 2) continue;
final y = _scale * (b.baseR * _scale + hudHeight / 2 + 2);
_project(b.wx, y, b.wz, focal, size);
if (_sd < 1) continue;
final w = 115 * f * _scale * focal / _sd;
if (fixedFocal != null) {
// 고정 크기 카드 — 장면 축척 · 줌과 무관하게 같은 크기. 아래 변을 프로토타입 HUD
// 아래 변 자리(행성 위 HUD Dist 2)에 둔다.
const hud = figmaHud;
_project(b.wx, _scale * (b.baseR * _scale + 2), b.wz, focal, size);
if (_sd < 1) continue;
final rect = Rect.fromLTWH(
_sx - hud.width / 2,
_sy - hud.height,
hud.width,
hud.height,
);
b
..hudW = hud.width
..hudRect = rect;
canvas.save();
canvas.translate(rect.left, rect.top);
canvas.scale(hud.width / _hudW, hud.height / _hudH);
canvas.drawPicture(b.hud!);
canvas.restore();
continue;
}
if (full) {
b.hudW = w;
b.hudRect = Rect.fromLTWH(_sx - w / 2, _sy - w / 4, w, w / 2);
canvas.save();
canvas.translate(_sx - w / 2, _sy - w / 4);
canvas.scale(w / _hudW, w / 2 / _hudH);
@@ -0,0 +1,206 @@
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../galaxy_data.dart';
import 'galaxy_mini_map.dart';
/// HOME GALAXY 미니맵 · 「GALAXY 맵 확장」 — 발주사 프로토타입 v6.2 HTML 을 그대로 띄운다
/// (`assets/galaxy/uzu_hud.html` · 9/22 D-6 · 2026-10-05 대표 지시 「html 을 클론코딩하듯」).
///
/// 한 위젯이 두 모드를 다 맡는다. [expanded] 가 바뀌면 WebView 를 새로 만들지 않고 JS
/// `setMode` 만 부른다 — HOME 은 같은 [GlobalKey] 로 미니맵 자리와 확장 자리 사이를 옮긴다.
///
/// - 데이터: [GalaxySnapshot] 의 FPI 상위 10 + 내 행성 → `setGalaxy(json)`.
/// - 이벤트: HTML 이 `FanuzuBridge.postMessage` 로 보낸다 — `open`(미니맵 탭) · `hud`(HUD 탭 →
/// [onOpenPlanet]) · `planet`(행성 탭, 중심 전환은 HTML 이 이미 했다).
/// - 화면 밖 · 다른 화면에 가림 · 앱 백그라운드면 `fanuzuPause(true)` 로 rAF 를 멈춘다.
/// - 플랫폼 뷰가 없는 곳(위젯 테스트 · 데스크톱)에서는 같은 장면을 옮긴 [GalaxyMiniMap] 으로 대신한다.
class GalaxyWebView extends StatefulWidget {
const GalaxyWebView({
super.key,
required this.data,
required this.expanded,
required this.onOpen,
required this.onOpenPlanet,
this.miniSize = 93,
});
final GalaxySnapshot data;
final bool expanded;
final double miniSize;
final VoidCallback onOpen;
final ValueChanged<PlanetNode> onOpenPlanet;
/// WebView 를 쓸 수 있는가. 테스트에서 끄거나 켤 수 있다.
static bool enabled =
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS) &&
!Platform.environment.containsKey('FLUTTER_TEST');
/// HTML 로 넘기는 행성 목록 — FPI 상위 10 + 순위 밖 내 행성(미니맵 painter 와 같은 규칙).
@visibleForTesting
static List<Map<String, Object>> payload(GalaxySnapshot data) {
final byFpi = [...data.planets]..sort((a, b) => b.fpi.compareTo(a.fpi));
final shown = byFpi.take(GalaxyMiniMapPainter.maxPlanets).toList();
final mine = data.mine;
if (mine != null && !shown.contains(mine)) shown.add(mine);
return [
for (final (i, p) in shown.indexed)
{
'id': p.id,
'name': p.name,
'rank': p.rank,
'fpi': p.fpi,
'live': p.online,
'color': galaxyPlanetColor(p, i).toARGB32() & 0xFFFFFF,
'mine': p.isMine,
},
];
}
@override
State<GalaxyWebView> createState() => _GalaxyWebViewState();
}
class _GalaxyWebViewState extends State<GalaxyWebView>
with WidgetsBindingObserver {
WebViewController? _web;
bool _ready = false;
bool? _paused;
bool _foreground = true;
ScrollPosition? _scroll;
@override
void initState() {
super.initState();
if (!GalaxyWebView.enabled) return;
WidgetsBinding.instance.addObserver(this);
_web = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel('FanuzuBridge', onMessageReceived: _onMessage)
..loadFlutterAsset('assets/galaxy/uzu_hud.html');
}
void _js(String code) {
if (_ready) _web?.runJavaScript(code);
}
void _push() {
_js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})');
_js('setGalaxy(${jsonEncode(GalaxyWebView.payload(widget.data))})');
}
void _onMessage(JavaScriptMessage m) {
final msg = jsonDecode(m.message) as Map<String, dynamic>;
switch (msg['type']) {
case 'ready':
_ready = true;
_push();
_paused = null;
_syncPause();
case 'open':
widget.onOpen();
case 'hud':
final p = widget.data.planets
.where((p) => p.id == msg['id'])
.firstOrNull;
if (p != null) widget.onOpenPlanet(p);
}
}
@override
void didUpdateWidget(GalaxyWebView old) {
super.didUpdateWidget(old);
if (_web == null) return;
if (!identical(old.data, widget.data)) {
_push();
} else if (old.expanded != widget.expanded) {
_js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})');
}
WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_web == null) return;
final scroll = Scrollable.maybeOf(context)?.position;
if (!identical(scroll, _scroll)) {
_scroll?.removeListener(_syncPause);
_scroll = scroll?..addListener(_syncPause);
}
// 다른 화면이 위에 올라오거나(ModalRoute) 탭이 바뀌면(TickerMode) 다시 판단한다.
ModalRoute.of(context);
TickerMode.valuesOf(context).enabled;
WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_foreground = state == AppLifecycleState.resumed;
_syncPause();
}
bool get _visible {
if (!mounted || !_foreground || !TickerMode.valuesOf(context).enabled) return false;
if (ModalRoute.of(context)?.isCurrent == false) return false;
final box = context.findRenderObject();
if (box is! RenderBox || !box.attached || !box.hasSize) return true;
final top = box.localToGlobal(Offset.zero).dy;
return top + box.size.height > 0 && top < MediaQuery.sizeOf(context).height;
}
void _syncPause() {
if (!_ready || !mounted) return;
final pause = !_visible;
if (pause == _paused) return;
_paused = pause;
_js('fanuzuPause($pause)');
}
@override
void dispose() {
_scroll?.removeListener(_syncPause);
if (_web != null) WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
final web = _web;
if (web == null) {
return widget.expanded
? GalaxyMiniMap.expanded(
data: widget.data,
onOpenPlanet: widget.onOpenPlanet,
)
: GalaxyMiniMap(
size: widget.miniSize,
data: widget.data,
onOpen: widget.onOpen,
);
}
final view = WebViewWidget(
controller: web,
// 확장 맵은 끌기 · 핀치를 모두 장면이 받는다(설명 프레임 2008:14486). 미니맵은 가로 끌기
// (회전)와 탭만 — 세로 끌기는 HOME 목록 스크롤로 남긴다.
gestureRecognizers: widget.expanded
? {Factory<OneSequenceGestureRecognizer>(EagerGestureRecognizer.new)}
: {
Factory<OneSequenceGestureRecognizer>(
HorizontalDragGestureRecognizer.new,
),
Factory<OneSequenceGestureRecognizer>(TapGestureRecognizer.new),
},
);
return widget.expanded
? view
: SizedBox.square(dimension: widget.miniSize, child: view);
}
}
+119 -59
View File
@@ -9,6 +9,8 @@
library;
import '../../core/i18n/app_locale.dart';
import '../quest/quest_data.dart' show QuestType;
import '../inside/inside_data.dart' show InsideQuestion, SampleInsideRepository;
/// 팬덤 행성의 성장 상태. 정책 근거는 정책 설계서 §P-08.
class PlanetGrowth {
@@ -85,6 +87,45 @@ class PlanetGrowth {
? appL10n.growthPromotionPending
: appL10n.growthCheckConditions;
/// 「행성 성장률 N%」 · 「현재 / 목표」의 기준 — 두 조건 중 **진행률이 낮은 쪽(병목)**.
///
/// Figma 「홈」(`2097:16428` `Frame 1984081073`)은 성장률 하나 · 수치 한 쌍이고, 정책은
/// 두 조건 동시 충족이다(원장 B-11 · 성운은 POP + 창립 인원 B-10). 다음 진화는 늦은 쪽이
/// 채워질 때 오므로 그 축을 보인다. 두 조건 상세는 말풍선을 누르면 연다.
/// ponytail: 해석 확인 전 임시 기준(HOME 정보위계 v1.0 §3 은 「단일 백분율로 합치지 않음」).
/// 값을 모르는 축은 건너뛰고, 둘 다 모르면 null.
({double ratio, num current, num required})? get bottleneck {
final axes = [
(currentPop, requiredPop),
isNebula ? (members, requiredMembers) : (currentSfpi, requiredSfpi),
];
({double ratio, num current, num required})? low;
for (final (cur, req) in axes) {
if (cur == null || req == null || req <= 0 || !cur.isFinite) continue;
final r = (cur / req).clamp(0.0, 1.0).toDouble();
if (low == null || r < low.ratio) {
low = (ratio: r, current: cur, required: req);
}
}
return low;
}
/// 말풍선 문구. Figma 문구는 「다음 진화까지 얼마 안남았어요!」 하나다 — 병목 진행률이
/// 절반 이상일 때만 그 문구를 쓰고, 그 아래는 「성장 조건 확인」(누르면 두 조건 상세).
/// ponytail: 절반 기준은 근거 없는 임시값 — 상태별 문구가 정해지면 바꾼다.
String get growthBubble => stage == 6
? appL10n.growthFinalStage
: _met(currentPop, requiredPop) == true &&
_met(
isNebula ? members : currentSfpi,
isNebula ? requiredMembers : requiredSfpi,
) ==
true
? appL10n.growthPromotionPending
: (bottleneck?.ratio ?? 0) >= 0.5
? appL10n.heroNextEvolution
: appL10n.growthCheckConditions;
double? get popRatio =>
currentPop == null || requiredPop == null || requiredPop! <= 0
? null
@@ -129,10 +170,19 @@ class QuestSummary {
this.progressLabel,
this.id = '',
this.endsAt,
this.type,
this.category,
});
final String title;
/// 보조 줄 — 퀘스트 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」). 퀘스트 목록의
/// 카테고리 이름과 같은 원천(`QuestType.label`).
final String? category;
/// `quests.type`. 줄 왼쪽 아이콘을 고르는 데만 쓴다(없으면 제목으로 고른다).
final QuestType? type;
/// 보상 POP — 서버 지급액과 같은 원천. 못 읽으면 null.
final int? rewardPop;
final QuestState state;
@@ -224,6 +274,7 @@ class BriefingItem {
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
class CampaignHighlight {
const CampaignHighlight({
this.id = '',
required this.authorName,
required this.achievedPercent,
required this.title,
@@ -233,6 +284,8 @@ class CampaignHighlight {
required this.likes,
});
/// `campaigns/{id}`. 「캠페인 보기」가 상세로 바로 간다(명세 §2.114-6). 비면 목록으로.
final String id;
final String authorName;
final int achievedPercent;
final String title;
@@ -242,25 +295,22 @@ class CampaignHighlight {
final int likes;
}
/// 인기 K-INSIDE 한 건.
class InsideDigest {
const InsideDigest({
required this.authorName,
required this.timeAgo,
required this.bountyPop,
required this.title,
required this.body,
required this.answers,
/// 팬덤행성 인큐베이터(성운 추천) 한 장 — 명세 §2.114-4 「성운명 · 가입 인원수 · 성운
/// 이미지 · 기여율% · 기여율 그래프 · 함께 성장하기」(`2097:16403`).
class NebulaPick {
const NebulaPick({
required this.id,
required this.name,
required this.members,
required this.percent,
});
final String authorName;
final String timeAgo;
final String id;
final String name;
final int members;
/// 현상금. 정책 설계서 §P-11 — 채택 기한은 아직 미확정이다.
final int bountyPop;
final String title;
final String body;
final int answers;
/// 기여율 — 누적 POP ÷ 성운 전환 목표 POP(`config/policy.nebulaMinPop`, B-10). 0~100.
final int percent;
}
/// 홈 한 화면이 필요로 하는 것 전부.
@@ -272,9 +322,10 @@ class HomeSnapshot {
required this.growth,
required this.quests,
required this.briefings,
required this.campaign,
this.campaigns = const [],
required this.insides,
this.items = const [],
this.nebulas = const [],
});
/// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고
@@ -293,8 +344,13 @@ class HomeSnapshot {
final PlanetGrowth growth;
final List<QuestSummary> quests;
final List<BriefingItem> briefings;
final CampaignHighlight? campaign;
final List<InsideDigest> insides;
/// HOT ARCHIVE 캐러셀 — 최대 5건(명세 §2.114-6).
final List<CampaignHighlight> campaigns;
final List<InsideQuestion> insides;
/// 팬덤행성 인큐베이터 — 기여율 높은 순 최대 6개(명세 §2.114-4).
final List<NebulaPick> nebulas;
/// 헤더 지갑 · ZAP 「아이템」 시트의 상품. Admin Catalog(B-84 · B-85)
/// `heroGiftProducts` 에서 온다.
@@ -339,6 +395,11 @@ abstract interface class HomeRepository {
Future<HomeSnapshot> load();
Stream<PlanetGrowth> watchGrowth(String fandomId);
/// 보유 활동 POP — `users/{uid}/wallet/main.activityBalance` 구독. 퀘스트 목록 헤더
/// (`QuestRepository.popBalance`)와 같은 문서라, 다른 화면에서 생긴 변동(현상금 예치 ·
/// 보상)이 HOME 헤더에도 바로 반영된다.
Stream<int> watchPopBalance();
/// 성운(Stage0) 생성 — `fn-nebula-create`(원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
/// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다.
Future<({String fandomId, bool primaryChanged})> createNebula(String name);
@@ -384,6 +445,9 @@ class SampleHomeRepository implements HomeRepository {
@override
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
@override
Stream<int> watchPopBalance() => const Stream.empty();
@override
Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets;
@@ -413,30 +477,51 @@ class SampleHomeRepository implements HomeRepository {
currentPop: 6270,
requiredPop: 10000,
),
quests: [
// 사본 「홈」 `2097:16428` 표본 문구.
quests: const [
QuestSummary(
title: '오늘의 출석체크',
title: '출석체크',
category: '오늘의 출석체크',
type: QuestType.attend,
// 서버 `config/policy.attendPop` 기본값과 맞춘다. Figma 의 50 이 아니다.
rewardPop: 10,
state: QuestState.claimable,
),
QuestSummary(
title: '만보기',
rewardPop: 150,
title: '좋아요',
category: '커뮤니티',
rewardPop: 5,
state: QuestState.inProgress,
),
QuestSummary(
title: '댓글',
category: '커뮤니티',
rewardPop: 5,
state: QuestState.inProgress,
progressLabel: '0/1,500 걸음',
),
],
briefings: _sampleBriefings,
campaign: CampaignHighlight(
authorName: '다이브짱',
achievedPercent: 92,
title: '강남역 초대형 LED 광고',
description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.',
supporters: 48,
comments: 24,
likes: 82,
),
campaigns: const [
CampaignHighlight(
authorName: '다이브짱',
achievedPercent: 92,
title: '강남역 초대형 LED 광고',
description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.',
supporters: 48,
comments: 24,
likes: 48,
),
],
nebulas: const [
NebulaPick(id: 'nebula-rm', name: 'BTS_RM', members: 1280, percent: 98),
NebulaPick(id: 'nebula-jin', name: 'BTS_JIN', members: 940, percent: 81),
NebulaPick(
id: 'nebula-suga',
name: 'BTS_SUGA',
members: 610,
percent: 64,
),
],
// 사본 `아이템 구입` 표본(Admin 값 아님).
items: const [
ItemProduct(
@@ -464,32 +549,7 @@ class SampleHomeRepository implements HomeRepository {
owned: 10,
),
],
insides: [
InsideDigest(
authorName: '아미보이',
timeAgo: '10분 전',
bountyPop: 500,
title: '화양연화 세계관 해석해주실 분?',
body: '이번 뮤비랑 연결되는 부분이 헷갈리네요. 타임라인 정리해주실 천재 아미 구합니다!',
answers: 3,
),
InsideDigest(
authorName: '다이브짱',
timeAgo: '1시간 전',
bountyPop: 200,
title: '공방 신청 꿀팁 공유합니다.',
body: '공방 신청할 때 폼 가장 빨리 제출하는 손크로 팁 알려드립! 궁금하신 분 질문 남겨주세요',
answers: 8,
),
InsideDigest(
authorName: '네누',
timeAgo: '3시간 전',
bountyPop: 100,
title: 'BTS 이 포즈 뭔가요?',
body: '아미 분들 이 포즈 뭔지 알려주실 수 있나요?',
answers: 8,
),
],
insides: SampleInsideRepository.samples.take(3).toList(),
);
}
@@ -4,6 +4,7 @@ import '../../core/data/backend.dart';
import '../../core/data/invite_code.dart';
import '../../core/data/server_clock.dart';
import 'home_data.dart';
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
import '../quest/quest_data.dart' show QuestType;
import '../quest/quest_repository_firestore.dart' show questToday;
import '../../core/i18n/app_locale.dart';
@@ -30,6 +31,14 @@ class FirestoreHomeRepository implements HomeRepository {
.asyncMap((s) => _growth(s.data() ?? const {}, fandomId));
}
@override
Stream<int> watchPopBalance() => _backend.db
.doc('users/${_backend.requireUid}/wallet/main')
.snapshots()
.map(
(s) => (s.data() ?? const <String, dynamic>{}).intOr('activityBalance'),
);
@override
Future<({String fandomId, bool primaryChanged})> createNebula(
String name,
@@ -82,10 +91,13 @@ class FirestoreHomeRepository implements HomeRepository {
// 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다.
final walletF = db.doc('users/$uid/wallet/main').get();
final fandomF = fandomId.isEmpty ? null : db.doc('fandoms/$fandomId').get();
// TODAY'S QUEST — 설명 프레임 `2097:16397` 「관리자 퀘스트 최신순 최대 3개」. 사용자 생성
// 퀘스트(`authorUid` 있음)는 HOME 에 띄우지 않는다. 정렬 · 거르기는 아래에서 한다.
// ponytail: 활성 30건 안에서만 고른다. (active, createdAt) 색인을 배포하면 질의로 옮긴다.
final questsF = db
.collection('quests')
.where('active', isEqualTo: true)
.limit(5)
.limit(30)
.get();
// 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에
// 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`.
@@ -124,12 +136,21 @@ class FirestoreHomeRepository implements HomeRepository {
.orderBy('rank')
.limit(6)
.get();
// 차단한 개설자의 캠페인은 건너뛴다 — 다섯 건을 읽어 첫 비차단 캠페인을 쓴다.
// HOT ARCHIVE 캐러셀 — 명세 §2.114-6 「최대 5개」. 차단한 개설자의 캠페인은 건너뛰므로
// 열 건을 읽어 다섯을 채운다. ponytail: 정렬은 모금액 순 하나(명세의 「달성 임박 > 기여 급증 >
// 신규」 3단 정렬은 기여 급증 집계가 없어 아직 못 한다).
final campaignF = db
.collection('campaigns')
.where('status', isEqualTo: 'funding')
.orderBy('raisedPop', descending: true)
.limit(5)
.limit(10)
.get();
// 팬덤행성 인큐베이터 — 명세 §2.114-4 「기여율 높은 순 최대 6개」. 성운(Stage0)만.
// ponytail: 성운 30건 안에서 고른다. (stage, growthPop) 색인을 배포하면 질의로 옮긴다.
final nebulasF = db
.collection('fandoms')
.where('stage', isEqualTo: 0)
.limit(30)
.get();
final blocksF = db.collection('users/$uid/blocks').get();
// Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
@@ -163,17 +184,24 @@ class FirestoreHomeRepository implements HomeRepository {
return to is Timestamp && to.toDate().isAfter(serverNow);
}),
// 타임어택은 위에서 창이 열린 것만 받았다. 여기서 다시 받으면 마감된 것이 남는다.
...(await questsF).docs.where((d) => d.data()['type'] != 'timeattack'),
];
...adminQuestsLatest([
for (final d in (await questsF).docs)
if (d.data()['type'] != 'timeattack') d,
], (d) => d.data()),
].take(3).toList();
final progress = (await progressF).docs;
final briefings = (await briefingsF).docs;
final blocked = {for (final d in (await blocksF).docs) d.id};
final campaigns = (await campaignF).docs
.where((d) => !blocked.contains(d.data()['creatorUid']))
.toList();
final insides = popularInsides([
for (final doc in (await insidesF).docs) doc.data(),
]);
final insideDocs = {
for (final doc in (await insidesF).docs) doc.data(): doc.id,
};
final insides = [
for (final data in popularInsides(insideDocs.keys.toList()))
(insideDocs[data]!, data),
];
final progressById = {for (final doc in progress) doc.id: doc.data()};
final policy = (await policyF).data() ?? const <String, dynamic>{};
@@ -190,8 +218,13 @@ class FirestoreHomeRepository implements HomeRepository {
_quest(doc.id, doc.data(), progressById[doc.id], policy),
],
briefings: [for (final doc in briefings) ?_briefing(doc.data())],
campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.data()),
insides: [for (final data in insides) _inside(data)],
campaigns: [for (final d in campaigns.take(5)) _campaign(d.id, d.data())],
insides: [
for (final (id, data) in insides) insideQuestionFrom(id, data, uid),
],
nebulas: nebulaPicks([
for (final d in (await nebulasF).docs) (d.id, d.data()),
], nebulaTargetsFrom(policy).pop),
items: [for (final doc in (await itemsF).docs) _item(doc.id, doc.data())],
);
}
@@ -304,11 +337,17 @@ class FirestoreHomeRepository implements HomeRepository {
Map<String, dynamic> policy,
) {
// 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
final (:count, :done) =
questToday(QuestType.parse(quest.strOr('type')), quest, progress, policy);
final (:count, :done) = questToday(
QuestType.parse(quest.strOr('type')),
quest,
progress,
policy,
);
final started = count > 0;
return QuestSummary(
title: quest.strOr('title'),
type: QuestType.parse(quest.strOr('type')),
category: QuestType.parse(quest.strOr('type')).label,
rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
state: done
? QuestState.done
@@ -325,11 +364,12 @@ class FirestoreHomeRepository implements HomeRepository {
);
}
CampaignHighlight _campaign(Map<String, dynamic> data) {
CampaignHighlight _campaign(String id, Map<String, dynamic> data) {
// 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다.
final goal = data.intOr('targetPop', data.intOr('goalPop'));
final raised = data.intOr('raisedPop');
return CampaignHighlight(
id: id,
authorName: publicName(data['creatorName'] ?? data['authorName']),
achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(),
title: data.strOr('title'),
@@ -339,27 +379,6 @@ class FirestoreHomeRepository implements HomeRepository {
likes: data.intOr('likeCount'),
);
}
InsideDigest _inside(Map<String, dynamic> data) {
final created = data.dateOr('createdAt', DateTime.now());
return InsideDigest(
authorName: publicName(data['creatorName'] ?? data['authorName']),
timeAgo: _timeAgo(created),
bountyPop: data.intOr('bountyPop'),
title: data.strOr('title'),
body: data.strOr('body'),
answers: data.intOr('answerCount'),
);
}
}
/// 상대 시각. 화면이 「10분 전」 형태를 그대로 쓰므로 여기서 문자열로 만든다.
String _timeAgo(DateTime at) {
final gap = DateTime.now().difference(at);
if (gap.inMinutes < 1) return appL10n.timeJustNow;
if (gap.inHours < 1) return appL10n.timeMinutesAgo('${gap.inMinutes}');
if (gap.inDays < 1) return appL10n.timeHoursAgo('${gap.inHours}');
return appL10n.timeDaysAgo('${gap.inDays}');
}
/// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
@@ -380,3 +399,43 @@ String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc())
.add(const Duration(hours: 9))
.toIso8601String()
.substring(0, 10);
/// HOME TODAY'S QUEST 의 관리자 퀘스트 — `authorUid` 가 없는 것만, `createdAt` 최신순
/// (설명 프레임 `2097:16397` 「관리자 퀘스트 - 최신순으로 최대 3개 노출」).
List<T> adminQuestsLatest<T>(
Iterable<T> docs,
Map<String, dynamic> Function(T) data,
) {
DateTime at(T d) => switch (data(d)['createdAt']) {
Timestamp t => t.toDate(),
DateTime t => t,
_ => DateTime.fromMillisecondsSinceEpoch(0),
};
return [
for (final d in docs)
if ((data(d)['authorUid'] ?? '').toString().isEmpty) d,
]..sort((a, b) => at(b).compareTo(at(a)));
}
/// 인큐베이터 카드 — 기여율(누적 POP ÷ 성운 전환 목표 POP) 높은 순 최대 6개.
List<NebulaPick> nebulaPicks(
Iterable<(String, Map<String, dynamic>)> docs,
int goalPop,
) {
final picks = [
for (final (id, d) in docs)
NebulaPick(
id: id,
name: d.strOr('name', id),
members: d.intOr('memberCount'),
percent: goalPop <= 0
? 0
: ((d['growthPop'] is num ? d['growthPop'] as num : 0) /
goalPop *
100)
.clamp(0, 100)
.floor(),
),
]..sort((a, b) => b.percent.compareTo(a.percent));
return picks.take(6).toList();
}
+429 -278
View File
@@ -2,8 +2,8 @@
export '../../core/format.dart' show formatPop;
import '../planet/planet_history_screen.dart';
import '../galaxy/widgets/galaxy_mini_map.dart';
import '../galaxy/galaxy_screen.dart';
import '../galaxy/widgets/galaxy_web_view.dart';
import '../galaxy/galaxy_search_screen.dart';
import '../galaxy/galaxy_data.dart';
import '../galaxy/zap_controller.dart';
import '../planet/hero_participation_sheet.dart';
@@ -15,9 +15,9 @@ 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_icons.dart';
import '../../core/data/planet_visual_state.dart';
import '../../core/ui/live_stream.dart';
import '../../core/ui/section_header.dart';
import '../eclipse/eclipse_data.dart';
import '../eclipse/widgets/eclipse_banner.dart';
import '../eclipse/widgets/live_proof.dart';
@@ -25,10 +25,13 @@ import 'home_data.dart';
import 'nebula_create_screen.dart';
import 'planet_create_screen.dart';
import 'widgets/briefing_list.dart';
import 'widgets/campaign_card.dart';
import 'widgets/home_hero.dart';
import 'widgets/item_sheet.dart';
import 'widgets/inside_card.dart';
import 'widgets/home_sections.dart';
import '../../core/router/app_router.dart' show appTabs;
import '../inside/inside_data.dart' show InsideRepository;
import '../inside/inside_detail_screen.dart';
import '../campaign/campaign_detail_screen.dart';
import 'widgets/live_ticker.dart';
import '../campaign/campaign_data.dart' show CampaignRepository;
import '../campaign/campaign_create_screen.dart';
@@ -63,9 +66,13 @@ class HomeScreen extends StatefulWidget {
required this.eclipse,
this.notifications = const SampleNotificationRepository(),
this.campaignRepository,
this.insideRepository,
});
final HomeRepository repository;
/// 인기 K-INSIDE 카드가 여는 상세의 저장소.
final InsideRepository? insideRepository;
final PlanetWatch watchPlanet;
final PlanetRepositoryFactory? planetRepository;
final GalaxyRepository? galaxyRepository;
@@ -93,6 +100,13 @@ class _HomeScreenState extends State<HomeScreen> {
Future<GalaxySnapshot>? _galaxy;
ZapController? _zap;
/// 히어로가 「GALAXY 맵 확장」(`343:211127`) 상태인가. 미니맵 탭으로 열고, 「GALAXY」 탭 ·
/// 뒤로가기로 닫는다.
bool _mapOpen = false;
final _mapKey = GlobalKey(debugLabel: 'home-galaxy-map');
final _zapKey = GlobalKey(debugLabel: 'home-zap');
void _setMap(bool open) => setState(() => _mapOpen = open);
void _onZapChanged() => setState(() {});
@override
@@ -120,12 +134,15 @@ class _HomeScreenState extends State<HomeScreen> {
/// 성운·행성 생성 화면은 성공하면 팬덤 ID 를 들고 닫힌다. 그때만 다시 읽는다.
Future<void> _createThenReload(Widget screen) async {
final created = await Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<String>(builder: (_) => screen),
);
final created = await Navigator.of(
context,
rootNavigator: true,
).push(MaterialPageRoute<String>(builder: (_) => screen));
if (created == null || !mounted) return;
if (widget.galaxyRepository case final repository?) {
setState(() { _galaxy = repository.load(); });
setState(() {
_galaxy = repository.load();
});
}
await _refresh();
}
@@ -137,7 +154,15 @@ class _HomeScreenState extends State<HomeScreen> {
}
@override
Widget build(BuildContext context) => Scaffold(
Widget build(BuildContext context) => PopScope(
canPop: !_mapOpen,
onPopInvokedWithResult: (didPop, _) {
if (!didPop && _mapOpen) _setMap(false);
},
child: _scaffold(context),
);
Widget _scaffold(BuildContext context) => Scaffold(
// 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서
// 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다.
body: FutureBuilder<HomeSnapshot>(
@@ -157,6 +182,11 @@ class _HomeScreenState extends State<HomeScreen> {
data: snap.data!,
galaxy: _galaxy,
zap: _zap,
mapOpen: _mapOpen,
mapKey: _mapKey,
zapKey: _zapKey,
onMap: _setMap,
insideRepository: widget.insideRepository,
repository: widget.repository,
watchPlanet: widget.watchPlanet,
planetRepository: widget.planetRepository,
@@ -249,6 +279,11 @@ class HomeHeader extends StatelessWidget {
_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'),
@@ -256,23 +291,25 @@ class HomeHeader extends StatelessWidget {
height: 42,
),
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
onPressed: () =>
showItemSheet(
context,
items: items,
purchase: true,
fandomId: fandomId,
),
onPressed: () => showItemSheet(
context,
items: items,
purchase: true,
fandomId: fandomId,
),
tooltip: context.l10n.homeItemsTooltip,
),
IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42),
padding: EdgeInsets.zero,
iconSize: 24,
icon: const Icon(
Icons.notifications_none,
color: AppColors.onCosmic,
// 사본 `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>(
@@ -292,7 +329,12 @@ class _HomeBody extends StatelessWidget {
required this.watchPlanet,
required this.galaxy,
required this.zap,
required this.mapOpen,
required this.mapKey,
required this.zapKey,
required this.onMap,
required this.planetRepository,
required this.insideRepository,
required this.data,
required this.repository,
required this.onRefresh,
@@ -311,6 +353,15 @@ class _HomeBody extends StatelessWidget {
final HomeSnapshot data;
final Future<GalaxySnapshot>? galaxy;
final ZapController? zap;
final bool mapOpen;
final GlobalKey mapKey;
/// 떠 있는 ZAP 버튼 — 기여 연출(히어로)이 이 자리에서 출발한다.
final GlobalKey zapKey;
final ValueChanged<bool> onMap;
/// 인기 K-INSIDE 상세를 열 저장소. null 이면 상세가 표본을 쓴다.
final InsideRepository? insideRepository;
final HomeRepository repository;
final PlanetWatch watchPlanet;
final PlanetRepositoryFactory? planetRepository;
@@ -321,277 +372,394 @@ class _HomeBody extends StatelessWidget {
final QuestRepository questRepository;
final CampaignRepository? campaignRepository;
/// HOME GALAXY 지도 — 미니맵(93pt)과 「GALAXY 맵 확장」 모두 이 하나다(같은 [mapKey]).
Widget? _map(BuildContext context, GalaxySnapshot? galaxy) =>
galaxy == null || planetRepository == null || zap == null
? null
: GalaxyWebView(
key: mapKey,
data: galaxy,
expanded: mapOpen,
miniSize: HomeHero.miniMapSize,
// 9/22 D-6 — 미니맵 상세는 HOME 히어로가 「GALAXY 맵 확장」으로 바뀐다.
onOpen: () => onMap(true),
// 명세 `2008:14486` 「HUD - 터치시 FPI 상세 페이지 이동」.
onOpenPlanet: (p) => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(p.id),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
);
/// 섹션은 ListView 의 직계 자식으로 둔다. 하나의 Column 으로 감싸면 첫 프레임에
/// 전부 빌드되어 지연 렌더링을 잃고, 테스트의 scrollUntilVisible 도 「이미 보인다」고
/// 오판한다. 좌우 여백은 자식마다 준다.
static const _h = EdgeInsets.symmetric(horizontal: AppSpace.s4);
/// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16.
static const _sectionGap = 36.0;
static const _headerGap = AppSpace.s4;
/// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16. 머리 줄은 터치 영역 때문에
/// 사본(20)보다 위아래 [HomeSectionHeader.slack] 씩 크므로 그만큼 뺀다.
static const _sectionGap = 36.0 - HomeSectionHeader.slack;
static const _headerGap = AppSpace.s4 - HomeSectionHeader.slack;
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top;
return RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
padding: const EdgeInsets.only(bottom: AppSpace.s7),
children: [
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고
// 헤더 행에 상단 여백을 직접 준다.
Stack(
return Stack(
children: [
RefreshIndicator(
onRefresh: onRefresh,
child: ListView(
// 사본 `Frame 4241` 아래 패딩 24.
padding: const EdgeInsets.only(bottom: AppSpace.s5),
children: [
LiveStreamBuilder<PlanetGrowth>(
key: ValueKey((repository, data.fandomId)),
create: () => repository.watchGrowth(data.fandomId),
builder: (context, growth) => FutureBuilder<GalaxySnapshot>(
future: galaxy,
builder: (context, galaxyState) => HomeHero(
zap: zap,
zapData: galaxyState.data,
miniMap:
galaxyState.data == null ||
planetRepository == null ||
zap == null
? null
: GalaxyMiniMap(
size: HomeHero.miniMapSize,
data: galaxyState.data!,
onOpen: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => GalaxyScreen(
repository: zap!.repository,
planetRepository: planetRepository!,
watchPlanet: watchPlanet,
eclipse: eclipse,
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고
// 헤더 행에 상단 여백을 직접 준다.
Stack(
children: [
LiveStreamBuilder<PlanetGrowth>(
key: ValueKey((repository, data.fandomId)),
create: () => repository.watchGrowth(data.fandomId),
builder: (context, growth) => FutureBuilder<GalaxySnapshot>(
future: galaxy,
// 미니맵과 확장 맵은 같은 GalaxyWebView(같은 키) — 자리만 옮기고 모드는 JS 로 바꾼다.
// 그래서 교차 페이드(AnimatedSwitcher)를 쓰지 않는다(전환 중 키가 둘이 된다).
builder: (context, galaxyState) => KeyedSubtree(
child:
mapOpen && _map(context, galaxyState.data) != null
? HomeGalaxyHero(
key: const ValueKey('home-galaxy-hero'),
map: _map(context, galaxyState.data)!,
ticker: _LiveTickerSlot(
eclipse: eclipse,
fandomId: data.fandomId,
fallback: data.liveTicker,
),
onClose: () => onMap(false),
onSearch: () =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) => GalaxySearchScreen(
repository: zap!.repository,
),
),
),
)
: HomeHero(
key: const ValueKey('home-hero'),
zap: zap,
zapData: galaxyState.data,
miniMap: _map(context, galaxyState.data),
// 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면
// 스냅샷의 `liveTicker` 문자열로 물러난다(폴백).
ticker: _LiveTickerSlot(
eclipse: eclipse,
fandomId: data.fandomId,
fallback: data.liveTicker,
),
growth: growth ?? data.growth,
fandomId: data.fandomId,
unjoined: data.fandomId.isEmpty,
watchPlanet: watchPlanet,
onOpenPlanet:
planetRepository == null ||
data.fandomId.isEmpty
? null
: () =>
Navigator.of(
context,
rootNavigator: true,
).push(
MaterialPageRoute<void>(
builder: (_) =>
PlanetDetailScreen(
repository:
planetRepository!(
data.fandomId,
),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
onOpenHistory: planetRepository == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlanetHistoryScreen(
repository: planetRepository!(
data.fandomId,
),
fandomName:
galaxyState.data?.mine?.name ??
context.l10n.homeFandomHistory,
),
),
),
// 만들고 돌아오면 HOME 이 새 성운/행성(내 Primary)을 다시 그린다.
onBirth: () => onCreate(
PlanetCreateScreen(
nebulaName:
(growth ?? data.growth).fandomName,
fandomId: data.fandomId,
repository: repository,
),
),
),
// 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면
// 스냅샷의 `liveTicker` 문자열로 물러난다(폴백).
ticker: _LiveTickerSlot(
eclipse: eclipse,
fandomId: data.fandomId,
fallback: data.liveTicker,
onCreateNebula: () => onCreate(
NebulaCreateScreen(
repository: repository,
myPop: data.popBalance,
),
),
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
onJoin: () =>
GoRouter.maybeOf(context)?.go('/galaxy'),
zapButtonKey: zapKey,
onHeroParticipate: _heroParticipate(context),
),
),
),
growth: growth ?? data.growth,
fandomId: data.fandomId,
unjoined: data.fandomId.isEmpty,
watchPlanet: watchPlanet,
onOpenPlanet:
planetRepository == null || data.fandomId.isEmpty
? null
: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(data.fandomId),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
),
onOpenHistory: planetRepository == null
? null
: () => Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => PlanetHistoryScreen(
repository: planetRepository!(data.fandomId),
fandomName:
galaxyState.data?.mine?.name ??
context.l10n.homeFandomHistory,
),
),
),
// 만들고 돌아오면 HOME 이 새 성운/행성(내 Primary)을 다시 그린다.
onBirth: () => onCreate(
PlanetCreateScreen(
nebulaName: (growth ?? data.growth).fandomName,
),
Positioned(
// 확장 맵(`343:211127`)은 라이브 띠가 헤더 위(y48)라 헤더가 띠 높이만큼 내려간다.
top: top + (mapOpen ? HomeGalaxyHero.headerShift : 0),
left: 0,
right: 0,
// 지갑 문서를 구독한다 — 한 번 읽은 값으로 두면 다른 화면의 변동(현상금 예치 ·
// 타임어택 보상)이 칩에 남지 않는다(2026-10-05 실기기).
child: LiveStreamBuilder<int>(
key: ValueKey(('pop', repository)),
create: repository.watchPopBalance,
builder: (context, wallet) => HomeHeader(
pop: wallet ?? zap?.balance ?? data.popBalance,
notifications: notifications,
items: data.items,
fandomId: data.fandomId,
repository: repository,
),
),
onCreateNebula: () => onCreate(
NebulaCreateScreen(
repository: repository,
myPop: data.popBalance,
),
],
),
// 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱
// 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다.
if (data.fandomId.isNotEmpty)
EclipseBanner(
repository: eclipse,
fandomId: data.fandomId,
// 히어로 아래 24 = 히어로 상자 12 + 여기 12. 구획 사이는 36(`Frame 4241`).
margin: const EdgeInsets.fromLTRB(
AppSpace.s4,
HomeSectionHeader.slack,
AppSpace.s4,
_sectionGap,
),
),
// 이하 구획은 Figma 「홈」 `2097:16428` `Frame 4241`(패딩 24/16 · 구획 사이 36 · 머리와
// 본문 16) 순서 — TODAY'S QUEST · 팬덤행성 인큐베이터 · 아티스트 소식 · HOT ARCHIVE ·
// 인기 K-INSIDE.
Padding(
padding: _h,
child: HomeSectionHeader(
title: 'TODAY’S QUEST',
onMore: () =>
_openQuests(context, questRepository, data.popBalance),
),
),
const SizedBox(height: _headerGap),
// 설명 프레임 `2097:16397` — 관리자 퀘스트 클릭 시 해당 퀘스트 상세 화면 이동.
Padding(
padding: _h,
child: QuestRows(
quests: data.quests,
onTap: (q) => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => QuestDetailScreen(
questId: q.id,
repository: questRepository,
),
),
// 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
onJoin: () => GoRouter.maybeOf(context)?.go('/galaxy'),
onItem: () => showItemSheet(
context,
items: data.items,
purchase: false,
),
onHeroParticipate:
planetRepository == null || data.fandomId.isEmpty
? null
: () {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => HeroParticipationSheet(
fandomId: data.fandomId,
watch: watchPlanet,
repository: planetRepository!(data.fandomId),
),
);
},
),
),
),
Positioned(
top: top,
left: 0,
right: 0,
child: HomeHeader(
pop: zap?.balance ?? data.popBalance,
notifications: notifications,
items: data.items,
fandomId: data.fandomId,
const SizedBox(height: _sectionGap),
// 팬덤행성 인큐베이터(성운 추천 `2097:16403`). 성운이 없으면 구획을 두지 않는다 —
// 사본에 빈 상태가 없다.
if (data.nebulas.isNotEmpty) ...[
Padding(
padding: _h,
child: HomeSectionHeader(title: context.l10n.homeIncubator),
),
const SizedBox(height: _headerGap),
NebulaIncubator(
nebulas: data.nebulas,
// 명세 「함께 성장하기 버튼 클릭 시, 성운 상세 화면 이동」.
onOpen: (n) {
if (planetRepository == null) return;
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => PlanetDetailScreen(
repository: planetRepository!(n.id),
watchPlanet: watchPlanet,
eclipse: eclipse,
),
),
);
},
),
const SizedBox(height: _sectionGap),
],
// 아티스트 소식 = AI Daily Briefing 영역(`2097:16409`). 0 건이면 헤더까지 그리지 않는다
// — 정책 원장 B-47 「News·SNS 모두 0건 시 Briefing 블록 전체 숨김」. 가입 · 생성 유저
// 에게만(명세 규칙) — 브리핑은 소속 팬덤 기준으로만 읽힌다.
if (data.briefings.isNotEmpty) ...[
Padding(
padding: _h,
child: HomeSectionHeader(
title: context.l10n.homeArtistNews,
// 「홈_브리핑 공유」 — 공유 카드 오버레이(`266:90529` · 명세 `179:29117`).
trailing: SharePill(
onTap: () => BriefingShareDialog.show(
context,
items: data.briefings,
fandomName: data.growth.fandomName,
link: briefingShareLink(data.fandomId, kstDayKey()),
),
),
),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: BriefingList(items: data.briefings),
),
const SizedBox(height: _sectionGap),
],
// HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
Padding(
padding: _h,
child: HomeSectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
),
],
),
// 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱
// 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다.
if (data.fandomId.isNotEmpty)
EclipseBanner(
repository: eclipse,
fandomId: data.fandomId,
// 히어로 아래 24 는 히어로 상자(ZAP 걸침 몫)가 이미 준다. 구획 사이는 36(`Frame 4241`).
margin: const EdgeInsets.fromLTRB(
AppSpace.s4,
0,
AppSpace.s4,
_sectionGap,
),
),
Padding(
padding: _h,
child: SectionHeader(
title: 'TODAY’S QUEST',
onMore: () =>
_openQuests(context, questRepository, data.popBalance),
),
),
const SizedBox(height: _headerGap),
// 사본 카드(196×137)를 가로로 늘어놓는다(`Frame 1984081088` · 간격 8).
SizedBox(
// 글자 확대 시 잘리지 않게 글자 몫(22+6+20+28)만 배율을 따른다.
height: 12 * 2 + 37 + MediaQuery.textScalerOf(context).scale(76),
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: _h,
itemCount: data.quests.length,
separatorBuilder: (_, _) => const SizedBox(width: AppSpace.s2),
itemBuilder: (context, i) => SizedBox(
width: QuestCard.width,
child: QuestCard(
quest: data.quests[i],
// 타임어택은 목록 첫 페이지에 든다는 보장이 없어 상세로 바로 간다.
onTap: data.quests[i].endsAt == null
? () =>
_openQuests(context, questRepository, data.popBalance)
: () => Navigator.of(context, rootNavigator: true).push(
const SizedBox(height: _headerGap),
if (data.campaigns.isNotEmpty)
CampaignCarousel(
campaigns: data.campaigns,
// 명세 「캠페인 보기 버튼 - 해당 캠페인 상세 화면 이동」.
onOpen: (c) => c.id.isEmpty
? _openCampaigns(context, campaignRepository)
: Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => QuestDetailScreen(
questId: data.quests[i].id,
repository: questRepository,
builder: (_) => CampaignDetailScreen(
campaignId: c.id,
repository: campaignRepository,
),
),
),
)
else
// 0 건 — 사본에 빈 상태가 없어 「새 캠페인」(`179:29123` 캠페인 개설) 입구를 둔다.
Center(
child: CampaignFab(
label: context.l10n.campaignNew,
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => CampaignCreateScreen(
repository: campaignRepository,
),
),
),
),
),
),
),
const SizedBox(height: _sectionGap),
// 0 건이면 헤더까지 그리지 않는다 — 정책 원장 B-47 「News·SNS 모두 0건 시
// Briefing 블록 전체 숨김(빈 상태 문구도 노출하지 않음)」.
if (data.briefings.isNotEmpty) ...[
Padding(
padding: _h,
child: SectionHeader(
title: 'AI Daily Briefing',
// 「홈_브리핑 공유」 — 이 자리가 공유 버튼이다(`266:90529` · 명세 `179:29117`).
moreLabel: context.l10n.commonShare,
onMore: () => BriefingShareDialog.show(
context,
items: data.briefings,
fandomName: data.growth.fandomName,
link: briefingShareLink(data.fandomId, kstDayKey()),
),
),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: BriefingList(items: data.briefings),
),
const SizedBox(height: _sectionGap),
],
// HOT ARCHIVE 는 모금 중 캠페인이 없어도 둔다 — 기능명세서 v1.4 §2.114-6
// (`2097:16415`) 「더보기 버튼 - HOT ARCHIVE 화면 이동」에 숨김 규칙이 없고,
// 캠페인 목록·개설로 가는 길이 이 구획뿐이다(공통 기준 5항).
Padding(
padding: _h,
child: SectionHeader(
title: 'HOT ARCHIVE',
onMore: () => _openCampaigns(context, campaignRepository),
),
),
const SizedBox(height: _headerGap),
Padding(
padding: _h,
child: switch (data.campaign) {
final c? => CampaignCard(
campaign: c,
onTap: () => _openCampaigns(context, campaignRepository),
),
// 0 건 — 「새 캠페인」(Figma `179:29123` 캠페인 개설)으로 바로 간다.
null => Center(
child: CampaignFab(
label: context.l10n.campaignNew,
onTap: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) =>
CampaignCreateScreen(repository: campaignRepository),
),
),
),
),
},
),
const SizedBox(height: _sectionGap),
const SizedBox(height: _sectionGap),
Padding(
padding: _h,
child: SectionHeader(
title: context.l10n.homePopularInside,
onMore: () => _notYet(context, context.l10n.homeWhereInsideList),
),
),
const SizedBox(height: _headerGap),
for (final i in data.insides) ...[
Padding(
padding: _h,
child: InsideCard(
digest: i,
onTap: () =>
_notYet(context, context.l10n.homeWhereInsideDetail),
Padding(
padding: _h,
child: HomeSectionHeader(
title: context.l10n.homePopularInside,
// 명세 「더보기 버튼 - 인사이드 화면 이동」.
onMore: () => GoRouter.maybeOf(context)?.go(appTabs[1].path),
),
),
),
const SizedBox(height: AppSpace.s3),
],
],
),
const SizedBox(height: _headerGap),
for (final (i, q) in data.insides.indexed) ...[
if (i > 0) const SizedBox(height: AppSpace.s3),
Padding(
padding: _h,
child: HomeInsideCard(
question: q,
// 명세 「인사이드 리스트 - 해당 인사이드 상세 화면 이동」.
onTap: () =>
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
builder: (_) => InsideDetailScreen(
question: q,
repository: insideRepository,
),
),
),
),
),
],
],
),
),
// ZAP — Figma 「홈」 `Frame 1984081111`. 스크롤과 무관하게 하단 탭 위 가운데에 떠 있다.
Positioned(
left: 0,
right: 0,
bottom: HomeZap.bottomGap,
child: FutureBuilder<GalaxySnapshot>(
future: galaxy,
builder: (context, galaxyState) =>
zap == null || !HomeZap.visible(galaxyState.data, data.fandomId)
? const SizedBox.shrink()
: Center(
child: HomeZap(
buttonKey: zapKey,
zap: zap!,
data: galaxyState.data!,
fandomId: data.fandomId,
watchPlanet: watchPlanet,
onItem: () => showItemSheet(
context,
items: data.items,
purchase: false,
),
onHeroParticipate: _heroParticipate(context),
),
),
),
),
],
);
}
VoidCallback? _heroParticipate(BuildContext context) =>
planetRepository == null || data.fandomId.isEmpty
? null
: () => showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (_) => HeroParticipationSheet(
fandomId: data.fandomId,
watch: watchPlanet,
repository: planetRepository!(data.fandomId),
),
);
}
/// 상태 표시줄 가림막 — 히어로는 상태 표시줄 뒤까지 배경을 깔아야 해서 목록에
@@ -611,7 +779,7 @@ class _StatusBarScrimState extends State<_StatusBarScrim> {
bool _scrolled = false;
bool _onScroll(ScrollNotification n) {
// 퀘스트 카드의 가로 스크롤은 무시한다 — 바깥 세로 목록만 본다.
// 바깥 세로 목록만 본다.
if (n.depth != 0 || n.metrics.axis != Axis.vertical) return false;
final scrolled = n.metrics.pixels > 0;
if (scrolled != _scrolled) setState(() => _scrolled = scrolled);
@@ -712,26 +880,9 @@ class _LoadFailed extends StatelessWidget {
}
}
/// 아직 라우트가 없는 곳으로 가려 할 때.
///
/// 조용히 무시하면 검수 때 「눌러도 아무 일이 없다」로 보인다. 무엇이 언제
/// 열리는지 알려 주는 편이 낫다.
void _notYet(BuildContext context, String where) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(
content: Text(context.l10n.commonNotYet(where)),
behavior: SnackBarBehavior.floating,
),
);
}
/// 홈의 HOT ARCHIVE 에서 캠페인 목록으로 — 화면 대조 원장 F1 → F5.
///
/// 홈 카드에는 캠페인 ID 가 없다([CampaignHighlight] 는 표시용 요약이다).
/// 상세로 바로 보내려면 ID 를 홈 데이터에 넣어야 하는데, 그러면 홈이 캠페인
/// 스키마를 알게 된다. 목록을 거쳐 들어간다.
/// 「더 보기」(HOT ARCHIVE 화면 이동)와 ID 없는 카드가 쓴다.
void _openCampaigns(BuildContext context, CampaignRepository? repository) {
Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>(
@@ -5,7 +5,8 @@ import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../../../core/i18n/app_locale.dart';
/// AI Daily Briefing — Figma 홈의 번호 매긴 헤드라인 줄.
/// 아티스트 소식(AI Daily Briefing) — Figma 「홈」 `Frame 1984081094`: `#1A1A1C` · r12 ·
/// 패딩 20/16 · 줄 간격 20. 줄은 번호(16/500 흰색) · 28 · 제목(16/500 · 행간 150% · #A3A2A0).
///
/// **구획을 News 와 SNS 로 나눈다** — 정책 원장 B-47 *News/SNS 각각 0 건이면 해당*
/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만
@@ -27,7 +28,6 @@ class BriefingList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
// 구획 순서는 enum 선언 순서다(News 다음 SNS). 수신 순서로 정하면 날마다 바뀐다.
final sections = [
for (final section in BriefingSection.values)
@@ -38,10 +38,14 @@ class BriefingList extends StatelessWidget {
),
].where((g) => g.rows.isNotEmpty).toList();
// 구획 이름(News · SNS)은 사본에 없지만 B-47 의 구획 단위 숨김을 화면에서 가르는 표지라
// 남긴다 — 「더 보기」와 같은 12/600 #A3A2A0 으로 작게.
return Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s4,
vertical: AppSpace.s2,
padding: const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s5,
AppSpace.s4,
AppSpace.s5,
),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
@@ -51,22 +55,19 @@ class BriefingList extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (final (groupIndex, group) in sections.indexed) ...[
if (groupIndex > 0) const Divider(height: 1),
Padding(
padding: const EdgeInsets.only(
top: AppSpace.s3,
bottom: AppSpace.s1,
),
child: Text(
group.section.label,
style: t.labelSmall?.copyWith(
color: AppColors.cosmicAccent,
fontWeight: FontWeight.w600,
),
if (groupIndex > 0) const SizedBox(height: AppSpace.s5),
Text(
group.section.label,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmicVariant,
),
),
for (final (i, item) in group.rows.indexed) ...[
if (i > 0) const Divider(height: 1),
for (final item in group.rows) ...[
const SizedBox(height: AppSpace.s5),
_Row(item: item, onOpen: () => _open(context, item)),
],
],
@@ -89,10 +90,12 @@ class BriefingList extends StatelessWidget {
if (!opened && context.mounted) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(
content: Text(context.l10n.briefingOpenFailed),
behavior: SnackBarBehavior.floating,
));
..showSnackBar(
SnackBar(
content: Text(context.l10n.briefingOpenFailed),
behavior: SnackBarBehavior.floating,
),
);
}
}
}
@@ -105,27 +108,39 @@ class _Row extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return InkWell(
onTap: onOpen,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s3),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 20,
child: Text(
'${item.rank}.',
style: t.bodySmall?.copyWith(
color: AppColors.cosmicAccent,
fontWeight: FontWeight.w600,
),
child: Row(
children: [
SizedBox(
width: 10,
child: Text(
'${item.rank}',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.4,
color: AppColors.onCosmic,
),
),
Expanded(child: Text(item.headline, style: t.bodySmall)),
],
),
),
const SizedBox(width: 28),
Expanded(
child: Text(
item.headline,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmicVariant,
),
),
),
],
),
);
}
@@ -1,129 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../../../core/i18n/app_locale.dart';
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
///
/// Figma 「다이브짱 / 92% 달성 / 강남역 초대형 LED 광고 / 48 · 24 · 82」.
/// 정책 설계서 §P-09 — 실패 시 100% 자동 환불이 이 기능의 검수 기준이다.
class CampaignCard extends StatelessWidget {
const CampaignCard({super.key, required this.campaign, this.onTap});
final CampaignHighlight campaign;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 시안 HOT ARCHIVE 카드(252:159447) — 위에 이미지 342×228.
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: AspectRatio(
aspectRatio: 342 / 228,
child: Image.asset('assets/figma/campaign-sample.png', fit: BoxFit.cover),
),
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
Text(campaign.authorName, style: t.bodySmall),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s2,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.cosmicAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
context.l10n.campaignAchieved('${campaign.achievedPercent}'),
style: t.labelSmall?.copyWith(
color: AppColors.cosmicAccent,
),
),
),
],
),
const SizedBox(height: AppSpace.s3),
Text(campaign.title, style: t.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(
campaign.description,
style: t.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpace.s3),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: LinearProgressIndicator(
value: (campaign.achievedPercent / 100).clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: AppColors.cosmicSurfaceRaised,
valueColor: const AlwaysStoppedAnimation(
AppColors.cosmicAccent,
),
),
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
_Stat(icon: Icons.people_outline, value: campaign.supporters),
const SizedBox(width: AppSpace.s4),
_Stat(
icon: Icons.chat_bubble_outline,
value: campaign.comments,
),
const SizedBox(width: AppSpace.s4),
_Stat(icon: Icons.favorite_border, value: campaign.likes),
const Spacer(),
Text(
context.l10n.campaignView,
style: t.bodySmall?.copyWith(color: AppColors.cosmicAccent),
),
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.cosmicAccent,
),
],
),
],
),
),
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({required this.icon, required this.value});
final IconData icon;
final int value;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppColors.onCosmicVariant),
const SizedBox(width: AppSpace.s1),
Text('$value', style: Theme.of(context).textTheme.labelSmall),
],
);
}
+286 -118
View File
@@ -47,8 +47,12 @@ class HomeHero extends StatefulWidget {
this.unjoined = false,
this.onItem,
this.miniMap,
this.zapButtonKey,
});
/// 화면 하단에 떠 있는 ZAP 버튼([HomeZap])의 키 — 기여 연출이 이 자리에서 행성으로 날아간다.
final GlobalKey? zapButtonKey;
final VoidCallback? onOpenHistory;
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
@@ -96,8 +100,9 @@ class HomeHero extends StatefulWidget {
/// 사본 기준 배경 높이(상태 표시줄 48 포함).
static const double designHeight = 680;
/// 배경 아래로 ZAP 원이 걸쳐 내려오는 몫. 다음 구획의 위 여백(24)과 같다.
static const double zapOverhang = 24;
/// 히어로 아래 여백 — 사본 `Frame 4241` 위 패딩 24 에서 첫 구획 머리의 터치 여유 12 를 뺀 값.
/// (ZAP 은 화면 하단에 떠 있어 더는 히어로에 걸치지 않는다.)
static const double bottomGap = 12;
@override
State<HomeHero> createState() => _HomeHeroState();
@@ -106,17 +111,18 @@ class HomeHero extends StatefulWidget {
class _HomeHeroState extends State<HomeHero> {
final _fxKey = GlobalKey();
final _planetKey = GlobalKey();
final _buttonKey = GlobalKey();
PlanetGrowth get growth => widget.growth;
String get fandomId => widget.fandomId;
PlanetWatch get watchPlanet => widget.watchPlanet;
VoidCallback? get onHeroParticipate => widget.onHeroParticipate;
Offset _center(GlobalKey key) {
final box = key.currentContext?.findRenderObject();
/// [key] 의 가운데를 히어로 좌표로. ZAP 버튼은 히어로 밖(화면 하단 고정)이라 전역 좌표로 잰다.
Offset _center(GlobalKey? key) {
final box = key?.currentContext?.findRenderObject();
final root = _fxKey.currentContext?.findRenderObject();
return box is RenderBox && box.hasSize && root is RenderBox
? box.localToGlobal(box.size.center(Offset.zero), ancestor: root)
return box is RenderBox && box.hasSize && root is RenderBox && root.hasSize
? box.localToGlobal(box.size.center(Offset.zero)) -
root.localToGlobal(Offset.zero)
: Offset.zero;
}
@@ -133,7 +139,7 @@ class _HomeHeroState extends State<HomeHero> {
return PlanetFxScope(
child: SizedBox(
height: background + HomeHero.zapOverhang,
height: background + HomeHero.bottomGap,
child: Stack(
key: _fxKey,
children: [
@@ -202,7 +208,6 @@ class _HomeHeroState extends State<HomeHero> {
glow: AppColors.neonViolet,
),
),
],
),
),
@@ -218,7 +223,6 @@ class _HomeHeroState extends State<HomeHero> {
_GrowthOverlayCard(
growth: growth,
onOpen: widget.onOpenPlanet,
zapClearance: hasZap,
),
],
],
@@ -226,67 +230,11 @@ class _HomeHeroState extends State<HomeHero> {
),
if (widget.miniMap case final mini?)
Positioned(
top: top + HomeHero.miniMapTop,
right: 0,
child: mini,
),
Positioned(top: top + HomeHero.miniMapTop, right: 0, child: mini),
if (widget.ticker case final ticker?)
Positioned(top: top + 102, left: 0, right: 0, child: ticker),
// 3. 원형 ZAP — 사본 `Frame 1984081111`. 중심이 카드 아래 변에 걸친다.
if (widget.zapData case final data?)
if (widget.zap case final zap?)
if (data.joined && data.mine?.id == fandomId)
Positioned(
left: 0,
right: 0,
bottom: 0,
child: LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(('home-zap', fandomId)),
create: () => watchPlanet(fandomId),
builder: (context, state) {
final hero =
state?.isHeroTime == true &&
state?.heroEndsAt?.isAfter(DateTime.now()) ==
true &&
onHeroParticipate != null &&
zap.pending == 0 &&
!zap.sending &&
!zap.needsRetry;
return Column(
key: _buttonKey,
mainAxisSize: MainAxisSize.min,
children: [
if (zap.needsRetry)
TextButton(
onPressed: zap.retry,
child: Text(context.l10n.zapRetrySame),
),
ZapButton(
round: true,
showLabel: !growth.isNebula,
onItem: widget.onItem,
zapPop: data.zapPop,
myPop:
(zap.balance ?? data.myPop) -
zap.pending * data.zapPop,
isHeroTime: hero,
blockedReason: zap.sending
? context.l10n.zapApplying
: zap.needsRetry
? context.l10n.zapNeedsCheck
: null,
onZap: () => hero
? onHeroParticipate!()
: zap.tap(fandomId),
),
],
);
},
),
),
if (widget.zap case final zap?)
Positioned.fill(
child: IgnorePointer(
@@ -294,7 +242,7 @@ class _HomeHeroState extends State<HomeHero> {
touchSerial: zap.touchSerial,
pending: zap.pending > 0 || zap.sending,
result: zap.result,
source: _center(_buttonKey),
source: _center(widget.zapButtonKey),
target: _center(_planetKey),
radius:
(_planetKey.currentContext?.findRenderObject()
@@ -344,7 +292,8 @@ class _Contribution extends StatelessWidget {
alignment: Alignment.center,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: AppColors.cosmicSurfaceRaised,
// 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
color: Color(0xFF9BF8A8),
boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)],
),
child: Text(
@@ -353,7 +302,7 @@ class _Contribution extends StatelessWidget {
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppColors.onCosmic,
color: Colors.black,
),
),
),
@@ -391,7 +340,9 @@ class _Contribution extends StatelessWidget {
: growth.isNebula
? context.l10n.heroNebulaPrompt
: growth.hasRecentContribution
? context.l10n.heroRecentContribution(formatPop(growth.contributedPop))
? context.l10n.heroRecentContribution(
formatPop(growth.contributedPop),
)
: context.l10n.heroGrowingTogether,
style: const TextStyle(
fontFamily: AppFonts.body,
@@ -406,26 +357,19 @@ class _Contribution extends StatelessWidget {
);
}
/// 성장 카드 — 사본 기본 상태의 「BTS › / 행성 성장률 67% / 진행 바 / 6,270 / 10,000」
/// (`Frame 1984081073` 343 폭 · `#040304` 40% · r12 · 패딩 12/16 · 간격 12 · 유리 흐림 4).
/// 성장 카드 — Figma 「홈」 `Frame 1984081073`(343×151 · `#040304` 40% · r12 · 패딩 12/16 ·
/// 간격 12 · 유리 흐림 4): 「BTS ›」(ABeeZee 32 · chevron 24 #777777) / 「행성 성장률 67%」
/// (14/500 · 행간 24) / 진행 바 10 / 「6,270 / 10,000」(14/500). 말풍선(`Group 563` 168×40)
/// 은 바 채움 끝 위에 뜬다.
///
/// 둘째 줄 「POP 진행률」과 바 아래 두 조건 줄은 정책값이다 — growthPercent 는 POP 진행률
/// 이고(전체 충족률 아님) 승급은 POP·sFPI 동시 충족이다(정책 설계서 §P-08 · UX·FX v1.5
/// §2.1). 말풍선 꼬리는 바 채움 끝을 가리킨다(사본 `Group 563` 가운데 = 채움 끝 x238).
/// 성장률 · 수치는 두 조건 중 병목 축([PlanetGrowth.bottleneck])이다. 말풍선을 누르면 두
/// 조건 상세([GrowthConditions])를 연다.
class _GrowthOverlayCard extends StatelessWidget {
const _GrowthOverlayCard({
required this.growth,
this.onOpen,
this.zapClearance = false,
});
const _GrowthOverlayCard({required this.growth, this.onOpen});
final PlanetGrowth growth;
final VoidCallback? onOpen;
/// 원형 ZAP(52)이 카드 아래 변에 걸친다. 둘째 조건 줄이 가려지지 않게 아래 여백을
/// ZAP 윗변(아래 변 − 28) 위로 올린다. ZAP 좌표는 사본 그대로 둔다.
final bool zapClearance;
static const _white14 = TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
@@ -434,26 +378,58 @@ class _GrowthOverlayCard extends StatelessWidget {
color: AppColors.onCosmic,
);
/// 말풍선 가운데의 최소 x — 「행성 성장률 100%」 줄(약 103)과 겹치지 않게 한다.
static const _bubbleMinCenter = 196.0;
String _value(num v) =>
v == v.roundToDouble() ? formatPop(v.toInt()) : v.toStringAsFixed(1);
void _details(BuildContext context) => showModalBottomSheet<void>(
context: context,
useRootNavigator: true,
backgroundColor: AppColors.cosmicSheet,
builder: (context) => SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s5,
AppSpace.s4,
AppSpace.s5,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.growthConditionsTitle,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: AppSpace.s3),
GrowthConditions(growth: growth, style: _white14),
],
),
),
),
);
@override
Widget build(BuildContext context) {
final ratio = growth.popRatio;
final low = growth.bottleneck;
final ratio = low?.ratio;
final percent = ratio == null ? '—' : '${(ratio * 100).round()}';
return ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
child: Container(
width: double.infinity,
padding: EdgeInsets.fromLTRB(
AppSpace.s4,
AppSpace.s3,
AppSpace.s4,
zapClearance ? 32 : AppSpace.s3,
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s4,
vertical: AppSpace.s3,
),
decoration: BoxDecoration(
color: const Color(0x66040304),
borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppFx.glassStroke),
),
child: LayoutBuilder(
builder: (context, box) => Stack(
@@ -485,44 +461,57 @@ class _GrowthOverlayCard extends StatelessWidget {
const ImageIcon(
AppIcons.chevronRight,
size: 24,
color: AppColors.onCosmicVariant,
color: Color(0xFF777777),
),
],
),
),
Row(
children: [
Text(context.l10n.heroPopProgress, style: _white14),
const SizedBox(width: 4),
Text(
growth.growthPercent == null
? '—'
: '${growth.growthPercent}%',
style: _white14,
),
],
),
const SizedBox(height: AppSpace.s3),
_Bar(ratio: ratio ?? 0),
const SizedBox(height: AppSpace.s3),
GrowthConditions(
growth: growth,
style: _white14.copyWith(height: 16 / 14),
// 성장률 · 바 · 수치를 누르면 두 조건 상세 — 말풍선이 없는 성운 카드의 입구.
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _details(context),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
growth.isNebula
? context.l10n.heroNebulaGrowthRate(percent)
: context.l10n.heroGrowthRate(percent),
style: _white14,
),
const SizedBox(height: AppSpace.s3),
_Bar(ratio: ratio ?? 0),
const SizedBox(height: AppSpace.s3),
Text(
low == null
? '— / —'
: '${_value(low.current)} / ${_value(low.required)}',
style: _white14,
),
],
),
),
],
),
// 말풍선 — 꼬리 끝이 바 위 9.5 에 닿는다(사본 581.5+40 → 바 631).
// 말풍선 — 사본 y566.5(카드 안 31.5) · 꼬리 끝이 바 위에 닿는다. 가운데는 채움 끝
// (사본 x238 = 채움 206)을 따르되 왼쪽 성장률 글자와 겹치지 않게 민다.
// 성운 카드(`344:213809`)에는 말풍선이 없다.
if (!growth.isNebula)
Positioned(
top: 45 + 24 + AppSpace.s3 - 49.5,
left: (box.maxWidth * (ratio ?? 0.5)).clamp(
84.0,
top: 31.5,
left: (box.maxWidth * (ratio ?? 0)).clamp(
math.min(_bubbleMinCenter, box.maxWidth - 84),
box.maxWidth - 84,
),
child: FractionalTranslation(
translation: const Offset(-0.5, 0),
child: _SpeechBubble(text: growth.growthStatus),
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => _details(context),
child: _SpeechBubble(text: growth.growthBubble),
),
),
),
),
],
@@ -820,7 +809,9 @@ class _StartSheet extends StatelessWidget {
onTap: () => Navigator.of(context).pop(),
child: SizedBox(
height: 47,
child: Center(child: Text(context.l10n.commonCancel, style: _item)),
child: Center(
child: Text(context.l10n.commonCancel, style: _item),
),
),
),
],
@@ -828,3 +819,180 @@ class _StartSheet extends StatelessWidget {
),
);
}
/// 「GALAXY 맵 확장」 히어로 — Figma 「홈_초기사용자」 `343:211127`.
///
/// 히어로 680(상태 표시줄 48 포함)은 그대로 두고, 그 안 y124~680 을 갤럭시 맵(프로토타입 v6.2
/// 장면 — `GalaxyWebView` 확장 모드)이 채운다. 프레임 순서대로 라이브 띠(y48 · 22)가 헤더(y70)
/// 위에 오고, 히어로 아래에 375×54 「GALAXY」 바(ABeeZee 20 · 오른쪽 돋보기 42)가 붙는다.
/// 돋보기 = 갤럭시 검색(`2008:14560` 「돋보기 - 검색페이지 이동」).
///
/// 닫기 컨트롤은 프레임에 없다. 「GALAXY」 글자 탭과 시스템 뒤로가기로 기본 히어로에 돌아간다.
class HomeGalaxyHero extends StatelessWidget {
const HomeGalaxyHero({
super.key,
required this.map,
required this.onClose,
required this.onSearch,
this.ticker,
});
/// 갤럭시 맵 — HOME 이 미니맵과 같은 `GalaxyWebView` 를 넘긴다(확장 모드).
final Widget map;
final VoidCallback onClose;
final VoidCallback onSearch;
final Widget? ticker;
/// 사본 「GALAXY」 바 높이.
static const barHeight = 54.0;
/// 헤더가 라이브 띠 아래로 내려가는 몫(띠 22).
static const headerShift = 22.0;
@override
Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top - 48;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
height: top + HomeHero.designHeight,
child: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/figma/bg-space-home.png',
fit: BoxFit.cover,
alignment: Alignment.topCenter,
),
),
Positioned(
top: top + 124,
left: 0,
right: 0,
bottom: 0,
child: map,
),
if (ticker case final t?)
Positioned(top: top + 48, left: 0, right: 0, child: t),
],
),
),
Container(
height: barHeight,
color: const Color(0xFF141414),
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s4,
vertical: 6,
),
child: Row(
children: [
GestureDetector(
key: const ValueKey('galaxy-bar-close'),
behavior: HitTestBehavior.opaque,
onTap: onClose,
child: const Text(
'GALAXY',
style: TextStyle(
fontFamily: AppFonts.display,
fontSize: 20,
fontWeight: FontWeight.w400,
height: 1.4,
color: AppColors.onCosmic,
),
),
),
const Spacer(),
IconButton(
constraints: const BoxConstraints.tightFor(
width: 42,
height: 42,
),
padding: EdgeInsets.zero,
tooltip: context.l10n.commonSearch,
onPressed: onSearch,
icon: const Icon(
Icons.search,
size: 20,
color: AppColors.onCosmic,
),
),
],
),
),
],
);
}
}
/// HOME 의 ZAP — Figma 「홈」 `Frame 1984081111`(52×53 원 · `#4BC2AE → #4F98C8 → #5669DC` ·
/// `#547BD4` 빛 6 · 번개 하나). 프레임 루트 직속이라 스크롤과 무관하게 하단 탭 위
/// 가운데에 떠 있다(사본 x162 · 하단 탭 위 16). 명세 「Zap 버튼」 규칙 *화면 하단 위치 고정 ·*
/// *행성/성운 가입 또는 생성한 유저에게만 노출* — 소속 팬덤이 지도 데이터의 내 행성일 때만.
class HomeZap extends StatelessWidget {
const HomeZap({
super.key,
required this.buttonKey,
required this.zap,
required this.data,
required this.fandomId,
required this.watchPlanet,
this.onHeroParticipate,
this.onItem,
});
final GlobalKey buttonKey;
final ZapController zap;
final GalaxySnapshot data;
final String fandomId;
final PlanetWatch watchPlanet;
final VoidCallback? onHeroParticipate;
final VoidCallback? onItem;
/// 사본 하단 탭 윗변(y2806)과 버튼 아랫변(y2790) 사이.
static const bottomGap = 16.0;
static bool visible(GalaxySnapshot? data, String fandomId) =>
data != null && data.joined && data.mine?.id == fandomId;
@override
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
key: ValueKey(('home-zap', fandomId)),
create: () => watchPlanet(fandomId),
builder: (context, state) {
final hero =
state?.isHeroTime == true &&
state?.heroEndsAt?.isAfter(DateTime.now()) == true &&
onHeroParticipate != null &&
zap.pending == 0 &&
!zap.sending &&
!zap.needsRetry;
return Column(
key: buttonKey,
mainAxisSize: MainAxisSize.min,
children: [
if (zap.needsRetry)
TextButton(
onPressed: zap.retry,
child: Text(context.l10n.zapRetrySame),
),
ZapButton(
round: true,
// 사본 `2097:16428` 의 떠 있는 ZAP 은 번개만 있다(라벨 없음).
showLabel: false,
onItem: onItem,
zapPop: data.zapPop,
myPop: (zap.balance ?? data.myPop) - zap.pending * data.zapPop,
isHeroTime: hero,
blockedReason: zap.sending
? context.l10n.zapApplying
: zap.needsRetry
? context.l10n.zapNeedsCheck
: null,
onZap: () => hero ? onHeroParticipate!() : zap.tap(fandomId),
),
],
);
},
);
}
@@ -0,0 +1,551 @@
import 'package:flutter/material.dart';
import '../../../core/format.dart';
import '../../../core/i18n/app_locale.dart';
import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../inside/inside_data.dart';
import '../../inside/inside_screen.dart' show InsideQuestionCard;
import '../home_data.dart';
/// Figma 「홈」(`2097:16428`) 구획 머리 — 제목 42dot 20/600(행간 100%) 과 오른쪽 「더 보기」
/// (12/600 #A3A2A0 + chevron 18). 오른쪽 자리에 다른 것(아티스트 소식 「공유」 알약)을 둘 수 있다.
class HomeSectionHeader extends StatelessWidget {
const HomeSectionHeader({
super.key,
required this.title,
this.onMore,
this.trailing,
});
final String title;
final VoidCallback? onMore;
final Widget? trailing;
static const _more = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmicVariant,
);
/// 머리 줄 높이 — 사본 20 이지만 「더 보기」 터치 영역 44 를 위해 44 로 잡고, 위아래 남는
/// 12 씩을 구획 간격에서 뺀다(HOME 이 [slack] 을 쓴다). 눈에 보이는 위치는 사본과 같다.
static const height = 44.0;
static const slack = (height - 20) / 2;
@override
Widget build(BuildContext context) => ConstrainedBox(
constraints: const BoxConstraints(minHeight: height),
child: Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
),
?trailing,
if (trailing == null && onMore != null)
// 글자는 사본 크기대로 두고 누르는 자리만 44 로 넓힌다.
InkWell(
onTap: onMore,
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: height),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.commonMore, style: _more),
const ImageIcon(
AppIcons.chevronRight,
size: 18,
color: AppColors.onCosmicVariant,
),
],
),
),
),
],
),
);
}
/// 팬덤행성 인큐베이터(성운 추천) — 사본 `Frame 1984081878`: 154×218 카드를 간격 8 로 가로
/// 스크롤. 카드 `#1A1A1C` · `#232324` 획 · r12 · 패딩 16 · 간격 20.
/// 명세 §2.114-4(`2097:16403`) — 기여율 높은 순 최대 6개 · 「함께 성장하기」 = 성운 상세.
class NebulaIncubator extends StatelessWidget {
const NebulaIncubator({
super.key,
required this.nebulas,
required this.onOpen,
});
final List<NebulaPick> nebulas;
final ValueChanged<NebulaPick> onOpen;
static const cardSize = Size(154, 218);
@override
Widget build(BuildContext context) => SizedBox(
// 글자 확대 때 카드 안이 넘치지 않게 글자 몫만 배율을 따른다.
height: cardSize.height - 70 + MediaQuery.textScalerOf(context).scale(70),
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
itemCount: nebulas.length,
separatorBuilder: (_, _) => const SizedBox(width: AppSpace.s2),
itemBuilder: (context, i) =>
_NebulaCard(nebula: nebulas[i], onOpen: () => onOpen(nebulas[i])),
),
);
}
class _NebulaCard extends StatelessWidget {
const _NebulaCard({required this.nebula, required this.onOpen});
final NebulaPick nebula;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) => Container(
width: NebulaIncubator.cardSize.width,
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF232324)),
),
child: Column(
children: [
Text(
nebula.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 4),
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Image(
image: AssetImage('assets/figma/icon-person.png'),
width: 14,
height: 14,
color: Color(0xFF45464A),
),
const SizedBox(width: 3),
Text(
formatPop(nebula.members),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.3,
color: Color(0xFF45464A),
),
),
],
),
const SizedBox(height: 12),
const Image(
image: AssetImage('assets/figma/nebula-card.png'),
width: 48,
height: 48,
),
const SizedBox(height: 12),
Text(
'${nebula.percent}%',
textAlign: TextAlign.center,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 24,
fontWeight: FontWeight.w600,
height: 1,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 8),
// 사본 `Frame 1984081872` — 122×4 #424242 위에 #E49BD1 → #FF7DDD 채움.
Container(
height: 4,
decoration: BoxDecoration(
color: const Color(0xFF424242),
borderRadius: BorderRadius.circular(AppRadius.full),
),
alignment: Alignment.centerLeft,
child: FractionallySizedBox(
widthFactor: (nebula.percent / 100).clamp(0.0, 1.0),
child: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppRadius.full),
gradient: const LinearGradient(
colors: [Color(0xFFE49BD1), Color(0xFFFF7DDD)],
),
),
),
),
),
const Spacer(),
Material(
color: const Color(0xFFFF7DDD),
shape: const StadiumBorder(),
child: InkWell(
onTap: onOpen,
customBorder: const StadiumBorder(),
child: SizedBox(
height: 26,
width: double.infinity,
child: Center(
child: Text(
context.l10n.homeGrowTogether,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.35,
color: Colors.black,
),
),
),
),
),
),
],
),
);
}
/// 아티스트 소식 머리의 「공유」 알약 — 사본 `Frame 1984081277`(55×26 · #1A1A1C · 패딩 6/8 ·
/// 12/600 #A3A2A0 + share-2 14).
class SharePill extends StatelessWidget {
const SharePill({super.key, required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: AppColors.cosmicSurfaceContainer,
shape: const StadiumBorder(),
child: InkWell(
onTap: onTap,
customBorder: const StadiumBorder(),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(context.l10n.commonShare, style: HomeSectionHeader._more),
const SizedBox(width: 4),
const ImageIcon(
AppIcons.share,
size: 14,
color: AppColors.onCosmicVariant,
),
],
),
),
),
);
}
/// HOT ARCHIVE 캐러셀 — 사본 `Frame 1984081106` 카드(343×481) + 아래 16 · 점 8(간격 8 ·
/// 현재 #FF7DDD · 나머지 #474747). 명세 §2.114-6 「좌/우 스와이프」.
class CampaignCarousel extends StatefulWidget {
const CampaignCarousel({
super.key,
required this.campaigns,
required this.onOpen,
});
final List<CampaignHighlight> campaigns;
final ValueChanged<CampaignHighlight> onOpen;
@override
State<CampaignCarousel> createState() => _CampaignCarouselState();
}
class _CampaignCarouselState extends State<CampaignCarousel> {
int _page = 0;
@override
Widget build(BuildContext context) {
// 이미지 228 + 본문 253. 본문 글자 몫(약 120)만 배율을 따른다.
final height = 481 - 120 + MediaQuery.textScalerOf(context).scale(120);
return Column(
children: [
SizedBox(
height: height,
child: PageView.builder(
itemCount: widget.campaigns.length,
onPageChanged: (i) => setState(() => _page = i),
itemBuilder: (context, i) => Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
child: HomeCampaignCard(
campaign: widget.campaigns[i],
onOpen: () => widget.onOpen(widget.campaigns[i]),
),
),
),
),
// 사본은 한 장이어도 점 줄을 둔다(현재 위치 표지).
if (widget.campaigns.isNotEmpty) ...[
const SizedBox(height: AppSpace.s4),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
for (var i = 0; i < widget.campaigns.length; i++) ...[
if (i > 0) const SizedBox(width: 8),
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: i == _page
? const Color(0xFFFF7DDD)
: const Color(0xFF474747),
),
),
],
],
),
],
],
);
}
}
/// 캠페인 카드 — 사본 `Frame 1984081106`: 이미지 342×228 / 본문 `#181818` · `#1E1E1E` 획 ·
/// 패딩 14 · 간격 21 — 작성자(원 26 #ED7E7E · ABeeZee 12) + 「92% 달성」(그라디언트 r4) /
/// 제목 ABeeZee 14 / 설명 14/500 #C2C2C2 2줄 / 선 #4F4F4F / 좋아요 · 댓글 / 「캠페인 보기」
/// (흰 r12 · 38).
///
/// 사본의 조회수 · 공유수 칸은 캠페인 문서에 없는 값이라 그리지 않는다.
class HomeCampaignCard extends StatelessWidget {
const HomeCampaignCard({
super.key,
required this.campaign,
required this.onOpen,
});
final CampaignHighlight campaign;
final VoidCallback onOpen;
static const _stat = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
letterSpacing: -0.3,
color: Color(0xFF767676),
);
@override
Widget build(BuildContext context) {
Widget stat(ImageProvider icon, int n) => Row(
mainAxisSize: MainAxisSize.min,
children: [
ImageIcon(icon, size: 14, color: const Color(0xFF767676)),
const SizedBox(width: 4),
Text(formatPop(n), style: _stat),
],
);
return ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
// 사본 342×228 — 높이를 고정해 넓은 화면에서도 본문 자리가 줄지 않게 한다.
const SizedBox(
height: 228,
width: double.infinity,
child: Image(
image: AssetImage('assets/figma/campaign-sample.png'),
fit: BoxFit.cover,
),
),
Expanded(
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: const BoxDecoration(
color: Color(0xFF181818),
border: Border(
left: BorderSide(color: Color(0xFF1E1E1E)),
right: BorderSide(color: Color(0xFF1E1E1E)),
bottom: BorderSide(color: Color(0xFF1E1E1E)),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 26,
height: 26,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFED7E7E),
),
),
const SizedBox(width: 9),
Expanded(
child: Text(
campaign.authorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
),
),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(4),
gradient: const LinearGradient(
colors: [
Color(0xFF4BC2AE),
Color(0xFF4F98C8),
Color(0xFF5669DC),
],
),
),
child: Text(
context.l10n.campaignAchieved(
'${campaign.achievedPercent}',
),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
),
),
],
),
const SizedBox(height: 16),
Text(
campaign.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 14,
height: 1.3,
letterSpacing: -0.35,
color: AppColors.onCosmic,
),
),
const SizedBox(height: 7),
Text(
campaign.description,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w500,
height: 1.4,
letterSpacing: -0.35,
color: Color(0xFFC2C2C2),
),
),
const Spacer(),
const Divider(
height: 1,
thickness: 1,
color: Color(0xFF4F4F4F),
),
const SizedBox(height: 21),
Row(
children: [
stat(AppIcons.heart, campaign.likes),
const SizedBox(width: 12),
stat(AppIcons.messageCircle, campaign.comments),
],
),
const SizedBox(height: 21),
Material(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: onOpen,
borderRadius: BorderRadius.circular(12),
child: SizedBox(
height: 38,
width: double.infinity,
child: Center(
child: Text(
context.l10n.campaignView,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.3,
letterSpacing: -0.35,
color: Colors.black,
),
),
),
),
),
),
],
),
),
),
],
),
);
}
}
/// 인기 K-INSIDE 한 장 — INSIDE 목록과 같은 카드(시안 `196:32243`)를 사본 `Frame 1984081004`
/// 바탕(`#19191C` · r8)에 얹는다.
class HomeInsideCard extends StatelessWidget {
const HomeInsideCard({
super.key,
required this.question,
required this.onTap,
});
final InsideQuestion question;
final VoidCallback onTap;
@override
Widget build(BuildContext context) => Material(
color: const Color(0xFF19191C),
borderRadius: BorderRadius.circular(8),
clipBehavior: Clip.antiAlias,
child: InsideQuestionCard(question: question, onTap: onTap),
);
}
@@ -1,86 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../home_screen.dart' show formatPop;
import '../../../core/i18n/app_locale.dart';
/// 인기 K-INSIDE 한 건 — Figma 「아미보이 / 10분 전 / 500 P / 제목 / 본문 / 3」.
///
/// 현상금(POP 바운티)이 붙은 Q&A 다. 정책 설계서 §P-11 — 자기 답변 채택은 막고
/// (`E-QNA-01`), 기한 만료 시 현상금은 자동 환불한다(`E-QNA-02`).
class InsideCard extends StatelessWidget {
const InsideCard({super.key, required this.digest, this.onTap});
final InsideDigest digest;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(digest.authorName, style: t.bodySmall),
const SizedBox(width: AppSpace.s2),
Text(digest.timeAgo, style: t.labelSmall),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s2,
vertical: 2,
),
decoration: BoxDecoration(
border: Border.all(color: AppColors.cosmicAccent),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
'${formatPop(digest.bountyPop)} P',
style: t.labelSmall?.copyWith(
color: AppColors.cosmicAccent,
),
),
),
],
),
const SizedBox(height: AppSpace.s3),
Text(digest.title, style: t.bodyMedium),
const SizedBox(height: AppSpace.s1),
Text(
digest.body,
style: t.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
const Icon(
Icons.mode_comment_outlined,
size: 14,
color: AppColors.onCosmicVariant,
),
const SizedBox(width: AppSpace.s1),
Text(
context.l10n.insideAnswers('${digest.answers}'),
style: t.labelSmall,
),
],
),
],
),
),
),
);
}
}
@@ -4,8 +4,9 @@ import '../../../core/theme/tokens.dart';
/// 상단 라이브 띠 — Figma 「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」.
///
/// 사본 `Frame 1984081132` — 헤더 바로 아래 전폭 검정 띠(높이 22 · 패딩 4/0), 가운데
/// 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의 표면이다.
/// 사본 `Frame 1984081132`(「홈」 `2097:16428`) — 헤더 바로 아래 전폭 띠(높이 22 · 패딩 4/0 ·
/// 바탕 없음), 가운데 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의
/// 표면이다.
class LiveTicker extends StatelessWidget {
const LiveTicker({super.key, required this.text});
@@ -14,7 +15,6 @@ class LiveTicker extends StatelessWidget {
@override
Widget build(BuildContext context) => Container(
height: 22,
color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
alignment: Alignment.center,
child: Text(
+109 -202
View File
@@ -1,219 +1,130 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../home_screen.dart' show formatPop;
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 「오늘의 출석체크 / 출석 완료 / +{보상} / 보상받기」.
/// 홈 「TODAY'S QUEST」 묶음 — Figma 「홈」 `2097:16428`: 343×270 · r12 안에 343×90 줄
/// 세 개를 간격 없이 쌓는다. 앞 두 줄에 `#242424` 1 획(사본 spec) — 줄 사이 구분선으로 보인다.
///
/// 보상 숫자는 Figma 의 50 이 아니라 서버 지급액과 같은 원천에서 온다.
/// 항목은 설명 프레임 `2097:16397` 「관리자 퀘스트 1·2·3 — 최신순 최대 3개 · 클릭 시 해당
/// 퀘스트 상세」. 사용자 생성 퀘스트는 저장소가 거른다.
class QuestRows extends StatelessWidget {
const QuestRows({super.key, required this.quests, required this.onTap});
final List<QuestSummary> quests;
final ValueChanged<QuestSummary> onTap;
/// 시안 줄 수. 나머지는 「더 보기」(퀘스트 목록)에서 본다.
static const maxRows = 3;
@override
Widget build(BuildContext context) => ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Column(
children: [
for (final (i, q) in quests.take(maxRows).indexed)
QuestRow(
quest: q,
divider: i < math.min(quests.length, maxRows) - 1,
onTap: () => onTap(q),
),
],
),
);
}
/// 한 줄 — `2097:16428` 「출석체크 / 오늘의 출석체크」 행: #1A1A1C · 패딩 20 · 왼쪽 50×50
/// #2E2E2E r16 아이콘 상자(42 이미지) · 간격 19 · 제목 18/600 + 보조 14/500(간격 4) ·
/// 오른쪽 chevron 24 #4F4F4F.
///
/// 상태별 오른쪽 버튼이 다르다. 정책 설계서 §P-05 — 조건 미충족 시 보상은
/// 지급하지 않는다(`E-QST-01`).
/// 보조 줄은 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」)다. 「홈」 프레임에는 보상 숫자 ·
/// 진행 상태가 없어 그리지 않는다. 예외는 긴급 타임어택의 남은 시간 하나 — FR-QST-06
/// 「홈 카운트다운」이 요구한다.
class QuestRow extends StatelessWidget {
const QuestRow({super.key, required this.quest, this.onTap});
const QuestRow({
super.key,
required this.quest,
this.onTap,
this.divider = false,
});
final QuestSummary quest;
final VoidCallback? onTap;
/// 아래 변 `#242424` 1 획.
final bool divider;
static const height = 90.0;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
final done = quest.state == QuestState.done;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
shape: divider
? const Border(bottom: BorderSide(color: Color(0xFF242424)))
: null,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(quest.title, style: t.bodyMedium),
const SizedBox(height: AppSpace.s1),
Text(_subtitle(quest), style: t.bodySmall),
],
),
),
const SizedBox(width: AppSpace.s3),
Text(
_reward(quest),
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent),
),
const SizedBox(width: AppSpace.s3),
_Action(state: quest.state, onTap: onTap),
],
),
),
),
);
}
static String _subtitle(QuestSummary q) => switch (q.state) {
QuestState.claimable => appL10n.questAttended,
QuestState.inProgress => q.progressLabel ?? appL10n.questInProgressSpaced,
QuestState.done => appL10n.questRewardPaid,
};
}
class _Action extends StatelessWidget {
const _Action({required this.state, this.onTap});
final QuestState state;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return switch (state) {
QuestState.claimable => FilledButton(
onPressed: onTap,
style: FilledButton.styleFrom(
minimumSize: const Size(0, AppSizing.touchMinApp),
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
backgroundColor: AppColors.cosmicAccent,
foregroundColor: AppColors.onCosmicAccent,
),
child: Text(_claimLabel),
),
QuestState.inProgress => OutlinedButton(
onPressed: onTap,
style: OutlinedButton.styleFrom(
minimumSize: const Size(0, AppSizing.touchMinApp),
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
foregroundColor: AppColors.onCosmic,
side: const BorderSide(color: AppColors.cosmicOutline),
),
child: Text(_startLabel),
),
QuestState.done => Padding(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2),
child: Text(_doneLabel, style: t.bodySmall),
),
};
}
}
/// 사본 HOME 「TODAY'S QUEST」 버튼 문구(`252:159354` spec). 사본 HOME 에 없는 완료 상태는
/// 퀘스트 화면(C 그룹)의 「보상 완료」와 맞춘다.
String get _claimLabel => appL10n.questClaim;
String get _startLabel => appL10n.questStart;
String get _doneLabel => appL10n.questRewardDone;
/// 홈 「TODAY'S QUEST」 가로 카드 — 사본 `Frame 1984081083`(196×137 · #1A1A1C · r12 ·
/// 패딩 12 · 간격 28): 제목 16 · 상태 14 #A3A2A0 · 오른쪽 「+보상」 20 #2AC28B, 아래 37 버튼
/// (보상 가능 = 초록 #3EB58A 흰 글자 · 진행 중 = 흰 바탕 검정 글자). 누르면 퀘스트 목록으로 간다.
class QuestCard extends StatelessWidget {
const QuestCard({super.key, required this.quest, this.onTap});
final QuestSummary quest;
final VoidCallback? onTap;
static const width = 196.0;
@override
Widget build(BuildContext context) {
final (label, fill, ink) = switch (quest.state) {
QuestState.claimable => (
_claimLabel,
const Color(0xFF3EB58A),
AppColors.onCosmic,
),
QuestState.inProgress => (_startLabel, AppColors.onCosmic, Colors.black),
QuestState.done => (
_doneLabel,
AppColors.cosmicSurfaceRaised,
AppColors.onCosmicMuted,
),
};
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s3),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quest.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w600,
height: 1.4,
color: AppColors.onCosmic,
),
child: ConstrainedBox(
// 글자 확대 때는 늘어난다 — 고정하면 잘린다.
constraints: const BoxConstraints(minHeight: height),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
QuestIconBox(image: questImageFor(quest.type, quest.title)),
const SizedBox(width: 19),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
quest.title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 18,
fontWeight: FontWeight.w600,
height: 20 / 18,
color: AppColors.onCosmic,
),
const SizedBox(height: 6),
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
if (quest.endsAt case final endsAt?
when quest.state != QuestState.done)
TimeAttackCountdown(
endsAt: endsAt,
builder: (context, left) => _subtitleText(
left == null
? context.l10n.questTimeAttackClosed
: context.l10n.questTimeAttackLeft(
formatLeft(left),
),
),
)
else
_subtitleText(QuestRow._subtitle(quest)),
],
),
),
const SizedBox(width: AppSpace.s2),
Text(
_reward(quest),
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w600,
height: 1,
color: Color(0xFF2AC28B),
),
),
],
),
Container(
height: 37,
alignment: Alignment.center,
decoration: BoxDecoration(
color: fill,
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: Text(
label,
style: TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1,
color: ink,
),
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 ?? ''),
],
),
),
),
],
const SizedBox(width: AppSpace.s2),
const ImageIcon(
AppIcons.chevronRight,
size: 24,
color: Color(0xFF4F4F4F),
),
],
),
),
),
),
@@ -221,19 +132,15 @@ class QuestCard extends StatelessWidget {
}
}
Widget _subtitleText(String text) => Text(
Widget _sub(String text) => Text(
text,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 14,
fontWeight: FontWeight.w600,
height: 1.4,
fontWeight: FontWeight.w500,
height: 20 / 14,
color: AppColors.onCosmicVariant,
),
);
/// 운영값을 못 읽었으면 숫자를 지어내지 않는다.
String _reward(QuestSummary quest) =>
quest.rewardPop == null ? 'POP' : '+${formatPop(quest.rewardPop!)}';
+3 -2
View File
@@ -220,7 +220,8 @@ String insideErrorMessage(Object error) => switch (error) {
class SampleInsideRepository implements InsideRepository {
const SampleInsideRepository();
static List<InsideQuestion> get _all {
/// 표본 질문(HOME 「인기 K-INSIDE」 표본도 같은 것을 쓴다).
static List<InsideQuestion> get samples {
final now = DateTime.now();
return [
InsideQuestion(
@@ -286,7 +287,7 @@ class SampleInsideRepository implements InsideRepository {
}
List<InsideQuestion> _sorted(InsideSort sort) {
final list = _all;
final list = samples;
switch (sort) {
case InsideSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt));
@@ -73,27 +73,8 @@ class FirestoreInsideRepository implements InsideRepository {
return [for (final doc in snap.docs) _question(doc)];
}
InsideQuestion _question(QueryDocumentSnapshot<Map<String, dynamic>> doc) {
final data = doc.data();
return InsideQuestion(
id: doc.id,
title: data.strOr('title'),
body: data.strOr('body'),
authorName: publicName(data['authorName']),
createdAt: data.dateOr('createdAt', DateTime.now()),
bountyPop: data.intOr('bountyPop'),
answerCount: data.intOr('answerCount'),
viewCount: data.intOr('viewCount'),
// 질문 문서에 아직 없는 필드다(fn-question-create 가 쓰지 않음). 없으면 0 · 빈 목록.
likeCount: data.intOr('likeCount'),
imageUrls: [
for (final u in (data['imageUrls'] as List?) ?? const []) '$u',
],
isMine: data.strOr('authorUid') == _backend.uid,
isSolved: data.strOr('status') == 'accepted',
refund: InsideRefund.fromStatus(data.strOr('status')),
);
}
InsideQuestion _question(QueryDocumentSnapshot<Map<String, dynamic>> doc) =>
insideQuestionFrom(doc.id, doc.data(), _backend.uid);
/// 팬덤과 상관없이 내가 쓴 질문 전부(`authorUid` 등호 하나라 복합 색인이 필요 없다).
// ponytail: 정렬은 앱에서 한다. 한 사람의 질문이 수백 건을 넘으면 orderBy + 복합 색인 + 페이지로.
@@ -183,3 +164,26 @@ class FirestoreInsideRepository implements InsideRepository {
{'questionId': questionId, 'answerId': answerId},
);
}
/// `questions/{id}` 문서 → 카드 · 상세가 쓰는 [InsideQuestion]. HOME 「인기 K-INSIDE」도 같은
/// 매핑을 쓴다(같은 카드 컴포넌트 · 시안 `196:32243`).
InsideQuestion insideQuestionFrom(
String id,
Map<String, dynamic> data,
String? uid,
) => InsideQuestion(
id: id,
title: data.strOr('title'),
body: data.strOr('body'),
authorName: publicName(data['authorName']),
createdAt: data.dateOr('createdAt', DateTime.now()),
bountyPop: data.intOr('bountyPop'),
answerCount: data.intOr('answerCount'),
viewCount: data.intOr('viewCount'),
// 질문 문서에 아직 없는 필드다(fn-question-create 가 쓰지 않음). 없으면 0 · 빈 목록.
likeCount: data.intOr('likeCount'),
imageUrls: [for (final u in (data['imageUrls'] as List?) ?? const []) '$u'],
isMine: uid != null && data.strOr('authorUid') == uid,
isSolved: data.strOr('status') == 'accepted',
refund: InsideRefund.fromStatus(data.strOr('status')),
);
@@ -10,8 +10,11 @@ import '../../../core/i18n/app_locale.dart';
///
/// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다.
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다.
String questImage(QuestCard card) {
switch (card.type) {
String questImage(QuestCard card) => questImageFor(card.type, card.title);
/// [questImage] 를 카드 없이 — HOME 「TODAY'S QUEST」 줄은 요약([QuestSummary])만 들고 있다.
String questImageFor(QuestType? type, String title) {
switch (type) {
case QuestType.attend:
return 'assets/figma/quest-img-stamp.png';
case QuestType.steps:
@@ -20,7 +23,6 @@ String questImage(QuestCard card) {
return 'assets/figma/quest-img-truck.png';
default:
}
final title = card.title;
if (title.contains('출석')) return 'assets/figma/quest-img-stamp.png';
if (title.contains('좋아요')) return 'assets/figma/quest-img-heart.png';
if (title.contains('댓글')) return 'assets/figma/quest-img-bubble.png';
@@ -33,9 +35,10 @@ String questImage(QuestCard card) {
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
class QuestIconBox extends StatelessWidget {
const QuestIconBox({super.key, required this.card});
const QuestIconBox({super.key, required this.image});
final QuestCard card;
/// 에셋 경로 — [questImage] · [questImageFor].
final String image;
@override
Widget build(BuildContext context) {
@@ -47,7 +50,7 @@ class QuestIconBox extends StatelessWidget {
color: const Color(0xFF2E2E2E),
borderRadius: BorderRadius.circular(16),
),
child: Image.asset(questImage(card), width: 42, height: 42),
child: Image.asset(image, width: 42, height: 42),
);
}
}
@@ -113,7 +116,7 @@ class QuestCardTile extends StatelessWidget {
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
QuestIconBox(card: card),
QuestIconBox(image: questImage(card)),
const SizedBox(width: 10),
Expanded(
child: Column(
@@ -335,7 +338,7 @@ class QuestAdminRow extends StatelessWidget {
: null,
child: Row(
children: [
QuestIconBox(card: card),
QuestIconBox(image: questImage(card)),
const SizedBox(width: 19),
Expanded(
child: Column(