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

This commit is contained in:
Sn-Kinos
2026-10-05 16:29:41 +09:00
parent f84d696c21
commit 54d0b23260
45 changed files with 3448 additions and 1111 deletions
+93
View File
@@ -0,0 +1,93 @@
Copyright 2020 The JetBrains Mono Project Authors (https://github.com/JetBrains/JetBrainsMono)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://openfontlicense.org
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
File diff suppressed because one or more lines are too long
+680
View File
@@ -0,0 +1,680 @@
<!DOCTYPE html>
<!--
FANUZU 앱 번들판 — 원본: UZU HUD (2).html (발주사 프로토타입 v6.2).
렌더링 코드(three.js 장면 · 셰이더/재질 · 별 · 입자 · HUD 텍스처 · animate)는 한 줄도 바꾸지 않았다.
바꾼 것:
1. CDN 제거 — three.js r128 min 을 같은 폴더 three.min.js 로 번들(MIT 라이선스 주석 유지).
tailwind CDN · font-awesome 제거 — 화면에 남는 클래스(fixed inset-0)만 아래 인라인 CSS 로.
Google Fonts @import → 같은 폴더 JetBrains Mono woff2(@font-face, OFL-JetBrainsMono.txt).
2. 조절 패널(게이지 8개 · D-8)과 상단 헤더(「Fancake」 표기 — 공통 기준 4항)를 숨김(.ui-overlay).
상단 SYSTEM_STATUS 띠(.data-stream)도 숨김 — Figma 343:211127 캡처에 보이지 않는다.
3. 캔버스 touch-action:none — 끌기 · 핀치를 페이지 스크롤 대신 장면이 받게.
4. 원본 스크립트 뒤에 FANUZU 훅 스크립트 추가(원본 전역만 바깥에서 다룸):
window.setGalaxy(json) 데이터 주입 · window.setMode('mini'|'expanded') · window.fanuzuPause(bool) ·
끌기 회전(±180°) · 핀치 줌 · 탭 → FanuzuBridge.postMessage · LIVE 3초 난수 흔들기 차단 ·
글꼴 로드 후 원본 onload 실행.
5. animate() 의 화면 중심 보정 1곳 — 중심 행성 위치를 장면 회전(rotation.y)만큼 돌려 쓴다.
원본은 회전 0 일 때만 중심이 맞아, 끌어서 돌린 뒤 행성을 누르면 초점이 어긋났다(2026-10-05 실기기).
6. mini(93pt) 모드 기본값 — HUD Size 6.0 · Orbit Zoom 0.5 · 카메라 줌 2.2. 원본 값으로는 내 행성 카드가
몇 px 라 읽히지 않아 대표 승인(2026-10-05)으로 키움. 렌더 코드는 그대로이고 게이지 값만 다르다.
-->
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Fancake Cinematic Engine v6.2 - Crystal Clarity</title>
<script src="three.min.js"></script>
<style>
@font-face { font-family: 'JetBrains Mono'; font-weight: 500; src: url('jetbrains-mono-latin-500-normal.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-weight: 700; src: url('jetbrains-mono-latin-700-normal.woff2') format('woff2'); }
@font-face { font-family: 'JetBrains Mono'; font-weight: 800; src: url('jetbrains-mono-latin-800-normal.woff2') format('woff2'); }
/* FANUZU: tailwind 대체(화면에 남는 클래스만) · 패널/헤더 숨김 · 터치는 장면이 받는다 */
.fixed { position: fixed; } .inset-0 { inset: 0; }
.ui-overlay, .data-stream { display: none !important; }
#canvas-container { touch-action: none; }
body { margin: 0; background-color: #000000; font-family: 'Inter', sans-serif; overflow: hidden; color: #fff; }
canvas { display: block; width: 100%; height: 100%; }
.ui-overlay { position: absolute; inset: 0; pointer-events: none; z-index: 100; }
.interactive { pointer-events: auto; }
/* 선명도 개선: 블러 수치를 줄이고 투명도를 살짝 조절하여 맑은 유리 느낌 구현 */
.glass-panel {
background: rgba(5, 10, 25, 0.75);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.15);
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes scanline { 0% { transform: translateY(-100%); } 100% { transform: translateY(100vh); } }
.scanline { position: absolute; width: 100%; height: 4px; background: linear-gradient(to bottom, transparent, rgba(16, 185, 129, 0.08), transparent); animation: scanline 6s linear infinite; pointer-events: none; z-index: 150; }
input[type=range] { -webkit-appearance: none; background: rgba(255,255,255,0.05); height: 2px; width: 100%; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 14px; height: 14px; background: #10b981; border-radius: 50%; cursor: pointer; box-shadow: 0 0 15px #10b981; border: 2px solid #fff; }
.fpi-badge { font-family: 'JetBrains Mono', monospace; font-size: 10px; font-weight: bold; color: #10b981; }
.hide-scrollbar::-webkit-scrollbar { display: none; }
.data-stream { position: absolute; top: 10px; left: 50%; transform: translateX(-50%); font-size: 8px; color: rgba(16, 185, 129, 0.6); font-family: 'JetBrains Mono'; letter-spacing: 2px; text-transform: uppercase; }
.panel-collapsed { transform: translateY(calc(100% - 60px)); }
.rotate-180 { transform: rotate(180deg); }
</style>
</head>
<body>
<div class="scanline"></div>
<div class="data-stream interactive">SYSTEM_STATUS: CRYSTAL_CLARITY_ACTIVE // ENGINE_SYNC: 100% // HUD_DEPTH: -500</div>
<div id="canvas-container" class="fixed inset-0"></div>
<div class="ui-overlay flex flex-col justify-between p-6">
<header class="flex justify-between items-start">
<div class="space-y-1">
<div class="flex items-center gap-2">
<h1 class="text-4xl font-black italic tracking-tighter uppercase leading-none text-transparent bg-clip-text bg-gradient-to-r from-emerald-400 to-blue-500">Fancake</h1>
<span class="text-[10px] bg-emerald-500/20 text-emerald-400 px-2 py-0.5 rounded border border-emerald-500/30 font-bold">V6.2 CLARITY</span>
</div>
<p class="text-[9px] font-bold text-zinc-500 tracking-[0.5em] uppercase">Deep Space Engine - Zero Mist Mode</p>
</div>
<div class="flex flex-col items-end gap-2 interactive">
<div class="glass-panel px-4 py-2 rounded-full flex items-center gap-3">
<div class="w-2 h-2 rounded-full bg-emerald-500 animate-ping shadow-[0_0_10px_#10b981]"></div>
<span class="text-[10px] font-black tracking-widest text-emerald-400 uppercase">Resonance: Ultra High</span>
</div>
</div>
</header>
<section id="main-ui-panel" class="max-w-md mx-auto w-full space-y-4 glass-panel rounded-[40px] p-6 interactive relative">
<button id="ui-toggle-btn" class="absolute -top-3 left-1/2 -translate-x-1/2 w-12 h-6 bg-emerald-500 rounded-full flex items-center justify-center border-2 border-black shadow-lg transition-transform pointer-events-auto">
<i id="toggle-icon" class="fa-solid fa-chevron-down text-black text-[10px] transition-transform"></i>
</button>
<div id="panel-content" class="space-y-4 transition-opacity duration-300">
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">Mass Volume</span>
<span id="scale-val" class="fpi-badge">x1.5</span>
</div>
<!-- Max 30.0 유지 -->
<input id="global-scale-slider" type="range" min="0.5" max="30.0" step="0.1" value="1.5">
</div>
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">Orbit Exp.</span>
<span id="zoom-val" class="fpi-badge">x1.0</span>
</div>
<input id="orbit-zoom-slider" type="range" min="0.5" max="2.5" step="0.1" value="1.0">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">HUD Size</span>
<span id="hud-size-val" class="fpi-badge">x1.0</span>
</div>
<input id="hud-size-slider" type="range" min="0.1" max="2.0" step="0.05" value="1.0">
</div>
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">HUD Dist.</span>
<span id="hud-dist-val" class="fpi-badge">x1.0</span>
</div>
<!-- -500.0 유지 -->
<input id="hud-dist-slider" type="range" min="-500.0" max="10.0" step="1.0" value="1.0">
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">Camera Height</span>
<span id="cam-y-val" class="fpi-badge">2000</span>
</div>
<input id="cam-y-slider" type="range" min="0" max="5000" step="10" value="2000">
</div>
<div class="bg-white/5 p-3 rounded-2xl border border-white/5">
<div class="flex justify-between mb-2">
<span class="text-[9px] font-black text-zinc-500 uppercase">View Rotation</span>
<span id="view-rot-val" class="fpi-badge">0°</span>
</div>
<input id="view-rot-slider" type="range" min="-180" max="180" step="1" value="0">
</div>
</div>
<div id="planet-controls" class="space-y-2 max-h-24 overflow-y-auto pr-2 hide-scrollbar"></div>
<div class="flex items-center gap-4 pt-1">
<button id="boost-btn" class="flex-1 h-12 rounded-2xl bg-gradient-to-r from-emerald-500 to-blue-600 flex items-center justify-center gap-3 active:scale-95 transition-all shadow-[0_10px_40px_rgba(16,185,129,0.3)] group">
<i class="fa-solid fa-fire text-lg group-active:scale-150 transition-transform"></i>
<span class="font-black italic tracking-tighter text-md uppercase">Action Flare</span>
</button>
</div>
</div>
</section>
</div>
<script>
const FPI_DATA = [
{ id: 1, name: 'NewJeans', fpi: 3200, color: 0x00F2FF, isMy: true, liveFans: 4820 },
{ id: 2, name: 'BTS', fpi: 2800, color: 0x8b5cf6, isMy: false, liveFans: 12540 },
{ id: 3, name: 'aespa', fpi: 2100, color: 0x10b981, isMy: false, liveFans: 3910 },
{ id: 4, name: 'IVE', fpi: 1700, color: 0xf43f5e, isMy: false, liveFans: 2840 },
{ id: 5, name: 'PLAVE', fpi: 1300, color: 0xf59e0b, isMy: false, liveFans: 5120 }
];
let state = {
boostFactor: 1.0,
planetScale: 1.5,
orbitZoom: 1.0,
hudSize: 1.0,
hudDist: 1.0,
camY: 2000,
viewRot: 0,
uiCollapsed: false,
planets: [...FPI_DATA].sort((a,b) => b.fpi - a.fpi).map((f, i) => ({ ...f, rank: i + 1 }))
};
const scene = new THREE.Scene();
const galaxyGroup = new THREE.Group();
scene.add(galaxyGroup);
const camera = new THREE.PerspectiveCamera(70, window.innerWidth / window.innerHeight, 0.1, 45000);
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
document.getElementById('canvas-container').appendChild(renderer.domElement);
function createNebula() {
const group = new THREE.Group();
for(let i=0; i<8; i++) {
const geo = new THREE.PlaneGeometry(3000, 3000);
const mat = new THREE.MeshBasicMaterial({
map: generateNebulaTexture(),
transparent: true,
// 뿌연 느낌을 없애기 위해 오퍼시티 대폭 하향 조정
opacity: 0.04,
depthWrite: false,
blending: THREE.AdditiveBlending,
side: THREE.DoubleSide
});
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set((Math.random()-0.5)*2000, (Math.random()-0.5)*500, (Math.random()-0.5)*2000);
mesh.rotation.x = Math.PI / 2;
group.add(mesh);
}
scene.add(group);
return group;
}
function generateNebulaTexture() {
const canvas = document.createElement('canvas');
canvas.width = 256; canvas.height = 256;
const ctx = canvas.getContext('2d');
const grad = ctx.createRadialGradient(128,128,0, 128,128,128);
// 색상 대비를 더 명확하게 하여 뿌연 느낌 제거
grad.addColorStop(0, 'rgba(16, 185, 129, 0.4)');
grad.addColorStop(0.4, 'rgba(59, 130, 246, 0.1)');
grad.addColorStop(0.8, 'transparent');
ctx.fillStyle = grad; ctx.fillRect(0,0,256,256);
return new THREE.CanvasTexture(canvas);
}
const nebula = createNebula();
function createStars() {
const geo = new THREE.BufferGeometry();
const pos = [];
const colors = [];
for(let i=0; i<8000; i++) {
pos.push((Math.random()-0.5)*15000, (Math.random()-0.5)*15000, (Math.random()-0.5)*15000);
colors.push(Math.random() * 0.5 + 0.5, Math.random() * 0.5 + 0.5, 1);
}
geo.setAttribute('position', new THREE.Float32BufferAttribute(pos, 3));
geo.setAttribute('color', new THREE.Float32BufferAttribute(colors, 3));
// 입자를 더 날카롭게 표현
const mat = new THREE.PointsMaterial({ size: 1.2, vertexColors: true, transparent: true, opacity: 0.8 });
const stars = new THREE.Points(geo, mat);
scene.add(stars);
return stars;
}
const stars = createStars();
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4); // 조명 대비를 위해 앰비언트 하향
scene.add(ambientLight);
const centerLight = new THREE.PointLight(0xffffff, 4, 25000); // 센터 포인트 라이트 강화
scene.add(centerLight);
const planetObjects = [];
const orbitRings = [];
function generateHUDTexture(data) {
const canvas = document.createElement('canvas');
canvas.width = 512; canvas.height = 256;
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, 512, 256);
ctx.strokeStyle = `rgba(${parseInt(data.color.toString(16).substring(0,2),16)}, ${parseInt(data.color.toString(16).substring(2,4),16)}, ${parseInt(data.color.toString(16).substring(4,6),16)}, 0.8)`;
ctx.lineWidth = 4;
ctx.strokeRect(10, 10, 492, 236);
ctx.fillStyle = 'rgba(0, 5, 20, 0.9)'; // HUD 배경을 더 어둡고 단단하게
ctx.fillRect(10, 10, 492, 236);
ctx.font = '800 50px JetBrains Mono';
ctx.fillStyle = '#' + data.color.toString(16).padStart(6, '0');
ctx.fillText(data.name.toUpperCase(), 40, 80);
ctx.font = '700 24px JetBrains Mono';
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.fillText(`RANK #${data.rank}`, 40, 120);
ctx.font = '800 40px JetBrains Mono';
ctx.fillStyle = '#10b981';
ctx.fillText(`FPI: ${data.fpi}`, 40, 180);
ctx.font = '700 24px JetBrains Mono';
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillText(`LIVE: ${data.liveFans.toLocaleString()}`, 40, 220);
ctx.fillStyle = '#10b981';
ctx.fillRect(450, 40, 20, 160 * (data.fpi / 5000));
return new THREE.CanvasTexture(canvas);
}
function createOrbitRing(radius, color) {
const geo = new THREE.RingGeometry(radius - 0.5, radius + 0.5, 128); // 링 두께 최소화
const mat = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.15, side: THREE.DoubleSide });
const mesh = new THREE.Mesh(geo, mat);
mesh.rotation.x = Math.PI / 2;
galaxyGroup.add(mesh);
return mesh;
}
function createPlanet(data) {
const group = new THREE.Group();
const radius = 15 + (data.fpi / 200);
const mesh = new THREE.Mesh(
new THREE.SphereGeometry(radius, 64, 64),
new THREE.MeshStandardMaterial({
color: data.color,
emissive: data.color,
emissiveIntensity: 0.8, // 발광 강화
metalness: 0.9,
roughness: 0.1
})
);
group.add(mesh);
const ringCount = data.rank === 1 ? 3 : (data.rank <= 3 ? 2 : 1);
for (let i = 0; i < ringCount; i++) {
const inner = radius * (1.6 + i * 0.15);
const outer = inner + (radius * 0.03); // 슬림해진 띠
const ringGeo = new THREE.RingGeometry(inner, outer, 128);
const ringMat = new THREE.MeshBasicMaterial({
color: data.color,
side: THREE.DoubleSide,
transparent: true,
opacity: 0.3 / (i + 1), // 띠 명확도 상승
depthWrite: false
});
const ringMesh = new THREE.Mesh(ringGeo, ringMat);
ringMesh.rotation.x = Math.PI / 2;
group.add(ringMesh);
}
const satCount = Math.floor(data.fpi / 10);
const satGeo = new THREE.BufferGeometry();
const satPos = [];
for(let i=0; i<satCount; i++) {
const r = radius * (1.8 + Math.random() * 2.5);
const theta = Math.random() * Math.PI * 2;
const phi = Math.random() * Math.PI;
satPos.push(r * Math.sin(phi) * Math.cos(theta), r * Math.sin(phi) * Math.sin(theta), r * Math.cos(phi));
}
satGeo.setAttribute('position', new THREE.Float32BufferAttribute(satPos, 3));
const satMat = new THREE.PointsMaterial({ color: data.color, size: 2, transparent: true, opacity: 0.9, blending: THREE.AdditiveBlending });
const satellites = new THREE.Points(satGeo, satMat);
group.add(satellites);
const auraMat = new THREE.SpriteMaterial({
map: generateGlowTexture(),
color: data.color,
transparent: true,
blending: THREE.AdditiveBlending,
opacity: 0.5
});
const aura = new THREE.Sprite(auraMat);
// 뿌연 느낌을 없애기 위해 Aura 스케일 대폭 축소 (15 -> 6)
aura.scale.set(radius * 6, radius * 6, 1);
group.add(aura);
const hudMat = new THREE.SpriteMaterial({
map: generateHUDTexture(data),
transparent: true,
depthTest: false
});
const hud = new THREE.Sprite(hudMat);
hud.position.y = radius + 5;
hud.scale.set(120, 60, 1);
group.add(hud);
galaxyGroup.add(group);
return {
group, mesh, satellites, aura, hud,
id: data.id, isMy: data.isMy,
baseRadius: radius, curRad: 0, curScale: 1.0
};
}
function generateGlowTexture() {
const canvas = document.createElement('canvas');
canvas.width = 128; canvas.height = 128;
const ctx = canvas.getContext('2d');
const grad = ctx.createRadialGradient(64,64,0, 64,64,64);
// 광륜 중심을 더 선명하게, 외곽을 빠르게 소멸시킴
grad.addColorStop(0, 'rgba(255,255,255,0.8)');
grad.addColorStop(0.2, 'rgba(255,255,255,0.3)');
grad.addColorStop(0.6, 'transparent');
ctx.fillStyle = grad; ctx.fillRect(0,0,128,128);
return new THREE.CanvasTexture(canvas);
}
function animate() {
const time = Date.now() * 0.0005 * state.boostFactor;
let myPos = new THREE.Vector3();
planetObjects.forEach((m, idx) => {
const data = state.planets.find(p => p.id === m.id);
m.curScale = THREE.MathUtils.lerp(m.curScale, state.planetScale, 0.05);
m.group.scale.set(m.curScale, m.curScale, m.curScale);
const orbitSpacing = 450 * state.planetScale * state.orbitZoom;
const targetOrbit = (data.rank - 1) * orbitSpacing;
m.curRad = THREE.MathUtils.lerp(m.curRad, targetOrbit, 0.03);
if(orbitRings[idx]) {
orbitRings[idx].scale.set(m.curRad / 100, m.curRad / 100, 1);
orbitRings[idx].material.opacity = m.curRad === 0 ? 0 : 0.15;
}
const speed = 0.8 / (data.rank + 1);
const x = Math.cos(time * speed + m.id) * m.curRad;
const z = Math.sin(time * speed + m.id) * m.curRad;
m.group.position.set(x, 0, z);
m.mesh.rotation.y += 0.02;
m.satellites.rotation.y -= 0.03;
// 압축된 아우라 스케일 유지
m.aura.scale.set(m.baseRadius * m.curScale * 6, m.baseRadius * m.curScale * 6, 1);
const baseInverseFactor = Math.pow(m.curScale, 0.22);
const hudHeight = 57.5 * baseInverseFactor * state.hudSize;
const proximityOffset = 2 * state.hudDist;
m.hud.position.y = (m.baseRadius * m.curScale) + (hudHeight / 2) + proximityOffset;
m.hud.scale.set(115 * baseInverseFactor * state.hudSize, hudHeight, 1);
if(m.isMy) myPos.copy(m.group.position);
});
// Camera Elevation & Rotation
camera.position.y = THREE.MathUtils.lerp(camera.position.y, state.camY, 0.05);
camera.lookAt(0, 0, 0);
const targetSceneRot = THREE.MathUtils.degToRad(state.viewRot);
galaxyGroup.rotation.y = THREE.MathUtils.lerp(galaxyGroup.rotation.y, targetSceneRot, 0.05);
// [FANUZU] 중심 보정을 장면 회전(rotation.y) 뒤 좌표로 — 원본은 회전 0 일 때만 맞았다.
myPos.applyAxisAngle(new THREE.Vector3(0, 1, 0), galaxyGroup.rotation.y);
galaxyGroup.position.x = THREE.MathUtils.lerp(galaxyGroup.position.x, -myPos.x, 0.08);
galaxyGroup.position.z = THREE.MathUtils.lerp(galaxyGroup.position.z, -myPos.z, 0.08);
stars.rotation.y += 0.0001;
nebula.rotation.y -= 0.00005;
renderer.render(scene, camera);
requestAnimationFrame(animate);
}
function buildUI() {
const container = document.getElementById('planet-controls');
container.innerHTML = '';
state.planets.forEach(p => {
const div = document.createElement('div');
div.className = `flex items-center gap-3 bg-white/5 p-3 rounded-2xl border ${p.isMy ? 'border-emerald-500/40 shadow-lg shadow-emerald-500/20' : 'border-white/5'}`;
div.innerHTML = `
<div class="w-24 shrink-0">
<p class="text-[8px] font-black text-zinc-500">RANK ${p.rank}${p.isMy ? ' (MY)' : ''}</p>
<p class="text-[10px] font-black uppercase truncate" style="color: #${p.color.toString(16).padStart(6,'0')}">${p.name}</p>
</div>
<input type="range" min="100" max="5000" value="${p.fpi}" class="flex-1" oninput="updateFPI(${p.id}, this.value)">
<div class="w-12 text-right"><span class="fpi-badge">${p.fpi}</span></div>
`;
container.appendChild(div);
});
}
window.updateFPI = (id, val) => {
const p = state.planets.find(x => x.id === id);
p.fpi = parseInt(val);
state.planets.sort((a,b) => b.fpi - a.fpi).forEach((pl, i) => pl.rank = i+1);
const obj = planetObjects.find(o => o.id === id);
if(obj) obj.hud.material.map = generateHUDTexture(p);
buildUI();
};
const toggleBtn = document.getElementById('ui-toggle-btn');
const mainPanel = document.getElementById('main-ui-panel');
const panelContent = document.getElementById('panel-content');
const toggleIcon = document.getElementById('toggle-icon');
toggleBtn.addEventListener('click', () => {
state.uiCollapsed = !state.uiCollapsed;
if (state.uiCollapsed) {
mainPanel.classList.add('panel-collapsed');
panelContent.style.opacity = '0';
toggleIcon.classList.add('rotate-180');
} else {
mainPanel.classList.remove('panel-collapsed');
panelContent.style.opacity = '1';
toggleIcon.classList.remove('rotate-180');
}
});
document.getElementById('global-scale-slider').oninput = (e) => {
state.planetScale = parseFloat(e.target.value);
document.getElementById('scale-val').innerText = `x${state.planetScale.toFixed(1)}`;
};
document.getElementById('orbit-zoom-slider').oninput = (e) => {
state.orbitZoom = parseFloat(e.target.value);
document.getElementById('zoom-val').innerText = `x${state.orbitZoom.toFixed(1)}`;
};
document.getElementById('hud-size-slider').oninput = (e) => {
state.hudSize = parseFloat(e.target.value);
document.getElementById('hud-size-val').innerText = `x${state.hudSize.toFixed(1)}`;
};
document.getElementById('hud-dist-slider').oninput = (e) => {
state.hudDist = parseFloat(e.target.value);
document.getElementById('hud-dist-val').innerText = `x${state.hudDist.toFixed(1)}`;
};
document.getElementById('cam-y-slider').oninput = (e) => {
state.camY = parseInt(e.target.value);
document.getElementById('cam-y-val').innerText = state.camY;
};
document.getElementById('view-rot-slider').oninput = (e) => {
state.viewRot = parseInt(e.target.value);
document.getElementById('view-rot-val').innerText = `${state.viewRot}°`;
};
const boostBtn = document.getElementById('boost-btn');
boostBtn.onmousedown = () => { state.boostFactor = 10.0; boostBtn.classList.add('bg-emerald-500'); };
boostBtn.onmouseup = () => { state.boostFactor = 1.0; boostBtn.classList.remove('bg-emerald-500'); };
window.onload = () => {
state.planets.forEach((p, idx) => {
planetObjects.push(createPlanet(p));
orbitRings.push(createOrbitRing(100, 0xffffff));
});
camera.position.set(0, 2000, 3500);
camera.lookAt(0, 0, 0);
buildUI();
animate();
setInterval(() => {
state.planets.forEach(p => {
p.liveFans += Math.floor(Math.random() * 21) - 10;
const obj = planetObjects.find(o => o.id === p.id);
if(obj) obj.hud.material.map = generateHUDTexture(p);
});
}, 3000);
};
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
</script>
<!-- ===== FANUZU 훅 — 위 원본 스크립트는 그대로 두고, 전역(state · planetObjects · orbitRings ·
galaxyGroup · camera · createPlanet · createOrbitRing)을 바깥에서만 다룬다. ===== -->
<script>
(() => {
const q = new URLSearchParams(location.search);
const bridge = (m) => { if (window.FanuzuBridge) FanuzuBridge.postMessage(JSON.stringify(m)); };
const clamp = (v, a, b) => Math.min(b, Math.max(a, v));
const num = (k, d) => (q.has(k) && isFinite(+q.get(k)) ? +q.get(k) : d);
// 일시정지 — 화면 밖 · 앱 백그라운드에서 rAF 를 멈춘다(animate 는 그대로).
let paused = false, pending = [];
const raf = window.requestAnimationFrame.bind(window);
// 매 프레임 전에 창 높이가 바뀌었는지 본다(WebView 크기 변경 · 모드 전환 직후 resize 누락 대비).
let lastH = 0;
window.requestAnimationFrame = (cb) => {
if (window.innerHeight !== lastH && typeof applyMode === 'function' && ready) applyMode();
return paused ? (pending.push(cb), 0) : raf(cb);
};
window.fanuzuPause = (p) => {
paused = !!p;
if (!paused) { const run = pending; pending = []; run.forEach((cb) => raf(cb)); }
};
// 데이터 무결성 — 원본의 3초 LIVE 난수 흔들기(setInterval 3000)는 실데이터를 꾸며 내므로 막는다.
const si = window.setInterval.bind(window);
window.setInterval = (fn, ms, ...a) => (ms === 3000 ? 0 : si(fn, ms, ...a));
// 모드 — mini(HOME 93pt · 내 행성 HUD 한 장 · 카메라 기본값) / expanded(Figma 343:211127 축척).
// Figma 는 프로토타입 캡처를 원래 픽셀로 넣어 초점 거리가 약 1650pt 다 → camera.zoom 으로만 맞춘다.
const FIGMA_FOCAL = 1650;
let mode = q.get('mode') || 'expanded';
let userZoom = 1;
let mineKey = null;
const keyOf = (o) => (state.planets.find((p) => p.id === o.id) || {}).key;
function applyMode() {
const mini = mode === 'mini';
planetObjects.forEach((o) => { o.hud.visible = !mini || o.isMy; });
// mini 는 93pt 에서 카드가 읽히게 HUD 를 키우고 궤도를 좁힌다(대표 승인 10/5 — 원본 게이지 범위 밖 허용).
state.hudSize = mini ? num('hud', 6.0) : num('hud', 1.85);
state.orbitZoom = mini ? num('orbit', 0.5) : num('orbit', 1.0);
const base = mini ? 2.2 : FIGMA_FOCAL * 2 * Math.tan(35 * Math.PI / 180) / window.innerHeight;
lastH = window.innerHeight;
camera.zoom = num('zoom', base) * userZoom;
camera.updateProjectionMatrix();
}
function focus(o) { planetObjects.forEach((p) => { p.isMy = p === o; }); applyMode(); }
window.setMode = (m) => {
mode = m; userZoom = 1; state.viewRot = 0;
const mine = planetObjects.find((o) => keyOf(o) === mineKey);
if (mine) focus(mine); else applyMode();
};
window.addEventListener('resize', applyMode);
// 데이터 주입 — [{id,name,rank,fpi,live,color,mine}]. FPI 는 원본 척도(1위 = 3200, 최소 100)로
// 크기·궤도·막대를 정하고, 글자는 실제 값을 쓴다(valueOf / toString).
let ready = false, queued = null;
window.setGalaxy = (json) => {
const list = typeof json === 'string' ? JSON.parse(json) : json;
if (!ready) { queued = list; return; }
const top = Math.max(1, ...list.map((p) => p.fpi));
planetObjects.forEach((o) => galaxyGroup.remove(o.group));
orbitRings.forEach((r) => galaxyGroup.remove(r));
planetObjects.length = 0; orbitRings.length = 0;
state.planets = list.map((p, i) => ({
id: i + 1, key: p.id, name: p.name, rank: p.rank, color: p.color, isMy: !!p.mine, liveFans: p.live,
fpi: { valueOf: () => Math.max(100, Math.round(p.fpi / top * 3200)), toString: () => String(p.fpi) },
}));
mineKey = (list.find((p) => p.mine) || {}).id || null;
state.planets.forEach((p) => {
planetObjects.push(createPlanet(p));
orbitRings.push(createOrbitRing(100, 0xffffff));
});
applyMode();
};
// 제스처 — 끌기 = View Rotation(±180°, 원본 lerp 0.05 · 관성 없음), 두 손가락 = 줌(확장),
// 탭 = mini 는 확장 열기 · expanded 는 HUD → 상세 / 행성 → 중심 전환.
const pts = new Map();
let down = null, moved = false, pinch = null;
const dist = () => { const [a, b] = [...pts.values()]; return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY) || 1; };
addEventListener('pointerdown', (e) => {
pts.set(e.pointerId, e);
if (pts.size === 1) { down = { x: e.clientX, y: e.clientY, rot: state.viewRot }; moved = false; }
if (pts.size === 2) { pinch = { d: dist(), z: userZoom }; moved = true; }
});
addEventListener('pointermove', (e) => {
if (!pts.has(e.pointerId)) return;
pts.set(e.pointerId, e);
if (pts.size === 2 && pinch) {
if (mode !== 'mini') { userZoom = clamp(pinch.z * dist() / pinch.d, 0.5, 3); applyMode(); }
return;
}
if (down && pts.size === 1) {
const dx = e.clientX - down.x;
if (Math.abs(dx) > 6 || Math.abs(e.clientY - down.y) > 6) moved = true;
state.viewRot = clamp(down.rot + dx / window.innerWidth * 180, -180, 180);
}
});
const up = (e) => {
pts.delete(e.pointerId);
if (pts.size) return;
if (down && !moved && e.type === 'pointerup') tap(e);
down = null; pinch = null;
};
addEventListener('pointerup', up);
addEventListener('pointercancel', up);
const ray = new THREE.Raycaster();
const v = new THREE.Vector3();
function tap(e) {
if (mode === 'mini') return bridge({ type: 'open' });
ray.setFromCamera(new THREE.Vector2(e.clientX / innerWidth * 2 - 1, -(e.clientY / innerHeight) * 2 + 1), camera);
const hud = ray.intersectObjects(planetObjects.filter((o) => o.hud.visible).map((o) => o.hud), false)[0];
if (hud) return bridge({ type: 'hud', id: keyOf(planetObjects.find((o) => o.hud === hud.object)) });
// 작은 행성도 손끝으로 — 화면 거리 28px 안 가장 가까운 행성.
let best = null, bestD = 28;
planetObjects.forEach((o) => {
v.setFromMatrixPosition(o.group.matrixWorld).project(camera);
const d = Math.hypot((v.x + 1) / 2 * innerWidth - e.clientX, (1 - v.y) / 2 * innerHeight - e.clientY);
if (v.z < 1 && d < bestD) { best = o; bestD = d; }
});
if (best) { focus(best); bridge({ type: 'planet', id: keyOf(best) }); }
}
// 시작 — 글꼴을 먼저 받아 HUD 텍스처가 JetBrains Mono 로 그려지게 한 뒤 원본 onload.
// 앱 안(FanuzuBridge 있음)에서는 원본 표본 5행성 대신 앱 데이터를 기다린다.
const origLoad = window.onload;
window.onload = async () => {
try { await Promise.all(['500', '700', '800'].map((w) => document.fonts.load(`${w} 50px "JetBrains Mono"`))); } catch (_) {}
origLoad();
ready = true;
// ?data=<json> — 브라우저 캡처 · 점검용 주입.
if (q.has('data')) queued = JSON.parse(q.get('data'));
if (queued || window.FanuzuBridge || q.has('wait')) window.setGalaxy(queued || []);
else applyMode();
bridge({ type: 'ready' });
};
})();
</script>
</body>
</html>
+1
View File
@@ -127,6 +127,7 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
// repositories.dart 한 곳이므로 화면이 표본을 직접 만들지 않는다. // repositories.dart 한 곳이므로 화면이 표본을 직접 만들지 않는다.
questRepository: repos.quest, questRepository: repos.quest,
campaignRepository: repos.campaign, campaignRepository: repos.campaign,
insideRepository: repos.inside,
notifications: repos.notifications, notifications: repos.notifications,
eclipse: repos.eclipse, eclipse: repos.eclipse,
), ),
-57
View File
@@ -1,57 +0,0 @@
import 'package:flutter/material.dart';
import '../theme/tokens.dart';
import '../i18n/app_locale.dart';
/// 섹션 제목 + 「더 보기」.
///
/// Figma 홈의 `TODAY'S QUEST` · `AI Daily Briefing` · `HOT ARCHIVE` ·
/// `인기 K-INSIDE` 가 전부 같은 모양이라 하나로 뺐다.
class SectionHeader extends StatelessWidget {
const SectionHeader({
super.key,
required this.title,
this.onMore,
this.moreLabel,
});
final String title;
/// 「더 보기」 자리의 다른 문구. 브리핑은 이 자리가 「공유」다(Figma `266:90529`).
final String? moreLabel;
/// 없으면 「더 보기」를 그리지 않는다.
final VoidCallback? onMore;
@override
Widget build(BuildContext context) {
final text = Theme.of(context).textTheme;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
child: Text(
title,
style: text.titleLarge?.copyWith(fontWeight: FontWeight.w600),
),
),
if (onMore != null)
TextButton(
onPressed: onMore,
style: TextButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2),
minimumSize: const Size(0, AppSizing.touchMinApp),
foregroundColor: AppColors.onCosmicVariant,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(moreLabel ?? context.l10n.commonMore, style: text.bodySmall),
const Icon(Icons.chevron_right, size: 16),
],
),
),
],
);
}
}
@@ -30,7 +30,22 @@ class GalaxyMiniMap extends StatefulWidget {
required this.data, required this.data,
required this.onOpen, required this.onOpen,
this.size = 140, 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 GalaxySnapshot data;
final VoidCallback onOpen; final VoidCallback onOpen;
@@ -38,13 +53,18 @@ class GalaxyMiniMap extends StatefulWidget {
/// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`). /// 정사각 한 변. HOME 은 약 140pt(C-105 · 자리는 C-113 → `HomeHero.miniMapAlignment`).
final double size; final double size;
final bool expanded;
final ValueChanged<PlanetNode>? onOpenPlanet;
@override @override
State<GalaxyMiniMap> createState() => _GalaxyMiniMapState(); State<GalaxyMiniMap> createState() => _GalaxyMiniMapState();
} }
class _GalaxyMiniMapState extends State<GalaxyMiniMap> class _GalaxyMiniMapState extends State<GalaxyMiniMap>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
final _painter = GalaxyMiniMapPainter(); late final _painter = widget.expanded
? GalaxyMiniMapPainter.figmaScale()
: GalaxyMiniMapPainter();
late final Ticker _ticker = createTicker(_tick); late final Ticker _ticker = createTicker(_tick);
Duration _last = Duration.zero; Duration _last = Duration.zero;
@@ -92,8 +112,58 @@ class _GalaxyMiniMapState extends State<GalaxyMiniMap>
super.dispose(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (widget.expanded) return _expanded(context);
final mine = widget.data.mine; final mine = widget.data.mine;
return Semantics( return Semantics(
button: true, button: true,
@@ -153,7 +223,7 @@ const _emerald = Color(0xFF10B981);
/// 행성 색 — 팬덤 `colorKey` 를 행성 색 팔레트([planetColors])로 칠한다. /// 행성 색 — 팬덤 `colorKey` 를 행성 색 팔레트([planetColors])로 칠한다.
/// 키가 없거나 모르는 값이면 FPI 순번으로 프로토타입 팔레트를 돈다. /// 키가 없거나 모르는 값이면 FPI 순번으로 프로토타입 팔레트를 돈다.
Color _planetColor(PlanetNode p, int fpiIndex) => Color galaxyPlanetColor(PlanetNode p, int fpiIndex) =>
planetColorOf(p.colorKey) ?? _palette[fpiIndex % _palette.length]; planetColorOf(p.colorKey) ?? _palette[fpiIndex % _palette.length];
/// 프로토타입 게이지 기본값. 조절 UI 는 없다(C-51). /// 프로토타입 게이지 기본값. 조절 UI 는 없다(C-51).
@@ -201,6 +271,10 @@ class _Body {
double wz = 0; double wz = 0;
double depth = 0; double depth = 0;
double hudW = 0; double hudW = 0;
/// 마지막 프레임의 화면 좌표·반지름·HUD 상자(탭 판정용).
double sx = 0, sy = 0, sr = 0;
Rect hudRect = Rect.zero;
ui.Picture? hud; ui.Picture? hud;
ui.Picture? sphere; ui.Picture? sphere;
Float32List sat = Float32List(0); Float32List sat = Float32List(0);
@@ -273,6 +347,8 @@ ui.Picture _hudPicture(PlanetNode p, Color color, double share) {
text: text, text: text,
style: TextStyle( style: TextStyle(
fontFamily: 'monospace', fontFamily: 'monospace',
// 프로토타입 JetBrains Mono 대신 기기 고정폭(Android monospace · iOS Menlo).
fontFamilyFallback: const ['Menlo', 'Courier', AppFonts.body],
fontSize: size, fontSize: size,
fontWeight: weight, fontWeight: weight,
color: c, color: c,
@@ -343,8 +419,27 @@ ui.Picture _spherePicture(Color c) {
/// 미니맵 장면 상태와 그리기. 상태는 위젯이 [advance]·[settle] 로 움직인다. /// 미니맵 장면 상태와 그리기. 상태는 위젯이 [advance]·[settle] 로 움직인다.
class GalaxyMiniMapPainter extends CustomPainter { class GalaxyMiniMapPainter extends CustomPainter {
GalaxyMiniMapPainter() : this._(ValueNotifier<int>(0)); GalaxyMiniMapPainter() : this._(ValueNotifier<int>(0), null);
GalaxyMiniMapPainter._(this._frame) : super(repaint: _frame);
/// 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; final ValueNotifier<int> _frame;
@@ -367,6 +462,43 @@ class GalaxyMiniMapPainter extends CustomPainter {
double _gx = 0; double _gx = 0;
double _gz = 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; static const maxPlanets = 10;
@@ -512,7 +644,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
(_bodies[p.id] ??= _Body( (_bodies[p.id] ??= _Body(
p.id, p.id,
_bodies.length + 1.0, _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}; final ids = {for (final p in shown) p.id};
_bodies.removeWhere((id, b) { _bodies.removeWhere((id, b) {
@@ -524,6 +656,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
..clear() ..clear()
..addAll(_bodies.values); ..addAll(_bodies.values);
_mine = _order.where((b) => b.node.isMine).firstOrNull; _mine = _order.where((b) => b.node.isMine).firstOrNull;
if (_focus != null && !_bodies.containsValue(_focus)) _focus = null;
_place(0); _place(0);
_frame.value++; _frame.value++;
} }
@@ -559,7 +692,7 @@ class GalaxyMiniMapPainter extends CustomPainter {
void _place(double follow) { void _place(double follow) {
final c = math.cos(_rot); final c = math.cos(_rot);
final s = math.sin(_rot); final s = math.sin(_rot);
if (_mine case final m?) { if (_focus ?? _mine case final m?) {
final a = _angle(m); final a = _angle(m);
final lx = math.cos(a) * m.rad; final lx = math.cos(a) * m.rad;
final lz = math.sin(a) * m.rad; final lz = math.sin(a) * m.rad;
@@ -679,9 +812,10 @@ class GalaxyMiniMapPainter extends CustomPainter {
@override @override
void paint(Canvas canvas, Size size) { void paint(Canvas canvas, Size size) {
final focal = size.height / 2 / _tanHalfFov; final bgFocal = size.height / 2 / _tanHalfFov;
if (size != _bgSize) _buildBackground(size, focal); if (size != _bgSize) _buildBackground(size, bgFocal);
canvas.drawPicture(_bg!); canvas.drawPicture(_bg!);
final focal = (fixedFocal ?? bgFocal) * zoom;
// 궤도 링 — 갤럭시 원점 중심, 두께 = 반지름/100, 흰색 15%. // 궤도 링 — 갤럭시 원점 중심, 두께 = 반지름/100, 흰색 15%.
_project(_gx, 0, _gz, focal, size); _project(_gx, 0, _gz, focal, size);
@@ -747,6 +881,10 @@ class GalaxyMiniMapPainter extends CustomPainter {
// 광륜 + 구체. // 광륜 + 구체.
final rPx = r * focal / d; final rPx = r * focal / d;
b
..sx = px
..sy = py
..sr = rPx;
canvas.save(); canvas.save();
canvas.translate(px, py); canvas.translate(px, py);
canvas.scale(rPx); canvas.scale(rPx);
@@ -778,17 +916,41 @@ class GalaxyMiniMapPainter extends CustomPainter {
final hudHeight = 57.5 * f; final hudHeight = 57.5 * f;
// [fullHudMin] 보다 작으면 프로토타입 카드는 몇 px 라 읽히지 않는다 — 내 행성만 // [fullHudMin] 보다 작으면 프로토타입 카드는 몇 px 라 읽히지 않는다 — 내 행성만
// 간략 카드로 그리고 다른 행성 카드는 뺀다(궤도·행성만). // 간략 카드로 그리고 다른 행성 카드는 뺀다(궤도·행성만).
final full = size.width >= fullHudMin; final full = fixedFocal != null || size.width >= fullHudMin;
for (final b in _order) { for (final b in _order) {
b.hudW = 0; b.hudW = 0;
b.hudRect = Rect.zero;
if (!full && !identical(b, _mine)) continue; if (!full && !identical(b, _mine)) continue;
if (b.depth < b.baseR * _scale * 2) continue; if (b.depth < b.baseR * _scale * 2) continue;
final y = _scale * (b.baseR * _scale + hudHeight / 2 + 2); final y = _scale * (b.baseR * _scale + hudHeight / 2 + 2);
_project(b.wx, y, b.wz, focal, size); _project(b.wx, y, b.wz, focal, size);
if (_sd < 1) continue; if (_sd < 1) continue;
final w = 115 * f * _scale * focal / _sd; 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) { if (full) {
b.hudW = w; b.hudW = w;
b.hudRect = Rect.fromLTWH(_sx - w / 2, _sy - w / 4, w, w / 2);
canvas.save(); canvas.save();
canvas.translate(_sx - w / 2, _sy - w / 4); canvas.translate(_sx - w / 2, _sy - w / 4);
canvas.scale(w / _hudW, w / 2 / _hudH); canvas.scale(w / _hudW, w / 2 / _hudH);
@@ -0,0 +1,206 @@
import 'dart:convert';
import 'dart:io' show Platform;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:webview_flutter/webview_flutter.dart';
import '../galaxy_data.dart';
import 'galaxy_mini_map.dart';
/// HOME GALAXY 미니맵 · 「GALAXY 맵 확장」 — 발주사 프로토타입 v6.2 HTML 을 그대로 띄운다
/// (`assets/galaxy/uzu_hud.html` · 9/22 D-6 · 2026-10-05 대표 지시 「html 을 클론코딩하듯」).
///
/// 한 위젯이 두 모드를 다 맡는다. [expanded] 가 바뀌면 WebView 를 새로 만들지 않고 JS
/// `setMode` 만 부른다 — HOME 은 같은 [GlobalKey] 로 미니맵 자리와 확장 자리 사이를 옮긴다.
///
/// - 데이터: [GalaxySnapshot] 의 FPI 상위 10 + 내 행성 → `setGalaxy(json)`.
/// - 이벤트: HTML 이 `FanuzuBridge.postMessage` 로 보낸다 — `open`(미니맵 탭) · `hud`(HUD 탭 →
/// [onOpenPlanet]) · `planet`(행성 탭, 중심 전환은 HTML 이 이미 했다).
/// - 화면 밖 · 다른 화면에 가림 · 앱 백그라운드면 `fanuzuPause(true)` 로 rAF 를 멈춘다.
/// - 플랫폼 뷰가 없는 곳(위젯 테스트 · 데스크톱)에서는 같은 장면을 옮긴 [GalaxyMiniMap] 으로 대신한다.
class GalaxyWebView extends StatefulWidget {
const GalaxyWebView({
super.key,
required this.data,
required this.expanded,
required this.onOpen,
required this.onOpenPlanet,
this.miniSize = 93,
});
final GalaxySnapshot data;
final bool expanded;
final double miniSize;
final VoidCallback onOpen;
final ValueChanged<PlanetNode> onOpenPlanet;
/// WebView 를 쓸 수 있는가. 테스트에서 끄거나 켤 수 있다.
static bool enabled =
!kIsWeb &&
(Platform.isAndroid || Platform.isIOS) &&
!Platform.environment.containsKey('FLUTTER_TEST');
/// HTML 로 넘기는 행성 목록 — FPI 상위 10 + 순위 밖 내 행성(미니맵 painter 와 같은 규칙).
@visibleForTesting
static List<Map<String, Object>> payload(GalaxySnapshot data) {
final byFpi = [...data.planets]..sort((a, b) => b.fpi.compareTo(a.fpi));
final shown = byFpi.take(GalaxyMiniMapPainter.maxPlanets).toList();
final mine = data.mine;
if (mine != null && !shown.contains(mine)) shown.add(mine);
return [
for (final (i, p) in shown.indexed)
{
'id': p.id,
'name': p.name,
'rank': p.rank,
'fpi': p.fpi,
'live': p.online,
'color': galaxyPlanetColor(p, i).toARGB32() & 0xFFFFFF,
'mine': p.isMine,
},
];
}
@override
State<GalaxyWebView> createState() => _GalaxyWebViewState();
}
class _GalaxyWebViewState extends State<GalaxyWebView>
with WidgetsBindingObserver {
WebViewController? _web;
bool _ready = false;
bool? _paused;
bool _foreground = true;
ScrollPosition? _scroll;
@override
void initState() {
super.initState();
if (!GalaxyWebView.enabled) return;
WidgetsBinding.instance.addObserver(this);
_web = WebViewController()
..setJavaScriptMode(JavaScriptMode.unrestricted)
..setBackgroundColor(Colors.transparent)
..addJavaScriptChannel('FanuzuBridge', onMessageReceived: _onMessage)
..loadFlutterAsset('assets/galaxy/uzu_hud.html');
}
void _js(String code) {
if (_ready) _web?.runJavaScript(code);
}
void _push() {
_js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})');
_js('setGalaxy(${jsonEncode(GalaxyWebView.payload(widget.data))})');
}
void _onMessage(JavaScriptMessage m) {
final msg = jsonDecode(m.message) as Map<String, dynamic>;
switch (msg['type']) {
case 'ready':
_ready = true;
_push();
_paused = null;
_syncPause();
case 'open':
widget.onOpen();
case 'hud':
final p = widget.data.planets
.where((p) => p.id == msg['id'])
.firstOrNull;
if (p != null) widget.onOpenPlanet(p);
}
}
@override
void didUpdateWidget(GalaxyWebView old) {
super.didUpdateWidget(old);
if (_web == null) return;
if (!identical(old.data, widget.data)) {
_push();
} else if (old.expanded != widget.expanded) {
_js('setMode(${jsonEncode(widget.expanded ? 'expanded' : 'mini')})');
}
WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause());
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
if (_web == null) return;
final scroll = Scrollable.maybeOf(context)?.position;
if (!identical(scroll, _scroll)) {
_scroll?.removeListener(_syncPause);
_scroll = scroll?..addListener(_syncPause);
}
// 다른 화면이 위에 올라오거나(ModalRoute) 탭이 바뀌면(TickerMode) 다시 판단한다.
ModalRoute.of(context);
TickerMode.valuesOf(context).enabled;
WidgetsBinding.instance.addPostFrameCallback((_) => _syncPause());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
_foreground = state == AppLifecycleState.resumed;
_syncPause();
}
bool get _visible {
if (!mounted || !_foreground || !TickerMode.valuesOf(context).enabled) return false;
if (ModalRoute.of(context)?.isCurrent == false) return false;
final box = context.findRenderObject();
if (box is! RenderBox || !box.attached || !box.hasSize) return true;
final top = box.localToGlobal(Offset.zero).dy;
return top + box.size.height > 0 && top < MediaQuery.sizeOf(context).height;
}
void _syncPause() {
if (!_ready || !mounted) return;
final pause = !_visible;
if (pause == _paused) return;
_paused = pause;
_js('fanuzuPause($pause)');
}
@override
void dispose() {
_scroll?.removeListener(_syncPause);
if (_web != null) WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
Widget build(BuildContext context) {
final web = _web;
if (web == null) {
return widget.expanded
? GalaxyMiniMap.expanded(
data: widget.data,
onOpenPlanet: widget.onOpenPlanet,
)
: GalaxyMiniMap(
size: widget.miniSize,
data: widget.data,
onOpen: widget.onOpen,
);
}
final view = WebViewWidget(
controller: web,
// 확장 맵은 끌기 · 핀치를 모두 장면이 받는다(설명 프레임 2008:14486). 미니맵은 가로 끌기
// (회전)와 탭만 — 세로 끌기는 HOME 목록 스크롤로 남긴다.
gestureRecognizers: widget.expanded
? {Factory<OneSequenceGestureRecognizer>(EagerGestureRecognizer.new)}
: {
Factory<OneSequenceGestureRecognizer>(
HorizontalDragGestureRecognizer.new,
),
Factory<OneSequenceGestureRecognizer>(TapGestureRecognizer.new),
},
);
return widget.expanded
? view
: SizedBox.square(dimension: widget.miniSize, child: view);
}
}
+119 -59
View File
@@ -9,6 +9,8 @@
library; library;
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../quest/quest_data.dart' show QuestType;
import '../inside/inside_data.dart' show InsideQuestion, SampleInsideRepository;
/// 팬덤 행성의 성장 상태. 정책 근거는 정책 설계서 §P-08. /// 팬덤 행성의 성장 상태. 정책 근거는 정책 설계서 §P-08.
class PlanetGrowth { class PlanetGrowth {
@@ -85,6 +87,45 @@ class PlanetGrowth {
? appL10n.growthPromotionPending ? appL10n.growthPromotionPending
: appL10n.growthCheckConditions; : 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 => double? get popRatio =>
currentPop == null || requiredPop == null || requiredPop! <= 0 currentPop == null || requiredPop == null || requiredPop! <= 0
? null ? null
@@ -129,10 +170,19 @@ class QuestSummary {
this.progressLabel, this.progressLabel,
this.id = '', this.id = '',
this.endsAt, this.endsAt,
this.type,
this.category,
}); });
final String title; final String title;
/// 보조 줄 — 퀘스트 카테고리(사본 「오늘의 출석체크」 · 「커뮤니티」). 퀘스트 목록의
/// 카테고리 이름과 같은 원천(`QuestType.label`).
final String? category;
/// `quests.type`. 줄 왼쪽 아이콘을 고르는 데만 쓴다(없으면 제목으로 고른다).
final QuestType? type;
/// 보상 POP — 서버 지급액과 같은 원천. 못 읽으면 null. /// 보상 POP — 서버 지급액과 같은 원천. 못 읽으면 null.
final int? rewardPop; final int? rewardPop;
final QuestState state; final QuestState state;
@@ -224,6 +274,7 @@ class BriefingItem {
/// HOT ARCHIVE — 진행 중인 캠페인 한 건. /// HOT ARCHIVE — 진행 중인 캠페인 한 건.
class CampaignHighlight { class CampaignHighlight {
const CampaignHighlight({ const CampaignHighlight({
this.id = '',
required this.authorName, required this.authorName,
required this.achievedPercent, required this.achievedPercent,
required this.title, required this.title,
@@ -233,6 +284,8 @@ class CampaignHighlight {
required this.likes, required this.likes,
}); });
/// `campaigns/{id}`. 「캠페인 보기」가 상세로 바로 간다(명세 §2.114-6). 비면 목록으로.
final String id;
final String authorName; final String authorName;
final int achievedPercent; final int achievedPercent;
final String title; final String title;
@@ -242,25 +295,22 @@ class CampaignHighlight {
final int likes; final int likes;
} }
/// 인기 K-INSIDE 한 건. /// 팬덤행성 인큐베이터(성운 추천) 한 장 — 명세 §2.114-4 「성운명 · 가입 인원수 · 성운
class InsideDigest { /// 이미지 · 기여율% · 기여율 그래프 · 함께 성장하기」(`2097:16403`).
const InsideDigest({ class NebulaPick {
required this.authorName, const NebulaPick({
required this.timeAgo, required this.id,
required this.bountyPop, required this.name,
required this.title, required this.members,
required this.body, required this.percent,
required this.answers,
}); });
final String authorName; final String id;
final String timeAgo; final String name;
final int members;
/// 현상금. 정책 설계서 §P-11 — 채택 기한은 아직 미확정이다. /// 기여율 — 누적 POP ÷ 성운 전환 목표 POP(`config/policy.nebulaMinPop`, B-10). 0~100.
final int bountyPop; final int percent;
final String title;
final String body;
final int answers;
} }
/// 홈 한 화면이 필요로 하는 것 전부. /// 홈 한 화면이 필요로 하는 것 전부.
@@ -272,9 +322,10 @@ class HomeSnapshot {
required this.growth, required this.growth,
required this.quests, required this.quests,
required this.briefings, required this.briefings,
required this.campaign, this.campaigns = const [],
required this.insides, required this.insides,
this.items = const [], this.items = const [],
this.nebulas = const [],
}); });
/// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고 /// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고
@@ -293,8 +344,13 @@ class HomeSnapshot {
final PlanetGrowth growth; final PlanetGrowth growth;
final List<QuestSummary> quests; final List<QuestSummary> quests;
final List<BriefingItem> briefings; 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) /// 헤더 지갑 · ZAP 「아이템」 시트의 상품. Admin Catalog(B-84 · B-85)
/// `heroGiftProducts` 에서 온다. /// `heroGiftProducts` 에서 온다.
@@ -339,6 +395,11 @@ abstract interface class HomeRepository {
Future<HomeSnapshot> load(); Future<HomeSnapshot> load();
Stream<PlanetGrowth> watchGrowth(String fandomId); 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 와, 대표 팬덤이 /// 성운(Stage0) 생성 — `fn-nebula-create`(원장 B-08 · B-09). 만든 팬덤 ID 와, 대표 팬덤이
/// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다. /// 바뀌었는지(C-15 30일 대기 중이면 false — 성운만 생기고 대표는 그대로)를 돌려준다.
Future<({String fandomId, bool primaryChanged})> createNebula(String name); Future<({String fandomId, bool primaryChanged})> createNebula(String name);
@@ -384,6 +445,9 @@ class SampleHomeRepository implements HomeRepository {
@override @override
Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty(); Stream<PlanetGrowth> watchGrowth(String fandomId) => const Stream.empty();
@override
Stream<int> watchPopBalance() => const Stream.empty();
@override @override
Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets; Future<NebulaTargets> nebulaTargets() async => defaultNebulaTargets;
@@ -413,30 +477,51 @@ class SampleHomeRepository implements HomeRepository {
currentPop: 6270, currentPop: 6270,
requiredPop: 10000, requiredPop: 10000,
), ),
quests: [ // 사본 「홈」 `2097:16428` 표본 문구.
quests: const [
QuestSummary( QuestSummary(
title: '오늘의 출석체크', title: '출석체크',
category: '오늘의 출석체크',
type: QuestType.attend,
// 서버 `config/policy.attendPop` 기본값과 맞춘다. Figma 의 50 이 아니다. // 서버 `config/policy.attendPop` 기본값과 맞춘다. Figma 의 50 이 아니다.
rewardPop: 10, rewardPop: 10,
state: QuestState.claimable, state: QuestState.claimable,
), ),
QuestSummary( QuestSummary(
title: '만보기', title: '좋아요',
rewardPop: 150, category: '커뮤니티',
rewardPop: 5,
state: QuestState.inProgress,
),
QuestSummary(
title: '댓글',
category: '커뮤니티',
rewardPop: 5,
state: QuestState.inProgress, state: QuestState.inProgress,
progressLabel: '0/1,500 걸음',
), ),
], ],
briefings: _sampleBriefings, briefings: _sampleBriefings,
campaign: CampaignHighlight( campaigns: const [
authorName: '다이브짱', CampaignHighlight(
achievedPercent: 92, authorName: '다이브짱',
title: '강남역 초대형 LED 광고', achievedPercent: 92,
description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.', title: '강남역 초대형 LED 광고',
supporters: 48, description: 'BTS 데뷔 기념 팬 연합의 초대형 프로젝트입니다. 도심을 BTS의 색으로 물들여주세요.',
comments: 24, supporters: 48,
likes: 82, 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 값 아님). // 사본 `아이템 구입` 표본(Admin 값 아님).
items: const [ items: const [
ItemProduct( ItemProduct(
@@ -464,32 +549,7 @@ class SampleHomeRepository implements HomeRepository {
owned: 10, owned: 10,
), ),
], ],
insides: [ insides: SampleInsideRepository.samples.take(3).toList(),
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,
),
],
); );
} }
@@ -4,6 +4,7 @@ import '../../core/data/backend.dart';
import '../../core/data/invite_code.dart'; import '../../core/data/invite_code.dart';
import '../../core/data/server_clock.dart'; import '../../core/data/server_clock.dart';
import 'home_data.dart'; import 'home_data.dart';
import '../inside/inside_repository_firestore.dart' show insideQuestionFrom;
import '../quest/quest_data.dart' show QuestType; import '../quest/quest_data.dart' show QuestType;
import '../quest/quest_repository_firestore.dart' show questToday; import '../quest/quest_repository_firestore.dart' show questToday;
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
@@ -30,6 +31,14 @@ class FirestoreHomeRepository implements HomeRepository {
.asyncMap((s) => _growth(s.data() ?? const {}, fandomId)); .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 @override
Future<({String fandomId, bool primaryChanged})> createNebula( Future<({String fandomId, bool primaryChanged})> createNebula(
String name, String name,
@@ -82,10 +91,13 @@ class FirestoreHomeRepository implements HomeRepository {
// 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다. // 홈 한 화면에 필요한 것을 병렬로 띄운다. 순차로 기다리면 왕복이 그대로 쌓인다.
final walletF = db.doc('users/$uid/wallet/main').get(); final walletF = db.doc('users/$uid/wallet/main').get();
final fandomF = fandomId.isEmpty ? null : db.doc('fandoms/$fandomId').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 final questsF = db
.collection('quests') .collection('quests')
.where('active', isEqualTo: true) .where('active', isEqualTo: true)
.limit(5) .limit(30)
.get(); .get();
// 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에 // 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에
// 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`. // 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`.
@@ -124,12 +136,21 @@ class FirestoreHomeRepository implements HomeRepository {
.orderBy('rank') .orderBy('rank')
.limit(6) .limit(6)
.get(); .get();
// 차단한 개설자의 캠페인은 건너뛴다 — 다섯 건을 읽어 첫 비차단 캠페인을 쓴다. // HOT ARCHIVE 캐러셀 — 명세 §2.114-6 「최대 5개」. 차단한 개설자의 캠페인은 건너뛰므로
// 열 건을 읽어 다섯을 채운다. ponytail: 정렬은 모금액 순 하나(명세의 「달성 임박 > 기여 급증 >
// 신규」 3단 정렬은 기여 급증 집계가 없어 아직 못 한다).
final campaignF = db final campaignF = db
.collection('campaigns') .collection('campaigns')
.where('status', isEqualTo: 'funding') .where('status', isEqualTo: 'funding')
.orderBy('raisedPop', descending: true) .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(); .get();
final blocksF = db.collection('users/$uid/blocks').get(); final blocksF = db.collection('users/$uid/blocks').get();
// Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가 // Hero Gift Catalog(원장 B-85) — 판매 중인 것만. 소속 행성이 없는 사용자는 시트가
@@ -163,17 +184,24 @@ class FirestoreHomeRepository implements HomeRepository {
return to is Timestamp && to.toDate().isAfter(serverNow); 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 progress = (await progressF).docs;
final briefings = (await briefingsF).docs; final briefings = (await briefingsF).docs;
final blocked = {for (final d in (await blocksF).docs) d.id}; final blocked = {for (final d in (await blocksF).docs) d.id};
final campaigns = (await campaignF).docs final campaigns = (await campaignF).docs
.where((d) => !blocked.contains(d.data()['creatorUid'])) .where((d) => !blocked.contains(d.data()['creatorUid']))
.toList(); .toList();
final insides = popularInsides([ final insideDocs = {
for (final doc in (await insidesF).docs) doc.data(), 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 progressById = {for (final doc in progress) doc.id: doc.data()};
final policy = (await policyF).data() ?? const <String, dynamic>{}; 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), _quest(doc.id, doc.data(), progressById[doc.id], policy),
], ],
briefings: [for (final doc in briefings) ?_briefing(doc.data())], briefings: [for (final doc in briefings) ?_briefing(doc.data())],
campaign: campaigns.isEmpty ? null : _campaign(campaigns.first.data()), campaigns: [for (final d in campaigns.take(5)) _campaign(d.id, d.data())],
insides: [for (final data in insides) _inside(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())], 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, Map<String, dynamic> policy,
) { ) {
// 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다. // 퀘스트 목록 · 상세와 같은 판정(`questToday`) — 오늘(KST) 행만 센다.
final (:count, :done) = final (:count, :done) = questToday(
questToday(QuestType.parse(quest.strOr('type')), quest, progress, policy); QuestType.parse(quest.strOr('type')),
quest,
progress,
policy,
);
final started = count > 0; final started = count > 0;
return QuestSummary( return QuestSummary(
title: quest.strOr('title'), 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), rewardPop: QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy),
state: done state: done
? QuestState.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`)을 먼저 읽는다. // 규칙의 스키마 이름(`targetPop` · `creatorName` · `summary`)을 먼저 읽는다.
final goal = data.intOr('targetPop', data.intOr('goalPop')); final goal = data.intOr('targetPop', data.intOr('goalPop'));
final raised = data.intOr('raisedPop'); final raised = data.intOr('raisedPop');
return CampaignHighlight( return CampaignHighlight(
id: id,
authorName: publicName(data['creatorName'] ?? data['authorName']), authorName: publicName(data['creatorName'] ?? data['authorName']),
achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(), achievedPercent: goal == 0 ? 0 : ((raised / goal) * 100).round(),
title: data.strOr('title'), title: data.strOr('title'),
@@ -339,27 +379,6 @@ class FirestoreHomeRepository implements HomeRepository {
likes: data.intOr('likeCount'), 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`) 먼저, 같은 묶음 안에서는 현상금 큰 순. /// 인기 K-INSIDE 3건 — 미해결(`status == open`) 먼저, 같은 묶음 안에서는 현상금 큰 순.
@@ -380,3 +399,43 @@ String kstDayKey([DateTime? at]) => (at ?? DateTime.now().toUtc())
.add(const Duration(hours: 9)) .add(const Duration(hours: 9))
.toIso8601String() .toIso8601String()
.substring(0, 10); .substring(0, 10);
/// HOME TODAY'S QUEST 의 관리자 퀘스트 — `authorUid` 가 없는 것만, `createdAt` 최신순
/// (설명 프레임 `2097:16397` 「관리자 퀘스트 - 최신순으로 최대 3개 노출」).
List<T> adminQuestsLatest<T>(
Iterable<T> docs,
Map<String, dynamic> Function(T) data,
) {
DateTime at(T d) => switch (data(d)['createdAt']) {
Timestamp t => t.toDate(),
DateTime t => t,
_ => DateTime.fromMillisecondsSinceEpoch(0),
};
return [
for (final d in docs)
if ((data(d)['authorUid'] ?? '').toString().isEmpty) d,
]..sort((a, b) => at(b).compareTo(at(a)));
}
/// 인큐베이터 카드 — 기여율(누적 POP ÷ 성운 전환 목표 POP) 높은 순 최대 6개.
List<NebulaPick> nebulaPicks(
Iterable<(String, Map<String, dynamic>)> docs,
int goalPop,
) {
final picks = [
for (final (id, d) in docs)
NebulaPick(
id: id,
name: d.strOr('name', id),
members: d.intOr('memberCount'),
percent: goalPop <= 0
? 0
: ((d['growthPop'] is num ? d['growthPop'] as num : 0) /
goalPop *
100)
.clamp(0, 100)
.floor(),
),
]..sort((a, b) => b.percent.compareTo(a.percent));
return picks.take(6).toList();
}
+429 -278
View File
@@ -2,8 +2,8 @@
export '../../core/format.dart' show formatPop; export '../../core/format.dart' show formatPop;
import '../planet/planet_history_screen.dart'; import '../planet/planet_history_screen.dart';
import '../galaxy/widgets/galaxy_mini_map.dart'; import '../galaxy/widgets/galaxy_web_view.dart';
import '../galaxy/galaxy_screen.dart'; import '../galaxy/galaxy_search_screen.dart';
import '../galaxy/galaxy_data.dart'; import '../galaxy/galaxy_data.dart';
import '../galaxy/zap_controller.dart'; import '../galaxy/zap_controller.dart';
import '../planet/hero_participation_sheet.dart'; import '../planet/hero_participation_sheet.dart';
@@ -15,9 +15,9 @@ import 'package:go_router/go_router.dart';
import '../../core/format.dart'; import '../../core/format.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart'; import '../../core/theme/tokens.dart';
import '../../core/ui/app_icons.dart';
import '../../core/data/planet_visual_state.dart'; import '../../core/data/planet_visual_state.dart';
import '../../core/ui/live_stream.dart'; import '../../core/ui/live_stream.dart';
import '../../core/ui/section_header.dart';
import '../eclipse/eclipse_data.dart'; import '../eclipse/eclipse_data.dart';
import '../eclipse/widgets/eclipse_banner.dart'; import '../eclipse/widgets/eclipse_banner.dart';
import '../eclipse/widgets/live_proof.dart'; import '../eclipse/widgets/live_proof.dart';
@@ -25,10 +25,13 @@ import 'home_data.dart';
import 'nebula_create_screen.dart'; import 'nebula_create_screen.dart';
import 'planet_create_screen.dart'; import 'planet_create_screen.dart';
import 'widgets/briefing_list.dart'; import 'widgets/briefing_list.dart';
import 'widgets/campaign_card.dart';
import 'widgets/home_hero.dart'; import 'widgets/home_hero.dart';
import 'widgets/item_sheet.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 'widgets/live_ticker.dart';
import '../campaign/campaign_data.dart' show CampaignRepository; import '../campaign/campaign_data.dart' show CampaignRepository;
import '../campaign/campaign_create_screen.dart'; import '../campaign/campaign_create_screen.dart';
@@ -63,9 +66,13 @@ class HomeScreen extends StatefulWidget {
required this.eclipse, required this.eclipse,
this.notifications = const SampleNotificationRepository(), this.notifications = const SampleNotificationRepository(),
this.campaignRepository, this.campaignRepository,
this.insideRepository,
}); });
final HomeRepository repository; final HomeRepository repository;
/// 인기 K-INSIDE 카드가 여는 상세의 저장소.
final InsideRepository? insideRepository;
final PlanetWatch watchPlanet; final PlanetWatch watchPlanet;
final PlanetRepositoryFactory? planetRepository; final PlanetRepositoryFactory? planetRepository;
final GalaxyRepository? galaxyRepository; final GalaxyRepository? galaxyRepository;
@@ -93,6 +100,13 @@ class _HomeScreenState extends State<HomeScreen> {
Future<GalaxySnapshot>? _galaxy; Future<GalaxySnapshot>? _galaxy;
ZapController? _zap; 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(() {}); void _onZapChanged() => setState(() {});
@override @override
@@ -120,12 +134,15 @@ class _HomeScreenState extends State<HomeScreen> {
/// 성운·행성 생성 화면은 성공하면 팬덤 ID 를 들고 닫힌다. 그때만 다시 읽는다. /// 성운·행성 생성 화면은 성공하면 팬덤 ID 를 들고 닫힌다. 그때만 다시 읽는다.
Future<void> _createThenReload(Widget screen) async { Future<void> _createThenReload(Widget screen) async {
final created = await Navigator.of(context, rootNavigator: true).push( final created = await Navigator.of(
MaterialPageRoute<String>(builder: (_) => screen), context,
); rootNavigator: true,
).push(MaterialPageRoute<String>(builder: (_) => screen));
if (created == null || !mounted) return; if (created == null || !mounted) return;
if (widget.galaxyRepository case final repository?) { if (widget.galaxyRepository case final repository?) {
setState(() { _galaxy = repository.load(); }); setState(() {
_galaxy = repository.load();
});
} }
await _refresh(); await _refresh();
} }
@@ -137,7 +154,15 @@ class _HomeScreenState extends State<HomeScreen> {
} }
@override @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 를 쓰면 배경이 헤더 아래에서 // 시안은 헤더가 우주 배경 위에 얹힌다. AppBar 를 쓰면 배경이 헤더 아래에서
// 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다. // 끊기므로 본문 안에 헤더 행을 그리고 Scaffold 는 배경만 준다.
body: FutureBuilder<HomeSnapshot>( body: FutureBuilder<HomeSnapshot>(
@@ -157,6 +182,11 @@ class _HomeScreenState extends State<HomeScreen> {
data: snap.data!, data: snap.data!,
galaxy: _galaxy, galaxy: _galaxy,
zap: _zap, zap: _zap,
mapOpen: _mapOpen,
mapKey: _mapKey,
zapKey: _zapKey,
onMap: _setMap,
insideRepository: widget.insideRepository,
repository: widget.repository, repository: widget.repository,
watchPlanet: widget.watchPlanet, watchPlanet: widget.watchPlanet,
planetRepository: widget.planetRepository, planetRepository: widget.planetRepository,
@@ -249,6 +279,11 @@ class HomeHeader extends StatelessWidget {
_PopBalance(value: pop), _PopBalance(value: pop),
IconButton( IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42), 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, padding: EdgeInsets.zero,
icon: const Image( icon: const Image(
image: AssetImage('assets/figma/icon-wallet.png'), image: AssetImage('assets/figma/icon-wallet.png'),
@@ -256,23 +291,25 @@ class HomeHeader extends StatelessWidget {
height: 42, height: 42,
), ),
// 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트. // 사본 `252:159367` — 홈 주 프레임에 열린 「아이템 구입」 시트.
onPressed: () => onPressed: () => showItemSheet(
showItemSheet( context,
context, items: items,
items: items, purchase: true,
purchase: true, fandomId: fandomId,
fandomId: fandomId, ),
),
tooltip: context.l10n.homeItemsTooltip, tooltip: context.l10n.homeItemsTooltip,
), ),
IconButton( IconButton(
constraints: const BoxConstraints.tightFor(width: 42, height: 42), constraints: const BoxConstraints.tightFor(width: 42, height: 42),
padding: EdgeInsets.zero, // 사본 `icon` 42 칸 — 테마 최소 크기(48)로 늘어나 POP 알약이 왼쪽으로 밀리지 않게.
iconSize: 24, style: IconButton.styleFrom(
icon: const Icon( minimumSize: const Size.square(42),
Icons.notifications_none, tapTargetSize: MaterialTapTargetSize.shrinkWrap,
color: AppColors.onCosmic,
), ),
padding: EdgeInsets.zero,
// 사본 `icon` 42 칸 안 lucide/bell 20.
iconSize: 20,
icon: const ImageIcon(AppIcons.bell, color: AppColors.onCosmic),
// 알림 — 화면 대조 원장 F0 · `229:11265`. // 알림 — 화면 대조 원장 F0 · `229:11265`.
onPressed: () => Navigator.of(context, rootNavigator: true).push( onPressed: () => Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -292,7 +329,12 @@ class _HomeBody extends StatelessWidget {
required this.watchPlanet, required this.watchPlanet,
required this.galaxy, required this.galaxy,
required this.zap, required this.zap,
required this.mapOpen,
required this.mapKey,
required this.zapKey,
required this.onMap,
required this.planetRepository, required this.planetRepository,
required this.insideRepository,
required this.data, required this.data,
required this.repository, required this.repository,
required this.onRefresh, required this.onRefresh,
@@ -311,6 +353,15 @@ class _HomeBody extends StatelessWidget {
final HomeSnapshot data; final HomeSnapshot data;
final Future<GalaxySnapshot>? galaxy; final Future<GalaxySnapshot>? galaxy;
final ZapController? zap; 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 HomeRepository repository;
final PlanetWatch watchPlanet; final PlanetWatch watchPlanet;
final PlanetRepositoryFactory? planetRepository; final PlanetRepositoryFactory? planetRepository;
@@ -321,277 +372,394 @@ class _HomeBody extends StatelessWidget {
final QuestRepository questRepository; final QuestRepository questRepository;
final CampaignRepository? campaignRepository; 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 으로 감싸면 첫 프레임에 /// 섹션은 ListView 의 직계 자식으로 둔다. 하나의 Column 으로 감싸면 첫 프레임에
/// 전부 빌드되어 지연 렌더링을 잃고, 테스트의 scrollUntilVisible 도 「이미 보인다」고 /// 전부 빌드되어 지연 렌더링을 잃고, 테스트의 scrollUntilVisible 도 「이미 보인다」고
/// 오판한다. 좌우 여백은 자식마다 준다. /// 오판한다. 좌우 여백은 자식마다 준다.
static const _h = EdgeInsets.symmetric(horizontal: AppSpace.s4); static const _h = EdgeInsets.symmetric(horizontal: AppSpace.s4);
/// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16. /// 사본 `Frame 4241` — 구획 사이 36, 구획 머리와 본문 사이 16. 머리 줄은 터치 영역 때문에
static const _sectionGap = 36.0; /// 사본(20)보다 위아래 [HomeSectionHeader.slack] 씩 크므로 그만큼 뺀다.
static const _headerGap = AppSpace.s4; static const _sectionGap = 36.0 - HomeSectionHeader.slack;
static const _headerGap = AppSpace.s4 - HomeSectionHeader.slack;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final top = MediaQuery.paddingOf(context).top; final top = MediaQuery.paddingOf(context).top;
return RefreshIndicator( return Stack(
onRefresh: onRefresh, children: [
child: ListView( RefreshIndicator(
padding: const EdgeInsets.only(bottom: AppSpace.s7), onRefresh: onRefresh,
children: [ child: ListView(
// 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고 // 사본 `Frame 4241` 아래 패딩 24.
// 헤더 행에 상단 여백을 직접 준다. padding: const EdgeInsets.only(bottom: AppSpace.s5),
Stack(
children: [ children: [
LiveStreamBuilder<PlanetGrowth>( // 히어로는 상태 표시줄 아래까지 배경이 이어져야 하므로 SafeArea 를 쓰지 않고
key: ValueKey((repository, data.fandomId)), // 헤더 행에 상단 여백을 직접 준다.
create: () => repository.watchGrowth(data.fandomId), Stack(
builder: (context, growth) => FutureBuilder<GalaxySnapshot>( children: [
future: galaxy, LiveStreamBuilder<PlanetGrowth>(
builder: (context, galaxyState) => HomeHero( key: ValueKey((repository, data.fandomId)),
zap: zap, create: () => repository.watchGrowth(data.fandomId),
zapData: galaxyState.data, builder: (context, growth) => FutureBuilder<GalaxySnapshot>(
miniMap: future: galaxy,
galaxyState.data == null || // 미니맵과 확장 맵은 같은 GalaxyWebView(같은 키) — 자리만 옮기고 모드는 JS 로 바꾼다.
planetRepository == null || // 그래서 교차 페이드(AnimatedSwitcher)를 쓰지 않는다(전환 중 키가 둘이 된다).
zap == null builder: (context, galaxyState) => KeyedSubtree(
? null child:
: GalaxyMiniMap( mapOpen && _map(context, galaxyState.data) != null
size: HomeHero.miniMapSize, ? HomeGalaxyHero(
data: galaxyState.data!, key: const ValueKey('home-galaxy-hero'),
onOpen: () => map: _map(context, galaxyState.data)!,
Navigator.of(context, rootNavigator: true).push( ticker: _LiveTickerSlot(
MaterialPageRoute<void>( eclipse: eclipse,
builder: (_) => GalaxyScreen( fandomId: data.fandomId,
repository: zap!.repository, fallback: data.liveTicker,
planetRepository: planetRepository!, ),
watchPlanet: watchPlanet, onClose: () => onMap(false),
eclipse: eclipse, 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,
), ),
), ),
), onCreateNebula: () => onCreate(
// 라이브 띠 — `fandoms/{id}/liveEvents` 를 구독한다. 이벤트가 아직 없으면 NebulaCreateScreen(
// 스냅샷의 `liveTicker` 문자열로 물러난다(폴백). repository: repository,
ticker: _LiveTickerSlot( myPop: data.popBalance,
eclipse: eclipse, ),
fandomId: data.fandomId, ),
fallback: data.liveTicker, // 행성 가입은 GALAXY 에서 한다(사본 시트 「행성 가입」).
onJoin: () =>
GoRouter.maybeOf(context)?.go('/galaxy'),
zapButtonKey: zapKey,
onHeroParticipate: _heroParticipate(context),
),
),
), ),
growth: growth ?? data.growth, ),
fandomId: data.fandomId, Positioned(
unjoined: data.fandomId.isEmpty, // 확장 맵(`343:211127`)은 라이브 띠가 헤더 위(y48)라 헤더가 띠 높이만큼 내려간다.
watchPlanet: watchPlanet, top: top + (mapOpen ? HomeGalaxyHero.headerShift : 0),
onOpenPlanet: left: 0,
planetRepository == null || data.fandomId.isEmpty right: 0,
? null // 지갑 문서를 구독한다 — 한 번 읽은 값으로 두면 다른 화면의 변동(현상금 예치 ·
: () => Navigator.of(context, rootNavigator: true).push( // 타임어택 보상)이 칩에 남지 않는다(2026-10-05 실기기).
MaterialPageRoute<void>( child: LiveStreamBuilder<int>(
builder: (_) => PlanetDetailScreen( key: ValueKey(('pop', repository)),
repository: planetRepository!(data.fandomId), create: repository.watchPopBalance,
watchPlanet: watchPlanet, builder: (context, wallet) => HomeHeader(
eclipse: eclipse, pop: wallet ?? zap?.balance ?? data.popBalance,
), notifications: notifications,
), items: data.items,
),
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, 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( const SizedBox(height: _sectionGap),
top: top,
left: 0, // 팬덤행성 인큐베이터(성운 추천 `2097:16403`). 성운이 없으면 구획을 두지 않는다 —
right: 0, // 사본에 빈 상태가 없다.
child: HomeHeader( if (data.nebulas.isNotEmpty) ...[
pop: zap?.balance ?? data.popBalance, Padding(
notifications: notifications, padding: _h,
items: data.items, child: HomeSectionHeader(title: context.l10n.homeIncubator),
fandomId: data.fandomId, ),
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),
), ),
), ),
], const SizedBox(height: _headerGap),
), if (data.campaigns.isNotEmpty)
// 이클립스 인앱 경보 — 별첨 1 ① 예외 처리 「푸시 미수신 단말 — 인앱 CampaignCarousel(
// 워닝 배너 병행」. 활성 이클립스가 없으면 자리도 차지하지 않는다. campaigns: data.campaigns,
if (data.fandomId.isNotEmpty) // 명세 「캠페인 보기 버튼 - 해당 캠페인 상세 화면 이동」.
EclipseBanner( onOpen: (c) => c.id.isEmpty
repository: eclipse, ? _openCampaigns(context, campaignRepository)
fandomId: data.fandomId, : Navigator.of(context, rootNavigator: true).push(
// 히어로 아래 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(
MaterialPageRoute<void>( MaterialPageRoute<void>(
builder: (_) => QuestDetailScreen( builder: (_) => CampaignDetailScreen(
questId: data.quests[i].id, campaignId: c.id,
repository: questRepository, 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(
padding: _h, padding: _h,
child: SectionHeader( child: HomeSectionHeader(
title: context.l10n.homePopularInside, title: context.l10n.homePopularInside,
onMore: () => _notYet(context, context.l10n.homeWhereInsideList), // 명세 「더보기 버튼 - 인사이드 화면 이동」.
), onMore: () => GoRouter.maybeOf(context)?.go(appTabs[1].path),
), ),
const SizedBox(height: _headerGap),
for (final i in data.insides) ...[
Padding(
padding: _h,
child: InsideCard(
digest: i,
onTap: () =>
_notYet(context, context.l10n.homeWhereInsideDetail),
), ),
), const SizedBox(height: _headerGap),
const SizedBox(height: AppSpace.s3), 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 _scrolled = false;
bool _onScroll(ScrollNotification n) { bool _onScroll(ScrollNotification n) {
// 퀘스트 카드의 가로 스크롤은 무시한다 — 바깥 세로 목록만 본다. // 바깥 세로 목록만 본다.
if (n.depth != 0 || n.metrics.axis != Axis.vertical) return false; if (n.depth != 0 || n.metrics.axis != Axis.vertical) return false;
final scrolled = n.metrics.pixels > 0; final scrolled = n.metrics.pixels > 0;
if (scrolled != _scrolled) setState(() => _scrolled = scrolled); 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. /// 홈의 HOT ARCHIVE 에서 캠페인 목록으로 — 화면 대조 원장 F1 → F5.
/// ///
/// 홈 카드에는 캠페인 ID 가 없다([CampaignHighlight] 는 표시용 요약이다). /// 「더 보기」(HOT ARCHIVE 화면 이동)와 ID 없는 카드가 쓴다.
/// 상세로 바로 보내려면 ID 를 홈 데이터에 넣어야 하는데, 그러면 홈이 캠페인
/// 스키마를 알게 된다. 목록을 거쳐 들어간다.
void _openCampaigns(BuildContext context, CampaignRepository? repository) { void _openCampaigns(BuildContext context, CampaignRepository? repository) {
Navigator.of(context, rootNavigator: true).push( Navigator.of(context, rootNavigator: true).push(
MaterialPageRoute<void>( MaterialPageRoute<void>(
@@ -5,7 +5,8 @@ import '../../../core/theme/tokens.dart';
import '../home_data.dart'; import '../home_data.dart';
import '../../../core/i18n/app_locale.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 건이면 해당* /// **구획을 News 와 SNS 로 나눈다** — 정책 원장 B-47 *News/SNS 각각 0 건이면 해당*
/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만 /// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만
@@ -27,7 +28,6 @@ class BriefingList extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
// 구획 순서는 enum 선언 순서다(News 다음 SNS). 수신 순서로 정하면 날마다 바뀐다. // 구획 순서는 enum 선언 순서다(News 다음 SNS). 수신 순서로 정하면 날마다 바뀐다.
final sections = [ final sections = [
for (final section in BriefingSection.values) for (final section in BriefingSection.values)
@@ -38,10 +38,14 @@ class BriefingList extends StatelessWidget {
), ),
].where((g) => g.rows.isNotEmpty).toList(); ].where((g) => g.rows.isNotEmpty).toList();
// 구획 이름(News · SNS)은 사본에 없지만 B-47 의 구획 단위 숨김을 화면에서 가르는 표지라
// 남긴다 — 「더 보기」와 같은 12/600 #A3A2A0 으로 작게.
return Container( return Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.fromLTRB(
horizontal: AppSpace.s4, AppSpace.s4,
vertical: AppSpace.s2, AppSpace.s5,
AppSpace.s4,
AppSpace.s5,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer, color: AppColors.cosmicSurfaceContainer,
@@ -51,22 +55,19 @@ class BriefingList extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
for (final (groupIndex, group) in sections.indexed) ...[ for (final (groupIndex, group) in sections.indexed) ...[
if (groupIndex > 0) const Divider(height: 1), if (groupIndex > 0) const SizedBox(height: AppSpace.s5),
Padding( Text(
padding: const EdgeInsets.only( group.section.label,
top: AppSpace.s3, style: const TextStyle(
bottom: AppSpace.s1, fontFamily: AppFonts.body,
), fontSize: 12,
child: Text( fontWeight: FontWeight.w600,
group.section.label, height: 1,
style: t.labelSmall?.copyWith( color: AppColors.onCosmicVariant,
color: AppColors.cosmicAccent,
fontWeight: FontWeight.w600,
),
), ),
), ),
for (final (i, item) in group.rows.indexed) ...[ for (final item in group.rows) ...[
if (i > 0) const Divider(height: 1), const SizedBox(height: AppSpace.s5),
_Row(item: item, onOpen: () => _open(context, item)), _Row(item: item, onOpen: () => _open(context, item)),
], ],
], ],
@@ -89,10 +90,12 @@ class BriefingList extends StatelessWidget {
if (!opened && context.mounted) { if (!opened && context.mounted) {
ScaffoldMessenger.of(context) ScaffoldMessenger.of(context)
..hideCurrentSnackBar() ..hideCurrentSnackBar()
..showSnackBar(SnackBar( ..showSnackBar(
content: Text(context.l10n.briefingOpenFailed), SnackBar(
behavior: SnackBarBehavior.floating, content: Text(context.l10n.briefingOpenFailed),
)); behavior: SnackBarBehavior.floating,
),
);
} }
} }
} }
@@ -105,27 +108,39 @@ class _Row extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return InkWell( return InkWell(
onTap: onOpen, onTap: onOpen,
child: Padding( child: Row(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s3), children: [
child: Row( SizedBox(
crossAxisAlignment: CrossAxisAlignment.start, width: 10,
children: [ child: Text(
SizedBox( '${item.rank}',
width: 20, style: const TextStyle(
child: Text( fontFamily: AppFonts.body,
'${item.rank}.', fontSize: 16,
style: t.bodySmall?.copyWith( fontWeight: FontWeight.w500,
color: AppColors.cosmicAccent, height: 1.4,
fontWeight: FontWeight.w600, color: AppColors.onCosmic,
),
), ),
), ),
Expanded(child: Text(item.headline, style: t.bodySmall)), ),
], const SizedBox(width: 28),
), Expanded(
child: Text(
item.headline,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmicVariant,
),
),
),
],
), ),
); );
} }
@@ -1,129 +0,0 @@
import 'package:flutter/material.dart';
import '../../../core/theme/tokens.dart';
import '../home_data.dart';
import '../../../core/i18n/app_locale.dart';
/// HOT ARCHIVE — 진행 중인 캠페인 한 건.
///
/// Figma 「다이브짱 / 92% 달성 / 강남역 초대형 LED 광고 / 48 · 24 · 82」.
/// 정책 설계서 §P-09 — 실패 시 100% 자동 환불이 이 기능의 검수 기준이다.
class CampaignCard extends StatelessWidget {
const CampaignCard({super.key, required this.campaign, this.onTap});
final CampaignHighlight campaign;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Material(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.lg),
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 시안 HOT ARCHIVE 카드(252:159447) — 위에 이미지 342×228.
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.sm),
child: AspectRatio(
aspectRatio: 342 / 228,
child: Image.asset('assets/figma/campaign-sample.png', fit: BoxFit.cover),
),
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
Text(campaign.authorName, style: t.bodySmall),
const Spacer(),
Container(
padding: const EdgeInsets.symmetric(
horizontal: AppSpace.s2,
vertical: 2,
),
decoration: BoxDecoration(
color: AppColors.cosmicAccent.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(AppRadius.full),
),
child: Text(
context.l10n.campaignAchieved('${campaign.achievedPercent}'),
style: t.labelSmall?.copyWith(
color: AppColors.cosmicAccent,
),
),
),
],
),
const SizedBox(height: AppSpace.s3),
Text(campaign.title, style: t.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(
campaign.description,
style: t.bodySmall,
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: AppSpace.s3),
ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.full),
child: LinearProgressIndicator(
value: (campaign.achievedPercent / 100).clamp(0.0, 1.0),
minHeight: 6,
backgroundColor: AppColors.cosmicSurfaceRaised,
valueColor: const AlwaysStoppedAnimation(
AppColors.cosmicAccent,
),
),
),
const SizedBox(height: AppSpace.s3),
Row(
children: [
_Stat(icon: Icons.people_outline, value: campaign.supporters),
const SizedBox(width: AppSpace.s4),
_Stat(
icon: Icons.chat_bubble_outline,
value: campaign.comments,
),
const SizedBox(width: AppSpace.s4),
_Stat(icon: Icons.favorite_border, value: campaign.likes),
const Spacer(),
Text(
context.l10n.campaignView,
style: t.bodySmall?.copyWith(color: AppColors.cosmicAccent),
),
const Icon(
Icons.chevron_right,
size: 16,
color: AppColors.cosmicAccent,
),
],
),
],
),
),
),
);
}
}
class _Stat extends StatelessWidget {
const _Stat({required this.icon, required this.value});
final IconData icon;
final int value;
@override
Widget build(BuildContext context) => Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 14, color: AppColors.onCosmicVariant),
const SizedBox(width: AppSpace.s1),
Text('$value', style: Theme.of(context).textTheme.labelSmall),
],
);
}
+286 -118
View File
@@ -47,8 +47,12 @@ class HomeHero extends StatefulWidget {
this.unjoined = false, this.unjoined = false,
this.onItem, this.onItem,
this.miniMap, this.miniMap,
this.zapButtonKey,
}); });
/// 화면 하단에 떠 있는 ZAP 버튼([HomeZap])의 키 — 기여 연출이 이 자리에서 행성으로 날아간다.
final GlobalKey? zapButtonKey;
final VoidCallback? onOpenHistory; final VoidCallback? onOpenHistory;
/// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다. /// 성장 카드의 「BTS ›」 — 사본 흐름대로 행성 상세(F3)로 간다.
@@ -96,8 +100,9 @@ class HomeHero extends StatefulWidget {
/// 사본 기준 배경 높이(상태 표시줄 48 포함). /// 사본 기준 배경 높이(상태 표시줄 48 포함).
static const double designHeight = 680; static const double designHeight = 680;
/// 배경 아래로 ZAP 원이 걸쳐 내려오는 몫. 다음 구획의 위 여백(24)과 같다. /// 히어로 아래 여백 — 사본 `Frame 4241` 위 패딩 24 에서 첫 구획 머리의 터치 여유 12 를 뺀 값.
static const double zapOverhang = 24; /// (ZAP 은 화면 하단에 떠 있어 더는 히어로에 걸치지 않는다.)
static const double bottomGap = 12;
@override @override
State<HomeHero> createState() => _HomeHeroState(); State<HomeHero> createState() => _HomeHeroState();
@@ -106,17 +111,18 @@ class HomeHero extends StatefulWidget {
class _HomeHeroState extends State<HomeHero> { class _HomeHeroState extends State<HomeHero> {
final _fxKey = GlobalKey(); final _fxKey = GlobalKey();
final _planetKey = GlobalKey(); final _planetKey = GlobalKey();
final _buttonKey = GlobalKey();
PlanetGrowth get growth => widget.growth; PlanetGrowth get growth => widget.growth;
String get fandomId => widget.fandomId; String get fandomId => widget.fandomId;
PlanetWatch get watchPlanet => widget.watchPlanet; PlanetWatch get watchPlanet => widget.watchPlanet;
VoidCallback? get onHeroParticipate => widget.onHeroParticipate; VoidCallback? get onHeroParticipate => widget.onHeroParticipate;
Offset _center(GlobalKey key) { /// [key] 의 가운데를 히어로 좌표로. ZAP 버튼은 히어로 밖(화면 하단 고정)이라 전역 좌표로 잰다.
final box = key.currentContext?.findRenderObject(); Offset _center(GlobalKey? key) {
final box = key?.currentContext?.findRenderObject();
final root = _fxKey.currentContext?.findRenderObject(); final root = _fxKey.currentContext?.findRenderObject();
return box is RenderBox && box.hasSize && root is RenderBox return box is RenderBox && box.hasSize && root is RenderBox && root.hasSize
? box.localToGlobal(box.size.center(Offset.zero), ancestor: root) ? box.localToGlobal(box.size.center(Offset.zero)) -
root.localToGlobal(Offset.zero)
: Offset.zero; : Offset.zero;
} }
@@ -133,7 +139,7 @@ class _HomeHeroState extends State<HomeHero> {
return PlanetFxScope( return PlanetFxScope(
child: SizedBox( child: SizedBox(
height: background + HomeHero.zapOverhang, height: background + HomeHero.bottomGap,
child: Stack( child: Stack(
key: _fxKey, key: _fxKey,
children: [ children: [
@@ -202,7 +208,6 @@ class _HomeHeroState extends State<HomeHero> {
glow: AppColors.neonViolet, glow: AppColors.neonViolet,
), ),
), ),
], ],
), ),
), ),
@@ -218,7 +223,6 @@ class _HomeHeroState extends State<HomeHero> {
_GrowthOverlayCard( _GrowthOverlayCard(
growth: growth, growth: growth,
onOpen: widget.onOpenPlanet, onOpen: widget.onOpenPlanet,
zapClearance: hasZap,
), ),
], ],
], ],
@@ -226,67 +230,11 @@ class _HomeHeroState extends State<HomeHero> {
), ),
if (widget.miniMap case final mini?) if (widget.miniMap case final mini?)
Positioned( Positioned(top: top + HomeHero.miniMapTop, right: 0, child: mini),
top: top + HomeHero.miniMapTop,
right: 0,
child: mini,
),
if (widget.ticker case final ticker?) if (widget.ticker case final ticker?)
Positioned(top: top + 102, left: 0, right: 0, child: 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?) if (widget.zap case final zap?)
Positioned.fill( Positioned.fill(
child: IgnorePointer( child: IgnorePointer(
@@ -294,7 +242,7 @@ class _HomeHeroState extends State<HomeHero> {
touchSerial: zap.touchSerial, touchSerial: zap.touchSerial,
pending: zap.pending > 0 || zap.sending, pending: zap.pending > 0 || zap.sending,
result: zap.result, result: zap.result,
source: _center(_buttonKey), source: _center(widget.zapButtonKey),
target: _center(_planetKey), target: _center(_planetKey),
radius: radius:
(_planetKey.currentContext?.findRenderObject() (_planetKey.currentContext?.findRenderObject()
@@ -344,7 +292,8 @@ class _Contribution extends StatelessWidget {
alignment: Alignment.center, alignment: Alignment.center,
decoration: const BoxDecoration( decoration: const BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: AppColors.cosmicSurfaceRaised, // 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
color: Color(0xFF9BF8A8),
boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)], boxShadow: [BoxShadow(color: Color(0xB3000000), blurRadius: 4)],
), ),
child: Text( child: Text(
@@ -353,7 +302,7 @@ class _Contribution extends StatelessWidget {
fontFamily: AppFonts.body, fontFamily: AppFonts.body,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: AppColors.onCosmic, color: Colors.black,
), ),
), ),
), ),
@@ -391,7 +340,9 @@ class _Contribution extends StatelessWidget {
: growth.isNebula : growth.isNebula
? context.l10n.heroNebulaPrompt ? context.l10n.heroNebulaPrompt
: growth.hasRecentContribution : growth.hasRecentContribution
? context.l10n.heroRecentContribution(formatPop(growth.contributedPop)) ? context.l10n.heroRecentContribution(
formatPop(growth.contributedPop),
)
: context.l10n.heroGrowingTogether, : context.l10n.heroGrowingTogether,
style: const TextStyle( style: const TextStyle(
fontFamily: AppFonts.body, fontFamily: AppFonts.body,
@@ -406,26 +357,19 @@ class _Contribution extends StatelessWidget {
); );
} }
/// 성장 카드 — 사본 기본 상태의 「BTS › / 행성 성장률 67% / 진행 바 / 6,270 / 10,000」 /// 성장 카드 — Figma 「홈」 `Frame 1984081073`(343×151 · `#040304` 40% · r12 · 패딩 12/16 ·
/// (`Frame 1984081073` 343 폭 · `#040304` 40% · r12 · 패딩 12/16 · 간격 12 · 유리 흐림 4). /// 간격 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 진행률 /// 성장률 · 수치는 두 조건 중 병목 축([PlanetGrowth.bottleneck])이다. 말풍선을 누르면 두
/// 이고(전체 충족률 아님) 승급은 POP·sFPI 동시 충족이다(정책 설계서 §P-08 · UX·FX v1.5 /// 조건 상세([GrowthConditions])를 연다.
/// §2.1). 말풍선 꼬리는 바 채움 끝을 가리킨다(사본 `Group 563` 가운데 = 채움 끝 x238).
class _GrowthOverlayCard extends StatelessWidget { class _GrowthOverlayCard extends StatelessWidget {
const _GrowthOverlayCard({ const _GrowthOverlayCard({required this.growth, this.onOpen});
required this.growth,
this.onOpen,
this.zapClearance = false,
});
final PlanetGrowth growth; final PlanetGrowth growth;
final VoidCallback? onOpen; final VoidCallback? onOpen;
/// 원형 ZAP(52)이 카드 아래 변에 걸친다. 둘째 조건 줄이 가려지지 않게 아래 여백을
/// ZAP 윗변(아래 변 − 28) 위로 올린다. ZAP 좌표는 사본 그대로 둔다.
final bool zapClearance;
static const _white14 = TextStyle( static const _white14 = TextStyle(
fontFamily: AppFonts.body, fontFamily: AppFonts.body,
fontSize: 14, fontSize: 14,
@@ -434,26 +378,58 @@ class _GrowthOverlayCard extends StatelessWidget {
color: AppColors.onCosmic, 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 @override
Widget build(BuildContext context) { 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( return ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md), borderRadius: BorderRadius.circular(AppRadius.md),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2), filter: ImageFilter.blur(sigmaX: 2, sigmaY: 2),
child: Container( child: Container(
width: double.infinity, width: double.infinity,
padding: EdgeInsets.fromLTRB( padding: const EdgeInsets.symmetric(
AppSpace.s4, horizontal: AppSpace.s4,
AppSpace.s3, vertical: AppSpace.s3,
AppSpace.s4,
zapClearance ? 32 : AppSpace.s3,
), ),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0x66040304), color: const Color(0x66040304),
borderRadius: BorderRadius.circular(AppRadius.md), borderRadius: BorderRadius.circular(AppRadius.md),
border: Border.all(color: AppFx.glassStroke),
), ),
child: LayoutBuilder( child: LayoutBuilder(
builder: (context, box) => Stack( builder: (context, box) => Stack(
@@ -485,44 +461,57 @@ class _GrowthOverlayCard extends StatelessWidget {
const ImageIcon( const ImageIcon(
AppIcons.chevronRight, AppIcons.chevronRight,
size: 24, size: 24,
color: AppColors.onCosmicVariant, color: Color(0xFF777777),
), ),
], ],
), ),
), ),
Row( // 성장률 · 바 · 수치를 누르면 두 조건 상세 — 말풍선이 없는 성운 카드의 입구.
children: [ GestureDetector(
Text(context.l10n.heroPopProgress, style: _white14), behavior: HitTestBehavior.opaque,
const SizedBox(width: 4), onTap: () => _details(context),
Text( child: Column(
growth.growthPercent == null crossAxisAlignment: CrossAxisAlignment.start,
? '—' children: [
: '${growth.growthPercent}%', Text(
style: _white14, 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),
const SizedBox(height: AppSpace.s3), _Bar(ratio: ratio ?? 0),
GrowthConditions( const SizedBox(height: AppSpace.s3),
growth: growth, Text(
style: _white14.copyWith(height: 16 / 14), low == null
? '— / —'
: '${_value(low.current)} / ${_value(low.required)}',
style: _white14,
),
],
),
), ),
], ],
), ),
// 말풍선 — 꼬리 끝이 바 위 9.5 에 닿는다(사본 581.5+40 → 바 631). // 말풍선 — 사본 y566.5(카드 안 31.5) · 꼬리 끝이 바 위에 닿는다. 가운데는 채움 끝
// (사본 x238 = 채움 206)을 따르되 왼쪽 성장률 글자와 겹치지 않게 민다.
// 성운 카드(`344:213809`)에는 말풍선이 없다. // 성운 카드(`344:213809`)에는 말풍선이 없다.
if (!growth.isNebula) if (!growth.isNebula)
Positioned( Positioned(
top: 45 + 24 + AppSpace.s3 - 49.5, top: 31.5,
left: (box.maxWidth * (ratio ?? 0.5)).clamp( left: (box.maxWidth * (ratio ?? 0)).clamp(
84.0, math.min(_bubbleMinCenter, box.maxWidth - 84),
box.maxWidth - 84, box.maxWidth - 84,
), ),
child: FractionalTranslation( child: FractionalTranslation(
translation: const Offset(-0.5, 0), 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(), onTap: () => Navigator.of(context).pop(),
child: SizedBox( child: SizedBox(
height: 47, 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] …님의 아이템 사용으로 버프가 발동되었습니다!」. /// 상단 라이브 띠 — Figma 「[LIVE] …님의 아이템 사용으로 버프가 발동되었습니다!」.
/// ///
/// 사본 `Frame 1984081132` — 헤더 바로 아래 전폭 검정 띠(높이 22 · 패딩 4/0), 가운데 /// 사본 `Frame 1984081132`(「홈」 `2097:16428`) — 헤더 바로 아래 전폭 띠(높이 22 · 패딩 4/0 ·
/// 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의 표면이다. /// 바탕 없음), 가운데 한 줄(42dot Medium 12 · 행간 14 흰색). 실시간 소셜 프루프(견적서 4주차)의
/// 표면이다.
class LiveTicker extends StatelessWidget { class LiveTicker extends StatelessWidget {
const LiveTicker({super.key, required this.text}); const LiveTicker({super.key, required this.text});
@@ -14,7 +15,6 @@ class LiveTicker extends StatelessWidget {
@override @override
Widget build(BuildContext context) => Container( Widget build(BuildContext context) => Container(
height: 22, height: 22,
color: Colors.black,
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4), padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
alignment: Alignment.center, alignment: Alignment.center,
child: Text( child: Text(
+109 -202
View File
@@ -1,219 +1,130 @@
import 'dart:math' as math;
import 'package:flutter/material.dart'; 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/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 '../../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 { 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 QuestSummary quest;
final VoidCallback? onTap; final VoidCallback? onTap;
/// 아래 변 `#242424` 1 획.
final bool divider;
static const height = 90.0;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final t = Theme.of(context).textTheme; final done = quest.state == QuestState.done;
return Material( return Material(
color: AppColors.cosmicSurfaceContainer, color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md), shape: divider
? const Border(bottom: BorderSide(color: Color(0xFF242424)))
: null,
child: InkWell( child: InkWell(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(AppRadius.md), child: ConstrainedBox(
child: Padding( // 글자 확대 때는 늘어난다 — 고정하면 잘린다.
padding: const EdgeInsets.all(AppSpace.s4), constraints: const BoxConstraints(minHeight: height),
child: Row( child: Padding(
children: [ padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
Expanded( child: Row(
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, QuestIconBox(image: questImageFor(quest.type, quest.title)),
children: [ const SizedBox(width: 19),
Text(quest.title, style: t.bodyMedium), Expanded(
const SizedBox(height: AppSpace.s1), child: Column(
Text(_subtitle(quest), style: t.bodySmall), mainAxisSize: MainAxisSize.min,
], crossAxisAlignment: CrossAxisAlignment.start,
), children: [
), Text(
const SizedBox(width: AppSpace.s3), quest.title,
Text( maxLines: 1,
_reward(quest), overflow: TextOverflow.ellipsis,
style: t.titleMedium?.copyWith(color: AppColors.cosmicAccent), style: const TextStyle(
), fontFamily: AppFonts.body,
const SizedBox(width: AppSpace.s3), fontSize: 18,
_Action(state: quest.state, onTap: onTap), fontWeight: FontWeight.w600,
], height: 20 / 18,
), color: AppColors.onCosmic,
),
),
);
}
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,
),
), ),
const SizedBox(height: 6), ),
// 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06). const SizedBox(height: 4),
if (quest.endsAt case final endsAt? // 타임어택은 받기 전까지 남은 시간을 센다(FR-QST-06).
when quest.state != QuestState.done) if (quest.endsAt case final endsAt? when !done)
TimeAttackCountdown( TimeAttackCountdown(
endsAt: endsAt, endsAt: endsAt,
builder: (context, left) => _subtitleText( builder: (context, left) => _sub(
left == null left == null
? context.l10n.questTimeAttackClosed ? context.l10n.questTimeAttackClosed
: context.l10n.questTimeAttackLeft( : context.l10n.questTimeAttackLeft(
formatLeft(left), formatLeft(left),
), ),
), ),
) )
else else
_subtitleText(QuestRow._subtitle(quest)), _sub(quest.category ?? ''),
], ],
),
),
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(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, text,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontFamily: AppFonts.body, fontFamily: AppFonts.body,
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
height: 1.4, height: 20 / 14,
color: AppColors.onCosmicVariant, color: AppColors.onCosmicVariant,
), ),
); );
/// 운영값을 못 읽었으면 숫자를 지어내지 않는다.
String _reward(QuestSummary quest) =>
quest.rewardPop == null ? 'POP' : '+${formatPop(quest.rewardPop!)}';
+3 -2
View File
@@ -220,7 +220,8 @@ String insideErrorMessage(Object error) => switch (error) {
class SampleInsideRepository implements InsideRepository { class SampleInsideRepository implements InsideRepository {
const SampleInsideRepository(); const SampleInsideRepository();
static List<InsideQuestion> get _all { /// 표본 질문(HOME 「인기 K-INSIDE」 표본도 같은 것을 쓴다).
static List<InsideQuestion> get samples {
final now = DateTime.now(); final now = DateTime.now();
return [ return [
InsideQuestion( InsideQuestion(
@@ -286,7 +287,7 @@ class SampleInsideRepository implements InsideRepository {
} }
List<InsideQuestion> _sorted(InsideSort sort) { List<InsideQuestion> _sorted(InsideSort sort) {
final list = _all; final list = samples;
switch (sort) { switch (sort) {
case InsideSort.latest: case InsideSort.latest:
list.sort((a, b) => b.createdAt.compareTo(a.createdAt)); 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)]; return [for (final doc in snap.docs) _question(doc)];
} }
InsideQuestion _question(QueryDocumentSnapshot<Map<String, dynamic>> doc) { InsideQuestion _question(QueryDocumentSnapshot<Map<String, dynamic>> doc) =>
final data = doc.data(); insideQuestionFrom(doc.id, doc.data(), _backend.uid);
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')),
);
}
/// 팬덤과 상관없이 내가 쓴 질문 전부(`authorUid` 등호 하나라 복합 색인이 필요 없다). /// 팬덤과 상관없이 내가 쓴 질문 전부(`authorUid` 등호 하나라 복합 색인이 필요 없다).
// ponytail: 정렬은 앱에서 한다. 한 사람의 질문이 수백 건을 넘으면 orderBy + 복합 색인 + 페이지로. // ponytail: 정렬은 앱에서 한다. 한 사람의 질문이 수백 건을 넘으면 orderBy + 복합 색인 + 페이지로.
@@ -183,3 +164,26 @@ class FirestoreInsideRepository implements InsideRepository {
{'questionId': questionId, 'answerId': answerId}, {'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)에 이미지 필드가 없어 유형과 제목으로 고른다. /// ponytail: `quests` 문서(설계서 §2.8)에 이미지 필드가 없어 유형과 제목으로 고른다.
/// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다. /// 서버가 이미지 URL 을 주게 되면 그 값으로 바꾼다. 맞는 것이 없으면 기본 아이콘이다.
String questImage(QuestCard card) { String questImage(QuestCard card) => questImageFor(card.type, card.title);
switch (card.type) {
/// [questImage] 를 카드 없이 — HOME 「TODAY'S QUEST」 줄은 요약([QuestSummary])만 들고 있다.
String questImageFor(QuestType? type, String title) {
switch (type) {
case QuestType.attend: case QuestType.attend:
return 'assets/figma/quest-img-stamp.png'; return 'assets/figma/quest-img-stamp.png';
case QuestType.steps: case QuestType.steps:
@@ -20,7 +23,6 @@ String questImage(QuestCard card) {
return 'assets/figma/quest-img-truck.png'; return 'assets/figma/quest-img-truck.png';
default: default:
} }
final title = card.title;
if (title.contains('출석')) return 'assets/figma/quest-img-stamp.png'; 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-heart.png';
if (title.contains('댓글')) return 'assets/figma/quest-img-bubble.png'; if (title.contains('댓글')) return 'assets/figma/quest-img-bubble.png';
@@ -33,9 +35,10 @@ String questImage(QuestCard card) {
/// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지. /// 목록의 아이콘 칸 — #2e2e2e r16 50 칸 안에 42 이미지.
class QuestIconBox extends StatelessWidget { 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -47,7 +50,7 @@ class QuestIconBox extends StatelessWidget {
color: const Color(0xFF2E2E2E), color: const Color(0xFF2E2E2E),
borderRadius: BorderRadius.circular(16), 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( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
QuestIconBox(card: card), QuestIconBox(image: questImage(card)),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
@@ -335,7 +338,7 @@ class QuestAdminRow extends StatelessWidget {
: null, : null,
child: Row( child: Row(
children: [ children: [
QuestIconBox(card: card), QuestIconBox(image: questImage(card)),
const SizedBox(width: 19), const SizedBox(width: 19),
Expanded( Expanded(
child: Column( child: Column(
+8 -1
View File
@@ -788,5 +788,12 @@
"popReasonQuestPoolDeposit": "Quest reward deposit", "popReasonQuestPoolDeposit": "Quest reward deposit",
"popReasonQuestPoolReturn": "Quest reward returned", "popReasonQuestPoolReturn": "Quest reward returned",
"campaignCommentNeedNickname": "Please set a nickname first", "campaignCommentNeedNickname": "Please set a nickname first",
"campaignCommentSetNickname": "Set nickname" "campaignCommentSetNickname": "Set nickname",
"heroNextEvolution": "Almost at the next evolution!",
"heroGrowthRate": "Planet growth {percent}%",
"heroNebulaGrowthRate": "Nebula growth {percent}%",
"homeIncubator": "Fandom Planet Incubator",
"homeGrowTogether": "Grow together",
"homeArtistNews": "Artist News",
"growthConditionsTitle": "Growth conditions"
} }
+8 -1
View File
@@ -788,5 +788,12 @@
"popReasonQuestPoolDeposit": "Depósito de recompensa", "popReasonQuestPoolDeposit": "Depósito de recompensa",
"popReasonQuestPoolReturn": "Devolución de recompensa", "popReasonQuestPoolReturn": "Devolución de recompensa",
"campaignCommentNeedNickname": "Primero elige un apodo", "campaignCommentNeedNickname": "Primero elige un apodo",
"campaignCommentSetNickname": "Elegir apodo" "campaignCommentSetNickname": "Elegir apodo",
"heroNextEvolution": "¡Falta poco para la próxima evolución!",
"heroGrowthRate": "Crecimiento del planeta {percent}%",
"heroNebulaGrowthRate": "Crecimiento de la nebulosa {percent}%",
"homeIncubator": "Incubadora de planetas fandom",
"homeGrowTogether": "Crecer juntos",
"homeArtistNews": "Noticias del artista",
"growthConditionsTitle": "Condiciones de crecimiento"
} }
+8 -1
View File
@@ -788,5 +788,12 @@
"popReasonQuestPoolDeposit": "クエスト報酬の預け入れ", "popReasonQuestPoolDeposit": "クエスト報酬の預け入れ",
"popReasonQuestPoolReturn": "クエスト報酬の返還", "popReasonQuestPoolReturn": "クエスト報酬の返還",
"campaignCommentNeedNickname": "先にニックネームを決めてください", "campaignCommentNeedNickname": "先にニックネームを決めてください",
"campaignCommentSetNickname": "ニックネームを決める" "campaignCommentSetNickname": "ニックネームを決める",
"heroNextEvolution": "次の進化まであと少し!",
"heroGrowthRate": "惑星成長率 {percent}%",
"heroNebulaGrowthRate": "星雲成長率 {percent}%",
"homeIncubator": "ファンダム惑星インキュベーター",
"homeGrowTogether": "一緒に成長する",
"homeArtistNews": "アーティストニュース",
"growthConditionsTitle": "成長条件"
} }
+22 -1
View File
@@ -1628,5 +1628,26 @@
"popReasonQuestPoolDeposit": "퀘스트 보상 예치", "popReasonQuestPoolDeposit": "퀘스트 보상 예치",
"popReasonQuestPoolReturn": "퀘스트 보상 반환", "popReasonQuestPoolReturn": "퀘스트 보상 반환",
"campaignCommentNeedNickname": "닉네임을 먼저 정해 주세요", "campaignCommentNeedNickname": "닉네임을 먼저 정해 주세요",
"campaignCommentSetNickname": "닉네임 정하기" "campaignCommentSetNickname": "닉네임 정하기",
"heroNextEvolution": "다음 진화까지 얼마 안남았어요!",
"heroGrowthRate": "행성 성장률 {percent}%",
"@heroGrowthRate": {
"placeholders": {
"percent": {
"type": "String"
}
}
},
"heroNebulaGrowthRate": "성운 성장률 {percent}%",
"@heroNebulaGrowthRate": {
"placeholders": {
"percent": {
"type": "String"
}
}
},
"homeIncubator": "팬덤행성 인큐베이터",
"homeGrowTogether": "함께 성장하기",
"homeArtistNews": "아티스트 소식",
"growthConditionsTitle": "성장 조건"
} }
+42
View File
@@ -4849,6 +4849,48 @@ abstract class AppLocalizations {
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'닉네임 정하기'** /// **'닉네임 정하기'**
String get campaignCommentSetNickname; String get campaignCommentSetNickname;
/// No description provided for @heroNextEvolution.
///
/// In ko, this message translates to:
/// **'다음 진화까지 얼마 안남았어요!'**
String get heroNextEvolution;
/// No description provided for @heroGrowthRate.
///
/// In ko, this message translates to:
/// **'행성 성장률 {percent}%'**
String heroGrowthRate(String percent);
/// No description provided for @heroNebulaGrowthRate.
///
/// In ko, this message translates to:
/// **'성운 성장률 {percent}%'**
String heroNebulaGrowthRate(String percent);
/// No description provided for @homeIncubator.
///
/// In ko, this message translates to:
/// **'팬덤행성 인큐베이터'**
String get homeIncubator;
/// No description provided for @homeGrowTogether.
///
/// In ko, this message translates to:
/// **'함께 성장하기'**
String get homeGrowTogether;
/// No description provided for @homeArtistNews.
///
/// In ko, this message translates to:
/// **'아티스트 소식'**
String get homeArtistNews;
/// No description provided for @growthConditionsTitle.
///
/// In ko, this message translates to:
/// **'성장 조건'**
String get growthConditionsTitle;
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate
+25
View File
@@ -2749,4 +2749,29 @@ class AppLocalizationsEn extends AppLocalizations {
@override @override
String get campaignCommentSetNickname => 'Set nickname'; String get campaignCommentSetNickname => 'Set nickname';
@override
String get heroNextEvolution => 'Almost at the next evolution!';
@override
String heroGrowthRate(String percent) {
return 'Planet growth $percent%';
}
@override
String heroNebulaGrowthRate(String percent) {
return 'Nebula growth $percent%';
}
@override
String get homeIncubator => 'Fandom Planet Incubator';
@override
String get homeGrowTogether => 'Grow together';
@override
String get homeArtistNews => 'Artist News';
@override
String get growthConditionsTitle => 'Growth conditions';
} }
+25
View File
@@ -2761,4 +2761,29 @@ class AppLocalizationsEs extends AppLocalizations {
@override @override
String get campaignCommentSetNickname => 'Elegir apodo'; String get campaignCommentSetNickname => 'Elegir apodo';
@override
String get heroNextEvolution => '¡Falta poco para la próxima evolución!';
@override
String heroGrowthRate(String percent) {
return 'Crecimiento del planeta $percent%';
}
@override
String heroNebulaGrowthRate(String percent) {
return 'Crecimiento de la nebulosa $percent%';
}
@override
String get homeIncubator => 'Incubadora de planetas fandom';
@override
String get homeGrowTogether => 'Crecer juntos';
@override
String get homeArtistNews => 'Noticias del artista';
@override
String get growthConditionsTitle => 'Condiciones de crecimiento';
} }
+25
View File
@@ -2658,4 +2658,29 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get campaignCommentSetNickname => 'ニックネームを決める'; String get campaignCommentSetNickname => 'ニックネームを決める';
@override
String get heroNextEvolution => '次の進化まであと少し!';
@override
String heroGrowthRate(String percent) {
return '惑星成長率 $percent%';
}
@override
String heroNebulaGrowthRate(String percent) {
return '星雲成長率 $percent%';
}
@override
String get homeIncubator => 'ファンダム惑星インキュベーター';
@override
String get homeGrowTogether => '一緒に成長する';
@override
String get homeArtistNews => 'アーティストニュース';
@override
String get growthConditionsTitle => '成長条件';
} }
+25
View File
@@ -2660,4 +2660,29 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get campaignCommentSetNickname => '닉네임 정하기'; String get campaignCommentSetNickname => '닉네임 정하기';
@override
String get heroNextEvolution => '다음 진화까지 얼마 안남았어요!';
@override
String heroGrowthRate(String percent) {
return '행성 성장률 $percent%';
}
@override
String heroNebulaGrowthRate(String percent) {
return '성운 성장률 $percent%';
}
@override
String get homeIncubator => '팬덤행성 인큐베이터';
@override
String get homeGrowTogether => '함께 성장하기';
@override
String get homeArtistNews => '아티스트 소식';
@override
String get growthConditionsTitle => '성장 조건';
} }
+25
View File
@@ -2655,4 +2655,29 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get campaignCommentSetNickname => '设置昵称'; String get campaignCommentSetNickname => '设置昵称';
@override
String get heroNextEvolution => '距离下次进化不远了!';
@override
String heroGrowthRate(String percent) {
return '行星成长率 $percent%';
}
@override
String heroNebulaGrowthRate(String percent) {
return '星云成长率 $percent%';
}
@override
String get homeIncubator => '粉丝星球孵化器';
@override
String get homeGrowTogether => '一起成长';
@override
String get homeArtistNews => '艺人动态';
@override
String get growthConditionsTitle => '成长条件';
} }
+8 -1
View File
@@ -788,5 +788,12 @@
"popReasonQuestPoolDeposit": "任务奖励存入", "popReasonQuestPoolDeposit": "任务奖励存入",
"popReasonQuestPoolReturn": "任务奖励退回", "popReasonQuestPoolReturn": "任务奖励退回",
"campaignCommentNeedNickname": "请先设置昵称", "campaignCommentNeedNickname": "请先设置昵称",
"campaignCommentSetNickname": "设置昵称" "campaignCommentSetNickname": "设置昵称",
"heroNextEvolution": "距离下次进化不远了!",
"heroGrowthRate": "行星成长率 {percent}%",
"heroNebulaGrowthRate": "星云成长率 {percent}%",
"homeIncubator": "粉丝星球孵化器",
"homeGrowTogether": "一起成长",
"homeArtistNews": "艺人动态",
"growthConditionsTitle": "成长条件"
} }
@@ -727,6 +727,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String growthConditionUnmet(String name) => _fill('growthConditionUnmet', {'name': name}) ?? _base.growthConditionUnmet(name); String growthConditionUnmet(String name) => _fill('growthConditionUnmet', {'name': name}) ?? _base.growthConditionUnmet(name);
@override
String get growthConditionsTitle => _remote['growthConditionsTitle'] ?? _base.growthConditionsTitle;
@override @override
String get growthFinalReached => _remote['growthFinalReached'] ?? _base.growthFinalReached; String get growthFinalReached => _remote['growthFinalReached'] ?? _base.growthFinalReached;
@@ -799,6 +802,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get heroGrowingTogether => _remote['heroGrowingTogether'] ?? _base.heroGrowingTogether; String get heroGrowingTogether => _remote['heroGrowingTogether'] ?? _base.heroGrowingTogether;
@override
String heroGrowthRate(String percent) => _fill('heroGrowthRate', {'percent': percent}) ?? _base.heroGrowthRate(percent);
@override @override
String heroInProgress(String name) => _fill('heroInProgress', {'name': name}) ?? _base.heroInProgress(name); String heroInProgress(String name) => _fill('heroInProgress', {'name': name}) ?? _base.heroInProgress(name);
@@ -808,12 +814,18 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get heroNebula => _remote['heroNebula'] ?? _base.heroNebula; String get heroNebula => _remote['heroNebula'] ?? _base.heroNebula;
@override
String heroNebulaGrowthRate(String percent) => _fill('heroNebulaGrowthRate', {'percent': percent}) ?? _base.heroNebulaGrowthRate(percent);
@override @override
String get heroNebulaPrompt => _remote['heroNebulaPrompt'] ?? _base.heroNebulaPrompt; String get heroNebulaPrompt => _remote['heroNebulaPrompt'] ?? _base.heroNebulaPrompt;
@override @override
String get heroNebulaTap => _remote['heroNebulaTap'] ?? _base.heroNebulaTap; String get heroNebulaTap => _remote['heroNebulaTap'] ?? _base.heroNebulaTap;
@override
String get heroNextEvolution => _remote['heroNextEvolution'] ?? _base.heroNextEvolution;
@override @override
String get heroOwnerSuffix => _remote['heroOwnerSuffix'] ?? _base.heroOwnerSuffix; String get heroOwnerSuffix => _remote['heroOwnerSuffix'] ?? _base.heroOwnerSuffix;
@@ -880,9 +892,18 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get historyZap => _remote['historyZap'] ?? _base.historyZap; String get historyZap => _remote['historyZap'] ?? _base.historyZap;
@override
String get homeArtistNews => _remote['homeArtistNews'] ?? _base.homeArtistNews;
@override @override
String get homeFandomHistory => _remote['homeFandomHistory'] ?? _base.homeFandomHistory; String get homeFandomHistory => _remote['homeFandomHistory'] ?? _base.homeFandomHistory;
@override
String get homeGrowTogether => _remote['homeGrowTogether'] ?? _base.homeGrowTogether;
@override
String get homeIncubator => _remote['homeIncubator'] ?? _base.homeIncubator;
@override @override
String get homeItemsTooltip => _remote['homeItemsTooltip'] ?? _base.homeItemsTooltip; String get homeItemsTooltip => _remote['homeItemsTooltip'] ?? _base.homeItemsTooltip;
+1 -1
View File
@@ -1306,7 +1306,7 @@ packages:
source: hosted source: hosted
version: "3.1.0" version: "3.1.0"
webview_flutter: webview_flutter:
dependency: transitive dependency: "direct main"
description: description:
name: webview_flutter name: webview_flutter
sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111 sha256: d53e1ccf5516f25017e3c9d44c39034db352d20fa34fe200674270242c2c5111
+6
View File
@@ -103,6 +103,10 @@ dependencies:
in_app_purchase: ^3.3.1 in_app_purchase: ^3.3.1
# Android 소비(consume)를 서버 검증 뒤로 미루려고 직접 부른다(자동 소비 끔). # Android 소비(consume)를 서버 검증 뒤로 미루려고 직접 부른다(자동 소비 끔).
in_app_purchase_android: ^0.5.3 in_app_purchase_android: ^0.5.3
# HOME GALAXY 미니맵 · 확장 맵 — 발주사 프로토타입 v6.2 HTML(three.js)을 그대로 띄운다(9/22 D-6 ·
# 2026-10-05 대표 지시 「html 을 클론코딩하듯」). flutter.dev 팀 관리.
# 최소 OS 하한 영향 없음: webview_flutter_android minSdk 24(앱 29) · wkwebview iOS 13.0(앱 15.0).
webview_flutter: ^4.14.1
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@@ -157,5 +161,7 @@ flutter:
assets: assets:
- assets/figma/ - assets/figma/
- assets/fx/ - assets/fx/
# 프로토타입 v6.2 장면(uzu_hud.html) · three.js r128(MIT) · JetBrains Mono(OFL).
- assets/galaxy/
# Figma Iconset 아이콘(lib/core/ui/app_icons.dart). # Figma Iconset 아이콘(lib/core/ui/app_icons.dart).
- assets/icons/ - assets/icons/
+4 -4
View File
@@ -250,8 +250,8 @@ void main() {
'donorCount': 48, 'commentCount': 24, 'likeCount': 82, 'donorCount': 48, 'commentCount': 24, 'likeCount': 82,
}); });
final snapshot = await FirestoreHomeRepository(_backend(db)).load(); final snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.campaign!.achievedPercent, 92); expect(snapshot.campaigns.first.achievedPercent, 92);
expect(snapshot.campaign!.supporters, 48); expect(snapshot.campaigns.first.supporters, 48);
}); });
test('차단한 개설자의 캠페인은 홈 대표 캠페인에서 건너뛴다', () async { test('차단한 개설자의 캠페인은 홈 대표 캠페인에서 건너뛴다', () async {
@@ -266,7 +266,7 @@ void main() {
}); });
await db.doc('users/$_uid/blocks/troll').set({'nickname': '트롤'}); await db.doc('users/$_uid/blocks/troll').set({'nickname': '트롤'});
final snapshot = await FirestoreHomeRepository(_backend(db)).load(); final snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.campaign!.title, '다음'); expect(snapshot.campaigns.first.title, '다음');
}); });
test('모금 중이 아닌 캠페인은 홈에 올리지 않는다', () async { test('모금 중이 아닌 캠페인은 홈에 올리지 않는다', () async {
@@ -275,7 +275,7 @@ void main() {
'status': 'review', 'title': '심사 중', 'goalPop': 100, 'raisedPop': 0, 'status': 'review', 'title': '심사 중', 'goalPop': 100, 'raisedPop': 0,
}); });
final snapshot = await FirestoreHomeRepository(_backend(db)).load(); final snapshot = await FirestoreHomeRepository(_backend(db)).load();
expect(snapshot.campaign, isNull); expect(snapshot.campaigns, isEmpty);
}); });
}); });
+6 -1
View File
@@ -72,11 +72,16 @@ final shots = <Shot>[
Shot('F1-01d', tab: '/home'), Shot('F1-01d', tab: '/home'),
// 아래 구획(TODAY'S QUEST 등) 눈 대조용 긴 렌더. // 아래 구획(TODAY'S QUEST 등) 눈 대조용 긴 렌더.
Shot('F1-01L', tab: '/home', h: 1400), Shot('F1-01L', tab: '/home', h: 1400),
// Figma 「홈」(`2097:16428`) 375×2904 전체 길이 — 사본 렌더와 나란히 대조.
Shot('F1-01F', tab: '/home', h: 2904),
Shot( Shot(
'F1-01i', 'F1-01i',
tab: '/home', tab: '/home',
after: (t) async { after: (t) async {
final hold = await t.startGesture(t.getCenter(find.text('기여'))); // 떠 있는 ZAP 은 번개만 있다(라벨 없음) — 의미 라벨로 잡는다.
final hold = await t.startGesture(
t.getCenter(find.bySemanticsLabel(RegExp(r'^ZAP'))),
);
await t.pump(const Duration(seconds: 2)); await t.pump(const Duration(seconds: 2));
await hold.up(); await hold.up();
await _settle(t); await _settle(t);
+2 -1
View File
@@ -51,7 +51,8 @@ class QuietHome extends SampleHomeRepository {
growth: growth ?? s.growth, growth: growth ?? s.growth,
quests: s.quests, quests: s.quests,
briefings: s.briefings, briefings: s.briefings,
campaign: s.campaign, campaigns: s.campaigns,
nebulas: s.nebulas,
insides: s.insides, insides: s.insides,
items: s.items, items: s.items,
); );
+3
View File
@@ -226,6 +226,9 @@ void main() {
testWidgets('두 조건을 갈라 보여 준다', (tester) async { testWidgets('두 조건을 갈라 보여 준다', (tester) async {
await _pump(tester, const GrowthCompleteScreen()); await _pump(tester, const GrowthCompleteScreen());
expect(find.text('활동을 통해 얻은 POP을 통해\n성운에 기여해 보세요!'), findsOneWidget); expect(find.text('활동을 통해 얻은 POP을 통해\n성운에 기여해 보세요!'), findsOneWidget);
// 카드는 성장률 한 줄(사본 「성운 성장률 100%」) — 누르면 두 조건 상세.
await tester.tap(find.textContaining('성운 성장률'));
await tester.pumpAndSettle();
expect(find.text('POP 조건 충족 · 1,240 / 1,000'), findsOneWidget); expect(find.text('POP 조건 충족 · 1,240 / 1,000'), findsOneWidget);
expect(find.text('창립 멤버 조건 충족 · 12 / 10'), findsOneWidget); expect(find.text('창립 멤버 조건 충족 · 12 / 10'), findsOneWidget);
}); });
@@ -125,4 +125,39 @@ void main() {
}); });
}); });
} }
// HOME 「GALAXY 맵 확장」(`343:211127`) — 히어로 안 맵 영역 375×556.
for (final (name, planets) in [
('expanded_v2_app_mine1', protoPlanets),
('expanded_v2_app_mine3', mineAt(3)),
]) {
testWidgets(name, (tester) async {
final key = GlobalKey();
await tester.pumpWidget(
MaterialApp(
home: Center(
child: RepaintBoundary(
key: key,
child: SizedBox(
width: 375,
height: 556,
child: GalaxyMiniMap.expanded(
data: GalaxySnapshot(planets: planets, myPop: 0),
onOpenPlanet: (_) {},
),
),
),
),
),
);
await tester.pump();
await tester.runAsync(() async {
final boundary =
key.currentContext!.findRenderObject()! as RenderRepaintBoundary;
final image = await boundary.toImage(pixelRatio: 3);
final bytes = await image.toByteData(format: ui.ImageByteFormat.png);
File('$out/$name.png').writeAsBytesSync(bytes!.buffer.asUint8List());
});
});
}
} }
+180 -17
View File
@@ -7,8 +7,10 @@ import 'package:fancake/core/data/planet_visual_state.dart';
import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/planet_25d.dart'; import 'package:fancake/core/ui/planet_25d.dart';
import 'package:fancake/features/galaxy/galaxy_data.dart'; import 'package:fancake/features/galaxy/galaxy_data.dart';
import 'package:fancake/features/galaxy/galaxy_screen.dart'; import 'package:fancake/features/galaxy/galaxy_search_screen.dart';
import 'package:fancake/features/planet/planet_detail_screen.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart'; import 'package:fancake/features/galaxy/widgets/galaxy_mini_map.dart';
import 'package:fancake/features/galaxy/widgets/galaxy_web_view.dart';
import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart'; import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/home/widgets/home_hero.dart'; import 'package:fancake/features/home/widgets/home_hero.dart';
@@ -329,11 +331,12 @@ void main() {
expect(micros, lessThan(paintBudgetMicros)); expect(micros, lessThan(paintBudgetMicros));
}); });
testWidgets('HOME 미니맵은 같은 저장소의 Galaxy로 진입한다', (tester) async { Future<StreamController<PlanetVisualState>> pumpHome(
WidgetTester tester,
) async {
tester.view.physicalSize = const Size(1080, 2400); tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3; tester.view.devicePixelRatio = 3;
addTearDown(tester.view.reset); addTearDown(tester.view.reset);
const galaxy = SampleGalaxyRepository();
final states = StreamController<PlanetVisualState>.broadcast(); final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close); addTearDown(states.close);
await tester.pumpWidget( await tester.pumpWidget(
@@ -343,7 +346,7 @@ void main() {
repository: const SampleHomeRepository(), repository: const SampleHomeRepository(),
questRepository: const SampleQuestRepository(), questRepository: const SampleQuestRepository(),
eclipse: const NoEclipseRepository(), eclipse: const NoEclipseRepository(),
galaxyRepository: galaxy, galaxyRepository: const SampleGalaxyRepository(),
planetRepository: samplePlanetRepository, planetRepository: samplePlanetRepository,
watchPlanet: (_) => states.stream, watchPlanet: (_) => states.stream,
), ),
@@ -351,13 +354,26 @@ void main() {
); );
await tester.pump(); await tester.pump();
await tester.pump(const Duration(seconds: 1)); await tester.pump(const Duration(seconds: 1));
return states;
}
GalaxyMiniMapPainter expandedPainter(WidgetTester tester) => tester
.widgetList<CustomPaint>(
find.descendant(
of: find.byType(HomeGalaxyHero),
matching: find.byType(CustomPaint),
),
)
.map((w) => w.painter)
.whereType<GalaxyMiniMapPainter>()
.single;
testWidgets('HOME 미니맵 자리 — Figma 「홈」 히어로 우측 상단', (tester) async {
final states = await pumpHome(tester);
final mini = find.byType(GalaxyMiniMap); final mini = find.byType(GalaxyMiniMap);
expect(mini, findsOneWidget); expect(mini, findsOneWidget);
// C-105 약 140pt · C-113 자리 = Figma 「홈」(2097:16428) 히어로 우측 상단 · 오른쪽 여백 0. // C-113 자리 = Figma 「홈」(2097:16428) 히어로 우측 상단 · 오른쪽 여백 0.
expect( expect(tester.getSize(mini), const Size.square(HomeHero.miniMapSize));
tester.getSize(mini),
const Size.square(HomeHero.miniMapSize),
);
expect( expect(
tester.getTopRight(mini).dx, tester.getTopRight(mini).dx,
closeTo(tester.getSize(find.byType(HomeScreen)).width, 1), closeTo(tester.getSize(find.byType(HomeScreen)).width, 1),
@@ -372,15 +388,162 @@ void main() {
1, 1,
), ),
); );
await tester.tap(mini);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(
tester.widget<GalaxyScreen>(find.byType(GalaxyScreen)).repository,
same(galaxy),
);
expect(tester.takeException(), isNull);
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
expect(states.hasListener, isFalse); expect(states.hasListener, isFalse);
}); });
testWidgets(
'미니맵을 누르면 히어로가 「GALAXY 맵 확장」(343:211127)으로 바뀌고, GALAXY · 뒤로가기로 돌아온다',
(tester) async {
await pumpHome(tester);
await tester.tap(find.byType(GalaxyMiniMap));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(HomeGalaxyHero), findsOneWidget);
expect(find.byType(HomeHero), findsNothing);
// 확장 맵 하나만 — 상단 미니맵은 사라진다. TODAY'S QUEST 등 아래 구획은 그대로.
expect(find.byType(GalaxyMiniMap), findsOneWidget);
expect(find.text('TODAY’S QUEST'), findsOneWidget);
// 히어로 680 안 y124~680 = 맵(375×556), 아래 54 「GALAXY」 바.
final map = find.byType(GalaxyMiniMap);
expect(tester.getSize(map), const Size(360, 556)); // 시험 창 폭 360.
expect(find.text('GALAXY'), findsOneWidget);
await tester.tap(find.byKey(const ValueKey('galaxy-bar-close')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(HomeGalaxyHero), findsNothing);
expect(find.byType(HomeHero), findsOneWidget);
await tester.tap(find.byType(GalaxyMiniMap));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(HomeGalaxyHero), findsOneWidget);
await tester.binding.handlePopRoute();
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(HomeGalaxyHero), findsNothing);
expect(find.byType(HomeScreen), findsOneWidget);
expect(tester.takeException(), isNull);
},
);
testWidgets('확장 맵 — 다른 행성을 누르면 그 행성이 화면 중심이 된다(2008:14486)', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: Center(
child: SizedBox(
width: 375,
height: 556,
child: GalaxyMiniMap.expanded(
data: _snap(_base),
onOpenPlanet: (_) {},
),
),
),
),
);
await tester.pump();
final painter = tester
.widgetList<CustomPaint>(find.byType(CustomPaint))
.map((w) => w.painter)
.whereType<GalaxyMiniMapPainter>()
.single;
expect(painter.focusId, 'a');
// Figma `343:211127` 축척 — HUD 카드는 고정 144×72, 줌과 무관.
expect(painter.hudRectOf('a')!.size, GalaxyMiniMapPainter.figmaHud);
painter.zoomTo(2);
await tester.pump();
expect(painter.hudRectOf('a')!.size, GalaxyMiniMapPainter.figmaHud);
painter.zoomTo(1);
await tester.pump();
final origin = tester.getTopLeft(find.byType(GalaxyMiniMap));
final at = painter.screenOf('b')!;
expect(painter.hudRectOf('b')!.contains(at), isFalse);
await tester.tapAt(origin + at);
await tester.pump();
expect(painter.focusId, 'b');
// 카메라가 따라가 고른 행성이 가운데로 온다.
await tester.pump(const Duration(seconds: 3));
expect(painter.screenOf('b')!.dx, closeTo(375 / 2, 2));
});
testWidgets('확장 맵 — 끌어 회전 · 핀치 줌 · HUD 탭 상세 · 돋보기 검색', (tester) async {
await pumpHome(tester);
await tester.tap(find.byType(GalaxyMiniMap));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
final painter = expandedPainter(tester);
final map = find.byType(GalaxyMiniMap);
final origin = tester.getTopLeft(map);
expect(painter.focusId, isNotNull);
// 끌기 — 회전(±180° 한계).
await tester.dragFrom(tester.getCenter(map), const Offset(2000, 0));
await tester.pump(const Duration(seconds: 3));
expect(painter.rotation, closeTo(math.pi, 0.05));
// 핀치 — 두 손가락 벌리기.
final c = tester.getCenter(map);
final a = await tester.startGesture(c - const Offset(40, 0));
final b = await tester.startGesture(c + const Offset(40, 0));
await a.moveBy(const Offset(-40, 0));
await b.moveBy(const Offset(40, 0));
await tester.pump();
await a.up();
await b.up();
await tester.pump();
expect(painter.zoom, greaterThan(1.2));
// HUD 탭 → 행성 상세.
await tester.pump(const Duration(milliseconds: 16));
final focus = painter.focusId!;
final hud = painter.hudRectOf(focus)!;
expect(hud.isEmpty, isFalse);
await tester.tapAt(origin + hud.center);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(PlanetDetailScreen), findsOneWidget);
Navigator.of(tester.element(find.byType(PlanetDetailScreen))).pop();
await tester.pump();
await tester.pump(const Duration(seconds: 1));
// 돋보기 → 갤럭시 검색.
await tester.tap(find.byIcon(Icons.search));
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(find.byType(GalaxySearchScreen), findsOneWidget);
});
test('WebView 로 넘기는 데이터 — FPI 상위 10 + 순위 밖 내 행성 · 실제 FPI · 색은 RGB 정수', () {
final planets = [
for (var i = 1; i <= 12; i++)
PlanetNode(
id: 'p$i',
name: 'P$i',
rank: i,
fpi: 100000 - i,
members: 1,
online: i,
isMine: i == 12,
),
];
final list = GalaxyWebView.payload(_snap(planets));
expect(list, hasLength(11));
expect(list.first['fpi'], 99999);
expect(list.last['id'], 'p12');
expect(list.last['mine'], isTrue);
expect(list.every((p) => (p['color']! as int) <= 0xFFFFFF), isTrue);
});
testWidgets('미니맵 → 확장은 같은 지도 위젯 상태를 옮긴다(WebView 재생성 없음)', (tester) async {
await pumpHome(tester);
final before = tester.state(find.byType(GalaxyWebView));
expect(tester.widget<GalaxyWebView>(find.byType(GalaxyWebView)).expanded, isFalse);
await tester.tap(find.byType(GalaxyMiniMap));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(tester.state(find.byType(GalaxyWebView)), same(before));
expect(tester.widget<GalaxyWebView>(find.byType(GalaxyWebView)).expanded, isTrue);
});
} }
+5 -3
View File
@@ -1,7 +1,7 @@
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart'; import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:fancake/core/data/backend.dart'; import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/section_header.dart'; import 'package:fancake/features/home/widgets/home_sections.dart';
import 'package:fancake/features/campaign/campaign_create_screen.dart'; import 'package:fancake/features/campaign/campaign_create_screen.dart';
import 'package:fancake/features/campaign/campaign_list_screen.dart'; import 'package:fancake/features/campaign/campaign_list_screen.dart';
import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_data.dart';
@@ -49,7 +49,7 @@ class _NoCampaignRepo extends SampleHomeRepository {
growth: base.growth, growth: base.growth,
quests: base.quests, quests: base.quests,
briefings: base.briefings, briefings: base.briefings,
campaign: null, campaigns: const [],
insides: base.insides, insides: base.insides,
); );
} }
@@ -80,7 +80,7 @@ Future<void> _pump(WidgetTester tester, HomeRepository repo) async {
} }
Finder get _hotArchive => find.byWidgetPredicate( Finder get _hotArchive => find.byWidgetPredicate(
(w) => w is SectionHeader && w.title == 'HOT ARCHIVE', (w) => w is HomeSectionHeader && w.title == 'HOT ARCHIVE',
); );
void main() { void main() {
@@ -94,6 +94,8 @@ void main() {
220, 220,
scrollable: find.byType(Scrollable).first, scrollable: find.byType(Scrollable).first,
); );
await tester.ensureVisible(_hotArchive);
await tester.pumpAndSettle();
await tester.tap( await tester.tap(
find.descendant(of: _hotArchive, matching: find.text('더 보기')), find.descendant(of: _hotArchive, matching: find.text('더 보기')),
); );
+122 -32
View File
@@ -1,5 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:fancake/core/ui/section_header.dart'; import 'package:fancake/features/home/widgets/home_sections.dart';
import 'package:fancake/features/quest/quest_list_screen.dart'; import 'package:fancake/features/quest/quest_list_screen.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
@@ -7,8 +7,12 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:fancake/core/theme/app_theme.dart'; import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/features/home/home_data.dart'; import 'package:fancake/features/home/home_data.dart';
import 'package:fancake/features/home/home_screen.dart'; import 'package:fancake/features/home/home_screen.dart';
import 'package:fancake/features/home/home_repository_firestore.dart' show adminQuestsLatest, nebulaPicks;
import 'package:fancake/features/quest/quest_data.dart'; import 'package:fancake/features/quest/quest_data.dart';
import 'package:fancake/features/home/widgets/briefing_list.dart'; import 'package:fancake/features/home/widgets/briefing_list.dart';
import 'package:fancake/features/home/widgets/quest_row.dart';
import 'package:fancake/features/inside/inside_detail_screen.dart';
import 'package:fancake/features/quest/widgets/quest_card_tile.dart' show QuestIconBox;
import 'eclipse_stubs.dart'; import 'eclipse_stubs.dart';
@@ -18,6 +22,36 @@ class _LiveGrowthRepo extends SampleHomeRepository {
Stream<PlanetGrowth> watchGrowth(String fandomId) => changes.stream; Stream<PlanetGrowth> watchGrowth(String fandomId) => changes.stream;
} }
class _LiveWalletRepo extends SampleHomeRepository {
final wallet = StreamController<int>.broadcast();
@override
Stream<int> watchPopBalance() => wallet.stream;
}
/// 퀘스트 4건 — 3줄까지만 그리는지 본다.
class _QuestRepo extends SampleHomeRepository {
const _QuestRepo();
@override
Future<HomeSnapshot> load() async {
final base = await const SampleHomeRepository().load();
return HomeSnapshot(
popBalance: base.popBalance,
fandomId: base.fandomId,
liveTicker: base.liveTicker,
growth: base.growth,
quests: [
...base.quests,
const QuestSummary(title: '좋아요', rewardPop: 5, state: QuestState.done),
const QuestSummary(title: '네 번째', rewardPop: 5, state: QuestState.inProgress),
],
briefings: base.briefings,
campaigns: base.campaigns,
nebulas: base.nebulas,
insides: base.insides,
);
}
}
/// 값을 못 가져오는 경우를 재현한다. /// 값을 못 가져오는 경우를 재현한다.
class _FailingRepo extends SampleHomeRepository { class _FailingRepo extends SampleHomeRepository {
@override @override
@@ -61,7 +95,8 @@ class _BriefingRepo extends SampleHomeRepository {
growth: base.growth, growth: base.growth,
quests: base.quests, quests: base.quests,
briefings: briefings, briefings: briefings,
campaign: base.campaign, campaigns: base.campaigns,
nebulas: base.nebulas,
insides: base.insides, insides: base.insides,
); );
} }
@@ -98,20 +133,36 @@ void main() {
requiredPop: 10000, currentSfpi: 750, requiredSfpi: 1000, requiredPop: 10000, currentSfpi: 750, requiredSfpi: 1000,
)); ));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(find.text('POP 조건 충족 · 10,000 / 10,000'), findsOneWidget); // 카드는 병목 축(sFPI 75%)을 보인다.
expect(find.text('sFPI 조건 미충족 · 750 / 1,000'), findsOneWidget); expect(find.text('행성 성장률 75%'), findsOneWidget);
expect(find.text('750 / 1,000'), findsOneWidget);
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
await tester.pump(); await tester.pump();
expect(repo.changes.hasListener, isFalse); expect(repo.changes.hasListener, isFalse);
}); });
testWidgets('헤더 POP 칩은 지갑 문서 변동을 따라간다(다른 화면의 예치·보상)', (tester) async {
final repo = _LiveWalletRepo();
addTearDown(repo.wallet.close);
await _pump(tester, repo: repo);
expect(find.text('12,540'), findsOneWidget);
expect(repo.wallet.hasListener, isTrue);
repo.wallet.add(12160);
await tester.pumpAndSettle();
expect(find.text('12,160'), findsOneWidget);
repo.wallet.add(12140);
await tester.pumpAndSettle();
expect(find.text('12,140'), findsOneWidget);
});
testWidgets('Figma 홈의 구획 제목이 순서대로 나온다', (tester) async { testWidgets('Figma 홈의 구획 제목이 순서대로 나온다', (tester) async {
await _pump(tester); await _pump(tester);
// 화면 대조 원장 F1 · Figma `252:159401` 이하의 구획명 그대로여야 한다. // Figma 「홈」 `2097:16428` `Frame 4241` 구획명 그대로여야 한다.
// 기본 뷰포트(800×600)보다 화면이 길어 아래쪽은 스크롤해야 잡힌다. // 기본 뷰포트(800×600)보다 화면이 길어 아래쪽은 스크롤해야 잡힌다.
for (final title in [ for (final title in [
'TODAY’S QUEST', 'TODAY’S QUEST',
'AI Daily Briefing', '팬덤행성 인큐베이터',
'아티스트 소식',
'HOT ARCHIVE', 'HOT ARCHIVE',
'인기 K-INSIDE', '인기 K-INSIDE',
]) { ]) {
@@ -124,25 +175,71 @@ void main() {
} }
}); });
testWidgets('POP 진행률과 두 성장 조건을 구분해서 표시한다', (tester) async { testWidgets('성장 카드는 Figma 배치 — 성장률 · 수치 한 쌍, 말풍선을 누르면 두 조건 상세', (tester) async {
await _pump(tester); await _pump(tester);
expect(find.text('67%'), findsOneWidget); // 표본은 sFPI 값이 없어 POP 축(6,270 / 10,000)이 기준이다.
expect(find.text('POP 진행률'), findsOneWidget); expect(find.text('행성 성장률 63%'), findsOneWidget);
expect(find.text('6,270 / 10,000'), findsOneWidget);
// 카드에 두 조건 줄을 늘어놓지 않는다.
expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsNothing);
// 말풍선이 성장률 글자를 가리지 않는다.
final rate = tester.getRect(find.text('행성 성장률 63%'));
final bubble = tester.getRect(find.text('다음 진화까지 얼마 안남았어요!'));
expect(bubble.left, greaterThan(rate.right));
await tester.tap(find.text('다음 진화까지 얼마 안남았어요!'));
await tester.pumpAndSettle();
expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsOneWidget); expect(find.text('POP 조건 미충족 · 6,270 / 10,000'), findsOneWidget);
expect(find.text('sFPI 조건 확인 중 · — / —'), findsOneWidget); expect(find.text('sFPI 조건 확인 중 · — / —'), findsOneWidget);
}); });
test('TODAY’S QUEST 는 관리자 퀘스트만 최신순 — 사용자 생성 퀘스트는 빠진다(2097:16397)', () {
final docs = [
{'id': 'old', 'createdAt': DateTime(2026, 9, 1)},
{'id': 'user', 'authorUid': 'u1', 'createdAt': DateTime(2026, 10, 4)},
{'id': 'new', 'createdAt': DateTime(2026, 10, 1)},
];
expect(adminQuestsLatest(docs, (d) => d).map((d) => d['id']), ['new', 'old']);
});
test('인큐베이터는 기여율 높은 순 최대 6개(명세 §2.114-4)', () {
final picks = nebulaPicks([
for (var i = 0; i < 8; i++) ('n$i', {'name': 'N$i', 'growthPop': i * 100}),
], 1000);
expect(picks, hasLength(6));
expect(picks.first.id, 'n7');
expect(picks.first.percent, 70);
});
test('성장률 기준은 두 조건 중 진행률이 낮은 쪽이다', () {
const g = PlanetGrowth(
stage: 2, fandomName: 'X', contributorName: '', contributedPop: 0,
growthPercent: 90, currentPop: 9000, requiredPop: 10000,
currentSfpi: 300, requiredSfpi: 1000,
);
expect(g.bottleneck!.ratio, closeTo(0.3, 1e-9));
expect(g.bottleneck!.current, 300);
expect(g.growthBubble, '성장 조건 확인');
});
testWidgets('POP 잔액은 천 단위로 끊어 띄운다', (tester) async { testWidgets('POP 잔액은 천 단위로 끊어 띄운다', (tester) async {
await _pump(tester); await _pump(tester);
expect(find.text('12,540'), findsOneWidget); expect(find.text('12,540'), findsOneWidget);
}); });
testWidgets('퀘스트 카드는 보상과 진행 상태를 보여 준다', (tester) async { testWidgets('TODAY’S QUEST 는 Figma 「홈」 대로 세로 줄이다 — 높이 90 · 아이콘 상자 50 · 최대 3줄', (tester) async {
await _pump(tester); await _pump(tester, repo: const _QuestRepo());
// 시안(가로 카드)은 제목 옆에 「+보상」, 아래에 상태 한 줄. final rows = find.byType(QuestRow);
expect(find.text('+10'), findsOneWidget); expect(rows, findsNWidgets(3));
expect(find.text('출석 완료'), findsOneWidget); final first = tester.getRect(rows.at(0));
expect(find.text('0/1,500 걸음'), findsOneWidget); final second = tester.getRect(rows.at(1));
expect(first.height, 90);
expect(second.top, first.bottom); // 간격 없이 쌓는다(`2097:16428`).
expect(second.left, first.left);
expect(tester.getSize(find.byType(QuestIconBox).first), const Size(50, 50));
// 보조 줄은 카테고리, 보상 숫자 · 진행 상태는 그리지 않는다(사본 「홈」).
expect(find.text('오늘의 출석체크'), findsOneWidget);
expect(find.text('+10'), findsNothing);
expect(find.text('네 번째'), findsNothing);
}); });
testWidgets('현상금 표기에 천 단위 구분이 들어간다', (tester) async { testWidgets('현상금 표기에 천 단위 구분이 들어간다', (tester) async {
@@ -155,23 +252,16 @@ void main() {
expect(find.text('500 P'), findsOneWidget); expect(find.text('500 P'), findsOneWidget);
}); });
testWidgets('아직 없는 화면으로 가려 하면 준비 중이라고 알린다', (tester) async { testWidgets('인기 K-INSIDE 카드를 누르면 인사이드 상세로 간다', (tester) async {
await _pump(tester); await _pump(tester);
// 첫 「더 보기」는 퀘스트라 실제로 이동한다. 아직 없는 구간까지 굴려서 누른다. final card = find.text('화양연화 세계관 해석해주실 분?');
// 브리핑 헤더는 「공유」다(`266:90529`). 아직 없는 목록인 인기 K-INSIDE 로 본다. await tester.scrollUntilVisible(card, 220,
final briefing = find.byWidgetPredicate(
(w) => w is SectionHeader && w.title == '인기 K-INSIDE',
);
await tester.scrollUntilVisible(briefing, 220,
scrollable: find.byType(Scrollable).first); scrollable: find.byType(Scrollable).first);
await tester.ensureVisible(briefing); await tester.ensureVisible(card);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.descendant(of: briefing, matching: find.text('더 보기'))); await tester.tap(card);
await tester.pump(); await tester.pumpAndSettle();
// 조용히 무시하면 검수 때 고장으로 보인다. expect(find.byType(InsideDetailScreen), findsOneWidget);
expect(find.textContaining('화면은 준비 중입니다.'), findsOneWidget);
// 내부 일정(구현 확인물 · 날짜)은 사용자 화면에 내보내지 않는다.
expect(find.textContaining('구현 확인물'), findsNothing);
}); });
testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async { testWidgets('오늘의 퀘스트에서 퀘스트 목록으로 넘어간다', (tester) async {
@@ -202,7 +292,7 @@ void main() {
/// *문구도 노출하지 않음)*. 서버가 당일 게시분을 0 건으로 주는 상황이다. /// *문구도 노출하지 않음)*. 서버가 당일 게시분을 0 건으로 주는 상황이다.
testWidgets('브리핑이 0 건이면 구획 제목까지 숨긴다', (tester) async { testWidgets('브리핑이 0 건이면 구획 제목까지 숨긴다', (tester) async {
await _pump(tester, repo: const _BriefingRepo([])); await _pump(tester, repo: const _BriefingRepo([]));
expect(find.text('AI Daily Briefing'), findsNothing); expect(find.text('아티스트 소식'), findsNothing);
// 빈 상태 문구도 두지 않는다 — 아래 구획은 그대로 있어야 한다. // 빈 상태 문구도 두지 않는다 — 아래 구획은 그대로 있어야 한다.
await tester.scrollUntilVisible( await tester.scrollUntilVisible(
find.text('HOT ARCHIVE'), find.text('HOT ARCHIVE'),
@@ -215,7 +305,7 @@ void main() {
testWidgets('브리핑 헤더의 「공유」는 브리핑 공유 카드를 연다(F8 · 별첨 1 ③-5)', (tester) async { testWidgets('브리핑 헤더의 「공유」는 브리핑 공유 카드를 연다(F8 · 별첨 1 ③-5)', (tester) async {
await _pump(tester, repo: _BriefingRepo([_brief('공유할 한 줄')])); await _pump(tester, repo: _BriefingRepo([_brief('공유할 한 줄')]));
final header = find.byWidgetPredicate( final header = find.byWidgetPredicate(
(w) => w is SectionHeader && w.title == 'AI Daily Briefing', (w) => w is HomeSectionHeader && w.title == '아티스트 소식',
); );
await tester.scrollUntilVisible(find.text('공유할 한 줄'), 220, await tester.scrollUntilVisible(find.text('공유할 한 줄'), 220,
scrollable: find.byType(Scrollable).first); scrollable: find.byType(Scrollable).first);
@@ -234,7 +324,7 @@ void main() {
220, 220,
scrollable: find.byType(Scrollable).first, scrollable: find.byType(Scrollable).first,
); );
expect(find.text('AI Daily Briefing'), findsOneWidget); expect(find.text('아티스트 소식'), findsOneWidget);
expect(find.text('오늘 게시된 한 줄'), findsOneWidget); expect(find.text('오늘 게시된 한 줄'), findsOneWidget);
}); });