앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user