끝난 이클립스 합류 문구 · HOME 헤더 POP · 다국어 HOME 레이아웃 · 캠페인 표시 수정

This commit is contained in:
Sn-Kinos
2026-10-08 19:00:24 +09:00
parent a74edaef6b
commit 40b2c0e6f6
40 changed files with 1649 additions and 845 deletions
+3 -3
View File
@@ -351,8 +351,8 @@ class HomeSnapshot {
/// HOME 주간 정보(A-37 · HOME 정보위계 v1.0 §2 · §3). 소속 행성이 없으면 null.
final HomeWeekly? weekly;
/// 활동 POP 잔액. 정책 설계서 §P-02 — 활동/지원 원장은 분리되어 있고
/// 홈 상단에는 활동 POP 을 띄운다.
/// 총 보유 POP(일반 + 기여 + 보류) — Figma `179:29099` 「보유 포인트 총합」.
/// 설정 · POP 내역의 총 보유와 같은 값이다(`walletTotal`).
final int popBalance;
/// 내 소속 팬덤. 이클립스 경보 배너와 라이브 띠가 **어느 팬덤을 구독할지**를
@@ -499,7 +499,7 @@ abstract interface class HomeRepository {
Future<HomeSnapshot> load();
Stream<PlanetGrowth> watchGrowth(String fandomId);
/// 보유 활동 POP — `users/{uid}/wallet/main.activityBalance` 구독. 퀘스트 목록 헤더
/// 총 보유 POP — `users/{uid}/wallet/main` 구독(`walletTotal`). 퀘스트 목록 헤더
/// (`QuestRepository.popBalance`)와 같은 문서라, 다른 화면에서 생긴 변동(현상금 예치 ·
/// 보상)이 HOME 헤더에도 바로 반영된다.
Stream<int> watchPopBalance();
@@ -44,9 +44,7 @@ class FirestoreHomeRepository implements HomeRepository {
Stream<int> watchPopBalance() => _backend.db
.doc('users/${_backend.requireUid}/wallet/main')
.snapshots()
.map(
(s) => (s.data() ?? const <String, dynamic>{}).intOr('activityBalance'),
);
.map((s) => (s.data() ?? const <String, dynamic>{}).walletTotal);
@override
Future<({String fandomId, bool primaryChanged})> createNebula(
@@ -233,7 +231,7 @@ class FirestoreHomeRepository implements HomeRepository {
return HomeSnapshot(
weekly: weekly,
popBalance: wallet.intOr('activityBalance'),
popBalance: wallet.walletTotal,
fandomId: fandomId,
liveTicker: fandom.strOr('liveTicker').isEmpty
? null
+2 -1
View File
@@ -684,7 +684,8 @@ class _HomeBody extends StatelessWidget {
key: ValueKey(('pop', repository)),
create: repository.watchPopBalance,
builder: (context, wallet) => HomeHeader(
pop: wallet ?? zap?.balance ?? data.popBalance,
// Zap 영수증 잔액(일반 POP)은 섞지 않는다 — 헤더는 총 보유다(`179:29099`).
pop: wallet ?? data.popBalance,
notifications: notifications,
items: data.items,
fandomId: data.fandomId,
@@ -2,7 +2,6 @@ import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/services.dart';
import 'package:share_plus/share_plus.dart';
import '../../../core/i18n/app_locale.dart';
@@ -10,6 +9,7 @@ import '../../../core/theme/tokens.dart';
import '../../../core/ui/app_icons.dart';
import '../../campaign/campaign_share.dart' show shareHashtags, shareLink, shareBaseUrl;
import '../home_data.dart';
import '../../../core/ui/clipboard_copy.dart';
/// 브리핑 공유 — 「홈_브리핑 공유」 `266:90445` · 인스타 `272:240423` · 틱톡 `272:240430`.
///
@@ -128,13 +128,16 @@ class _BriefingShareDialogState extends State<BriefingShareDialog> {
setState(() => _busy = true);
try {
if (index == _icons.length - 1) {
await Clipboard.setData(ClipboardData(text: _text));
var announce = false;
await copyToClipboard(_text, () => announce = true);
if (!mounted) return;
// 오버레이를 닫고 알린다 — 열린 채로 띄우면 스낵바가 장막 뒤에 가린다.
Navigator.of(context).pop();
toast(widget.link == null
? l10n.briefingShareTextCopied
: l10n.briefingShareCopied);
if (announce) {
toast(widget.link == null
? l10n.briefingShareTextCopied
: l10n.briefingShareCopied);
}
return;
}
final file = await _capture();
+199 -138
View File
@@ -183,7 +183,19 @@ class _HomeHeroState extends State<HomeHero> {
children: [
// 첫 사용자 홈(`343:212939`)은 아바타가 y124, 나머지 상태는 y130.
if (!widget.unjoined) const SizedBox(height: 6),
_Contribution(growth: growth, joined: !widget.unjoined),
// 미니맵(오른쪽 위 [HomeHero.miniMapSize])과 같은 높이라 그 폭만큼 비운다 — 긴 언어(ja ·
// es)의 제목이 미니맵 아래로 들어가 잘리지 않고 줄바꿈된다(10/8 r11).
Padding(
padding: EdgeInsets.only(
right: widget.miniMap == null
? 0
: HomeHero.miniMapSize - AppSpace.s4 + AppSpace.s2,
),
child: _Contribution(
growth: growth,
joined: !widget.unjoined,
),
),
const SizedBox(height: AppSpace.s7),
// HERO TIME 안내·큰 글자처럼 아래가 길어지면 행성이 먼저 줄어든다.
Flexible(
@@ -216,19 +228,22 @@ class _HomeHeroState extends State<HomeHero> {
child: _TapShake(
onTap: widget.onTapPlanet,
child: LivePlanet25D(
onOpenHistory: widget.onOpenHistory,
key: _planetKey,
fandomId: fandomId,
watch: watchPlanet,
stage: growth.stage,
// 형태 없는 옛 행성은 다른 화면과 같은 그림(10/6 C-5).
image: planetFallbackImage(fandomId),
size: math.min(
planet,
box.maxHeight,
onOpenHistory:
widget.onOpenHistory,
key: _planetKey,
fandomId: fandomId,
watch: watchPlanet,
stage: growth.stage,
// 형태 없는 옛 행성은 다른 화면과 같은 그림(10/6 C-5).
image: planetFallbackImage(
fandomId,
),
size: math.min(
planet,
box.maxHeight,
),
glow: AppColors.neonViolet,
),
glow: AppColors.neonViolet,
),
),
),
],
@@ -276,6 +291,7 @@ class _HomeHeroState extends State<HomeHero> {
child: ZapFeedback(
fandomName: widget.growth.fandomName,
quest: zap.questReaction,
messageAboveTarget: true,
touchSerial: zap.touchSerial,
pending: zap.pending > 0 || zap.sending,
result: zap.result,
@@ -312,96 +328,104 @@ class _Contribution extends StatelessWidget {
/// 소속 팬덤이 있는가. 없으면 첫 사용자 문구(`343:212939`)를 쓴다.
final bool joined;
@override
Widget build(BuildContext context) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 「{닉네임} 님의」는 로그인 사용자의 인사 줄이다(설명 프레임 `2097:16391`).
if (growth.contributorName.trim().isNotEmpty) ...[
Row(
children: [
// 로그인 사용자의 프로필 사진(`2097:16391` 「프로필 - 이미지」). 없으면 이니셜.
Container(
width: 26,
height: 26,
alignment: Alignment.center,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
color: const Color(0xFF9BF8A8),
boxShadow: const [
BoxShadow(color: Color(0xB3000000), blurRadius: 4),
],
image: growth.photoUrl == null
? null
: DecorationImage(
image: NetworkImage(growth.photoUrl!),
fit: BoxFit.cover,
onError: (_, _) {},
),
),
child: growth.photoUrl != null
? null
: Text(
growth.contributorName.characters.firstOrNull ?? '—',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
),
const SizedBox(width: 9),
Text(
growth.contributorName,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 12,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
),
const SizedBox(width: 2),
Text(
context.l10n.heroOwnerSuffix,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmicVariant,
),
),
],
),
const SizedBox(height: 3),
],
Text(
// 사본 문구의 「팬케이크」는 사용자 노출 브랜드라 「팬우주」로 바꾼다(공통 기준 4항).
!joined
? context.l10n.heroStartPrompt
: growth.isNebula
? context.l10n.heroNebulaPrompt
: growth.hasRecentContribution
? context.l10n.heroRecentContribution(
formatPop(growth.contributedPop),
)
: context.l10n.heroGrowingTogether,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w500,
height: 1.4,
letterSpacing: -0.5,
color: AppColors.onCosmic,
),
),
],
static const _ownerAffix = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmicVariant,
);
@override
Widget build(BuildContext context) {
final owner = context.l10n.heroOwner('\u0000').split('\u0000');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 「{닉네임} 님의」는 로그인 사용자의 인사 줄이다(설명 프레임 `2097:16391`).
if (growth.contributorName.trim().isNotEmpty) ...[
Row(
children: [
// 로그인 사용자의 프로필 사진(`2097:16391` 「프로필 - 이미지」). 없으면 이니셜.
Container(
width: 26,
height: 26,
alignment: Alignment.center,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
shape: BoxShape.circle,
// 사본 `Ellipse 3` #9BF8A8(프로필 사진이 없을 때의 바탕).
color: const Color(0xFF9BF8A8),
boxShadow: const [
BoxShadow(color: Color(0xB3000000), blurRadius: 4),
],
image: growth.photoUrl == null
? null
: DecorationImage(
image: NetworkImage(growth.photoUrl!),
fit: BoxFit.cover,
onError: (_, _) {},
),
),
child: growth.photoUrl != null
? null
: Text(
growth.contributorName.characters.firstOrNull ?? '—',
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.black,
),
),
),
const SizedBox(width: 9),
// 「님의」는 언어마다 이름 앞(es 「De」) 또는 뒤(ko 「님의」 · ja 「さんの」)에 붙는다 — 문구
// 하나({name} 자리표시)를 이름 자리에서 갈라 앞뒤를 흐린 색으로 그린다(10/8 r11 es 오역).
if (owner.first.isNotEmpty) Text(owner.first, style: _ownerAffix),
Flexible(
child: Text(
growth.contributorName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.display,
fontSize: 12,
height: 1.3,
letterSpacing: -0.3,
color: AppColors.onCosmic,
),
),
),
if (owner.last.isNotEmpty) Text(owner.last, style: _ownerAffix),
],
),
const SizedBox(height: 3),
],
Text(
// 사본 문구의 「팬케이크」는 사용자 노출 브랜드라 「팬우주」로 바꾼다(공통 기준 4항).
!joined
? context.l10n.heroStartPrompt
: growth.isNebula
? context.l10n.heroNebulaPrompt
: growth.hasRecentContribution
? context.l10n.heroRecentContribution(
formatPop(growth.contributedPop),
)
: context.l10n.heroGrowingTogether,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
fontWeight: FontWeight.w500,
height: 1.4,
letterSpacing: -0.5,
color: AppColors.onCosmic,
),
),
],
);
}
}
/// 성장 카드 — Figma 「홈」 `Frame 1984081073`(343×151 · `#040304` 40% · r12 · 패딩 12/16 ·
@@ -425,8 +449,21 @@ class _GrowthOverlayCard extends StatelessWidget {
color: AppColors.onCosmic,
);
/// 말풍선 가운데의 최소 x — 「행성 성장률 100%」 줄(약 103)과 겹치지 않게 한다.
static const _bubbleMinCenter = 196.0;
/// 말풍선과 성장률 글자 사이 최소 간격.
static const _bubbleGap = 8.0;
static double _textWidth(
BuildContext context,
String text,
TextStyle style,
) =>
(TextPainter(
text: TextSpan(text: text, style: style),
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
maxLines: 1,
)..layout()).width.ceilToDouble() +
1;
String _value(num v) =>
v == v.roundToDouble() ? formatPop(v.toInt()) : v.toStringAsFixed(1);
@@ -464,6 +501,9 @@ class _GrowthOverlayCard extends StatelessWidget {
final low = growth.bottleneck;
final ratio = low?.ratio;
final percent = ratio == null ? '—' : '${(ratio * 100).round()}';
final rate = growth.isNebula
? context.l10n.heroNebulaGrowthRate(percent)
: context.l10n.heroGrowthRate(percent);
return ClipRRect(
borderRadius: BorderRadius.circular(AppRadius.md),
child: BackdropFilter(
@@ -520,12 +560,7 @@ class _GrowthOverlayCard extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
growth.isNebula
? context.l10n.heroNebulaGrowthRate(percent)
: context.l10n.heroGrowthRate(percent),
style: _white14,
),
Text(rate, style: _white14),
const SizedBox(height: AppSpace.s3),
_Bar(ratio: ratio ?? 0),
const SizedBox(height: AppSpace.s3),
@@ -541,25 +576,39 @@ class _GrowthOverlayCard extends StatelessWidget {
],
),
// 말풍선 — 사본 y566.5(카드 안 31.5) · 꼬리 끝이 바 위에 닿는다. 가운데는 채움 끝
// (사본 x238 = 채움 206)을 따르되 왼쪽 성장률 글자와 겹치지 않게 민다.
// (사본 x238 = 채움 206)을 따르되 왼쪽 성장률 글자와 겹치지 않게 민다. 두 글자 폭은
// 언어마다 달라 재서 정한다 — 남는 폭이 모자라면 말풍선 글자를 줄인다(10/8 r11 es).
// 성운 카드(`344:213809`)에는 말풍선이 없다.
if (!growth.isNebula)
Positioned(
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: Semantics(
button: true,
child: GestureDetector(
onTap: () => _details(context),
child: _SpeechBubble(text: growth.growthBubble),
Builder(
builder: (context) {
final left =
_textWidth(context, rate, _white14) + _bubbleGap;
final width = math.min(
_textWidth(
context,
growth.growthBubble,
_SpeechBubble.style,
) +
_SpeechBubble.padding * 2,
math.max(0.0, box.maxWidth - left),
);
return Positioned(
top: 31.5,
left: (box.maxWidth * (ratio ?? 0) - width / 2).clamp(
math.min(left, box.maxWidth - width),
box.maxWidth - width,
),
),
),
width: width,
child: Semantics(
button: true,
child: GestureDetector(
onTap: () => _details(context),
child: _SpeechBubble(text: growth.growthBubble),
),
),
);
},
),
],
),
@@ -606,13 +655,23 @@ class _SpeechBubble extends StatelessWidget {
static const _fill = Color(0xCC000000);
static const padding = 10.0;
static const style = TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
color: AppColors.onCosmic,
);
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
height: 30,
padding: const EdgeInsets.symmetric(horizontal: 10),
padding: const EdgeInsets.symmetric(horizontal: padding),
alignment: Alignment.center,
decoration: BoxDecoration(
color: _fill,
@@ -620,13 +679,9 @@ class _SpeechBubble extends StatelessWidget {
),
child: Text(
text,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1,
color: AppColors.onCosmic,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style,
),
),
CustomPaint(
@@ -1049,7 +1104,6 @@ class HomeZap extends StatelessWidget {
);
}
/// 행성 터치 반응 — Figma `2024:20758` 「행성 1회 터치시 흔들림 1번」 · UX·FX v1.5 §3 Light Haptic.
/// 흔들림이 끝나면 [onTap](커뮤니티 이동)을 부른다. [onTap] 이 없으면 아무 반응도 하지 않는다.
class _TapShake extends StatefulWidget {
@@ -1062,8 +1116,12 @@ class _TapShake extends StatefulWidget {
State<_TapShake> createState() => _TapShakeState();
}
class _TapShakeState extends State<_TapShake> with SingleTickerProviderStateMixin {
late final _shake = AnimationController(vsync: this, duration: const Duration(milliseconds: 320));
class _TapShakeState extends State<_TapShake>
with SingleTickerProviderStateMixin {
late final _shake = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 320),
);
@override
void dispose() {
@@ -1087,7 +1145,10 @@ class _TapShakeState extends State<_TapShake> with SingleTickerProviderStateMixi
animation: _shake,
// 한 번 좌우로 흔들리고 제자리 — 진폭 6 · 감쇠.
builder: (context, child) => Transform.translate(
offset: Offset(math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value), 0),
offset: Offset(
math.sin(_shake.value * math.pi * 4) * 6 * (1 - _shake.value),
0,
),
child: child,
),
child: widget.child,
@@ -37,16 +37,22 @@ class HomeWeeklyCard extends StatelessWidget {
final l10n = context.l10n;
final w = weekly;
final change = w.rankChange;
// 라벨 2 : 값 3 으로 나눈다 — 값이 폭을 다 차지하면 긴 언어(es)에서 라벨이 0 폭이 되어
// 한 글자씩 줄바꿈된다(10/8 r11 「Mis POP」). 넘치면 둘 다 단어 단위로 줄바꿈한다.
Widget row(String label, Widget value) => Padding(
padding: const EdgeInsets.symmetric(vertical: AppSpace.s2),
child: Row(
children: [
Expanded(child: Text(label, style: _label)),
value,
Expanded(flex: 2, child: Text(label, style: _label)),
const SizedBox(width: AppSpace.s2),
Flexible(
flex: 3,
child: Align(alignment: Alignment.centerRight, child: value),
),
],
),
);
Text text(String s) => Text(s, style: _value);
Text text(String s) => Text(s, style: _value, textAlign: TextAlign.end);
return Container(
padding: const EdgeInsets.symmetric(