끝난 이클립스 합류 문구 · HOME 헤더 POP · 다국어 HOME 레이아웃 · 캠페인 표시 수정
This commit is contained in:
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/ui/app_dialog.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
@@ -23,6 +22,7 @@ import 'widgets/campaign_rank.dart';
|
||||
import 'widgets/campaign_share_dialog.dart';
|
||||
import 'widgets/campaign_style.dart';
|
||||
import '../../core/ui/fx_intensity.dart';
|
||||
import '../../core/ui/clipboard_copy.dart';
|
||||
|
||||
/// 캠페인 상세 — 화면 대조 원장 F5 · Figma `272:245738`.
|
||||
///
|
||||
@@ -448,6 +448,10 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
|
||||
),
|
||||
)
|
||||
: null)
|
||||
// 모금 중이 아니면(달성 · 미달 환불 · 결과보고 · 취소) 기여할 수 없다 — Figma 는 종료 상태의
|
||||
// 버튼을 정하지 않아(`523:18589` 「타인 캠페인에서만 표시」) 숨긴다(10/8 r11).
|
||||
: card.status != CampaignStatus.funding
|
||||
? null
|
||||
: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 8),
|
||||
@@ -1070,8 +1074,9 @@ class _CampaignDetailScreenState extends State<CampaignDetailScreen> {
|
||||
}
|
||||
|
||||
Future<void> _copyText(CampaignCardData card) async {
|
||||
await Clipboard.setData(ClipboardData(text: _shareBody(card)));
|
||||
if (mounted) _toast(context.l10n.shareCopied);
|
||||
await copyToClipboard(_shareBody(card), () {
|
||||
if (mounted) _toast(context.l10n.shareCopied);
|
||||
});
|
||||
}
|
||||
|
||||
/// 캠페인 신고(`reports/campaign-{캠페인}-{신고자}`) · 개설자 차단.
|
||||
|
||||
@@ -21,6 +21,23 @@ String liveEventMessage(LiveEvent event) => switch (event.kind) {
|
||||
LiveEventKind.notice => '[LIVE] ${event.nickname}',
|
||||
};
|
||||
|
||||
/// 띠·섬광 배너에 올려도 되는 라이브 이벤트. 이클립스 방어 합류는 **그 회차가 진행 중일 때만** 싣는다 —
|
||||
/// 끝난 회차의 합류 문구가 서버 TTL(1시간)까지 띠에 남던 결함(10/8 r11 2-5). [eclipse] 는 지금 진행 중인
|
||||
/// 회차(`watch` 는 마감되면 null 을 낸다)이고, 회차 발생 전의 합류는 지난 회차의 것이다.
|
||||
List<LiveEvent> liveEventsFor(
|
||||
List<LiveEvent> events,
|
||||
EclipseState? eclipse,
|
||||
DateTime now,
|
||||
) => [
|
||||
for (final e in events)
|
||||
if (e.kind != LiveEventKind.eclipseDefense ||
|
||||
(eclipse != null &&
|
||||
eclipse.result == null &&
|
||||
!eclipse.isExpired(now) &&
|
||||
!(eclipse.startAt?.isAfter(e.at) ?? false)))
|
||||
e,
|
||||
];
|
||||
|
||||
/// 홈 라이브 띠(전광판)에 올릴 한 줄. [hero] 면 띠가 HERO TIME 참여 진입이 된다.
|
||||
/// [push] 는 같은 사건을 알리는 Push 의 `kind` — 띠가 떠 있으면 앱 안 Push 를 겹쳐 띄우지 않는다(v1.5 §1.1).
|
||||
typedef TickerLine = ({String text, bool hero, String? push});
|
||||
@@ -46,7 +63,8 @@ TickerLine? tickerLine({
|
||||
required DateTime now,
|
||||
String? fallback,
|
||||
}) {
|
||||
final event = events.isEmpty ? null : events.first;
|
||||
final live = liveEventsFor(events, eclipse, now);
|
||||
final event = live.isEmpty ? null : live.first;
|
||||
if (eclipse != null && eclipse.result == null) {
|
||||
final started = eclipse.startAt;
|
||||
if (event == null || started == null || event.at.isBefore(started)) {
|
||||
|
||||
@@ -509,6 +509,7 @@ class _Map extends StatelessWidget {
|
||||
child: _LiveFlash(
|
||||
repository: eclipseRepository,
|
||||
fandomId: eclipse?.fandomId ?? data.mine?.id,
|
||||
eclipse: eclipse,
|
||||
fallback: data.event,
|
||||
),
|
||||
),
|
||||
@@ -1050,12 +1051,16 @@ class _LiveFlash extends StatelessWidget {
|
||||
const _LiveFlash({
|
||||
required this.repository,
|
||||
required this.fandomId,
|
||||
required this.eclipse,
|
||||
required this.fallback,
|
||||
});
|
||||
|
||||
final EclipseRepository repository;
|
||||
final String? fandomId;
|
||||
|
||||
/// 진행 중인 회차. 끝난 회차의 방어 합류는 흘리지 않는다([liveEventsFor]).
|
||||
final EclipseState? eclipse;
|
||||
|
||||
/// 라이브 이벤트가 없을 때 그릴 기존 `galaxyEvents` 한 건.
|
||||
final GalaxyEvent? fallback;
|
||||
|
||||
@@ -1073,8 +1078,9 @@ class _LiveFlash extends StatelessWidget {
|
||||
return LiveStreamBuilder<List<LiveEvent>>(
|
||||
key: ValueKey('flash-$fandomId'),
|
||||
create: () => repository.liveEvents(fandomId: fandomId, limit: 5),
|
||||
builder: (context, events) {
|
||||
if (events == null || events.isEmpty) {
|
||||
builder: (context, all) {
|
||||
final events = liveEventsFor(all ?? const [], eclipse, DateTime.now());
|
||||
if (events.isEmpty) {
|
||||
return fallback == null
|
||||
? const SizedBox.shrink()
|
||||
: _EventTicker(
|
||||
|
||||
@@ -20,8 +20,13 @@ class ZapFeedback extends StatefulWidget {
|
||||
required this.radius,
|
||||
this.fandomName,
|
||||
this.quest = false,
|
||||
this.messageAboveTarget = false,
|
||||
});
|
||||
|
||||
/// 안내 띠를 [target] 행성 상자 위쪽에 둔다(HOME). 기본 자리(아래 128)는 HOME 에서 행성 이름 ·
|
||||
/// 성장 카드 말풍선을 덮는다(10/8 r11). 행성 상자 반 변 = [radius] / 0.4(HOME 이 넘기는 값).
|
||||
final bool messageAboveTarget;
|
||||
|
||||
/// 퀘스트 보상 확정의 행성 반응인가 — 결과 문구를 퀘스트 반영으로 바꾼다.
|
||||
final bool quest;
|
||||
|
||||
@@ -101,7 +106,12 @@ class _ZapFeedbackState extends State<ZapFeedback>
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 128,
|
||||
top: widget.messageAboveTarget && widget.target != Offset.zero
|
||||
? math.max(0, widget.target.dy - widget.radius / 0.4 / 2)
|
||||
: null,
|
||||
bottom: widget.messageAboveTarget && widget.target != Offset.zero
|
||||
? null
|
||||
: 128,
|
||||
child: Center(
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -126,7 +126,7 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
userExp: user.intOr('userExp'),
|
||||
userLevel: user.intOr('userLevel').clamp(1, 1 << 31),
|
||||
passportTier: primaryTier,
|
||||
balancePop: wallet.intOr('activityBalance') + wallet.intOr('supportBalance') + wallet.intOr('pendingActivity'),
|
||||
balancePop: wallet.walletTotal,
|
||||
reservedPop: wallet.intOr('pendingActivity'),
|
||||
steps: user.intOr('steps'),
|
||||
completedQuests: await completedF,
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import '../../../core/format.dart';
|
||||
@@ -8,6 +7,7 @@ import '../../../core/ui/app_icons.dart';
|
||||
import '../my_data.dart';
|
||||
import 'my_cards.dart';
|
||||
import '../../../core/i18n/app_locale.dart';
|
||||
import '../../../core/ui/clipboard_copy.dart';
|
||||
import '../../campaign/campaign_share.dart' show shareHashtags;
|
||||
|
||||
/// 프로필 카드 오버레이 — 시안 `374:15738`(명세 `549:22058`).
|
||||
@@ -123,7 +123,8 @@ String profileShareText(MyProfile profile, [AppLocalizations? l10n]) => [
|
||||
].join('\n');
|
||||
|
||||
/// 프로필 카드 공유. 앱별 전용 SDK 없이 OS 공유 시트로 보내고(사용자가 그 안에서
|
||||
/// 인스타 · 틱톡 · X 를 고른다), 마지막 칸은 문구를 복사한다. 복사했으면 true.
|
||||
/// 인스타 · 틱톡 · X 를 고른다), 마지막 칸은 문구를 복사한다. 복사했고 앱이 알려야 하면 true
|
||||
/// (Android 13+ 는 시스템이 알린다 — [copyToClipboard]).
|
||||
Future<bool> shareProfile(
|
||||
MyProfile profile,
|
||||
String target, {
|
||||
@@ -131,8 +132,9 @@ Future<bool> shareProfile(
|
||||
}) async {
|
||||
final text = profileShareText(profile);
|
||||
if (target == ProfileCard.shareTargets.last) {
|
||||
await Clipboard.setData(ClipboardData(text: text));
|
||||
return true;
|
||||
var announce = false;
|
||||
await copyToClipboard(text, () => announce = true);
|
||||
return announce;
|
||||
}
|
||||
await (share ?? SharePlus.instance.share)(ShareParams(text: text));
|
||||
return false;
|
||||
|
||||
@@ -100,7 +100,10 @@ class AppNotification {
|
||||
/// 없으면 종류 이름([NotificationKind.label])으로 물러난다.
|
||||
final String? source;
|
||||
|
||||
String get sourceLabel => source ?? kind.label;
|
||||
String get sourceLabel =>
|
||||
source ??
|
||||
(kind == NotificationKind.campaign ? campaignNotificationLabel(id) : null) ??
|
||||
kind.label;
|
||||
|
||||
/// 눌렀을 때 열 대상 문서 경로(`quests/{id}` · `campaigns/{id}` 등). 서버가 싣는다.
|
||||
/// 명세가 *이동 X* 로 정한 종류(이클립스 · 아이템)는 경로가 있어도 쓰지 않는다.
|
||||
@@ -125,6 +128,17 @@ class AppNotification {
|
||||
);
|
||||
}
|
||||
|
||||
/// 캠페인 알림의 세부 종류 이름. 서버(`campaign/settle.ts` `notifyOnce`)는 `eventType` 을 모두
|
||||
/// `campaign` 으로 싣고 문서 ID 앞머리로 가른다 — `campaignSuccess_` · `campaignRefund_`(목표 미달 ·
|
||||
/// 취소 환불) · `campaignCancel_`(개설자 등록비 반환) · `campaignReport_`. 모르는 앞머리는 null
|
||||
/// (종류 이름 「캠페인 달성」으로 물러난다).
|
||||
String? campaignNotificationLabel(String id) => switch (id.split('_').first) {
|
||||
'campaignRefund' => appL10n.notifKindCampaignRefund,
|
||||
'campaignCancel' => appL10n.notifKindCampaignCancel,
|
||||
'campaignReport' => appL10n.notifKindCampaignReport,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// 날짜 머리글. 명세 — *오늘 및 어제를 제외한 과거는 yyyy.mm.dd 로 표시*.
|
||||
String notificationDayLabel(DateTime at, {DateTime? now}) {
|
||||
final today = _dateOnly(now ?? DateTime.now());
|
||||
|
||||
@@ -740,7 +740,7 @@ abstract interface class QuestRepository {
|
||||
|
||||
Future<String> create(QuestDraft draft);
|
||||
|
||||
/// 보유 일반 POP(`wallet/main.activityBalance`) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다.
|
||||
/// 총 보유 POP(`wallet/main` 일반 + 기여 + 보류, HOME 헤더와 같은 값) — 생성 · 보상 뒤 목록 헤더를 다시 맞춘다.
|
||||
/// 모르면 null(헤더는 받은 값을 유지한다).
|
||||
Future<int?> popBalance();
|
||||
|
||||
|
||||
@@ -649,7 +649,7 @@ class FirestoreQuestRepository implements QuestRepository {
|
||||
@override
|
||||
Future<int?> popBalance() async {
|
||||
final wallet = await _backend.db.doc('users/${_backend.requireUid}/wallet/main').get();
|
||||
return wallet.data()?.intOr('activityBalance');
|
||||
return wallet.data()?.walletTotal;
|
||||
}
|
||||
|
||||
@override
|
||||
|
||||
Reference in New Issue
Block a user