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