앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:cloud_functions/cloud_functions.dart';
|
||||
import 'package:firebase_auth/firebase_auth.dart';
|
||||
import 'package:firebase_storage/firebase_storage.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 서버 경계 — 앱에서 Firestore·Cloud Functions 로 나가는 유일한 통로.
|
||||
///
|
||||
@@ -19,9 +21,11 @@ class Backend {
|
||||
FirebaseFirestore? firestore,
|
||||
FirebaseFunctions? functions,
|
||||
FirebaseAuth? auth,
|
||||
FirebaseStorage? storage,
|
||||
}) : _firestore = firestore ?? FirebaseFirestore.instance,
|
||||
_functions = functions,
|
||||
_auth = auth ?? FirebaseAuth.instance;
|
||||
_auth = auth ?? FirebaseAuth.instance,
|
||||
_storage = storage;
|
||||
|
||||
static const String region = 'asia-northeast3';
|
||||
|
||||
@@ -35,15 +39,24 @@ class Backend {
|
||||
FirebaseFunctions get functions =>
|
||||
_functions ??= FirebaseFunctions.instanceFor(region: region);
|
||||
|
||||
/// 사용자가 올리는 파일(프로필 사진 등)이 나가는 곳. Functions 와 같은 이유로
|
||||
/// 실제로 쓰기 전까지 만들지 않는다.
|
||||
FirebaseStorage? _storage;
|
||||
|
||||
FirebaseStorage get storage => _storage ??= FirebaseStorage.instance;
|
||||
|
||||
FirebaseFirestore get db => _firestore;
|
||||
|
||||
String? get uid => _auth.currentUser?.uid;
|
||||
|
||||
/// 로그인 계정 이메일. 닉네임이 없을 때 화면이 부르는 이름의 재료다.
|
||||
String? get email => _auth.currentUser?.email;
|
||||
|
||||
/// 로그인 필수 경로에서 UID 를 강제로 얻는다.
|
||||
String get requireUid {
|
||||
final value = uid;
|
||||
if (value == null) {
|
||||
throw const BackendException('E-AUTH-03', '로그인이 필요합니다.');
|
||||
throw BackendException('E-AUTH-03', appL10n.authLoginRequired);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -63,13 +76,34 @@ class Backend {
|
||||
if (data is Map) return Map<String, dynamic>.from(data);
|
||||
return const {};
|
||||
} on FirebaseFunctionsException catch (error) {
|
||||
throw BackendException(
|
||||
_catalogCode(error),
|
||||
error.message ?? '요청을 처리하지 못했습니다.',
|
||||
);
|
||||
final code = _catalogCode(error);
|
||||
throw BackendException(code, _message(code, error));
|
||||
}
|
||||
}
|
||||
|
||||
/// 앱이 문구를 가진 카탈로그 코드는 표시 언어로 바꾼다. 나머지는 서버 사유 그대로.
|
||||
static String _message(String code, FirebaseFunctionsException error) {
|
||||
final details = error.details;
|
||||
return switch (code) {
|
||||
// 성운 창립자 탄생 전 탈퇴(Q-PN-03).
|
||||
'E-FDM-05' => appL10n.fandomFounderLeaveBlocked,
|
||||
// 검증 활동(Quest 완료) 미달 — 서버가 `done` · `need` 를 싣는다(Campaign 개설자격 A안).
|
||||
'E-CMP-04'
|
||||
when details is Map &&
|
||||
details['done'] is int &&
|
||||
details['need'] is int =>
|
||||
appL10n.campaignNeedsQuests(
|
||||
'${details['done']}',
|
||||
'${details['need']}',
|
||||
'${(details['need'] as int) - (details['done'] as int)}',
|
||||
),
|
||||
// 보상 없는 사용자 생성 Quest 하루 상한 — 서버가 `cap` 을 싣는다(Q-UQ-02 보완).
|
||||
'E-QST-04' when details is Map && details['cap'] is int =>
|
||||
appL10n.questFreeDailyCap('${details['cap']}'),
|
||||
_ => error.message ?? appL10n.commonRequestFailed,
|
||||
};
|
||||
}
|
||||
|
||||
static String _catalogCode(FirebaseFunctionsException error) {
|
||||
final details = error.details;
|
||||
if (details is Map && details['code'] is String) {
|
||||
@@ -97,6 +131,29 @@ class BackendException implements Exception {
|
||||
String toString() => 'BackendException($code): $message';
|
||||
}
|
||||
|
||||
/// 다른 사용자에게도 보이는 이름 — 정책 원장 B-52 · G-07
|
||||
///
|
||||
/// 닉네임이 비었거나 **이메일 형태(`@` 포함)**면 [fallback] 을 쓴다. 예전에는 닉네임이
|
||||
/// 없을 때 이메일 앞부분을 이름으로 썼고, 그 값이 닉네임으로 저장돼 홈 기여자명에
|
||||
/// 노출됐다. UID 도 이름 자리에 넣지 않는다.
|
||||
///
|
||||
/// 서버 · 규칙은 이름 없는 작성자를 「팬」([storedFanName])으로 저장한다 — 읽을 때는 그 값도
|
||||
/// 지금 언어의 「팬」으로 바꿔 보여 준다.
|
||||
String publicName(Object? raw, [String? fallback]) {
|
||||
final v = raw is String ? raw.trim() : '';
|
||||
return v.isEmpty || v.contains('@') || v == storedFanName
|
||||
? (fallback ?? appL10n.commonFan)
|
||||
: v;
|
||||
}
|
||||
|
||||
/// 문서에 싣는 이름 없는 작성자 표기. 규칙 `publicNameOf` · 서버 `publicNameOf` 와 같은 값이라
|
||||
/// 언어와 무관하게 고정이다(번역된 「Fan」을 실으면 규칙이 사칭으로 거절한다).
|
||||
const String storedFanName = '팬';
|
||||
|
||||
/// 내 이름을 문서에 실을 때 — [publicName] 과 같되 대체값은 언어 무관 [storedFanName].
|
||||
String storedPublicName(Object? nickname) =>
|
||||
publicName(nickname, storedFanName);
|
||||
|
||||
/// Firestore 문서에서 값을 꺼내는 도우미.
|
||||
///
|
||||
/// 서버 스키마가 앞서 나가거나 필드가 아직 채워지지 않은 문서가 섞일 수 있어,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import 'backend.dart';
|
||||
|
||||
/// 내 초대 코드 — 공유 링크 `?ref=` 에 붙는다(원장 B-69 추천).
|
||||
///
|
||||
/// 세션당 한 번 `fnInviteCodeGet` 을 부른다(없으면 서버가 만든다). 공유는 이 값을 기다리지
|
||||
/// 않는다 — 아직 없거나 실패했으면 `?ref=` 없이 나간다. 계정이 바뀌면 다시 받는다.
|
||||
class InviteCode {
|
||||
InviteCode._();
|
||||
|
||||
/// 받아 둔 코드. 없으면 null.
|
||||
static String? current;
|
||||
|
||||
static String? _uid;
|
||||
static Future<void>? _fetch;
|
||||
|
||||
static Future<void> ensure(Backend backend) {
|
||||
final uid = backend.uid;
|
||||
if (uid == null) return Future.value();
|
||||
if (uid != _uid) {
|
||||
_uid = uid;
|
||||
current = null;
|
||||
_fetch = null;
|
||||
}
|
||||
return _fetch ??= _load(backend, uid);
|
||||
}
|
||||
|
||||
static Future<void> _load(Backend backend, String uid) async {
|
||||
try {
|
||||
final code = (await backend.call('fnInviteCodeGet'))['code'];
|
||||
if (_uid == uid && code is String && code.isNotEmpty) current = code;
|
||||
} catch (_) {
|
||||
if (_uid == uid) _fetch = null; // 다음 기회에 다시 받는다.
|
||||
}
|
||||
}
|
||||
|
||||
/// 테스트가 회차마다 처음 상태로 돌린다.
|
||||
static void reset() {
|
||||
current = null;
|
||||
_uid = null;
|
||||
_fetch = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
/// 화면은 이 서버 확정 상태를 읽기만 한다. 승급 조건/환산계수를 계산하지 않는다.
|
||||
class PlanetVisualState {
|
||||
const PlanetVisualState({
|
||||
this.stage,
|
||||
this.level,
|
||||
this.isEclipsed = false,
|
||||
this.isHibernating = false,
|
||||
this.isHeroTime = false,
|
||||
this.heroEventIds,
|
||||
this.heroEndsAt,
|
||||
this.heroNicknames = const [],
|
||||
this.heroClaimCount,
|
||||
this.colorKey,
|
||||
});
|
||||
final int? stage;
|
||||
|
||||
/// 생성 때 고른 행성 컬러 키(C-50 · `fn-planet-create`). 성운이면 null 이다.
|
||||
final String? colorKey;
|
||||
final int? level;
|
||||
final bool isEclipsed;
|
||||
final bool isHibernating;
|
||||
final bool isHeroTime;
|
||||
|
||||
/// 남아 있는 HERO 이벤트 중 마지막 종료시각. 운영값을 추정하지 않는다.
|
||||
final DateTime? heroEndsAt;
|
||||
final List<String> heroNicknames;
|
||||
/// 활성 이벤트 수령 건수 합계. 여러 이벤트의 고유 참여자 수는 아니다.
|
||||
final int? heroClaimCount;
|
||||
|
||||
/// null은 아직 최초 서버 이벤트 조회가 끝나지 않은 상태다.
|
||||
final Set<String>? heroEventIds;
|
||||
|
||||
factory PlanetVisualState.fromDocument(Map<String, dynamic> data) =>
|
||||
PlanetVisualState(
|
||||
isHibernating: data['isHibernating'] == true,
|
||||
colorKey: data['colorKey'] is String ? data['colorKey'] as String : null,
|
||||
stage: switch (data['stage']) {
|
||||
int n when n >= 0 && n <= 6 => n,
|
||||
_ => null,
|
||||
},
|
||||
level: switch (data['level']) {
|
||||
int n when n >= 0 => n,
|
||||
_ => null,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
typedef PlanetWatch = Stream<PlanetVisualState> Function(String fandomId);
|
||||
Stream<PlanetVisualState> noPlanetWatch(String fandomId) =>
|
||||
const Stream.empty();
|
||||
@@ -0,0 +1,258 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'dart:async';
|
||||
|
||||
import '../config/feature_flags.dart';
|
||||
import '../monetization/store.dart';
|
||||
import '../firebase/firebase_boot.dart';
|
||||
import '../push/push_messaging.dart';
|
||||
import '../../features/galaxy/galaxy_data.dart';
|
||||
import '../../features/galaxy/galaxy_repository_firestore.dart';
|
||||
import '../../features/home/home_data.dart';
|
||||
import '../../features/home/home_repository_firestore.dart';
|
||||
import '../../features/inside/inside_data.dart';
|
||||
import '../../features/inside/inside_repository_firestore.dart';
|
||||
import '../../features/auth/auth.dart';
|
||||
import '../../features/auth/firebase_account_linker.dart';
|
||||
import '../../features/campaign/campaign_data.dart';
|
||||
import '../../features/campaign/campaign_repository_firestore.dart';
|
||||
import '../../features/eclipse/eclipse_data.dart';
|
||||
import '../../features/eclipse/eclipse_repository_firestore.dart';
|
||||
import '../../features/my/my_data.dart';
|
||||
import '../../features/notification/notification_data.dart';
|
||||
import '../../features/my/my_repository_firestore.dart';
|
||||
import '../../features/planet/planet_data.dart';
|
||||
import '../../features/quest/quest_data.dart';
|
||||
import '../../features/quest/quest_repository_firestore.dart';
|
||||
import '../../features/planet/planet_repository_firestore.dart';
|
||||
import '../../features/station/station_data.dart';
|
||||
import '../../features/station/station_repository_firestore.dart';
|
||||
import '../../features/translation/translation_data.dart';
|
||||
import 'backend.dart';
|
||||
import 'planet_visual_state.dart';
|
||||
import 'shared_stream.dart';
|
||||
|
||||
/// 저장소 선택 — 서버가 붙어 있으면 Firestore, 아니면 표본.
|
||||
///
|
||||
/// **화면은 이 분기를 모른다.** 화면이 `FirebaseBoot.isLive` 를 읽기 시작하면
|
||||
/// 같은 조건문이 화면 수만큼 늘어나고, 설정 파일이 투입되는 날 전부 고쳐야 한다.
|
||||
/// 갈림길을 여기 한 곳에 둔다.
|
||||
///
|
||||
/// 목 모드에서 표본을 쓰는 이유는 별첨 2 2주차 확인 항목 「앱 설치·로그인·탭 이동
|
||||
/// 확인」 때문이다. 설정 파일이 없다고 화면이 빈 상태로 뜨면 확인 자체가 불가능하다.
|
||||
class Repositories {
|
||||
Repositories({Backend? backend, bool? live})
|
||||
: _live = live ?? FirebaseBoot.isLive,
|
||||
_backend = backend;
|
||||
|
||||
final bool _live;
|
||||
Backend? _backend;
|
||||
|
||||
/// 서버가 붙어 있는가. 저장소 밖에서 Firestore 를 직접 볼 곳(다국어 원격 문자열)이 쓴다.
|
||||
bool get live => _live;
|
||||
|
||||
Backend get backend => _backend ??= Backend();
|
||||
|
||||
HomeRepository get home =>
|
||||
_live ? FirestoreHomeRepository(backend) : const SampleHomeRepository();
|
||||
|
||||
GalaxyRepository get galaxy => _live
|
||||
? FirestoreGalaxyRepository(backend)
|
||||
: const SampleGalaxyRepository();
|
||||
|
||||
/// INSIDE 와 STATION 은 소속 팬덤으로 범위가 갈리지만, 라우터는 그 값을 모른다.
|
||||
/// 구현체가 사용자 문서에서 직접 읽으므로 여기서는 인자를 받지 않는다.
|
||||
/// 화면을 한 번 만들면 그대로 들고 있어야 커서와 캐시가 유지된다.
|
||||
late final InsideRepository inside = _live
|
||||
? FirestoreInsideRepository(backend)
|
||||
: const SampleInsideRepository();
|
||||
|
||||
late final MyRepository my = _live
|
||||
? FirestoreMyRepository(backend)
|
||||
: SampleMyRepository();
|
||||
|
||||
/// 퀘스트는 화면 3개(목록·상세·생성)가 같은 것을 들고 있어야 한다. 목록에서
|
||||
/// 상세로 들어갔다 돌아올 때 페이지 커서와 걸음 세션이 유지되어야 하므로
|
||||
/// 한 번 만들어 계속 쓴다.
|
||||
late final QuestRepository quest = _live
|
||||
? FirestoreQuestRepository(backend)
|
||||
: const SampleQuestRepository();
|
||||
|
||||
/// 캠페인(F5). 목록 · 상세 · 랭킹이 같은 것을 들고 있어야 검색어가 유지된다.
|
||||
/// 예전에는 여기 없어서 서버가 붙어도 표본이 후원을 성공처럼 그렸다.
|
||||
late final CampaignRepository campaign = _live
|
||||
? FirestoreCampaignRepository(backend)
|
||||
: SampleCampaignRepository();
|
||||
|
||||
late final StationRepository station = _live
|
||||
? FirestoreStationRepository(backend)
|
||||
: const SampleStationRepository();
|
||||
|
||||
/// 알림함(F0). 화면을 여는 곳이 HOME·GALAXY 둘이라 저장소를 한 번만 만들어
|
||||
/// 양쪽에 같은 것을 내려 준다.
|
||||
late final NotificationRepository notifications = _live
|
||||
? FirestoreNotificationRepository(backend)
|
||||
: SampleNotificationRepository();
|
||||
|
||||
/// 이클립스. 대상 팬덤은 호출 시점에 정해지므로(내 팬덤이거나 보고 있는 행성)
|
||||
/// 저장소 자체는 하나만 둔다 — 화면마다 새로 만들면 구독이 화면 수만큼 늘어난다.
|
||||
late final EclipseRepository eclipse = _live
|
||||
? FirestoreEclipseRepository(backend)
|
||||
: SampleEclipseRepository();
|
||||
|
||||
/// 참여형 번역(FR-I18N-02). 번역 영역을 붙이는 화면이 같은 것을 쓴다.
|
||||
late final TranslationRepository translation = _live
|
||||
? FirestoreTranslationRepository(backend)
|
||||
: SampleTranslationRepository();
|
||||
|
||||
/// 계정 연결 경계 — 별첨 B v1.4 「하나의 UID 에 복수 소셜 Provider」.
|
||||
/// 목 모드에서는 실제 UID 가 없어 표본이 화면 동선만 유지한다.
|
||||
AccountLinker linker(List<SocialAuthProvider> providers) => _live
|
||||
? FirebaseAccountLinker(providers: providers)
|
||||
: SampleAccountLinker(
|
||||
seed: const {AuthProviderKind.google: 'fan@gmail.com'},
|
||||
);
|
||||
|
||||
/// 푸시 수신 경계 — 정책 원장 B-77. 목 모드에서는 null 이다.
|
||||
///
|
||||
/// 플러그인은 Firebase 가 초기화된 뒤에만 부를 수 있어서 여기서 갈라 둔다.
|
||||
/// null 이면 화면은 권한 재요청 버튼을 그리지 않고 토글 동선만 유지한다.
|
||||
late final PushMessaging? push = _live
|
||||
? PushMessaging(backend: backend)
|
||||
: null;
|
||||
|
||||
/// 인앱 결제 경계. 목 모드에서는 null 이고 화면은 「준비 중」을 띄운다.
|
||||
///
|
||||
/// 결제 결과는 화면이 닫힌 뒤에도 도착하므로(OS 결제 창 · 승인 대기) 앱 전체에서
|
||||
/// 하나를 쓴다. 홈 아이템 시트와 퀘스트 광고 모달이 [Store.instance] 로 찾는다.
|
||||
late final Store? store = _live ? (Store.instance = Store(backend)) : null;
|
||||
|
||||
/// 원격 기능 플래그. 목 모드에서는 구독 없이 전부 꺼진 채로 둔다.
|
||||
late final FeatureFlags flags = FeatureFlags(
|
||||
source: _live ? _featureDocument() : null,
|
||||
);
|
||||
|
||||
/// `config/features` 한 장을 구독한다. 로그인 전에도 읽어야 해서
|
||||
/// `firestore.rules` 가 `config/{id}` 를 `allow read: if true` 로 열어 두었다.
|
||||
Stream<Map<String, Object?>> _featureDocument() => backend.db
|
||||
.doc(FeatureFlags.documentPath)
|
||||
.snapshots()
|
||||
.map((snapshot) => snapshot.data() ?? const {});
|
||||
|
||||
final _planetStates = <String, SharedStream<PlanetVisualState>>{};
|
||||
|
||||
Stream<PlanetVisualState> watchPlanet(String fandomId) {
|
||||
if (!_live || fandomId.isEmpty) return const Stream.empty();
|
||||
return (_planetStates[fandomId] ??= SharedStream(
|
||||
() => _openPlanet(fandomId),
|
||||
)).stream;
|
||||
}
|
||||
|
||||
Stream<PlanetVisualState> _openPlanet(String fandomId) => Stream.multi((out) {
|
||||
PlanetVisualState? planet;
|
||||
var isEclipsed = false;
|
||||
var isHeroTime = false;
|
||||
Set<String>? heroEventIds;
|
||||
Timer? heroExpiry;
|
||||
DateTime? heroEndsAt;
|
||||
List<String> heroNicknames = const [];
|
||||
int? heroClaimCount;
|
||||
void emit() {
|
||||
final current = planet;
|
||||
if (current != null)
|
||||
out.add(
|
||||
PlanetVisualState(
|
||||
stage: current.stage,
|
||||
colorKey: current.colorKey,
|
||||
isHibernating: current.isHibernating,
|
||||
level: current.level,
|
||||
isEclipsed: isEclipsed,
|
||||
isHeroTime: isHeroTime,
|
||||
heroEventIds: heroEventIds,
|
||||
heroEndsAt: heroEndsAt,
|
||||
heroNicknames: heroNicknames,
|
||||
heroClaimCount: heroClaimCount,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final document = backend.db
|
||||
.doc('fandoms/$fandomId')
|
||||
.snapshots(includeMetadataChanges: true)
|
||||
.where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites)
|
||||
.listen((s) {
|
||||
planet = PlanetVisualState.fromDocument(s.data() ?? const {});
|
||||
emit();
|
||||
}, onError: out.addError);
|
||||
final danger = eclipse.watch(fandomId: fandomId).listen((state) {
|
||||
isEclipsed =
|
||||
state != null && !state.defended && !state.isExpired(DateTime.now());
|
||||
emit();
|
||||
}, onError: out.addError);
|
||||
// 기존 선물 이벤트만 읽는다. 구매 시도/로컬 캐시는 축제 확정이 아니다.
|
||||
final gifts = backend.db
|
||||
.collection('fandoms/$fandomId/heroDrops')
|
||||
.where('endsAt', isGreaterThan: Timestamp.now())
|
||||
.snapshots(includeMetadataChanges: true)
|
||||
.where((s) => !s.metadata.isFromCache && !s.metadata.hasPendingWrites)
|
||||
.listen((snapshot) {
|
||||
void refresh() {
|
||||
heroExpiry?.cancel();
|
||||
final now = DateTime.now();
|
||||
final ends = <DateTime>[];
|
||||
final ids = <String>{};
|
||||
final names = <String>{};
|
||||
var claims = 0;
|
||||
var countsKnown = true;
|
||||
for (final doc in snapshot.docs) {
|
||||
final d = doc.data();
|
||||
final end = d['endsAt'];
|
||||
final remaining = d['remainingPop'];
|
||||
if (end is Timestamp &&
|
||||
end.toDate().isAfter(now) &&
|
||||
remaining is num &&
|
||||
remaining.isFinite &&
|
||||
remaining > 0) {
|
||||
ends.add(end.toDate());
|
||||
ids.add(doc.id);
|
||||
// 닉네임 없는 영웅(저장값 「팬」 · 빈 값)은 이름을 띄우지 않는다 — 서버가 「팬」을
|
||||
// 싣기 전과 같은 표시(M-7).
|
||||
final nickname = publicName(d['heroNickname'], '');
|
||||
if (nickname.isNotEmpty) names.add(nickname);
|
||||
final count = d['claimedCount'];
|
||||
if (count is int && count >= 0) {
|
||||
claims += count;
|
||||
} else {
|
||||
countsKnown = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
heroEventIds = Set.unmodifiable(ids);
|
||||
heroNicknames = List.unmodifiable(names);
|
||||
heroClaimCount = ids.isNotEmpty && countsKnown ? claims : null;
|
||||
ends.sort();
|
||||
heroEndsAt = ends.lastOrNull;
|
||||
isHeroTime = ends.isNotEmpty;
|
||||
emit();
|
||||
if (ends.isNotEmpty) {
|
||||
heroExpiry = Timer(ends.first.difference(now), refresh);
|
||||
}
|
||||
}
|
||||
|
||||
refresh();
|
||||
}, onError: out.addError);
|
||||
out.onCancel = () async {
|
||||
heroExpiry?.cancel();
|
||||
await gifts.cancel();
|
||||
await document.cancel();
|
||||
await danger.cancel();
|
||||
};
|
||||
});
|
||||
|
||||
/// 행성 상세는 팬덤 하나를 지정해야 하므로 호출부가 ID 를 준다.
|
||||
PlanetRepository planet(String? fandomId) =>
|
||||
_live && fandomId != null && fandomId.isNotEmpty
|
||||
? FirestorePlanetRepository(backend, fandomId: fandomId)
|
||||
// 목 모드에서도 누른 행성을 그려야 한다 — ID 를 버리면 어느 HUD 를
|
||||
// 눌러도 같은 상세가 열린다.
|
||||
: SamplePlanetRepository(fandomId: fandomId);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'backend.dart';
|
||||
|
||||
/// 서버 시각 — 단말 시계와 서버 시계의 차이를 한 번 재어 앱 전체가 같이 쓴다.
|
||||
///
|
||||
/// 타임어택 창(`quests.activeFrom`~`activeTo`)은 서버 시각이다. 단말 시계로 세면 시계를
|
||||
/// 돌린 단말에서 마감이 늘어나 보인다. 판정은 서버가 다시 하므로 이것은 표시용이다.
|
||||
class ServerClock {
|
||||
ServerClock._();
|
||||
|
||||
/// 서버 − 단말. 재지 못했으면 0 이다(단말 시계 그대로).
|
||||
static Duration offset = Duration.zero;
|
||||
|
||||
static Future<void>? _synced;
|
||||
|
||||
static DateTime now() => DateTime.now().add(offset);
|
||||
|
||||
/// 세션당 한 번 `fnServerTime` 을 부른다. 실패하면 단말 시계로 두고 넘어간다.
|
||||
static Future<void> ensure(Backend backend) => _synced ??= _sync(backend);
|
||||
|
||||
static Future<void> _sync(Backend backend) async {
|
||||
try {
|
||||
final sent = DateTime.now();
|
||||
final reply = await backend.call('fnServerTime');
|
||||
final received = DateTime.now();
|
||||
final server = reply['now'];
|
||||
if (server is! num) return;
|
||||
// 왕복의 가운데를 서버가 답한 시각으로 본다.
|
||||
final middle = sent.add(received.difference(sent) ~/ 2);
|
||||
offset = DateTime.fromMillisecondsSinceEpoch(server.round()).difference(middle);
|
||||
} catch (_) {
|
||||
_synced = null; // 다음 기회에 다시 잰다.
|
||||
}
|
||||
}
|
||||
|
||||
/// 테스트가 회차마다 처음 상태로 돌린다.
|
||||
static void reset() {
|
||||
offset = Duration.zero;
|
||||
_synced = null;
|
||||
}
|
||||
}
|
||||
@@ -41,8 +41,8 @@ abstract final class FirebaseBoot {
|
||||
/// 설정 파일이 없을 때 투입해야 하는 파일 목록.
|
||||
/// **리포 경로다 — 로그·문서 전용이고 화면에 내보내지 마라.** 화면에는 [missingLabels] 를 쓴다.
|
||||
static const List<String> requiredConfigFiles = [
|
||||
'app/android/app/google-services.json',
|
||||
'app/ios/Runner/GoogleService-Info.plist',
|
||||
'android/app/google-services.json',
|
||||
'ios/Runner/GoogleService-Info.plist',
|
||||
];
|
||||
|
||||
/// 화면에 보여 줄 미투입 항목 이름. 내부 경로를 담지 않는다.
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/// 천 단위 구분. `intl` 을 끌어오지 않고 끝낸다 — 이 앱에서 필요한 형식은 이것뿐이다.
|
||||
String formatPop(int v) {
|
||||
final s = v.abs().toString();
|
||||
final b = StringBuffer(v < 0 ? '-' : '');
|
||||
for (var i = 0; i < s.length; i++) {
|
||||
if (i > 0 && (s.length - i) % 3 == 0) b.write(',');
|
||||
b.write(s[i]);
|
||||
}
|
||||
return b.toString();
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../l10n/remote_app_localizations.dart';
|
||||
|
||||
export '../../l10n/app_localizations.dart' show AppLocalizations;
|
||||
|
||||
/// 1차 공식 지원 언어 — 정책 원장 **B-76** 확정값 `['ko','en','ja','zh-CN','es']`.
|
||||
///
|
||||
/// 순서는 언어 설정 시트(명세 §2.95 `554:24126` 「소팅은 UI대로 유지」)와 같다.
|
||||
/// **zh-TW 확장**: `lib/l10n/app_zh_TW.arb` 를 추가하고 여기에 `Locale('zh', 'TW')` 한 줄과
|
||||
/// [languageName] 한 줄을 더하면 끝난다. 그 전까지 zh-TW 단말은 [resolveAppLocale] 이
|
||||
/// zh-CN 으로 폴백한다.
|
||||
const List<Locale> appLocales = [
|
||||
Locale('ko'),
|
||||
Locale('en'),
|
||||
Locale('ja'),
|
||||
Locale('zh', 'CN'),
|
||||
Locale('es'),
|
||||
];
|
||||
|
||||
/// 기준 언어(arb template). 미번역 문구는 여기로 떨어진다(FR-I18N-01).
|
||||
const Locale fallbackLocale = Locale('ko');
|
||||
|
||||
/// 지원 5종에 없는 단말 언어의 기본값. 🟡 발주사 회신 전 제안값 — 한국어를 모르는
|
||||
/// 해외 단말에 한국어를 띄우지 않는다.
|
||||
const Locale deviceFallbackLocale = Locale('en');
|
||||
|
||||
/// 언어 설정 시트 표기 — 명세 §2.95 원문(자기 언어로 적는다. 번역하지 않는다).
|
||||
String languageName(Locale l) => switch (l.languageCode) {
|
||||
'ko' => '한국어',
|
||||
'en' => 'English',
|
||||
'ja' => '日本語',
|
||||
'zh' => '中文',
|
||||
'es' => 'Español',
|
||||
_ => l.toLanguageTag(),
|
||||
};
|
||||
|
||||
/// 단말 언어 목록 → 지원 5종 중 하나(B-76 「기동 시 단말 국가/언어 조회 → 지원 5종 내 매칭」).
|
||||
///
|
||||
/// 언어 코드로 맞춘다. zh 는 지역·문자 체계(zh-TW · zh-Hant)와 무관하게 zh-CN 으로
|
||||
/// 간다(🟡 회신 전 제안값). 어느 것도 맞지 않으면 [deviceFallbackLocale](en).
|
||||
Locale resolveAppLocale(Iterable<Locale> device) {
|
||||
for (final d in device) {
|
||||
for (final l in appLocales) {
|
||||
if (l.languageCode == d.languageCode) return l;
|
||||
}
|
||||
}
|
||||
return deviceFallbackLocale;
|
||||
}
|
||||
|
||||
/// `users/{uid}.locale` · `i18n/{locale}` 문서 ID 에 쓰는 태그. zh-CN 은 하이픈이다.
|
||||
String localeTag(Locale l) => l.toLanguageTag();
|
||||
|
||||
Locale? parseLocaleTag(String? tag) {
|
||||
if (tag == null) return null;
|
||||
for (final l in appLocales) {
|
||||
if (localeTag(l) == tag) return l;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// 앱 표시 언어 · 원격 문자열 상태.
|
||||
///
|
||||
/// - 고른 언어는 기기(SharedPreferences)에 남기고, 로그인 상태면 [onSaved] 로
|
||||
/// `users/{uid}.locale` 에도 쓴다(설계서 §2.1).
|
||||
/// - 원격 문자열은 [remoteSource] 가 주는 `i18n/{locale}` 문서 필드(키 → 문자열)다.
|
||||
/// 문서가 없거나 읽기가 실패하면 번들 문자열 그대로다.
|
||||
class LocaleController extends ChangeNotifier {
|
||||
LocaleController({
|
||||
Locale? initial,
|
||||
this.remoteSource,
|
||||
this.onSaved,
|
||||
List<Locale>? deviceLocales,
|
||||
}) : _locale =
|
||||
initial ??
|
||||
resolveAppLocale(
|
||||
deviceLocales ??
|
||||
WidgetsBinding.instance.platformDispatcher.locales,
|
||||
) {
|
||||
_watchRemote();
|
||||
}
|
||||
|
||||
static const String prefsKey = 'app.locale';
|
||||
|
||||
/// 원격 문자열 구독. null 이면 번들만 쓴다(목 모드 · 테스트).
|
||||
final Stream<Map<String, Object?>> Function(String tag)? remoteSource;
|
||||
|
||||
/// 사용자가 언어를 바꿨을 때 서버 반영. 실패는 삼킨다 — 화면 언어는 이미 바뀌었다.
|
||||
final Future<void> Function(String tag)? onSaved;
|
||||
|
||||
Locale _locale;
|
||||
Locale get locale => _locale;
|
||||
|
||||
Map<String, String> _remote = const {};
|
||||
Map<String, String> get remote => _remote;
|
||||
|
||||
StreamSubscription<Map<String, Object?>>? _sub;
|
||||
|
||||
/// 기기에 저장된 선택을 불러온다. 없으면 단말 언어 매칭값을 유지한다.
|
||||
Future<void> restore() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final saved = parseLocaleTag(prefs.getString(prefsKey));
|
||||
if (saved != null) _apply(saved);
|
||||
} catch (e) {
|
||||
debugPrint('[LocaleController] restore failed: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> select(Locale next) async {
|
||||
if (!appLocales.contains(next)) return;
|
||||
_apply(next);
|
||||
final tag = localeTag(next);
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString(prefsKey, tag);
|
||||
} catch (e) {
|
||||
debugPrint('[LocaleController] save failed: ${e.runtimeType}');
|
||||
}
|
||||
try {
|
||||
await onSaved?.call(tag);
|
||||
} catch (e) {
|
||||
debugPrint('[LocaleController] profile save failed: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
/// 로그인 직후 `users/{uid}.locale` 을 받는다. 이 기기에서 고른 언어가 있으면 그쪽이 이긴다.
|
||||
/// 서버로 다시 쓰지 않는다(읽은 값 그대로).
|
||||
Future<void> adoptProfile(String? tag) async {
|
||||
final profile = parseLocaleTag(tag);
|
||||
if (profile == null) return;
|
||||
try {
|
||||
// 기기 저장값을 직접 본다 — [restore] 가 아직 안 끝났어도 판정이 맞다.
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (prefs.getString(prefsKey) != null) return;
|
||||
await prefs.setString(prefsKey, localeTag(profile));
|
||||
} catch (e) {
|
||||
debugPrint('[LocaleController] profile adopt failed: ${e.runtimeType}');
|
||||
return;
|
||||
}
|
||||
_apply(profile);
|
||||
}
|
||||
|
||||
/// 언어를 바꾼다. 이전 언어의 원격 문자열은 버린다 — 새 언어 문서가 오기 전까지 번들만 쓴다.
|
||||
void _apply(Locale next) {
|
||||
if (next == _locale) return;
|
||||
_locale = next;
|
||||
_remote = const {};
|
||||
_watchRemote();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _watchRemote() {
|
||||
unawaited(_sub?.cancel());
|
||||
_sub = null;
|
||||
final source = remoteSource;
|
||||
if (source == null) return;
|
||||
_sub = source(localeTag(_locale)).listen(
|
||||
(data) {
|
||||
// 문자열 값만 받는다. 운영자가 숫자나 맵을 넣어도 화면이 깨지지 않게.
|
||||
_remote = {
|
||||
for (final e in data.entries)
|
||||
if (e.value is String && (e.value as String).isNotEmpty)
|
||||
e.key: e.value as String,
|
||||
};
|
||||
notifyListeners();
|
||||
},
|
||||
onError: (Object e) =>
|
||||
debugPrint('[LocaleController] remote: ${e.runtimeType}'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(_sub?.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// 설정 화면처럼 트리 아래에서 언어를 바꿔야 하는 곳이 [LocaleController] 를 찾는 통로.
|
||||
class LocaleScope extends InheritedNotifier<LocaleController> {
|
||||
const LocaleScope({
|
||||
super.key,
|
||||
required LocaleController controller,
|
||||
required super.child,
|
||||
}) : super(notifier: controller);
|
||||
|
||||
static LocaleController? maybeOf(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<LocaleScope>()?.notifier;
|
||||
}
|
||||
|
||||
/// 번들 + 원격 문자열 대리자. 원격 맵이 바뀌면 다시 읽는다.
|
||||
class RemoteL10nDelegate extends LocalizationsDelegate<AppLocalizations> {
|
||||
const RemoteL10nDelegate(this.remote);
|
||||
|
||||
final Map<String, String> remote;
|
||||
|
||||
@override
|
||||
bool isSupported(Locale locale) =>
|
||||
AppLocalizations.delegate.isSupported(locale);
|
||||
|
||||
@override
|
||||
Future<AppLocalizations> load(Locale locale) => SynchronousFuture(
|
||||
appL10n = RemoteAppLocalizations(lookupAppLocalizations(locale), remote),
|
||||
);
|
||||
|
||||
@override
|
||||
bool shouldReload(RemoteL10nDelegate old) => !identical(old.remote, remote);
|
||||
}
|
||||
|
||||
/// 기준 언어(ko) 문자열. `BuildContext` 가 없는 곳의 기본값으로 쓴다.
|
||||
final AppLocalizations koL10n = lookupAppLocalizations(fallbackLocale);
|
||||
|
||||
/// 지금 앱 언어 문자열 — `BuildContext` 가 없는 데이터층(저장소 오류 문구 · 상태 라벨)이 쓴다.
|
||||
/// [RemoteL10nDelegate] 가 언어를 불러올 때마다 바뀐다. 대리자가 없는 트리(단독 위젯 테스트)는 ko.
|
||||
/// 화면 코드는 이것 대신 [L10nContext.l10n] 을 쓴다 — 언어가 바뀌면 다시 그려진다.
|
||||
AppLocalizations appL10n = koL10n;
|
||||
|
||||
extension L10nContext on BuildContext {
|
||||
/// 현재 언어 문자열. 대리자가 없는 트리(단독 위젯 테스트 등)는 ko 를 쓴다.
|
||||
AppLocalizations get l10n => AppLocalizations.of(this) ?? koL10n;
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:google_mobile_ads/google_mobile_ads.dart';
|
||||
|
||||
import '../data/backend.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 광고 단위 ID — **실 값으로 바꾸는 곳은 여기 한 곳이다**(앱 ID 는 AndroidManifest ·
|
||||
/// Info.plist 의 `GADApplicationIdentifier`).
|
||||
///
|
||||
/// 지금 값은 Google 이 공개한 **테스트 광고 단위**다(별첨 1 ⑥ 9주차 샌드박스). 테스트
|
||||
/// 단위는 실 계정 없이 항상 테스트 광고를 채우고 수익·무효 트래픽 판정이 없다.
|
||||
abstract final class AdUnits {
|
||||
static const androidRewarded = 'ca-app-pub-3940256099942544/5224354917';
|
||||
static const iosRewarded = 'ca-app-pub-3940256099942544/1712485313';
|
||||
|
||||
static String get rewarded =>
|
||||
Platform.isIOS ? iosRewarded : androidRewarded;
|
||||
}
|
||||
|
||||
/// 불러 둔 보상형 광고 한 편.
|
||||
///
|
||||
/// 광고 네트워크 경계다. 미디에이션(AppLovin MAX 등)을 붙일 때 이 뒤만 바꾼다.
|
||||
abstract interface class RewardedAdHandle {
|
||||
/// 전면으로 띄우고 닫힐 때까지 기다린다. 끝까지 봐서 보상 콜백이 왔으면 노출 ID,
|
||||
/// 중간에 닫았거나 띄우지 못했으면 null. 끝나면 스스로 정리한다.
|
||||
Future<String?> show();
|
||||
|
||||
/// 띄우지 않고 버린다(사용자가 로딩 중 건너뛴 경우).
|
||||
void dispose();
|
||||
}
|
||||
|
||||
/// Google Mobile Ads 보상형 광고를 불러온다.
|
||||
///
|
||||
/// 불러오지 못하면(채울 광고 없음 · 네트워크 · 시간 초과) `E-AD-01` 로 끊는다 —
|
||||
/// 원장 B-03 「광고 로드 실패 시 기본 보상은 지급하고 추가 보상·횟수는 차감하지 않음」.
|
||||
Future<RewardedAdHandle> loadGoogleRewardedAd({
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
}) async {
|
||||
await (_initialized ??= MobileAds.instance.initialize());
|
||||
final loaded = Completer<RewardedAd>();
|
||||
await RewardedAd.load(
|
||||
adUnitId: AdUnits.rewarded,
|
||||
request: const AdRequest(),
|
||||
rewardedAdLoadCallback: RewardedAdLoadCallback(
|
||||
onAdLoaded: loaded.complete,
|
||||
onAdFailedToLoad: (error) => loaded.completeError(error),
|
||||
),
|
||||
);
|
||||
try {
|
||||
return _GoogleRewardedAd(await loaded.future.timeout(timeout));
|
||||
} catch (_) {
|
||||
// 시간 초과 뒤에 늦게 도착한 광고는 띄우지 않고 버린다.
|
||||
unawaited(loaded.future.then((ad) => ad.dispose(), onError: (_) {}));
|
||||
throw BackendException('E-AD-01', appL10n.adLoadFailed);
|
||||
}
|
||||
}
|
||||
|
||||
Future<InitializationStatus>? _initialized;
|
||||
|
||||
class _GoogleRewardedAd implements RewardedAdHandle {
|
||||
_GoogleRewardedAd(this._ad);
|
||||
|
||||
final RewardedAd _ad;
|
||||
|
||||
@override
|
||||
Future<String?> show() async {
|
||||
final closed = Completer<void>();
|
||||
String? impression;
|
||||
_ad.fullScreenContentCallback = FullScreenContentCallback(
|
||||
onAdDismissedFullScreenContent: (_) => closed.complete(),
|
||||
onAdFailedToShowFullScreenContent: (_, _) => closed.complete(),
|
||||
);
|
||||
try {
|
||||
await _ad.show(
|
||||
onUserEarnedReward: (_, _) {
|
||||
// 서버가 이 값으로 같은 노출의 이중 지급을 막는다(`adRewardLogs/{id}`).
|
||||
impression = _ad.responseInfo?.responseId ??
|
||||
'gma-${DateTime.now().microsecondsSinceEpoch}';
|
||||
},
|
||||
);
|
||||
await closed.future;
|
||||
} finally {
|
||||
// 띄우기가 던져도 네이티브 광고 객체를 남기지 않는다.
|
||||
await _ad.dispose();
|
||||
}
|
||||
return impression;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() => unawaited(_ad.dispose());
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:in_app_purchase/in_app_purchase.dart';
|
||||
import 'package:in_app_purchase_android/in_app_purchase_android.dart';
|
||||
|
||||
import '../data/backend.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 인앱 결제 — 별첨 1 ⑥ 「영수증 서버검증 통과(위조 차단)」 · 정책 원장 B-02 · B-04 · B-84.
|
||||
///
|
||||
/// **앱은 지급하지 않는다.** 스토어가 준 영수증(Android 구매 토큰 · iOS 영수증)을
|
||||
/// `fnIapVerify` 에 넘기고, 서버가 상품 ID 로 혜택을 정한다. 서버가 받아들인 뒤에만
|
||||
/// 스토어에 구매 완료를 알린다 — 검증 전에 완료하면 검증이 실패해도 결제가 확정되고,
|
||||
/// 완료하지 않은 Android 구매는 3일 뒤 스토어가 자동 환불한다.
|
||||
///
|
||||
/// 상품 ID 는 서버 상품 표(`firebase/functions/src/payment/receipt.ts`)와 Hero Gift
|
||||
/// Catalog(`heroGiftProducts/{스토어 상품 ID}`)가 정한다. 스토어에 상품이 아직 없으면
|
||||
/// 조회가 비어 오고, 화면은 「스토어 상품 준비 중」으로 정직하게 멈춘다.
|
||||
class Store {
|
||||
Store(this._backend, {InAppPurchase? iap}) : _iap = iap ?? InAppPurchase.instance;
|
||||
|
||||
/// 실 모드에서만 채워진다(`Repositories`). 목 모드와 테스트에서는 null 이라
|
||||
/// 화면이 「준비 중」을 띄운다.
|
||||
static Store? instance;
|
||||
|
||||
/// Focus 월 구독 — 서버 `SUBSCRIPTION_PRODUCTS` 와 같은 ID.
|
||||
static const focusMonthly = 'focus.monthly';
|
||||
static const _subscriptions = {'focus.monthly', 'focus.yearly'};
|
||||
|
||||
static String get notReady => appL10n.storeNotReady;
|
||||
|
||||
final Backend _backend;
|
||||
final InAppPurchase _iap;
|
||||
final _products = <String, ProductDetails>{};
|
||||
final _messages = StreamController<String>.broadcast();
|
||||
StreamSubscription<List<PurchaseDetails>>? _purchases;
|
||||
|
||||
/// 사용자에게 보여 줄 결제 결과 한 줄. 결제는 OS 창에서 끝나 화면 밖에서 도착한다.
|
||||
Stream<String> get messages => _messages.stream;
|
||||
|
||||
/// 구매 알림을 듣기 시작한다. 로그인 뒤에 한 번 부른다.
|
||||
///
|
||||
/// iOS 는 완료하지 않은 거래를 구독 즉시 다시 보낸다. Android 는 보내지 않으므로
|
||||
/// 복원으로 끌어온다 — 결제 대기(pending)였다가 승인된 구매와 검증에 실패했던
|
||||
/// 구매가 여기서 다시 서버로 간다. 서버는 거래 ID 로 멱등이라 여러 번 가도 한 번 지급이다.
|
||||
void start() {
|
||||
if (_purchases != null) return;
|
||||
_purchases = _iap.purchaseStream.listen(_onPurchases, onError: (_) {});
|
||||
if (Platform.isAndroid) unawaited(_iap.restorePurchases().catchError((_) {}));
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _purchases?.cancel();
|
||||
_purchases = null;
|
||||
}
|
||||
|
||||
/// 스토어 상품 한 개. 스토어를 쓸 수 없거나 등록되지 않았으면 null.
|
||||
Future<ProductDetails?> product(String id) async {
|
||||
final cached = _products[id];
|
||||
if (cached != null) return cached;
|
||||
if (!await _iap.isAvailable()) return null;
|
||||
final result = await _iap.queryProductDetails({id});
|
||||
if (result.productDetails.isEmpty) return null;
|
||||
return _products[id] = result.productDetails.first;
|
||||
}
|
||||
|
||||
/// 결제 창을 띄운다. 결과는 [messages] 로 온다.
|
||||
Future<void> buy(String productId) async {
|
||||
final details = await product(productId);
|
||||
if (details == null) throw BackendException('E-PAY-02', notReady);
|
||||
final param = PurchaseParam(productDetails: details);
|
||||
if (_subscriptions.contains(productId)) {
|
||||
await _iap.buyNonConsumable(purchaseParam: param);
|
||||
} else {
|
||||
// 자동 소비를 끈다 — 서버 검증 전에 소비하면 실패한 구매를 다시 보낼 수 없다.
|
||||
await _iap.buyConsumable(purchaseParam: param, autoConsume: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 구매 복원. Focus 구독을 다른 기기·재설치 뒤에 되살린다.
|
||||
Future<void> restore() => _iap.restorePurchases();
|
||||
|
||||
Future<void> _onPurchases(List<PurchaseDetails> purchases) async {
|
||||
for (final purchase in purchases) {
|
||||
switch (purchase.status) {
|
||||
case PurchaseStatus.pending:
|
||||
_messages.add(appL10n.storePending);
|
||||
case PurchaseStatus.canceled:
|
||||
case PurchaseStatus.error:
|
||||
if (purchase.status == PurchaseStatus.error) {
|
||||
_messages.add(appL10n.storeFailed);
|
||||
}
|
||||
// 실패·취소 거래도 닫아야 iOS 큐에 남지 않는다. 결제가 확정되지 않은 건이다.
|
||||
if (purchase.pendingCompletePurchase) {
|
||||
await _iap.completePurchase(purchase);
|
||||
}
|
||||
case PurchaseStatus.purchased:
|
||||
case PurchaseStatus.restored:
|
||||
await _verify(purchase);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _verify(PurchaseDetails purchase) async {
|
||||
final details = _products[purchase.productID];
|
||||
try {
|
||||
await _backend.call('fnIapVerify', {
|
||||
'platform': Platform.isIOS ? 'ios' : 'android',
|
||||
'receipt': purchase.verificationData.serverVerificationData,
|
||||
'productId': purchase.productID,
|
||||
// iOS 앱 영수증은 여러 거래를 담는다 — 서버가 이 거래를 고른다.
|
||||
if (purchase.purchaseID case final id?) 'transactionId': id,
|
||||
// 표시용. 서버는 지급 판단에 쓰지 않는다.
|
||||
if (details != null) ...{
|
||||
'title': details.title,
|
||||
'priceAmount': details.rawPrice,
|
||||
'currency': details.currencyCode,
|
||||
},
|
||||
});
|
||||
} on BackendException catch (error) {
|
||||
if (purchase.status == PurchaseStatus.purchased) _messages.add(error.message);
|
||||
if (error.code == 'E-PAY-01' && !Platform.isAndroid) {
|
||||
// 재시도해도 같은 거부(판매 중지 · 다른 계정 · 소속 행성 없음). iOS 는 거래를 닫지
|
||||
// 않으면 실행마다 다시 온다. 환불은 Apple 경로(사용자 요청)로 간다.
|
||||
if (purchase.pendingCompletePurchase) await _iap.completePurchase(purchase);
|
||||
}
|
||||
// 그 밖은 닫지 않는다 — 다음 실행에서 다시 보낸다. Android 는 E-PAY-01 도 확인하지
|
||||
// 않고 둔다: 3일 안에 확인되지 않은 구매는 스토어가 **자동 환불**한다. 다시 보내는
|
||||
// 복원 재전송은 조용하므로 알림은 처음 한 번뿐이다.
|
||||
return;
|
||||
}
|
||||
if (Platform.isAndroid && !_subscriptions.contains(purchase.productID)) {
|
||||
// 소비해야 같은 상품을 다시 살 수 있다. 소비가 곧 확인(acknowledge)이다.
|
||||
await _iap
|
||||
.getPlatformAddition<InAppPurchaseAndroidPlatformAddition>()
|
||||
.consumePurchase(purchase);
|
||||
} else if (purchase.pendingCompletePurchase) {
|
||||
await _iap.completePurchase(purchase);
|
||||
}
|
||||
if (purchase.status == PurchaseStatus.purchased) {
|
||||
_messages.add(appL10n.storePurchased);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:firebase_messaging/firebase_messaging.dart' show FirebaseMessaging;
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../data/backend.dart';
|
||||
import '../../features/my/my_data.dart';
|
||||
import 'push_transport.dart';
|
||||
|
||||
/// FCM 수신 — 정책 원장 B-77 「Push 알림」 5 개 구분의 앱 쪽 절반.
|
||||
///
|
||||
/// **구분마다 토픽을 나눈 이유가 여기 있다.** 서버는 토픽으로만 보내므로 수신자를
|
||||
/// 고를 수 없다. 사용자가 「AI Briefing 만 끄기」를 고르면 앱이 그 토픽을 해지하고,
|
||||
/// 그래서 **OFF 구분의 수신이 0 건**이 된다(B-77 검증 기준). 앱이 받아서 버리는
|
||||
/// 방식이면 기기에 알림이 먼저 떠 버려 정책을 지킬 수 없다.
|
||||
///
|
||||
/// Marketing 은 기본값이 OFF 라 명시 동의 전에는 구독하지 않는다.
|
||||
///
|
||||
/// **iOS 는 코드 경로만이다.** APNs 키가 Apple Developer 계정 확정 대기(별첨 A-1)라
|
||||
/// 토큰이 발급되지 않는다. 키가 들어오는 날 이 파일을 고치지 않아도 되도록
|
||||
/// 지금 경로를 맞춰 둔다.
|
||||
class PushMessaging {
|
||||
PushMessaging({required Backend backend, PushTransport? transport})
|
||||
: _backend = backend,
|
||||
_transport = transport ?? FirebaseMessagingTransport();
|
||||
|
||||
final Backend _backend;
|
||||
final PushTransport _transport;
|
||||
|
||||
StreamSubscription<RemoteMessage>? _foreground;
|
||||
StreamSubscription<RemoteMessage>? _opened;
|
||||
StreamSubscription<String>? _tokenRefresh;
|
||||
|
||||
/// 소속 팬덤. 토픽 이름의 일부라 매번 사용자 문서를 읽지 않도록 기억한다.
|
||||
String? _fandomId;
|
||||
|
||||
/// 알림 권한 상태. 화면이 「거부됨」 안내와 재요청 버튼을 그릴 근거다.
|
||||
AuthorizationStatus _status = AuthorizationStatus.notDetermined;
|
||||
AuthorizationStatus get status => _status;
|
||||
|
||||
bool get isAuthorized => _status.isAllowed;
|
||||
|
||||
/// 알림 권한 요청.
|
||||
///
|
||||
/// Android 13+ 의 `POST_NOTIFICATIONS` 런타임 권한도 이 호출이 띄운다 —
|
||||
/// 플러그인이 매니페스트에 권한을 선언하고 `requestPermission` 이 시스템
|
||||
/// 다이얼로그로 이어진다. 별도 권한 플러그인을 넣지 않은 이유가 이것이다.
|
||||
Future<AuthorizationStatus> requestPermission() async {
|
||||
_status = await _transport.requestPermission();
|
||||
return _status;
|
||||
}
|
||||
|
||||
/// 현재 권한 상태만 읽는다. 다이얼로그를 띄우지 않으므로 화면 진입 시 쓴다.
|
||||
Future<AuthorizationStatus> refreshStatus() async {
|
||||
_status = await _transport.currentStatus();
|
||||
return _status;
|
||||
}
|
||||
|
||||
/// 단말 토큰을 `users/{uid}.fcmTokens` 에 더한다.
|
||||
///
|
||||
/// 배열로 두는 이유는 한 계정이 여러 단말을 쓰기 때문이다. 단일 필드면 새 단말
|
||||
/// 로그인이 이전 단말의 토큰을 지우고, 그 단말은 조용히 알림을 못 받는다.
|
||||
/// 토큰 재발급도 같은 경로로 흘러 들어온다([listen] 의 `onTokenRefresh`).
|
||||
///
|
||||
/// iOS 는 APNs 키 전까지 토큰이 null 이다 — 예외가 아니라 정상이다.
|
||||
Future<String?> syncToken() async {
|
||||
final token = await _transport.getToken();
|
||||
if (token == null || token.isEmpty) return null;
|
||||
await _saveToken(token);
|
||||
return token;
|
||||
}
|
||||
|
||||
Future<void> _saveToken(String token) async {
|
||||
final uid = _backend.uid;
|
||||
if (uid == null) return;
|
||||
await _backend.db.doc('users/$uid').set({
|
||||
'fcmTokens': FieldValue.arrayUnion([token]),
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
}, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
/// 소속 팬덤 ID. 한 번 읽어 두고 다시 읽지 않는다.
|
||||
Future<String> _resolveFandomId() async {
|
||||
final cached = _fandomId;
|
||||
if (cached != null) return cached;
|
||||
final uid = _backend.uid;
|
||||
if (uid == null) return '';
|
||||
final user = await _backend.db.doc('users/$uid').get();
|
||||
final id = (user.data() ?? const <String, dynamic>{}).strOr('fandomId');
|
||||
if (id.isNotEmpty) _fandomId = id;
|
||||
return id;
|
||||
}
|
||||
|
||||
/// 설정대로 토픽을 맞춘다.
|
||||
///
|
||||
/// **구독 상태를 앱이 기억하지 않는다.** 켜진 구분은 구독을, 꺼진 구분은 해지를
|
||||
/// 조건 없이 부른다. FCM 의 두 호출은 멱등이라 이미 그 상태여도 문제가 없고,
|
||||
/// 반대로 앱이 기억한 상태를 믿으면 재설치·토큰 재발급·다른 기기 로그인처럼
|
||||
/// 앱이 모르는 사이에 실제 구독이 달라진 경우를 되돌릴 방법이 없다.
|
||||
///
|
||||
/// 팬덤 ID 는 사용자 문서에서 스스로 읽는다. 호출자가 넘기게 하면 설정 화면이
|
||||
/// 그 값을 알아야 하고, 화면이 알 이유가 없다.
|
||||
Future<void> syncTopics(Map<PushTopic, bool> prefs) async {
|
||||
final fandomId = await _resolveFandomId();
|
||||
if (fandomId.isEmpty) return;
|
||||
final plan = topicPlan(fandomId, prefs);
|
||||
for (final topic in plan.subscribe) {
|
||||
await _transport.subscribeToTopic(topic);
|
||||
}
|
||||
for (final topic in plan.unsubscribe) {
|
||||
await _transport.unsubscribeFromTopic(topic);
|
||||
}
|
||||
}
|
||||
|
||||
/// 수신 구독을 연다.
|
||||
///
|
||||
/// 포그라운드에서는 시스템 알림이 뜨지 않으므로(Android 기본 동작) 앱이 직접
|
||||
/// 표시해야 한다. 탭은 두 경로로 들어온다 — 앱이 살아 있을 때의
|
||||
/// `onMessageOpenedApp` 과 종료 상태에서 알림으로 켠 `getInitialMessage`.
|
||||
/// 후자를 빼면 「알림을 눌러 앱이 열렸는데 홈에 그냥 서 있다」가 된다.
|
||||
Future<void> listen({
|
||||
required void Function(PushNotice notice) onForeground,
|
||||
required void Function(PushNotice notice) onOpened,
|
||||
}) async {
|
||||
_foreground = FirebaseMessaging.onMessage
|
||||
.listen((m) => onForeground(PushNotice.of(m)));
|
||||
_opened = FirebaseMessaging.onMessageOpenedApp
|
||||
.listen((m) => onOpened(PushNotice.of(m)));
|
||||
_tokenRefresh = _transport.onTokenRefresh.listen(_saveToken);
|
||||
|
||||
final initial = await _transport.getInitialMessage();
|
||||
if (initial != null) onOpened(PushNotice.of(initial));
|
||||
}
|
||||
|
||||
/// 로그아웃 정리 — 팬덤 토픽을 전부 해지하고 이 기기 토큰을 지운다.
|
||||
///
|
||||
/// **로그아웃보다 먼저 불러야 한다.** 보안 규칙이 `users/{uid}` 쓰기를
|
||||
/// `isSelf(uid)` 로 막으므로 인증이 끊긴 뒤에는 토큰을 지울 수 없다. 그대로 두면
|
||||
/// 다음 사용자가 그 기기를 쓸 때 이전 계정의 알림이 계속 내려간다.
|
||||
///
|
||||
/// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨
|
||||
/// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다.
|
||||
Future<void> clearOnSignOut() async {
|
||||
final fandomId = await _resolveFandomId();
|
||||
if (fandomId.isNotEmpty) {
|
||||
for (final topic in PushTopic.values) {
|
||||
await _transport.unsubscribeFromTopic(topic.topicFor(fandomId));
|
||||
}
|
||||
}
|
||||
final uid = _backend.uid;
|
||||
final token = await _transport.getToken();
|
||||
if (uid != null && token != null && token.isNotEmpty) {
|
||||
await _backend.db.doc('users/$uid').set({
|
||||
'fcmTokens': FieldValue.arrayRemove([token]),
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
}, SetOptions(merge: true));
|
||||
}
|
||||
// 다음 로그인은 다른 계정일 수 있다. 팬덤을 들고 있으면 그 계정의 토픽에
|
||||
// 구독해 버린다.
|
||||
_fandomId = null;
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
await _foreground?.cancel();
|
||||
await _opened?.cancel();
|
||||
await _tokenRefresh?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
/// 알림을 실제로 받을 수 있는 상태인가.
|
||||
///
|
||||
/// `provisional` 을 포함하는 이유는 iOS 임시 승인이다. 사용자가 아직 명시적으로
|
||||
/// 허용하지 않았어도 조용한 알림은 전달되므로, 「꺼져 있습니다」 안내를 띄우면
|
||||
/// 사실과 다르다.
|
||||
extension PushAuthorization on AuthorizationStatus {
|
||||
bool get isAllowed =>
|
||||
this == AuthorizationStatus.authorized ||
|
||||
this == AuthorizationStatus.provisional;
|
||||
}
|
||||
|
||||
/// 토픽 계획 — 무엇을 구독하고 무엇을 해지할지.
|
||||
@immutable
|
||||
class TopicPlan {
|
||||
const TopicPlan({required this.subscribe, required this.unsubscribe});
|
||||
final Set<String> subscribe;
|
||||
final Set<String> unsubscribe;
|
||||
}
|
||||
|
||||
/// 설정에서 계획을 낸다. 모든 구분이 둘 중 한쪽에 들어간다.
|
||||
///
|
||||
/// 플러그인을 부르지 않는 순수 함수로 떼어 둔 이유는 검증이다. 「Marketing 을 끈
|
||||
/// 상태로는 그 토픽을 구독하지 않는다」가 정책 검증 기준이고, 그 판정을 실제 FCM
|
||||
/// 없이 확인할 수 있어야 한다.
|
||||
TopicPlan topicPlan(String fandomId, Map<PushTopic, bool> prefs) {
|
||||
final subscribe = <String>{};
|
||||
final unsubscribe = <String>{};
|
||||
for (final topic in PushTopic.values) {
|
||||
// 서비스 필수는 끌 수 없으므로 설정값을 보지 않는다.
|
||||
final on = topic.locked || (prefs[topic] ?? topic.defaultOn);
|
||||
(on ? subscribe : unsubscribe).add(topic.topicFor(fandomId));
|
||||
}
|
||||
return TopicPlan(subscribe: subscribe, unsubscribe: unsubscribe);
|
||||
}
|
||||
|
||||
/// 받은 알림 한 건. 화면이 쓰는 형태로만 남긴다.
|
||||
@immutable
|
||||
class PushNotice {
|
||||
const PushNotice({
|
||||
required this.title,
|
||||
required this.body,
|
||||
required this.kind,
|
||||
this.fandomId,
|
||||
});
|
||||
|
||||
factory PushNotice.of(RemoteMessage message) => PushNotice(
|
||||
title: message.notification?.title ?? '',
|
||||
body: message.notification?.body ?? '',
|
||||
kind: message.data['kind']?.toString() ?? '',
|
||||
fandomId: message.data['fandomId']?.toString(),
|
||||
);
|
||||
|
||||
final String title;
|
||||
final String body;
|
||||
|
||||
/// 서버가 실은 분기 키 — `briefing` · `eclipse`.
|
||||
final String kind;
|
||||
final String? fandomId;
|
||||
|
||||
/// 탭했을 때 열 경로.
|
||||
///
|
||||
/// 브리핑 상세 화면은 아직 라우트가 없어 홈의 브리핑 블록으로 보낸다. 이클립스는
|
||||
/// 갤럭시에서 행성으로 들어가는 동선이 이미 있다.
|
||||
String get route => switch (kind) {
|
||||
'eclipse' => '/galaxy',
|
||||
_ => '/home',
|
||||
};
|
||||
|
||||
/// 표시할 내용이 있는가. 데이터 전용 메시지는 화면에 띄우지 않는다.
|
||||
bool get hasText => title.isNotEmpty || body.isNotEmpty;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
|
||||
export 'package:firebase_messaging/firebase_messaging.dart'
|
||||
show AuthorizationStatus, RemoteMessage;
|
||||
|
||||
/// FCM 플러그인 경계.
|
||||
///
|
||||
/// **왜 인터페이스를 하나 두는가** — `FirebaseMessaging` 은 비공개 생성자를 가진
|
||||
/// 구체 클래스라 테스트에서 대신 세울 수 없다. 「마케팅 토글을 끄면 그 토픽을
|
||||
/// 해지하는가」는 정책 검증 기준(B-77 「OFF 카테고리 발송 0건」)이고, 실제 FCM 없이
|
||||
/// 확인할 수 있어야 한다. 그래서 [PushMessaging] 이 쓰는 호출만 여기로 좁혔다.
|
||||
///
|
||||
/// 정적 스트림(`FirebaseMessaging.onMessage`·`onMessageOpenedApp`)은 여기 넣지
|
||||
/// 않았다. 구독 판정과 무관하고, 넣으면 이 경계가 플러그인 전체를 베끼게 된다.
|
||||
abstract interface class PushTransport {
|
||||
/// 알림 권한 요청. 시스템 다이얼로그를 띄운다.
|
||||
Future<AuthorizationStatus> requestPermission();
|
||||
|
||||
/// 현재 권한 상태만 읽는다. 다이얼로그를 띄우지 않는다.
|
||||
Future<AuthorizationStatus> currentStatus();
|
||||
|
||||
/// 이 단말 토큰. iOS 는 APNs 키 전까지 null 이다.
|
||||
Future<String?> getToken();
|
||||
|
||||
/// 토큰 재발급 알림.
|
||||
Stream<String> get onTokenRefresh;
|
||||
|
||||
Future<void> subscribeToTopic(String topic);
|
||||
|
||||
Future<void> unsubscribeFromTopic(String topic);
|
||||
|
||||
/// 종료 상태에서 알림을 눌러 앱이 켜진 경우의 첫 메시지.
|
||||
Future<RemoteMessage?> getInitialMessage();
|
||||
}
|
||||
|
||||
/// 실제 플러그인에 붙는 구현. 로직이 없어야 한다 — 판정은 [PushMessaging] 이 한다.
|
||||
class FirebaseMessagingTransport implements PushTransport {
|
||||
FirebaseMessagingTransport([FirebaseMessaging? messaging])
|
||||
: _messaging = messaging ?? FirebaseMessaging.instance;
|
||||
|
||||
final FirebaseMessaging _messaging;
|
||||
|
||||
@override
|
||||
Future<AuthorizationStatus> requestPermission() async =>
|
||||
(await _messaging.requestPermission()).authorizationStatus;
|
||||
|
||||
@override
|
||||
Future<AuthorizationStatus> currentStatus() async =>
|
||||
(await _messaging.getNotificationSettings()).authorizationStatus;
|
||||
|
||||
@override
|
||||
Future<String?> getToken() => _messaging.getToken();
|
||||
|
||||
@override
|
||||
Stream<String> get onTokenRefresh => _messaging.onTokenRefresh;
|
||||
|
||||
@override
|
||||
Future<void> subscribeToTopic(String topic) =>
|
||||
_messaging.subscribeToTopic(topic);
|
||||
|
||||
@override
|
||||
Future<void> unsubscribeFromTopic(String topic) =>
|
||||
_messaging.unsubscribeFromTopic(topic);
|
||||
|
||||
@override
|
||||
Future<RemoteMessage?> getInitialMessage() => _messaging.getInitialMessage();
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../config/feature_flags.dart';
|
||||
import '../data/repositories.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import '../ui/app_icons.dart';
|
||||
import '../../features/auth/auth_repository.dart';
|
||||
import '../../features/auth/login_screen.dart';
|
||||
import '../../features/galaxy/galaxy_screen.dart';
|
||||
import '../../features/auth/signup_screens.dart';
|
||||
import '../../features/galaxy/galaxy_home_screen.dart';
|
||||
import '../../features/home/home_screen.dart';
|
||||
import '../../features/inside/inside_screen.dart';
|
||||
import '../../features/my/my_screen.dart';
|
||||
@@ -35,7 +37,7 @@ class AppTab {
|
||||
final bool Function(FeatureFlags flags)? gatedBy;
|
||||
}
|
||||
|
||||
/// 계약 고정 5탭.
|
||||
/// 계약 고정 5탭 — 화면 대조 원장 F0 · Figma `350:215853` 「Bottom Navigation」.
|
||||
///
|
||||
/// 근거 2개가 서로 일치한다:
|
||||
/// - 견적서 v2.1 「5탭 네비게이션」
|
||||
@@ -76,20 +78,28 @@ List<AppTab> visibleTabs(FeatureFlags flags) => [
|
||||
];
|
||||
|
||||
const String loginPath = '/login';
|
||||
const String signupCompletePath = '/welcome';
|
||||
|
||||
/// 5탭 셸 라우트 + 온보딩(로그인) 라우트.
|
||||
///
|
||||
/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST ·
|
||||
/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다.
|
||||
GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
final featureFlags = flags ?? FeatureFlags();
|
||||
GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
// 저장소 선택은 앱이 뜰 때 한 번만 한다. 탭을 옮길 때마다 다시 만들면
|
||||
// Firestore 커서 같은 화면 상태가 매번 초기화된다.
|
||||
final repos = repositories ?? Repositories();
|
||||
// 한 번만 만든다. 라우트 빌더 안에서 만들면 탭을 옮길 때마다 연결 상태가 초기화된다.
|
||||
final linker = repos.linker(auth.providers);
|
||||
return GoRouter(
|
||||
initialLocation: appTabs.first.path,
|
||||
refreshListenable: auth,
|
||||
redirect: (context, state) {
|
||||
final atLogin = state.matchedLocation == loginPath;
|
||||
if (!auth.isSignedIn) return atLogin ? null : loginPath;
|
||||
if (atLogin) return appTabs.first.path;
|
||||
final at = state.matchedLocation;
|
||||
if (!auth.isSignedIn) return at == loginPath ? null : loginPath;
|
||||
if (auth.justSignedUp)
|
||||
return at == signupCompletePath ? null : signupCompletePath;
|
||||
if (at == loginPath || at == signupCompletePath)
|
||||
return appTabs.first.path;
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
@@ -97,14 +107,29 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
path: loginPath,
|
||||
builder: (_, _) => LoginScreen(auth: auth),
|
||||
),
|
||||
GoRoute(
|
||||
path: signupCompletePath,
|
||||
builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup),
|
||||
),
|
||||
StatefulShellRoute.indexedStack(
|
||||
builder: (_, _, shell) => AppShell(shell: shell, flags: featureFlags),
|
||||
builder: (_, _, shell) => AppShell(shell: shell, flags: repos.flags),
|
||||
branches: [
|
||||
StatefulShellBranch(
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: appTabs[0].path,
|
||||
builder: (_, _) => const HomeScreen(),
|
||||
builder: (_, _) => HomeScreen(
|
||||
watchPlanet: repos.watchPlanet,
|
||||
galaxyRepository: repos.galaxy,
|
||||
planetRepository: repos.planet,
|
||||
repository: repos.home,
|
||||
// 홈의 「오늘의 퀘스트」에서 F4 로 들어간다. 저장소 선택은
|
||||
// repositories.dart 한 곳이므로 화면이 표본을 직접 만들지 않는다.
|
||||
questRepository: repos.quest,
|
||||
campaignRepository: repos.campaign,
|
||||
notifications: repos.notifications,
|
||||
eclipse: repos.eclipse,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -112,7 +137,11 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: appTabs[1].path,
|
||||
builder: (_, _) => const InsideScreen(),
|
||||
builder: (_, _) => InsideScreen(
|
||||
repository: repos.inside,
|
||||
translation: repos.translation,
|
||||
notifications: repos.notifications,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -120,7 +149,15 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: appTabs[2].path,
|
||||
builder: (_, _) => const GalaxyScreen(),
|
||||
// 탭 루트는 시안(354:220228)의 「내 행성」 그리드. 궤도 맵은 그 안에서 연다.
|
||||
builder: (_, _) => GalaxyHomeScreen(
|
||||
home: repos.home,
|
||||
watchPlanet: repos.watchPlanet,
|
||||
repository: repos.galaxy,
|
||||
planetRepository: repos.planet,
|
||||
notifications: repos.notifications,
|
||||
eclipse: repos.eclipse,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -128,7 +165,7 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: appTabs[3].path,
|
||||
builder: (_, _) => const StationScreen(),
|
||||
builder: (_, _) => StationScreen(repository: repos.station),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -136,7 +173,13 @@ GoRouter buildRouter(AuthRepository auth, {FeatureFlags? flags}) {
|
||||
routes: [
|
||||
GoRoute(
|
||||
path: appTabs[4].path,
|
||||
builder: (_, _) => const MyScreen(),
|
||||
builder: (_, _) => MyScreen(
|
||||
repository: repos.my,
|
||||
linker: linker,
|
||||
push: repos.push,
|
||||
auth: auth,
|
||||
campaigns: repos.campaign,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -92,6 +92,16 @@ ThemeData buildAppTheme() {
|
||||
),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(color: AppColors.cosmicOutline, thickness: 1),
|
||||
// 시안 행성 상세(373:441117) 탭 — 활성 청록 글자·밑줄, 비활성 #bbbbbb, 트랙 #1a1a1a.
|
||||
tabBarTheme: TabBarThemeData(
|
||||
labelColor: AppColors.cosmicTeal,
|
||||
unselectedLabelColor: const Color(0xFFBBBBBB),
|
||||
indicatorColor: AppColors.cosmicTeal,
|
||||
indicatorSize: TabBarIndicatorSize.tab,
|
||||
dividerColor: const Color(0xFF1A1A1A),
|
||||
labelStyle: _styleNoColor(AppType.labelL),
|
||||
unselectedLabelStyle: _styleNoColor(AppType.labelL),
|
||||
),
|
||||
|
||||
// ── 아래는 테마를 주지 않으면 Material 3 기본값(보라 시드)으로 칠해지는 것들이다.
|
||||
// 코스믹 다크 위에서 눈에 띄게 어긋나므로 전부 토큰으로 고정한다.
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
/// 0=원본, 1=입자/광량 축소, 2=Blur·텍스처까지 축소. 상태/결과는 그대로 둔다.
|
||||
class PlanetEffectsQuality extends InheritedWidget {
|
||||
const PlanetEffectsQuality({
|
||||
super.key,
|
||||
required this.level,
|
||||
required super.child,
|
||||
});
|
||||
final int level;
|
||||
static int of(BuildContext context) =>
|
||||
context
|
||||
.dependOnInheritedWidgetOfExactType<PlanetEffectsQuality>()
|
||||
?.level ??
|
||||
0;
|
||||
@override
|
||||
bool updateShouldNotify(PlanetEffectsQuality old) => old.level != level;
|
||||
}
|
||||
|
||||
class AdaptivePlanetEffects extends StatefulWidget {
|
||||
const AdaptivePlanetEffects({super.key, required this.child});
|
||||
final Widget child;
|
||||
@override
|
||||
State<AdaptivePlanetEffects> createState() => _AdaptivePlanetEffectsState();
|
||||
}
|
||||
|
||||
class _AdaptivePlanetEffectsState extends State<AdaptivePlanetEffects>
|
||||
with WidgetsBindingObserver {
|
||||
int _level = 0;
|
||||
int _warmup = 60;
|
||||
int _frames = 0;
|
||||
int _slow = 0;
|
||||
double _budgetMicros = 1000000 / 60;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
SchedulerBinding.instance.addTimingsCallback(_measure);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
final hz = View.of(context).display.refreshRate;
|
||||
_budgetMicros = 1000000 / (hz > 0 ? hz.clamp(30, 120) : 60);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_warmup = 60;
|
||||
_frames = _slow = 0;
|
||||
}
|
||||
}
|
||||
|
||||
void _measure(List<FrameTiming> frames) {
|
||||
if (!mounted || _level == 2) return;
|
||||
final lifecycle = WidgetsBinding.instance.lifecycleState;
|
||||
if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return;
|
||||
for (final frame in frames) {
|
||||
if (_warmup > 0) {
|
||||
_warmup--;
|
||||
continue;
|
||||
}
|
||||
_frames++;
|
||||
if (frame.buildDuration.inMicroseconds > _budgetMicros ||
|
||||
frame.rasterDuration.inMicroseconds > _budgetMicros)
|
||||
_slow++;
|
||||
if (_frames < 120) continue;
|
||||
final downgrade = _slow >= 30;
|
||||
_frames = _slow = 0;
|
||||
if (downgrade) {
|
||||
setState(() => _level++);
|
||||
// ponytail: 한 앱 실행에서는 하향만 한다. 복구 시 품질 진동을 피하며,
|
||||
// 실기기 측정으로 안정 구간을 확보하면 장시간 회복 히스테리시스를 추가한다.
|
||||
if (_level == 2) break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
SchedulerBinding.instance.removeTimingsCallback(_measure);
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
PlanetEffectsQuality(level: _level, child: widget.child);
|
||||
}
|
||||
@@ -19,4 +19,20 @@ abstract final class AppIcons {
|
||||
static const x = AssetImage('assets/icons/x.png');
|
||||
static const search = AssetImage('assets/icons/search.png');
|
||||
static const share = AssetImage('assets/icons/share_2.png');
|
||||
// INSIDE 시안에서 쓰던 아이콘(그룹 E) — 다른 화면도 같은 Iconset 이라 여기로 올렸다.
|
||||
// heart · message_circle · eye · bell · corner_down_right 는 재추출본(2026-09-27, reextract/map.json).
|
||||
static const heart = AssetImage('assets/icons/heart.png');
|
||||
static const messageCircle = AssetImage('assets/icons/message_circle.png');
|
||||
static const eye = AssetImage('assets/icons/eye.png');
|
||||
static const bell = AssetImage('assets/icons/bell.png');
|
||||
static const clock = AssetImage('assets/icons/clock_8.png');
|
||||
static const bolt = AssetImage('assets/icons/bolt.png');
|
||||
static const cornerDownRight = AssetImage(
|
||||
'assets/icons/corner_down_right.png',
|
||||
);
|
||||
static const globe = AssetImage('assets/icons/globe.png');
|
||||
static const pencil = AssetImage('assets/icons/pencil.png');
|
||||
static const send = AssetImage('assets/icons/send.png');
|
||||
static const camera = AssetImage('assets/icons/camera.png');
|
||||
static const image = AssetImage('assets/icons/image.png');
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 사본 `Radio` 컴포넌트(20) — 미선택은 #2A2A2C 가는 테두리, 선택은 #D1D1D2 원 안의 어두운 점.
|
||||
///
|
||||
/// Material [Radio] 는 2px 링을 그려 사본보다 굵고 밝다. 신고 사유 · 환불 사유가 같이 쓴다.
|
||||
class AppRadio extends StatelessWidget {
|
||||
const AppRadio({super.key, required this.selected});
|
||||
|
||||
final bool selected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Semantics(
|
||||
checked: selected,
|
||||
inMutuallyExclusiveGroup: true,
|
||||
child: Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: selected ? const Color(0xFFD1D1D2) : null,
|
||||
border: selected
|
||||
? null
|
||||
: Border.all(color: const Color(0xFF2A2A2C), width: 1.5),
|
||||
),
|
||||
child: selected
|
||||
? const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Color(0xFF141414),
|
||||
),
|
||||
child: SizedBox.square(dimension: 8),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// 시안의 CONTRIBUTION · 누적 기여 POP 아이콘 — 파란 그라디언트 원에 흰 번개.
|
||||
/// 다른 3D 아이콘과 달리 Figma 에서 벡터라 이미지로 내보낼 수 없어 위젯으로 그린다.
|
||||
class BoltBadge extends StatelessWidget {
|
||||
const BoltBadge({super.key, this.size = 40});
|
||||
|
||||
final double size;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [AppColors.neonCyan, AppColors.cosmicBlue],
|
||||
),
|
||||
),
|
||||
child: Icon(Icons.bolt, size: size * 0.6, color: AppColors.onCosmic),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../data/planet_visual_state.dart';
|
||||
import 'live_stream.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 서버 종료시각을 기준으로 표시한다. 재진입해도 10분으로 다시 시작하지 않는다.
|
||||
class HeroTimeStatus extends StatelessWidget {
|
||||
const HeroTimeStatus({super.key, required this.fandomId, required this.watch, this.onParticipate});
|
||||
final String fandomId;
|
||||
final PlanetWatch watch;
|
||||
final VoidCallback? onParticipate;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey(('hero-time', fandomId)),
|
||||
create: () => watch(fandomId),
|
||||
builder: (context, state) => state?.isHeroTime == true && state?.heroEndsAt != null
|
||||
? Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_RemainingTime(endsAt: state!.heroEndsAt!, nicknames: state.heroNicknames, claims: state.heroClaimCount),
|
||||
if (onParticipate != null) TextButton(onPressed: onParticipate, child: Text(context.l10n.heroParticipate)),
|
||||
])
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
|
||||
class _RemainingTime extends StatefulWidget {
|
||||
const _RemainingTime({required this.endsAt, required this.nicknames, this.claims});
|
||||
final List<String> nicknames;
|
||||
final int? claims;
|
||||
final DateTime endsAt;
|
||||
@override
|
||||
State<_RemainingTime> createState() => _RemainingTimeState();
|
||||
}
|
||||
|
||||
class _RemainingTimeState extends State<_RemainingTime> {
|
||||
Timer? _timer;
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) => setState(() {}));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final seconds = (widget.endsAt.difference(DateTime.now()).inMilliseconds / 1000).ceil();
|
||||
if (seconds <= 0) return const SizedBox.shrink();
|
||||
final minutes = seconds ~/ 60;
|
||||
final remainder = (seconds % 60).toString().padLeft(2, '0');
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Semantics(
|
||||
label: context.l10n.heroRemainingSemantics('$minutes', '${seconds % 60}'),
|
||||
excludeSemantics: true,
|
||||
child: Text(context.l10n.heroRemaining('$minutes:$remainder'),
|
||||
style: Theme.of(context).textTheme.labelMedium),
|
||||
),
|
||||
if (widget.nicknames.isNotEmpty)
|
||||
Text('by ${widget.nicknames.join(' · ')}', maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodySmall),
|
||||
if (widget.claims != null)
|
||||
Text(context.l10n.heroClaims('${widget.claims}'), style: Theme.of(context).textTheme.bodySmall),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 선택한 팬덤의 진행 중 이벤트에서 해당 행성으로 진입한다.
|
||||
class HeroTimeBillboard extends StatelessWidget {
|
||||
const HeroTimeBillboard({super.key, required this.fandomId,
|
||||
required this.fandomName, required this.watch, required this.onOpen});
|
||||
final String fandomId;
|
||||
final String fandomName;
|
||||
final PlanetWatch watch;
|
||||
final VoidCallback onOpen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey(('hero-billboard', fandomId)),
|
||||
create: () => watch(fandomId),
|
||||
builder: (context, state) {
|
||||
if (state?.isHeroTime != true || state?.heroEndsAt == null ||
|
||||
!state!.heroEndsAt!.isAfter(DateTime.now())) return const SizedBox.shrink();
|
||||
return Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_RemainingTime(endsAt: state.heroEndsAt!,
|
||||
nicknames: state.heroNicknames, claims: state.heroClaimCount),
|
||||
TextButton(onPressed: onOpen,
|
||||
child: Text(context.l10n.heroInProgress(fandomName))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../data/planet_visual_state.dart';
|
||||
import '../theme/tokens.dart';
|
||||
import 'live_stream.dart';
|
||||
import 'planet_25d.dart';
|
||||
|
||||
/// 첫 서버 스냅샷은 기준점. 과거 캐시와의 차이를 새 진화로 오인하지 않는다.
|
||||
class LivePlanet25D extends StatelessWidget {
|
||||
const LivePlanet25D({
|
||||
super.key,
|
||||
required this.fandomId,
|
||||
required this.watch,
|
||||
required this.image,
|
||||
required this.size,
|
||||
this.stage,
|
||||
this.onOpenHistory,
|
||||
this.glow = AppColors.neonViolet,
|
||||
this.animate = true,
|
||||
this.lowEffects = false,
|
||||
});
|
||||
final VoidCallback? onOpenHistory;
|
||||
final String fandomId;
|
||||
final PlanetWatch watch;
|
||||
final String image;
|
||||
final double size;
|
||||
final int? stage;
|
||||
final Color glow;
|
||||
final bool animate;
|
||||
final bool lowEffects;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey(('planet-state', fandomId)),
|
||||
create: () => watch(fandomId),
|
||||
builder: (context, state) => Planet25D(
|
||||
key: ValueKey((fandomId, state != null)),
|
||||
image: image,
|
||||
onOpenHistory: onOpenHistory,
|
||||
size: size,
|
||||
glow: glow,
|
||||
tint: planetColorOf(state?.colorKey),
|
||||
stage: state == null ? stage : state.stage,
|
||||
level: state?.level,
|
||||
isEclipsed: state?.isEclipsed ?? false,
|
||||
isHibernating: state?.isHibernating ?? false,
|
||||
isHeroTime: state?.isHeroTime ?? false,
|
||||
heroEventIds: state?.heroEventIds,
|
||||
animate: animate,
|
||||
lowEffects: lowEffects,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 배경 광량만 바꿔 콘텐츠의 가독성과 화면 구조는 유지한다.
|
||||
class LivePlanetLighting extends StatelessWidget {
|
||||
const LivePlanetLighting({
|
||||
super.key,
|
||||
required this.fandomId,
|
||||
required this.watch,
|
||||
required this.child,
|
||||
});
|
||||
final String fandomId;
|
||||
final PlanetWatch watch;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LiveStreamBuilder<PlanetVisualState>(
|
||||
key: ValueKey(('planet-light', fandomId)),
|
||||
create: () => watch(fandomId),
|
||||
builder: (context, state) => AnimatedOpacity(
|
||||
opacity: state?.isHibernating == true ? 0.35 : state?.isEclipsed == true ? 0.45 : 1,
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(seconds: 2),
|
||||
child: Stack(
|
||||
fit: StackFit.passthrough,
|
||||
children: [
|
||||
child,
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
opacity: state?.isHeroTime == true && state?.isEclipsed != true && state?.isHibernating != true
|
||||
? 1
|
||||
: 0,
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(seconds: 2),
|
||||
child: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: RadialGradient(
|
||||
colors: [Color(0x26FFD34D), Color(0x00FFD34D)],
|
||||
radius: 0.85,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 실시간 구독 한 자리 — [StreamBuilder] 에 **백그라운드 복귀 재구독**을 더한 것.
|
||||
///
|
||||
/// 왜 필요한가. Firestore 리스너는 단말이 오래 잠들면 서버와 끊긴 채로 남을 수
|
||||
/// 있고, 그러면 깨어난 화면이 잠들기 전 값을 그대로 그린다. 별첨 1 ① 예외 처리가
|
||||
/// 「백그라운드 복귀·화면 회전 시 렌더링 정상 재개」를 판정 항목으로 두었으므로
|
||||
/// 복귀 시 스트림을 새로 열어 첫 스냅샷을 다시 받는다.
|
||||
///
|
||||
/// [create] 는 **구독할 때마다 새 스트림을 만드는 함수**다. 이미 만들어진 스트림을
|
||||
/// 넘기면 재구독이 같은(이미 닫힌) 스트림을 다시 듣게 되므로 값이 오지 않는다.
|
||||
///
|
||||
/// **구독 대상이 바뀌면 [key] 를 바꿔 준다.** [create] 는 빌드마다 새로 만들어지는
|
||||
/// 클로저라 같은 대상인지 비교할 수 없다. 비교해서 다시 열면 프레임마다 재구독이
|
||||
/// 돌고, 비교 없이 두면 대상이 바뀌어도 옛 구독이 남는다. 그래서 갈아타기는
|
||||
/// 위젯 키(예: `ValueKey(fandomId)`)로만 한다.
|
||||
///
|
||||
/// 위젯이 사라지면 [StreamBuilder] 가 구독을 끊는다 — 별도 해제 코드가 없는 이유다.
|
||||
class LiveStreamBuilder<T> extends StatefulWidget {
|
||||
const LiveStreamBuilder({
|
||||
super.key,
|
||||
required this.create,
|
||||
required this.builder,
|
||||
});
|
||||
|
||||
final Stream<T> Function() create;
|
||||
|
||||
/// 값이 아직 없으면 null 로 부른다. 첫 프레임에 빈 자리를 그릴지 로딩을 그릴지는
|
||||
/// 화면이 정한다 — 배너는 빈 자리가 맞고 목록은 로딩이 맞다.
|
||||
final Widget Function(BuildContext context, T? value) builder;
|
||||
|
||||
@override
|
||||
State<LiveStreamBuilder<T>> createState() => _LiveStreamBuilderState<T>();
|
||||
}
|
||||
|
||||
class _LiveStreamBuilderState<T> extends State<LiveStreamBuilder<T>>
|
||||
with WidgetsBindingObserver {
|
||||
late Stream<T> _stream = widget.create();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
setState(() => _stream = widget.create());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => StreamBuilder<T>(
|
||||
stream: _stream,
|
||||
builder: (context, snap) => widget.builder(context, snap.data),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
|
||||
/// 표본 데이터 알림(G-08) — 본문을 밀지 않도록 앱바 오른쪽에 작은 「MOCK」 알약으로 둔다.
|
||||
/// 사유 문구는 길게 눌렀을 때와 스크린리더로 읽힌다.
|
||||
class MockTag extends StatelessWidget {
|
||||
const MockTag({super.key, required this.note});
|
||||
|
||||
final String note;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Tooltip(
|
||||
message: note,
|
||||
child: Semantics(
|
||||
label: 'MOCK · $note',
|
||||
excludeSemantics: true,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
border: Border.all(color: AppColors.neonViolet),
|
||||
),
|
||||
child: Text(
|
||||
'MOCK',
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.2,
|
||||
letterSpacing: -0.25,
|
||||
color: AppColors.neonViolet,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
import 'planet_evolution.dart';
|
||||
import 'adaptive_planet_effects.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 행성 컬러 팔레트 — 사본 「행성 색 선택」 `373:358685` ~ `373:358692` 순서.
|
||||
/// 생성 화면이 고르는 값과 렌더링이 읽는 값이 **같은 표**여야 하므로 여기 한 곳에 둔다(C-50).
|
||||
/// 흰 = 색 선택 명세(`2066:17910`)의 #FFFFFF — 해석 확인 요청 Q-PN-01 권고 B안(칩의 #D9D9D9 는
|
||||
/// Figma 기본 채움). 저장값은 키(`white`)라 값만 바꾸면 기존 행성도 따라온다.
|
||||
const planetColors = <(String, Color)>[
|
||||
('white', Color(0xFFFFFFFF)),
|
||||
('black', Color(0xFF000000)),
|
||||
('blue', Color(0xFF009CFE)),
|
||||
('green', Color(0xFF00C60C)),
|
||||
('gold', Color(0xFFFFC500)),
|
||||
('orange', Color(0xFFFF7D00)),
|
||||
('red', Color(0xFFFF0049)),
|
||||
('purple', Color(0xFFC300C8)),
|
||||
];
|
||||
|
||||
/// 서버 `colorKey` → 칩 색. 모르는 키(서버 기본값 `violet` 포함)는 null — 원래 일러스트 그대로.
|
||||
Color? planetColorOf(String? key) =>
|
||||
planetColors.where((c) => c.$1 == key).firstOrNull?.$2;
|
||||
|
||||
/// Figma 본체에 UX/FX v1.5 §2의 Stage별 레이어를 조합한다.
|
||||
/// Stage는 서버 확정값이며, null은 단계 데이터 미제공 상태다.
|
||||
class Planet25D extends StatefulWidget {
|
||||
const Planet25D({
|
||||
super.key,
|
||||
required this.image,
|
||||
required this.size,
|
||||
this.glow = AppColors.neonViolet,
|
||||
this.tint,
|
||||
this.stage,
|
||||
this.level,
|
||||
this.isEclipsed = false,
|
||||
this.isHibernating = false,
|
||||
this.isHeroTime = false,
|
||||
this.heroEventIds,
|
||||
this.onOpenHistory,
|
||||
this.lowEffects = false,
|
||||
this.animate = true,
|
||||
});
|
||||
|
||||
/// 행성 일러스트 자산 경로(`assets/figma/planet-*.png`).
|
||||
final VoidCallback? onOpenHistory;
|
||||
final String image;
|
||||
|
||||
/// 한 변 길이. 글로우와 링은 이 바깥으로 조금 번진다.
|
||||
final double size;
|
||||
|
||||
/// 아우라 색.
|
||||
final Color glow;
|
||||
|
||||
/// 생성 때 고른 컬러([planetColorOf]). 생성 화면 미리보기와 같은 곱하기 합성으로 본체에 입힌다.
|
||||
final Color? tint;
|
||||
|
||||
final int? stage;
|
||||
final int? level;
|
||||
final bool isEclipsed;
|
||||
final bool isHibernating;
|
||||
final bool isHeroTime;
|
||||
final Set<String>? heroEventIds;
|
||||
|
||||
/// 저사양에서는 상태를 유지하면서 입자·Blur만 축소한다.
|
||||
final bool lowEffects;
|
||||
|
||||
/// 정지 화면(목록 카드 등)에서는 끈다.
|
||||
final bool animate;
|
||||
|
||||
/// 전역 스위치. 위젯 테스트는 `pumpAndSettle` 이 끝나야 하므로 끈다
|
||||
/// (`test/flutter_test_config.dart`). 앱에서는 항상 켜져 있다.
|
||||
static bool animationsEnabled = true;
|
||||
|
||||
@override
|
||||
State<Planet25D> createState() => _Planet25DState();
|
||||
}
|
||||
|
||||
class _Planet25DState extends State<Planet25D>
|
||||
with SingleTickerProviderStateMixin {
|
||||
int get _quality => widget.lowEffects ? 2 : PlanetEffectsQuality.of(context);
|
||||
bool get _lowEffects => _quality >= 2;
|
||||
bool get _celebrating =>
|
||||
widget.isHeroTime && !widget.isEclipsed && !widget.isHibernating;
|
||||
int get _period => widget.isHibernating
|
||||
? 192
|
||||
: widget.isEclipsed
|
||||
? 96
|
||||
: _celebrating
|
||||
? 32
|
||||
: 48;
|
||||
double get _light => widget.isHibernating
|
||||
? 0.35
|
||||
: widget.isEclipsed
|
||||
? 0.5
|
||||
: _celebrating
|
||||
? 1.3
|
||||
: 1;
|
||||
late final AnimationController _c = AnimationController(
|
||||
vsync: this,
|
||||
duration: Duration(seconds: _period),
|
||||
);
|
||||
|
||||
bool get _motionEnabled =>
|
||||
widget.animate &&
|
||||
Planet25D.animationsEnabled &&
|
||||
!MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
void _syncMotion() {
|
||||
if (_motionEnabled && !_c.isAnimating) _c.repeat();
|
||||
if (!_motionEnabled) _c.stop();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_syncMotion();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(Planet25D old) {
|
||||
super.didUpdateWidget(old);
|
||||
if (old.isHibernating != widget.isHibernating ||
|
||||
old.isEclipsed != widget.isEclipsed ||
|
||||
old.isHeroTime != widget.isHeroTime) {
|
||||
_c.duration = Duration(seconds: _period);
|
||||
if (_motionEnabled) _c.repeat();
|
||||
}
|
||||
_syncMotion();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_c.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: _light, end: _light),
|
||||
duration: MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: const Duration(seconds: 2),
|
||||
builder: (context, light, _) => PlanetEvolution(
|
||||
stage: widget.stage,
|
||||
onOpenHistory: widget.onOpenHistory,
|
||||
level: widget.level,
|
||||
heroEventIds: widget.heroEventIds,
|
||||
heroEnabled: !widget.isEclipsed && !widget.isHibernating,
|
||||
animate: widget.animate,
|
||||
lowEffects: _lowEffects,
|
||||
builder: (stage) => Opacity(
|
||||
key: const ValueKey('planet-surface-light'),
|
||||
opacity: light.clamp(0.0, 1.0),
|
||||
child: _planet(context, stage, light),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _tinted(Widget body) => switch (widget.tint) {
|
||||
final tint? => ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(tint, BlendMode.modulate),
|
||||
child: body,
|
||||
),
|
||||
null => body,
|
||||
};
|
||||
|
||||
Widget _planet(BuildContext context, int? stage, double light) {
|
||||
final s = widget.size;
|
||||
final validStage = stage != null && stage >= 0 && stage <= 6;
|
||||
final l10n = context.l10n;
|
||||
final names = l10n.stageNames.split(',');
|
||||
final particleCount = stage == 0
|
||||
? 24
|
||||
: stage == 1
|
||||
? 6
|
||||
: stage == 5
|
||||
? 40
|
||||
: stage == 6
|
||||
? 18
|
||||
: 14;
|
||||
final glowStrength = _quality == 0
|
||||
? 1.0
|
||||
: _quality == 1
|
||||
? 0.8
|
||||
: 0.6;
|
||||
final ring = validStage && stage >= 4;
|
||||
final glow = Color.lerp(
|
||||
widget.glow,
|
||||
AppColors.gold,
|
||||
((light - 1) / 0.3).clamp(0.0, 1.0),
|
||||
)!;
|
||||
return Semantics(
|
||||
label:
|
||||
'${validStage ? 'Stage $stage · ${names[stage]}' : l10n.planetStageUnknown}${widget.isHibernating
|
||||
? l10n.planetHibernatingSuffix
|
||||
: widget.isEclipsed
|
||||
? l10n.planetEclipseSuffix
|
||||
: _celebrating
|
||||
? l10n.planetHeroSuffix
|
||||
: ''}',
|
||||
image: true,
|
||||
child: RepaintBoundary(
|
||||
child: SizedBox(
|
||||
width: s,
|
||||
height: s,
|
||||
child: AnimatedBuilder(
|
||||
animation: _c,
|
||||
builder: (context, _) {
|
||||
final t = _motionEnabled ? _c.value : 0.0; // 0~1
|
||||
final wave = math.sin(t * 2 * math.pi); // -1~1
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
// 2. 글로우 아우라 — 은은하게 커졌다 작아진다.
|
||||
Positioned.fill(
|
||||
child: Transform.scale(
|
||||
scale:
|
||||
(stage == 5 ? 1.42 : 1.25) +
|
||||
(stage == 6 ? 0 : wave * 0.05) +
|
||||
(light - 1).clamp(0.0, 0.3),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
gradient: RadialGradient(
|
||||
colors: [
|
||||
glow.withValues(
|
||||
alpha:
|
||||
((stage == 1
|
||||
? 0.22
|
||||
: stage == 5
|
||||
? 0.55
|
||||
: 0.42) +
|
||||
wave * (stage == 6 ? 0.02 : 0.05)) *
|
||||
glowStrength,
|
||||
),
|
||||
glow.withValues(alpha: 0.10 * glowStrength),
|
||||
glow.withValues(alpha: 0),
|
||||
],
|
||||
stops: const [0.45, 0.72, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (stage == 6)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _SustainedAura(
|
||||
color: glow,
|
||||
lowEffects: _lowEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 6. 링 — 뒤쪽 절반. 본체 뒤에 깔린다.
|
||||
if (ring)
|
||||
Positioned.fill(
|
||||
child: _RingLayer(
|
||||
compact: stage == 4,
|
||||
strength: stage == 5
|
||||
? 0.58
|
||||
: stage == 6
|
||||
? 0.52
|
||||
: 0.48,
|
||||
t: t,
|
||||
color: glow,
|
||||
lowEffects: _lowEffects,
|
||||
front: false,
|
||||
),
|
||||
),
|
||||
if (stage == 0)
|
||||
Transform.rotate(
|
||||
angle: t * 2 * math.pi,
|
||||
child: Opacity(
|
||||
opacity: 0.85,
|
||||
child: Image.asset(
|
||||
'assets/fx/nebula-figma.png',
|
||||
width: s,
|
||||
height: s,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Transform.translate(
|
||||
offset: Offset(0, wave * s * 0.025),
|
||||
// 임시조치: 본체 이미지를 평면 회전시키지 않는다.
|
||||
// 실제 구체 자전은 별도 표면/모델 기반 구현이 필요하다.
|
||||
child: _tinted(
|
||||
Image.asset(
|
||||
widget.image,
|
||||
width: s,
|
||||
height: s,
|
||||
fit: BoxFit.contain,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (validStage && stage >= 2)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: Transform.rotate(
|
||||
angle: -t * 2 * math.pi,
|
||||
child: Opacity(
|
||||
opacity: stage == 2 ? 0.65 : 0.8,
|
||||
child: Image.asset(
|
||||
'assets/fx/clouds.png',
|
||||
fit: BoxFit.contain,
|
||||
cacheWidth: _lowEffects ? 256 : 512,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (validStage && stage >= 3)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _OrbitPainter(
|
||||
t: t,
|
||||
stage: stage,
|
||||
color: glow,
|
||||
lowEffects: _lowEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// 6. 링 — 앞쪽 절반. 본체를 가려 깊이를 만든다.
|
||||
if (ring)
|
||||
Positioned.fill(
|
||||
child: _RingLayer(
|
||||
compact: stage == 4,
|
||||
strength: stage == 5
|
||||
? 0.58
|
||||
: stage == 6
|
||||
? 0.52
|
||||
: 0.48,
|
||||
t: t,
|
||||
color: glow,
|
||||
lowEffects: _lowEffects,
|
||||
front: true,
|
||||
),
|
||||
),
|
||||
// 7. 입자 — 자리마다 위상이 달라 서로 다른 박자로 반짝인다.
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _ParticlePainter(
|
||||
t: t,
|
||||
color: glow,
|
||||
steady: stage == 6,
|
||||
count:
|
||||
(particleCount *
|
||||
light *
|
||||
(_quality == 2
|
||||
? 0.3
|
||||
: _quality == 1
|
||||
? 0.6
|
||||
: 1))
|
||||
.round()
|
||||
.clamp(2, 60),
|
||||
lowEffects: _lowEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 원본 보드의 다중 고리/잔해 질감. 뒤→본체→앞으로 합성한다.
|
||||
class _RingLayer extends StatelessWidget {
|
||||
const _RingLayer({
|
||||
required this.strength,
|
||||
required this.compact,
|
||||
required this.t,
|
||||
required this.color,
|
||||
required this.front,
|
||||
required this.lowEffects,
|
||||
});
|
||||
final double strength;
|
||||
final bool compact;
|
||||
final double t;
|
||||
final Color color;
|
||||
final bool front;
|
||||
final bool lowEffects;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => LayoutBuilder(
|
||||
builder: (context, box) {
|
||||
final s = box.maxWidth;
|
||||
return IgnorePointer(
|
||||
child: OverflowBox(
|
||||
maxWidth: s * 1.7,
|
||||
maxHeight: s * 1.7,
|
||||
child: Transform.rotate(
|
||||
angle: -0.32,
|
||||
child: SizedBox.square(
|
||||
dimension: s * 1.7,
|
||||
child: ClipPath(
|
||||
clipper: _RingHalf(front, compact: compact),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: strength * (front ? 1 : 0.7),
|
||||
child: Image.asset(
|
||||
'assets/fx/planet-rings.png',
|
||||
fit: BoxFit.contain,
|
||||
cacheWidth: lowEffects ? 512 : 1024,
|
||||
),
|
||||
),
|
||||
CustomPaint(
|
||||
painter: _RingDust(
|
||||
t: t,
|
||||
color: color,
|
||||
lowEffects: lowEffects,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _RingHalf extends CustomClipper<Path> {
|
||||
const _RingHalf(this.front, {this.compact = false});
|
||||
final bool compact;
|
||||
final bool front;
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
var half = Path()
|
||||
..addRect(
|
||||
Rect.fromLTWH(
|
||||
0,
|
||||
front ? size.height / 2 : 0,
|
||||
size.width,
|
||||
size.height / 2,
|
||||
),
|
||||
);
|
||||
if (compact) {
|
||||
half = Path.combine(
|
||||
PathOperation.intersect,
|
||||
half,
|
||||
Path()..addOval(
|
||||
Rect.fromCenter(
|
||||
center: size.center(Offset.zero),
|
||||
width: size.width * 0.80,
|
||||
height: size.height * 0.32,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (front) return half;
|
||||
// 본체 에셋의 가장자리 알파에도 뒤 고리가 비치지 않도록 구면 안쪽을 차폐.
|
||||
final body = Path()
|
||||
..addOval(
|
||||
Rect.fromCircle(
|
||||
center: size.center(Offset.zero),
|
||||
radius: size.width / 1.7 * 0.44,
|
||||
),
|
||||
);
|
||||
return Path.combine(PathOperation.difference, half, body);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(_RingHalf old) =>
|
||||
old.front != front || old.compact != compact;
|
||||
}
|
||||
|
||||
/// 정적 질감 위를 실제로 공전하는 빛가루. 저사양은 수만 줄인다.
|
||||
class _RingDust extends CustomPainter {
|
||||
const _RingDust({
|
||||
required this.t,
|
||||
required this.color,
|
||||
required this.lowEffects,
|
||||
});
|
||||
final double t;
|
||||
final Color color;
|
||||
final bool lowEffects;
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final random = math.Random(421);
|
||||
final c = size.center(Offset.zero);
|
||||
for (var i = 0; i < (lowEffects ? 6 : 18); i++) {
|
||||
final radius = 0.34 + random.nextDouble() * 0.10;
|
||||
final a = random.nextDouble() * math.pi * 2 + t * math.pi * 2;
|
||||
final p =
|
||||
c +
|
||||
Offset(
|
||||
math.cos(a) * size.width * radius,
|
||||
math.sin(a) * size.height * radius * 0.52,
|
||||
);
|
||||
final r = size.width * (0.0008 + random.nextDouble() * 0.002);
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
r,
|
||||
Paint()
|
||||
..color = Color.lerp(
|
||||
color,
|
||||
Colors.white,
|
||||
random.nextDouble(),
|
||||
)!.withValues(alpha: 0.2 + 0.2 * math.sin(a).abs()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_RingDust old) =>
|
||||
old.t != t || old.color != color || old.lowEffects != lowEffects;
|
||||
}
|
||||
|
||||
/// 행성 둘레의 반짝이. 자리는 고정 시드, 밝기는 자리별 위상으로 흔든다.
|
||||
class _ParticlePainter extends CustomPainter {
|
||||
const _ParticlePainter({
|
||||
required this.t,
|
||||
required this.color,
|
||||
required this.count,
|
||||
this.steady = false,
|
||||
required this.lowEffects,
|
||||
});
|
||||
|
||||
final int count;
|
||||
final bool steady;
|
||||
final bool lowEffects;
|
||||
|
||||
final double t;
|
||||
final Color color;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rnd = math.Random(2026);
|
||||
final c = size.center(Offset.zero);
|
||||
for (var i = 0; i < count; i++) {
|
||||
final ang = rnd.nextDouble() * 2 * math.pi;
|
||||
final dist = size.width * (0.38 + rnd.nextDouble() * 0.28);
|
||||
final phase = rnd.nextDouble();
|
||||
final blink = steady
|
||||
? 0.55
|
||||
: (math.sin((t + phase) * 2 * math.pi) + 1) / 2; // 0~1
|
||||
final p = Offset(
|
||||
c.dx + dist * math.cos(ang),
|
||||
c.dy + dist * math.sin(ang) * 0.8,
|
||||
);
|
||||
final r = size.width * (0.003 + 0.006 * blink);
|
||||
if (!lowEffects)
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
r * 2.5,
|
||||
Paint()
|
||||
..color = color.withValues(alpha: 0.35 * blink)
|
||||
..maskFilter = lowEffects
|
||||
? null
|
||||
: const MaskFilter.blur(BlurStyle.normal, 4),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
r,
|
||||
Paint()
|
||||
..color = AppColors.onCosmic.withValues(alpha: 0.4 + 0.6 * blink),
|
||||
);
|
||||
// 몇 개에는 가는 십자 광선.
|
||||
if (!steady && i % 5 == 0) {
|
||||
final ray = Paint()
|
||||
..color = AppColors.onCosmic.withValues(alpha: 0.6 * blink)
|
||||
..strokeWidth = 0.8;
|
||||
canvas.drawLine(p - Offset(r * 3, 0), p + Offset(r * 3, 0), ray);
|
||||
canvas.drawLine(p - Offset(0, r * 3), p + Offset(0, r * 3), ray);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ParticlePainter old) =>
|
||||
old.t != t ||
|
||||
old.color != color ||
|
||||
old.count != count ||
|
||||
old.steady != steady ||
|
||||
old.lowEffects != lowEffects;
|
||||
}
|
||||
|
||||
/// Stage 3 공동 에너지 궤도, Stage 4+ 지속 위성.
|
||||
class _OrbitPainter extends CustomPainter {
|
||||
const _OrbitPainter({
|
||||
required this.t,
|
||||
required this.stage,
|
||||
required this.color,
|
||||
required this.lowEffects,
|
||||
});
|
||||
final double t;
|
||||
final int stage;
|
||||
final Color color;
|
||||
final bool lowEffects;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final c = size.center(Offset.zero);
|
||||
final s = size.shortestSide;
|
||||
final orbit = Rect.fromCenter(center: c, width: s * 1.18, height: s * 0.52);
|
||||
canvas.save();
|
||||
canvas.translate(c.dx, c.dy);
|
||||
canvas.rotate(-0.35);
|
||||
canvas.translate(-c.dx, -c.dy);
|
||||
if (stage == 3) {
|
||||
// 공유 에너지 세 무리: 표면 구름(Stage 2)과 다른 주변 구조이며 고체 위성은 아니다.
|
||||
final energyOrbit = Rect.fromCenter(
|
||||
center: c,
|
||||
width: s * 1.32,
|
||||
height: s * 1.04,
|
||||
);
|
||||
for (var group = 0; group < 3; group++) {
|
||||
final a = (t + group / 3) * math.pi * 2 - 0.4;
|
||||
for (var j = 0; j < (lowEffects ? 3 : 6); j++) {
|
||||
final angle = a - j * 0.07;
|
||||
final p =
|
||||
c +
|
||||
Offset(
|
||||
math.cos(angle) * energyOrbit.width / 2,
|
||||
math.sin(angle) * energyOrbit.height / 2,
|
||||
);
|
||||
final radius = s * (0.024 - j * 0.0025);
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
radius * 2.3,
|
||||
Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [
|
||||
color.withValues(alpha: 0.65 - j * 0.07),
|
||||
color.withValues(alpha: 0),
|
||||
],
|
||||
).createShader(Rect.fromCircle(center: p, radius: radius * 2.3)),
|
||||
);
|
||||
if (j == 0)
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
radius * 0.48,
|
||||
Paint()
|
||||
..color = Color.lerp(
|
||||
color,
|
||||
Colors.white,
|
||||
0.7,
|
||||
)!.withValues(alpha: 0.9 - j * 0.1),
|
||||
);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
final a = t * math.pi * 2;
|
||||
final p =
|
||||
c +
|
||||
Offset(math.cos(a) * orbit.width / 2, math.sin(a) * orbit.height / 2);
|
||||
final r = s * 0.065;
|
||||
canvas.drawCircle(
|
||||
p,
|
||||
r,
|
||||
Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [Colors.white, color, const Color(0xFF251744)],
|
||||
stops: const [0, 0.55, 1],
|
||||
center: const Alignment(-0.4, -0.4),
|
||||
).createShader(Rect.fromCircle(center: p, radius: r)),
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_OrbitPainter old) =>
|
||||
old.t != t ||
|
||||
old.stage != stage ||
|
||||
old.color != color ||
|
||||
old.lowEffects != lowEffects;
|
||||
}
|
||||
|
||||
/// Stage 6: 폭죽 대신 구면 주위에 머무는 넓고 일정한 대기광.
|
||||
class _SustainedAura extends CustomPainter {
|
||||
const _SustainedAura({required this.color, required this.lowEffects});
|
||||
final Color color;
|
||||
final bool lowEffects;
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final c = size.center(Offset.zero);
|
||||
final r = size.shortestSide * 0.82;
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
r,
|
||||
Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Colors.transparent,
|
||||
const Color(0xFFC1B9FF).withValues(alpha: lowEffects ? 0.19 : 0.3),
|
||||
color.withValues(alpha: lowEffects ? 0.1 : 0.17),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0, 0.50, 0.62, 0.76, 1],
|
||||
).createShader(Rect.fromCircle(center: c, radius: r)),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_SustainedAura old) =>
|
||||
old.color != color || old.lowEffects != lowEffects;
|
||||
}
|
||||
@@ -0,0 +1,509 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
import 'planet_fx_scope.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// v1.5 §2.1/6. 진화·HERO 발동은 하나의 타임라인을 쓰며 첫 조회는 기준점이다.
|
||||
class PlanetEvolution extends StatefulWidget {
|
||||
const PlanetEvolution({
|
||||
super.key,
|
||||
required this.stage,
|
||||
required this.level,
|
||||
required this.animate,
|
||||
required this.lowEffects,
|
||||
required this.builder,
|
||||
this.heroEventIds,
|
||||
this.onOpenHistory,
|
||||
this.heroEnabled = true,
|
||||
});
|
||||
final VoidCallback? onOpenHistory;
|
||||
final Set<String>? heroEventIds;
|
||||
final bool heroEnabled;
|
||||
final int? stage;
|
||||
final int? level;
|
||||
final bool animate;
|
||||
final bool lowEffects;
|
||||
final Widget Function(int? stage) builder;
|
||||
|
||||
@override
|
||||
State<PlanetEvolution> createState() => _PlanetEvolutionState();
|
||||
}
|
||||
|
||||
class _PlanetEvolutionState extends State<PlanetEvolution>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final _fx = AnimationController(vsync: this)
|
||||
..addStatusListener((status) {
|
||||
if (status == AnimationStatus.completed)
|
||||
setState(() {
|
||||
if (_evolution && !_hero) _completedEvolution = true;
|
||||
_active = false;
|
||||
_shownStage = widget.stage;
|
||||
if (_pendingHero.isNotEmpty &&
|
||||
_canHero &&
|
||||
widget.animate &&
|
||||
TickerMode.valuesOf(context).enabled) {
|
||||
_pendingHero.clear();
|
||||
_start(false, hero: true);
|
||||
} else if (_pendingHeroEnd &&
|
||||
!_canHero &&
|
||||
widget.heroEnabled &&
|
||||
widget.animate &&
|
||||
TickerMode.valuesOf(context).enabled) {
|
||||
_pendingHeroEnd = false;
|
||||
_start(false, hero: true, ending: true);
|
||||
} else {
|
||||
_activity?.setActive(this, false);
|
||||
}
|
||||
});
|
||||
});
|
||||
PlanetFxActivity? _activity;
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_active && !TickerMode.valuesOf(context).enabled) {
|
||||
_fx.stop();
|
||||
_pendingHero.clear();
|
||||
_pendingHeroEnd = false;
|
||||
_activity?.setActive(this, false);
|
||||
_active = false;
|
||||
_shownStage = widget.stage;
|
||||
}
|
||||
final next = PlanetFxScope.of(context);
|
||||
if (!identical(next, _activity)) {
|
||||
_activity?.setActive(this, false);
|
||||
_activity = next;
|
||||
_activity?.setActive(this, _active && (_evolution || _hero));
|
||||
}
|
||||
}
|
||||
|
||||
int? _highestStage;
|
||||
int? _highestLevel;
|
||||
int? _shownStage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_highestStage = widget.stage;
|
||||
_highestLevel = widget.level;
|
||||
_shownStage = widget.stage;
|
||||
_heroReady = widget.heroEventIds != null;
|
||||
_seenHeroIds.addAll(widget.heroEventIds ?? const {});
|
||||
}
|
||||
|
||||
final _seenHeroIds = <String>{};
|
||||
bool _heroReady = false;
|
||||
bool _hero = false;
|
||||
bool _heroEnding = false;
|
||||
bool _pendingHeroEnd = false;
|
||||
final _pendingHero = <String>{};
|
||||
bool get _canHero =>
|
||||
widget.heroEnabled && (widget.heroEventIds?.isNotEmpty ?? false);
|
||||
bool _active = false;
|
||||
bool _evolution = false;
|
||||
bool _completedEvolution = false;
|
||||
|
||||
bool get _reduced => MediaQuery.disableAnimationsOf(context);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PlanetEvolution old) {
|
||||
super.didUpdateWidget(old);
|
||||
final stage = widget.stage;
|
||||
final level = widget.level;
|
||||
final evolved =
|
||||
stage != null &&
|
||||
old.stage != null &&
|
||||
_highestStage != null &&
|
||||
stage > _highestStage!;
|
||||
final levelled =
|
||||
level != null &&
|
||||
old.level != null &&
|
||||
_highestLevel != null &&
|
||||
level > _highestLevel!;
|
||||
if (stage != null) _highestStage = math.max(stage, _highestStage ?? stage);
|
||||
if (level != null) _highestLevel = math.max(level, _highestLevel ?? level);
|
||||
if (old.stage != null && (stage == null || stage < old.stage!)) {
|
||||
_completedEvolution = false;
|
||||
_fx.stop();
|
||||
_activity?.setActive(this, false);
|
||||
_active = false;
|
||||
_shownStage = stage;
|
||||
}
|
||||
final ids = widget.heroEventIds;
|
||||
final heroEnded =
|
||||
old.heroEventIds?.isNotEmpty == true && ids?.isEmpty == true;
|
||||
if (_canHero || !widget.heroEnabled) _pendingHeroEnd = false;
|
||||
if (_active && _heroEnding && (_canHero || !widget.heroEnabled)) {
|
||||
_fx.stop();
|
||||
_activity?.setActive(this, false);
|
||||
_active = false;
|
||||
}
|
||||
final newHeroIds = (ids ?? const <String>{}).difference(_seenHeroIds);
|
||||
_pendingHero.retainAll(ids ?? const {});
|
||||
final newHero = ids != null && _heroReady && newHeroIds.isNotEmpty;
|
||||
if (ids != null) {
|
||||
_seenHeroIds.addAll(ids);
|
||||
_heroReady = true;
|
||||
}
|
||||
if (!_canHero) {
|
||||
_pendingHero.clear();
|
||||
if (_active && _hero && !_heroEnding) {
|
||||
_fx.stop();
|
||||
_activity?.setActive(this, false);
|
||||
_active = false;
|
||||
}
|
||||
}
|
||||
final visible = widget.animate && TickerMode.valuesOf(context).enabled;
|
||||
if (!visible) {
|
||||
_fx.stop();
|
||||
_activity?.setActive(this, false);
|
||||
_active = false;
|
||||
}
|
||||
if (newHero && visible && _canHero) _pendingHero.addAll(newHeroIds);
|
||||
if (!visible) {
|
||||
_pendingHero.clear();
|
||||
_pendingHeroEnd = false;
|
||||
}
|
||||
if (heroEnded && visible && widget.heroEnabled) _pendingHeroEnd = true;
|
||||
if (evolved && visible) {
|
||||
// 같은 화면에서 Evolution 중 추가 승급은 최종 Stage로 합쳐 강한 FX를 겹치지 않는다.
|
||||
if (!_active || !_evolution) _start(true);
|
||||
} else if (_pendingHeroEnd && visible && !(_active && _evolution)) {
|
||||
_pendingHeroEnd = false;
|
||||
_start(false, hero: true, ending: true);
|
||||
} else if (_pendingHero.isNotEmpty &&
|
||||
visible &&
|
||||
!(_active && (_evolution || _hero))) {
|
||||
_pendingHero.clear();
|
||||
_start(false, hero: true);
|
||||
} else if (levelled && visible && !(_active && (_evolution || _hero))) {
|
||||
_start(false);
|
||||
}
|
||||
if (!_active) _shownStage = stage;
|
||||
}
|
||||
|
||||
void _start(bool evolution, {bool hero = false, bool ending = false}) {
|
||||
if (evolution) _completedEvolution = false;
|
||||
_hero = hero;
|
||||
_heroEnding = ending;
|
||||
_activity?.setActive(this, evolution || hero);
|
||||
_evolution = evolution;
|
||||
_active = true;
|
||||
_fx.duration = Duration(
|
||||
milliseconds: _reduced
|
||||
? 1200
|
||||
: ending
|
||||
? 1100
|
||||
: hero
|
||||
? 2400
|
||||
: evolution
|
||||
? 3200
|
||||
: 850,
|
||||
);
|
||||
_fx.forward(from: 0);
|
||||
if (ending) return;
|
||||
if (evolution || hero) {
|
||||
HapticFeedback.heavyImpact();
|
||||
} else {
|
||||
HapticFeedback.mediumImpact();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_activity?.setActive(this, false);
|
||||
_fx.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AnimatedBuilder(
|
||||
animation: _fx,
|
||||
builder: (context, _) {
|
||||
final p = _fx.value;
|
||||
final stage = !_active || !_evolution || _reduced || p >= 0.48
|
||||
? widget.stage
|
||||
: _shownStage;
|
||||
final l10n = context.l10n;
|
||||
final names = l10n.stageNames.split(',');
|
||||
final stageLabel =
|
||||
widget.stage != null && widget.stage! >= 0 && widget.stage! <= 6
|
||||
? 'Stage ${widget.stage} · ${names[widget.stage!]}'
|
||||
: l10n.evoPlanetGrowth;
|
||||
final label = _heroEnding
|
||||
? l10n.evoHeroEnd
|
||||
: _hero
|
||||
? (_reduced || p >= 0.5 ? 'HERO TIME' : l10n.evoAwakening)
|
||||
: !_evolution
|
||||
? 'Lv.${widget.level}'
|
||||
: _reduced
|
||||
? stageLabel
|
||||
: p < 0.16
|
||||
? l10n.evoAnomaly
|
||||
: p < 0.36
|
||||
? l10n.evoEnergy
|
||||
: p < 0.48
|
||||
? l10n.evoTransition
|
||||
: p < 0.70
|
||||
? stageLabel
|
||||
: l10n.evoComplete;
|
||||
return Stack(
|
||||
clipBehavior: Clip.none,
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Opacity(
|
||||
opacity: _active && _hero && !_heroEnding && !_reduced
|
||||
? (p < 0.18
|
||||
? 1 - p / 0.18 * 0.3
|
||||
: p < 0.5
|
||||
? 0.7 + (p - 0.18) / 0.32 * 0.3
|
||||
: 1)
|
||||
: 1,
|
||||
child: widget.builder(stage),
|
||||
),
|
||||
if (_active && !_reduced)
|
||||
Positioned.fill(
|
||||
child: IgnorePointer(
|
||||
child: CustomPaint(
|
||||
painter: _hero
|
||||
? _HeroPainter(
|
||||
progress: p,
|
||||
lowEffects: widget.lowEffects,
|
||||
ending: _heroEnding,
|
||||
)
|
||||
: _EvolutionPainter(
|
||||
progress: p,
|
||||
evolution: _evolution,
|
||||
lowEffects: widget.lowEffects,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!_active && _completedEvolution && widget.onOpenHistory != null)
|
||||
Positioned(bottom: 0, left: 0, right: 0,
|
||||
child: Center(child: TextButton(
|
||||
onPressed: () {
|
||||
setState(() => _completedEvolution = false);
|
||||
widget.onOpenHistory!();
|
||||
},
|
||||
child: Text(l10n.evoOpenHistory),
|
||||
)),
|
||||
),
|
||||
if (_active)
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: -30,
|
||||
right: -30,
|
||||
child: IgnorePointer(
|
||||
child: Semantics(
|
||||
liveRegion: true,
|
||||
child: Center(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicSurface.withValues(alpha: 0.9),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.labelMedium
|
||||
?.copyWith(color: AppColors.onCosmic),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _EvolutionPainter extends CustomPainter {
|
||||
const _EvolutionPainter({
|
||||
required this.progress,
|
||||
required this.evolution,
|
||||
required this.lowEffects,
|
||||
});
|
||||
final double progress;
|
||||
final bool evolution;
|
||||
final bool lowEffects;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final c = size.center(Offset.zero);
|
||||
final radius = size.shortestSide * 0.42;
|
||||
const color = AppColors.cosmicAccent;
|
||||
final p = progress;
|
||||
if (!evolution) {
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
radius * (1 + p * 0.5),
|
||||
Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2
|
||||
..color = color.withValues(alpha: (1 - p) * 0.8),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
radius * 1.4,
|
||||
Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [
|
||||
color.withValues(alpha: (1 - p) * 0.25),
|
||||
color.withValues(alpha: 0),
|
||||
],
|
||||
).createShader(Rect.fromCircle(center: c, radius: radius * 1.4)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (p < 0.16) return;
|
||||
if (p < 0.48) {
|
||||
final focus = ((p - 0.16) / 0.20).clamp(0.0, 1.0);
|
||||
final r = radius * (1.5 - focus * 1.3);
|
||||
canvas.drawCircle(
|
||||
c,
|
||||
r,
|
||||
Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.85 * focus),
|
||||
color.withValues(alpha: 0.4),
|
||||
color.withValues(alpha: 0),
|
||||
],
|
||||
).createShader(Rect.fromCircle(center: c, radius: r)),
|
||||
);
|
||||
for (var i = 0; i < (lowEffects ? 6 : 16); i++) {
|
||||
final a = i * 2.4 + p * 5;
|
||||
canvas.drawCircle(
|
||||
c + Offset(math.cos(a), math.sin(a)) * r,
|
||||
2,
|
||||
Paint()..color = color.withValues(alpha: 0.8),
|
||||
);
|
||||
}
|
||||
} else if (p >= 0.70) {
|
||||
final burst = (p - 0.70) / 0.30;
|
||||
for (var i = 0; i < (lowEffects ? 8 : 28); i++) {
|
||||
final x = ((i * 0.618) % 1 - 0.5) * radius * 3;
|
||||
final y = -radius + ((i * 0.37) % 1 + burst) * radius * 1.3;
|
||||
canvas.drawCircle(
|
||||
c + Offset(x, y),
|
||||
1.5 + (i % 3) * 0.5,
|
||||
Paint()
|
||||
..color = Color.lerp(
|
||||
color,
|
||||
Colors.white,
|
||||
(i % 3) / 2,
|
||||
)!.withValues(alpha: 1 - burst),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_EvolutionPainter old) =>
|
||||
old.progress != progress ||
|
||||
old.evolution != evolution ||
|
||||
old.lowEffects != lowEffects;
|
||||
}
|
||||
|
||||
/// 짧은 광원 집중→코어 이동→Ring Wave. 본체 반경 안팎에서 끝낸다.
|
||||
class _HeroPainter extends CustomPainter {
|
||||
const _HeroPainter({
|
||||
required this.progress,
|
||||
required this.lowEffects,
|
||||
this.ending = false,
|
||||
});
|
||||
final double progress;
|
||||
final bool ending;
|
||||
final bool lowEffects;
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final c = size.center(Offset.zero);
|
||||
final r = size.shortestSide * 0.46;
|
||||
const color = AppColors.gold;
|
||||
final p = progress;
|
||||
if (ending) {
|
||||
final remaining = 1 - Curves.easeInCubic.transform(p);
|
||||
final count = lowEffects ? 8 : 24;
|
||||
for (var i = 0; i < count; i++) {
|
||||
final a = i * 2.399 + p * 0.4;
|
||||
final reach = r * remaining * (0.7 + (i % 4) * 0.15);
|
||||
canvas.drawCircle(
|
||||
c + Offset(math.cos(a), math.sin(a) * 0.7) * reach,
|
||||
1.2 + i % 3 * 0.4,
|
||||
Paint()..color = color.withValues(alpha: (1 - p) * 0.65),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (p < 0.5) {
|
||||
final travel = ((p - 0.18) / 0.32).clamp(0.0, 1.0);
|
||||
final point = Offset.lerp(
|
||||
c + Offset(-r * 0.7, -r),
|
||||
c,
|
||||
Curves.easeInCubic.transform(travel),
|
||||
)!;
|
||||
final radius = r * (0.10 + travel * 0.10);
|
||||
canvas.drawCircle(
|
||||
point,
|
||||
radius * 2,
|
||||
Paint()
|
||||
..shader = RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.85),
|
||||
color.withValues(alpha: 0.6),
|
||||
color.withValues(alpha: 0),
|
||||
],
|
||||
stops: const [0, 0.2, 1],
|
||||
).createShader(Rect.fromCircle(center: point, radius: radius * 2)),
|
||||
);
|
||||
} else {
|
||||
final burst = (p - 0.5) * 2;
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2 * (1 - burst) + 0.5
|
||||
..color = color.withValues(alpha: (1 - burst) * 0.65);
|
||||
canvas.save();
|
||||
canvas.translate(c.dx, c.dy);
|
||||
canvas.rotate(-0.32);
|
||||
for (var i = 0; i < 2; i++) {
|
||||
canvas.drawOval(
|
||||
Rect.fromCenter(
|
||||
center: Offset.zero,
|
||||
width: r * (0.8 + burst * 2.2 - i * 0.15),
|
||||
height: r * (0.3 + burst - i * 0.05),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
canvas.restore();
|
||||
final count = lowEffects ? 8 : 24;
|
||||
for (var i = 0; i < count; i++) {
|
||||
final a = i * 2.399;
|
||||
final reach = r * (0.3 + burst * (0.8 + (i % 3) * 0.12));
|
||||
canvas.drawCircle(
|
||||
c + Offset(math.cos(a), math.sin(a) * 0.7) * reach,
|
||||
1 + i % 3 * 0.4,
|
||||
Paint()..color = color.withValues(alpha: (1 - burst) * 0.8),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HeroPainter old) =>
|
||||
old.progress != progress ||
|
||||
old.lowEffects != lowEffects ||
|
||||
old.ending != ending;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 같은 화면의 강한 행성 연출을 소유자별로 추적한다. 결과 문구는 숨기지 않는다.
|
||||
class PlanetFxActivity extends ChangeNotifier {
|
||||
final _owners = <Object>{};
|
||||
bool _scheduled = false;
|
||||
bool _disposed = false;
|
||||
bool get busy => _owners.isNotEmpty;
|
||||
|
||||
void setActive(Object owner, bool active) {
|
||||
final wasBusy = busy;
|
||||
active ? _owners.add(owner) : _owners.remove(owner);
|
||||
if (wasBusy == busy || _scheduled || _disposed) return;
|
||||
_scheduled = true;
|
||||
// 자식의 didUpdateWidget/dispose에서도 호출되므로 빌드 중 알림은 피한다.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_scheduled = false;
|
||||
if (!_disposed) notifyListeners();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
class PlanetFxScope extends StatefulWidget {
|
||||
const PlanetFxScope({super.key, required this.child});
|
||||
final Widget child;
|
||||
|
||||
static PlanetFxActivity? of(BuildContext context) =>
|
||||
context.dependOnInheritedWidgetOfExactType<_FxScope>()?.notifier;
|
||||
|
||||
@override
|
||||
State<PlanetFxScope> createState() => _PlanetFxScopeState();
|
||||
}
|
||||
|
||||
class _PlanetFxScopeState extends State<PlanetFxScope> {
|
||||
final activity = PlanetFxActivity();
|
||||
@override
|
||||
void dispose() {
|
||||
activity.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
_FxScope(notifier: activity, child: widget.child);
|
||||
}
|
||||
|
||||
class _FxScope extends InheritedNotifier<PlanetFxActivity> {
|
||||
const _FxScope({required super.notifier, required super.child});
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 시안 검색 화면 상단 배너(`Frame 1984081037`) — #dcdcdc r10 · 오른쪽 핑크 로고 칸 62.
|
||||
/// GALAXY 검색 · INSIDE 검색이 같이 쓴다.
|
||||
class PlayBanner extends StatelessWidget {
|
||||
const PlayBanner({super.key});
|
||||
|
||||
static TextStyle _t(double size, double height) => TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: size,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: height,
|
||||
letterSpacing: 0,
|
||||
color: const Color(0xFF737373),
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFDCDCDC),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(context.l10n.playNow, style: _t(10, 1.4)),
|
||||
Text(
|
||||
context.l10n.playHeadline,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.display,
|
||||
fontFamilyFallback: [AppFonts.body],
|
||||
fontWeight: FontWeight.w400,
|
||||
fontSize: 16,
|
||||
height: 20 / 16,
|
||||
letterSpacing: 0,
|
||||
color: Color(0xFF444444),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
context.l10n.playBody,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: _t(12, 20 / 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Container(
|
||||
width: 62,
|
||||
height: 62,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
'F',
|
||||
style: TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 34,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
color: Colors.black,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/// 상대 시각 표기.
|
||||
///
|
||||
/// 명세 `523:18399`(캠페인 카드)가 규칙 전부를 정했다 —
|
||||
/// *~1분 : 방금 전 · ~1시간 : n분 전 · ~24시간 : n시간 전 · ~7일 : n일 전 ·*
|
||||
/// *이후 : 날짜(2026.01.01)*
|
||||
///
|
||||
/// 같은 규칙이 `548:19137`(게시글 카드) · `554:22611` 에도 사본 주석으로 있다. 사본 표본의
|
||||
/// 「1달 전」은 이 규칙과 어긋나는 표본 문구라 따르지 않는다.
|
||||
///
|
||||
/// 화면마다 따로 만들면 「3일 전」과 「2026.01.01」이 같은 목록에서 섞인다.
|
||||
/// 한 곳에 두고 전 화면이 쓴다.
|
||||
library;
|
||||
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
String relativeTime(DateTime at, {DateTime? now}) {
|
||||
final d = (now ?? DateTime.now()).difference(at);
|
||||
if (d.inMinutes < 1) return appL10n.timeJustNow;
|
||||
if (d.inMinutes < 60) return appL10n.timeMinutesAgo('${d.inMinutes}');
|
||||
if (d.inHours < 24) return appL10n.timeHoursAgo('${d.inHours}');
|
||||
if (d.inDays < 7) return appL10n.timeDaysAgo('${d.inDays}');
|
||||
// 일주일이 넘으면 「37일 전」보다 날짜가 읽기 쉽다.
|
||||
final y = at.year.toString().padLeft(4, '0');
|
||||
final m = at.month.toString().padLeft(2, '0');
|
||||
final day = at.day.toString().padLeft(2, '0');
|
||||
return '$y.$m.$day';
|
||||
}
|
||||
|
||||
/// 반응 수치 표기. 명세 — *반응 수치는 K 단위 활용*.
|
||||
///
|
||||
/// POP 은 천 단위 구분자를 쓰므로([formatPop]) 다른 함수다. 좋아요 1,200 은
|
||||
/// 「1.2K」로 줄이는 편이 카드 안에서 읽히지만, POP 잔액을 줄이면 금액이 흐려진다.
|
||||
String formatReaction(int value) {
|
||||
if (value < 1000) return '$value';
|
||||
final k = value / 1000;
|
||||
// 시안은 「3K」 「1K」 처럼 소수 0 을 떼어 쓴다.
|
||||
final text = k >= 10 ? '${k.round()}' : k.toStringAsFixed(1);
|
||||
return '${text.endsWith('.0') ? text.substring(0, text.length - 2) : text}K';
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../theme/tokens.dart';
|
||||
import '../i18n/app_locale.dart';
|
||||
|
||||
/// 섹션 제목 + 「더 보기」.
|
||||
///
|
||||
/// Figma 홈의 `TODAY'S QUEST` · `AI Daily Briefing` · `HOT ARCHIVE` ·
|
||||
/// `인기 K-INSIDE` 가 전부 같은 모양이라 하나로 뺐다.
|
||||
class SectionHeader extends StatelessWidget {
|
||||
const SectionHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
this.onMore,
|
||||
this.moreLabel,
|
||||
});
|
||||
|
||||
final String title;
|
||||
|
||||
/// 「더 보기」 자리의 다른 문구. 브리핑은 이 자리가 「공유」다(Figma `266:90529`).
|
||||
final String? moreLabel;
|
||||
|
||||
/// 없으면 「더 보기」를 그리지 않는다.
|
||||
final VoidCallback? onMore;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final text = Theme.of(context).textTheme;
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
style: text.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
if (onMore != null)
|
||||
TextButton(
|
||||
onPressed: onMore,
|
||||
style: TextButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppSpace.s2),
|
||||
minimumSize: const Size(0, AppSizing.touchMinApp),
|
||||
foregroundColor: AppColors.onCosmicVariant,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(moreLabel ?? context.l10n.commonMore, style: text.bodySmall),
|
||||
const Icon(Icons.chevron_right, size: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user