앱 공통 계층 — 데이터 저장소 · 서버 시계 · 푸시 · 결제·광고 · 다국어 5종 · 행성 표현 · 공통 UI · 화면 자산
This commit is contained in:
+216
-11
@@ -1,31 +1,236 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'core/data/repositories.dart';
|
||||
import 'core/i18n/app_locale.dart';
|
||||
import 'core/ui/adaptive_planet_effects.dart';
|
||||
import 'core/push/push_messaging.dart';
|
||||
import 'core/router/app_router.dart';
|
||||
import 'core/theme/app_theme.dart';
|
||||
import 'features/auth/auth_repository.dart';
|
||||
import 'features/translation/translation_comments.dart';
|
||||
|
||||
/// MaterialApp + 라우터 + 테마 주입.
|
||||
class FancakeApp extends StatefulWidget {
|
||||
const FancakeApp({super.key, required this.auth});
|
||||
const FancakeApp({
|
||||
super.key,
|
||||
required this.auth,
|
||||
this.repositories,
|
||||
this.locale,
|
||||
});
|
||||
|
||||
final AuthRepository auth;
|
||||
|
||||
/// 저장소 묶음. 라우터와 푸시가 같은 것을 봐야 하므로 여기서 한 번만 만든다.
|
||||
/// 테스트에서 목 모드를 고정할 때만 넘긴다.
|
||||
final Repositories? repositories;
|
||||
|
||||
/// 표시 언어. 테스트가 언어를 고정할 때만 넘긴다.
|
||||
final LocaleController? locale;
|
||||
|
||||
@override
|
||||
State<FancakeApp> createState() => _FancakeAppState();
|
||||
}
|
||||
|
||||
class _FancakeAppState extends State<FancakeApp> {
|
||||
late final GoRouter _router = buildRouter(widget.auth);
|
||||
class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
late final Repositories _repos = widget.repositories ?? Repositories();
|
||||
late final GoRouter _router = buildRouter(widget.auth, repositories: _repos);
|
||||
|
||||
/// 표시 언어 — 정책 원장 B-76. 서버가 붙어 있으면 `i18n/{locale}` 원격 문자열을
|
||||
/// 덮고(FR-I18N-01), 고른 언어를 `users/{uid}.locale` 에 남긴다(설계서 §2.1).
|
||||
late final LocaleController _locale =
|
||||
widget.locale ??
|
||||
(LocaleController(
|
||||
remoteSource: _repos.live
|
||||
? (tag) => _repos.backend.db
|
||||
.doc('i18n/$tag')
|
||||
.snapshots()
|
||||
.map((s) => s.data() ?? const <String, Object?>{})
|
||||
: null,
|
||||
onSaved: _repos.live ? _saveLocale : null,
|
||||
)..restore());
|
||||
|
||||
/// 로그인 직후 — 이 기기에서 고른 언어가 없으면 프로필(`users/{uid}.locale`)을 따른다.
|
||||
Future<void> _adoptProfileLocale() async {
|
||||
final uid = _repos.backend.uid;
|
||||
if (uid == null) return;
|
||||
try {
|
||||
final snap = await _repos.backend.db.doc('users/$uid').get();
|
||||
await _locale.adoptProfile(snap.data()?['locale'] as String?);
|
||||
} catch (e) {
|
||||
debugPrint('[FancakeApp] profile locale: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _saveLocale(String tag) async {
|
||||
final uid = _repos.backend.uid;
|
||||
if (uid == null) return;
|
||||
await _repos.backend.db.doc('users/$uid').update({
|
||||
'locale': tag,
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
}
|
||||
|
||||
/// 포그라운드 알림을 앱 안에서 띄울 통로. 라우터 밖이라 `context` 를 쓸 수 없다.
|
||||
final _messengerKey = GlobalKey<ScaffoldMessengerState>();
|
||||
|
||||
/// 푸시를 이미 켰는가. 로그인 알림은 여러 번 오므로 한 번만 켠다.
|
||||
bool _pushStarted = false;
|
||||
|
||||
StreamSubscription<String>? _storeMessages;
|
||||
|
||||
/// HUD 「LIVE」 입력 — 포그라운드인 동안 `users/{uid}.lastSeenAt` 을 서버 시각으로 올린다
|
||||
/// (해석 확인 요청 Q-GM-03 A안 · 서버가 최근 5분 운영값으로 센다). 간격 2분은 창보다 짧아
|
||||
/// 앱을 켜 둔 사람이 집계에서 빠지지 않게 한다.
|
||||
Timer? _presence;
|
||||
|
||||
void _startPresence() {
|
||||
_presence?.cancel();
|
||||
if (!_repos.live || !widget.auth.isSignedIn) return;
|
||||
_touchPresence();
|
||||
_presence = Timer.periodic(
|
||||
const Duration(minutes: 2),
|
||||
(_) => _touchPresence(),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _touchPresence() async {
|
||||
final uid = _repos.backend.uid;
|
||||
if (uid == null) return;
|
||||
try {
|
||||
await _repos.backend.db.doc('users/$uid').update({
|
||||
'lastSeenAt': FieldValue.serverTimestamp(),
|
||||
});
|
||||
} catch (e) {
|
||||
debugPrint('[FancakeApp] presence: ${e.runtimeType}');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => MaterialApp.router(
|
||||
title: 'FANUZU',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: buildAppTheme(),
|
||||
// 코스믹 다크가 유일한 서피스다(결정 D-19). 라이트 테마를 두지 않는다.
|
||||
themeMode: ThemeMode.dark,
|
||||
darkTheme: buildAppTheme(),
|
||||
routerConfig: _router,
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
_startPresence();
|
||||
} else if (state == AppLifecycleState.paused) {
|
||||
_presence?.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
widget.auth.addListener(_onAuthChanged);
|
||||
_onAuthChanged();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_presence?.cancel();
|
||||
widget.auth.removeListener(_onAuthChanged);
|
||||
_repos.push?.dispose();
|
||||
if (widget.locale == null) _locale.dispose();
|
||||
unawaited(_storeMessages?.cancel());
|
||||
unawaited(_repos.store?.dispose());
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// 로그인 이후에 푸시를 켠다 — 토큰을 `users/{uid}` 에 붙이므로 UID 가 필요하다.
|
||||
///
|
||||
/// **로그아웃하면 플래그를 내린다.** 내리지 않으면 같은 앱 실행 안에서 다시
|
||||
/// 로그인했을 때 토큰 저장과 토픽 구독이 통째로 건너뛰어지고, 그 계정은 알림을
|
||||
/// 하나도 받지 못한다. 화면은 정상으로 보여 원인이 드러나지 않는다.
|
||||
void _onAuthChanged() {
|
||||
if (!widget.auth.isSignedIn) {
|
||||
_pushStarted = false;
|
||||
_presence?.cancel();
|
||||
return;
|
||||
}
|
||||
if (_presence == null || !_presence!.isActive) _startPresence();
|
||||
// 결제 알림은 검증에 UID 가 필요해 로그인 뒤에 듣는다. 두 번 불러도 한 번만 붙는다.
|
||||
final store = _repos.store;
|
||||
if (store != null && _storeMessages == null) {
|
||||
store.start();
|
||||
_storeMessages = store.messages.listen(_showMessage);
|
||||
}
|
||||
if (_pushStarted) return;
|
||||
if (_repos.live) _adoptProfileLocale();
|
||||
final push = _repos.push;
|
||||
if (push == null) return; // 목 모드. 플러그인을 부를 Firebase 가 없다.
|
||||
_pushStarted = true;
|
||||
_startPush(push);
|
||||
}
|
||||
|
||||
Future<void> _startPush(PushMessaging push) async {
|
||||
// 권한을 먼저 묻는다. 거부해도 나머지는 계속한다 — 토큰과 구독은 사용자가
|
||||
// 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
|
||||
await push.requestPermission();
|
||||
await push.syncToken();
|
||||
|
||||
// 팬덤 ID 는 PushMessaging 이 사용자 문서에서 직접 읽는다.
|
||||
await push.syncTopics(await _repos.my.pushSettings());
|
||||
await push.listen(onForeground: _showInApp, onOpened: _openFromPush);
|
||||
}
|
||||
|
||||
void _showMessage(String message) => _messengerKey.currentState
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(behavior: SnackBarBehavior.floating, content: Text(message)),
|
||||
);
|
||||
|
||||
/// 포그라운드 수신 — 시스템 알림이 뜨지 않으므로 앱이 직접 보여 준다.
|
||||
/// 탭하면 알림을 눌렀을 때와 같은 곳으로 간다.
|
||||
void _showInApp(PushNotice notice) {
|
||||
if (!notice.hasText) return;
|
||||
_messengerKey.currentState
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(
|
||||
SnackBar(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
content: Text(notice.title.isEmpty ? notice.body : notice.title),
|
||||
action: SnackBarAction(
|
||||
label: (_messengerKey.currentContext ?? context).l10n.commonView,
|
||||
onPressed: () => _openFromPush(notice),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openFromPush(PushNotice notice) => _router.go(notice.route);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ListenableBuilder(
|
||||
listenable: _locale,
|
||||
builder: (context, _) => LocaleScope(
|
||||
controller: _locale,
|
||||
// 캠페인 상세 · 라운지 글 상세처럼 여러 경로로 열리는 화면이 번역 저장소를 찾는 곳.
|
||||
child: TranslationScope(
|
||||
repository: _repos.translation,
|
||||
child: MaterialApp.router(
|
||||
title: 'FANUZU',
|
||||
builder: (context, child) =>
|
||||
AdaptivePlanetEffects(child: child ?? const SizedBox()),
|
||||
debugShowCheckedModeBanner: false,
|
||||
scaffoldMessengerKey: _messengerKey,
|
||||
locale: _locale.locale,
|
||||
supportedLocales: appLocales,
|
||||
localizationsDelegates: [
|
||||
RemoteL10nDelegate(_locale.remote),
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
theme: buildAppTheme(),
|
||||
// 코스믹 다크가 유일한 서피스다(결정 D-19). 라이트 테마를 두지 않는다.
|
||||
themeMode: ThemeMode.dark,
|
||||
darkTheme: buildAppTheme(),
|
||||
routerConfig: _router,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"@@locale": "en",
|
||||
"commonConfirm": "OK",
|
||||
"commonCancel": "Cancel",
|
||||
"commonClose": "Close",
|
||||
"commonSelect": "Select",
|
||||
"commonRetry": "Try again",
|
||||
"commonView": "View",
|
||||
"commonPrev": "Back",
|
||||
"commonDone": "Done",
|
||||
"commonStart": "Start",
|
||||
"commonNotYet": "The {where} screen arrives in the second build review (2026-09-10).",
|
||||
"languageSettingsTitle": "Language",
|
||||
"authLogin": "Log in",
|
||||
"authSignup": "Sign up",
|
||||
"authFirstTime": "New to FANUZU?",
|
||||
"authAlreadyMember": "Already have an account?",
|
||||
"authLoginHeadline": "Log in to FANUZU\nthe easiest way!",
|
||||
"authSignupHeadline": "Join FANUZU\nthe easiest way!",
|
||||
"authProviderLogin": "{provider, select, apple{Log in with Apple} google{Log in with Google} kakao{Log in with Kakao} line{Log in with LINE} other{Log in with email}}",
|
||||
"authProviderSignup": "{provider, select, apple{Sign up with Apple} google{Sign up with Google} kakao{Sign up with Kakao} line{Sign up with LINE} other{Sign up with email}}",
|
||||
"authNotConfiguredTitle": "{name} login coming soon",
|
||||
"authNotConfiguredBody": "{name} login opens once the client keys are provided.\n\nRequired\n· {items}",
|
||||
"authNotConfiguredNotice": "{name} login is not ready yet.\nRequired: {items}",
|
||||
"authResetNeedEmail": "Enter the email address for the reset link first.",
|
||||
"authResetSent": "We sent reset instructions to that address.",
|
||||
"termsTitle": "Terms of use",
|
||||
"termsAgreeAll": "Agree to all",
|
||||
"termsService": "Terms of Service (required)",
|
||||
"termsLocation": "Location-based Service Terms (required)",
|
||||
"termsAge": "I am 14 or older. (required)",
|
||||
"termsMarketing": "Receive marketing and event notifications (optional)",
|
||||
"signupDoneTitle": "You're all signed up",
|
||||
"signupDoneBody": "You can start your fandom activities.\nGrow your fandom planet through all kinds of activities.",
|
||||
"homeItemsTooltip": "Items",
|
||||
"homeNotificationsTooltip": "Notifications",
|
||||
"homeFandomHistory": "Fandom history",
|
||||
"homePopularInside": "Popular K-INSIDE",
|
||||
"homeWhereBriefing": "AI Briefing",
|
||||
"homeWhereInsideList": "INSIDE list",
|
||||
"homeWhereInsideDetail": "INSIDE detail",
|
||||
"homeLoadFailed": "Couldn't load Home",
|
||||
"homeRetryLater": "Please try again in a moment.",
|
||||
"myProfileCardTooltip": "Profile card",
|
||||
"myTabPlanets": "Planets/Nebulae",
|
||||
"myTabActivity": "Activity",
|
||||
"myInside": "Inside",
|
||||
"myCampaign": "Campaigns",
|
||||
"myShareLater": "{target} sharing will be connected in the 09-10 build.",
|
||||
"myEditPhoto": "Edit profile photo",
|
||||
"myEditProfile": "Edit profile",
|
||||
"settingsTitle": "Settings",
|
||||
"settingsAccount": "Account",
|
||||
"settingsNickname": "Nickname",
|
||||
"settingsEmail": "Email",
|
||||
"settingsSns": "Linked social accounts",
|
||||
"settingsPopHistory": "POP history",
|
||||
"settingsPayments": "Payments",
|
||||
"settingsServicePayments": "Service payment history",
|
||||
"settingsService": "Service settings",
|
||||
"settingsPush": "Push notifications",
|
||||
"settingsBlocked": "Blocked users",
|
||||
"settingsInfo": "About",
|
||||
"settingsNotice": "Notices",
|
||||
"settingsInquiry": "1:1 inquiry",
|
||||
"settingsAppVersion": "App version",
|
||||
"settingsLatest": "Latest",
|
||||
"settingsLogout": "Log out",
|
||||
"settingsLogoutConfirm": "Do you want to log out?",
|
||||
"settingsWithdraw": "Delete account",
|
||||
"settingsWithdrawConfirm": "Do you want to delete your account?",
|
||||
"settingsWithdrawBody": "Your POP will be lost and this cannot be undone.",
|
||||
"settingsWithdrawLater": "Account deletion will be connected in the 09-10 build.",
|
||||
"settingsUnlinkConfirm": "Unlink this account?",
|
||||
"settingsUnlinkBody": "Only this social login method is removed. Your account data is not deleted.",
|
||||
"settingsUnlink": "Unlink",
|
||||
"settingsSameEmailOnly": "Only social accounts with the same\nemail as your account can be linked.",
|
||||
"settingsLinkNotReady": "{name} linking is not ready yet. Required: {items}",
|
||||
"settingsConnect": "Link",
|
||||
"settingsDisconnect": "Unlink",
|
||||
"settingsEmailProvider": "Email",
|
||||
"pushDeviceOff": "Push notifications are turned off on this device.",
|
||||
"pushDeviceOffHint": "Allow notifications in system settings to receive them.",
|
||||
"pushRequesting": "Requesting…",
|
||||
"pushGoToSettings": "Open settings",
|
||||
"pushOpeningDeviceSettings": "Opening device settings.",
|
||||
"pushEnableInDevice": "Turn on FANUZU notifications in device settings to receive them.",
|
||||
"pushDeviceSection": "Device notifications",
|
||||
"pushSoundVibration": "Sound & vibration",
|
||||
"pushServiceSection": "Service notifications",
|
||||
"pushTopicEssential": "Essential",
|
||||
"pushTopicFandomEvent": "Fandom events",
|
||||
"pushTopicBriefing": "AI Briefing",
|
||||
"pushTopicCommunity": "Community",
|
||||
"pushTopicMarketing": "Marketing",
|
||||
"linkTitle": "{name} link",
|
||||
"linkNotLinked": "Not linked",
|
||||
"linkLinked": "Linked",
|
||||
"linkSameEmailOnly": "Only social accounts with the same email as your account can be linked.",
|
||||
"linkUnlink": "Unlink",
|
||||
"linkLink": "Link",
|
||||
"noticeBadge": "Notice",
|
||||
"noticeTitle": "Notices",
|
||||
"noticeEmpty": "No notices yet.",
|
||||
"noticeDetailTitle": "Notice",
|
||||
"translationMark": "Translated",
|
||||
"translationReplyTo": "Translating {name}'s comment",
|
||||
"translationWrite": "Write a translation",
|
||||
"translationHint": "Enter your translation",
|
||||
"translationValid": "Valid",
|
||||
"translationInvalid": "Invalid",
|
||||
"translationSelfMarkBlocked": "You can't mark your own translation.",
|
||||
"translationSubmitted": "Translation posted. If it passes review, POP is paid after 24 hours.",
|
||||
"translationMarked": "Marked.",
|
||||
"translationAlreadyMarked": "You already marked this translation.",
|
||||
"translationDuplicate": "You already posted a translation in this language.",
|
||||
"translationFailed": "Something went wrong. Please try again shortly.",
|
||||
"translationNotEligible": "Only members of this fandom whose account is at least 24 hours old can mark.",
|
||||
"translationDailyCap": "You can't post more translations today.",
|
||||
"translationInvalidTarget": "This content can't be translated.",
|
||||
"commonRequestFailed": "Couldn't process the request.",
|
||||
"commonFan": "Fan",
|
||||
"adLoadFailed": "Couldn't load the ad. You'll get the basic reward only.",
|
||||
"storeNotReady": "Store items coming soon",
|
||||
"storePending": "Waiting for payment approval. It will apply automatically once approved.",
|
||||
"storeFailed": "Couldn't complete the payment.",
|
||||
"storePurchased": "Your purchase has been applied.",
|
||||
"heroParticipate": "ZAP+ · Join the gift",
|
||||
"heroRemainingSemantics": "HERO TIME remaining: {minutes} min {seconds} sec",
|
||||
"heroRemaining": "HERO TIME · {time} left",
|
||||
"heroClaims": "Gifts claimed by fans: {count}",
|
||||
"heroInProgress": "{name} · HERO TIME in progress →",
|
||||
"stageNames": "Nebula,Start,Growth,Together,Journey,Light,Eternity",
|
||||
"planetStageUnknown": "Planet · No stage info",
|
||||
"planetHibernatingSuffix": " · Dormant",
|
||||
"planetEclipseSuffix": " · Eclipse in progress",
|
||||
"planetHeroSuffix": " · HERO TIME in progress",
|
||||
"evoPlanetGrowth": "Planet growth",
|
||||
"evoHeroEnd": "HERO TIME ended",
|
||||
"evoAwakening": "The planet is awakening",
|
||||
"evoAnomaly": "Anomaly detected",
|
||||
"evoEnergy": "Gathering energy",
|
||||
"evoTransition": "Transforming",
|
||||
"evoComplete": "Planet evolution complete",
|
||||
"evoOpenHistory": "View PASSPORT history",
|
||||
"playNow": "Play now",
|
||||
"playHeadline": "Let's go meet your fandom!",
|
||||
"playBody": "Create a planet, connect with fandoms, and enjoy fan activities...",
|
||||
"timeJustNow": "Just now",
|
||||
"timeMinutesAgo": "{n}m ago",
|
||||
"timeHoursAgo": "{n}h ago",
|
||||
"timeDaysAgo": "{n}d ago",
|
||||
"commonMore": "More",
|
||||
"authProviderStart": "{provider, select, email{Continue with email} kakao{Continue with Kakao} line{Continue with LINE} apple{Continue with Apple} other{Continue with Google}}",
|
||||
"authProviderName": "{provider, select, email{Email} kakao{Kakao} line{LINE} apple{Apple} other{Google}}",
|
||||
"authLastProvider": "You can't unlink your last sign-in method.",
|
||||
"authLoginRequired": "Please sign in.",
|
||||
"authDifferentEmail": "This account doesn't match your sign-up email.",
|
||||
"authLinkFailed": "Couldn't link the account.",
|
||||
"authNotLinked": "This account isn't linked.",
|
||||
"authEmailUnavailable": "Email sign-in is unavailable.",
|
||||
"authCheckCredentials": "Please check your email or password.",
|
||||
"authSocialFailed": "Social sign-in failed.",
|
||||
"authAppleUnavailable": "Sign in with Apple isn't available on this device.",
|
||||
"authEmailInUse": "This email is already registered.",
|
||||
"authWeakPassword": "Password is too short. Use at least 8 characters.",
|
||||
"authInvalidEmail": "Invalid email format.",
|
||||
"commonTryLater": "Please try again later.",
|
||||
"authCredentialInUse": "This social account is already linked to another FANUZU account.",
|
||||
"authAlreadyLinked": "This account is already linked.",
|
||||
"authMethodUnavailable": "This sign-in method isn't available yet.",
|
||||
"authRecentLogin": "For security, please sign in again and retry.",
|
||||
"authOtherProviderGuide": "The {email} account was registered with a different sign-in method. Sign in with the method you first used, then link this one in MY > Settings > Linked accounts.",
|
||||
"authThisAccount": "This",
|
||||
"eclipseCapped": "You reached the participation cap, so some shakes weren't counted.",
|
||||
"eclipseResultUnknown": "Couldn't confirm your participation. Please check again with the same request.",
|
||||
"eclipseChecking": "Checking participation",
|
||||
"eclipseRetrySame": "Recheck same participation",
|
||||
"eclipseEnded": "The eclipse has already ended or was successfully defended.",
|
||||
"eclipseTooFast": "You're participating too fast. Please try again shortly.",
|
||||
"eclipseNone": "No eclipse in progress",
|
||||
"eclipseSuccessTitle": "You stopped\nthe meteor impact!",
|
||||
"eclipseQualified": "Participation confirmed",
|
||||
"eclipseFailTitle": "The planet defense\nfailed this time",
|
||||
"eclipseFailBody": "This round's defense goal wasn't reached.",
|
||||
"eclipseCancelTitle": "Eclipse cancelled",
|
||||
"eclipseCancelBody": "Cancelled due to a tally error. No FPI was deducted.",
|
||||
"eclipsePendingTitle": "Checking defense result",
|
||||
"eclipsePendingBody": "Participation time is over. Waiting for the server's decision.",
|
||||
"eclipseShakeTimed": "s — shake your phone\nto stop the meteor impact!",
|
||||
"eclipseShake": "Shake your phone\nto stop the meteor impact!",
|
||||
"eclipseMyCount": "My shakes: {count}",
|
||||
"eclipsePendingSuffix": " · +{count} pending",
|
||||
"eclipseSendingSuffix": " · Sending",
|
||||
"eclipseNoPopNote": "No POP is spent. Make at least {count} valid shakes to count as a participant.",
|
||||
"eclipseTapToJoin": "Can't shake? Tap to join",
|
||||
"eclipseTimeLeft": "Eclipse time left:",
|
||||
"eclipseSeconds": " {seconds}s",
|
||||
"eclipseBannerDefended": "Defended — no FPI loss for {name}",
|
||||
"eclipseBannerActive": "Eclipse triggered — {name} erosion {rate}%",
|
||||
"eclipseBannerDefense": "Valid participants {count} / {target}",
|
||||
"timeHoursMinutesLeft": "{hours}h {minutes}m left",
|
||||
"timeMinutesLeft": "{minutes}m left",
|
||||
"liveEclipseDefense": "[LIVE] {name} joined the eclipse defense! (+{count})",
|
||||
"liveHotTime": "[LIVE] {name}'s Artist Hot Time has started!",
|
||||
"liveHeroGift": "[LIVE] A Hero's Gift from {name} has arrived!",
|
||||
"liveCampaign": "[LIVE] {name} backed a campaign!",
|
||||
"galaxySetPrimaryConfirm": "Set the selected planet as your main planet?",
|
||||
"galaxyLoadFailed": "Couldn't load the galaxy",
|
||||
"commonSearch": "Search",
|
||||
"galaxyMyPlanets": "My planets",
|
||||
"galaxyMap": "Galaxy map",
|
||||
"nebulaCreate": "Create nebula",
|
||||
"commonRecheck": "Check again",
|
||||
"zapApplying": "Applying contribution",
|
||||
"zapNeedsCheck": "Contribution result needs checking",
|
||||
"zapNoPlanet": "You don't belong to a planet",
|
||||
"commonContribute": "Contribute",
|
||||
"commonItem": "Items",
|
||||
"galaxyDefended": "Defended · No FPI loss",
|
||||
"galaxyErosion": "Erosion {rate}% · Defense {progress}%",
|
||||
"galaxyOnlineMembers": "{online} online · {members} members",
|
||||
"galaxySearchHint": "Search by fandom name",
|
||||
"commonRecentSearches": "Recent searches",
|
||||
"commonClearAll": "Clear all",
|
||||
"commonNoRecentSearches": "No recent searches.",
|
||||
"galaxyNoHit": "No fandom found.\nPlease check the name.",
|
||||
"galaxyMembers": "{count} members",
|
||||
"galaxyPlanetRank": "{name} planet, rank {rank}",
|
||||
"galaxyMapOpen": "Open galaxy map",
|
||||
"zapNotEnoughPop": "Not enough POP",
|
||||
"zapFailNoPop": "Not enough POP to contribute.",
|
||||
"zapFailLedger": "Contribution stopped: balance and earnings history don't match.",
|
||||
"zapFailRetry": "Check your connection and recheck the same contribution.",
|
||||
"zapDone": "Contributed",
|
||||
"zapDonePop": "Contributed {pop} POP",
|
||||
"growthFounders": "Founding members",
|
||||
"growthNebulaGoalPassed": "The goal period has passed · Meet the conditions and the planet will be born",
|
||||
"growthFinalStage": "Final stage",
|
||||
"growthPromotionPending": "Awaiting promotion",
|
||||
"growthCheckConditions": "Check growth conditions",
|
||||
"growthConditionChecking": "Checking {name} condition",
|
||||
"growthConditionMet": "{name} condition met",
|
||||
"growthConditionUnmet": "{name} condition not met",
|
||||
"nebulaHeadline": "Name your fandom planet.",
|
||||
"nebulaBody": "If founding members reach the POP goal within {days} days,\nit's born as an official planet!\nMembers who joined the evolution get a commemorative badge.",
|
||||
"nebulaNameTaken": "This name is already taken.",
|
||||
"nebulaNameHint": "Fandom name",
|
||||
"nebulaGoal": "Goal ",
|
||||
"nebulaFounders": " / Founders",
|
||||
"nebulaGoalSemantics": "Goal {pop} POP · {members} founding members · fixed {days} days",
|
||||
"commonMyPop": "My POP: ",
|
||||
"commonChange": "Change",
|
||||
"planetShape": "Shape {n}",
|
||||
"planetBgColor": "Choose background color",
|
||||
"planetColorSelect": "Choose planet color",
|
||||
"planetTypeSelect": "Choose planet type",
|
||||
"commonNext": "Next",
|
||||
"planetCreateFailed": "Couldn't create the planet. Please try again later.",
|
||||
"planetCreateConfirm": "Create a planet\nwith these options?",
|
||||
"commonCreate": "Create",
|
||||
"briefingOpenFailed": "Couldn't open the original.",
|
||||
"campaignAchieved": "{percent}% reached",
|
||||
"campaignView": "View campaign",
|
||||
"growthFinalReached": "You've reached the final stage",
|
||||
"zapRetrySame": "Recheck same contribution",
|
||||
"heroOwnerSuffix": "'s",
|
||||
"heroStartPrompt": "Create or join a planet\nand start your FANUZU journey!",
|
||||
"heroNebulaPrompt": "Use the POP you earn from activities\nto contribute to the nebula!",
|
||||
"heroRecentContribution": "Growing steadily with\n{pop} POP contributed.",
|
||||
"heroGrowingTogether": "A fandom planet we grow together",
|
||||
"heroPopProgress": "POP progress",
|
||||
"heroNebula": "Nebula",
|
||||
"heroNebulaTap": "Tap the nebula\nto give birth to a planet!",
|
||||
"heroCreateNebula": "Create planet (nebula)",
|
||||
"heroJoinPlanet": "Join a planet",
|
||||
"heroCreateOrJoin": "Create or join a planet",
|
||||
"insideAnswers": "Answers {count}",
|
||||
"itemJoinFirst": "Join a planet to send a Hero's Gift.",
|
||||
"itemBuyNotReady": "Item purchase is coming soon.",
|
||||
"itemUseNotReady": "Item use is coming soon.",
|
||||
"payOpenFailed": "Couldn't open the payment window.",
|
||||
"itemBuy": "Buy items",
|
||||
"itemSelect": "Choose an item",
|
||||
"commonComingSoon": "Coming soon.",
|
||||
"itemSendPop": "Send {pop} POP",
|
||||
"itemBuyerPop": "Buyer +{pop} POP",
|
||||
"itemOwned": "Owned: {count}",
|
||||
"itemOwnedCount": "Owned: {count}",
|
||||
"growthOwnerOf": "'s ",
|
||||
"growthSteadySuffix": " contributed — growing steadily.",
|
||||
"questAttended": "Checked in",
|
||||
"questInProgressSpaced": "In progress",
|
||||
"questRewardPaid": "Reward paid",
|
||||
"questClaim": "Claim reward",
|
||||
"questStart": "Start quest",
|
||||
"questRewardDone": "Reward claimed",
|
||||
"popKindGeneral": "Regular POP",
|
||||
"popKindContribution": "Contribution POP",
|
||||
"popTxGrant": "Granted",
|
||||
"popTxSpend": "Spent",
|
||||
"popTxRefund": "Refund",
|
||||
"popTxVoid": "Voided",
|
||||
"popTypeQuest": "Quest",
|
||||
"popTypeReward": "Reward",
|
||||
"popTypePayment": "Payment",
|
||||
"popTypeSubscription": "Subscription",
|
||||
"popTypeAdmin": "Admin",
|
||||
"popTypeFee": "Fee",
|
||||
"commonEtc": "Other",
|
||||
"popReasonQuestSteps": "Step counter quest",
|
||||
"popReasonQuestMusicShake": "Music Shake quest",
|
||||
"popReasonQuestPlace": "Place visit quest",
|
||||
"popReasonQuestAttend": "Check-in quest",
|
||||
"popReasonAdReward": "Ad reward",
|
||||
"popReasonHeroDrop": "Hero's Gift",
|
||||
"popReasonHeroDropTrigger": "Hero's Gift sent",
|
||||
"popReasonTranslation": "Community translation",
|
||||
"popReasonIapPurchase": "Purchase",
|
||||
"popReasonFocusDaily": "Focus daily reward",
|
||||
"popReasonPreregBonus": "Pre-registration reward",
|
||||
"popReasonReferral": "Friend invite",
|
||||
"popTypeReferral": "Referral",
|
||||
"popReasonAdminGrant": "Admin grant",
|
||||
"popReasonAdminClawback": "Admin clawback",
|
||||
"popReasonCampaignFund": "Campaign support",
|
||||
"popReasonCampaignRefund": "Campaign refund",
|
||||
"popReasonCampaignFeeRefund": "Campaign fee refund",
|
||||
"popReasonCampaignFee": "Campaign fee",
|
||||
"popReasonQnaBounty": "INSIDE bounty",
|
||||
"popReasonQnaRefund": "Bounty refund",
|
||||
"popReasonQnaReward": "INSIDE accepted-answer reward",
|
||||
"popReasonEclipseDefense": "Zap contribution",
|
||||
"popReasonOther": "POP change",
|
||||
"popRepeat": "{title} ×{count}",
|
||||
"payStatePaid": "Paid",
|
||||
"payStateRefundRequested": "Refund requested",
|
||||
"payStateRefundApproved": "Refund approved",
|
||||
"payStateRefunded": "Refunded",
|
||||
"payStateRefundRejected": "Refund rejected",
|
||||
"pushTopicEssentialDesc": "Account, payment, and maintenance notices. These can't be turned off.",
|
||||
"pushTopicFandomEventDesc": "Eclipse, campaign, quest, and Artist Hot Time news.",
|
||||
"pushTopicBriefingDesc": "Daily briefing cards summarizing your planet's day.",
|
||||
"pushTopicCommunityDesc": "Comments, likes, and answers on your posts.",
|
||||
"pushTopicMarketingDesc": "Get event and benefit info. Only on if you agree.",
|
||||
"inquiryPending": "Received",
|
||||
"inquiryInProgress": "In progress",
|
||||
"inquiryAnswered": "Answered",
|
||||
"photoBadType": "Only JPG, PNG, WEBP, or GIF images can be uploaded.",
|
||||
"photoTooBig": "The photo is over 10MB. Please choose a smaller one.",
|
||||
"photoUnreadable": "Couldn't read the photo. Please choose again.",
|
||||
"myListInside": "My INSIDE",
|
||||
"myListInsideEmpty": "You haven't posted to INSIDE yet.",
|
||||
"myListCampaign": "My campaigns",
|
||||
"myListCampaignEmpty": "You haven't created a campaign yet.",
|
||||
"sortLatest": "Latest",
|
||||
"sortPop": "By POP",
|
||||
"sortSolved": "Accepted",
|
||||
"sortAchieved": "Achieved",
|
||||
"payNotFound": "Payment not found.",
|
||||
"payItemFallback": "Purchased item",
|
||||
"payMethodInApp": "In-app purchase",
|
||||
"unknownUser": "Unknown user",
|
||||
"blockSelf": "You can't block yourself.",
|
||||
"inquiryFallback": "Inquiry",
|
||||
"photoRejectedServer": "Couldn't upload the photo. Check its format and size.",
|
||||
"photoUploadFailed": "Photo upload failed. Please try again later.",
|
||||
"focusClaimed": "You got today's Focus reward: +{pop} Contribution POP.",
|
||||
"focusActive": "Ad-free Focus mode active",
|
||||
"focusUntil": "Until {date} · Today's reward can only be claimed today",
|
||||
"focusClaimedToday": "Claimed today",
|
||||
"focusClaimToday": "Claim today's reward",
|
||||
"storeConnectFailed": "Couldn't connect to the store. Please try again later.",
|
||||
"payRestore": "Restore purchases",
|
||||
"payEmpty": "No payment history.",
|
||||
"payRefundGuide": "Consumables: refundable within 7 days of purchase if unused. Items that have been partly used are not refundable.\nSubscriptions: refundable within 24 hours of payment if no benefits (e.g., bonus rewards) were used.\nRefunds are completed within 7–14 days after approval.",
|
||||
"payDetailTitle": "Payment details",
|
||||
"payNumber": "Payment no.",
|
||||
"payDate": "Payment date",
|
||||
"payMethod": "Payment method",
|
||||
"payStatus": "Payment status",
|
||||
"payAmount": "Amount",
|
||||
"payRefundInfo": "Refund policy",
|
||||
"payRefundApply": "Request refund",
|
||||
"payRefundUnavailable": "Not refundable",
|
||||
"refundReasonMistake": "I paid by mistake",
|
||||
"refundReasonExpectation": "Not what I expected",
|
||||
"refundReasonError": "Couldn't use it due to an error",
|
||||
"refundReasonDuplicate": "Duplicate payment",
|
||||
"refundTitle": "Refund",
|
||||
"refundSubmit": "Submit",
|
||||
"refundReason": "Reason for refund",
|
||||
"refundConfirm": "Request a refund?",
|
||||
"myPop": "My POP",
|
||||
"commonNoHistory": "No history yet.",
|
||||
"nicknameEdit": "Edit nickname",
|
||||
"nicknameReserved": "You can't use a name that could be mistaken for staff.",
|
||||
"nicknameTaken": "This nickname is already in use.",
|
||||
"albumItems": "Photo options",
|
||||
"takePhoto": "Take photo",
|
||||
"pickFromAlbum": "Choose from album",
|
||||
"tierGuide": "Tier guide",
|
||||
"statSteps": "Total steps",
|
||||
"statContribution": "Total contribution POP",
|
||||
"statQuests": "Completed quests",
|
||||
"shareInstagram": "Instagram",
|
||||
"shareTiktok": "TikTok",
|
||||
"shareCopyLink": "Copy link",
|
||||
"inquiryListTitle": "1:1 inquiries",
|
||||
"inquiryEmpty": "You have no inquiries.",
|
||||
"inquiryWrite": "Write inquiry",
|
||||
"inquiryDetail": "Inquiry details",
|
||||
"commonEdit": "Edit",
|
||||
"inquiryAnswer": "Answer",
|
||||
"inquiryNew": "New inquiry",
|
||||
"inquiryEdit": "Edit inquiry",
|
||||
"commonTitle": "Title",
|
||||
"commonTitleHint": "Enter a title.",
|
||||
"inquiryBody": "Details",
|
||||
"inquiryBodyHint": "Describe your inquiry.",
|
||||
"inquiryBodyHelper": "Writing 10+ characters helps us assist you faster.",
|
||||
"inquirySubmitFailed": "Couldn't submit your inquiry. {reason}",
|
||||
"inquiryEditFailed": "Couldn't update your inquiry. {reason}",
|
||||
"inquirySubmitted": "Inquiry submitted.",
|
||||
"inquiryEdited": "Inquiry updated.",
|
||||
"blockedEmpty": "No blocked accounts.",
|
||||
"unblock": "Unblock",
|
||||
"unblockFailed": "Couldn't unblock. {reason}",
|
||||
"tierInfoTitle": "TIER info",
|
||||
"tierPromptPrefix": "Raise your ",
|
||||
"tierPromptRank": "rank",
|
||||
"tierPromptSuffix": "\nby contributing to planets and nebulae",
|
||||
"tierPassportNote": "Passport Tier is a card appearance grade separate from your level, and it never goes down.",
|
||||
"tierTotalContribution": "Total contribution",
|
||||
"notifKindEclipse": "Eclipse",
|
||||
"notifKindItemUsed": "Item used",
|
||||
"notifKindLike": "Likes",
|
||||
"notifKindComment": "Comments",
|
||||
"notifKindCampaign": "Campaign goal",
|
||||
"notifKindQuest": "Quest complete",
|
||||
"notifTargetPost": "post",
|
||||
"notifTargetCampaign": "campaign",
|
||||
"notifTargetQuest": "quest details",
|
||||
"dayToday": "Today",
|
||||
"dayYesterday": "Yesterday",
|
||||
"notifEmpty": "No notifications.",
|
||||
"notifGoTo": "Going to {target}.",
|
||||
"stationOfficial": "Official",
|
||||
"stationShop": "Shops",
|
||||
"stationTicket": "Tickets",
|
||||
"stationMedia": "Media",
|
||||
"stationLoadFailed": "Couldn't load STATION",
|
||||
"commonAll": "All",
|
||||
"stationSuggest": "Suggest a shop",
|
||||
"stationEmpty": "No links yet.\nJoin a planet to see curated links.",
|
||||
"stationCategoryEmpty": "No links in this category yet.",
|
||||
"stationSuggestLater": "Suggestions open once the server is connected.",
|
||||
"commonSubmit": "Submit",
|
||||
"commonName": "Name",
|
||||
"commonNameRequired": "Please enter a name.",
|
||||
"commonUrl": "URL",
|
||||
"commonUrlRequired": "Please enter a URL.",
|
||||
"commonUrlHttps": "Please enter a URL starting with https.",
|
||||
"commonCategory": "Category",
|
||||
"stationReason": "Why you recommend it (optional)",
|
||||
"stationSuggestNote": "Suggested links are added after review. We'll notify you of the result.",
|
||||
"heroGiftUnavailable": "This gift can't be claimed right now.",
|
||||
"heroGiftAlready": "You've already joined this gift. Check your POP history.",
|
||||
"heroGiftRelogin": "Please sign in again to join.",
|
||||
"heroGiftNoPermission": "You don't have permission to join this gift.",
|
||||
"heroGiftHowTo": "Shake your phone or tap the button below to join the light energy gift.",
|
||||
"heroGiftPopNote": "POP you can receive depends on the remaining gift and the per-person cap.",
|
||||
"heroGiftReceived": "{total} POP received",
|
||||
"heroGiftUnknown": "Couldn't confirm the payout. Please check again.",
|
||||
"heroGiftNone": "No gifts to join right now.",
|
||||
"heroGiftConfirmed": "Your gift claim is confirmed.",
|
||||
"heroGiftChecking": "Confirming payout…",
|
||||
"heroGiftRetry": "Recheck same gift",
|
||||
"heroGiftTap": "Join with a button instead",
|
||||
"rankMyPlanet": "My planet's {metric} ranking",
|
||||
"rankPlanetEmpty": "No planet rankings yet",
|
||||
"rankScopeLive": "Live",
|
||||
"rankScopeCountry": "By country",
|
||||
"rankMine": "My {metric} ranking",
|
||||
"rankMyCountry": "My country's {metric} ranking",
|
||||
"rankEmpty": "No rankings yet",
|
||||
"rankScope": "{scope} ranking",
|
||||
"rankLoadFailed": "Couldn't load the ranking",
|
||||
"rankMeSuffix": "{name} (me)",
|
||||
"reportDefamation": "Artist slander or defamation",
|
||||
"reportInappropriate": "Inappropriate content",
|
||||
"reportSpam": "Spam or commercial ads",
|
||||
"reportPrivacy": "Personal info exposure or impersonation",
|
||||
"reportTitle": "Report",
|
||||
"reportSubmit": "Report",
|
||||
"reportAuthor": "Author",
|
||||
"reportReasonLabel": "Reason",
|
||||
"reportConfirm": "Report this post?\nThis can't be undone once submitted.",
|
||||
"blockMemberConfirm": "Block all posts from this member?",
|
||||
"loungeMembersOnly": "Only members of this planet's fandom can post, comment, or like.",
|
||||
"loungeFailed": "Couldn't complete that. Please try again later.",
|
||||
"loungeTitle": "Fandom Lounge",
|
||||
"likeCancel": "Unlike",
|
||||
"like": "Like",
|
||||
"commentDeleteConfirm": "Delete this comment?",
|
||||
"commonShare": "Share",
|
||||
"commonEditAction": "Edit",
|
||||
"commonDeleteAction": "Delete",
|
||||
"commonBlockAction": "Block",
|
||||
"commentFirst": "Be the first to comment.",
|
||||
"postDeleteConfirm": "Delete this post?",
|
||||
"reportReceived": "Report received.",
|
||||
"commonDelete": "Delete",
|
||||
"commentHint": "Write a comment.",
|
||||
"commonSend": "Send",
|
||||
"postWrite": "Write post",
|
||||
"postEdit": "Edit post",
|
||||
"loungeHint": "Write freely in the Fandom Lounge.",
|
||||
"photoRemove": "Remove photo {n}",
|
||||
"camera": "Camera",
|
||||
"album": "Album",
|
||||
"historyTitle": "PASSPORT · History",
|
||||
"historyMine": "My activity",
|
||||
"historyEvolution": "Evolved Stage {from} → {to}",
|
||||
"historyHibernation": "Planet dormant",
|
||||
"historyWakeUp": "Planet awakened",
|
||||
"historyZap": "ZAP contribution",
|
||||
"historyQuest": "Quest reward",
|
||||
"historyAdBonus": "Ad bonus reward",
|
||||
"historyFunding": "Campaign contribution",
|
||||
"historyGrowthPop": "{amount} POP added to growth",
|
||||
"historyJoined": "You were part of this evolution",
|
||||
"historyLoadFailed": "Couldn't load history.",
|
||||
"historyMineEmpty": "No activity yet.",
|
||||
"historyFandomEmpty": "No fandom history yet.",
|
||||
"historyMore": "Load earlier history",
|
||||
"countryName": "{code, select, KR{South Korea} JP{Japan} US{United States} CN{China} TW{Taiwan} HK{Hong Kong} TH{Thailand} VN{Vietnam} ID{Indonesia} PH{Philippines} MY{Malaysia} SG{Singapore} IN{India} GB{United Kingdom} FR{France} DE{Germany} ES{Spain} BR{Brazil} MX{Mexico} CA{Canada} AU{Australia} ZZ{Unspecified} other{-}}",
|
||||
"loungeMaxPhotos": "You can upload up to {count} photos.",
|
||||
"commonLoadFailed": "Couldn't load",
|
||||
"planetPassportHistory": "PASSPORT · Fandom history",
|
||||
"planetNamed": "Planet {name}",
|
||||
"blockNebulaConfirm": "Block this nebula?",
|
||||
"blockPlanetConfirm": "Block this planet?",
|
||||
"blocked": "Blocked.",
|
||||
"planetLastUpdated": "Last updated {time}",
|
||||
"planetContribution": "Contributions",
|
||||
"planetNebulaGrowth": "Nebula growth",
|
||||
"planetLiveRanking": "Live contribution ranking",
|
||||
"planetNoContributors": "No contributors yet",
|
||||
"planetContributeCta": "Contribute and climb the ranking!",
|
||||
"planetOverallRank": "Overall #{rank}",
|
||||
"planetMyContribution": "My share",
|
||||
"planetTallying": "Tallying soon",
|
||||
"planetTopPercent": "Top {percent}%",
|
||||
"planetHideMember": "Hide all posts from this member",
|
||||
"reportReasonUnknown": "Couldn't verify the report reason.",
|
||||
"reportAlready": "You've already reported this post.",
|
||||
"blockUnavailable": "This author can't be blocked.",
|
||||
"commonMe": "Me",
|
||||
"planetSizeMedium": "Mid-size fandom",
|
||||
"planetPerHead": "Power per member: {value}",
|
||||
"planetJoined": "Joined",
|
||||
"planetJoin": "Join",
|
||||
"questDetails": "Details",
|
||||
"questInProgress": "In progress",
|
||||
"questTypeAttend": "Check-in",
|
||||
"questTypeSteps": "Walking",
|
||||
"questTypePlace": "Location",
|
||||
"questTypeStreaming": "Streaming",
|
||||
"questTypeUnverifiable": "The verification method for this quest type isn't set yet.",
|
||||
"questTabMine": "My quests",
|
||||
"placePickPrompt": "Choose where to\ncomplete the quest.",
|
||||
"placeSearch": "Search places",
|
||||
"commonNoResults": "No results.",
|
||||
"commonSelectDone": "Done",
|
||||
"adMoreChance": "Get more chances to contribute.",
|
||||
"adWatchPrompt": "Watch an ad to get\na bonus reward!",
|
||||
"adBonusPlaceholder": "+ Contribution POP",
|
||||
"adPartyPack": "Party Pack",
|
||||
"adPickProduct": "Choose item",
|
||||
"adWatchBonus": "Watch ad for bonus reward",
|
||||
"adBasicReward": "Get basic reward",
|
||||
"focusMode": "Ad-free Focus mode",
|
||||
"pricePerMonth": "{price}/mo",
|
||||
"adWarpBefore": "Warping... Finding the right ad so you can contribute more to your fandom and artist.",
|
||||
"adWarpAfter": "Warping... Preparing the reward for your contribution.",
|
||||
"adSkip": "Skip",
|
||||
"questPreviewHint": "Pick options and check the preview",
|
||||
"questNoCategory": "No category selected yet",
|
||||
"questNew": "New quest",
|
||||
"questCategory": "Category",
|
||||
"questName": "Quest name",
|
||||
"questAction": "Action details",
|
||||
"questPickPlace": "Choose a target location",
|
||||
"questGoalValue": "Goal",
|
||||
"questParticipants": "Participants",
|
||||
"questRewardPop": "Completion reward POP",
|
||||
"questCreateConfirm": "Create with these details?",
|
||||
"questCreateSummary": "{title} · Goal {goal} · Reward {reward} POP",
|
||||
"commonKeepEditing": "Keep editing",
|
||||
"questCreate": "Create",
|
||||
"questNamed": "{title} quest",
|
||||
"questDailyHelp": "Help your planet every day",
|
||||
"questTodayMust": "Quests to do today",
|
||||
"questTabEmpty": "No quests in this tab yet.",
|
||||
"questCreateNotReady": "Quest creation opens together with reward funding. Coming soon.",
|
||||
"questNoBaseReward": "Couldn't find the basic reward record, so the ad bonus isn't available.",
|
||||
"questNoSteps": "No steps recorded yet. Walk a bit and try again.",
|
||||
"questNoPlace": "No location is registered for this quest.",
|
||||
"questGoal": "Goal",
|
||||
"questReward": "Reward",
|
||||
"questPopGrant": "POP reward",
|
||||
"commonChecking": "Checking…",
|
||||
"questMine": "This is your quest. {count} participants.",
|
||||
"questStopConfirm": "Stop this quest?",
|
||||
"questStopBody": "Your progress will be reset to the beginning.",
|
||||
"questStop": "Stop",
|
||||
"adNotFinished": "You didn't finish the ad, so there's no bonus. You still get the basic reward.",
|
||||
"openSettings": "Open settings",
|
||||
"permStepsDenied": "Step permission is off. Allow \"Physical activity\" in Settings.",
|
||||
"permStepsNeeded": "Physical activity permission is needed to count steps.",
|
||||
"permLocationOff": "Location services are off. Turn them on and try again.",
|
||||
"permLocationNeeded": "Location permission is needed to verify your visit.",
|
||||
"permLocationDenied": "Location permission is off. Allow location access in Settings.",
|
||||
"questRewardReceived": " received!",
|
||||
"questAdBonus": "Ad bonus +{pop} POP",
|
||||
"questParticipantsCount": "Participants: {count}",
|
||||
"questParticipantsCap": "Participants: {count} / {cap}",
|
||||
"nebulaCreateFailed": "Couldn't create the nebula. Please try again later.",
|
||||
"nebulaAlreadyToday": "You've already created a nebula today.",
|
||||
"nebulaCreatedKeepPrimary": "Nebula created. Your main planet stays the same because it can only be changed 30 days after the last change.",
|
||||
"fandomFounderLeaveBlocked": "You can leave after the planet is born.",
|
||||
"sortPopular": "Popular",
|
||||
"campaignSortContributing": "Contributing",
|
||||
"campaignStatusReview": "This campaign is under review.",
|
||||
"campaignStatusRejected": "This campaign was rejected in review. Your registration fee has been returned.",
|
||||
"campaignStatusWithdrawn": "This campaign was withdrawn by its creator.",
|
||||
"campaignStatusCancelled": "This campaign was cancelled for operational reasons. The registration fee and supported POP have been returned.",
|
||||
"rankWindowDaily": "Daily",
|
||||
"rankWindowWeekly": "Weekly",
|
||||
"campaignNotFound": "Campaign not found.",
|
||||
"campaignMediaMain": "Main image",
|
||||
"campaignLoadFailed": "Could not load the campaign.",
|
||||
"campaignTabInfo": "Campaign info",
|
||||
"campaignTabBoard": "Cheers {count}",
|
||||
"campaignWithdraw": "Withdraw campaign",
|
||||
"campaignContributeAction": "Contribute",
|
||||
"campaignFundingStatus": "Funding status",
|
||||
"campaignSupporting": "Supporters",
|
||||
"campaignTimeLeft": "Left!",
|
||||
"campaignReport": "Result report",
|
||||
"campaignReportPhotos": "Execution photos",
|
||||
"campaignBudget": "Budget breakdown",
|
||||
"campaignTotal": "Total",
|
||||
"campaignReceipts": "Receipts",
|
||||
"campaignDaysLeft": "{days}d {time}",
|
||||
"campaignWithdrawTitle": "Withdraw this campaign?",
|
||||
"campaignWithdrawRefund": "Since the campaign is not public yet, the {fee} POP registration fee will be returned in the currency you paid.",
|
||||
"campaignWithdrawNoRefund": "This campaign is already public, so the {fee} POP registration fee will not be returned.",
|
||||
"campaignWithdrawConfirm": "Withdraw",
|
||||
"shareCopied": "Share text copied.",
|
||||
"campaignContributePop": "Set POP to contribute",
|
||||
"campaignEmptyContributing": "You haven't contributed to any campaigns yet.",
|
||||
"campaignEmpty": "No campaigns yet.",
|
||||
"campaignNew": "New campaign",
|
||||
"campaignMyRanking": "My contribution rank",
|
||||
"campaignWindowRanking": "{window} ranking",
|
||||
"campaignSearchHint": "Search campaigns",
|
||||
"campaignNoRecentSearches": "No recent searches",
|
||||
"searchRemoveTerm": "Remove {term}",
|
||||
"campaignNoResults": "No results",
|
||||
"shareTopicCampaign": "Campaign",
|
||||
"sharePlanetLine": "{brand} · {name} Planet",
|
||||
"campaignGaugeLabel": "Campaign progress",
|
||||
"campaignPeriodTitle": "Choose the campaign period",
|
||||
"campaignPeriodMax": "You can choose up to one year",
|
||||
"campaignYearMonth": "{month}/{year}",
|
||||
"campaignWeekdays": "S,M,T,W,T,F,S",
|
||||
"campaignPeriodRange": "{startMonth}/{startDay} ~ {endMonth}/{endDay}",
|
||||
"campaignPeriodSelect": "Select period",
|
||||
"calendarPrevMonth": "Previous month",
|
||||
"calendarNextMonth": "Next month",
|
||||
"campaignShareTitle": "Share campaign",
|
||||
"campaignPickMedia": "Choose image/video",
|
||||
"campaignTitleHint": "Enter a campaign title.",
|
||||
"campaignBodyHint": "Describe your campaign.",
|
||||
"campaignReviewNote": "Once submitted, funding opens after review.",
|
||||
"campaignSupportType": "Choose support type",
|
||||
"campaignPeriod": "Campaign period",
|
||||
"campaignDateSelect": "Select dates",
|
||||
"campaignGoalPop": "Goal POP",
|
||||
"campaignSubmitTitle": "Submit this campaign?",
|
||||
"campaignSubmitBody": "{title} · Goal {goal} POP · {days} days\nThe {fee} POP registration fee will be deducted, and funding opens after review.",
|
||||
"campaignSubmit": "Submit",
|
||||
"campaignCreateFailed": "Could not submit the campaign.",
|
||||
"campaignNeedsQuests": "Quests completed {done}/{need} — complete {left} more to open a campaign.",
|
||||
"campaignPendingTitle": "Awaiting review",
|
||||
"campaignPendingBody": "Once review is complete, funding opens and the campaign appears in the list.",
|
||||
"insideRefundPending": "The acceptance deadline has passed. The bounty will be refunded.",
|
||||
"insideRefundDone": "The acceptance deadline has passed. The bounty has been refunded.",
|
||||
"insideRefundChecking": "The acceptance deadline has passed. We are checking the bounty refund.",
|
||||
"insideErrPopShort": "Not enough POP to set the bounty.",
|
||||
"insideErrSelfAccept": "You can't accept your own answer.",
|
||||
"insideErrAlreadyAccepted": "An answer has already been accepted and cannot be changed.",
|
||||
"insideErrNotOwner": "Only the person who asked can accept an answer.",
|
||||
"commonCheckConnection": "Check your connection and try again.",
|
||||
"insideAnswerLater": "Comments open once the server is connected.",
|
||||
"insideAcceptConfirm": "Accept this comment?\nThe {pop} POP bounty will be given and this cannot be undone.",
|
||||
"insideAccept": "Accept",
|
||||
"insideMore": "More",
|
||||
"insideFirstAnswer": "Be the first to comment.",
|
||||
"insideAcceptDone": "Accepted",
|
||||
"insideAcceptAction": "Accept",
|
||||
"insideAnswerHint": "Write a comment.",
|
||||
"insideLoadFailed": "Could not load INSIDE",
|
||||
"insideEmpty": "No questions yet.",
|
||||
"insideWrite": "Write INSIDE",
|
||||
"insideSearchHint": "INSIDE title, content",
|
||||
"insideBodyHint": "Ask and answer freely with other fans.",
|
||||
"insideTitleRequired": "Please enter a title.",
|
||||
"insideBodyRequired": "Please enter the content.",
|
||||
"insideBountySet": "Set bounty",
|
||||
"insideBountyHint": "Set {min} or more.",
|
||||
"insideAttachLater": "Image attachments open once the server is connected.",
|
||||
"insideBountyRequired": "Please enter a bounty.",
|
||||
"insideNumberOnly": "Please enter a number.",
|
||||
"insideBountyMin": "The bounty must be at least {min} POP.",
|
||||
"insideBountyOverBalance": "Can't exceed your POP balance.",
|
||||
"questTypeTimeAttack": "Flash Time Attack",
|
||||
"questTimeLeft": "Time left",
|
||||
"questTimeAttackClosed": "Closed",
|
||||
"questTimeAttackLeft": "{time} left",
|
||||
"popReasonQuestTimeAttack": "Flash time attack",
|
||||
"briefingShareCopied": "Share text and link copied.",
|
||||
"shareCopyText": "Copy text",
|
||||
"briefingShareTextCopied": "Share text copied.",
|
||||
"questAlreadyRewarded": "Reward already received (+{pop} POP).",
|
||||
"questCatHealth": "Health",
|
||||
"questCatPlanet": "Planet activity",
|
||||
"questCatCommunity": "Community",
|
||||
"questActShake": "Shake",
|
||||
"questTypeVote": "Vote",
|
||||
"questTypeSns": "Social media",
|
||||
"questActivity": "Activity",
|
||||
"questNoCreatable": "No quest activities are open for creation right now.",
|
||||
"questDescriptionOptional": "Description (optional)",
|
||||
"questLinkUrl": "Link URL",
|
||||
"questLinkCheck": "Check link",
|
||||
"questLinkInvalid": "This link isn't valid. Please check the address.",
|
||||
"questLinkPreviewFailed": "Couldn't load the link info. Please enter a title.",
|
||||
"questLinkTitle": "Link title",
|
||||
"questEndDate": "End date",
|
||||
"questEndDefault": "24 hours after publishing",
|
||||
"questEndDateValue": "Ends {date} 23:59:59",
|
||||
"questPoolHelper": "Deposit {pool} POP · Fee {fee} POP",
|
||||
"questCreateCost": "{title}\nReward {reward} POP × {count} = {pool} POP\nFee {fee} POP (burned on publishing)\n{total} POP in total will be deducted.",
|
||||
"questNoReward": "This quest has no reward.",
|
||||
"questDeadline": "Deadline",
|
||||
"questRemainingHours": "{hours}h left",
|
||||
"questEnded": "Ended",
|
||||
"questAuthorNote": "Creator's note",
|
||||
"questRepeatOnce": "One entry per person",
|
||||
"questRepeatEvery": "Every {hours}h · up to {count} times",
|
||||
"questSubmit": "Submit proof",
|
||||
"questResubmit": "Resubmit",
|
||||
"questEvidenceUrl": "Proof link (optional)",
|
||||
"questEvidenceText": "Proof details",
|
||||
"questSubmitted": "Submitted. The reward is paid after review.",
|
||||
"questReviewPending": "Under review. The reward is paid once approved.",
|
||||
"questReviewDelayed": "Review is taking longer than usual. Our team is checking.",
|
||||
"questApprovedPaying": "Approved. Paying your reward.",
|
||||
"questRejected": "Rejected: {reason}",
|
||||
"questResubmitAllowed": "You can revise and resubmit.",
|
||||
"questResubmitNotAllowed": "This entry can't be resubmitted.",
|
||||
"questReviewButton": "In review",
|
||||
"questRejectedButton": "Rejected",
|
||||
"questReviewQueue": "{count} awaiting review",
|
||||
"questApprove": "Approve",
|
||||
"questReject": "Reject",
|
||||
"questRejectReason": "Reason",
|
||||
"questAllowResubmit": "Allow resubmission",
|
||||
"questEdit": "Edit title and description",
|
||||
"questEditNotice": "After publishing, only the title and description can be edited, and edits are logged.",
|
||||
"popReasonQuestUgc": "Quest reward",
|
||||
"popReasonQuestFee": "Quest creation fee",
|
||||
"questFreeDailyCap": "You can create up to {cap} no-reward quests a day.",
|
||||
"popReasonQuestPoolDeposit": "Quest reward deposit",
|
||||
"popReasonQuestPoolReturn": "Quest reward returned",
|
||||
"campaignCommentNeedNickname": "Please set a nickname first",
|
||||
"campaignCommentSetNickname": "Set nickname"
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"@@locale": "es",
|
||||
"commonConfirm": "Aceptar",
|
||||
"commonCancel": "Cancelar",
|
||||
"commonClose": "Cerrar",
|
||||
"commonSelect": "Seleccionar",
|
||||
"commonRetry": "Reintentar",
|
||||
"commonView": "Ver",
|
||||
"commonPrev": "Atrás",
|
||||
"commonDone": "Listo",
|
||||
"commonStart": "Empezar",
|
||||
"commonNotYet": "La pantalla {where} llega en la segunda revisión (2026-09-10).",
|
||||
"languageSettingsTitle": "Idioma",
|
||||
"authLogin": "Iniciar sesión",
|
||||
"authSignup": "Registrarse",
|
||||
"authFirstTime": "¿Nuevo en FANUZU?",
|
||||
"authAlreadyMember": "¿Ya tienes una cuenta?",
|
||||
"authLoginHeadline": "¡Inicia sesión en FANUZU\nde la forma más fácil!",
|
||||
"authSignupHeadline": "¡Únete a FANUZU\nde la forma más fácil!",
|
||||
"authProviderLogin": "{provider, select, apple{Iniciar sesión con Apple} google{Iniciar sesión con Google} kakao{Iniciar sesión con Kakao} line{Iniciar sesión con LINE} other{Iniciar sesión con correo}}",
|
||||
"authProviderSignup": "{provider, select, apple{Registrarse con Apple} google{Registrarse con Google} kakao{Registrarse con Kakao} line{Registrarse con LINE} other{Registrarse con correo}}",
|
||||
"authNotConfiguredTitle": "Inicio con {name} próximamente",
|
||||
"authNotConfiguredBody": "El inicio con {name} se habilitará cuando se entreguen las claves.\n\nRequerido\n· {items}",
|
||||
"authNotConfiguredNotice": "El inicio con {name} aún no está listo.\nRequerido: {items}",
|
||||
"authResetNeedEmail": "Primero introduce el correo para recibir el enlace.",
|
||||
"authResetSent": "Enviamos las instrucciones a esa dirección.",
|
||||
"termsTitle": "Términos de uso",
|
||||
"termsAgreeAll": "Aceptar todo",
|
||||
"termsService": "Términos del servicio (obligatorio)",
|
||||
"termsLocation": "Términos de servicios de ubicación (obligatorio)",
|
||||
"termsAge": "Tengo 14 años o más. (obligatorio)",
|
||||
"termsMarketing": "Recibir información de marketing y eventos (opcional)",
|
||||
"signupDoneTitle": "Registro completado",
|
||||
"signupDoneBody": "Ya puedes empezar tus actividades de fandom.\nHaz crecer tu planeta con muchas actividades.",
|
||||
"homeItemsTooltip": "Objetos",
|
||||
"homeNotificationsTooltip": "Notificaciones",
|
||||
"homeFandomHistory": "Historia del fandom",
|
||||
"homePopularInside": "K-INSIDE popular",
|
||||
"homeWhereBriefing": "Resumen IA",
|
||||
"homeWhereInsideList": "Lista de INSIDE",
|
||||
"homeWhereInsideDetail": "Detalle de INSIDE",
|
||||
"homeLoadFailed": "No se pudo cargar el inicio",
|
||||
"homeRetryLater": "Inténtalo de nuevo en un momento.",
|
||||
"myProfileCardTooltip": "Tarjeta de perfil",
|
||||
"myTabPlanets": "Planetas/Nebulosas",
|
||||
"myTabActivity": "Actividad",
|
||||
"myInside": "Inside",
|
||||
"myCampaign": "Campañas",
|
||||
"myShareLater": "Compartir en {target} se conectará en la versión 09-10.",
|
||||
"myEditPhoto": "Editar foto de perfil",
|
||||
"myEditProfile": "Editar perfil",
|
||||
"settingsTitle": "Ajustes",
|
||||
"settingsAccount": "Cuenta",
|
||||
"settingsNickname": "Apodo",
|
||||
"settingsEmail": "Correo",
|
||||
"settingsSns": "Cuentas sociales vinculadas",
|
||||
"settingsPopHistory": "Historial de POP",
|
||||
"settingsPayments": "Pagos",
|
||||
"settingsServicePayments": "Historial de pagos del servicio",
|
||||
"settingsService": "Ajustes del servicio",
|
||||
"settingsPush": "Notificaciones push",
|
||||
"settingsBlocked": "Usuarios bloqueados",
|
||||
"settingsInfo": "Información",
|
||||
"settingsNotice": "Avisos",
|
||||
"settingsInquiry": "Consulta 1:1",
|
||||
"settingsAppVersion": "Versión de la app",
|
||||
"settingsLatest": "Última versión",
|
||||
"settingsLogout": "Cerrar sesión",
|
||||
"settingsLogoutConfirm": "¿Quieres cerrar sesión?",
|
||||
"settingsWithdraw": "Eliminar cuenta",
|
||||
"settingsWithdrawConfirm": "¿Quieres eliminar tu cuenta?",
|
||||
"settingsWithdrawBody": "Perderás tus POP y no se puede deshacer.",
|
||||
"settingsWithdrawLater": "La eliminación de cuenta se conectará en la versión 09-10.",
|
||||
"settingsUnlinkConfirm": "¿Desvincular esta cuenta?",
|
||||
"settingsUnlinkBody": "Solo se quita este método de inicio social; los datos de tu cuenta no se eliminan.",
|
||||
"settingsUnlink": "Desvincular",
|
||||
"settingsSameEmailOnly": "Solo se pueden vincular cuentas sociales\ncon el mismo correo.",
|
||||
"settingsLinkNotReady": "La vinculación con {name} aún no está lista. Requerido: {items}",
|
||||
"settingsConnect": "Vincular",
|
||||
"settingsDisconnect": "Desvincular",
|
||||
"settingsEmailProvider": "Correo",
|
||||
"pushDeviceOff": "Las notificaciones push están desactivadas en este dispositivo.",
|
||||
"pushDeviceOffHint": "Permite las notificaciones en los ajustes del sistema.",
|
||||
"pushRequesting": "Solicitando…",
|
||||
"pushGoToSettings": "Ir a ajustes",
|
||||
"pushOpeningDeviceSettings": "Abriendo los ajustes del dispositivo.",
|
||||
"pushEnableInDevice": "Activa las notificaciones de FANUZU en los ajustes del dispositivo.",
|
||||
"pushDeviceSection": "Notificaciones del dispositivo",
|
||||
"pushSoundVibration": "Sonido y vibración",
|
||||
"pushServiceSection": "Notificaciones del servicio",
|
||||
"pushTopicEssential": "Esenciales",
|
||||
"pushTopicFandomEvent": "Eventos del fandom",
|
||||
"pushTopicBriefing": "AI Briefing",
|
||||
"pushTopicCommunity": "Comunidad",
|
||||
"pushTopicMarketing": "Marketing",
|
||||
"linkTitle": "Vincular {name}",
|
||||
"linkNotLinked": "No vinculada",
|
||||
"linkLinked": "Vinculada",
|
||||
"linkSameEmailOnly": "Solo se pueden vincular cuentas sociales con el mismo correo.",
|
||||
"linkUnlink": "Desvincular",
|
||||
"linkLink": "Vincular",
|
||||
"noticeBadge": "Aviso",
|
||||
"noticeTitle": "Avisos",
|
||||
"noticeEmpty": "No hay avisos.",
|
||||
"noticeDetailTitle": "Detalle del aviso",
|
||||
"translationMark": "Traducido",
|
||||
"translationReplyTo": "Traduciendo el comentario de {name}",
|
||||
"translationWrite": "Escribir traducción",
|
||||
"translationHint": "Escribe tu traducción",
|
||||
"translationValid": "Válida",
|
||||
"translationInvalid": "No válida",
|
||||
"translationSelfMarkBlocked": "No puedes marcar tu propia traducción.",
|
||||
"translationSubmitted": "Traducción publicada. Si se valida, recibirás POP en 24 horas.",
|
||||
"translationMarked": "Marcada.",
|
||||
"translationAlreadyMarked": "Ya marcaste esta traducción.",
|
||||
"translationDuplicate": "Ya publicaste una traducción en este idioma.",
|
||||
"translationFailed": "No se pudo procesar. Inténtalo de nuevo en un momento.",
|
||||
"translationNotEligible": "Solo los miembros de este fandom con más de 24 horas de antigüedad pueden marcar.",
|
||||
"translationDailyCap": "Hoy ya no puedes publicar más traducciones.",
|
||||
"translationInvalidTarget": "Este contenido no se puede traducir.",
|
||||
"commonRequestFailed": "No se pudo procesar la solicitud.",
|
||||
"commonFan": "Fan",
|
||||
"adLoadFailed": "No se pudo cargar el anuncio. Solo recibirás la recompensa básica.",
|
||||
"storeNotReady": "Productos de la tienda en preparación",
|
||||
"storePending": "Esperando la aprobación del pago. Se aplicará automáticamente al aprobarse.",
|
||||
"storeFailed": "No se pudo completar el pago.",
|
||||
"storePurchased": "Tu compra se ha aplicado.",
|
||||
"heroParticipate": "ZAP+ · Unirse al regalo",
|
||||
"heroRemainingSemantics": "HERO TIME restante: {minutes} min {seconds} s",
|
||||
"heroRemaining": "HERO TIME · quedan {time}",
|
||||
"heroClaims": "Regalos recibidos por fans: {count}",
|
||||
"heroInProgress": "{name} · HERO TIME en curso →",
|
||||
"stageNames": "Nebulosa,Inicio,Crecimiento,Juntos,Compañía,Luz,Eternidad",
|
||||
"planetStageUnknown": "Planeta · Sin información de etapa",
|
||||
"planetHibernatingSuffix": " · En reposo",
|
||||
"planetEclipseSuffix": " · Eclipse en curso",
|
||||
"planetHeroSuffix": " · HERO TIME en curso",
|
||||
"evoPlanetGrowth": "Crecimiento del planeta",
|
||||
"evoHeroEnd": "HERO TIME terminado",
|
||||
"evoAwakening": "El planeta despierta",
|
||||
"evoAnomaly": "Reacción anómala",
|
||||
"evoEnergy": "Concentrando energía",
|
||||
"evoTransition": "Transición",
|
||||
"evoComplete": "Evolución del planeta completa",
|
||||
"evoOpenHistory": "Ver historial de PASSPORT",
|
||||
"playNow": "Juega ahora",
|
||||
"playHeadline": "¡Vamos a conocer a tu fandom!",
|
||||
"playBody": "Crea un planeta, conecta con fandoms y disfruta de la actividad fan...",
|
||||
"timeJustNow": "Ahora",
|
||||
"timeMinutesAgo": "hace {n} min",
|
||||
"timeHoursAgo": "hace {n} h",
|
||||
"timeDaysAgo": "hace {n} d",
|
||||
"commonMore": "Ver más",
|
||||
"authProviderStart": "{provider, select, email{Continuar con correo} kakao{Continuar con Kakao} line{Continuar con LINE} apple{Continuar con Apple} other{Continuar con Google}}",
|
||||
"authProviderName": "{provider, select, email{Correo} kakao{Kakao} line{LINE} apple{Apple} other{Google}}",
|
||||
"authLastProvider": "No puedes desvincular tu último método de inicio de sesión.",
|
||||
"authLoginRequired": "Debes iniciar sesión.",
|
||||
"authDifferentEmail": "Esta cuenta no coincide con tu correo de registro.",
|
||||
"authLinkFailed": "No se pudo vincular la cuenta.",
|
||||
"authNotLinked": "Esta cuenta no está vinculada.",
|
||||
"authEmailUnavailable": "El inicio de sesión con correo no está disponible.",
|
||||
"authCheckCredentials": "Revisa tu correo o contraseña.",
|
||||
"authSocialFailed": "Error al iniciar sesión con la red social.",
|
||||
"authAppleUnavailable": "Iniciar sesión con Apple no está disponible en este dispositivo.",
|
||||
"authEmailInUse": "Este correo ya está registrado.",
|
||||
"authWeakPassword": "La contraseña es muy corta. Usa al menos 8 caracteres.",
|
||||
"authInvalidEmail": "El formato del correo no es válido.",
|
||||
"commonTryLater": "Inténtalo de nuevo más tarde.",
|
||||
"authCredentialInUse": "Esta cuenta social ya está vinculada a otra cuenta de FANUZU.",
|
||||
"authAlreadyLinked": "Esta cuenta ya está vinculada.",
|
||||
"authMethodUnavailable": "Este método de inicio de sesión aún no está disponible.",
|
||||
"authRecentLogin": "Por seguridad, vuelve a iniciar sesión e inténtalo de nuevo.",
|
||||
"authOtherProviderGuide": "La cuenta {email} se registró con otro método de inicio de sesión. Inicia sesión con el método que usaste primero y vincula este en MY > Ajustes > Cuentas vinculadas.",
|
||||
"authThisAccount": "Esta",
|
||||
"eclipseCapped": "Alcanzaste el límite de participación; algunas no se contaron.",
|
||||
"eclipseResultUnknown": "No se pudo confirmar tu participación. Vuelve a comprobarlo con la misma solicitud.",
|
||||
"eclipseChecking": "Comprobando participación",
|
||||
"eclipseRetrySame": "Volver a comprobar",
|
||||
"eclipseEnded": "El eclipse ya terminó o se defendió con éxito.",
|
||||
"eclipseTooFast": "Estás participando demasiado rápido. Inténtalo en un momento.",
|
||||
"eclipseNone": "No hay ningún eclipse en curso",
|
||||
"eclipseSuccessTitle": "¡Detuviste\nel impacto del meteorito!",
|
||||
"eclipseQualified": "Participación confirmada",
|
||||
"eclipseFailTitle": "Lamentablemente,\nla defensa del planeta falló",
|
||||
"eclipseFailBody": "No se alcanzó el objetivo de defensa de esta ronda.",
|
||||
"eclipseCancelTitle": "Eclipse cancelado",
|
||||
"eclipseCancelBody": "Se canceló por un error de recuento. No se descontó FPI.",
|
||||
"eclipsePendingTitle": "Comprobando resultado de la defensa",
|
||||
"eclipsePendingBody": "El tiempo de participación terminó. Esperando la decisión del servidor.",
|
||||
"eclipseShakeTimed": "s: ¡agita tu móvil\npara detener el meteorito!",
|
||||
"eclipseShake": "¡Agita tu móvil\npara detener el meteorito!",
|
||||
"eclipseMyCount": "Mis participaciones: {count}",
|
||||
"eclipsePendingSuffix": " · +{count} pendientes",
|
||||
"eclipseSendingSuffix": " · Enviando",
|
||||
"eclipseNoPopNote": "No se gasta POP. Haz al menos {count} sacudidas válidas para contar como participante.",
|
||||
"eclipseTapToJoin": "¿No puedes agitar? Toca para participar",
|
||||
"eclipseTimeLeft": "Tiempo restante del eclipse:",
|
||||
"eclipseSeconds": " {seconds} s",
|
||||
"eclipseBannerDefended": "Defensa exitosa: {name} sin pérdida de FPI",
|
||||
"eclipseBannerActive": "Eclipse activado: {name} erosión {rate}%",
|
||||
"eclipseBannerDefense": "Participantes válidos {count} / {target}",
|
||||
"timeHoursMinutesLeft": "Quedan {hours} h {minutes} min",
|
||||
"timeMinutesLeft": "Quedan {minutes} min",
|
||||
"liveEclipseDefense": "[LIVE] ¡{name} se unió a la defensa del eclipse! (+{count})",
|
||||
"liveHotTime": "[LIVE] ¡Comenzó el Artist Hot Time de {name}!",
|
||||
"liveHeroGift": "[LIVE] ¡Llegó el Regalo del Héroe de {name}!",
|
||||
"liveCampaign": "[LIVE] ¡{name} apoyó una campaña!",
|
||||
"galaxySetPrimaryConfirm": "¿Establecer el planeta seleccionado como principal?",
|
||||
"galaxyLoadFailed": "No se pudo cargar la galaxia",
|
||||
"commonSearch": "Buscar",
|
||||
"galaxyMyPlanets": "Mis planetas",
|
||||
"galaxyMap": "Mapa de la galaxia",
|
||||
"nebulaCreate": "Crear nebulosa",
|
||||
"commonRecheck": "Volver a comprobar",
|
||||
"zapApplying": "Aplicando contribución",
|
||||
"zapNeedsCheck": "Hay que comprobar la contribución",
|
||||
"zapNoPlanet": "No perteneces a ningún planeta",
|
||||
"commonContribute": "Contribuir",
|
||||
"commonItem": "Objetos",
|
||||
"galaxyDefended": "Defensa exitosa · Sin pérdida de FPI",
|
||||
"galaxyErosion": "Erosión {rate}% · Defensa {progress}%",
|
||||
"galaxyOnlineMembers": "{online} en línea · {members} miembros",
|
||||
"galaxySearchHint": "Buscar por nombre de fandom",
|
||||
"commonRecentSearches": "Búsquedas recientes",
|
||||
"commonClearAll": "Borrar todo",
|
||||
"commonNoRecentSearches": "No hay búsquedas recientes.",
|
||||
"galaxyNoHit": "No se encontró el fandom.\nRevisa el nombre.",
|
||||
"galaxyMembers": "{count} miembros",
|
||||
"galaxyPlanetRank": "Planeta {name}, puesto {rank}",
|
||||
"galaxyMapOpen": "Abrir mapa de la galaxia",
|
||||
"zapNotEnoughPop": "POP insuficiente",
|
||||
"zapFailNoPop": "No tienes POP suficiente para contribuir.",
|
||||
"zapFailLedger": "Se detuvo la contribución: el saldo no coincide con el historial.",
|
||||
"zapFailRetry": "Revisa tu conexión y vuelve a comprobar la contribución.",
|
||||
"zapDone": "Contribución completada",
|
||||
"zapDonePop": "{pop} POP contribuidos",
|
||||
"growthFounders": "Miembros fundadores",
|
||||
"growthNebulaGoalPassed": "El plazo objetivo ha pasado · Cumple las condiciones y nacerá el planeta",
|
||||
"growthFinalStage": "Etapa final",
|
||||
"growthPromotionPending": "Ascenso pendiente",
|
||||
"growthCheckConditions": "Ver condiciones de crecimiento",
|
||||
"growthConditionChecking": "Comprobando condición de {name}",
|
||||
"growthConditionMet": "Condición de {name} cumplida",
|
||||
"growthConditionUnmet": "Condición de {name} no cumplida",
|
||||
"nebulaHeadline": "Ponle nombre al planeta de tu fandom.",
|
||||
"nebulaBody": "Si los miembros fundadores alcanzan la meta de POP en {days} días,\n¡nacerá como planeta oficial!\nQuienes participen en la evolución recibirán una insignia.",
|
||||
"nebulaNameTaken": "Este nombre ya está en uso.",
|
||||
"nebulaNameHint": "Nombre del fandom",
|
||||
"nebulaGoal": "Meta ",
|
||||
"nebulaFounders": " / Fundadores",
|
||||
"nebulaGoalSemantics": "Meta {pop} POP · {members} fundadores · {days} días fijos",
|
||||
"commonMyPop": "Mi POP: ",
|
||||
"commonChange": "Cambiar",
|
||||
"planetShape": "Forma {n}",
|
||||
"planetBgColor": "Elige el color de fondo",
|
||||
"planetColorSelect": "Elige el color del planeta",
|
||||
"planetTypeSelect": "Elige el tipo de planeta",
|
||||
"commonNext": "Siguiente",
|
||||
"planetCreateFailed": "No se pudo crear el planeta. Inténtalo más tarde.",
|
||||
"planetCreateConfirm": "¿Crear el planeta\ncon estas opciones?",
|
||||
"commonCreate": "Crear",
|
||||
"briefingOpenFailed": "No se puede abrir el original.",
|
||||
"campaignAchieved": "{percent}% alcanzado",
|
||||
"campaignView": "Ver campaña",
|
||||
"growthFinalReached": "Has llegado a la etapa final",
|
||||
"zapRetrySame": "Volver a comprobar la contribución",
|
||||
"heroOwnerSuffix": "aporta:",
|
||||
"heroStartPrompt": "¡Crea o únete a un planeta\ny empieza en FANUZU!",
|
||||
"heroNebulaPrompt": "¡Usa el POP que ganas con actividades\npara contribuir a la nebulosa!",
|
||||
"heroRecentContribution": "Creciendo sin parar con\n{pop} POP aportados.",
|
||||
"heroGrowingTogether": "Un planeta fan que crecemos juntos",
|
||||
"heroPopProgress": "Progreso de POP",
|
||||
"heroNebula": "Nebulosa",
|
||||
"heroNebulaTap": "¡Toca la nebulosa\npara que nazca un planeta!",
|
||||
"heroCreateNebula": "Crear planeta (nebulosa)",
|
||||
"heroJoinPlanet": "Unirse a un planeta",
|
||||
"heroCreateOrJoin": "Crear o unirse a un planeta",
|
||||
"insideAnswers": "Respuestas {count}",
|
||||
"itemJoinFirst": "Únete a un planeta para enviar un Regalo del Héroe.",
|
||||
"itemBuyNotReady": "La compra de objetos llegará pronto.",
|
||||
"itemUseNotReady": "El uso de objetos llegará pronto.",
|
||||
"payOpenFailed": "No se pudo abrir la ventana de pago.",
|
||||
"itemBuy": "Comprar objetos",
|
||||
"itemSelect": "Elegir objeto",
|
||||
"commonComingSoon": "Próximamente.",
|
||||
"itemSendPop": "Enviar {pop} POP",
|
||||
"itemBuyerPop": "Comprador +{pop} POP",
|
||||
"itemOwned": "Tienes: {count}",
|
||||
"itemOwnedCount": "Tienes: {count}",
|
||||
"growthOwnerOf": " aportó ",
|
||||
"growthSteadySuffix": " y seguimos creciendo.",
|
||||
"questAttended": "Asistencia registrada",
|
||||
"questInProgressSpaced": "En curso",
|
||||
"questRewardPaid": "Recompensa entregada",
|
||||
"questClaim": "Reclamar",
|
||||
"questStart": "Iniciar misión",
|
||||
"questRewardDone": "Recompensa recibida",
|
||||
"popKindGeneral": "POP normal",
|
||||
"popKindContribution": "POP de contribución",
|
||||
"popTxGrant": "Otorgado",
|
||||
"popTxSpend": "Gastado",
|
||||
"popTxRefund": "Reembolso",
|
||||
"popTxVoid": "Anulado",
|
||||
"popTypeQuest": "Misión",
|
||||
"popTypeReward": "Recompensa",
|
||||
"popTypePayment": "Pago",
|
||||
"popTypeSubscription": "Suscripción",
|
||||
"popTypeAdmin": "Equipo",
|
||||
"popTypeFee": "Tarifa",
|
||||
"commonEtc": "Otros",
|
||||
"popReasonQuestSteps": "Misión de pasos",
|
||||
"popReasonQuestMusicShake": "Misión Music Shake",
|
||||
"popReasonQuestPlace": "Misión de visita",
|
||||
"popReasonQuestAttend": "Misión de asistencia",
|
||||
"popReasonAdReward": "Recompensa por anuncio",
|
||||
"popReasonHeroDrop": "Regalo del Héroe",
|
||||
"popReasonHeroDropTrigger": "Regalo del Héroe activado",
|
||||
"popReasonTranslation": "Traducción colaborativa",
|
||||
"popReasonIapPurchase": "Compra",
|
||||
"popReasonFocusDaily": "Recompensa diaria Focus",
|
||||
"popReasonPreregBonus": "Recompensa de prerregistro",
|
||||
"popReasonReferral": "Invitación de amigos",
|
||||
"popTypeReferral": "Referido",
|
||||
"popReasonAdminGrant": "Otorgado por el equipo",
|
||||
"popReasonAdminClawback": "Retirado por el equipo",
|
||||
"popReasonCampaignFund": "Apoyo a campaña",
|
||||
"popReasonCampaignRefund": "Reembolso de campaña",
|
||||
"popReasonCampaignFeeRefund": "Reembolso de la tarifa de campaña",
|
||||
"popReasonCampaignFee": "Tarifa de campaña",
|
||||
"popReasonQnaBounty": "Recompensa de INSIDE",
|
||||
"popReasonQnaRefund": "Reembolso de recompensa",
|
||||
"popReasonQnaReward": "Recompensa por respuesta aceptada",
|
||||
"popReasonEclipseDefense": "Contribución Zap",
|
||||
"popReasonOther": "Cambio de POP",
|
||||
"popRepeat": "{title} ×{count}",
|
||||
"payStatePaid": "Pagado",
|
||||
"payStateRefundRequested": "Reembolso solicitado",
|
||||
"payStateRefundApproved": "Reembolso aprobado",
|
||||
"payStateRefunded": "Reembolsado",
|
||||
"payStateRefundRejected": "Reembolso rechazado",
|
||||
"pushTopicEssentialDesc": "Avisos de cuenta, pagos y mantenimiento. No se pueden desactivar.",
|
||||
"pushTopicFandomEventDesc": "Novedades de eclipses, campañas, misiones y Artist Hot Time.",
|
||||
"pushTopicBriefingDesc": "Tarjetas de resumen diario de tu planeta.",
|
||||
"pushTopicCommunityDesc": "Comentarios, me gusta y respuestas en tus publicaciones.",
|
||||
"pushTopicMarketingDesc": "Recibe eventos y beneficios. Solo se activa si aceptas.",
|
||||
"inquiryPending": "Recibida",
|
||||
"inquiryInProgress": "En proceso",
|
||||
"inquiryAnswered": "Respondida",
|
||||
"photoBadType": "Solo puedes subir imágenes JPG, PNG, WEBP o GIF.",
|
||||
"photoTooBig": "La foto supera los 10 MB. Elige una más pequeña.",
|
||||
"photoUnreadable": "No se pudo leer la foto. Elige otra vez.",
|
||||
"myListInside": "Mi INSIDE",
|
||||
"myListInsideEmpty": "Aún no has publicado en INSIDE.",
|
||||
"myListCampaign": "Mis campañas",
|
||||
"myListCampaignEmpty": "Aún no has creado campañas.",
|
||||
"sortLatest": "Recientes",
|
||||
"sortPop": "Por POP",
|
||||
"sortSolved": "Aceptadas",
|
||||
"sortAchieved": "Logradas",
|
||||
"payNotFound": "No se encontró el pago.",
|
||||
"payItemFallback": "Artículo comprado",
|
||||
"payMethodInApp": "Compra en la app",
|
||||
"unknownUser": "Usuario desconocido",
|
||||
"blockSelf": "No puedes bloquearte a ti mismo.",
|
||||
"inquiryFallback": "Consulta",
|
||||
"photoRejectedServer": "No se pudo subir la foto. Revisa el formato y el tamaño.",
|
||||
"photoUploadFailed": "Error al subir la foto. Inténtalo más tarde.",
|
||||
"focusClaimed": "Recibiste la recompensa Focus de hoy: +{pop} POP de contribución.",
|
||||
"focusActive": "Modo Focus sin anuncios activo",
|
||||
"focusUntil": "Hasta el {date} · La recompensa de hoy solo se reclama hoy",
|
||||
"focusClaimedToday": "Recibida hoy",
|
||||
"focusClaimToday": "Reclamar hoy",
|
||||
"storeConnectFailed": "No se pudo conectar con la tienda. Inténtalo más tarde.",
|
||||
"payRestore": "Restaurar compras",
|
||||
"payEmpty": "No hay historial de pagos.",
|
||||
"payRefundGuide": "Consumibles: reembolsables en 7 días desde la compra si no se usaron. Los artículos usados parcialmente no son reembolsables.\nSuscripciones: reembolsables en 24 horas desde el pago si no se usaron beneficios (p. ej., recompensas extra).\nEl reembolso se completa en 7–14 días tras la aprobación.",
|
||||
"payDetailTitle": "Detalle del pago",
|
||||
"payNumber": "N.º de pago",
|
||||
"payDate": "Fecha de pago",
|
||||
"payMethod": "Método de pago",
|
||||
"payStatus": "Estado del pago",
|
||||
"payAmount": "Importe",
|
||||
"payRefundInfo": "Política de reembolso",
|
||||
"payRefundApply": "Solicitar reembolso",
|
||||
"payRefundUnavailable": "No reembolsable",
|
||||
"refundReasonMistake": "Pagué por error",
|
||||
"refundReasonExpectation": "No es lo que esperaba",
|
||||
"refundReasonError": "No pude usarlo por un error",
|
||||
"refundReasonDuplicate": "Pago duplicado",
|
||||
"refundTitle": "Reembolso",
|
||||
"refundSubmit": "Enviar",
|
||||
"refundReason": "Motivo del reembolso",
|
||||
"refundConfirm": "¿Seguro que quieres solicitar el reembolso?",
|
||||
"myPop": "Mi POP",
|
||||
"commonNoHistory": "Sin historial.",
|
||||
"nicknameEdit": "Editar apodo",
|
||||
"nicknameReserved": "No puedes usar un nombre que parezca del equipo.",
|
||||
"nicknameTaken": "Este apodo ya está en uso.",
|
||||
"albumItems": "Opciones de foto",
|
||||
"takePhoto": "Tomar foto",
|
||||
"pickFromAlbum": "Elegir del álbum",
|
||||
"tierGuide": "Guía de niveles",
|
||||
"statSteps": "Pasos totales",
|
||||
"statContribution": "POP aportado total",
|
||||
"statQuests": "Misiones completadas",
|
||||
"shareInstagram": "Instagram",
|
||||
"shareTiktok": "TikTok",
|
||||
"shareCopyLink": "Copiar enlace",
|
||||
"inquiryListTitle": "Consultas 1:1",
|
||||
"inquiryEmpty": "No tienes consultas.",
|
||||
"inquiryWrite": "Escribir consulta",
|
||||
"inquiryDetail": "Detalle de la consulta",
|
||||
"commonEdit": "Editar",
|
||||
"inquiryAnswer": "Respuesta",
|
||||
"inquiryNew": "Nueva consulta",
|
||||
"inquiryEdit": "Editar consulta",
|
||||
"commonTitle": "Título",
|
||||
"commonTitleHint": "Escribe un título.",
|
||||
"inquiryBody": "Detalles",
|
||||
"inquiryBodyHint": "Describe tu consulta.",
|
||||
"inquiryBodyHelper": "Con 10 caracteres o más podremos ayudarte antes.",
|
||||
"inquirySubmitFailed": "No se pudo enviar la consulta. {reason}",
|
||||
"inquiryEditFailed": "No se pudo editar la consulta. {reason}",
|
||||
"inquirySubmitted": "Consulta enviada.",
|
||||
"inquiryEdited": "Consulta actualizada.",
|
||||
"blockedEmpty": "No hay cuentas bloqueadas.",
|
||||
"unblock": "Desbloquear",
|
||||
"unblockFailed": "No se pudo desbloquear. {reason}",
|
||||
"tierInfoTitle": "Info de TIER",
|
||||
"tierPromptPrefix": "Sube tu ",
|
||||
"tierPromptRank": "rango",
|
||||
"tierPromptSuffix": "\ncontribuyendo a planetas y nebulosas",
|
||||
"tierPassportNote": "Passport Tier es un grado de apariencia de tarjeta, independiente del nivel, y nunca baja.",
|
||||
"tierTotalContribution": "Contribución total",
|
||||
"notifKindEclipse": "Eclipse",
|
||||
"notifKindItemUsed": "Objeto usado",
|
||||
"notifKindLike": "Me gusta",
|
||||
"notifKindComment": "Comentarios",
|
||||
"notifKindCampaign": "Meta de campaña",
|
||||
"notifKindQuest": "Misión completada",
|
||||
"notifTargetPost": "la publicación",
|
||||
"notifTargetCampaign": "la campaña",
|
||||
"notifTargetQuest": "el detalle de la misión",
|
||||
"dayToday": "Hoy",
|
||||
"dayYesterday": "Ayer",
|
||||
"notifEmpty": "No hay notificaciones.",
|
||||
"notifGoTo": "Yendo a {target}.",
|
||||
"stationOfficial": "Oficial",
|
||||
"stationShop": "Tiendas",
|
||||
"stationTicket": "Entradas",
|
||||
"stationMedia": "Medios",
|
||||
"stationLoadFailed": "No se pudo cargar STATION",
|
||||
"commonAll": "Todo",
|
||||
"stationSuggest": "Sugerir tienda",
|
||||
"stationEmpty": "Aún no hay enlaces.\nÚnete a un planeta para ver la selección.",
|
||||
"stationCategoryEmpty": "Aún no hay enlaces en esta categoría.",
|
||||
"stationSuggestLater": "Las sugerencias se abrirán al conectar el servidor.",
|
||||
"commonSubmit": "Enviar",
|
||||
"commonName": "Nombre",
|
||||
"commonNameRequired": "Escribe un nombre.",
|
||||
"commonUrl": "URL",
|
||||
"commonUrlRequired": "Escribe una URL.",
|
||||
"commonUrlHttps": "Escribe una URL que empiece por https.",
|
||||
"commonCategory": "Categoría",
|
||||
"stationReason": "Por qué lo recomiendas (opcional)",
|
||||
"stationSuggestNote": "Los enlaces sugeridos se añaden tras una revisión. Te avisaremos del resultado.",
|
||||
"heroGiftUnavailable": "Este regalo no se puede recibir ahora.",
|
||||
"heroGiftAlready": "Ya participaste en este regalo. Revisa tu historial de POP.",
|
||||
"heroGiftRelogin": "Vuelve a iniciar sesión para participar.",
|
||||
"heroGiftNoPermission": "No tienes permiso para participar en este regalo.",
|
||||
"heroGiftHowTo": "Agita el móvil o toca el botón para unirte al regalo de energía de luz.",
|
||||
"heroGiftPopNote": "El POP que recibes depende del regalo restante y del límite por persona.",
|
||||
"heroGiftReceived": "{total} POP recibidos",
|
||||
"heroGiftUnknown": "No se pudo confirmar el pago. Vuelve a comprobarlo.",
|
||||
"heroGiftNone": "No hay regalos disponibles ahora.",
|
||||
"heroGiftConfirmed": "Se confirmó la recepción del regalo.",
|
||||
"heroGiftChecking": "Confirmando pago…",
|
||||
"heroGiftRetry": "Volver a comprobar el regalo",
|
||||
"heroGiftTap": "Participar con el botón",
|
||||
"rankMyPlanet": "Ranking de {metric} de mi planeta",
|
||||
"rankPlanetEmpty": "Aún no hay ranking de planetas",
|
||||
"rankScopeLive": "En vivo",
|
||||
"rankScopeCountry": "Por país",
|
||||
"rankMine": "Mi ranking de {metric}",
|
||||
"rankMyCountry": "Ranking de {metric} de mi país",
|
||||
"rankEmpty": "Aún no hay ranking",
|
||||
"rankScope": "Ranking {scope}",
|
||||
"rankLoadFailed": "No se pudo cargar el ranking",
|
||||
"rankMeSuffix": "{name} (yo)",
|
||||
"reportDefamation": "Difamación o insultos al artista",
|
||||
"reportInappropriate": "Contenido inapropiado",
|
||||
"reportSpam": "Spam o publicidad comercial",
|
||||
"reportPrivacy": "Exposición de datos personales o suplantación",
|
||||
"reportTitle": "Denunciar",
|
||||
"reportSubmit": "Denunciar",
|
||||
"reportAuthor": "Autor",
|
||||
"reportReasonLabel": "Motivo",
|
||||
"reportConfirm": "¿Denunciar esta publicación?\nNo se puede deshacer.",
|
||||
"blockMemberConfirm": "¿Bloquear todas las publicaciones de este miembro?",
|
||||
"loungeMembersOnly": "Solo los miembros del fandom de este planeta pueden publicar, comentar o dar me gusta.",
|
||||
"loungeFailed": "No se pudo completar. Inténtalo más tarde.",
|
||||
"loungeTitle": "Lounge del fandom",
|
||||
"likeCancel": "Quitar me gusta",
|
||||
"like": "Me gusta",
|
||||
"commentDeleteConfirm": "¿Eliminar este comentario?",
|
||||
"commonShare": "Compartir",
|
||||
"commonEditAction": "Editar",
|
||||
"commonDeleteAction": "Eliminar",
|
||||
"commonBlockAction": "Bloquear",
|
||||
"commentFirst": "Sé el primero en comentar.",
|
||||
"postDeleteConfirm": "¿Eliminar esta publicación?",
|
||||
"reportReceived": "Denuncia recibida.",
|
||||
"commonDelete": "Eliminar",
|
||||
"commentHint": "Escribe un comentario.",
|
||||
"commonSend": "Enviar",
|
||||
"postWrite": "Escribir",
|
||||
"postEdit": "Editar publicación",
|
||||
"loungeHint": "Escribe libremente en el lounge del fandom.",
|
||||
"photoRemove": "Quitar foto {n}",
|
||||
"camera": "Cámara",
|
||||
"album": "Álbum",
|
||||
"historyTitle": "PASSPORT · Historial",
|
||||
"historyMine": "Mi actividad",
|
||||
"historyEvolution": "Evolución Stage {from} → {to}",
|
||||
"historyHibernation": "Planeta en reposo",
|
||||
"historyWakeUp": "Planeta despertó",
|
||||
"historyZap": "Contribución ZAP",
|
||||
"historyQuest": "Recompensa de Quest",
|
||||
"historyAdBonus": "Recompensa extra por anuncio",
|
||||
"historyFunding": "Contribución a Campaign",
|
||||
"historyGrowthPop": "{amount} POP sumados al crecimiento",
|
||||
"historyJoined": "Participaste en esta evolución",
|
||||
"historyLoadFailed": "No se pudo cargar el historial.",
|
||||
"historyMineEmpty": "Aún no hay actividad.",
|
||||
"historyFandomEmpty": "Aún no hay historia del fandom.",
|
||||
"historyMore": "Ver historial anterior",
|
||||
"countryName": "{code, select, KR{Corea del Sur} JP{Japón} US{Estados Unidos} CN{China} TW{Taiwán} HK{Hong Kong} TH{Tailandia} VN{Vietnam} ID{Indonesia} PH{Filipinas} MY{Malasia} SG{Singapur} IN{India} GB{Reino Unido} FR{Francia} DE{Alemania} ES{España} BR{Brasil} MX{México} CA{Canadá} AU{Australia} ZZ{Sin especificar} other{-}}",
|
||||
"loungeMaxPhotos": "Puedes subir hasta {count} fotos.",
|
||||
"commonLoadFailed": "No se pudo cargar",
|
||||
"planetPassportHistory": "PASSPORT · Historia del fandom",
|
||||
"planetNamed": "Planeta {name}",
|
||||
"blockNebulaConfirm": "¿Bloquear esta nebulosa?",
|
||||
"blockPlanetConfirm": "¿Bloquear este planeta?",
|
||||
"blocked": "Bloqueado.",
|
||||
"planetLastUpdated": "Última actualización {time}",
|
||||
"planetContribution": "Contribuciones",
|
||||
"planetNebulaGrowth": "Crecimiento de la nebulosa",
|
||||
"planetLiveRanking": "Ranking de contribución en vivo",
|
||||
"planetNoContributors": "Aún no hay contribuyentes",
|
||||
"planetContributeCta": "¡Contribuye y entra en el ranking!",
|
||||
"planetOverallRank": "Puesto general {rank}",
|
||||
"planetMyContribution": "Mi aporte",
|
||||
"planetTallying": "Cálculo pendiente",
|
||||
"planetTopPercent": "Top {percent}%",
|
||||
"planetHideMember": "Ocultar todas las publicaciones de este miembro",
|
||||
"reportReasonUnknown": "No se pudo verificar el motivo.",
|
||||
"reportAlready": "Ya denunciaste esta publicación.",
|
||||
"blockUnavailable": "No se puede bloquear a este autor.",
|
||||
"commonMe": "Yo",
|
||||
"planetSizeMedium": "Fandom mediano",
|
||||
"planetPerHead": "Poder por miembro: {value}",
|
||||
"planetJoined": "Unido",
|
||||
"planetJoin": "Unirse",
|
||||
"questDetails": "Detalles",
|
||||
"questInProgress": "En curso",
|
||||
"questTypeAttend": "Asistencia",
|
||||
"questTypeSteps": "Caminar",
|
||||
"questTypePlace": "Ubicación",
|
||||
"questTypeStreaming": "Streaming",
|
||||
"questTypeUnverifiable": "Aún no se ha definido cómo verificar este tipo de misión.",
|
||||
"questTabMine": "Mis misiones",
|
||||
"placePickPrompt": "Elige dónde\ncompletar la misión.",
|
||||
"placeSearch": "Buscar lugar",
|
||||
"commonNoResults": "Sin resultados.",
|
||||
"commonSelectDone": "Listo",
|
||||
"adMoreChance": "Consigue más oportunidades de contribuir.",
|
||||
"adWatchPrompt": "¡Mira un anuncio para\nconseguir una recompensa extra!",
|
||||
"adBonusPlaceholder": "+ POP de contribución",
|
||||
"adPartyPack": "Pack de fiesta",
|
||||
"adPickProduct": "Elegir producto",
|
||||
"adWatchBonus": "Ver anuncio y recibir extra",
|
||||
"adBasicReward": "Recibir recompensa básica",
|
||||
"focusMode": "Modo Focus sin anuncios",
|
||||
"pricePerMonth": "{price}/mes",
|
||||
"adWarpBefore": "Warping... Buscando el anuncio ideal para que aportes más a tu fandom y artista.",
|
||||
"adWarpAfter": "Warping... Preparando la recompensa por tu contribución.",
|
||||
"adSkip": "Omitir",
|
||||
"questPreviewHint": "Elige opciones y revisa la vista previa",
|
||||
"questNoCategory": "Aún no hay categoría seleccionada",
|
||||
"questNew": "Nueva misión",
|
||||
"questCategory": "Categoría",
|
||||
"questName": "Nombre de la misión",
|
||||
"questAction": "Acción detallada",
|
||||
"questPickPlace": "Elige la ubicación objetivo",
|
||||
"questGoalValue": "Meta",
|
||||
"questParticipants": "Participantes",
|
||||
"questRewardPop": "POP de recompensa",
|
||||
"questCreateConfirm": "¿Crear con estos datos?",
|
||||
"questCreateSummary": "{title} · Meta {goal} · Recompensa {reward} POP",
|
||||
"commonKeepEditing": "Seguir editando",
|
||||
"questCreate": "Crear",
|
||||
"questNamed": "Misión {title}",
|
||||
"questDailyHelp": "Ayuda a tu planeta cada día",
|
||||
"questTodayMust": "Misiones para hoy",
|
||||
"questTabEmpty": "Aún no hay misiones en esta pestaña.",
|
||||
"questCreateNotReady": "La creación de misiones se abrirá junto con los fondos de recompensa. Próximamente.",
|
||||
"questNoBaseReward": "No se encontró la recompensa básica, así que no hay extra por anuncio.",
|
||||
"questNoSteps": "Aún no hay pasos registrados. Camina un poco y vuelve a intentarlo.",
|
||||
"questNoPlace": "Esta misión no tiene una ubicación registrada.",
|
||||
"questGoal": "Meta",
|
||||
"questReward": "Recompensa",
|
||||
"questPopGrant": "Recompensa de POP",
|
||||
"commonChecking": "Comprobando…",
|
||||
"questMine": "Es tu misión. Participantes: {count}.",
|
||||
"questStopConfirm": "¿Detener esta misión?",
|
||||
"questStopBody": "Tu progreso se reiniciará desde el principio.",
|
||||
"questStop": "Detener",
|
||||
"adNotFinished": "No terminaste el anuncio, así que no hay extra. Recibes la recompensa básica.",
|
||||
"openSettings": "Abrir ajustes",
|
||||
"permStepsDenied": "El permiso de pasos está desactivado. Permite \"Actividad física\" en Ajustes.",
|
||||
"permStepsNeeded": "Se necesita el permiso de actividad física para contar pasos.",
|
||||
"permLocationOff": "La ubicación del dispositivo está desactivada. Actívala e inténtalo de nuevo.",
|
||||
"permLocationNeeded": "Se necesita el permiso de ubicación para verificar tu visita.",
|
||||
"permLocationDenied": "El permiso de ubicación está desactivado. Permite el acceso en Ajustes.",
|
||||
"questRewardReceived": " ¡recibidos!",
|
||||
"questAdBonus": "Extra por anuncio +{pop} POP",
|
||||
"questParticipantsCount": "Participantes: {count}",
|
||||
"questParticipantsCap": "Participantes: {count} / {cap}",
|
||||
"nebulaCreateFailed": "No se pudo crear la nebulosa. Inténtalo más tarde.",
|
||||
"nebulaAlreadyToday": "Ya creaste una nebulosa hoy.",
|
||||
"nebulaCreatedKeepPrimary": "Nebulosa creada. Tu planeta principal no cambia: solo puede cambiarse 30 días después del último cambio.",
|
||||
"fandomFounderLeaveBlocked": "Podrás salir cuando nazca el planeta.",
|
||||
"sortPopular": "Populares",
|
||||
"campaignSortContributing": "Contribuyendo",
|
||||
"campaignStatusReview": "Esta campaña está en revisión.",
|
||||
"campaignStatusRejected": "Esta campaña fue rechazada en la revisión. Te hemos devuelto la tarifa de registro.",
|
||||
"campaignStatusWithdrawn": "El creador retiró esta campaña.",
|
||||
"campaignStatusCancelled": "Se canceló por motivos operativos. Te hemos devuelto la tarifa de registro y los POP aportados.",
|
||||
"rankWindowDaily": "Diario",
|
||||
"rankWindowWeekly": "Semanal",
|
||||
"campaignNotFound": "No se encontró la campaña.",
|
||||
"campaignMediaMain": "Imagen principal",
|
||||
"campaignLoadFailed": "No se pudo cargar la campaña.",
|
||||
"campaignTabInfo": "Información",
|
||||
"campaignTabBoard": "Apoyos {count}",
|
||||
"campaignWithdraw": "Retirar campaña",
|
||||
"campaignContributeAction": "Contribuir",
|
||||
"campaignFundingStatus": "Estado de la financiación",
|
||||
"campaignSupporting": "Apoyando",
|
||||
"campaignTimeLeft": "¡Restante!",
|
||||
"campaignReport": "Informe de resultados",
|
||||
"campaignReportPhotos": "Fotos de ejecución",
|
||||
"campaignBudget": "Desglose del presupuesto",
|
||||
"campaignTotal": "Total",
|
||||
"campaignReceipts": "Recibos",
|
||||
"campaignDaysLeft": "{days} d {time}",
|
||||
"campaignWithdrawTitle": "¿Retirar esta campaña?",
|
||||
"campaignWithdrawRefund": "Como aún no es pública, te devolveremos la tarifa de registro de {fee} POP tal como la pagaste.",
|
||||
"campaignWithdrawNoRefund": "La campaña ya es pública, por lo que la tarifa de registro de {fee} POP no se devolverá.",
|
||||
"campaignWithdrawConfirm": "Retirar",
|
||||
"shareCopied": "Texto para compartir copiado.",
|
||||
"campaignContributePop": "Elige los POP a aportar",
|
||||
"campaignEmptyContributing": "Aún no has contribuido a ninguna campaña.",
|
||||
"campaignEmpty": "No hay campañas.",
|
||||
"campaignNew": "Nueva campaña",
|
||||
"campaignMyRanking": "Mi ranking de contribución",
|
||||
"campaignWindowRanking": "Ranking {window}",
|
||||
"campaignSearchHint": "Buscar campañas",
|
||||
"campaignNoRecentSearches": "No hay búsquedas recientes",
|
||||
"searchRemoveTerm": "Eliminar {term}",
|
||||
"campaignNoResults": "Sin resultados",
|
||||
"shareTopicCampaign": "Campaña",
|
||||
"sharePlanetLine": "{brand} · Planeta {name}",
|
||||
"campaignGaugeLabel": "Progreso de la campaña",
|
||||
"campaignPeriodTitle": "Elige el periodo de la campaña",
|
||||
"campaignPeriodMax": "Puedes elegir hasta un año",
|
||||
"campaignYearMonth": "{month}/{year}",
|
||||
"campaignWeekdays": "D,L,M,X,J,V,S",
|
||||
"campaignPeriodRange": "{startDay}/{startMonth} ~ {endDay}/{endMonth}",
|
||||
"campaignPeriodSelect": "Seleccionar periodo",
|
||||
"calendarPrevMonth": "Mes anterior",
|
||||
"calendarNextMonth": "Mes siguiente",
|
||||
"campaignShareTitle": "Compartir campaña",
|
||||
"campaignPickMedia": "Elegir imagen/vídeo",
|
||||
"campaignTitleHint": "Escribe el título de la campaña.",
|
||||
"campaignBodyHint": "Describe tu campaña.",
|
||||
"campaignReviewNote": "Tras enviarla, la financiación se abre después de la revisión.",
|
||||
"campaignSupportType": "Elige el tipo de apoyo",
|
||||
"campaignPeriod": "Periodo de la campaña",
|
||||
"campaignDateSelect": "Seleccionar fechas",
|
||||
"campaignGoalPop": "POP objetivo",
|
||||
"campaignSubmitTitle": "¿Enviar esta campaña?",
|
||||
"campaignSubmitBody": "{title} · Objetivo {goal} POP · {days} días\nSe descontará la tarifa de registro de {fee} POP y la financiación se abrirá tras la revisión.",
|
||||
"campaignSubmit": "Enviar",
|
||||
"campaignCreateFailed": "No se pudo enviar la campaña.",
|
||||
"campaignNeedsQuests": "Misiones completadas {done}/{need}: completa {left} más para abrir una campaña.",
|
||||
"campaignPendingTitle": "Pendiente de revisión",
|
||||
"campaignPendingBody": "Cuando termine la revisión, se abrirá la financiación y aparecerá en la lista.",
|
||||
"insideRefundPending": "El plazo de aceptación venció. Se reembolsará la recompensa.",
|
||||
"insideRefundDone": "El plazo de aceptación venció. Se reembolsó la recompensa.",
|
||||
"insideRefundChecking": "El plazo de aceptación venció. Estamos revisando el reembolso de la recompensa.",
|
||||
"insideErrPopShort": "No tienes POP suficientes para la recompensa.",
|
||||
"insideErrSelfAccept": "No puedes aceptar tu propia respuesta.",
|
||||
"insideErrAlreadyAccepted": "Ya se aceptó una respuesta y no se puede cambiar.",
|
||||
"insideErrNotOwner": "Solo quien preguntó puede aceptar una respuesta.",
|
||||
"commonCheckConnection": "Revisa tu conexión e inténtalo de nuevo.",
|
||||
"insideAnswerLater": "Los comentarios se abrirán cuando se conecte el servidor.",
|
||||
"insideAcceptConfirm": "¿Aceptar este comentario?\nSe entregará la recompensa de {pop} POP y no se podrá deshacer.",
|
||||
"insideAccept": "Aceptar",
|
||||
"insideMore": "Más",
|
||||
"insideFirstAnswer": "Sé el primero en comentar.",
|
||||
"insideAcceptDone": "Aceptada",
|
||||
"insideAcceptAction": "Aceptar",
|
||||
"insideAnswerHint": "Escribe un comentario.",
|
||||
"insideLoadFailed": "No se pudo cargar INSIDE",
|
||||
"insideEmpty": "Aún no hay preguntas.",
|
||||
"insideWrite": "Escribir INSIDE",
|
||||
"insideSearchHint": "Título o contenido de INSIDE",
|
||||
"insideBodyHint": "Pregunta y responde libremente con otros fans.",
|
||||
"insideTitleRequired": "Escribe un título.",
|
||||
"insideBodyRequired": "Escribe el contenido.",
|
||||
"insideBountySet": "Fijar recompensa",
|
||||
"insideBountyHint": "Fija {min} o más.",
|
||||
"insideAttachLater": "Los adjuntos de imagen se abrirán cuando se conecte el servidor.",
|
||||
"insideBountyRequired": "Indica una recompensa.",
|
||||
"insideNumberOnly": "Introduce un número.",
|
||||
"insideBountyMin": "La recompensa debe ser de al menos {min} POP.",
|
||||
"insideBountyOverBalance": "No puede superar tus POP disponibles.",
|
||||
"questTypeTimeAttack": "Contrarreloj relámpago",
|
||||
"questTimeLeft": "Tiempo restante",
|
||||
"questTimeAttackClosed": "Cerrado",
|
||||
"questTimeAttackLeft": "Quedan {time}",
|
||||
"popReasonQuestTimeAttack": "Contrarreloj relámpago",
|
||||
"briefingShareCopied": "Texto y enlace copiados.",
|
||||
"shareCopyText": "Copiar texto",
|
||||
"briefingShareTextCopied": "Texto copiado.",
|
||||
"questAlreadyRewarded": "Recompensa ya recibida (+{pop} POP).",
|
||||
"questCatHealth": "Salud",
|
||||
"questCatPlanet": "Actividad del planeta",
|
||||
"questCatCommunity": "Comunidad",
|
||||
"questActShake": "Shake",
|
||||
"questTypeVote": "Votar",
|
||||
"questTypeSns": "Redes sociales",
|
||||
"questActivity": "Actividad",
|
||||
"questNoCreatable": "Ahora no hay actividades disponibles para crear.",
|
||||
"questDescriptionOptional": "Descripción (opcional)",
|
||||
"questLinkUrl": "URL del enlace",
|
||||
"questLinkCheck": "Comprobar enlace",
|
||||
"questLinkInvalid": "El enlace no es válido. Revisa la dirección.",
|
||||
"questLinkPreviewFailed": "No se pudo cargar el enlace. Escribe un título.",
|
||||
"questLinkTitle": "Título del enlace",
|
||||
"questEndDate": "Fecha de fin",
|
||||
"questEndDefault": "24 horas tras publicar",
|
||||
"questEndDateValue": "Termina el {date} 23:59:59",
|
||||
"questPoolHelper": "Depósito {pool} POP · Comisión {fee} POP",
|
||||
"questCreateCost": "{title}\nRecompensa {reward} POP × {count} = {pool} POP\nComisión {fee} POP (se quema al publicar)\nSe descontarán {total} POP en total.",
|
||||
"questNoReward": "Esta misión no tiene recompensa.",
|
||||
"questDeadline": "Fecha límite",
|
||||
"questRemainingHours": "Quedan {hours} h",
|
||||
"questEnded": "Finalizada",
|
||||
"questAuthorNote": "Nota del creador",
|
||||
"questRepeatOnce": "Una participación por persona",
|
||||
"questRepeatEvery": "Cada {hours} h · hasta {count} veces",
|
||||
"questSubmit": "Enviar prueba",
|
||||
"questResubmit": "Reenviar",
|
||||
"questEvidenceUrl": "Enlace de prueba (opcional)",
|
||||
"questEvidenceText": "Detalles de la prueba",
|
||||
"questSubmitted": "Enviado. La recompensa se paga tras la revisión.",
|
||||
"questReviewPending": "En revisión. La recompensa se paga al aprobarse.",
|
||||
"questReviewDelayed": "La revisión se está retrasando. Nuestro equipo lo está revisando.",
|
||||
"questApprovedPaying": "Aprobado. Pagando tu recompensa.",
|
||||
"questRejected": "Rechazado: {reason}",
|
||||
"questResubmitAllowed": "Puedes corregir y reenviar.",
|
||||
"questResubmitNotAllowed": "Esta entrada no se puede reenviar.",
|
||||
"questReviewButton": "En revisión",
|
||||
"questRejectedButton": "Rechazado",
|
||||
"questReviewQueue": "{count} pendientes de revisión",
|
||||
"questApprove": "Aprobar",
|
||||
"questReject": "Rechazar",
|
||||
"questRejectReason": "Motivo",
|
||||
"questAllowResubmit": "Permitir reenvío",
|
||||
"questEdit": "Editar título y descripción",
|
||||
"questEditNotice": "Tras publicar solo puedes editar el título y la descripción, y se guarda el historial.",
|
||||
"popReasonQuestUgc": "Recompensa de misión",
|
||||
"popReasonQuestFee": "Comisión por crear misión",
|
||||
"questFreeDailyCap": "Puedes crear hasta {cap} misiones sin recompensa al día.",
|
||||
"popReasonQuestPoolDeposit": "Depósito de recompensa",
|
||||
"popReasonQuestPoolReturn": "Devolución de recompensa",
|
||||
"campaignCommentNeedNickname": "Primero elige un apodo",
|
||||
"campaignCommentSetNickname": "Elegir apodo"
|
||||
}
|
||||
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"@@locale": "ja",
|
||||
"commonConfirm": "確認",
|
||||
"commonCancel": "キャンセル",
|
||||
"commonClose": "閉じる",
|
||||
"commonSelect": "選択",
|
||||
"commonRetry": "再試行",
|
||||
"commonView": "見る",
|
||||
"commonPrev": "戻る",
|
||||
"commonDone": "完了",
|
||||
"commonStart": "はじめる",
|
||||
"commonNotYet": "{where}画面は第2次実装確認(2026-09-10)で追加されます。",
|
||||
"languageSettingsTitle": "言語設定",
|
||||
"authLogin": "ログイン",
|
||||
"authSignup": "新規登録",
|
||||
"authFirstTime": "FANUZUは初めてですか?",
|
||||
"authAlreadyMember": "すでに登録済みですか?",
|
||||
"authLoginHeadline": "いちばん簡単な方法で\nFANUZUにログイン!",
|
||||
"authSignupHeadline": "いちばん簡単な方法で\nFANUZUに新規登録!",
|
||||
"authProviderLogin": "{provider, select, apple{Appleでログイン} google{Googleでログイン} kakao{Kakaoでログイン} line{LINEでログイン} other{メールでログイン}}",
|
||||
"authProviderSignup": "{provider, select, apple{Appleで登録} google{Googleで登録} kakao{Kakaoで登録} line{LINEで登録} other{メールで登録}}",
|
||||
"authNotConfiguredTitle": "{name}ログイン準備中",
|
||||
"authNotConfiguredBody": "{name}ログインは発注元のキー提供後に利用できます。\n\n必要な項目\n· {items}",
|
||||
"authNotConfiguredNotice": "{name}ログインはまだ準備中です。\n必要な項目: {items}",
|
||||
"authResetNeedEmail": "再設定メールを受け取るメールアドレスを先に入力してください。",
|
||||
"authResetSent": "入力されたアドレスに再設定の案内を送信しました。",
|
||||
"termsTitle": "利用規約",
|
||||
"termsAgreeAll": "すべて同意",
|
||||
"termsService": "サービス利用規約(必須)",
|
||||
"termsLocation": "位置情報サービス利用規約(必須)",
|
||||
"termsAge": "14歳以上です。(必須)",
|
||||
"termsMarketing": "マーケティング情報・イベント通知の受信に同意(任意)",
|
||||
"signupDoneTitle": "新規登録が完了しました",
|
||||
"signupDoneBody": "ファンダム活動を始められます。\nさまざまな活動でファンダム惑星を育てましょう。",
|
||||
"homeItemsTooltip": "アイテム",
|
||||
"homeNotificationsTooltip": "通知",
|
||||
"homeFandomHistory": "ファンダムの歴史",
|
||||
"homePopularInside": "人気 K-INSIDE",
|
||||
"homeWhereBriefing": "AIブリーフィング",
|
||||
"homeWhereInsideList": "INSIDE一覧",
|
||||
"homeWhereInsideDetail": "INSIDE詳細",
|
||||
"homeLoadFailed": "ホームを読み込めませんでした",
|
||||
"homeRetryLater": "しばらくしてから再度お試しください。",
|
||||
"myProfileCardTooltip": "プロフィールカード",
|
||||
"myTabPlanets": "惑星/星雲",
|
||||
"myTabActivity": "アクティビティ",
|
||||
"myInside": "インサイド",
|
||||
"myCampaign": "キャンペーン",
|
||||
"myShareLater": "{target}への共有は09-10回で接続します。",
|
||||
"myEditPhoto": "プロフィール写真を編集",
|
||||
"myEditProfile": "プロフィールを編集",
|
||||
"settingsTitle": "設定",
|
||||
"settingsAccount": "アカウント",
|
||||
"settingsNickname": "ニックネーム",
|
||||
"settingsEmail": "メール",
|
||||
"settingsSns": "SNS連携アカウント",
|
||||
"settingsPopHistory": "POP履歴",
|
||||
"settingsPayments": "決済履歴",
|
||||
"settingsServicePayments": "サービス決済履歴",
|
||||
"settingsService": "サービス設定",
|
||||
"settingsPush": "プッシュ通知設定",
|
||||
"settingsBlocked": "ブロック設定",
|
||||
"settingsInfo": "サービス情報",
|
||||
"settingsNotice": "お知らせ",
|
||||
"settingsInquiry": "1:1お問い合わせ",
|
||||
"settingsAppVersion": "アプリバージョン",
|
||||
"settingsLatest": "最新バージョン",
|
||||
"settingsLogout": "ログアウト",
|
||||
"settingsLogoutConfirm": "ログアウトしますか?",
|
||||
"settingsWithdraw": "退会",
|
||||
"settingsWithdrawConfirm": "退会しますか?",
|
||||
"settingsWithdrawBody": "保有しているPOPは消滅し、元に戻せません。",
|
||||
"settingsWithdrawLater": "退会処理は09-10回で接続します。",
|
||||
"settingsUnlinkConfirm": "連携中のアカウントを解除しますか?",
|
||||
"settingsUnlinkBody": "連携したSNSのログイン手段のみ削除され、アカウント情報は削除されません。",
|
||||
"settingsUnlink": "連携解除",
|
||||
"settingsSameEmailOnly": "アカウントのメールと同じ\nソーシャルアカウントのみ連携できます。",
|
||||
"settingsLinkNotReady": "{name}連携はまだ準備中です。必要な項目: {items}",
|
||||
"settingsConnect": "連携",
|
||||
"settingsDisconnect": "解除",
|
||||
"settingsEmailProvider": "メール",
|
||||
"pushDeviceOff": "端末のプッシュ通知がオフになっています。",
|
||||
"pushDeviceOffHint": "通知を受け取るにはシステム設定で通知を許可してください。",
|
||||
"pushRequesting": "リクエスト中…",
|
||||
"pushGoToSettings": "設定へ移動",
|
||||
"pushOpeningDeviceSettings": "端末の設定に移動します。",
|
||||
"pushEnableInDevice": "端末の設定でFANUZUの通知をオンにしてください。",
|
||||
"pushDeviceSection": "端末の通知設定",
|
||||
"pushSoundVibration": "サウンド・バイブ設定",
|
||||
"pushServiceSection": "サービス通知設定",
|
||||
"pushTopicEssential": "サービス必須",
|
||||
"pushTopicFandomEvent": "ファンダムイベント",
|
||||
"pushTopicBriefing": "AI Briefing",
|
||||
"pushTopicCommunity": "コミュニティ",
|
||||
"pushTopicMarketing": "マーケティング・広報",
|
||||
"linkTitle": "{name}連携",
|
||||
"linkNotLinked": "未連携",
|
||||
"linkLinked": "連携済み",
|
||||
"linkSameEmailOnly": "アカウントのメールと同じソーシャルアカウントのみ連携できます。",
|
||||
"linkUnlink": "連携解除",
|
||||
"linkLink": "連携する",
|
||||
"noticeBadge": "お知らせ",
|
||||
"noticeTitle": "お知らせ",
|
||||
"noticeEmpty": "お知らせはありません。",
|
||||
"noticeDetailTitle": "お知らせ詳細",
|
||||
"translationMark": "翻訳済み",
|
||||
"translationReplyTo": "{name}さんのコメントを翻訳",
|
||||
"translationWrite": "翻訳コメントを書く",
|
||||
"translationHint": "翻訳を入力してください",
|
||||
"translationValid": "有効",
|
||||
"translationInvalid": "無効",
|
||||
"translationSelfMarkBlocked": "自分の翻訳はマークできません。",
|
||||
"translationSubmitted": "翻訳を登録しました。検証を通過すると24時間後にPOPが付与されます。",
|
||||
"translationMarked": "マークしました。",
|
||||
"translationAlreadyMarked": "すでにマークした翻訳です。",
|
||||
"translationDuplicate": "この投稿にはすでに同じ言語で翻訳を登録しています。",
|
||||
"translationFailed": "処理できませんでした。しばらくしてから再度お試しください。",
|
||||
"translationNotEligible": "登録から24時間以上経過したこのファンダムのメンバーのみマークできます。",
|
||||
"translationDailyCap": "本日はこれ以上翻訳を登録できません。",
|
||||
"translationInvalidTarget": "このコンテンツは翻訳できません。",
|
||||
"commonRequestFailed": "リクエストを処理できませんでした。",
|
||||
"commonFan": "ファン",
|
||||
"adLoadFailed": "広告を読み込めませんでした。基本報酬のみ受け取ります。",
|
||||
"storeNotReady": "ストア商品準備中",
|
||||
"storePending": "決済の承認を待っています。承認されると自動で反映されます。",
|
||||
"storeFailed": "決済を完了できませんでした。",
|
||||
"storePurchased": "購入が反映されました。",
|
||||
"heroParticipate": "ZAP+ · ギフトに参加",
|
||||
"heroRemainingSemantics": "HERO TIME 残り{minutes}分{seconds}秒",
|
||||
"heroRemaining": "HERO TIME · 残り{time}",
|
||||
"heroClaims": "ファンのギフト受取 {count}件",
|
||||
"heroInProgress": "{name} · HERO TIME 開催中 →",
|
||||
"stageNames": "星雲,始まり,成長,共に,同行,光,永遠",
|
||||
"planetStageUnknown": "惑星 · ステージ情報なし",
|
||||
"planetHibernatingSuffix": " · 休眠中",
|
||||
"planetEclipseSuffix": " · Eclipse進行中",
|
||||
"planetHeroSuffix": " · HERO TIME開催中",
|
||||
"evoPlanetGrowth": "惑星の成長",
|
||||
"evoHeroEnd": "HERO TIME 終了",
|
||||
"evoAwakening": "惑星が目覚めます",
|
||||
"evoAnomaly": "異常反応",
|
||||
"evoEnergy": "エネルギー集中",
|
||||
"evoTransition": "転換",
|
||||
"evoComplete": "惑星の進化完了",
|
||||
"evoOpenHistory": "PASSPORTの記録を見る",
|
||||
"playNow": "今すぐプレイ",
|
||||
"playHeadline": "さあ、ファンダムに会いに行こう!",
|
||||
"playBody": "惑星を作ってファンダムと交流しながら楽しいファン活動...",
|
||||
"timeJustNow": "たった今",
|
||||
"timeMinutesAgo": "{n}分前",
|
||||
"timeHoursAgo": "{n}時間前",
|
||||
"timeDaysAgo": "{n}日前",
|
||||
"commonMore": "もっと見る",
|
||||
"authProviderStart": "{provider, select, email{メールではじめる} kakao{Kakaoではじめる} line{LINEではじめる} apple{Appleではじめる} other{Googleではじめる}}",
|
||||
"authProviderName": "{provider, select, email{メール} kakao{Kakao} line{LINE} apple{Apple} other{Google}}",
|
||||
"authLastProvider": "最後のログイン手段は連携解除できません。",
|
||||
"authLoginRequired": "ログインが必要です。",
|
||||
"authDifferentEmail": "登録したメールアドレスと異なるアカウントです。",
|
||||
"authLinkFailed": "アカウントの連携に失敗しました。",
|
||||
"authNotLinked": "連携されていないアカウントです。",
|
||||
"authEmailUnavailable": "メールログインは利用できません。",
|
||||
"authCheckCredentials": "メールアドレスまたはパスワードをご確認ください。",
|
||||
"authSocialFailed": "ソーシャルログインに失敗しました。",
|
||||
"authAppleUnavailable": "この端末ではAppleでログインを利用できません。",
|
||||
"authEmailInUse": "すでに登録されているメールアドレスです。",
|
||||
"authWeakPassword": "パスワードが短すぎます。8文字以上で設定してください。",
|
||||
"authInvalidEmail": "メールアドレスの形式が正しくありません。",
|
||||
"commonTryLater": "しばらくしてから再度お試しください。",
|
||||
"authCredentialInUse": "別のFANUZUアカウントにすでに連携されているソーシャルアカウントです。",
|
||||
"authAlreadyLinked": "すでに連携済みのアカウントです。",
|
||||
"authMethodUnavailable": "このログイン手段はまだ利用できません。",
|
||||
"authRecentLogin": "セキュリティのため、再度ログインしてからお試しください。",
|
||||
"authOtherProviderGuide": "{email} アカウントは別のログイン手段で登録されています。最初に登録した手段でログインし、MY > 設定 > SNS連携でこの手段を連携してください。",
|
||||
"authThisAccount": "この",
|
||||
"eclipseCapped": "参加上限に達したため、一部は集計されませんでした。",
|
||||
"eclipseResultUnknown": "参加結果を確認できませんでした。同じリクエストで再度ご確認ください。",
|
||||
"eclipseChecking": "参加を確認中",
|
||||
"eclipseRetrySame": "同じ参加を再確認",
|
||||
"eclipseEnded": "イクリプスはすでに終了したか、防衛に成功しました。",
|
||||
"eclipseTooFast": "参加が速すぎます。しばらくしてから再度お試しください。",
|
||||
"eclipseNone": "進行中のイクリプスはありません",
|
||||
"eclipseSuccessTitle": "隕石の衝突を\n見事に防ぎました!",
|
||||
"eclipseQualified": "参加認定完了",
|
||||
"eclipseFailTitle": "惜しくも惑星の防衛に\n失敗しました",
|
||||
"eclipseFailBody": "今回の防衛目標を達成できませんでした。",
|
||||
"eclipseCancelTitle": "イクリプス中止",
|
||||
"eclipseCancelBody": "集計エラーのため中止されました。FPIは差し引かれていません。",
|
||||
"eclipsePendingTitle": "防衛結果を確認中",
|
||||
"eclipsePendingBody": "参加時間が終了しました。サーバーの判定を待っています。",
|
||||
"eclipseShakeTimed": "秒間スマホを振って\n隕石の衝突を防ごう!",
|
||||
"eclipseShake": "スマホを振って\n隕石の衝突を防ごう!",
|
||||
"eclipseMyCount": "自分の参加 {count}回",
|
||||
"eclipsePendingSuffix": " · +{count} 送信待ち",
|
||||
"eclipseSendingSuffix": " · 送信中",
|
||||
"eclipseNoPopNote": "POPは消費されません。有効Shakeを{count}回以上振って防衛に参加すると参加者として認定されます。",
|
||||
"eclipseTapToJoin": "振るのが難しければタップで参加",
|
||||
"eclipseTimeLeft": "イクリプス残り時間:",
|
||||
"eclipseSeconds": " {seconds}秒",
|
||||
"eclipseBannerDefended": "防衛成功 — {name} FPI減少なし",
|
||||
"eclipseBannerActive": "イクリプス発動 — {name} 侵食 {rate}%",
|
||||
"eclipseBannerDefense": "有効参加 {count} / {target}人",
|
||||
"timeHoursMinutesLeft": "残り{hours}時間{minutes}分",
|
||||
"timeMinutesLeft": "残り{minutes}分",
|
||||
"liveEclipseDefense": "[LIVE] {name}さんがイクリプス防衛に参加しました!(+{count})",
|
||||
"liveHotTime": "[LIVE] {name}さんのアーティストホットタイムが始まりました!",
|
||||
"liveHeroGift": "[LIVE] {name}さんの英雄のギフトが届きました!",
|
||||
"liveCampaign": "[LIVE] {name}さんがキャンペーンを支援しました!",
|
||||
"galaxySetPrimaryConfirm": "選択した惑星をメイン惑星に変更しますか?",
|
||||
"galaxyLoadFailed": "ギャラクシーを読み込めませんでした",
|
||||
"commonSearch": "検索",
|
||||
"galaxyMyPlanets": "マイ惑星",
|
||||
"galaxyMap": "ギャラクシーマップ",
|
||||
"nebulaCreate": "星雲を作成",
|
||||
"commonRecheck": "再確認",
|
||||
"zapApplying": "貢献を反映中",
|
||||
"zapNeedsCheck": "貢献結果の確認が必要",
|
||||
"zapNoPlanet": "所属惑星がありません",
|
||||
"commonContribute": "貢献",
|
||||
"commonItem": "アイテム",
|
||||
"galaxyDefended": "防衛成功 · FPI減少なし",
|
||||
"galaxyErosion": "侵食 {rate}% · 防衛 {progress}%",
|
||||
"galaxyOnlineMembers": "接続 {online}人 · メンバー {members}人",
|
||||
"galaxySearchHint": "ファンダム名で検索",
|
||||
"commonRecentSearches": "最近の検索",
|
||||
"commonClearAll": "すべて削除",
|
||||
"commonNoRecentSearches": "最近の検索はありません。",
|
||||
"galaxyNoHit": "該当するファンダムがありません。\n名前をもう一度ご確認ください。",
|
||||
"galaxyMembers": "メンバー {count}人",
|
||||
"galaxyPlanetRank": "{name} 惑星 {rank}位",
|
||||
"galaxyMapOpen": "ギャラクシーマップを開く",
|
||||
"zapNotEnoughPop": "POPが足りません",
|
||||
"zapFailNoPop": "POPが足りず貢献できませんでした。",
|
||||
"zapFailLedger": "残高と獲得履歴が一致しないため、貢献を停止しました。",
|
||||
"zapFailRetry": "接続を確認して、同じ貢献を再度ご確認ください。",
|
||||
"zapDone": "貢献完了",
|
||||
"zapDonePop": "{pop} POP 貢献完了",
|
||||
"growthFounders": "創立メンバー",
|
||||
"growthNebulaGoalPassed": "目標期間が過ぎました · 条件を満たすと惑星が誕生します",
|
||||
"growthFinalStage": "最終ステージ",
|
||||
"growthPromotionPending": "昇格確定待ち",
|
||||
"growthCheckConditions": "成長条件を確認",
|
||||
"growthConditionChecking": "{name} 条件を確認中",
|
||||
"growthConditionMet": "{name} 条件達成",
|
||||
"growthConditionUnmet": "{name} 条件未達成",
|
||||
"nebulaHeadline": "ファンダム惑星の名前を決めよう。",
|
||||
"nebulaBody": "創立メンバーが{days}日以内に目標POPを達成すると\n正式な惑星として誕生します!\n進化に参加したメンバーには記念バッジが贈られます。",
|
||||
"nebulaNameTaken": "すでに使用されている名前です。",
|
||||
"nebulaNameHint": "ファンダム名",
|
||||
"nebulaGoal": "目標 ",
|
||||
"nebulaFounders": " / 創立メンバー",
|
||||
"nebulaGoalSemantics": "目標 {pop} POP · 創立メンバー {members}人 · {days}日固定",
|
||||
"commonMyPop": "保有POP:",
|
||||
"commonChange": "変更",
|
||||
"planetShape": "形 {n}",
|
||||
"planetBgColor": "背景色を選択",
|
||||
"planetColorSelect": "惑星の色を選択",
|
||||
"planetTypeSelect": "惑星タイプを選択",
|
||||
"commonNext": "次へ",
|
||||
"planetCreateFailed": "惑星を作成できませんでした。しばらくしてから再度お試しください。",
|
||||
"planetCreateConfirm": "選択したオプションで\n惑星を作成しますか?",
|
||||
"commonCreate": "作成",
|
||||
"briefingOpenFailed": "原文を開けません。",
|
||||
"campaignAchieved": "{percent}% 達成",
|
||||
"campaignView": "キャンペーンを見る",
|
||||
"growthFinalReached": "最終ステージに到達しました",
|
||||
"zapRetrySame": "同じ貢献を再確認",
|
||||
"heroOwnerSuffix": "さんの",
|
||||
"heroStartPrompt": "惑星を作成または参加して\nファン宇宙を始めよう!",
|
||||
"heroNebulaPrompt": "活動で獲得したPOPで\n星雲に貢献しよう!",
|
||||
"heroRecentContribution": "{pop} POPの貢献で\n着実に成長しています。",
|
||||
"heroGrowingTogether": "みんなで育てるファンダム惑星",
|
||||
"heroPopProgress": "POP進捗率",
|
||||
"heroNebula": "星雲",
|
||||
"heroNebulaTap": "星雲をタップして\n惑星を誕生させよう!",
|
||||
"heroCreateNebula": "惑星を作成(星雲)",
|
||||
"heroJoinPlanet": "惑星に参加",
|
||||
"heroCreateOrJoin": "惑星を作成または参加",
|
||||
"insideAnswers": "回答 {count}",
|
||||
"itemJoinFirst": "惑星に参加すると英雄のギフトを送れます。",
|
||||
"itemBuyNotReady": "アイテム購入は準備中です。",
|
||||
"itemUseNotReady": "アイテムの使用は準備中です。",
|
||||
"payOpenFailed": "決済画面を開けませんでした。",
|
||||
"itemBuy": "アイテム購入",
|
||||
"itemSelect": "アイテム選択",
|
||||
"commonComingSoon": "準備中です。",
|
||||
"itemSendPop": "{pop} POP 送信",
|
||||
"itemBuyerPop": "購入者 +{pop} POP",
|
||||
"itemOwned": "保有:{count}",
|
||||
"itemOwnedCount": "保有:{count}個",
|
||||
"growthOwnerOf": "さんの ",
|
||||
"growthSteadySuffix": " の貢献で着実に成長しています。",
|
||||
"questAttended": "出席完了",
|
||||
"questInProgressSpaced": "進行中",
|
||||
"questRewardPaid": "報酬支給完了",
|
||||
"questClaim": "報酬を受け取る",
|
||||
"questStart": "クエストを始める",
|
||||
"questRewardDone": "報酬受取済み",
|
||||
"popKindGeneral": "一般POP",
|
||||
"popKindContribution": "貢献POP",
|
||||
"popTxGrant": "支給",
|
||||
"popTxSpend": "差引",
|
||||
"popTxRefund": "返金",
|
||||
"popTxVoid": "無効",
|
||||
"popTypeQuest": "クエスト",
|
||||
"popTypeReward": "報酬",
|
||||
"popTypePayment": "決済",
|
||||
"popTypeSubscription": "サブスク",
|
||||
"popTypeAdmin": "運営",
|
||||
"popTypeFee": "手数料",
|
||||
"commonEtc": "その他",
|
||||
"popReasonQuestSteps": "歩数計クエスト",
|
||||
"popReasonQuestMusicShake": "ミュージックシェイククエスト",
|
||||
"popReasonQuestPlace": "スポット訪問クエスト",
|
||||
"popReasonQuestAttend": "出席クエスト",
|
||||
"popReasonAdReward": "広告報酬",
|
||||
"popReasonHeroDrop": "英雄のギフト",
|
||||
"popReasonHeroDropTrigger": "英雄のギフト発動",
|
||||
"popReasonTranslation": "参加型翻訳",
|
||||
"popReasonIapPurchase": "商品購入",
|
||||
"popReasonFocusDaily": "Focusデイリー報酬",
|
||||
"popReasonPreregBonus": "事前予約報酬",
|
||||
"popReasonReferral": "友達招待",
|
||||
"popTypeReferral": "リファラル",
|
||||
"popReasonAdminGrant": "運営支給",
|
||||
"popReasonAdminClawback": "運営回収",
|
||||
"popReasonCampaignFund": "キャンペーン支援",
|
||||
"popReasonCampaignRefund": "キャンペーン返金",
|
||||
"popReasonCampaignFeeRefund": "キャンペーン開設手数料の返金",
|
||||
"popReasonCampaignFee": "キャンペーン登録費",
|
||||
"popReasonQnaBounty": "インサイド懸賞金",
|
||||
"popReasonQnaRefund": "懸賞金返金",
|
||||
"popReasonQnaReward": "インサイド採択報酬",
|
||||
"popReasonEclipseDefense": "Zap貢献",
|
||||
"popReasonOther": "POP変動",
|
||||
"popRepeat": "{title} {count}回",
|
||||
"payStatePaid": "決済完了",
|
||||
"payStateRefundRequested": "返金申請",
|
||||
"payStateRefundApproved": "返金承認",
|
||||
"payStateRefunded": "返金完了",
|
||||
"payStateRefundRejected": "返金却下",
|
||||
"pushTopicEssentialDesc": "アカウント・決済・メンテナンスのお知らせです。オフにできません。",
|
||||
"pushTopicFandomEventDesc": "イクリプス・キャンペーン・クエスト・アーティストホットタイムのお知らせです。",
|
||||
"pushTopicBriefingDesc": "惑星の一日をまとめたブリーフィングカードをお届けします。",
|
||||
"pushTopicCommunityDesc": "自分の投稿へのコメント・いいね・回答の通知です。",
|
||||
"pushTopicMarketingDesc": "イベント・特典情報を受け取ります。同意した場合のみオンになります。",
|
||||
"inquiryPending": "受付",
|
||||
"inquiryInProgress": "対応中",
|
||||
"inquiryAnswered": "回答済み",
|
||||
"photoBadType": "JPG・PNG・WEBP・GIF画像のみアップロードできます。",
|
||||
"photoTooBig": "写真の容量が10MBを超えています。もっと小さい写真を選んでください。",
|
||||
"photoUnreadable": "写真を読み込めませんでした。もう一度選んでください。",
|
||||
"myListInside": "マイインサイド",
|
||||
"myListInsideEmpty": "まだ投稿したインサイドはありません。",
|
||||
"myListCampaign": "マイキャンペーン",
|
||||
"myListCampaignEmpty": "まだ作成したキャンペーンはありません。",
|
||||
"sortLatest": "新着順",
|
||||
"sortPop": "POP順",
|
||||
"sortSolved": "採択済み",
|
||||
"sortAchieved": "達成済み",
|
||||
"payNotFound": "決済履歴が見つかりません。",
|
||||
"payItemFallback": "決済商品",
|
||||
"payMethodInApp": "アプリ内購入",
|
||||
"unknownUser": "不明なユーザー",
|
||||
"blockSelf": "自分をブロックすることはできません。",
|
||||
"inquiryFallback": "お問い合わせ",
|
||||
"photoRejectedServer": "写真をアップロードできません。形式と容量をご確認ください。",
|
||||
"photoUploadFailed": "写真のアップロードに失敗しました。しばらくしてから再度お試しください。",
|
||||
"focusClaimed": "今日のFocus報酬 +{pop} 貢献POPを受け取りました。",
|
||||
"focusActive": "広告なし集中モード利用中",
|
||||
"focusUntil": "{date}まで · 今日の分は今日のみ受け取れます",
|
||||
"focusClaimedToday": "本日受取済み",
|
||||
"focusClaimToday": "今日の報酬を受け取る",
|
||||
"storeConnectFailed": "ストアに接続できませんでした。しばらくしてから再度お試しください。",
|
||||
"payRestore": "購入を復元",
|
||||
"payEmpty": "決済履歴はありません。",
|
||||
"payRefundGuide": "消費型商品:購入後7日以内かつ未使用の商品は返金できます。一部でも使用した商品は返金対象外です。\nサブスク商品:決済後24時間以内に特典(追加報酬など)を未使用の場合、返金できます。\n返金承認後7〜14日以内に返金が完了します。",
|
||||
"payDetailTitle": "決済履歴の詳細",
|
||||
"payNumber": "決済番号",
|
||||
"payDate": "決済日時",
|
||||
"payMethod": "決済方法",
|
||||
"payStatus": "決済状況",
|
||||
"payAmount": "決済金額",
|
||||
"payRefundInfo": "返金のご案内",
|
||||
"payRefundApply": "返金を申請",
|
||||
"payRefundUnavailable": "返金不可",
|
||||
"refundReasonMistake": "誤って決済しました",
|
||||
"refundReasonExpectation": "サービスが期待と違いました",
|
||||
"refundReasonError": "エラーで利用できませんでした",
|
||||
"refundReasonDuplicate": "二重に決済しました",
|
||||
"refundTitle": "返金",
|
||||
"refundSubmit": "申請",
|
||||
"refundReason": "返金理由",
|
||||
"refundConfirm": "本当に返金を申請しますか?",
|
||||
"myPop": "マイPOP",
|
||||
"commonNoHistory": "履歴はありません。",
|
||||
"nicknameEdit": "ニックネーム変更",
|
||||
"nicknameReserved": "運営者と誤解される名前は使用できません。",
|
||||
"nicknameTaken": "すでに使われているニックネームです。",
|
||||
"albumItems": "アルバム項目",
|
||||
"takePhoto": "写真を撮影",
|
||||
"pickFromAlbum": "アルバムから選択",
|
||||
"tierGuide": "ティア案内",
|
||||
"statSteps": "累計歩数",
|
||||
"statContribution": "累計貢献POP",
|
||||
"statQuests": "完了クエスト",
|
||||
"shareInstagram": "Instagram",
|
||||
"shareTiktok": "TikTok",
|
||||
"shareCopyLink": "リンクをコピー",
|
||||
"inquiryListTitle": "1:1 お問い合わせ",
|
||||
"inquiryEmpty": "お問い合わせはありません。",
|
||||
"inquiryWrite": "お問い合わせを作成",
|
||||
"inquiryDetail": "お問い合わせ詳細",
|
||||
"commonEdit": "編集",
|
||||
"inquiryAnswer": "回答",
|
||||
"inquiryNew": "新規お問い合わせ",
|
||||
"inquiryEdit": "お問い合わせを編集",
|
||||
"commonTitle": "タイトル",
|
||||
"commonTitleHint": "タイトルを入力してください。",
|
||||
"inquiryBody": "お問い合わせ内容",
|
||||
"inquiryBodyHint": "お問い合わせ内容を入力してください。",
|
||||
"inquiryBodyHelper": "10文字以上書いていただくと、より早く対応できます。",
|
||||
"inquirySubmitFailed": "お問い合わせを受け付けられませんでした。{reason}",
|
||||
"inquiryEditFailed": "お問い合わせを修正できませんでした。{reason}",
|
||||
"inquirySubmitted": "お問い合わせを受け付けました。",
|
||||
"inquiryEdited": "お問い合わせを修正しました。",
|
||||
"blockedEmpty": "ブロックしたアカウントはありません。",
|
||||
"unblock": "ブロック解除",
|
||||
"unblockFailed": "ブロックを解除できませんでした。{reason}",
|
||||
"tierInfoTitle": "TIER情報",
|
||||
"tierPromptPrefix": "惑星・星雲への貢献で\n",
|
||||
"tierPromptRank": "ランク",
|
||||
"tierPromptSuffix": "を上げよう",
|
||||
"tierPassportNote": "Passport Tierはレベルとは別のカード外観の等級で、下がることはありません。",
|
||||
"tierTotalContribution": "累計貢献",
|
||||
"notifKindEclipse": "イクリプス",
|
||||
"notifKindItemUsed": "アイテム使用",
|
||||
"notifKindLike": "いいね",
|
||||
"notifKindComment": "コメント",
|
||||
"notifKindCampaign": "キャンペーン達成",
|
||||
"notifKindQuest": "クエスト完了",
|
||||
"notifTargetPost": "投稿",
|
||||
"notifTargetCampaign": "キャンペーン",
|
||||
"notifTargetQuest": "クエスト詳細",
|
||||
"dayToday": "今日",
|
||||
"dayYesterday": "昨日",
|
||||
"notifEmpty": "通知はありません。",
|
||||
"notifGoTo": "{target}へ移動します。",
|
||||
"stationOfficial": "公式",
|
||||
"stationShop": "販売店",
|
||||
"stationTicket": "チケット",
|
||||
"stationMedia": "メディア",
|
||||
"stationLoadFailed": "STATIONを読み込めませんでした",
|
||||
"commonAll": "すべて",
|
||||
"stationSuggest": "販売店を提案",
|
||||
"stationEmpty": "まだ登録されたリンクはありません。\n惑星に参加するとキュレーションが表示されます。",
|
||||
"stationCategoryEmpty": "この分類にはまだリンクがありません。",
|
||||
"stationSuggestLater": "提案の受付はサーバー連携後に開始します。",
|
||||
"commonSubmit": "提出",
|
||||
"commonName": "名前",
|
||||
"commonNameRequired": "名前を入力してください。",
|
||||
"commonUrl": "URL",
|
||||
"commonUrlRequired": "URLを入力してください。",
|
||||
"commonUrlHttps": "httpsで始まるURLを入力してください。",
|
||||
"commonCategory": "分類",
|
||||
"stationReason": "おすすめ理由(任意)",
|
||||
"stationSuggestNote": "ご提案のリンクは運営の審査を経て登録されます。審査結果は通知でお知らせします。",
|
||||
"heroGiftUnavailable": "このギフトは現在受け取れません。",
|
||||
"heroGiftAlready": "すでに参加したギフトです。POP履歴をご確認ください。",
|
||||
"heroGiftRelogin": "再度ログインしてから参加してください。",
|
||||
"heroGiftNoPermission": "このギフトに参加する権限がありません。",
|
||||
"heroGiftHowTo": "スマホを振るか下のボタンを押して、光エネルギーのギフトに参加しましょう。",
|
||||
"heroGiftPopNote": "受け取れるPOPは残りのギフトと1人あたりの上限で決まります。",
|
||||
"heroGiftReceived": "{total} POP 受取確認",
|
||||
"heroGiftUnknown": "支給結果を確認できませんでした。再度ご確認ください。",
|
||||
"heroGiftNone": "今参加できるギフトはありません。",
|
||||
"heroGiftConfirmed": "参加したギフトの受取を確認しました。",
|
||||
"heroGiftChecking": "支給を確認中…",
|
||||
"heroGiftRetry": "同じギフトを再確認",
|
||||
"heroGiftTap": "振る代わりにボタンで参加",
|
||||
"rankMyPlanet": "マイ惑星の{metric}ランキング",
|
||||
"rankPlanetEmpty": "まだ集計された惑星ランキングはありません",
|
||||
"rankScopeLive": "リアルタイム",
|
||||
"rankScopeCountry": "国別",
|
||||
"rankMine": "私の{metric}ランキング",
|
||||
"rankMyCountry": "自国の{metric}ランキング",
|
||||
"rankEmpty": "まだ集計されたランキングはありません",
|
||||
"rankScope": "{scope}ランキング",
|
||||
"rankLoadFailed": "ランキングを読み込めませんでした",
|
||||
"rankMeSuffix": "{name}(自分)",
|
||||
"reportDefamation": "アーティストへの誹謗・名誉毀損",
|
||||
"reportInappropriate": "不適切なコンテンツ",
|
||||
"reportSpam": "スパム・営利目的の広告",
|
||||
"reportPrivacy": "個人情報の露出・なりすまし",
|
||||
"reportTitle": "通報する",
|
||||
"reportSubmit": "通報",
|
||||
"reportAuthor": "投稿者",
|
||||
"reportReasonLabel": "通報理由",
|
||||
"reportConfirm": "この投稿を通報しますか?\n受付後は取り消せません。",
|
||||
"blockMemberConfirm": "この会員のすべての投稿をブロックしますか?",
|
||||
"loungeMembersOnly": "この惑星のファンダム会員のみ投稿・コメント・いいねができます。",
|
||||
"loungeFailed": "処理できませんでした。しばらくしてから再度お試しください。",
|
||||
"loungeTitle": "ファンダムラウンジ",
|
||||
"likeCancel": "いいね取り消し",
|
||||
"like": "いいね",
|
||||
"commentDeleteConfirm": "コメントを削除しますか?",
|
||||
"commonShare": "共有",
|
||||
"commonEditAction": "編集する",
|
||||
"commonDeleteAction": "削除する",
|
||||
"commonBlockAction": "ブロックする",
|
||||
"commentFirst": "最初のコメントを残してください。",
|
||||
"postDeleteConfirm": "この投稿を削除しますか?",
|
||||
"reportReceived": "通報を受け付けました。",
|
||||
"commonDelete": "削除",
|
||||
"commentHint": "コメントを入力してください。",
|
||||
"commonSend": "送信",
|
||||
"postWrite": "投稿する",
|
||||
"postEdit": "投稿を編集",
|
||||
"loungeHint": "ファンダムラウンジに自由に投稿してみましょう。",
|
||||
"photoRemove": "写真{n}を削除",
|
||||
"camera": "カメラ",
|
||||
"album": "アルバム",
|
||||
"historyTitle": "PASSPORT · 記録",
|
||||
"historyMine": "自分の活動",
|
||||
"historyEvolution": "Stage {from} → {to} 進化",
|
||||
"historyHibernation": "惑星休眠",
|
||||
"historyWakeUp": "惑星が目覚めた",
|
||||
"historyZap": "ZAP貢献",
|
||||
"historyQuest": "Quest報酬",
|
||||
"historyAdBonus": "広告追加報酬",
|
||||
"historyFunding": "Campaign貢献",
|
||||
"historyGrowthPop": "成長に{amount} POP反映",
|
||||
"historyJoined": "この進化に参加しました",
|
||||
"historyLoadFailed": "記録を読み込めませんでした。",
|
||||
"historyMineEmpty": "まだ記録された活動はありません。",
|
||||
"historyFandomEmpty": "まだ記録されたファンダムの歴史はありません。",
|
||||
"historyMore": "以前の記録をもっと見る",
|
||||
"countryName": "{code, select, KR{韓国} JP{日本} US{アメリカ} CN{中国} TW{台湾} HK{香港} TH{タイ} VN{ベトナム} ID{インドネシア} PH{フィリピン} MY{マレーシア} SG{シンガポール} IN{インド} GB{イギリス} FR{フランス} DE{ドイツ} ES{スペイン} BR{ブラジル} MX{メキシコ} CA{カナダ} AU{オーストラリア} ZZ{未指定} other{-}}",
|
||||
"loungeMaxPhotos": "写真は{count}枚までアップロードできます。",
|
||||
"commonLoadFailed": "読み込めませんでした",
|
||||
"planetPassportHistory": "PASSPORT · ファンダムの歴史",
|
||||
"planetNamed": "{name} 惑星",
|
||||
"blockNebulaConfirm": "この星雲をブロックしますか?",
|
||||
"blockPlanetConfirm": "この惑星をブロックしますか?",
|
||||
"blocked": "ブロックしました。",
|
||||
"planetLastUpdated": "最終更新 {time}",
|
||||
"planetContribution": "貢献状況",
|
||||
"planetNebulaGrowth": "星雲成長率",
|
||||
"planetLiveRanking": "リアルタイム貢献ランキング",
|
||||
"planetNoContributors": "まだ貢献者がいません",
|
||||
"planetContributeCta": "貢献してランキングに入ろう!",
|
||||
"planetOverallRank": "全体{rank}位",
|
||||
"planetMyContribution": "自分の貢献度",
|
||||
"planetTallying": "集計準備中",
|
||||
"planetTopPercent": "上位{percent}%",
|
||||
"planetHideMember": "この会員の投稿をすべて非表示",
|
||||
"reportReasonUnknown": "通報理由を確認できません。",
|
||||
"reportAlready": "すでに通報した投稿です。",
|
||||
"blockUnavailable": "ブロックできない投稿者です。",
|
||||
"commonMe": "自分",
|
||||
"planetSizeMedium": "中規模ファンダム",
|
||||
"planetPerHead": "1人あたり火力:{value}",
|
||||
"planetJoined": "参加中",
|
||||
"planetJoin": "参加する",
|
||||
"questDetails": "詳細",
|
||||
"questInProgress": "進行中",
|
||||
"questTypeAttend": "出席",
|
||||
"questTypeSteps": "ウォーキング",
|
||||
"questTypePlace": "特定の場所",
|
||||
"questTypeStreaming": "ストリーミング",
|
||||
"questTypeUnverifiable": "このクエストタイプの検証方法はまだ決まっていません。",
|
||||
"questTabMine": "マイクエスト",
|
||||
"placePickPrompt": "クエストを完了する\n場所を選択してください。",
|
||||
"placeSearch": "場所を検索",
|
||||
"commonNoResults": "検索結果がありません。",
|
||||
"commonSelectDone": "選択完了",
|
||||
"adMoreChance": "もっと貢献のチャンスを手に入れよう。",
|
||||
"adWatchPrompt": "広告を視聴して追加報酬を\n受け取れます!",
|
||||
"adBonusPlaceholder": "+ 貢献POP",
|
||||
"adPartyPack": "パーティーパック",
|
||||
"adPickProduct": "商品を選ぶ",
|
||||
"adWatchBonus": "広告を見て追加報酬を受け取る",
|
||||
"adBasicReward": "基本報酬を受け取る",
|
||||
"focusMode": "広告なし集中モード",
|
||||
"pricePerMonth": "{price}/月",
|
||||
"adWarpBefore": "Warping...ファンダムとアーティストにもっと貢献できるよう、ぴったりの広告を探しています。",
|
||||
"adWarpAfter": "Warping...あなたの貢献への報酬を準備しています。",
|
||||
"adSkip": "スキップ",
|
||||
"questPreviewHint": "オプションを選んでプレビューを確認してください",
|
||||
"questNoCategory": "まだカテゴリが選択されていません",
|
||||
"questNew": "新規クエスト",
|
||||
"questCategory": "カテゴリ",
|
||||
"questName": "クエスト名",
|
||||
"questAction": "詳細アクション",
|
||||
"questPickPlace": "目標の場所を選んでください",
|
||||
"questGoalValue": "目標値",
|
||||
"questParticipants": "参加人数",
|
||||
"questRewardPop": "完了報酬POP",
|
||||
"questCreateConfirm": "この内容で作成しますか?",
|
||||
"questCreateSummary": "{title} · 目標 {goal} · 報酬 {reward} POP",
|
||||
"commonKeepEditing": "さらに編集",
|
||||
"questCreate": "作成",
|
||||
"questNamed": "{title} クエスト",
|
||||
"questDailyHelp": "毎日惑星をサポート",
|
||||
"questTodayMust": "今日やるべきクエストです",
|
||||
"questTabEmpty": "このタブにはまだクエストがありません。",
|
||||
"questCreateNotReady": "クエスト作成は報酬原資の預け入れと同時に開始する機能です。準備中です。",
|
||||
"questNoBaseReward": "基本報酬の記録が見つからないため、広告追加報酬を受け取れません。",
|
||||
"questNoSteps": "歩数がまだ取得されていません。少し歩いてから再度お試しください。",
|
||||
"questNoPlace": "このクエストに認証する場所が登録されていません。",
|
||||
"questGoal": "目標",
|
||||
"questReward": "報酬",
|
||||
"questPopGrant": "POP支給",
|
||||
"commonChecking": "確認中…",
|
||||
"questMine": "自分が作成したクエストです。参加者は{count}人です。",
|
||||
"questStopConfirm": "クエストを中断しますか?",
|
||||
"questStopBody": "これまでの進行状況がリセットされ、最初の状態に戻ります。",
|
||||
"questStop": "中断",
|
||||
"adNotFinished": "広告を最後まで見なかったため追加報酬はありません。基本報酬はそのまま受け取れます。",
|
||||
"openSettings": "設定を開く",
|
||||
"permStepsDenied": "歩数の権限がオフです。設定で「身体活動」を許可してください。",
|
||||
"permStepsNeeded": "歩数を数えるには身体活動の権限が必要です。",
|
||||
"permLocationOff": "端末の位置情報がオフです。オンにして再度お試しください。",
|
||||
"permLocationNeeded": "訪問を確認するには位置情報の権限が必要です。",
|
||||
"permLocationDenied": "位置情報の権限がオフです。設定で位置情報へのアクセスを許可してください。",
|
||||
"questRewardReceived": " 受け取りました!",
|
||||
"questAdBonus": "広告追加報酬 +{pop} POP",
|
||||
"questParticipantsCount": "参加人数:{count}",
|
||||
"questParticipantsCap": "参加人数:{count} / {cap}",
|
||||
"nebulaCreateFailed": "星雲を作成できませんでした。しばらくしてから再度お試しください。",
|
||||
"nebulaAlreadyToday": "今日はすでに星雲を作成しました。",
|
||||
"nebulaCreatedKeepPrimary": "星雲を作成しました。メイン惑星は前回の変更から30日経過後に変更できるため、そのままです。",
|
||||
"fandomFounderLeaveBlocked": "惑星が誕生した後に脱退できます。",
|
||||
"sortPopular": "人気順",
|
||||
"campaignSortContributing": "貢献中",
|
||||
"campaignStatusReview": "運営が審査中のキャンペーンです。",
|
||||
"campaignStatusRejected": "審査で差し戻されました。登録料は返還しました。",
|
||||
"campaignStatusWithdrawn": "作成者が取り下げたキャンペーンです。",
|
||||
"campaignStatusCancelled": "運営上の理由でキャンセルされました。登録料と支援POPを返還しました。",
|
||||
"rankWindowDaily": "日間",
|
||||
"rankWindowWeekly": "週間",
|
||||
"campaignNotFound": "キャンペーンが見つかりません。",
|
||||
"campaignMediaMain": "メイン画像",
|
||||
"campaignLoadFailed": "キャンペーンを読み込めませんでした。",
|
||||
"campaignTabInfo": "キャンペーン情報",
|
||||
"campaignTabBoard": "応援掲示板 {count}",
|
||||
"campaignWithdraw": "キャンペーン取り下げ",
|
||||
"campaignContributeAction": "貢献する",
|
||||
"campaignFundingStatus": "現在の支援状況",
|
||||
"campaignSupporting": "応援中",
|
||||
"campaignTimeLeft": "残り!",
|
||||
"campaignReport": "結果報告",
|
||||
"campaignReportPhotos": "実施写真",
|
||||
"campaignBudget": "予算の使用内訳",
|
||||
"campaignTotal": "合計",
|
||||
"campaignReceipts": "領収書",
|
||||
"campaignDaysLeft": "{days}日 {time}",
|
||||
"campaignWithdrawTitle": "キャンペーンを取り下げますか?",
|
||||
"campaignWithdrawRefund": "公開前の取り下げのため、登録料 {fee} POP を支払った通貨のまま返還します。",
|
||||
"campaignWithdrawNoRefund": "すでに公開されたキャンペーンのため、登録料 {fee} POP は返還されません。",
|
||||
"campaignWithdrawConfirm": "取り下げる",
|
||||
"shareCopied": "共有テキストをコピーしました。",
|
||||
"campaignContributePop": "貢献POPの設定",
|
||||
"campaignEmptyContributing": "まだ貢献したキャンペーンはありません。",
|
||||
"campaignEmpty": "登録されたキャンペーンはありません。",
|
||||
"campaignNew": "新しいキャンペーン",
|
||||
"campaignMyRanking": "私の貢献ランキング",
|
||||
"campaignWindowRanking": "{window}ランキング",
|
||||
"campaignSearchHint": "キャンペーン検索",
|
||||
"campaignNoRecentSearches": "最近の検索はありません",
|
||||
"searchRemoveTerm": "{term}を削除",
|
||||
"campaignNoResults": "検索結果がありません",
|
||||
"shareTopicCampaign": "キャンペーン",
|
||||
"sharePlanetLine": "{brand} · {name} 惑星",
|
||||
"campaignGaugeLabel": "キャンペーン貢献率",
|
||||
"campaignPeriodTitle": "キャンペーン期間を選択してください",
|
||||
"campaignPeriodMax": "期間は最大1年まで選択できます",
|
||||
"campaignYearMonth": "{year}年{month}月",
|
||||
"campaignWeekdays": "日,月,火,水,木,金,土",
|
||||
"campaignPeriodRange": "{startMonth}月{startDay}日 ~ {endMonth}月{endDay}日",
|
||||
"campaignPeriodSelect": "期間を選択",
|
||||
"calendarPrevMonth": "前の月",
|
||||
"calendarNextMonth": "次の月",
|
||||
"campaignShareTitle": "キャンペーンを共有",
|
||||
"campaignPickMedia": "画像/動画を選択",
|
||||
"campaignTitleHint": "キャンペーンのタイトルを入力してください。",
|
||||
"campaignBodyHint": "キャンペーンの説明を入力してください。",
|
||||
"campaignReviewNote": "投稿後、運営の審査を経て支援が始まります。",
|
||||
"campaignSupportType": "支援の種類を選択",
|
||||
"campaignPeriod": "キャンペーン期間",
|
||||
"campaignDateSelect": "日付を選択",
|
||||
"campaignGoalPop": "目標貢献POP",
|
||||
"campaignSubmitTitle": "この内容で投稿しますか?",
|
||||
"campaignSubmitBody": "{title} · 目標 {goal} POP · {days}日\n登録料 {fee} POP が差し引かれ、運営の審査後に支援が始まります。",
|
||||
"campaignSubmit": "投稿する",
|
||||
"campaignCreateFailed": "キャンペーンを投稿できませんでした。",
|
||||
"campaignNeedsQuests": "クエスト完了 {done}/{need} — あと{left}回完了するとキャンペーンを開けます。",
|
||||
"campaignPendingTitle": "審査待ちです",
|
||||
"campaignPendingBody": "運営の審査が終わると支援が始まり、一覧に表示されます。",
|
||||
"insideRefundPending": "採択期限が過ぎたため、懸賞金は返金される予定です。",
|
||||
"insideRefundDone": "採択期限が過ぎたため、懸賞金は返金されました。",
|
||||
"insideRefundChecking": "採択期限が過ぎたため、懸賞金の返金を確認しています。",
|
||||
"insideErrPopShort": "POPが足りず懸賞金を設定できませんでした。",
|
||||
"insideErrSelfAccept": "自分の回答は採択できません。",
|
||||
"insideErrAlreadyAccepted": "すでに採択済みの質問です。採択は変更できません。",
|
||||
"insideErrNotOwner": "質問者のみ採択できます。",
|
||||
"commonCheckConnection": "接続を確認して、もう一度お試しください。",
|
||||
"insideAnswerLater": "コメントはサーバー連携後に利用できます。",
|
||||
"insideAcceptConfirm": "このコメントを採択しますか?\n懸賞金 {pop} POP が渡され、採択は取り消せません。",
|
||||
"insideAccept": "採択",
|
||||
"insideMore": "もっと見る",
|
||||
"insideFirstAnswer": "最初のコメントを残してください。",
|
||||
"insideAcceptDone": "採択済み",
|
||||
"insideAcceptAction": "採択する",
|
||||
"insideAnswerHint": "コメントを入力してください。",
|
||||
"insideLoadFailed": "INSIDEを読み込めませんでした",
|
||||
"insideEmpty": "まだ質問はありません。",
|
||||
"insideWrite": "INSIDEを作成",
|
||||
"insideSearchHint": "INSIDEのタイトル・内容",
|
||||
"insideBodyHint": "ファンダムと自由に質問をやり取りしてみましょう。",
|
||||
"insideTitleRequired": "タイトルを入力してください。",
|
||||
"insideBodyRequired": "内容を入力してください。",
|
||||
"insideBountySet": "懸賞金の設定",
|
||||
"insideBountyHint": "{min}以上に設定してください。",
|
||||
"insideAttachLater": "画像の添付はサーバー連携後に利用できます。",
|
||||
"insideBountyRequired": "懸賞金を入力してください。",
|
||||
"insideNumberOnly": "数字で入力してください。",
|
||||
"insideBountyMin": "懸賞金は {min} POP 以上である必要があります。",
|
||||
"insideBountyOverBalance": "保有POPを超えることはできません。",
|
||||
"questTypeTimeAttack": "緊急タイムアタック",
|
||||
"questTimeLeft": "残り時間",
|
||||
"questTimeAttackClosed": "締め切り",
|
||||
"questTimeAttackLeft": "残り {time}",
|
||||
"popReasonQuestTimeAttack": "緊急タイムアタック",
|
||||
"briefingShareCopied": "共有テキストとリンクをコピーしました。",
|
||||
"shareCopyText": "テキストをコピー",
|
||||
"briefingShareTextCopied": "共有テキストをコピーしました。",
|
||||
"questAlreadyRewarded": "受け取り済みの報酬です(+{pop} POP)。",
|
||||
"questCatHealth": "健康",
|
||||
"questCatPlanet": "惑星アクティビティ",
|
||||
"questCatCommunity": "コミュニティ",
|
||||
"questActShake": "シェイク",
|
||||
"questTypeVote": "投票参加",
|
||||
"questTypeSns": "SNS",
|
||||
"questActivity": "アクティビティ",
|
||||
"questNoCreatable": "現在作成できるクエスト活動はありません。",
|
||||
"questDescriptionOptional": "説明(任意)",
|
||||
"questLinkUrl": "リンクURL",
|
||||
"questLinkCheck": "リンクを確認",
|
||||
"questLinkInvalid": "正しいリンクではありません。URLを確認してください。",
|
||||
"questLinkPreviewFailed": "リンク情報を取得できませんでした。タイトルを入力してください。",
|
||||
"questLinkTitle": "リンクのタイトル",
|
||||
"questEndDate": "終了日",
|
||||
"questEndDefault": "公開から24時間",
|
||||
"questEndDateValue": "{date} 23:59:59 締切",
|
||||
"questPoolHelper": "合計 {pool} POP 預け入れ・手数料 {fee} POP",
|
||||
"questCreateCost": "{title}\n報酬 {reward} POP × {count}人 = {pool} POP\n手数料 {fee} POP(公開時に消滅)\n合計 {total} POP が差し引かれます。",
|
||||
"questNoReward": "報酬のない参加クエストです。",
|
||||
"questDeadline": "締切",
|
||||
"questRemainingHours": "残り{hours}時間",
|
||||
"questEnded": "終了",
|
||||
"questAuthorNote": "作成者の説明",
|
||||
"questRepeatOnce": "1人1回参加",
|
||||
"questRepeatEvery": "{hours}時間おき・最大{count}回",
|
||||
"questSubmit": "認証を提出",
|
||||
"questResubmit": "補完して再提出",
|
||||
"questEvidenceUrl": "認証リンク(任意)",
|
||||
"questEvidenceText": "認証内容",
|
||||
"questSubmitted": "提出しました。審査後に報酬が支払われます。",
|
||||
"questReviewPending": "審査中です。承認されると報酬が支払われます。",
|
||||
"questReviewDelayed": "審査が遅れています。運営チームが確認中です。",
|
||||
"questApprovedPaying": "承認されました。報酬を支払っています。",
|
||||
"questRejected": "差し戻し: {reason}",
|
||||
"questResubmitAllowed": "補完して再提出できます。",
|
||||
"questResubmitNotAllowed": "この提出は再提出できません。",
|
||||
"questReviewButton": "審査中",
|
||||
"questRejectedButton": "差し戻し済み",
|
||||
"questReviewQueue": "審査待ち {count}件",
|
||||
"questApprove": "承認",
|
||||
"questReject": "差し戻す",
|
||||
"questRejectReason": "差し戻し理由",
|
||||
"questAllowResubmit": "再提出を許可",
|
||||
"questEdit": "タイトル・説明を編集",
|
||||
"questEditNotice": "公開後はタイトルと説明のみ編集でき、履歴が残ります。",
|
||||
"popReasonQuestUgc": "クエスト報酬",
|
||||
"popReasonQuestFee": "クエスト作成手数料",
|
||||
"questFreeDailyCap": "報酬なしのクエストは1日{cap}件まで作成できます。",
|
||||
"popReasonQuestPoolDeposit": "クエスト報酬の預け入れ",
|
||||
"popReasonQuestPoolReturn": "クエスト報酬の返還",
|
||||
"campaignCommentNeedNickname": "先にニックネームを決めてください",
|
||||
"campaignCommentSetNickname": "ニックネームを決める"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,791 @@
|
||||
{
|
||||
"@@locale": "zh",
|
||||
"commonConfirm": "确认",
|
||||
"commonCancel": "取消",
|
||||
"commonClose": "关闭",
|
||||
"commonSelect": "选择",
|
||||
"commonRetry": "重试",
|
||||
"commonView": "查看",
|
||||
"commonPrev": "上一步",
|
||||
"commonDone": "完成",
|
||||
"commonStart": "开始",
|
||||
"commonNotYet": "{where}页面将在第二次实现确认(2026-09-10)中加入。",
|
||||
"languageSettingsTitle": "语言设置",
|
||||
"authLogin": "登录",
|
||||
"authSignup": "注册",
|
||||
"authFirstTime": "第一次使用FANUZU?",
|
||||
"authAlreadyMember": "已经注册了吗?",
|
||||
"authLoginHeadline": "用最方便的方式\n登录FANUZU!",
|
||||
"authSignupHeadline": "用最方便的方式\n注册FANUZU!",
|
||||
"authProviderLogin": "{provider, select, apple{通过Apple登录} google{通过Google登录} kakao{通过Kakao登录} line{通过LINE登录} other{通过邮箱登录}}",
|
||||
"authProviderSignup": "{provider, select, apple{通过Apple注册} google{通过Google注册} kakao{通过Kakao注册} line{通过LINE注册} other{通过邮箱注册}}",
|
||||
"authNotConfiguredTitle": "{name}登录准备中",
|
||||
"authNotConfiguredBody": "{name}登录将在客户提供密钥后开放。\n\n所需项目\n· {items}",
|
||||
"authNotConfiguredNotice": "{name}登录尚在准备中。\n所需项目: {items}",
|
||||
"authResetNeedEmail": "请先输入接收重置邮件的邮箱。",
|
||||
"authResetSent": "已向该地址发送重置说明。",
|
||||
"termsTitle": "使用条款",
|
||||
"termsAgreeAll": "全部同意",
|
||||
"termsService": "服务条款(必选)",
|
||||
"termsLocation": "位置服务条款(必选)",
|
||||
"termsAge": "本人已满14周岁。(必选)",
|
||||
"termsMarketing": "同意接收营销信息与活动通知(可选)",
|
||||
"signupDoneTitle": "注册完成",
|
||||
"signupDoneBody": "现在可以开始粉丝活动了。\n通过各种活动培养你的粉丝星球吧。",
|
||||
"homeItemsTooltip": "道具",
|
||||
"homeNotificationsTooltip": "通知",
|
||||
"homeFandomHistory": "粉丝历史",
|
||||
"homePopularInside": "热门 K-INSIDE",
|
||||
"homeWhereBriefing": "AI简报",
|
||||
"homeWhereInsideList": "INSIDE列表",
|
||||
"homeWhereInsideDetail": "INSIDE详情",
|
||||
"homeLoadFailed": "无法加载首页",
|
||||
"homeRetryLater": "请稍后再试。",
|
||||
"myProfileCardTooltip": "个人名片",
|
||||
"myTabPlanets": "星球/星云",
|
||||
"myTabActivity": "活动",
|
||||
"myInside": "Inside",
|
||||
"myCampaign": "应援活动",
|
||||
"myShareLater": "{target}分享将在09-10版本中接入。",
|
||||
"myEditPhoto": "修改头像",
|
||||
"myEditProfile": "编辑资料",
|
||||
"settingsTitle": "设置",
|
||||
"settingsAccount": "账号",
|
||||
"settingsNickname": "昵称",
|
||||
"settingsEmail": "邮箱",
|
||||
"settingsSns": "已关联社交账号",
|
||||
"settingsPopHistory": "POP记录",
|
||||
"settingsPayments": "支付记录",
|
||||
"settingsServicePayments": "服务支付记录",
|
||||
"settingsService": "服务设置",
|
||||
"settingsPush": "推送通知设置",
|
||||
"settingsBlocked": "屏蔽设置",
|
||||
"settingsInfo": "服务信息",
|
||||
"settingsNotice": "公告",
|
||||
"settingsInquiry": "1:1咨询",
|
||||
"settingsAppVersion": "应用版本",
|
||||
"settingsLatest": "最新版本",
|
||||
"settingsLogout": "退出登录",
|
||||
"settingsLogoutConfirm": "确定要退出登录吗?",
|
||||
"settingsWithdraw": "注销账号",
|
||||
"settingsWithdrawConfirm": "确定要注销账号吗?",
|
||||
"settingsWithdrawBody": "您持有的POP将会清零且无法恢复。",
|
||||
"settingsWithdrawLater": "注销处理将在09-10版本中接入。",
|
||||
"settingsUnlinkConfirm": "确定要解除关联账号吗?",
|
||||
"settingsUnlinkBody": "仅移除该社交登录方式,账号信息不会被删除。",
|
||||
"settingsUnlink": "解除关联",
|
||||
"settingsSameEmailOnly": "只能关联与账号邮箱\n相同的社交账号。",
|
||||
"settingsLinkNotReady": "{name}关联尚在准备中。所需项目: {items}",
|
||||
"settingsConnect": "关联",
|
||||
"settingsDisconnect": "解除",
|
||||
"settingsEmailProvider": "邮箱",
|
||||
"pushDeviceOff": "设备的推送通知已关闭。",
|
||||
"pushDeviceOffHint": "请在系统设置中允许通知以接收消息。",
|
||||
"pushRequesting": "请求中…",
|
||||
"pushGoToSettings": "前往设置",
|
||||
"pushOpeningDeviceSettings": "正在前往设备设置。",
|
||||
"pushEnableInDevice": "请在设备设置中开启FANUZU通知。",
|
||||
"pushDeviceSection": "设备通知设置",
|
||||
"pushSoundVibration": "声音、振动设置",
|
||||
"pushServiceSection": "服务通知设置",
|
||||
"pushTopicEssential": "服务必需",
|
||||
"pushTopicFandomEvent": "粉丝活动",
|
||||
"pushTopicBriefing": "AI Briefing",
|
||||
"pushTopicCommunity": "社区",
|
||||
"pushTopicMarketing": "营销推广",
|
||||
"linkTitle": "{name}关联",
|
||||
"linkNotLinked": "未关联",
|
||||
"linkLinked": "已关联",
|
||||
"linkSameEmailOnly": "只能关联与账号邮箱相同的社交账号。",
|
||||
"linkUnlink": "解除关联",
|
||||
"linkLink": "关联",
|
||||
"noticeBadge": "公告",
|
||||
"noticeTitle": "公告",
|
||||
"noticeEmpty": "暂无公告。",
|
||||
"noticeDetailTitle": "公告详情",
|
||||
"translationMark": "已翻译",
|
||||
"translationReplyTo": "翻译 {name} 的评论",
|
||||
"translationWrite": "撰写翻译评论",
|
||||
"translationHint": "请输入翻译",
|
||||
"translationValid": "有效",
|
||||
"translationInvalid": "无效",
|
||||
"translationSelfMarkBlocked": "不能标记自己的翻译。",
|
||||
"translationSubmitted": "翻译已提交。通过验证后将在24小时后发放POP。",
|
||||
"translationMarked": "已标记。",
|
||||
"translationAlreadyMarked": "您已标记过该翻译。",
|
||||
"translationDuplicate": "您已为该内容提交过同一语言的翻译。",
|
||||
"translationFailed": "处理失败,请稍后再试。",
|
||||
"translationNotEligible": "仅注册满24小时的本粉丝团成员可以标记。",
|
||||
"translationDailyCap": "今天无法再提交翻译。",
|
||||
"translationInvalidTarget": "该内容无法翻译。",
|
||||
"commonRequestFailed": "无法处理请求。",
|
||||
"commonFan": "粉丝",
|
||||
"adLoadFailed": "广告加载失败,仅发放基础奖励。",
|
||||
"storeNotReady": "商店商品准备中",
|
||||
"storePending": "正在等待支付确认,确认后将自动生效。",
|
||||
"storeFailed": "支付未完成。",
|
||||
"storePurchased": "购买已生效。",
|
||||
"heroParticipate": "ZAP+ · 参与礼物",
|
||||
"heroRemainingSemantics": "HERO TIME 剩余 {minutes} 分 {seconds} 秒",
|
||||
"heroRemaining": "HERO TIME · 剩余 {time}",
|
||||
"heroClaims": "粉丝已领取礼物 {count} 次",
|
||||
"heroInProgress": "{name} · HERO TIME 进行中 →",
|
||||
"stageNames": "星云,起始,成长,同心,同行,光芒,永恒",
|
||||
"planetStageUnknown": "星球 · 无阶段信息",
|
||||
"planetHibernatingSuffix": " · 休眠中",
|
||||
"planetEclipseSuffix": " · Eclipse 进行中",
|
||||
"planetHeroSuffix": " · HERO TIME 进行中",
|
||||
"evoPlanetGrowth": "星球成长",
|
||||
"evoHeroEnd": "HERO TIME 结束",
|
||||
"evoAwakening": "星球正在苏醒",
|
||||
"evoAnomaly": "异常反应",
|
||||
"evoEnergy": "能量汇聚",
|
||||
"evoTransition": "转换",
|
||||
"evoComplete": "星球进化完成",
|
||||
"evoOpenHistory": "查看 PASSPORT 记录",
|
||||
"playNow": "立即开玩",
|
||||
"playHeadline": "来吧,去见你的粉丝圈!",
|
||||
"playBody": "创建星球,与粉丝们交流,享受愉快的追星活动...",
|
||||
"timeJustNow": "刚刚",
|
||||
"timeMinutesAgo": "{n}分钟前",
|
||||
"timeHoursAgo": "{n}小时前",
|
||||
"timeDaysAgo": "{n}天前",
|
||||
"commonMore": "更多",
|
||||
"authProviderStart": "{provider, select, email{使用邮箱开始} kakao{使用 Kakao 开始} line{使用 LINE 开始} apple{使用 Apple 开始} other{使用 Google 开始}}",
|
||||
"authProviderName": "{provider, select, email{邮箱} kakao{Kakao} line{LINE} apple{Apple} other{Google}}",
|
||||
"authLastProvider": "无法解除最后一个登录方式的关联。",
|
||||
"authLoginRequired": "请先登录。",
|
||||
"authDifferentEmail": "该账号与注册邮箱不一致。",
|
||||
"authLinkFailed": "账号关联失败。",
|
||||
"authNotLinked": "该账号未关联。",
|
||||
"authEmailUnavailable": "无法使用邮箱登录。",
|
||||
"authCheckCredentials": "请检查邮箱或密码。",
|
||||
"authSocialFailed": "社交账号登录失败。",
|
||||
"authAppleUnavailable": "此设备无法使用 Apple 登录。",
|
||||
"authEmailInUse": "该邮箱已注册。",
|
||||
"authWeakPassword": "密码太短,请至少设置 8 个字符。",
|
||||
"authInvalidEmail": "邮箱格式不正确。",
|
||||
"commonTryLater": "请稍后再试。",
|
||||
"authCredentialInUse": "该社交账号已关联其他 FANUZU 账号。",
|
||||
"authAlreadyLinked": "该账号已关联。",
|
||||
"authMethodUnavailable": "此登录方式暂不可用。",
|
||||
"authRecentLogin": "为了安全,请重新登录后再试。",
|
||||
"authOtherProviderGuide": "{email} 账号已通过其他登录方式注册。请使用首次注册时的方式登录,然后在 MY > 设置 > SNS 关联中关联此方式。",
|
||||
"authThisAccount": "此",
|
||||
"eclipseCapped": "已达参与上限,部分未计入。",
|
||||
"eclipseResultUnknown": "无法确认参与结果,请以相同请求再次确认。",
|
||||
"eclipseChecking": "正在确认参与",
|
||||
"eclipseRetrySame": "重新确认本次参与",
|
||||
"eclipseEnded": "日蚀已结束或已防御成功。",
|
||||
"eclipseTooFast": "参与过快,请稍后再试。",
|
||||
"eclipseNone": "当前没有进行中的日蚀",
|
||||
"eclipseSuccessTitle": "成功阻止了\n陨石撞击!",
|
||||
"eclipseQualified": "参与已认定",
|
||||
"eclipseFailTitle": "很遗憾,\n星球防御失败了",
|
||||
"eclipseFailBody": "未达成本轮防御目标。",
|
||||
"eclipseCancelTitle": "日蚀已取消",
|
||||
"eclipseCancelBody": "因统计错误已取消,FPI 未扣除。",
|
||||
"eclipsePendingTitle": "正在确认防御结果",
|
||||
"eclipsePendingBody": "参与时间已结束,正在等待服务器判定。",
|
||||
"eclipseShakeTimed": "秒内摇动手机\n阻止陨石撞击星球!",
|
||||
"eclipseShake": "摇动手机\n阻止陨石撞击星球!",
|
||||
"eclipseMyCount": "我的参与 {count} 次",
|
||||
"eclipsePendingSuffix": " · +{count} 待发送",
|
||||
"eclipseSendingSuffix": " · 发送中",
|
||||
"eclipseNoPopNote": "不会消耗 POP。有效摇动 {count} 次以上即可认定为参与者。",
|
||||
"eclipseTapToJoin": "不方便摇动?点击参与",
|
||||
"eclipseTimeLeft": "日蚀剩余时间:",
|
||||
"eclipseSeconds": " {seconds} 秒",
|
||||
"eclipseBannerDefended": "防御成功 — {name} FPI 未减少",
|
||||
"eclipseBannerActive": "日蚀发动 — {name} 侵蚀 {rate}%",
|
||||
"eclipseBannerDefense": "有效参与 {count} / {target} 人",
|
||||
"timeHoursMinutesLeft": "剩余 {hours} 小时 {minutes} 分",
|
||||
"timeMinutesLeft": "剩余 {minutes} 分",
|
||||
"liveEclipseDefense": "[LIVE] {name} 参与了日蚀防御!(+{count})",
|
||||
"liveHotTime": "[LIVE] {name} 的艺人热力时刻开始了!",
|
||||
"liveHeroGift": "[LIVE] {name} 的英雄礼物到了!",
|
||||
"liveCampaign": "[LIVE] {name} 支持了一个活动!",
|
||||
"galaxySetPrimaryConfirm": "要将所选星球设为默认星球吗?",
|
||||
"galaxyLoadFailed": "无法加载银河",
|
||||
"commonSearch": "搜索",
|
||||
"galaxyMyPlanets": "我的星球",
|
||||
"galaxyMap": "银河地图",
|
||||
"nebulaCreate": "创建星云",
|
||||
"commonRecheck": "重新确认",
|
||||
"zapApplying": "正在计入贡献",
|
||||
"zapNeedsCheck": "需确认贡献结果",
|
||||
"zapNoPlanet": "你还没有所属星球",
|
||||
"commonContribute": "贡献",
|
||||
"commonItem": "道具",
|
||||
"galaxyDefended": "防御成功 · FPI 未减少",
|
||||
"galaxyErosion": "侵蚀 {rate}% · 防御 {progress}%",
|
||||
"galaxyOnlineMembers": "在线 {online} 人 · 成员 {members} 人",
|
||||
"galaxySearchHint": "按粉丝圈名称搜索",
|
||||
"commonRecentSearches": "最近搜索",
|
||||
"commonClearAll": "全部删除",
|
||||
"commonNoRecentSearches": "没有最近搜索。",
|
||||
"galaxyNoHit": "未找到该粉丝圈。\n请再次确认名称。",
|
||||
"galaxyMembers": "成员 {count} 人",
|
||||
"galaxyPlanetRank": "{name} 星球 第 {rank} 名",
|
||||
"galaxyMapOpen": "打开银河地图",
|
||||
"zapNotEnoughPop": "POP 不足",
|
||||
"zapFailNoPop": "POP 不足,无法贡献。",
|
||||
"zapFailLedger": "余额与获取记录不符,已暂停贡献。",
|
||||
"zapFailRetry": "请检查网络并重新确认本次贡献。",
|
||||
"zapDone": "贡献完成",
|
||||
"zapDonePop": "已贡献 {pop} POP",
|
||||
"growthFounders": "创始成员",
|
||||
"growthNebulaGoalPassed": "目标期限已过 · 满足条件即可诞生行星",
|
||||
"growthFinalStage": "最终阶段",
|
||||
"growthPromotionPending": "等待晋级确认",
|
||||
"growthCheckConditions": "查看成长条件",
|
||||
"growthConditionChecking": "正在确认{name}条件",
|
||||
"growthConditionMet": "已满足{name}条件",
|
||||
"growthConditionUnmet": "未满足{name}条件",
|
||||
"nebulaHeadline": "为我们的粉丝星球取个名字吧。",
|
||||
"nebulaBody": "创始成员在 {days} 天内达成目标 POP,\n即可诞生为正式星球!\n参与进化的成员将获得纪念徽章。",
|
||||
"nebulaNameTaken": "该名称已被使用。",
|
||||
"nebulaNameHint": "粉丝圈名称",
|
||||
"nebulaGoal": "目标 ",
|
||||
"nebulaFounders": " / 创始成员",
|
||||
"nebulaGoalSemantics": "目标 {pop} POP · 创始成员 {members} 人 · 固定 {days} 天",
|
||||
"commonMyPop": "我的 POP:",
|
||||
"commonChange": "更改",
|
||||
"planetShape": "形态 {n}",
|
||||
"planetBgColor": "选择背景色",
|
||||
"planetColorSelect": "选择星球颜色",
|
||||
"planetTypeSelect": "选择星球类型",
|
||||
"commonNext": "下一步",
|
||||
"planetCreateFailed": "无法创建星球,请稍后再试。",
|
||||
"planetCreateConfirm": "要用所选选项\n创建星球吗?",
|
||||
"commonCreate": "创建",
|
||||
"briefingOpenFailed": "无法打开原文。",
|
||||
"campaignAchieved": "已达成 {percent}%",
|
||||
"campaignView": "查看活动",
|
||||
"growthFinalReached": "已到达最终阶段",
|
||||
"zapRetrySame": "重新确认本次贡献",
|
||||
"heroOwnerSuffix": "的",
|
||||
"heroStartPrompt": "创建或加入星球,\n开启粉丝宇宙吧!",
|
||||
"heroNebulaPrompt": "用活动获得的 POP\n为星云做贡献吧!",
|
||||
"heroRecentContribution": "凭借 {pop} POP 的贡献\n正在稳步成长。",
|
||||
"heroGrowingTogether": "一起培育的粉丝星球",
|
||||
"heroPopProgress": "POP 进度",
|
||||
"heroNebula": "星云",
|
||||
"heroNebulaTap": "点击星云,\n让它诞生为星球吧!",
|
||||
"heroCreateNebula": "创建星球(星云)",
|
||||
"heroJoinPlanet": "加入星球",
|
||||
"heroCreateOrJoin": "创建或加入星球",
|
||||
"insideAnswers": "回答 {count}",
|
||||
"itemJoinFirst": "加入星球后才能发送英雄礼物。",
|
||||
"itemBuyNotReady": "道具购买功能准备中。",
|
||||
"itemUseNotReady": "道具使用功能准备中。",
|
||||
"payOpenFailed": "无法打开支付窗口。",
|
||||
"itemBuy": "购买道具",
|
||||
"itemSelect": "选择道具",
|
||||
"commonComingSoon": "准备中。",
|
||||
"itemSendPop": "发送 {pop} POP",
|
||||
"itemBuyerPop": "购买者 +{pop} POP",
|
||||
"itemOwned": "持有:{count}",
|
||||
"itemOwnedCount": "持有:{count} 个",
|
||||
"growthOwnerOf": "的 ",
|
||||
"growthSteadySuffix": " 贡献,正在稳步成长。",
|
||||
"questAttended": "已签到",
|
||||
"questInProgressSpaced": "进行中",
|
||||
"questRewardPaid": "奖励已发放",
|
||||
"questClaim": "领取奖励",
|
||||
"questStart": "开始任务",
|
||||
"questRewardDone": "已领取奖励",
|
||||
"popKindGeneral": "普通 POP",
|
||||
"popKindContribution": "贡献 POP",
|
||||
"popTxGrant": "发放",
|
||||
"popTxSpend": "扣除",
|
||||
"popTxRefund": "退款",
|
||||
"popTxVoid": "作废",
|
||||
"popTypeQuest": "任务",
|
||||
"popTypeReward": "奖励",
|
||||
"popTypePayment": "支付",
|
||||
"popTypeSubscription": "订阅",
|
||||
"popTypeAdmin": "运营",
|
||||
"popTypeFee": "手续费",
|
||||
"commonEtc": "其他",
|
||||
"popReasonQuestSteps": "计步任务",
|
||||
"popReasonQuestMusicShake": "音乐摇摇任务",
|
||||
"popReasonQuestPlace": "地点打卡任务",
|
||||
"popReasonQuestAttend": "签到任务",
|
||||
"popReasonAdReward": "广告奖励",
|
||||
"popReasonHeroDrop": "英雄礼物",
|
||||
"popReasonHeroDropTrigger": "英雄礼物发动",
|
||||
"popReasonTranslation": "参与式翻译",
|
||||
"popReasonIapPurchase": "购买商品",
|
||||
"popReasonFocusDaily": "Focus 每日奖励",
|
||||
"popReasonPreregBonus": "预约奖励",
|
||||
"popReasonReferral": "邀请好友",
|
||||
"popTypeReferral": "推荐",
|
||||
"popReasonAdminGrant": "运营发放",
|
||||
"popReasonAdminClawback": "运营回收",
|
||||
"popReasonCampaignFund": "活动支持",
|
||||
"popReasonCampaignRefund": "活动退款",
|
||||
"popReasonCampaignFeeRefund": "活动开设手续费退款",
|
||||
"popReasonCampaignFee": "活动登记费",
|
||||
"popReasonQnaBounty": "INSIDE 悬赏",
|
||||
"popReasonQnaRefund": "悬赏退款",
|
||||
"popReasonQnaReward": "INSIDE 采纳奖励",
|
||||
"popReasonEclipseDefense": "Zap 贡献",
|
||||
"popReasonOther": "POP 变动",
|
||||
"popRepeat": "{title} {count} 次",
|
||||
"payStatePaid": "支付完成",
|
||||
"payStateRefundRequested": "已申请退款",
|
||||
"payStateRefundApproved": "退款已批准",
|
||||
"payStateRefunded": "退款完成",
|
||||
"payStateRefundRejected": "退款被驳回",
|
||||
"pushTopicEssentialDesc": "账号、支付、维护通知,无法关闭。",
|
||||
"pushTopicFandomEventDesc": "日蚀、活动、任务、艺人热力时刻消息。",
|
||||
"pushTopicBriefingDesc": "为你推送星球每日简报卡片。",
|
||||
"pushTopicCommunityDesc": "我的帖子的评论、点赞、回答通知。",
|
||||
"pushTopicMarketingDesc": "接收活动与优惠信息,仅在同意后开启。",
|
||||
"inquiryPending": "已受理",
|
||||
"inquiryInProgress": "处理中",
|
||||
"inquiryAnswered": "已回复",
|
||||
"photoBadType": "仅可上传 JPG、PNG、WEBP、GIF 图片。",
|
||||
"photoTooBig": "照片超过 10MB,请选择更小的照片。",
|
||||
"photoUnreadable": "无法读取照片,请重新选择。",
|
||||
"myListInside": "我的 INSIDE",
|
||||
"myListInsideEmpty": "你还没有发布 INSIDE。",
|
||||
"myListCampaign": "我的活动",
|
||||
"myListCampaignEmpty": "你还没有创建活动。",
|
||||
"sortLatest": "最新",
|
||||
"sortPop": "按 POP",
|
||||
"sortSolved": "已采纳",
|
||||
"sortAchieved": "已达成",
|
||||
"payNotFound": "找不到支付记录。",
|
||||
"payItemFallback": "支付商品",
|
||||
"payMethodInApp": "应用内购买",
|
||||
"unknownUser": "未知用户",
|
||||
"blockSelf": "不能屏蔽自己。",
|
||||
"inquiryFallback": "咨询",
|
||||
"photoRejectedServer": "无法上传照片,请检查格式和大小。",
|
||||
"photoUploadFailed": "照片上传失败,请稍后再试。",
|
||||
"focusClaimed": "已领取今日 Focus 奖励 +{pop} 贡献 POP。",
|
||||
"focusActive": "正在使用无广告专注模式",
|
||||
"focusUntil": "有效期至 {date} · 今日奖励仅限今日领取",
|
||||
"focusClaimedToday": "今日已领取",
|
||||
"focusClaimToday": "领取今日奖励",
|
||||
"storeConnectFailed": "无法连接商店,请稍后再试。",
|
||||
"payRestore": "恢复购买",
|
||||
"payEmpty": "暂无支付记录。",
|
||||
"payRefundGuide": "消耗型商品:购买后 7 天内且未使用可退款,已部分使用的商品不予退款。\n订阅型商品:支付后 24 小时内未使用权益(如额外奖励)可退款。\n退款批准后 7–14 天内完成退款。",
|
||||
"payDetailTitle": "支付详情",
|
||||
"payNumber": "支付编号",
|
||||
"payDate": "支付时间",
|
||||
"payMethod": "支付方式",
|
||||
"payStatus": "支付状态",
|
||||
"payAmount": "支付金额",
|
||||
"payRefundInfo": "退款说明",
|
||||
"payRefundApply": "申请退款",
|
||||
"payRefundUnavailable": "不可退款",
|
||||
"refundReasonMistake": "误操作付款",
|
||||
"refundReasonExpectation": "服务与预期不符",
|
||||
"refundReasonError": "因错误无法使用",
|
||||
"refundReasonDuplicate": "重复付款",
|
||||
"refundTitle": "退款",
|
||||
"refundSubmit": "提交",
|
||||
"refundReason": "退款原因",
|
||||
"refundConfirm": "确定要申请退款吗?",
|
||||
"myPop": "我的 POP",
|
||||
"commonNoHistory": "暂无记录。",
|
||||
"nicknameEdit": "修改昵称",
|
||||
"nicknameReserved": "不能使用可能被误认为运营方的名称。",
|
||||
"nicknameTaken": "该昵称已被使用。",
|
||||
"albumItems": "相册选项",
|
||||
"takePhoto": "拍照",
|
||||
"pickFromAlbum": "从相册选择",
|
||||
"tierGuide": "等级说明",
|
||||
"statSteps": "累计步数",
|
||||
"statContribution": "累计贡献 POP",
|
||||
"statQuests": "已完成任务",
|
||||
"shareInstagram": "Instagram",
|
||||
"shareTiktok": "TikTok",
|
||||
"shareCopyLink": "复制链接",
|
||||
"inquiryListTitle": "1:1 咨询",
|
||||
"inquiryEmpty": "暂无咨询。",
|
||||
"inquiryWrite": "撰写咨询",
|
||||
"inquiryDetail": "咨询详情",
|
||||
"commonEdit": "编辑",
|
||||
"inquiryAnswer": "回复",
|
||||
"inquiryNew": "新建咨询",
|
||||
"inquiryEdit": "编辑咨询",
|
||||
"commonTitle": "标题",
|
||||
"commonTitleHint": "请输入标题。",
|
||||
"inquiryBody": "咨询内容",
|
||||
"inquiryBodyHint": "请填写咨询内容。",
|
||||
"inquiryBodyHelper": "填写 10 个字以上,我们能更快为你处理。",
|
||||
"inquirySubmitFailed": "咨询提交失败。{reason}",
|
||||
"inquiryEditFailed": "咨询修改失败。{reason}",
|
||||
"inquirySubmitted": "咨询已提交。",
|
||||
"inquiryEdited": "咨询已修改。",
|
||||
"blockedEmpty": "没有已屏蔽的账号。",
|
||||
"unblock": "取消屏蔽",
|
||||
"unblockFailed": "取消屏蔽失败。{reason}",
|
||||
"tierInfoTitle": "TIER 信息",
|
||||
"tierPromptPrefix": "通过为星球/星云做贡献\n",
|
||||
"tierPromptRank": "等级",
|
||||
"tierPromptSuffix": "来提升吧",
|
||||
"tierPassportNote": "Passport Tier 是与等级无关的卡片外观等级,不会降低。",
|
||||
"tierTotalContribution": "累计贡献",
|
||||
"notifKindEclipse": "日蚀",
|
||||
"notifKindItemUsed": "道具使用",
|
||||
"notifKindLike": "点赞",
|
||||
"notifKindComment": "评论",
|
||||
"notifKindCampaign": "活动达成",
|
||||
"notifKindQuest": "任务完成",
|
||||
"notifTargetPost": "帖子",
|
||||
"notifTargetCampaign": "活动",
|
||||
"notifTargetQuest": "任务详情",
|
||||
"dayToday": "今天",
|
||||
"dayYesterday": "昨天",
|
||||
"notifEmpty": "暂无通知。",
|
||||
"notifGoTo": "即将前往{target}。",
|
||||
"stationOfficial": "官方",
|
||||
"stationShop": "商店",
|
||||
"stationTicket": "购票",
|
||||
"stationMedia": "媒体",
|
||||
"stationLoadFailed": "无法加载 STATION",
|
||||
"commonAll": "全部",
|
||||
"stationSuggest": "推荐商店",
|
||||
"stationEmpty": "暂无链接。\n加入星球后即可看到精选内容。",
|
||||
"stationCategoryEmpty": "此分类暂无链接。",
|
||||
"stationSuggestLater": "服务器连接后即可提交推荐。",
|
||||
"commonSubmit": "提交",
|
||||
"commonName": "名称",
|
||||
"commonNameRequired": "请输入名称。",
|
||||
"commonUrl": "网址",
|
||||
"commonUrlRequired": "请输入网址。",
|
||||
"commonUrlHttps": "请输入以 https 开头的网址。",
|
||||
"commonCategory": "分类",
|
||||
"stationReason": "推荐理由(选填)",
|
||||
"stationSuggestNote": "推荐的链接经运营审核后登记,审核结果将通过通知告知。",
|
||||
"heroGiftUnavailable": "该礼物目前无法领取。",
|
||||
"heroGiftAlready": "你已参与过该礼物,请查看 POP 记录。",
|
||||
"heroGiftRelogin": "请重新登录后参与。",
|
||||
"heroGiftNoPermission": "你没有参与该礼物的权限。",
|
||||
"heroGiftHowTo": "摇动手机或点击下方按钮,参与光能礼物。",
|
||||
"heroGiftPopNote": "可领取的 POP 取决于剩余礼物和每人上限。",
|
||||
"heroGiftReceived": "已确认领取 {total} POP",
|
||||
"heroGiftUnknown": "无法确认发放结果,请重新确认。",
|
||||
"heroGiftNone": "目前没有可参与的礼物。",
|
||||
"heroGiftConfirmed": "已确认领取所参与的礼物。",
|
||||
"heroGiftChecking": "正在确认发放…",
|
||||
"heroGiftRetry": "重新确认该礼物",
|
||||
"heroGiftTap": "用按钮代替摇动参与",
|
||||
"rankMyPlanet": "我的星球 {metric} 排名",
|
||||
"rankPlanetEmpty": "暂无星球排名数据",
|
||||
"rankScopeLive": "实时",
|
||||
"rankScopeCountry": "按国家",
|
||||
"rankMine": "我的 {metric} 排名",
|
||||
"rankMyCountry": "我所在国家的 {metric} 排名",
|
||||
"rankEmpty": "暂无排名数据",
|
||||
"rankScope": "{scope}排名",
|
||||
"rankLoadFailed": "无法加载排名",
|
||||
"rankMeSuffix": "{name}(我)",
|
||||
"reportDefamation": "诽谤艺人及损害名誉",
|
||||
"reportInappropriate": "不当内容",
|
||||
"reportSpam": "垃圾信息及商业广告",
|
||||
"reportPrivacy": "泄露个人信息及冒充他人",
|
||||
"reportTitle": "举报",
|
||||
"reportSubmit": "举报",
|
||||
"reportAuthor": "作者",
|
||||
"reportReasonLabel": "举报原因",
|
||||
"reportConfirm": "要举报这条帖子吗?\n提交后无法撤销。",
|
||||
"blockMemberConfirm": "要屏蔽该会员的所有帖子吗?",
|
||||
"loungeMembersOnly": "仅该星球粉丝圈成员可发帖、评论和点赞。",
|
||||
"loungeFailed": "处理失败,请稍后再试。",
|
||||
"loungeTitle": "粉丝休息室",
|
||||
"likeCancel": "取消点赞",
|
||||
"like": "点赞",
|
||||
"commentDeleteConfirm": "要删除评论吗?",
|
||||
"commonShare": "分享",
|
||||
"commonEditAction": "编辑",
|
||||
"commonDeleteAction": "删除",
|
||||
"commonBlockAction": "屏蔽",
|
||||
"commentFirst": "来发表第一条评论吧。",
|
||||
"postDeleteConfirm": "要删除这篇帖子吗?",
|
||||
"reportReceived": "举报已受理。",
|
||||
"commonDelete": "删除",
|
||||
"commentHint": "写下评论吧。",
|
||||
"commonSend": "发送",
|
||||
"postWrite": "发帖",
|
||||
"postEdit": "编辑帖子",
|
||||
"loungeHint": "在粉丝休息室自由发帖吧。",
|
||||
"photoRemove": "移除照片 {n}",
|
||||
"camera": "相机",
|
||||
"album": "相册",
|
||||
"historyTitle": "PASSPORT · 记录",
|
||||
"historyMine": "我的活动",
|
||||
"historyEvolution": "Stage {from} → {to} 进化",
|
||||
"historyHibernation": "星球休眠",
|
||||
"historyWakeUp": "星球苏醒",
|
||||
"historyZap": "ZAP 贡献",
|
||||
"historyQuest": "Quest 奖励",
|
||||
"historyAdBonus": "广告额外奖励",
|
||||
"historyFunding": "Campaign 贡献",
|
||||
"historyGrowthPop": "{amount} POP 计入成长",
|
||||
"historyJoined": "你参与了这次进化",
|
||||
"historyLoadFailed": "无法加载记录。",
|
||||
"historyMineEmpty": "暂无活动记录。",
|
||||
"historyFandomEmpty": "暂无粉丝圈历史。",
|
||||
"historyMore": "查看更早记录",
|
||||
"countryName": "{code, select, KR{韩国} JP{日本} US{美国} CN{中国} TW{台湾} HK{香港} TH{泰国} VN{越南} ID{印度尼西亚} PH{菲律宾} MY{马来西亚} SG{新加坡} IN{印度} GB{英国} FR{法国} DE{德国} ES{西班牙} BR{巴西} MX{墨西哥} CA{加拿大} AU{澳大利亚} ZZ{未指定} other{-}}",
|
||||
"loungeMaxPhotos": "最多可上传 {count} 张照片。",
|
||||
"commonLoadFailed": "加载失败",
|
||||
"planetPassportHistory": "PASSPORT · 粉丝圈历史",
|
||||
"planetNamed": "{name} 星球",
|
||||
"blockNebulaConfirm": "要屏蔽这个星云吗?",
|
||||
"blockPlanetConfirm": "要屏蔽这个星球吗?",
|
||||
"blocked": "已屏蔽。",
|
||||
"planetLastUpdated": "最后更新 {time}",
|
||||
"planetContribution": "贡献情况",
|
||||
"planetNebulaGrowth": "星云成长率",
|
||||
"planetLiveRanking": "实时贡献排名",
|
||||
"planetNoContributors": "还没有贡献者",
|
||||
"planetContributeCta": "贡献一下,登上排行榜吧!",
|
||||
"planetOverallRank": "总排名第 {rank}",
|
||||
"planetMyContribution": "我的贡献度",
|
||||
"planetTallying": "统计准备中",
|
||||
"planetTopPercent": "前 {percent}%",
|
||||
"planetHideMember": "隐藏该会员的所有帖子",
|
||||
"reportReasonUnknown": "无法确认举报原因。",
|
||||
"reportAlready": "你已举报过该帖子。",
|
||||
"blockUnavailable": "无法屏蔽该作者。",
|
||||
"commonMe": "我",
|
||||
"planetSizeMedium": "中型粉丝圈",
|
||||
"planetPerHead": "人均火力:{value}",
|
||||
"planetJoined": "已加入",
|
||||
"planetJoin": "加入",
|
||||
"questDetails": "详情",
|
||||
"questInProgress": "进行中",
|
||||
"questTypeAttend": "签到",
|
||||
"questTypeSteps": "步行",
|
||||
"questTypePlace": "指定地点",
|
||||
"questTypeStreaming": "流媒体",
|
||||
"questTypeUnverifiable": "该任务类型的验证方式尚未确定。",
|
||||
"questTabMine": "我的任务",
|
||||
"placePickPrompt": "请选择\n完成任务的地点。",
|
||||
"placeSearch": "搜索地点",
|
||||
"commonNoResults": "没有搜索结果。",
|
||||
"commonSelectDone": "选择完成",
|
||||
"adMoreChance": "获得更多贡献机会。",
|
||||
"adWatchPrompt": "观看广告即可\n获得额外奖励!",
|
||||
"adBonusPlaceholder": "+ 贡献 POP",
|
||||
"adPartyPack": "派对礼包",
|
||||
"adPickProduct": "选择商品",
|
||||
"adWatchBonus": "看广告领取额外奖励",
|
||||
"adBasicReward": "领取基础奖励",
|
||||
"focusMode": "无广告专注模式",
|
||||
"pricePerMonth": "{price}/月",
|
||||
"adWarpBefore": "Warping...正在努力寻找合适的广告,帮你为粉丝圈和艺人做更多贡献。",
|
||||
"adWarpAfter": "Warping...正在为你的贡献准备奖励。",
|
||||
"adSkip": "跳过",
|
||||
"questPreviewHint": "选择选项并查看预览",
|
||||
"questNoCategory": "尚未选择分类",
|
||||
"questNew": "新任务",
|
||||
"questCategory": "分类",
|
||||
"questName": "任务名称",
|
||||
"questAction": "具体行动",
|
||||
"questPickPlace": "请选择目标地点",
|
||||
"questGoalValue": "目标数值",
|
||||
"questParticipants": "参与人数",
|
||||
"questRewardPop": "完成奖励 POP",
|
||||
"questCreateConfirm": "按此内容创建吗?",
|
||||
"questCreateSummary": "{title} · 目标 {goal} · 奖励 {reward} POP",
|
||||
"commonKeepEditing": "继续修改",
|
||||
"questCreate": "创建",
|
||||
"questNamed": "{title} 任务",
|
||||
"questDailyHelp": "每天为星球助力",
|
||||
"questTodayMust": "今天必做的任务",
|
||||
"questTabEmpty": "此标签下暂无任务。",
|
||||
"questCreateNotReady": "任务创建将与奖励资金托管一同开放,敬请期待。",
|
||||
"questNoBaseReward": "找不到基础奖励记录,无法领取广告额外奖励。",
|
||||
"questNoSteps": "尚未收集到步数,请走一会儿后再试。",
|
||||
"questNoPlace": "该任务未登记验证地点。",
|
||||
"questGoal": "目标",
|
||||
"questReward": "奖励",
|
||||
"questPopGrant": "发放 POP",
|
||||
"commonChecking": "确认中…",
|
||||
"questMine": "这是你创建的任务,参与者 {count} 人。",
|
||||
"questStopConfirm": "要中止任务吗?",
|
||||
"questStopBody": "当前进度将被重置并恢复初始状态。",
|
||||
"questStop": "中止",
|
||||
"adNotFinished": "未看完广告,无额外奖励,基础奖励照常发放。",
|
||||
"openSettings": "打开设置",
|
||||
"permStepsDenied": "步数权限已关闭,请在设置中允许“身体活动”。",
|
||||
"permStepsNeeded": "计步需要身体活动权限。",
|
||||
"permLocationOff": "设备定位已关闭,请开启后重试。",
|
||||
"permLocationNeeded": "需要定位权限以确认到访。",
|
||||
"permLocationDenied": "定位权限已关闭,请在设置中允许访问位置。",
|
||||
"questRewardReceived": " 已领取!",
|
||||
"questAdBonus": "广告额外奖励 +{pop} POP",
|
||||
"questParticipantsCount": "参与人数:{count}",
|
||||
"questParticipantsCap": "参与人数:{count} / {cap}",
|
||||
"nebulaCreateFailed": "无法创建星云,请稍后再试。",
|
||||
"nebulaAlreadyToday": "你今天已创建过星云。",
|
||||
"nebulaCreatedKeepPrimary": "星云已创建。默认星球需在上次更改 30 天后才能更换,因此保持不变。",
|
||||
"fandomFounderLeaveBlocked": "行星诞生后才能退出。",
|
||||
"sortPopular": "热门",
|
||||
"campaignSortContributing": "贡献中",
|
||||
"campaignStatusReview": "该活动正在接受运营审核。",
|
||||
"campaignStatusRejected": "活动未通过审核。登记费已退还。",
|
||||
"campaignStatusWithdrawn": "该活动已被发起人撤回。",
|
||||
"campaignStatusCancelled": "因运营原因已取消。登记费和支援的 POP 已退还。",
|
||||
"rankWindowDaily": "日榜",
|
||||
"rankWindowWeekly": "周榜",
|
||||
"campaignNotFound": "找不到该活动。",
|
||||
"campaignMediaMain": "主图",
|
||||
"campaignLoadFailed": "无法加载活动。",
|
||||
"campaignTabInfo": "活动信息",
|
||||
"campaignTabBoard": "应援留言 {count}",
|
||||
"campaignWithdraw": "撤回活动",
|
||||
"campaignContributeAction": "贡献",
|
||||
"campaignFundingStatus": "当前筹集情况",
|
||||
"campaignSupporting": "应援中",
|
||||
"campaignTimeLeft": "剩余!",
|
||||
"campaignReport": "结果报告",
|
||||
"campaignReportPhotos": "执行照片",
|
||||
"campaignBudget": "预算使用明细",
|
||||
"campaignTotal": "合计",
|
||||
"campaignReceipts": "收据",
|
||||
"campaignDaysLeft": "{days}天 {time}",
|
||||
"campaignWithdrawTitle": "要撤回该活动吗?",
|
||||
"campaignWithdrawRefund": "由于在公开前撤回,{fee} POP 登记费将按原支付方式退还。",
|
||||
"campaignWithdrawNoRefund": "该活动已公开,{fee} POP 登记费不予退还。",
|
||||
"campaignWithdrawConfirm": "撤回",
|
||||
"shareCopied": "已复制分享文案。",
|
||||
"campaignContributePop": "设置贡献 POP",
|
||||
"campaignEmptyContributing": "您还没有贡献过任何活动。",
|
||||
"campaignEmpty": "暂无活动。",
|
||||
"campaignNew": "新活动",
|
||||
"campaignMyRanking": "我的贡献排名",
|
||||
"campaignWindowRanking": "{window}排名",
|
||||
"campaignSearchHint": "搜索活动",
|
||||
"campaignNoRecentSearches": "没有最近搜索",
|
||||
"searchRemoveTerm": "删除 {term}",
|
||||
"campaignNoResults": "没有搜索结果",
|
||||
"shareTopicCampaign": "活动",
|
||||
"sharePlanetLine": "{brand} · {name} 行星",
|
||||
"campaignGaugeLabel": "活动贡献率",
|
||||
"campaignPeriodTitle": "请选择活动期间",
|
||||
"campaignPeriodMax": "最长可选择一年",
|
||||
"campaignYearMonth": "{year}年{month}月",
|
||||
"campaignWeekdays": "日,一,二,三,四,五,六",
|
||||
"campaignPeriodRange": "{startMonth}月{startDay}日 ~ {endMonth}月{endDay}日",
|
||||
"campaignPeriodSelect": "选择期间",
|
||||
"calendarPrevMonth": "上个月",
|
||||
"calendarNextMonth": "下个月",
|
||||
"campaignShareTitle": "分享活动",
|
||||
"campaignPickMedia": "选择图片/视频",
|
||||
"campaignTitleHint": "请输入活动标题。",
|
||||
"campaignBodyHint": "请填写活动说明。",
|
||||
"campaignReviewNote": "提交后,经运营审核即开放筹集。",
|
||||
"campaignSupportType": "选择支援类型",
|
||||
"campaignPeriod": "活动期间",
|
||||
"campaignDateSelect": "选择日期",
|
||||
"campaignGoalPop": "目标贡献 POP",
|
||||
"campaignSubmitTitle": "按此内容提交吗?",
|
||||
"campaignSubmitBody": "{title} · 目标 {goal} POP · {days}天\n将扣除 {fee} POP 登记费,运营审核后开放筹集。",
|
||||
"campaignSubmit": "提交",
|
||||
"campaignCreateFailed": "无法提交活动。",
|
||||
"campaignNeedsQuests": "已完成任务 {done}/{need} — 再完成 {left} 次即可发起活动。",
|
||||
"campaignPendingTitle": "等待审核",
|
||||
"campaignPendingBody": "运营审核完成后将开放筹集并显示在列表中。",
|
||||
"insideRefundPending": "采纳期限已过,悬赏将被退还。",
|
||||
"insideRefundDone": "采纳期限已过,悬赏已退还。",
|
||||
"insideRefundChecking": "采纳期限已过,正在确认悬赏退还。",
|
||||
"insideErrPopShort": "POP 不足,无法设置悬赏。",
|
||||
"insideErrSelfAccept": "不能采纳自己的回答。",
|
||||
"insideErrAlreadyAccepted": "该问题已采纳,无法更改。",
|
||||
"insideErrNotOwner": "只有提问者可以采纳。",
|
||||
"commonCheckConnection": "请检查网络连接后重试。",
|
||||
"insideAnswerLater": "服务器连接后即可发表评论。",
|
||||
"insideAcceptConfirm": "要采纳这条评论吗?\n将发放 {pop} POP 悬赏,采纳后无法撤销。",
|
||||
"insideAccept": "采纳",
|
||||
"insideMore": "更多",
|
||||
"insideFirstAnswer": "来发表第一条评论吧。",
|
||||
"insideAcceptDone": "已采纳",
|
||||
"insideAcceptAction": "采纳",
|
||||
"insideAnswerHint": "写下评论吧。",
|
||||
"insideLoadFailed": "无法加载 INSIDE",
|
||||
"insideEmpty": "暂无提问。",
|
||||
"insideWrite": "发布 INSIDE",
|
||||
"insideSearchHint": "INSIDE 标题、内容",
|
||||
"insideBodyHint": "和粉丝们自由地提问交流吧。",
|
||||
"insideTitleRequired": "请输入标题。",
|
||||
"insideBodyRequired": "请输入内容。",
|
||||
"insideBountySet": "设置悬赏",
|
||||
"insideBountyHint": "请设置 {min} 以上。",
|
||||
"insideAttachLater": "服务器连接后即可附加图片。",
|
||||
"insideBountyRequired": "请输入悬赏。",
|
||||
"insideNumberOnly": "请输入数字。",
|
||||
"insideBountyMin": "悬赏须至少为 {min} POP。",
|
||||
"insideBountyOverBalance": "不能超过持有的 POP。",
|
||||
"questTypeTimeAttack": "紧急限时挑战",
|
||||
"questTimeLeft": "剩余时间",
|
||||
"questTimeAttackClosed": "已截止",
|
||||
"questTimeAttackLeft": "剩余 {time}",
|
||||
"popReasonQuestTimeAttack": "紧急限时挑战",
|
||||
"briefingShareCopied": "已复制分享文字和链接。",
|
||||
"shareCopyText": "复制文字",
|
||||
"briefingShareTextCopied": "已复制分享文字。",
|
||||
"questAlreadyRewarded": "已领取该奖励(+{pop} POP)。",
|
||||
"questCatHealth": "健康",
|
||||
"questCatPlanet": "星球活动",
|
||||
"questCatCommunity": "社区",
|
||||
"questActShake": "摇一摇",
|
||||
"questTypeVote": "参与投票",
|
||||
"questTypeSns": "社交媒体",
|
||||
"questActivity": "活动",
|
||||
"questNoCreatable": "目前没有可创建的任务活动。",
|
||||
"questDescriptionOptional": "说明(可选)",
|
||||
"questLinkUrl": "链接地址",
|
||||
"questLinkCheck": "检查链接",
|
||||
"questLinkInvalid": "链接无效,请检查地址。",
|
||||
"questLinkPreviewFailed": "无法获取链接信息,请手动输入标题。",
|
||||
"questLinkTitle": "链接标题",
|
||||
"questEndDate": "结束日期",
|
||||
"questEndDefault": "发布后24小时",
|
||||
"questEndDateValue": "{date} 23:59:59 截止",
|
||||
"questPoolHelper": "共存入 {pool} POP · 手续费 {fee} POP",
|
||||
"questCreateCost": "{title}\n奖励 {reward} POP × {count}人 = {pool} POP\n手续费 {fee} POP(发布时销毁)\n共扣除 {total} POP。",
|
||||
"questNoReward": "这是无奖励的参与任务。",
|
||||
"questDeadline": "截止",
|
||||
"questRemainingHours": "剩余{hours}小时",
|
||||
"questEnded": "已截止",
|
||||
"questAuthorNote": "创建者说明",
|
||||
"questRepeatOnce": "每人限参与1次",
|
||||
"questRepeatEvery": "间隔{hours}小时 · 最多{count}次",
|
||||
"questSubmit": "提交认证",
|
||||
"questResubmit": "补充提交",
|
||||
"questEvidenceUrl": "认证链接(可选)",
|
||||
"questEvidenceText": "认证内容",
|
||||
"questSubmitted": "已提交,审核后发放奖励。",
|
||||
"questReviewPending": "审核中,批准后将发放奖励。",
|
||||
"questReviewDelayed": "审核有所延迟,运营团队正在确认。",
|
||||
"questApprovedPaying": "已批准,正在发放奖励。",
|
||||
"questRejected": "已驳回:{reason}",
|
||||
"questResubmitAllowed": "可补充后重新提交。",
|
||||
"questResubmitNotAllowed": "此提交无法重新提交。",
|
||||
"questReviewButton": "审核中",
|
||||
"questRejectedButton": "已驳回",
|
||||
"questReviewQueue": "待审核 {count} 件",
|
||||
"questApprove": "批准",
|
||||
"questReject": "驳回",
|
||||
"questRejectReason": "驳回理由",
|
||||
"questAllowResubmit": "允许补充重新提交",
|
||||
"questEdit": "编辑标题和说明",
|
||||
"questEditNotice": "发布后仅可编辑标题和说明,并会保留修改记录。",
|
||||
"popReasonQuestUgc": "任务奖励",
|
||||
"popReasonQuestFee": "任务创建手续费",
|
||||
"questFreeDailyCap": "无奖励任务每天最多可创建 {cap} 个。",
|
||||
"popReasonQuestPoolDeposit": "任务奖励存入",
|
||||
"popReasonQuestPoolReturn": "任务奖励退回",
|
||||
"campaignCommentNeedNickname": "请先设置昵称",
|
||||
"campaignCommentSetNickname": "设置昵称"
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user