HOME 을 Figma 「홈」 구성으로 — TODAY'S QUEST · 미니맵·확장 맵(프로토타입 v6.2) · 행성 정보 박스 · 인큐베이터 · 아티스트 소식 · HOT ARCHIVE · 인기 K-INSIDE
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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!)}';
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user