From 99b9bb86653f2f170762336e8f52a7d9b4c6bf6f Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 04:12:13 +0900 Subject: [PATCH] =?UTF-8?q?=EC=84=B1=EC=9A=B4=EB=AA=85=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EB=AC=B8=EA=B5=AC=20=EC=A4=84=EB=B0=94=EA=BF=88=20?= =?UTF-8?q?=C2=B7=20=EC=98=9B=20=ED=96=89=EC=84=B1=20=EA=B7=B8=EB=A6=BC=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20=C2=B7=20=EA=B4=91=EA=B3=A0=20=EC=95=88?= =?UTF-8?q?=EB=82=B4=20=EB=AA=A8=EB=8B=AC=20=EC=A4=84=EB=B0=94=EA=BF=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/core/ui/app_dialog.dart | 8 +++++ app/lib/core/ui/live_planet.dart | 29 ++++++++++++++++++ app/lib/core/ui/planet_25d.dart | 10 +++++++ .../features/galaxy/galaxy_home_screen.dart | 11 +++---- .../features/galaxy/galaxy_search_screen.dart | 6 ++-- .../features/galaxy/widgets/galaxy_field.dart | 13 +++----- .../features/home/nebula_create_screen.dart | 6 ++-- app/lib/features/home/widgets/home_hero.dart | 5 ++-- app/lib/features/my/tier_screens.dart | 4 +-- .../features/planet/fpi_ranking_screen.dart | 4 +-- .../features/planet/planet_detail_screen.dart | 13 +++++--- .../planet/widgets/planet_detail_hero.dart | 5 +++- app/lib/features/quest/quest_ad_screens.dart | 4 ++- app/test/keep_words_test.dart | 30 +++++++++++++++++++ 14 files changed, 114 insertions(+), 34 deletions(-) create mode 100644 app/test/keep_words_test.dart diff --git a/app/lib/core/ui/app_dialog.dart b/app/lib/core/ui/app_dialog.dart index b4d311c..c213801 100644 --- a/app/lib/core/ui/app_dialog.dart +++ b/app/lib/core/ui/app_dialog.dart @@ -3,6 +3,14 @@ import 'package:flutter/material.dart'; import '../i18n/app_locale.dart'; import '../theme/tokens.dart'; +/// 한국어 어절 단위 줄바꿈 — 글자 사이에 WORD JOINER(U+2060)를 넣어 공백에서만 줄을 바꾼다. +/// Flutter 는 한글을 음절마다 끊어 「얻으세 / 요.」처럼 낱말 중간에서 줄을 바꾼다(10/6 실기기 D-4). +/// 한 어절이 한 줄보다 길면 그 어절만 글자 단위로 끊긴다(엔진 기본). +String keepWords(String text) => text.splitMapJoin( + RegExp(r'\s+'), + onNonMatch: (word) => word.characters.join('\u2060'), +); + /// 앱 공통 확인 창 — Figma `2066:17799`(행성 생성 확인) · `554:24669`(설정 확인 팝업). /// /// 카드 312 폭 · #282828 r12 · 안쪽 24/16 · 문구 16/600 가운데 정렬, diff --git a/app/lib/core/ui/live_planet.dart b/app/lib/core/ui/live_planet.dart index 0e773a3..9a404d5 100644 --- a/app/lib/core/ui/live_planet.dart +++ b/app/lib/core/ui/live_planet.dart @@ -54,6 +54,35 @@ class LivePlanet25D extends StatelessWidget { ); } +/// 정지 그림의 실시간판 — 지켜보는 행성의 형태 · 색, 없으면 [planetFallbackImage]. +/// 히어로([LivePlanet25D])와 같은 원천이라 접힌 앱 바의 작은 그림이 히어로와 갈리지 않는다. +class LivePlanetArt extends StatelessWidget { + const LivePlanetArt({ + super.key, + required this.fandomId, + required this.watch, + required this.size, + this.nebula = false, + }); + final String fandomId; + final PlanetWatch watch; + final double size; + final bool nebula; + + @override + Widget build(BuildContext context) => LiveStreamBuilder( + key: ValueKey(('planet-art', fandomId)), + create: () => watch(fandomId), + builder: (context, state) => PlanetArt( + fallback: planetFallbackImage(fandomId, nebula: nebula), + planetType: nebula ? null : state?.planetType, + colorKey: state?.colorKey, + width: size, + height: size, + ), + ); +} + /// 배경 광량만 바꿔 콘텐츠의 가독성과 화면 구조는 유지한다. class LivePlanetLighting extends StatelessWidget { const LivePlanetLighting({ diff --git a/app/lib/core/ui/planet_25d.dart b/app/lib/core/ui/planet_25d.dart index 5b99805..5870775 100644 --- a/app/lib/core/ui/planet_25d.dart +++ b/app/lib/core/ui/planet_25d.dart @@ -71,6 +71,16 @@ String? planetShapeImage(String? planetType) { return n == null ? null : 'assets/figma/planet-shape-$n.png'; } +/// 형태(`planetType`)가 없는 행성 · 성운의 그림 — **앱 전체가 이 한 함수로 고른다.** 화면마다 +/// 따로 고르면 같은 옛 행성이 내 행성 격자에서는 파란 행성, 검색 · 상세에서는 보라 행성으로 +/// 달라진다(10/6 실기기 C-5). 성운은 성운 그림, 행성은 시안 행성 그림 두 장 중 id 로 하나. +// ponytail: 형태 없는 옛 데이터용 고정 두 그림. 모든 행성에 형태가 생기면 지운다. +String planetFallbackImage(String id, {bool nebula = false}) => nebula + ? 'assets/figma/nebula-card.png' + : id.codeUnits.fold(0, (a, c) => a + c).isEven + ? 'assets/figma/planet-detail-hero.png' + : 'assets/figma/planet-earth.png'; + /// 정지 그림 — 형태 · 색을 입힌 행성(목록 카드 · MY). 형태가 없으면 [fallback] 그림 그대로다. /// 색은 [Planet25D] 와 같은 곱하기 합성이다. class PlanetArt extends StatelessWidget { diff --git a/app/lib/features/galaxy/galaxy_home_screen.dart b/app/lib/features/galaxy/galaxy_home_screen.dart index c08a330..2194489 100644 --- a/app/lib/features/galaxy/galaxy_home_screen.dart +++ b/app/lib/features/galaxy/galaxy_home_screen.dart @@ -4,7 +4,7 @@ import '../../core/data/planet_visual_state.dart'; import '../../core/theme/tokens.dart'; import '../../core/ui/app_dialog.dart'; import '../../core/ui/app_icons.dart'; -import '../../core/ui/planet_25d.dart' show PlanetArt; +import '../../core/ui/planet_25d.dart' show PlanetArt, planetFallbackImage; import '../notification/notification_screen.dart'; import '../eclipse/eclipse_data.dart'; import '../eclipse/widgets/eclipse_banner.dart'; @@ -31,13 +31,10 @@ class GalaxyHomeScreen extends StatefulWidget { /// 카드 그림 — HOME · 맵과 같이 팬덤 `stage` 로 고른다. Stage0 성운만 성운 그림이고 /// 정식 행성은 시안 행성 그림(`image 8` · 파란 행성 벡터)을 칸마다 번갈아 쓴다. /// 예전엔 칸 순서로 골라 둘째 칸이 행성이어도 성운 그림이 됐다. - // ponytail: 형태가 없는 행성(옛 데이터)만 고정된 두 그림 중 하나(id 기준). 형태가 있으면 + // 형태가 없는 행성(옛 데이터)만 [planetFallbackImage]. 형태가 있으면 // 카드가 [PlanetArt] 로 생성 때 고른 형태 · 색을 그린다. - static String cardImageOf(PlanetNode p, [int _ = 0]) => p.stage == 0 - ? 'assets/figma/nebula-card.png' - : (p.id.codeUnits.fold(0, (a, c) => a + c).isEven - ? 'assets/figma/planet-detail-hero.png' - : 'assets/figma/planet-earth.png'); + static String cardImageOf(PlanetNode p, [int _ = 0]) => + planetFallbackImage(p.id, nebula: p.stage == 0); const GalaxyHomeScreen({ super.key, diff --git a/app/lib/features/galaxy/galaxy_search_screen.dart b/app/lib/features/galaxy/galaxy_search_screen.dart index fdd548c..dbaf660 100644 --- a/app/lib/features/galaxy/galaxy_search_screen.dart +++ b/app/lib/features/galaxy/galaxy_search_screen.dart @@ -5,7 +5,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../core/theme/tokens.dart'; import '../../core/data/backend.dart' show BackendException; -import '../../core/ui/planet_25d.dart' show PlanetArt; +import '../../core/ui/planet_25d.dart' show PlanetArt, planetFallbackImage; import '../../core/ui/play_banner.dart'; import '../planet/widgets/fandom_size_badge.dart'; import 'galaxy_data.dart'; @@ -546,9 +546,7 @@ class _Hit extends StatelessWidget { SizedBox.square( dimension: 70, child: PlanetArt( - fallback: nebula - ? 'assets/figma/nebula-card.png' - : 'assets/figma/planet-detail-hero.png', + fallback: planetFallbackImage(planet.id, nebula: nebula), planetType: nebula ? null : planet.planetType, colorKey: planet.colorKey, semanticLabel: planet.name, diff --git a/app/lib/features/galaxy/widgets/galaxy_field.dart b/app/lib/features/galaxy/widgets/galaxy_field.dart index 4f5ce8d..57bba9b 100644 --- a/app/lib/features/galaxy/widgets/galaxy_field.dart +++ b/app/lib/features/galaxy/widgets/galaxy_field.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/live_planet.dart'; -import '../../../core/ui/planet_25d.dart' show planetShapeImage; +import '../../../core/ui/planet_25d.dart' show planetFallbackImage, planetShapeImage; import '../../../core/data/planet_visual_state.dart'; import '../galaxy_data.dart'; import '../galaxy_motion.dart'; @@ -134,15 +134,10 @@ class GalaxyField extends StatelessWidget { return radiusOf(p) * depthScaleOf(at); } - /// 행성 일러스트 — 생성 때 고른 형태([planetShapeImage]). 형태가 없는 행성(성운 · 옛 데이터)만 - /// 내 행성은 홈과 같은 그림, 나머지는 시안 행성 2종을 돌려 쓴다. 색은 [LivePlanet25D] 가 입힌다. + /// 행성 일러스트 — 생성 때 고른 형태([planetShapeImage]). 형태가 없는 행성(성운 · 옛 데이터)은 + /// 다른 화면과 같은 [planetFallbackImage](10/6 C-5). 색은 [LivePlanet25D] 가 입힌다. static String imageOf(PlanetNode p, int index) => - planetShapeImage(p.planetType) ?? - (p.isMine - ? 'assets/figma/planet-home.png' - : (index.isEven - ? 'assets/figma/planet-1.png' - : 'assets/figma/planet-2.png')); + planetShapeImage(p.planetType) ?? planetFallbackImage(p.id); @override Widget build(BuildContext context) { diff --git a/app/lib/features/home/nebula_create_screen.dart b/app/lib/features/home/nebula_create_screen.dart index b44d719..c547788 100644 --- a/app/lib/features/home/nebula_create_screen.dart +++ b/app/lib/features/home/nebula_create_screen.dart @@ -137,8 +137,10 @@ class _NebulaCreateScreenState extends State { const SizedBox(height: AppSpace.s5), _NameInput(controller: _name, error: _taken || _invalid), const SizedBox(height: 6), - SizedBox( - height: 17, + // 한 줄 자리(17)는 비어 있어도 잡아 두되, 두 줄 문구는 잘리지 않게 늘어난다 + // (10/6 실기기 「…연속/앞뒤 공」 잘림). + ConstrainedBox( + constraints: const BoxConstraints(minHeight: 17), child: _taken || _invalid ? Text( _taken diff --git a/app/lib/features/home/widgets/home_hero.dart b/app/lib/features/home/widgets/home_hero.dart index f7c9338..ea90a15 100644 --- a/app/lib/features/home/widgets/home_hero.dart +++ b/app/lib/features/home/widgets/home_hero.dart @@ -16,6 +16,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/app_icons.dart'; import '../../../core/ui/live_planet.dart'; +import '../../../core/ui/planet_25d.dart' show planetFallbackImage; import '../../../core/data/planet_visual_state.dart'; import '../home_data.dart'; import '../home_screen.dart' show formatPop; @@ -220,8 +221,8 @@ class _HomeHeroState extends State { fandomId: fandomId, watch: watchPlanet, stage: growth.stage, - image: - 'assets/figma/planet-home.png', + // 형태 없는 옛 행성은 다른 화면과 같은 그림(10/6 C-5). + image: planetFallbackImage(fandomId), size: math.min( planet, box.maxHeight, diff --git a/app/lib/features/my/tier_screens.dart b/app/lib/features/my/tier_screens.dart index a427779..68ee06a 100644 --- a/app/lib/features/my/tier_screens.dart +++ b/app/lib/features/my/tier_screens.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/tokens.dart'; -import '../../core/ui/planet_25d.dart' show PlanetArt; +import '../../core/ui/planet_25d.dart' show PlanetArt, planetFallbackImage; import '../home/home_screen.dart' show formatPop; import '../planet/planet_data.dart' show PlanetRepositoryFactory; import '../planet/planet_history_screen.dart'; @@ -214,7 +214,7 @@ class _PlanetCard extends StatelessWidget { child: Column( children: [ PlanetArt( - fallback: 'assets/figma/my-planet-1.png', + fallback: planetFallbackImage(planet.fandomId), planetType: planet.planetType, colorKey: planet.colorKey, width: 60, diff --git a/app/lib/features/planet/fpi_ranking_screen.dart b/app/lib/features/planet/fpi_ranking_screen.dart index ef9f85f..4ae714e 100644 --- a/app/lib/features/planet/fpi_ranking_screen.dart +++ b/app/lib/features/planet/fpi_ranking_screen.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import '../../core/theme/tokens.dart'; -import '../../core/ui/planet_25d.dart' show PlanetArt; +import '../../core/ui/planet_25d.dart' show PlanetArt, planetFallbackImage; import 'planet_data.dart'; import 'widgets/action_sheet.dart'; import '../../core/i18n/app_locale.dart'; @@ -412,7 +412,7 @@ Widget _avatar(int index, double size, {bool planet = false, FpiRankRow? row}) = ? SizedBox.square( dimension: size, child: PlanetArt( - fallback: 'assets/figma/planet-detail-hero.png', + fallback: planetFallbackImage(row?.id ?? ''), planetType: row?.planetType, colorKey: row?.colorKey, ), diff --git a/app/lib/features/planet/planet_detail_screen.dart b/app/lib/features/planet/planet_detail_screen.dart index d8bc157..8b82235 100644 --- a/app/lib/features/planet/planet_detail_screen.dart +++ b/app/lib/features/planet/planet_detail_screen.dart @@ -7,6 +7,7 @@ import '../campaign/widgets/campaign_rank.dart' show RankPodium; import '../../core/data/backend.dart' show BackendException; import '../../core/data/planet_visual_state.dart'; +import '../../core/ui/live_planet.dart' show LivePlanetArt; import '../../core/theme/tokens.dart'; import '../../core/ui/app_dialog.dart'; @@ -158,6 +159,7 @@ class _PlanetDetailScreenState extends State { ], flexibleSpace: _CollapsingHero( detail: d, + watchPlanet: widget.watchPlanet, min: top + kToolbarHeight + _tabHeight, hero: PlanetDetailHero( fandomId: d.fandomId, @@ -322,9 +324,11 @@ class _CollapsingHero extends StatelessWidget { required this.detail, required this.min, required this.hero, + required this.watchPlanet, }); final PlanetDetail detail; + final PlanetWatch watchPlanet; final double min; final Widget hero; @@ -356,10 +360,11 @@ class _CollapsingHero extends StatelessWidget { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - Image.asset( - 'assets/figma/planet-detail-hero.png', - width: 28, - height: 28, + LivePlanetArt( + fandomId: detail.fandomId, + watch: watchPlanet, + size: 28, + nebula: detail.isNebula, ), const SizedBox(width: AppSpace.s2), Flexible( diff --git a/app/lib/features/planet/widgets/planet_detail_hero.dart b/app/lib/features/planet/widgets/planet_detail_hero.dart index b73a89a..469e6ea 100644 --- a/app/lib/features/planet/widgets/planet_detail_hero.dart +++ b/app/lib/features/planet/widgets/planet_detail_hero.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import '../../../core/theme/tokens.dart'; import '../../../core/ui/live_planet.dart'; +import '../../../core/ui/planet_25d.dart' show planetFallbackImage; import '../../../core/data/planet_visual_state.dart'; import '../../home/home_screen.dart' show formatPop; import '../../../core/i18n/app_locale.dart'; @@ -155,7 +156,9 @@ class PlanetDetailHero extends StatelessWidget { onOpenHistory: onOpenHistory, fandomId: fandomId, watch: watchPlanet, - image: 'assets/figma/planet-detail-hero.png', + image: nebula + ? 'assets/figma/planet-detail-hero.png' + : planetFallbackImage(fandomId), size: 200, glow: AppColors.neonViolet, stage: stage, diff --git a/app/lib/features/quest/quest_ad_screens.dart b/app/lib/features/quest/quest_ad_screens.dart index 8d081c3..5c9ec40 100644 --- a/app/lib/features/quest/quest_ad_screens.dart +++ b/app/lib/features/quest/quest_ad_screens.dart @@ -8,6 +8,7 @@ import '../../core/monetization/store.dart'; import '../home/home_screen.dart' show formatPop; import '../../core/theme/tokens.dart'; +import '../../core/ui/app_dialog.dart' show keepWords; import '../../core/ui/app_icons.dart'; import '../../core/i18n/app_locale.dart'; @@ -62,7 +63,8 @@ class _AdBonusDialog extends StatelessWidget { ), const SizedBox(height: 24), Text( - context.l10n.adMoreChance, + // 25pt 두 줄 제목 — 낱말 중간(「얻으세 / 요.」)이 아니라 어절에서 바꾼다. + keepWords(context.l10n.adMoreChance), textAlign: TextAlign.center, style: TextStyle( fontSize: 25, diff --git a/app/test/keep_words_test.dart b/app/test/keep_words_test.dart new file mode 100644 index 0000000..60c0dc9 --- /dev/null +++ b/app/test/keep_words_test.dart @@ -0,0 +1,30 @@ +import 'package:fancake/core/ui/app_dialog.dart' show keepWords; +import 'package:flutter/painting.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// 10/6 실기기 D-4 — 광고 안내 모달 제목이 「얻으세 / 요.」로 낱말 중간에서 바뀌었다. +void main() { + test('줄은 공백에서만 바뀐다', () { + const text = '더 많은 기여의 기회를 얻으세요.'; + final joined = keepWords(text); + expect(joined.replaceAll('⁠', ''), text); + // 여러 폭에서 줄 끝이 늘 공백 앞뒤다. + // 가장 긴 어절(「얻으세요.」)보다 넓은 폭만 — 그보다 좁으면 엔진이 어절 안을 끊는다. + for (var width = 160.0; width <= 400; width += 7) { + final painter = TextPainter( + text: TextSpan(text: joined, style: const TextStyle(fontSize: 25)), + textDirection: TextDirection.ltr, + )..layout(maxWidth: width); + for (final line in painter.computeLineMetrics().skip(1)) { + final start = painter + .getPositionForOffset(Offset(0, line.baseline - line.ascent + 1)) + .offset; + expect( + start == 0 || joined[start - 1] == ' ', + isTrue, + reason: 'width $width: 「${joined.substring(start).replaceAll('⁠', '')}」', + ); + } + } + }); +}