이클립스 사전 경고 연출 · 브리핑 출처·게시시각 · PASSPORT 역사적 사건 Stamp

This commit is contained in:
Sn-Kinos
2026-10-08 15:43:12 +09:00
parent 62b9637db9
commit 586039da0a
26 changed files with 1223 additions and 1013 deletions
+20
View File
@@ -69,6 +69,26 @@ fvm flutter build apk --release # build/app/outputs/flutter-apk/app-relea
fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab fvm flutter build appbundle --release # build/app/outputs/bundle/release/app-release.aab
``` ```
### Android Apple 로그인 — 빌드 옵션 두 개를 꼭 넣는다
Android 의 Apple 로그인은 iOS 와 달리 Apple 개발자 계정에 등록한 **서비스 ID** 와 **반환 주소**를
빌드 때 받아야 동작한다(`lib/features/auth/social_providers.dart` `appleWebAuthenticationOptionsFromEnvironment`).
둘 중 하나라도 비었거나 반환 주소가 `https://호스트` 꼴이 아니면 Apple 버튼이 비활성(「준비 중」)으로 남는다.
비밀값이 아니지만 환경별 값이라 저장소에 상수로 두지 않는다.
```bash
fvm flutter build apk --release \
--dart-define-from-file=config/kakao.env \
--dart-define=FANUZU_APPLE_SERVICE_ID=<서비스 ID> \
--dart-define=FANUZU_APPLE_REDIRECT_URI=<서비스 ID 에 등록한 Return URL>
```
- 값은 Apple 개발자 계정 「Identifiers → Services IDs」의 식별자와 그 「Sign in with Apple → Return URLs」 항목이다.
- 반환 주소가 Apple 응답을 앱으로 돌려보내야 한다 — `sign_in_with_apple` 패키지 규약대로
`intent://callback?<응답>#Intent;package=kr.co.fanuzu.app;scheme=signinwithapple;end` 로 넘기면
`AndroidManifest.xml` 의 `SignInWithAppleCallback`(scheme `signinwithapple`)이 받는다.
- 확인: 빌드한 APK 의 첫 화면에서 Apple 버튼이 눌리는지(비활성이면 옵션 누락).
## 서명 ## 서명
`android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다 `android/key.properties` 가 없으면 **릴리스 빌드가 의도적으로 실패**한다
@@ -6,6 +6,8 @@ class PlanetVisualState {
this.isEclipsed = false, this.isEclipsed = false,
this.eclipseFailed = false, this.eclipseFailed = false,
this.eclipseSuccessId, this.eclipseSuccessId,
this.eclipseWarnAt,
this.eclipseStartAt,
this.isHibernating = false, this.isHibernating = false,
this.isHeroTime = false, this.isHeroTime = false,
this.heroEventIds, this.heroEventIds,
@@ -51,6 +53,10 @@ class PlanetVisualState {
/// 서버 판정 성공 회차 ID — 바뀌는 순간 Orbit Burst 한 번(v1.5 §5 성공). /// 서버 판정 성공 회차 ID — 바뀌는 순간 Orbit Burst 한 번(v1.5 §5 성공).
final String? eclipseSuccessId; final String? eclipseSuccessId;
/// 경보 단계(`scheduled`) 회차의 사전 경고 창 — v1.5 §5 「그림자 접근」. 그 밖에는 null.
final DateTime? eclipseWarnAt;
final DateTime? eclipseStartAt;
final bool isHibernating; final bool isHibernating;
final bool isHeroTime; final bool isHeroTime;
+17 -2
View File
@@ -158,6 +158,7 @@ class Repositories {
var isEclipsed = false; var isEclipsed = false;
var eclipseFailed = false; var eclipseFailed = false;
String? eclipseSuccessId; String? eclipseSuccessId;
(DateTime, DateTime)? eclipseWarning;
var isHeroTime = false; var isHeroTime = false;
Set<String>? heroEventIds; Set<String>? heroEventIds;
Timer? heroExpiry; Timer? heroExpiry;
@@ -178,6 +179,8 @@ class Repositories {
isEclipsed: isEclipsed, isEclipsed: isEclipsed,
eclipseFailed: eclipseFailed, eclipseFailed: eclipseFailed,
eclipseSuccessId: eclipseSuccessId, eclipseSuccessId: eclipseSuccessId,
eclipseWarnAt: eclipseWarning?.$1,
eclipseStartAt: eclipseWarning?.$2,
isHeroTime: isHeroTime, isHeroTime: isHeroTime,
heroEventIds: heroEventIds, heroEventIds: heroEventIds,
heroEndsAt: heroEndsAt, heroEndsAt: heroEndsAt,
@@ -196,8 +199,12 @@ class Repositories {
emit(); emit();
}, onError: out.addError); }, onError: out.addError);
// 판정 결과까지 받는다 — 활성 회차만 보면 마감 순간 null 이 되어 성공 · 실패를 가를 수 없다. // 판정 결과까지 받는다 — 활성 회차만 보면 마감 순간 null 이 되어 성공 · 실패를 가를 수 없다.
final danger = eclipse.watch(fandomId: fandomId, includeResults: true).listen((state) { // 경보 단계 회차도 받는다 — 사전 경고 「그림자 접근」(v1.5 §5).
final danger = eclipse
.watch(fandomId: fandomId, includeResults: true, includeScheduled: true)
.listen((state) {
(isEclipsed, eclipseFailed, eclipseSuccessId) = eclipseLook(state, DateTime.now()); (isEclipsed, eclipseFailed, eclipseSuccessId) = eclipseLook(state, DateTime.now());
eclipseWarning = eclipseWarnWindow(state);
emit(); emit();
}, onError: out.addError); }, onError: out.addError);
// 기존 선물 이벤트만 읽는다. 구매 시도/로컬 캐시는 축제 확정이 아니다. // 기존 선물 이벤트만 읽는다. 구매 시도/로컬 캐시는 축제 확정이 아니다.
@@ -272,7 +279,9 @@ class Repositories {
/// 최근 이클립스 회차 → 행성 표현 (진행 중 감소 · 실패 감소 · 성공 회차 ID). /// 최근 이클립스 회차 → 행성 표현 (진행 중 감소 · 실패 감소 · 성공 회차 ID).
/// 판정 대기(마감 후 결과 전)는 셋 다 아니다 — 오류를 실패처럼 보이지 않는다(v1.5 §5 집계 오류). /// 판정 대기(마감 후 결과 전)는 셋 다 아니다 — 오류를 실패처럼 보이지 않는다(v1.5 §5 집계 오류).
(bool, bool, String?) eclipseLook(EclipseState? state, DateTime now) { (bool, bool, String?) eclipseLook(EclipseState? state, DateTime now) {
if (state == null) return (false, false, null); // 경보 단계는 아직 이클립스가 아니다(그림자 접근은 [eclipseWarnWindow]). 다음 회차가 열렸으니
// 이전 회차의 실패 감소도 여기서 풀린다.
if (state == null || state.scheduled) return (false, false, null);
final won = state.defended || state.result == EclipseResult.success; final won = state.defended || state.result == EclipseResult.success;
return ( return (
state.result == null && !won && !state.isExpired(now), state.result == null && !won && !state.isExpired(now),
@@ -280,3 +289,9 @@ class Repositories {
won ? state.eclipseId : null, won ? state.eclipseId : null,
); );
} }
/// 경보 단계 회차의 (warnAt, startAt). 경보가 아니거나 시각이 없으면 null.
(DateTime, DateTime)? eclipseWarnWindow(EclipseState? state) => switch (state) {
EclipseState(scheduled: true, warnAt: final warn?, startAt: final start?) => (warn, start),
_ => null,
};
+2
View File
@@ -47,6 +47,8 @@ class LivePlanet25D extends StatelessWidget {
isEclipsed: state?.isEclipsed ?? false, isEclipsed: state?.isEclipsed ?? false,
eclipseFailed: state?.eclipseFailed ?? false, eclipseFailed: state?.eclipseFailed ?? false,
eclipseSuccessId: state?.eclipseSuccessId, eclipseSuccessId: state?.eclipseSuccessId,
eclipseWarnAt: state?.eclipseWarnAt,
eclipseStartAt: state?.eclipseStartAt,
isHibernating: state?.isHibernating ?? false, isHibernating: state?.isHibernating ?? false,
isHeroTime: state?.isHeroTime ?? false, isHeroTime: state?.isHeroTime ?? false,
heroEventIds: state?.heroEventIds, heroEventIds: state?.heroEventIds,
+60 -4
View File
@@ -137,6 +137,8 @@ class Planet25D extends StatefulWidget {
this.isEclipsed = false, this.isEclipsed = false,
this.eclipseFailed = false, this.eclipseFailed = false,
this.eclipseSuccessId, this.eclipseSuccessId,
this.eclipseWarnAt,
this.eclipseStartAt,
this.isHibernating = false, this.isHibernating = false,
this.isHeroTime = false, this.isHeroTime = false,
this.heroEventIds, this.heroEventIds,
@@ -170,6 +172,11 @@ class Planet25D extends StatefulWidget {
/// 이클립스 성공 회차. 바뀌면 광량 복원 뒤 짧은 Orbit Burst(v1.5 §5 「그림자 이탈 → 복원 → Orbit Burst」). /// 이클립스 성공 회차. 바뀌면 광량 복원 뒤 짧은 Orbit Burst(v1.5 §5 「그림자 이탈 → 복원 → Orbit Burst」).
final String? eclipseSuccessId; final String? eclipseSuccessId;
/// 사전 경고 창(서버 회차 `warnAt` → `startAt`, 원장 C-22 발생 3분 전). 이 사이에만
/// v1.5 §5 「행성 뒤 어두운 그림자 접근, 별빛·Glow 점진 감소」를 그린다([eclipseApproach]).
final DateTime? eclipseWarnAt;
final DateTime? eclipseStartAt;
final bool isHibernating; final bool isHibernating;
final bool isHeroTime; final bool isHeroTime;
final Set<String>? heroEventIds; final Set<String>? heroEventIds;
@@ -204,12 +211,12 @@ class _Planet25DState extends State<Planet25D>
double get _light => widget.isHibernating double get _light => widget.isHibernating
? 0.35 ? 0.35
: widget.isEclipsed : widget.isEclipsed
? 0.5 ? eclipseLight
: _celebrating : _celebrating
? 1.3 ? 1.3
: widget.eclipseFailed : widget.eclipseFailed
// 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목). // 실패의 감소 폭은 원문에 수치가 없어 이클립스 진행 중과 같은 값을 쓴다(해석 확인 항목).
? 0.5 ? eclipseLight
: 1; : 1;
late final AnimationController _c = AnimationController( late final AnimationController _c = AnimationController(
vsync: this, vsync: this,
@@ -376,6 +383,13 @@ class _Planet25DState extends State<Planet25D>
builder: (context, _) { builder: (context, _) {
final t = _motionEnabled ? _c.value : 0.0; // 0~1 final t = _motionEnabled ? _c.value : 0.0; // 0~1
final wave = math.sin(t * 2 * math.pi); // -1~1 final wave = math.sin(t * 2 * math.pi); // -1~1
// 사전 경고 — 매 프레임 서버 시각 기준으로 다시 잰다. 동작 줄이기면 프레임이 돌지 않아
// 그린 시점의 위치에 멈춰 있다(움직임 없이 상태만 전달).
final warn = widget.isEclipsed
? 0.0
: eclipseApproach(widget.eclipseWarnAt, widget.eclipseStartAt, DateTime.now()) ?? 0.0;
// 별빛(입자) · Glow 를 이클립스 진행 중 광량(0.5)까지 점진적으로 낮춘다.
final dim = 1 - (1 - eclipseLight) * warn;
return Stack( return Stack(
clipBehavior: Clip.none, clipBehavior: Clip.none,
alignment: Alignment.center, alignment: Alignment.center,
@@ -400,9 +414,10 @@ class _Planet25DState extends State<Planet25D>
? 0.55 ? 0.55
: 0.42) + : 0.42) +
wave * (stage == 6 ? 0.02 : 0.05)) * wave * (stage == 6 ? 0.02 : 0.05)) *
glowStrength, glowStrength *
dim,
), ),
glow.withValues(alpha: 0.10 * glowStrength), glow.withValues(alpha: 0.10 * glowStrength * dim),
glow.withValues(alpha: 0), glow.withValues(alpha: 0),
], ],
stops: const [0.45, 0.72, 1], stops: const [0.45, 0.72, 1],
@@ -423,6 +438,31 @@ class _Planet25DState extends State<Planet25D>
), ),
), ),
), ),
// 이클립스 사전 경고 — 행성 뒤로 어두운 그림자가 우상단에서 다가와 Glow 를 가린다.
if (warn > 0)
Positioned.fill(
child: IgnorePointer(
child: Transform.translate(
key: const ValueKey('eclipse-shadow'),
offset: Offset(s, -s * 0.5) * eclipseShadowTravel * (1 - warn),
child: Transform.scale(
scale: 1.15,
child: Opacity(
opacity: 0.85 * warn,
child: const DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [Color(0xFF05040D), Color(0xCC05040D), Color(0x0005040D)],
stops: [0, 0.6, 1],
),
),
),
),
),
),
),
),
// 6. 링 — 뒤쪽 절반. 본체 뒤에 깔린다. // 6. 링 — 뒤쪽 절반. 본체 뒤에 깔린다.
if (ring) if (ring)
Positioned.fill( Positioned.fill(
@@ -522,6 +562,7 @@ class _Planet25DState extends State<Planet25D>
count: count:
(particleCount * (particleCount *
light * light *
dim *
(_quality == 2 (_quality == 2
? 0.3 ? 0.3
: _quality == 1 : _quality == 1
@@ -883,6 +924,21 @@ class _SustainedAura extends CustomPainter {
old.color != color || old.lowEffects != lowEffects; old.color != color || old.lowEffects != lowEffects;
} }
/// 이클립스 진행 중 광량. 사전 경고의 Glow 감소 · 실패 감소가 같은 값을 쓴다(해석 확인 Q-A28).
const eclipseLight = 0.5;
/// 사전 경고 그림자의 출발 거리(행성 한 변 대비). 원문에 수치가 없는 임시값(해석 확인 항목).
const eclipseShadowTravel = 0.9;
/// 사전 경고 진행 0~1 — [warnAt] 에 0, 발생 시각 [startAt] 에 1. 창이 없거나 경고 전이면 null.
/// 발생 시각이 지나도 서버가 회차를 열기 전(스케줄러 지연 수 초)에는 1 로 둔다 — 그림자가 빠졌다 다시 어두워지지 않게.
double? eclipseApproach(DateTime? warnAt, DateTime? startAt, DateTime now) {
if (warnAt == null || startAt == null || !startAt.isAfter(warnAt)) return null;
if (now.isBefore(warnAt)) return null;
return (now.difference(warnAt).inMilliseconds / startAt.difference(warnAt).inMilliseconds)
.clamp(0.0, 1.0);
}
/// 이클립스 성공 Orbit Burst 길이. 원문은 「짧은」만 적어 Level Up 경량 연출(850ms)과 같게 둔다(해석 확인 항목). /// 이클립스 성공 Orbit Burst 길이. 원문은 「짧은」만 적어 Level Up 경량 연출(850ms)과 같게 둔다(해석 확인 항목).
const orbitBurstDuration = Duration(milliseconds: 850); const orbitBurstDuration = Duration(milliseconds: 850);
@@ -30,6 +30,7 @@ class EclipseState {
required this.defended, required this.defended,
this.qualifyShakes = 60, this.qualifyShakes = 60,
this.startAt, this.startAt,
this.warnAt,
this.scheduled = false, this.scheduled = false,
this.endsAt, this.endsAt,
this.result, this.result,
@@ -73,6 +74,9 @@ class EclipseState {
/// 없으면 이미 시작한 회차로 본다. /// 없으면 이미 시작한 회차로 본다.
final DateTime? startAt; final DateTime? startAt;
/// 사전 경고 시각(`warnAt` = 발생 − 사전 알림, 원장 C-22 3분). 행성 그림자 접근의 출발점.
final DateTime? warnAt;
/// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다. /// 서버 회차가 아직 `scheduled`(경보 단계)인가. 방어 호출은 `active` 에서만 받는다.
final bool scheduled; final bool scheduled;
@@ -452,6 +452,7 @@ class _EclipseDefendScreenState extends State<EclipseDefendScreen>
remaining: remaining, remaining: remaining,
pending: _pending, pending: _pending,
pulse: _pulse, pulse: _pulse,
shield: state.progress,
image: _imageOf(widget.nebula), image: _imageOf(widget.nebula),
), ),
), ),
@@ -713,8 +714,12 @@ class _DefendPlanet extends StatelessWidget {
required this.pending, required this.pending,
required this.image, required this.image,
this.pulse = 0, this.pulse = 0,
this.shield = 0,
}); });
/// 고유 유효 참여자 / 목표(서버 값 [EclipseState.progress]) — Shield 조각이 이어지는 정도.
final double shield;
/// 흔들기 일련번호. 0 이면 아직 파동이 없다. /// 흔들기 일련번호. 0 이면 아직 파동이 없다.
final int pulse; final int pulse;
@@ -767,6 +772,23 @@ class _DefendPlanet extends StatelessWidget {
CustomPaint(painter: _RemainingRing(remaining)), CustomPaint(painter: _RemainingRing(remaining)),
), ),
_at(67.5, 300, 240, 240, Image(image: image)), _at(67.5, 300, 240, 240, Image(image: image)),
// Shield — 끊어진 Ring 이 참여자 비율만큼 이어지고 목표 달성 시 완전한 원(v1.5 §5.1).
// 값이 바뀌면 Shield Pulse 와 같은 길이로 이어 붙인다. 동작 줄이기면 바로 바뀐다.
_at(
48,
280,
280,
280,
TweenAnimationBuilder<double>(
key: const ValueKey('shield-ring'),
tween: Tween(end: shield.clamp(0.0, 1.0)),
duration: MediaQuery.disableAnimationsOf(context)
? Duration.zero
: shieldPulseDuration,
builder: (context, p, _) =>
CustomPaint(painter: ShieldRingPainter(p)),
),
),
// Shield Pulse — 흔들기마다 행성 가장자리에서 원형 파동이 한 번 퍼진다. 동작 줄이기면 그리지 않는다. // Shield Pulse — 흔들기마다 행성 가장자리에서 원형 파동이 한 번 퍼진다. 동작 줄이기면 그리지 않는다.
if (pulse > 0 && !MediaQuery.disableAnimationsOf(context)) if (pulse > 0 && !MediaQuery.disableAnimationsOf(context))
_at( _at(
@@ -978,3 +1000,53 @@ class ShieldPulsePainter extends CustomPainter {
@override @override
bool shouldRepaint(ShieldPulsePainter old) => old.t != t; bool shouldRepaint(ShieldPulsePainter old) => old.t != t;
} }
/// Shield Ring 조각 수. 원문(v1.5 §5.1)에 수치가 없는 임시값(해석 확인 항목).
const shieldSegments = 12;
/// 진행 [p] 0~1 → Ring 조각들의 (시작각, 길이). 처음엔 조각 사이 틈이 절반이고, 참여자가 늘수록
/// 틈이 좁아져 이어지며, 목표 달성(1)이면 틈 없는 원 하나다.
List<(double, double)> shieldArcs(double p) {
if (p >= 1) return const [(-math.pi / 2, 2 * math.pi)];
const slot = 2 * math.pi / shieldSegments;
final sweep = slot * (0.5 + 0.5 * p.clamp(0.0, 1.0));
return [
for (var i = 0; i < shieldSegments; i++)
(-math.pi / 2 + i * slot + (slot - sweep) / 2, sweep),
];
}
/// 행성(240)과 잔여 시간 원호(280) 사이의 Shield Ring. 완성되면 바깥 번짐을 한 겹 더한다.
class ShieldRingPainter extends CustomPainter {
const ShieldRingPainter(this.progress);
final double progress;
@override
void paint(Canvas canvas, Size size) {
final r = size.shortestSide / 2 * (260 / 280);
final rect = Rect.fromCircle(center: size.center(Offset.zero), radius: r);
final full = progress >= 1;
final paint = Paint()
..style = PaintingStyle.stroke
..strokeWidth = full ? 5 : 3
..strokeCap = StrokeCap.round
..color = AppColors.neonEmerald.withValues(alpha: 0.35 + 0.55 * progress.clamp(0.0, 1.0));
if (full) {
canvas.drawCircle(
rect.center,
r,
Paint()
..style = PaintingStyle.stroke
..strokeWidth = 12
..color = AppColors.neonEmerald.withValues(alpha: 0.25),
);
}
for (final (start, sweep) in shieldArcs(progress)) {
canvas.drawArc(rect, start, sweep, false, paint);
}
}
@override
bool shouldRepaint(ShieldRingPainter old) => old.progress != progress;
}
@@ -199,6 +199,7 @@ class FirestoreEclipseRepository implements EclipseRepository {
_ => null, _ => null,
}, },
startAt: startAt is Timestamp ? startAt.toDate() : null, startAt: startAt is Timestamp ? startAt.toDate() : null,
warnAt: data['warnAt'] is Timestamp ? (data['warnAt'] as Timestamp).toDate() : null,
scheduled: data['status'] == 'scheduled', scheduled: data['status'] == 'scheduled',
endsAt: endsAt is Timestamp ? endsAt.toDate() : null, endsAt: endsAt is Timestamp ? endsAt.toDate() : null,
); );
@@ -12,9 +12,10 @@ import '../../../core/i18n/app_locale.dart';
/// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만 /// *섹션 숨김, 모두 0 건이면 전체 Briefing 블록을 숨김*. 구획이 없으면 「섹션만
/// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다. /// 숨김」이라는 판정 자체가 성립하지 않는다. 블록 전체 숨김은 홈 화면이 맡는다.
/// ///
/// **한 줄은 Figma 대로 번호 + 제목만 띄운다.** 정책 원장 B-75(확정) 「각 항목에 출처명 · /// **한 줄은 Figma 의 번호 + 제목 아래에 출처명 · 게시시각을 12/500 으로 덧붙인다** — UX·FX v1.5
/// 게시/발행시각 · 원본 URL · 원문보기 · AI 요약 표시」는 Figma 에 배치가 없어, 줄을 누르면 /// 「AI Briefing 출처 표시」(B-3 8-4). 정책 원장 B-75(확정)의 나머지(공식/팬덤 구분 · 원본 URL ·
/// 여는 상세 시트([showBriefingDetail])에 모두 싣는다. 시트의 「원문 보기」가 원문으로 나간다. /// 원문보기 · AI 요약 표시)는 Figma 에 배치가 없어, 줄을 누르면 여는 상세 시트([showBriefingDetail])에
/// 싣는다. 시트의 「원문 보기」가 원문으로 나간다.
class BriefingList extends StatelessWidget { class BriefingList extends StatelessWidget {
const BriefingList({super.key, required this.items, this.onOpen}); const BriefingList({super.key, required this.items, this.onOpen});
@@ -132,17 +133,35 @@ class _Row extends StatelessWidget {
), ),
const SizedBox(width: 28), const SizedBox(width: 28),
Expanded( Expanded(
child: Text( child: Column(
item.headline, crossAxisAlignment: CrossAxisAlignment.start,
maxLines: 2, children: [
overflow: TextOverflow.ellipsis, Text(
style: const TextStyle( item.headline,
fontFamily: AppFonts.body, maxLines: 2,
fontSize: 16, overflow: TextOverflow.ellipsis,
fontWeight: FontWeight.w500, style: const TextStyle(
height: 1.5, fontFamily: AppFonts.body,
color: AppColors.onCosmicVariant, fontSize: 16,
), fontWeight: FontWeight.w500,
height: 1.5,
color: AppColors.onCosmicVariant,
),
),
const SizedBox(height: AppSpace.s1),
Text(
'${item.sourceName} · ${briefingStamp(item.sourceAt)}',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontFamily: AppFonts.body,
fontSize: 12,
fontWeight: FontWeight.w500,
height: 1.4,
color: AppColors.onCosmicMuted,
),
),
],
), ),
), ),
], ],
@@ -151,6 +170,13 @@ class _Row extends StatelessWidget {
} }
} }
/// 게시시각 표기 `yyyy.MM.dd HH:mm`(기기 시간대) — 줄과 상세 시트가 같이 쓴다.
String briefingStamp(DateTime sourceAt) {
String two(int v) => v.toString().padLeft(2, '0');
final at = sourceAt.toLocal();
return '${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
}
/// 브리핑 항목 상세 — B-75 5 요소(출처명 · 게시시각 · 원본 URL · 원문보기 · AI 요약 표시)와 /// 브리핑 항목 상세 — B-75 5 요소(출처명 · 게시시각 · 원본 URL · 원문보기 · AI 요약 표시)와
/// 공식/팬덤 출처 구분을 한 시트에 싣는다. /// 공식/팬덤 출처 구분을 한 시트에 싣는다.
Future<void> showBriefingDetail( Future<void> showBriefingDetail(
@@ -171,10 +197,7 @@ Future<void> showBriefingDetail(
'fandom' => l10n.briefingSourceFandom, 'fandom' => l10n.briefingSourceFandom,
_ => null, _ => null,
}; };
String two(int v) => v.toString().padLeft(2, '0'); final stamp = briefingStamp(item.sourceAt);
final at = item.sourceAt.toLocal();
final stamp =
'${at.year}.${two(at.month)}.${two(at.day)} ${two(at.hour)}:${two(at.minute)}';
return showModalBottomSheet<void>( return showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
+8 -1
View File
@@ -377,7 +377,14 @@ enum PlanetHistoryKind {
/// PASSPORT Stamp — Eclipse 성공 참여(FX §5.2) · 진화 기념 Emblem(상세정의 22장) · 최초 100명 Badge(별첨 B). /// PASSPORT Stamp — Eclipse 성공 참여(FX §5.2) · 진화 기념 Emblem(상세정의 22장) · 최초 100명 Badge(별첨 B).
eclipseSuccess, eclipseSuccess,
emblemEvolution, emblemEvolution,
founderBadge, founderBadge;
/// UX·FX v1.5 §7.2 「역사적 사건」 — Planet Evolution · Eclipse 성공 참여 · 주요 Badge 획득은
/// 일반 활동 로그와 구분되는 Stamp 로 보인다. Campaign 성공은 역사 응답에 종류가 없어 아직 빠진다.
bool get isHistoric => switch (this) {
evolution || eclipseSuccess || emblemEvolution || founderBadge => true,
_ => false,
};
} }
class PlanetHistoryEntry { class PlanetHistoryEntry {
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'planet_data.dart'; import 'planet_data.dart';
import '../../core/i18n/app_locale.dart'; import '../../core/i18n/app_locale.dart';
import '../../core/ui/bottom_inset.dart'; import '../../core/ui/bottom_inset.dart';
@@ -86,47 +87,52 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
for (final item in _items) for (final item in _items)
ListTile( _Stamp(
contentPadding: EdgeInsets.zero, historic: item.kind.isHistoric,
leading: Icon(switch (item.kind) { child: ListTile(
PlanetHistoryKind.evolution => Icons.auto_awesome, contentPadding: EdgeInsets.zero,
PlanetHistoryKind.hibernation => Icons.bedtime_outlined, leading: Icon(switch (item.kind) {
PlanetHistoryKind.wakeUp => Icons.wb_sunny_outlined, PlanetHistoryKind.evolution => Icons.auto_awesome,
PlanetHistoryKind.zap => Icons.bolt, PlanetHistoryKind.hibernation => Icons.bedtime_outlined,
PlanetHistoryKind.questReward => Icons.task_alt, PlanetHistoryKind.wakeUp => Icons.wb_sunny_outlined,
PlanetHistoryKind.companyAdBonus => Icons.play_circle_outline, PlanetHistoryKind.zap => Icons.bolt,
PlanetHistoryKind.funding => Icons.favorite_border, PlanetHistoryKind.questReward => Icons.task_alt,
PlanetHistoryKind.eclipseSuccess => Icons.shield_outlined, PlanetHistoryKind.companyAdBonus => Icons.play_circle_outline,
PlanetHistoryKind.emblemEvolution => Icons.military_tech_outlined, PlanetHistoryKind.funding => Icons.favorite_border,
PlanetHistoryKind.founderBadge => Icons.workspace_premium_outlined, PlanetHistoryKind.eclipseSuccess => Icons.shield_outlined,
}), PlanetHistoryKind.emblemEvolution =>
title: Text(switch (item.kind) { Icons.military_tech_outlined,
PlanetHistoryKind.evolution => context.l10n.historyEvolution( PlanetHistoryKind.founderBadge =>
'${item.fromStage}', Icons.workspace_premium_outlined,
'${item.toStage}', }),
title: Text(switch (item.kind) {
PlanetHistoryKind.evolution => context.l10n.historyEvolution(
'${item.fromStage}',
'${item.toStage}',
),
PlanetHistoryKind.hibernation =>
context.l10n.historyHibernation,
PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp,
PlanetHistoryKind.zap => context.l10n.historyZap,
PlanetHistoryKind.questReward => context.l10n.historyQuest,
PlanetHistoryKind.companyAdBonus => context.l10n.historyAdBonus,
PlanetHistoryKind.funding => context.l10n.historyFunding,
PlanetHistoryKind.eclipseSuccess =>
context.l10n.historyEclipseSuccess,
PlanetHistoryKind.emblemEvolution =>
'${context.l10n.historyEmblemEvolution} · STAGE ${item.toStage ?? ''}',
PlanetHistoryKind.founderBadge =>
'${context.l10n.historyFounderBadge} · #${(item.memberNo ?? 0).toString().padLeft(3, '0')}',
}),
subtitle: Text(
[
MaterialLocalizations.of(context)
.formatMediumDate(item.occurredAt.toLocal()),
if (item.amount != null)
context.l10n.historyGrowthPop('${item.amount}'),
if (item.participationConfirmed) context.l10n.historyJoined,
].join('\n'),
), ),
PlanetHistoryKind.hibernation => context.l10n.historyHibernation,
PlanetHistoryKind.wakeUp => context.l10n.historyWakeUp,
PlanetHistoryKind.zap => context.l10n.historyZap,
PlanetHistoryKind.questReward => context.l10n.historyQuest,
PlanetHistoryKind.companyAdBonus => context.l10n.historyAdBonus,
PlanetHistoryKind.funding => context.l10n.historyFunding,
PlanetHistoryKind.eclipseSuccess =>
context.l10n.historyEclipseSuccess,
PlanetHistoryKind.emblemEvolution =>
'${context.l10n.historyEmblemEvolution} · STAGE ${item.toStage ?? ''}',
PlanetHistoryKind.founderBadge =>
'${context.l10n.historyFounderBadge} · #${(item.memberNo ?? 0).toString().padLeft(3, '0')}',
}),
subtitle: Text(
[
MaterialLocalizations.of(
context,
).formatMediumDate(item.occurredAt.toLocal()),
if (item.amount != null)
context.l10n.historyGrowthPop('${item.amount}'),
if (item.participationConfirmed) context.l10n.historyJoined,
].join('\n'),
), ),
), ),
if (_loading) const Center(child: CircularProgressIndicator()), if (_loading) const Center(child: CircularProgressIndicator()),
@@ -145,3 +151,36 @@ class _PlanetHistoryScreenState extends State<PlanetHistoryScreen> {
), ),
); );
} }
/// 「역사적 사건」(UX·FX v1.5 §7.2)은 테두리 Stamp 와 표지로 일반 활동 줄과 가른다.
/// 앱 Figma 에 PASSPORT 기록 화면이 없어 화면 테마 색만 쓴다.
class _Stamp extends StatelessWidget {
const _Stamp({required this.historic, required this.child});
final bool historic;
final Widget child;
@override
Widget build(BuildContext context) {
if (!historic) return child;
final scheme = Theme.of(context).colorScheme;
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 0),
decoration: BoxDecoration(
border: Border.all(color: scheme.primary),
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.historyHistoric,
style: Theme.of(context).textTheme.labelSmall
?.copyWith(color: scheme.primary),
),
child,
],
),
);
}
}
+1
View File
@@ -544,6 +544,7 @@
"historyEclipseSuccess": "Joined a successful eclipse defense", "historyEclipseSuccess": "Joined a successful eclipse defense",
"historyEmblemEvolution": "Evolution emblem", "historyEmblemEvolution": "Evolution emblem",
"historyFounderBadge": "First 100 commemorative badge", "historyFounderBadge": "First 100 commemorative badge",
"historyHistoric": "Historic moment",
"historyGrowthPop": "{amount} POP added to growth", "historyGrowthPop": "{amount} POP added to growth",
"historyJoined": "You were part of this evolution", "historyJoined": "You were part of this evolution",
"historyLoadFailed": "Couldn't load history.", "historyLoadFailed": "Couldn't load history.",
+1
View File
@@ -544,6 +544,7 @@
"historyEclipseSuccess": "Participaste en una defensa exitosa del eclipse", "historyEclipseSuccess": "Participaste en una defensa exitosa del eclipse",
"historyEmblemEvolution": "Emblema de evolución", "historyEmblemEvolution": "Emblema de evolución",
"historyFounderBadge": "Insignia conmemorativa de los primeros 100", "historyFounderBadge": "Insignia conmemorativa de los primeros 100",
"historyHistoric": "Momento histórico",
"historyGrowthPop": "{amount} POP sumados al crecimiento", "historyGrowthPop": "{amount} POP sumados al crecimiento",
"historyJoined": "Participaste en esta evolución", "historyJoined": "Participaste en esta evolución",
"historyLoadFailed": "No se pudo cargar el historial.", "historyLoadFailed": "No se pudo cargar el historial.",
+1
View File
@@ -544,6 +544,7 @@
"historyEclipseSuccess": "イクリプス防御成功に参加", "historyEclipseSuccess": "イクリプス防御成功に参加",
"historyEmblemEvolution": "進化記念エンブレム", "historyEmblemEvolution": "進化記念エンブレム",
"historyFounderBadge": "最初の100人記念バッジ", "historyFounderBadge": "最初の100人記念バッジ",
"historyHistoric": "歴史的な出来事",
"historyGrowthPop": "成長に{amount} POP反映", "historyGrowthPop": "成長に{amount} POP反映",
"historyJoined": "この進化に参加しました", "historyJoined": "この進化に参加しました",
"historyLoadFailed": "記録を読み込めませんでした。", "historyLoadFailed": "記録を読み込めませんでした。",
+1
View File
@@ -1065,6 +1065,7 @@
"historyEclipseSuccess": "이클립스 방어 성공 참여", "historyEclipseSuccess": "이클립스 방어 성공 참여",
"historyEmblemEvolution": "진화 기념 Emblem", "historyEmblemEvolution": "진화 기념 Emblem",
"historyFounderBadge": "최초 100명 기념 Badge", "historyFounderBadge": "최초 100명 기념 Badge",
"historyHistoric": "역사적 사건",
"historyGrowthPop": "성장에 {amount} POP 반영", "historyGrowthPop": "성장에 {amount} POP 반영",
"@historyGrowthPop": { "@historyGrowthPop": {
"placeholders": { "placeholders": {
+27 -41
View File
@@ -65,8 +65,7 @@ import 'app_localizations_zh.dart';
/// be consistent with the languages listed in the AppLocalizations.supportedLocales /// be consistent with the languages listed in the AppLocalizations.supportedLocales
/// property. /// property.
abstract class AppLocalizations { abstract class AppLocalizations {
AppLocalizations(String locale) AppLocalizations(String locale) : localeName = intl.Intl.canonicalizedLocale(locale.toString());
: localeName = intl.Intl.canonicalizedLocale(locale.toString());
final String localeName; final String localeName;
@@ -74,8 +73,7 @@ abstract class AppLocalizations {
return Localizations.of<AppLocalizations>(context, AppLocalizations); return Localizations.of<AppLocalizations>(context, AppLocalizations);
} }
static const LocalizationsDelegate<AppLocalizations> delegate = static const LocalizationsDelegate<AppLocalizations> delegate = _AppLocalizationsDelegate();
_AppLocalizationsDelegate();
/// A list of this localizations delegate along with the default localizations /// A list of this localizations delegate along with the default localizations
/// delegates. /// delegates.
@@ -87,13 +85,12 @@ abstract class AppLocalizations {
/// Additional delegates can be added by appending to this list in /// Additional delegates can be added by appending to this list in
/// MaterialApp. This list does not have to be used at all if a custom list /// MaterialApp. This list does not have to be used at all if a custom list
/// of delegates is preferred or required. /// of delegates is preferred or required.
static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = static const List<LocalizationsDelegate<dynamic>> localizationsDelegates = <LocalizationsDelegate<dynamic>>[
<LocalizationsDelegate<dynamic>>[ delegate,
delegate, GlobalMaterialLocalizations.delegate,
GlobalMaterialLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, ];
];
/// A list of this localizations delegate's supported locales. /// A list of this localizations delegate's supported locales.
static const List<Locale> supportedLocales = <Locale>[ static const List<Locale> supportedLocales = <Locale>[
@@ -101,7 +98,7 @@ abstract class AppLocalizations {
Locale('es'), Locale('es'),
Locale('ja'), Locale('ja'),
Locale('ko'), Locale('ko'),
Locale('zh'), Locale('zh')
]; ];
/// No description provided for @commonConfirm. /// No description provided for @commonConfirm.
@@ -3368,6 +3365,12 @@ abstract class AppLocalizations {
/// **'최초 100명 기념 Badge'** /// **'최초 100명 기념 Badge'**
String get historyFounderBadge; String get historyFounderBadge;
/// No description provided for @historyHistoric.
///
/// In ko, this message translates to:
/// **'역사적 사건'**
String get historyHistoric;
/// No description provided for @historyGrowthPop. /// No description provided for @historyGrowthPop.
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
@@ -4350,12 +4353,7 @@ abstract class AppLocalizations {
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'{startMonth}월 {startDay}일 ~ {endMonth}월 {endDay}일'** /// **'{startMonth}월 {startDay}일 ~ {endMonth}월 {endDay}일'**
String campaignPeriodRange( String campaignPeriodRange(String startMonth, String startDay, String endMonth, String endDay);
String startMonth,
String startDay,
String endMonth,
String endDay,
);
/// No description provided for @campaignPeriodSelect. /// No description provided for @campaignPeriodSelect.
/// ///
@@ -4817,14 +4815,7 @@ abstract class AppLocalizations {
/// ///
/// In ko, this message translates to: /// In ko, this message translates to:
/// **'{title}\n보상 {reward} POP × {count}명 = {pool} POP\n수수료 {fee} POP (공개 시 소멸)\n총 {total} POP 이 차감됩니다.'** /// **'{title}\n보상 {reward} POP × {count}명 = {pool} POP\n수수료 {fee} POP (공개 시 소멸)\n총 {total} POP 이 차감됩니다.'**
String questCreateCost( String questCreateCost(String title, String reward, String count, String pool, String fee, String total);
String title,
String reward,
String count,
String pool,
String fee,
String total,
);
/// No description provided for @questNoReward. /// No description provided for @questNoReward.
/// ///
@@ -5541,8 +5532,7 @@ abstract class AppLocalizations {
String liveHeroTimeBy(String fandom, String name); String liveHeroTimeBy(String fandom, String name);
} }
class _AppLocalizationsDelegate class _AppLocalizationsDelegate extends LocalizationsDelegate<AppLocalizations> {
extends LocalizationsDelegate<AppLocalizations> {
const _AppLocalizationsDelegate(); const _AppLocalizationsDelegate();
@override @override
@@ -5551,32 +5541,28 @@ class _AppLocalizationsDelegate
} }
@override @override
bool isSupported(Locale locale) => bool isSupported(Locale locale) => <String>['en', 'es', 'ja', 'ko', 'zh'].contains(locale.languageCode);
<String>['en', 'es', 'ja', 'ko', 'zh'].contains(locale.languageCode);
@override @override
bool shouldReload(_AppLocalizationsDelegate old) => false; bool shouldReload(_AppLocalizationsDelegate old) => false;
} }
AppLocalizations lookupAppLocalizations(Locale locale) { AppLocalizations lookupAppLocalizations(Locale locale) {
// Lookup logic when only language code is specified. // Lookup logic when only language code is specified.
switch (locale.languageCode) { switch (locale.languageCode) {
case 'en': case 'en': return AppLocalizationsEn();
return AppLocalizationsEn(); case 'es': return AppLocalizationsEs();
case 'es': case 'ja': return AppLocalizationsJa();
return AppLocalizationsEs(); case 'ko': return AppLocalizationsKo();
case 'ja': case 'zh': return AppLocalizationsZh();
return AppLocalizationsJa();
case 'ko':
return AppLocalizationsKo();
case 'zh':
return AppLocalizationsZh();
} }
throw FlutterError( throw FlutterError(
'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely ' 'AppLocalizations.delegate failed to load unsupported locale "$locale". This is likely '
'an issue with the localizations generation tool. Please file an issue ' 'an issue with the localizations generation tool. Please file an issue '
'on GitHub with a reproducible sample app and the gen-l10n configuration ' 'on GitHub with a reproducible sample app and the gen-l10n configuration '
'that was used.', 'that was used.'
); );
} }
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+80 -85
View File
@@ -63,25 +63,31 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String authProviderLogin(String provider) { String authProviderLogin(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'apple': 'Appleでログイン', provider,
'google': 'Googleでログイン', {
'kakao': 'Kakaoでログイン', 'apple': 'Appleでログイン',
'line': 'LINEでログイン', 'google': 'Googleでログイン',
'other': 'メールでログイン', 'kakao': 'Kakaoでログイン',
}); 'line': 'LINEでログイン',
'other': 'メールでログイン',
},
);
return '$_temp0'; return '$_temp0';
} }
@override @override
String authProviderSignup(String provider) { String authProviderSignup(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'apple': 'Appleで登録', provider,
'google': 'Googleで登録', {
'kakao': 'Kakaoで登録', 'apple': 'Appleで登録',
'line': 'LINEで登録', 'google': 'Googleで登録',
'other': 'メールで登録', 'kakao': 'Kakaoで登録',
}); 'line': 'LINEで登録',
'other': 'メールで登録',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -519,25 +525,31 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String authProviderStart(String provider) { String authProviderStart(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'email': 'メールではじめる', provider,
'kakao': 'Kakaoではじめる', {
'line': 'LINEではじめる', 'email': 'メールではじめる',
'apple': 'Appleではじめる', 'kakao': 'Kakaoではじめる',
'other': 'Googleではじめる', 'line': 'LINEではじめる',
}); 'apple': 'Appleではじめる',
'other': 'Googleではじめる',
},
);
return '$_temp0'; return '$_temp0';
} }
@override @override
String authProviderName(String provider) { String authProviderName(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'email': 'メール', provider,
'kakao': 'Kakao', {
'line': 'LINE', 'email': 'メール',
'apple': 'Apple', 'kakao': 'Kakao',
'other': 'Google', 'line': 'LINE',
}); 'apple': 'Apple',
'other': 'Google',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -869,8 +881,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get nebulaNameTaken => 'すでに使用されている名前です。'; String get nebulaNameTaken => 'すでに使用されている名前です。';
@override @override
String get nebulaNameInvalid => String get nebulaNameInvalid => '2〜20文字のハングル・英字・数字で入力してください(記号・絵文字・連続/前後の空白は不可)。';
'2〜20文字のハングル・英字・数字で入力してください(記号・絵文字・連続/前後の空白は不可)。';
@override @override
String get nebulaNameHint => 'ファンダム名'; String get nebulaNameHint => 'ファンダム名';
@@ -1187,8 +1198,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get pushTopicEssentialDesc => 'アカウント・決済・メンテナンスのお知らせです。オフにできません。'; String get pushTopicEssentialDesc => 'アカウント・決済・メンテナンスのお知らせです。オフにできません。';
@override @override
String get pushTopicFandomEventDesc => String get pushTopicFandomEventDesc => 'イクリプス・キャンペーン・クエスト・アーティストホットタイムのお知らせです。';
'イクリプス・キャンペーン・クエスト・アーティストホットタイムのお知らせです。';
@override @override
String get pushTopicBriefingDesc => '惑星の一日をまとめたブリーフィングカードをお届けします。'; String get pushTopicBriefingDesc => '惑星の一日をまとめたブリーフィングカードをお届けします。';
@@ -1294,8 +1304,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get payEmpty => '決済履歴はありません。'; String get payEmpty => '決済履歴はありません。';
@override @override
String get payRefundGuide => String get payRefundGuide => '消費型商品:購入後7日以内かつ未使用の商品は返金できます。一部でも使用した商品は返金対象外です。\nサブスク商品:決済後24時間以内に特典(追加報酬など)を未使用の場合、返金できます。\n返金承認後7〜14日以内に返金が完了します。';
'消費型商品:購入後7日以内かつ未使用の商品は返金できます。一部でも使用した商品は返金対象外です。\nサブスク商品:決済後24時間以内に特典(追加報酬など)を未使用の場合、返金できます。\n返金承認後7〜14日以内に返金が完了します。';
@override @override
String get payDetailTitle => '決済履歴の詳細'; String get payDetailTitle => '決済履歴の詳細';
@@ -1809,6 +1818,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String get historyFounderBadge => '最初の100人記念バッジ'; String get historyFounderBadge => '最初の100人記念バッジ';
@override
String get historyHistoric => '歴史的な出来事';
@override @override
String historyGrowthPop(String amount) { String historyGrowthPop(String amount) {
return '成長に$amount POP反映'; return '成長に$amount POP反映';
@@ -1831,31 +1843,34 @@ class AppLocalizationsJa extends AppLocalizations {
@override @override
String countryName(String code) { String countryName(String code) {
String _temp0 = intl.Intl.selectLogic(code, { String _temp0 = intl.Intl.selectLogic(
'KR': '韓国', code,
'JP': '日本', {
'US': 'アメリカ', 'KR': '韓国',
'CN': '中国', 'JP': '日本',
'TW': '台湾', 'US': 'アメリカ',
'HK': '香港', 'CN': '中国',
'TH': 'タイ', 'TW': '台湾',
'VN': 'ベトナム', 'HK': '香港',
'ID': 'インドネシア', 'TH': 'タイ',
'PH': 'フィリピン', 'VN': 'ベトナム',
'MY': 'マレーシア', 'ID': 'インドネシア',
'SG': 'シンガポール', 'PH': 'フィリピン',
'IN': 'インド', 'MY': 'マレーシア',
'GB': 'イギリス', 'SG': 'シンガポール',
'FR': 'フランス', 'IN': 'インド',
'DE': 'ドイツ', 'GB': 'イギリス',
'ES': 'スペイン', 'FR': 'フランス',
'BR': 'ブラジル', 'DE': 'ドイツ',
'MX': 'メキシコ', 'ES': 'スペイン',
'CA': 'カナダ', 'BR': 'ブラジル',
'AU': 'オーストラリア', 'MX': 'メキシコ',
'ZZ': '未指定', 'CA': 'カナダ',
'other': '-', 'AU': 'オーストラリア',
}); 'ZZ': '未指定',
'other': '-',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -2051,8 +2066,7 @@ class AppLocalizationsJa extends AppLocalizations {
} }
@override @override
String get adWarpBefore => String get adWarpBefore => 'Warping...ファンダムとアーティストにもっと貢献できるよう、ぴったりの広告を探しています。';
'Warping...ファンダムとアーティストにもっと貢献できるよう、ぴったりの広告を探しています。';
@override @override
String get adWarpAfter => 'Warping...あなたの貢献への報酬を準備しています。'; String get adWarpAfter => 'Warping...あなたの貢献への報酬を準備しています。';
@@ -2205,8 +2219,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get nebulaAlreadyToday => '今日はすでに星雲を作成しました。'; String get nebulaAlreadyToday => '今日はすでに星雲を作成しました。';
@override @override
String get nebulaCreatedKeepPrimary => String get nebulaCreatedKeepPrimary => '星雲を作成しました。メイン惑星は前回の変更から30日経過後に変更できるため、そのままです。';
'星雲を作成しました。メイン惑星は前回の変更から30日経過後に変更できるため、そのままです。';
@override @override
String get planetLeave => '脱退する'; String get planetLeave => '脱退する';
@@ -2372,12 +2385,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get campaignWeekdays => '日,月,火,水,木,金,土'; String get campaignWeekdays => '日,月,火,水,木,金,土';
@override @override
String campaignPeriodRange( String campaignPeriodRange(String startMonth, String startDay, String endMonth, String endDay) {
String startMonth,
String startDay,
String endMonth,
String endDay,
) {
return '$startMonth月$startDay日 ~ $endMonth月$endDay日'; return '$startMonth月$startDay日 ~ $endMonth月$endDay日';
} }
@@ -2421,12 +2429,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get campaignSubmitTitle => 'この内容で投稿しますか?'; String get campaignSubmitTitle => 'この内容で投稿しますか?';
@override @override
String campaignSubmitBody( String campaignSubmitBody(String title, String goal, String days, String fee) {
String title,
String goal,
String days,
String fee,
) {
return '$title · 目標 $goal POP · $days日\n登録料 $fee POP が差し引かれ、運営の審査後に支援が始まります。'; return '$title · 目標 $goal POP · $days日\n登録料 $fee POP が差し引かれ、運営の審査後に支援が始まります。';
} }
@@ -2633,14 +2636,7 @@ class AppLocalizationsJa extends AppLocalizations {
} }
@override @override
String questCreateCost( String questCreateCost(String title, String reward, String count, String pool, String fee, String total) {
String title,
String reward,
String count,
String pool,
String fee,
String total,
) {
return '$title\n報酬 $reward POP × $count人 = $pool POP\n手数料 $fee POP(公開時に消滅)\n合計 $total POP が差し引かれます。'; return '$title\n報酬 $reward POP × $count人 = $pool POP\n手数料 $fee POP(公開時に消滅)\n合計 $total POP が差し引かれます。';
} }
@@ -2849,8 +2845,7 @@ class AppLocalizationsJa extends AppLocalizations {
String get questCancelInReview => '審査中に取り消すと、預けた報酬POPと手数料はすべて返還されます。'; String get questCancelInReview => '審査中に取り消すと、預けた報酬POPと手数料はすべて返還されます。';
@override @override
String get questCancelPublished => String get questCancelPublished => '公開中のクエストを取り消すと参加はすぐ締め切られ、残りの報酬POPは返還されます。手数料は返還されません。';
'公開中のクエストを取り消すと参加はすぐ締め切られ、残りの報酬POPは返還されます。手数料は返還されません。';
@override @override
String get questUnderReview => '運営の審査中です。承認されると公開されます。'; String get questUnderReview => '運営の審査中です。承認されると公開されます。';
+84 -93
View File
@@ -63,25 +63,31 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String authProviderLogin(String provider) { String authProviderLogin(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'apple': 'Apple로 로그인', provider,
'google': 'Google로 로그인', {
'kakao': 'Kakao로 로그인', 'apple': 'Apple로 로그인',
'line': 'LINE으로 로그인', 'google': 'Google로 로그인',
'other': '이메일로 로그인', 'kakao': 'Kakao로 로그인',
}); 'line': 'LINE으로 로그인',
'other': '이메일로 로그인',
},
);
return '$_temp0'; return '$_temp0';
} }
@override @override
String authProviderSignup(String provider) { String authProviderSignup(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'apple': 'Apple로 가입하기', provider,
'google': 'Google로 가입하기', {
'kakao': 'Kakao로 가입하기', 'apple': 'Apple로 가입하기',
'line': 'LINE으로 가입하기', 'google': 'Google로 가입하기',
'other': '이메일로 가입하기', 'kakao': 'Kakao로 가입하기',
}); 'line': 'LINE으로 가입하기',
'other': '이메일로 가입하기',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -519,25 +525,31 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String authProviderStart(String provider) { String authProviderStart(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'email': '이메일로 시작하기', provider,
'kakao': '카카오로 시작하기', {
'line': 'LINE으로 시작하기', 'email': '이메일로 시작하기',
'apple': 'Apple로 시작하기', 'kakao': '카카오로 시작하기',
'other': 'Google로 시작하기', 'line': 'LINE으로 시작하기',
}); 'apple': 'Apple로 시작하기',
'other': 'Google로 시작하기',
},
);
return '$_temp0'; return '$_temp0';
} }
@override @override
String authProviderName(String provider) { String authProviderName(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'email': '이메일', provider,
'kakao': '카카오', {
'line': 'LINE', 'email': '이메일',
'apple': 'Apple', 'kakao': '카카오',
'other': 'Google', 'line': 'LINE',
}); 'apple': 'Apple',
'other': 'Google',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -869,8 +881,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get nebulaNameTaken => '이미 사용중인 이름입니다.'; String get nebulaNameTaken => '이미 사용중인 이름입니다.';
@override @override
String get nebulaNameInvalid => String get nebulaNameInvalid => '2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).';
'2~20자 한글·영문·숫자로 입력해 주세요(특수문자·이모지·연속/앞뒤 공백 불가).';
@override @override
String get nebulaNameHint => '팬덤 이름'; String get nebulaNameHint => '팬덤 이름';
@@ -1293,8 +1304,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get payEmpty => '결제 내역이 없습니다.'; String get payEmpty => '결제 내역이 없습니다.';
@override @override
String get payRefundGuide => String get payRefundGuide => '소비형 상품 : 구매 후 7일 이내이며 사용하지 않은 상품은 환불 가능합니다. 일부라도 사용된 상품은 환불 대상에서 제외됩니다.\n구독형 상품 : 결제 후 24시간 이내 혜택 미사용(추가 보상 등) 시 환불 가능합니다.\n환불 승인 후 7-14 이내 환불 완료 처리가 됩니다.';
'소비형 상품 : 구매 후 7일 이내이며 사용하지 않은 상품은 환불 가능합니다. 일부라도 사용된 상품은 환불 대상에서 제외됩니다.\n구독형 상품 : 결제 후 24시간 이내 혜택 미사용(추가 보상 등) 시 환불 가능합니다.\n환불 승인 후 7-14 이내 환불 완료 처리가 됩니다.';
@override @override
String get payDetailTitle => '결제 내역 상세'; String get payDetailTitle => '결제 내역 상세';
@@ -1480,8 +1490,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get tierPromptSuffix => '를 올려보세요'; String get tierPromptSuffix => '를 올려보세요';
@override @override
String get tierPassportNote => String get tierPassportNote => 'Passport Tier 는 레벨과 별개인 카드 외형 등급이며 내려가지 않습니다.';
'Passport Tier 는 레벨과 별개인 카드 외형 등급이며 내려가지 않습니다.';
@override @override
String get tierTotalContribution => '누적 기여'; String get tierTotalContribution => '누적 기여';
@@ -1585,8 +1594,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get stationReason => '추천 이유 (선택)'; String get stationReason => '추천 이유 (선택)';
@override @override
String get stationSuggestNote => String get stationSuggestNote => '제안하신 링크는 운영 검토를 거쳐 등록됩니다. 검토 결과는 알림으로 알려 드립니다.';
'제안하신 링크는 운영 검토를 거쳐 등록됩니다. 검토 결과는 알림으로 알려 드립니다.';
@override @override
String get heroGiftUnavailable => '이 선물은 현재 수령할 수 없습니다.'; String get heroGiftUnavailable => '이 선물은 현재 수령할 수 없습니다.';
@@ -1810,6 +1818,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String get historyFounderBadge => '최초 100명 기념 Badge'; String get historyFounderBadge => '최초 100명 기념 Badge';
@override
String get historyHistoric => '역사적 사건';
@override @override
String historyGrowthPop(String amount) { String historyGrowthPop(String amount) {
return '성장에 $amount POP 반영'; return '성장에 $amount POP 반영';
@@ -1832,31 +1843,34 @@ class AppLocalizationsKo extends AppLocalizations {
@override @override
String countryName(String code) { String countryName(String code) {
String _temp0 = intl.Intl.selectLogic(code, { String _temp0 = intl.Intl.selectLogic(
'KR': '대한민국', code,
'JP': '일본', {
'US': '미국', 'KR': '대한민국',
'CN': '중국', 'JP': '일본',
'TW': '대만', 'US': '미국',
'HK': '홍콩', 'CN': '중국',
'TH': '태국', 'TW': '대만',
'VN': '베트남', 'HK': '홍콩',
'ID': '인도네시아', 'TH': '태국',
'PH': '필리핀', 'VN': '베트남',
'MY': '말레이시아', 'ID': '인도네시아',
'SG': '싱가포르', 'PH': '필리핀',
'IN': '인도', 'MY': '말레이시아',
'GB': '영국', 'SG': '싱가포르',
'FR': '프랑스', 'IN': '인도',
'DE': '독일', 'GB': '영국',
'ES': '스페인', 'FR': '프랑스',
'BR': '브라질', 'DE': '독일',
'MX': '멕시코', 'ES': '스페인',
'CA': '캐나다', 'BR': '브라질',
'AU': '호주', 'MX': '멕시코',
'ZZ': '미지정', 'CA': '캐나다',
'other': '-', 'AU': '호주',
}); 'ZZ': '미지정',
'other': '-',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -2052,8 +2066,7 @@ class AppLocalizationsKo extends AppLocalizations {
} }
@override @override
String get adWarpBefore => String get adWarpBefore => 'Warping...우리 팬덤과 아티스트에 더 많은 기여를 위해 적합한 광고를 열심히 찾고 있어요.';
'Warping...우리 팬덤과 아티스트에 더 많은 기여를 위해 적합한 광고를 열심히 찾고 있어요.';
@override @override
String get adWarpAfter => 'Warping...당신의 기여에 대한 보상을 준비하고 있어요.'; String get adWarpAfter => 'Warping...당신의 기여에 대한 보상을 준비하고 있어요.';
@@ -2206,8 +2219,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get nebulaAlreadyToday => '이미 오늘 성운을 생성하셨습니다.'; String get nebulaAlreadyToday => '이미 오늘 성운을 생성하셨습니다.';
@override @override
String get nebulaCreatedKeepPrimary => String get nebulaCreatedKeepPrimary => '성운을 만들었습니다. 기본 행성은 최근 변경 후 30일이 지나야 바꿀 수 있어 그대로 유지됩니다.';
'성운을 만들었습니다. 기본 행성은 최근 변경 후 30일이 지나야 바꿀 수 있어 그대로 유지됩니다.';
@override @override
String get planetLeave => '탈퇴하기'; String get planetLeave => '탈퇴하기';
@@ -2234,8 +2246,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get campaignStatusWithdrawn => '개설자가 철회한 캠페인입니다.'; String get campaignStatusWithdrawn => '개설자가 철회한 캠페인입니다.';
@override @override
String get campaignStatusCancelled => String get campaignStatusCancelled => '운영 사유로 취소되었습니다. 등록비와 후원 POP 을 돌려드렸습니다.';
'운영 사유로 취소되었습니다. 등록비와 후원 POP 을 돌려드렸습니다.';
@override @override
String get rankWindowDaily => '일간'; String get rankWindowDaily => '일간';
@@ -2374,12 +2385,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get campaignWeekdays => '일,월,화,수,목,금,토'; String get campaignWeekdays => '일,월,화,수,목,금,토';
@override @override
String campaignPeriodRange( String campaignPeriodRange(String startMonth, String startDay, String endMonth, String endDay) {
String startMonth,
String startDay,
String endMonth,
String endDay,
) {
return '$startMonth월 $startDay일 ~ $endMonth월 $endDay일'; return '$startMonth월 $startDay일 ~ $endMonth월 $endDay일';
} }
@@ -2423,12 +2429,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get campaignSubmitTitle => '이 내용으로 올릴까요?'; String get campaignSubmitTitle => '이 내용으로 올릴까요?';
@override @override
String campaignSubmitBody( String campaignSubmitBody(String title, String goal, String days, String fee) {
String title,
String goal,
String days,
String fee,
) {
return '$title · 목표 $goal POP · $days일\n등록비 $fee POP 이 빠지고 운영 검수 뒤 모금이 열립니다.'; return '$title · 목표 $goal POP · $days일\n등록비 $fee POP 이 빠지고 운영 검수 뒤 모금이 열립니다.';
} }
@@ -2635,14 +2636,7 @@ class AppLocalizationsKo extends AppLocalizations {
} }
@override @override
String questCreateCost( String questCreateCost(String title, String reward, String count, String pool, String fee, String total) {
String title,
String reward,
String count,
String pool,
String fee,
String total,
) {
return '$title\n보상 $reward POP × $count명 = $pool POP\n수수료 $fee POP (공개 시 소멸)\n총 $total POP 이 차감됩니다.'; return '$title\n보상 $reward POP × $count명 = $pool POP\n수수료 $fee POP (공개 시 소멸)\n총 $total POP 이 차감됩니다.';
} }
@@ -2848,12 +2842,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get questCancel => '퀘스트 취소'; String get questCancel => '퀘스트 취소';
@override @override
String get questCancelInReview => String get questCancelInReview => '검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.';
'검수 중인 퀘스트를 취소하면 예치한 보상 POP 과 수수료를 모두 돌려드려요.';
@override @override
String get questCancelPublished => String get questCancelPublished => '공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.';
'공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.';
@override @override
String get questUnderReview => '운영 검수 중이에요. 승인되면 공개됩니다.'; String get questUnderReview => '운영 검수 중이에요. 승인되면 공개됩니다.';
@@ -2974,8 +2966,7 @@ class AppLocalizationsKo extends AppLocalizations {
String get onbPermLocation => '위치'; String get onbPermLocation => '위치';
@override @override
String get onbPermLocationDesc => String get onbPermLocationDesc => '위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.';
'위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.';
@override @override
String get onbPermContinue => '계속'; String get onbPermContinue => '계속';
+75 -75
View File
@@ -63,25 +63,31 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String authProviderLogin(String provider) { String authProviderLogin(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'apple': '通过Apple登录', provider,
'google': '通过Google登录', {
'kakao': '通过Kakao登录', 'apple': '通过Apple登录',
'line': '通过LINE登录', 'google': '通过Google登录',
'other': '通过邮箱登录', 'kakao': '通过Kakao登录',
}); 'line': '通过LINE登录',
'other': '通过邮箱登录',
},
);
return '$_temp0'; return '$_temp0';
} }
@override @override
String authProviderSignup(String provider) { String authProviderSignup(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'apple': '通过Apple注册', provider,
'google': '通过Google注册', {
'kakao': '通过Kakao注册', 'apple': '通过Apple注册',
'line': '通过LINE注册', 'google': '通过Google注册',
'other': '通过邮箱注册', 'kakao': '通过Kakao注册',
}); 'line': '通过LINE注册',
'other': '通过邮箱注册',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -519,25 +525,31 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String authProviderStart(String provider) { String authProviderStart(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'email': '使用邮箱开始', provider,
'kakao': '使用 Kakao 开始', {
'line': '使用 LINE 开始', 'email': '使用邮箱开始',
'apple': '使用 Apple 开始', 'kakao': '使用 Kakao 开始',
'other': '使用 Google 开始', 'line': '使用 LINE 开始',
}); 'apple': '使用 Apple 开始',
'other': '使用 Google 开始',
},
);
return '$_temp0'; return '$_temp0';
} }
@override @override
String authProviderName(String provider) { String authProviderName(String provider) {
String _temp0 = intl.Intl.selectLogic(provider, { String _temp0 = intl.Intl.selectLogic(
'email': '邮箱', provider,
'kakao': 'Kakao', {
'line': 'LINE', 'email': '邮箱',
'apple': 'Apple', 'kakao': 'Kakao',
'other': 'Google', 'line': 'LINE',
}); 'apple': 'Apple',
'other': 'Google',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -1292,8 +1304,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get payEmpty => '暂无支付记录。'; String get payEmpty => '暂无支付记录。';
@override @override
String get payRefundGuide => String get payRefundGuide => '消耗型商品:购买后 7 天内且未使用可退款,已部分使用的商品不予退款。\n订阅型商品:支付后 24 小时内未使用权益(如额外奖励)可退款。\n退款批准后 7–14 天内完成退款。';
'消耗型商品:购买后 7 天内且未使用可退款,已部分使用的商品不予退款。\n订阅型商品:支付后 24 小时内未使用权益(如额外奖励)可退款。\n退款批准后 7–14 天内完成退款。';
@override @override
String get payDetailTitle => '支付详情'; String get payDetailTitle => '支付详情';
@@ -1807,6 +1818,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String get historyFounderBadge => '前100名纪念徽章'; String get historyFounderBadge => '前100名纪念徽章';
@override
String get historyHistoric => '历史性事件';
@override @override
String historyGrowthPop(String amount) { String historyGrowthPop(String amount) {
return '$amount POP 计入成长'; return '$amount POP 计入成长';
@@ -1829,31 +1843,34 @@ class AppLocalizationsZh extends AppLocalizations {
@override @override
String countryName(String code) { String countryName(String code) {
String _temp0 = intl.Intl.selectLogic(code, { String _temp0 = intl.Intl.selectLogic(
'KR': '韩国', code,
'JP': '日本', {
'US': '美国', 'KR': '韩国',
'CN': '中国', 'JP': '日本',
'TW': '台湾', 'US': '美国',
'HK': '香港', 'CN': '中国',
'TH': '泰国', 'TW': '台湾',
'VN': '越南', 'HK': '香港',
'ID': '印度尼西亚', 'TH': '泰国',
'PH': '菲律宾', 'VN': '越南',
'MY': '马来西亚', 'ID': '印度尼西亚',
'SG': '新加坡', 'PH': '菲律宾',
'IN': '印度', 'MY': '马来西亚',
'GB': '英国', 'SG': '新加坡',
'FR': '法国', 'IN': '印度',
'DE': '德国', 'GB': '英国',
'ES': '西班牙', 'FR': '法国',
'BR': '巴西', 'DE': '德国',
'MX': '墨西哥', 'ES': '西班牙',
'CA': '加拿大', 'BR': '巴西',
'AU': '澳大利亚', 'MX': '墨西哥',
'ZZ': '未指定', 'CA': '加拿大',
'other': '-', 'AU': '澳大利亚',
}); 'ZZ': '未指定',
'other': '-',
},
);
return '$_temp0'; return '$_temp0';
} }
@@ -2368,12 +2385,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get campaignWeekdays => '日,一,二,三,四,五,六'; String get campaignWeekdays => '日,一,二,三,四,五,六';
@override @override
String campaignPeriodRange( String campaignPeriodRange(String startMonth, String startDay, String endMonth, String endDay) {
String startMonth,
String startDay,
String endMonth,
String endDay,
) {
return '$startMonth月$startDay日 ~ $endMonth月$endDay日'; return '$startMonth月$startDay日 ~ $endMonth月$endDay日';
} }
@@ -2417,12 +2429,7 @@ class AppLocalizationsZh extends AppLocalizations {
String get campaignSubmitTitle => '按此内容提交吗?'; String get campaignSubmitTitle => '按此内容提交吗?';
@override @override
String campaignSubmitBody( String campaignSubmitBody(String title, String goal, String days, String fee) {
String title,
String goal,
String days,
String fee,
) {
return '$title · 目标 $goal POP · $days天\n将扣除 $fee POP 登记费,运营审核后开放筹集。'; return '$title · 目标 $goal POP · $days天\n将扣除 $fee POP 登记费,运营审核后开放筹集。';
} }
@@ -2629,14 +2636,7 @@ class AppLocalizationsZh extends AppLocalizations {
} }
@override @override
String questCreateCost( String questCreateCost(String title, String reward, String count, String pool, String fee, String total) {
String title,
String reward,
String count,
String pool,
String fee,
String total,
) {
return '$title\n奖励 $reward POP × $count人 = $pool POP\n手续费 $fee POP(发布时销毁)\n共扣除 $total POP。'; return '$title\n奖励 $reward POP × $count人 = $pool POP\n手续费 $fee POP(发布时销毁)\n共扣除 $total POP。';
} }
+1
View File
@@ -544,6 +544,7 @@
"historyEclipseSuccess": "参与日食防御成功", "historyEclipseSuccess": "参与日食防御成功",
"historyEmblemEvolution": "进化纪念徽章", "historyEmblemEvolution": "进化纪念徽章",
"historyFounderBadge": "前100名纪念徽章", "historyFounderBadge": "前100名纪念徽章",
"historyHistoric": "历史性事件",
"historyGrowthPop": "{amount} POP 计入成长", "historyGrowthPop": "{amount} POP 计入成长",
"historyJoined": "你参与了这次进化", "historyJoined": "你参与了这次进化",
"historyLoadFailed": "无法加载记录。", "historyLoadFailed": "无法加载记录。",
@@ -934,6 +934,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override @override
String get historyHibernation => _remote['historyHibernation'] ?? _base.historyHibernation; String get historyHibernation => _remote['historyHibernation'] ?? _base.historyHibernation;
@override
String get historyHistoric => _remote['historyHistoric'] ?? _base.historyHistoric;
@override @override
String get historyJoined => _remote['historyJoined'] ?? _base.historyJoined; String get historyJoined => _remote['historyJoined'] ?? _base.historyJoined;
+134
View File
@@ -0,0 +1,134 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fancake/core/data/repositories.dart'
show eclipseLook, eclipseWarnWindow;
import 'package:fancake/core/theme/app_theme.dart';
import 'package:fancake/core/ui/planet_25d.dart';
import 'package:fancake/features/eclipse/eclipse_data.dart';
import 'package:fancake/features/eclipse/eclipse_defend_screen.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'eclipse_stubs.dart';
/// UX·FX v1.5 §5 사전 경고 「그림자 접근」 · §5.1 「끊어진 Ring → 완전한 Shield」
/// (진척현황 부록 B-3 5-1 · 5-4).
void main() {
final warn = DateTime(2026, 10, 8, 7, 47);
final start = DateTime(2026, 10, 8, 7, 50);
test('그림자 접근 진행 — 경고 시각 0 → 발생 시각 1, 경고 전 · 창 없음은 null', () {
expect(eclipseApproach(null, start, warn), isNull);
expect(eclipseApproach(warn, start, warn.subtract(const Duration(seconds: 1))), isNull);
expect(eclipseApproach(warn, start, warn), 0);
expect(eclipseApproach(warn, start, warn.add(const Duration(seconds: 90))), 0.5);
expect(
eclipseApproach(warn, start, start.add(const Duration(seconds: 3))),
1,
reason: '서버가 회차를 열기 전 지연 동안 그림자가 빠지지 않는다',
);
});
test('경보 단계 회차 — 이클립스 감소가 아니라 경고 창만', () {
final scheduled = EclipseState(
fandomId: 'bts',
eclipseId: 'ecl-2',
fandomName: 'BTS',
erosionRate: 0,
defense: 0,
targetDefense: 10,
defenseCount: 0,
defended: false,
scheduled: true,
warnAt: warn,
startAt: start,
endsAt: start.add(const Duration(seconds: 60)),
);
expect(eclipseLook(scheduled, warn), (false, false, null));
expect(eclipseWarnWindow(scheduled), (warn, start));
expect(eclipseWarnWindow(eclipseFixture()), isNull, reason: '진행 중 회차');
expect(eclipseWarnWindow(null), isNull);
});
group('행성 그림자', () {
Widget planet({DateTime? warnAt, DateTime? startAt, bool eclipsed = false}) =>
MaterialApp(
home: Center(
child: Planet25D(
image: 'assets/figma/planet-home.png',
size: 200,
stage: 3,
isEclipsed: eclipsed,
eclipseWarnAt: warnAt,
eclipseStartAt: startAt,
),
),
);
final now = DateTime.now();
final w = now.subtract(const Duration(seconds: 90));
final s = now.add(const Duration(seconds: 90));
testWidgets('경고 창 안이면 그림자를 그린다', (tester) async {
await tester.pumpWidget(planet(warnAt: w, startAt: s));
expect(find.byKey(const ValueKey('eclipse-shadow')), findsOneWidget);
});
testWidgets('경고가 없거나 이미 이클립스 진행 중이면 그리지 않는다', (tester) async {
await tester.pumpWidget(planet());
expect(find.byKey(const ValueKey('eclipse-shadow')), findsNothing);
await tester.pumpWidget(planet(warnAt: w, startAt: s, eclipsed: true));
expect(find.byKey(const ValueKey('eclipse-shadow')), findsNothing);
});
});
test('Shield Ring — 처음엔 끊어진 조각, 참여자만큼 이어지고 목표 달성이면 원 하나', () {
const slot = 2 * math.pi / shieldSegments;
final broken = shieldArcs(0);
expect(broken, hasLength(shieldSegments));
expect(broken.first.$2, closeTo(slot * 0.5, 1e-9));
expect(shieldArcs(0.5).first.$2, closeTo(slot * 0.75, 1e-9));
expect(shieldArcs(0.99).first.$2, lessThan(slot));
expect(shieldArcs(1), [(-math.pi / 2, 2 * math.pi)]);
});
testWidgets('방어 화면 Shield 는 서버 고유 참여자 / 목표 비율을 따른다', (tester) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 2;
addTearDown(tester.view.reset);
final shakes = StreamController<DateTime>.broadcast();
addTearDown(shakes.close);
final repo = FakeEclipseRepository(
state: eclipseFixture(
defense: 3,
targetDefense: 4,
startAt: DateTime.now().subtract(const Duration(seconds: 1)),
endsAt: DateTime.now().add(const Duration(seconds: 59)),
),
);
addTearDown(repo.dispose);
await tester.pumpWidget(
MaterialApp(
theme: buildAppTheme(),
home: EclipseDefendScreen(
repository: repo,
fandomId: 'bts',
shakes: () => shakes.stream,
),
),
);
for (var i = 0; i < 6; i++) {
await tester.pump(const Duration(milliseconds: 120));
}
final ring = tester
.widgetList<CustomPaint>(find.descendant(
of: find.byKey(const ValueKey('shield-ring')),
matching: find.byType(CustomPaint),
))
.map((p) => p.painter)
.whereType<ShieldRingPainter>()
.single;
expect(ring.progress, 0.75);
await tester.pumpWidget(const SizedBox());
});
}
+10
View File
@@ -359,6 +359,16 @@ void main() {
expect(find.text('NEWS'), findsNothing); expect(find.text('NEWS'), findsNothing);
}); });
testWidgets('B-3 8-4 — 카드 줄에 출처명 · 게시시각이 보인다(상세 시트를 열지 않아도)', (tester) async {
await tester.pumpWidget(MaterialApp(
theme: buildAppTheme(),
home: Scaffold(body: BriefingList(items: [_brief('출처가 보이는 줄')])),
));
expect(find.text('연합뉴스 · ${briefingStamp(DateTime(2026, 9, 14, 8))}'), findsOneWidget);
expect(briefingStamp(DateTime(2026, 9, 4, 7, 5)), '2026.09.04 07:05');
expect(find.text('AI 요약'), findsNothing);
});
testWidgets('줄을 누르면 B-75 5 요소 상세가 뜨고, 원문 보기로 나간다', (tester) async { testWidgets('줄을 누르면 B-75 5 요소 상세가 뜨고, 원문 보기로 나간다', (tester) async {
final opened = <String>[]; final opened = <String>[];
tester.view.physicalSize = const Size(1080, 2400); tester.view.physicalSize = const Size(1080, 2400);
+105 -24
View File
@@ -48,20 +48,34 @@ class _Backend extends Backend {
class _PersonalHistory extends SamplePlanetRepository { class _PersonalHistory extends SamplePlanetRepository {
final scopes = <bool>[]; final scopes = <bool>[];
@override @override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async { Future<PlanetHistoryPage> history({
String? cursor,
bool personal = false,
}) async {
scopes.add(personal); scopes.add(personal);
return PlanetHistoryPage(items: [PlanetHistoryEntry( return PlanetHistoryPage(
id: 'one', kind: personal ? PlanetHistoryKind.zap : PlanetHistoryKind.evolution, items: [
occurredAt: DateTime.utc(2026, 9, 22), fromStage: personal ? null : 1, PlanetHistoryEntry(
toStage: personal ? null : 2, amount: personal ? 37 : null, id: 'one',
)], nextCursor: personal ? null : 'older'); kind: personal ? PlanetHistoryKind.zap : PlanetHistoryKind.evolution,
occurredAt: DateTime.utc(2026, 9, 22),
fromStage: personal ? null : 1,
toStage: personal ? null : 2,
amount: personal ? 37 : null,
),
],
nextCursor: personal ? null : 'older',
);
} }
} }
class _History extends SamplePlanetRepository { class _History extends SamplePlanetRepository {
final cursors = <String?>[]; final cursors = <String?>[];
@override @override
Future<PlanetHistoryPage> history({String? cursor, bool personal = false}) async { Future<PlanetHistoryPage> history({
String? cursor,
bool personal = false,
}) async {
cursors.add(cursor); cursors.add(cursor);
if (cursors.length == 2) throw StateError('private details'); if (cursors.length == 2) throw StateError('private details');
return PlanetHistoryPage( return PlanetHistoryPage(
@@ -83,9 +97,11 @@ class _History extends SamplePlanetRepository {
void main() { void main() {
testWidgets('내 활동 전환은 이전 목록과 커서를 지우고 실제 성장량을 표시한다', (tester) async { testWidgets('내 활동 전환은 이전 목록과 커서를 지우고 실제 성장량을 표시한다', (tester) async {
final repository = _PersonalHistory(); final repository = _PersonalHistory();
await tester.pumpWidget(MaterialApp(home: PlanetHistoryScreen( await tester.pumpWidget(
repository: repository, fandomName: '팬덤', MaterialApp(
))); home: PlanetHistoryScreen(repository: repository, fandomName: '팬덤'),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
await tester.tap(find.text('내 활동')); await tester.tap(find.text('내 활동'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -101,10 +117,15 @@ void main() {
const repository = SamplePlanetRepository(); const repository = SamplePlanetRepository();
final states = StreamController<PlanetVisualState>.broadcast(); final states = StreamController<PlanetVisualState>.broadcast();
addTearDown(states.close); addTearDown(states.close);
await tester.pumpWidget(MaterialApp(home: PlanetDetailScreen( await tester.pumpWidget(
repository: repository, eclipse: const NoEclipseRepository(), MaterialApp(
watchPlanet: (_) => states.stream, home: PlanetDetailScreen(
))); repository: repository,
eclipse: const NoEclipseRepository(),
watchPlanet: (_) => states.stream,
),
),
);
await tester.pumpAndSettle(); await tester.pumpAndSettle();
states.add(const PlanetVisualState(stage: 1)); states.add(const PlanetVisualState(stage: 1));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
@@ -114,7 +135,12 @@ void main() {
await tester.pump(const Duration(seconds: 4)); await tester.pump(const Duration(seconds: 4));
await tester.tap(find.text('PASSPORT 기록 보기')); await tester.tap(find.text('PASSPORT 기록 보기'));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
expect(tester.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen)).repository, same(repository)); expect(
tester
.widget<PlanetHistoryScreen>(find.byType(PlanetHistoryScreen))
.repository,
same(repository),
);
await tester.pumpWidget(const SizedBox()); await tester.pumpWidget(const SizedBox());
}); });
@@ -131,26 +157,54 @@ void main() {
'nextCursor': null, 'nextCursor': null,
}; };
await expectLater(repository.history(), throwsStateError); await expectLater(repository.history(), throwsStateError);
backend.response = {'items': [{'id': 'zap', 'kind': 'zap', backend.response = {
'occurredAt': '2026-09-22T00:00:00Z', 'amount': 37, 'participationConfirmed': true}], 'nextCursor': null}; 'items': [
{
'id': 'zap',
'kind': 'zap',
'occurredAt': '2026-09-22T00:00:00Z',
'amount': 37,
'participationConfirmed': true,
},
],
'nextCursor': null,
};
final personal = await repository.history(personal: true); final personal = await repository.history(personal: true);
expect(backend.payload, {'fandomId': 'target', 'scope': 'personal'}); expect(backend.payload, {'fandomId': 'target', 'scope': 'personal'});
expect(personal.items.single.amount, 37); expect(personal.items.single.amount, 37);
expect(personal.items.single.participationConfirmed, isFalse); expect(personal.items.single.participationConfirmed, isFalse);
// PASSPORT Stamp 는 내 기록에서만 받는다(FX §5.2 · §7.2). // PASSPORT Stamp 는 내 기록에서만 받는다(FX §5.2 · §7.2).
backend.response = {'items': [ backend.response = {
{'id': 'e', 'kind': 'eclipse_success', 'occurredAt': '2026-09-23T00:00:00Z'}, 'items': [
{'id': 'v', 'kind': 'emblem_evolution', 'occurredAt': '2026-09-22T00:00:00Z', 'toStage': 3}, {
{'id': 'f', 'kind': 'badge_founder100', 'occurredAt': '2026-09-21T00:00:00Z', 'memberNo': 7}, 'id': 'e',
], 'nextCursor': null}; 'kind': 'eclipse_success',
'occurredAt': '2026-09-23T00:00:00Z',
},
{
'id': 'v',
'kind': 'emblem_evolution',
'occurredAt': '2026-09-22T00:00:00Z',
'toStage': 3,
},
{
'id': 'f',
'kind': 'badge_founder100',
'occurredAt': '2026-09-21T00:00:00Z',
'memberNo': 7,
},
],
'nextCursor': null,
};
final stamps = await repository.history(personal: true); final stamps = await repository.history(personal: true);
expect(stamps.items.map((i) => i.kind), [ expect(stamps.items.map((i) => i.kind), [
PlanetHistoryKind.eclipseSuccess, PlanetHistoryKind.emblemEvolution, PlanetHistoryKind.founderBadge, PlanetHistoryKind.eclipseSuccess,
PlanetHistoryKind.emblemEvolution,
PlanetHistoryKind.founderBadge,
]); ]);
expect(stamps.items[1].toStage, 3); expect(stamps.items[1].toStage, 3);
expect(stamps.items[2].memberNo, 7); expect(stamps.items[2].memberNo, 7);
await expectLater(repository.history(), throwsStateError); await expectLater(repository.history(), throwsStateError);
}); });
testWidgets('행성 상세에서 같은 저장소의 팬덤 역사를 연다', (tester) async { testWidgets('행성 상세에서 같은 저장소의 팬덤 역사를 연다', (tester) async {
@@ -211,4 +265,31 @@ void main() {
expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsOneWidget); expect(find.text('아직 기록된 팬덤 역사가 없습니다.'), findsOneWidget);
expect(find.text('다시 시도'), findsNothing); expect(find.text('다시 시도'), findsNothing);
}); });
test('UX·FX §7.2 — 진화 · Eclipse 성공 · 기념 Badge 만 역사적 사건', () {
expect(PlanetHistoryKind.values.where((k) => k.isHistoric).toSet(), {
PlanetHistoryKind.evolution,
PlanetHistoryKind.eclipseSuccess,
PlanetHistoryKind.emblemEvolution,
PlanetHistoryKind.founderBadge,
});
});
testWidgets('역사적 사건 줄에만 「역사적 사건」 표지가 붙는다', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: PlanetHistoryScreen(
repository: _PersonalHistory(),
fandomName: '팬덤',
),
),
);
await tester.pumpAndSettle();
expect(find.text('Stage 1 → 2 진화'), findsOneWidget);
expect(find.text('역사적 사건'), findsOneWidget);
await tester.tap(find.text('내 활동'));
await tester.pumpAndSettle();
expect(find.text('ZAP 기여'), findsOneWidget);
expect(find.text('역사적 사건'), findsNothing);
});
} }