가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark
This commit is contained in:
+3
-3
@@ -169,9 +169,9 @@ class _FancakeAppState extends State<FancakeApp> with WidgetsBindingObserver {
|
||||
}
|
||||
|
||||
Future<void> _startPush(PushMessaging push) async {
|
||||
// 권한을 먼저 묻는다. 거부해도 나머지는 계속한다 — 토큰과 구독은 사용자가
|
||||
// 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
|
||||
await push.requestPermission();
|
||||
// 알림 권한은 여기서 묻지 않는다 — 온보딩 ⑥ 권한 안내가 쓰임을 설명한 뒤 묻는다
|
||||
// (FR-CMN-08 · 원장 C-84, 로그인 직후 일괄 요청 금지). 거부해도 토큰과 구독은
|
||||
// 사용자가 나중에 설정에서 허용했을 때 그대로 살아 있어야 한다.
|
||||
await push.syncToken();
|
||||
|
||||
// 팬덤 ID 는 PushMessaging 이 사용자 문서에서 직접 읽는다.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../../features/auth/onboarding_data.dart';
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
import 'dart:async';
|
||||
|
||||
@@ -66,6 +67,11 @@ class Repositories {
|
||||
? FirestoreInsideRepository(backend)
|
||||
: const SampleInsideRepository();
|
||||
|
||||
/// 온보딩 6단계(약관 · 프로필 · 관심 아티스트 · 행성 가입).
|
||||
late final OnboardingRepository onboarding = _live
|
||||
? FirestoreOnboardingRepository(backend)
|
||||
: SampleOnboardingRepository();
|
||||
|
||||
late final MyRepository my = _live
|
||||
? FirestoreMyRepository(backend)
|
||||
: SampleMyRepository();
|
||||
|
||||
@@ -7,8 +7,10 @@ import '../theme/tokens.dart';
|
||||
import '../ui/app_icons.dart';
|
||||
import '../../features/auth/auth_repository.dart';
|
||||
import '../../features/auth/login_screen.dart';
|
||||
import '../../features/auth/onboarding_screens.dart';
|
||||
import '../../features/auth/signup_screens.dart';
|
||||
import '../../features/galaxy/galaxy_home_screen.dart';
|
||||
import '../../features/home/coach_mark.dart';
|
||||
import '../../features/home/home_screen.dart';
|
||||
import '../../features/inside/inside_screen.dart';
|
||||
import '../../features/my/my_screen.dart';
|
||||
@@ -79,11 +81,15 @@ List<AppTab> visibleTabs(FeatureFlags flags) => [
|
||||
|
||||
const String loginPath = '/login';
|
||||
const String signupCompletePath = '/welcome';
|
||||
const String introPath = '/intro';
|
||||
const String onboardingPath = '/onboarding';
|
||||
|
||||
/// 5탭 셸 라우트 + 온보딩(로그인) 라우트.
|
||||
/// 5탭 셸 라우트 + 온보딩 라우트.
|
||||
///
|
||||
/// 온보딩 나머지 화면(S-A-INTRO · S-A-TERMS · S-A-PROFILE · S-A-ARTIST ·
|
||||
/// S-A-PLANET-NEW · S-A-PERMS)은 3주차 범위라 아직 라우트가 없다.
|
||||
/// 온보딩 6단계(10/3 통합회신 1항): ① 소개 3장([introPath], 첫 실행 · 로그인 전) → 로그인 →
|
||||
/// ②~⑥ 약관 · 프로필 · 관심 아티스트 · 행성 가입/성운 생성 · 권한 안내([onboardingPath]) →
|
||||
/// 가입 완료([signupCompletePath]) → HOME(Coach Mark). 서버에 약관 동의가 없는 계정은
|
||||
/// 「로그인」 버튼으로 들어왔어도 셸로 가지 못하고 [onboardingPath] 로 간다.
|
||||
GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
// 저장소 선택은 앱이 뜰 때 한 번만 한다. 탭을 옮길 때마다 다시 만들면
|
||||
// Firestore 커서 같은 화면 상태가 매번 초기화된다.
|
||||
@@ -95,11 +101,23 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
refreshListenable: auth,
|
||||
redirect: (context, state) {
|
||||
final at = state.matchedLocation;
|
||||
if (!auth.isSignedIn) return at == loginPath ? null : loginPath;
|
||||
if (!auth.isSignedIn) {
|
||||
final entry = auth.introPending ? introPath : loginPath;
|
||||
return at == entry ? null : entry;
|
||||
}
|
||||
if (auth.onboarding != OnboardingGate.done) {
|
||||
return at == onboardingPath ? null : onboardingPath;
|
||||
}
|
||||
if (auth.justSignedUp)
|
||||
return at == signupCompletePath ? null : signupCompletePath;
|
||||
if (at == loginPath || at == signupCompletePath)
|
||||
if (const [
|
||||
loginPath,
|
||||
signupCompletePath,
|
||||
introPath,
|
||||
onboardingPath,
|
||||
].contains(at)) {
|
||||
return appTabs.first.path;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
@@ -107,6 +125,24 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) {
|
||||
path: loginPath,
|
||||
builder: (_, _) => LoginScreen(auth: auth),
|
||||
),
|
||||
GoRoute(
|
||||
path: introPath,
|
||||
builder: (_, _) => IntroScreen(onDone: auth.finishIntro),
|
||||
),
|
||||
GoRoute(
|
||||
path: onboardingPath,
|
||||
builder: (_, _) => ListenableBuilder(
|
||||
listenable: auth,
|
||||
builder: (_, _) => auth.onboarding == OnboardingGate.checking
|
||||
? const Scaffold(body: Center(child: CircularProgressIndicator()))
|
||||
: OnboardingFlow(
|
||||
auth: auth,
|
||||
repository: repos.onboarding,
|
||||
home: repos.home,
|
||||
push: repos.push,
|
||||
),
|
||||
),
|
||||
),
|
||||
GoRoute(
|
||||
path: signupCompletePath,
|
||||
builder: (_, _) => SignupCompleteScreen(onStart: auth.finishSignup),
|
||||
@@ -230,7 +266,13 @@ class AppShell extends StatelessWidget {
|
||||
),
|
||||
destinations: [
|
||||
for (final tab in tabs)
|
||||
NavigationDestination(icon: tab.icon, label: tab.label),
|
||||
NavigationDestination(
|
||||
// Coach Mark ② 가 MY 위치를 가리킨다(coach_mark.dart).
|
||||
icon: tab.path == appTabs.last.path
|
||||
? KeyedSubtree(key: coachMyTabKey, child: tab.icon)
|
||||
: tab.icon,
|
||||
label: tab.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -8,21 +8,84 @@ import 'firebase_account_linker.dart';
|
||||
import 'social_providers.dart';
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
|
||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 로그인/셸을 가른다.
|
||||
/// 온보딩 관문 — 로그인한 계정이 가입 약관 동의를 마쳤는가(`users/{uid}.termsAgreedAt`).
|
||||
enum OnboardingGate { checking, required, done }
|
||||
|
||||
/// 로그인 상태 보관소. 라우터가 이걸 듣고 소개/로그인/온보딩/셸을 가른다.
|
||||
class AuthRepository extends ChangeNotifier {
|
||||
AuthRepository({
|
||||
List<SocialAuthProvider>? providers,
|
||||
AuthSession? session,
|
||||
EmailAuthProvider? qaEmailProvider,
|
||||
bool? releaseMode,
|
||||
Future<bool> Function(String uid)? needsOnboarding,
|
||||
bool introPending = false,
|
||||
Future<void> Function()? onIntroDone,
|
||||
}) : providers = providers ?? _defaultProviders(),
|
||||
_qaEmail = qaEmailProvider ?? const EmailPasswordProvider(),
|
||||
_session = session,
|
||||
_account = session?.currentAccount,
|
||||
_needsOnboarding = needsOnboarding,
|
||||
_introPending = introPending,
|
||||
_onIntroDone = onIntroDone,
|
||||
// 기본값은 실제 빌드 모드. 테스트에서만 주입해 릴리스 동작을 재현한다
|
||||
// (kReleaseMode 는 컴파일 상수라 테스트에서 뒤집을 수 없다).
|
||||
_releaseMode = releaseMode ?? (kReleaseMode || kProfileMode) {
|
||||
_sessionSubscription = session?.accountChanges.listen(_replaceAccount);
|
||||
_replaceAccount(session?.currentAccount);
|
||||
}
|
||||
|
||||
/// 이 계정이 온보딩(약관부터)을 거쳐야 하는가. null 이면 판정하지 않는다(목 모드 · 테스트).
|
||||
///
|
||||
/// **「로그인」 버튼으로 처음 들어온 소셜 계정도 여기서 걸린다** — 가입/로그인 버튼 구분이나
|
||||
/// 제공자(카카오 커스텀 토큰 포함)와 무관하게, 서버에 약관 동의 기록이 없으면 셸에 들어가지 못한다.
|
||||
final Future<bool> Function(String uid)? _needsOnboarding;
|
||||
|
||||
OnboardingGate _gate = OnboardingGate.done;
|
||||
OnboardingGate get onboarding => _gate;
|
||||
|
||||
/// 가입 모드에서 제공자 인증 **전에** 동의한 약관(Figma `177:9451` → `179:24230`).
|
||||
/// 인증 뒤 온보딩이 이 값으로 동의를 기록하고 약관 단계를 건너뛴다.
|
||||
Set<String>? pendingConsent;
|
||||
|
||||
/// 온보딩 6단계를 마쳤다 — 가입 완료 화면(`182:29344`)을 한 번 띄우고 셸로 간다.
|
||||
void completeOnboarding() {
|
||||
_gate = OnboardingGate.done;
|
||||
pendingConsent = null;
|
||||
_justSignedUp = true;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> _checkOnboarding(AuthAccount account) async {
|
||||
final check = _needsOnboarding;
|
||||
if (check == null || account.isMock) {
|
||||
_gate = OnboardingGate.done;
|
||||
return;
|
||||
}
|
||||
_gate = OnboardingGate.checking;
|
||||
bool required;
|
||||
try {
|
||||
required = await check(account.uid);
|
||||
} catch (e) {
|
||||
// 판정하지 못하면 약관부터 다시 받는다 — 동의 없는 계정이 셸로 새는 쪽이 더 나쁘다.
|
||||
debugPrint('[AuthRepository] onboarding check failed: ${e.runtimeType}');
|
||||
required = true;
|
||||
}
|
||||
if (_account?.uid != account.uid) return;
|
||||
_gate = required ? OnboardingGate.required : OnboardingGate.done;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// 앱 소개 3장(온보딩 ①)을 아직 보지 않았다 — 첫 실행에서 로그인 화면 앞에 한 번.
|
||||
bool get introPending => _introPending;
|
||||
bool _introPending;
|
||||
final Future<void> Function()? _onIntroDone;
|
||||
|
||||
/// 소개를 넘겼다(「건너뛰기」 포함) — FR-CMN-03 「건너뛰기를 눌러도 로그인 화면으로」.
|
||||
void finishIntro() {
|
||||
if (!_introPending) return;
|
||||
_introPending = false;
|
||||
unawaited(_onIntroDone?.call());
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
final bool _releaseMode;
|
||||
@@ -192,7 +255,14 @@ class AuthRepository extends ChangeNotifier {
|
||||
void _replaceAccount(AuthAccount? next) {
|
||||
if (next == null) _justSignedUp = false;
|
||||
if (_sameAccount(_account, next)) return;
|
||||
final uidChanged = _account?.uid != next?.uid;
|
||||
_account = next;
|
||||
if (next == null) {
|
||||
_gate = OnboardingGate.done;
|
||||
pendingConsent = null;
|
||||
} else if (uidChanged) {
|
||||
unawaited(_checkOnboarding(next));
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
|
||||
@@ -213,10 +213,12 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
|
||||
|
||||
Future<void> _signIn(SocialProvider provider) async {
|
||||
if (widget.signUp) {
|
||||
final agreed = await Navigator.of(
|
||||
context,
|
||||
).push<bool>(MaterialPageRoute(builder: (_) => const TermsScreen()));
|
||||
if (agreed != true || !mounted) return;
|
||||
final agreed = await Navigator.of(context).push<Set<String>>(
|
||||
MaterialPageRoute(builder: (_) => const TermsScreen()),
|
||||
);
|
||||
if (agreed == null || !mounted) return;
|
||||
// 인증 뒤 온보딩이 이 동의를 서버에 기록한다(약관 단계를 다시 묻지 않는다).
|
||||
widget.auth.pendingConsent = agreed;
|
||||
}
|
||||
setState(() => _busy = true);
|
||||
final outcome = await widget.auth.signIn(provider, signUp: widget.signUp);
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import 'package:cloud_firestore/cloud_firestore.dart';
|
||||
|
||||
import '../../core/data/backend.dart';
|
||||
|
||||
/// 온보딩 6단계(10/3 통합회신 1항 · 해석 확인 Q-OB-02 A · FR-CMN-03~08)가 서버와 주고받는 것.
|
||||
abstract interface class OnboardingRepository {
|
||||
/// ② 약관 — `fnSignupConsent`. 필수 누락은 서버가 거부한다.
|
||||
Future<void> agree(Set<String> agreed, String version);
|
||||
|
||||
/// ③ 프로필 — 닉네임(2~20 · 중복 허용) · 국가 · 표시 언어. 지금 값(있으면)을 돌려준다.
|
||||
Future<({String nickname, String? country})> currentProfile();
|
||||
Future<void> saveProfile({
|
||||
required String nickname,
|
||||
required String country,
|
||||
required String locale,
|
||||
});
|
||||
|
||||
/// ③ 추천인 코드(선택) — `fnReferralAttribute`(10/3 7항 「가입 직후 온보딩에 선택 입력」).
|
||||
Future<void> attributeReferral(String code);
|
||||
|
||||
/// ④ 관심 아티스트 — 팬덤 행성/성운 목록. [query] 가 있으면 이름 앞부분으로 찾는다.
|
||||
Future<List<OnboardingFandom>> fandoms({String query = ''});
|
||||
|
||||
/// ⑤ 행성 가입 — 고른 순서대로 `fnFandomJoin`. 대표가 없던 계정은 첫 가입이 대표가 된다(C-15).
|
||||
Future<void> join(List<String> fandomIds);
|
||||
}
|
||||
|
||||
class OnboardingFandom {
|
||||
const OnboardingFandom({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.members,
|
||||
required this.nebula,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final String name;
|
||||
final int members;
|
||||
|
||||
/// Stage0 성운인가.
|
||||
final bool nebula;
|
||||
}
|
||||
|
||||
/// 계정이 온보딩을 거쳐야 하는가 — 서버에 가입 약관 동의(`termsAgreedAt`)가 없으면 그렇다.
|
||||
/// 트리거가 아직 사용자 문서를 만들지 않았어도(갓 생성된 계정) 그렇다.
|
||||
Future<bool> needsOnboardingFirestore(String uid) async {
|
||||
final snap = await FirebaseFirestore.instance.doc('users/$uid').get();
|
||||
return snap.data()?['termsAgreedAt'] == null;
|
||||
}
|
||||
|
||||
class FirestoreOnboardingRepository implements OnboardingRepository {
|
||||
FirestoreOnboardingRepository(this._backend);
|
||||
|
||||
final Backend _backend;
|
||||
|
||||
@override
|
||||
Future<void> agree(Set<String> agreed, String version) => _backend.call(
|
||||
'fnSignupConsent',
|
||||
{'agreed': agreed.toList(), 'version': version},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<({String nickname, String? country})> currentProfile() async {
|
||||
final data =
|
||||
(await _backend.db.doc('users/${_backend.requireUid}').get()).data() ??
|
||||
const <String, dynamic>{};
|
||||
final nickname = data['nickname'];
|
||||
final country = data['countryCode'];
|
||||
return (
|
||||
nickname: nickname is String ? nickname : '',
|
||||
country: country is String && country.isNotEmpty ? country : null,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> saveProfile({
|
||||
required String nickname,
|
||||
required String country,
|
||||
required String locale,
|
||||
}) => _backend.db.doc('users/${_backend.requireUid}').set({
|
||||
'nickname': nickname,
|
||||
'countryCode': country,
|
||||
'locale': locale,
|
||||
'updatedAt': FieldValue.serverTimestamp(),
|
||||
}, SetOptions(merge: true));
|
||||
|
||||
@override
|
||||
Future<void> attributeReferral(String code) => _backend.call(
|
||||
'fnReferralAttribute',
|
||||
{'referralCode': code, 'channel': 'onboarding'},
|
||||
);
|
||||
|
||||
@override
|
||||
Future<List<OnboardingFandom>> fandoms({String query = ''}) async {
|
||||
final col = _backend.db.collection('fandoms');
|
||||
final q = query.trim();
|
||||
// 검색은 이름 앞부분 일치(단일 필드 정렬이라 별도 색인이 필요 없다). 목록은 FPI 순.
|
||||
// ponytail: 페이지 50 건 — 팬덤이 늘면 스크롤 이어읽기를 붙인다.
|
||||
final snap =
|
||||
await (q.isEmpty
|
||||
? col.orderBy('fpiScore', descending: true)
|
||||
: col
|
||||
.orderBy('name')
|
||||
.where('name', isGreaterThanOrEqualTo: q)
|
||||
.where('name', isLessThan: '$q'))
|
||||
.limit(50)
|
||||
.get();
|
||||
return [
|
||||
for (final d in snap.docs)
|
||||
OnboardingFandom(
|
||||
id: d.id,
|
||||
name: (d.data()['name'] as String?) ?? d.id,
|
||||
members: (d.data()['memberCount'] as num?)?.toInt() ?? 0,
|
||||
nebula: d.data()['stage'] == 0,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> join(List<String> fandomIds) async {
|
||||
for (final id in fandomIds) {
|
||||
await _backend.call('fnFandomJoin', {'fandomId': id});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 목 모드 · 테스트용. **정책값이 아니다.**
|
||||
class SampleOnboardingRepository implements OnboardingRepository {
|
||||
SampleOnboardingRepository();
|
||||
|
||||
final joined = <String>[];
|
||||
Set<String>? agreed;
|
||||
String? referral;
|
||||
({String nickname, String country, String locale})? profile;
|
||||
|
||||
@override
|
||||
Future<void> agree(Set<String> agreed, String version) async =>
|
||||
this.agreed = agreed;
|
||||
|
||||
@override
|
||||
Future<({String nickname, String? country})> currentProfile() async =>
|
||||
(nickname: '', country: null);
|
||||
|
||||
@override
|
||||
Future<void> saveProfile({
|
||||
required String nickname,
|
||||
required String country,
|
||||
required String locale,
|
||||
}) async => profile = (nickname: nickname, country: country, locale: locale);
|
||||
|
||||
@override
|
||||
Future<void> attributeReferral(String code) async => referral = code;
|
||||
|
||||
@override
|
||||
Future<List<OnboardingFandom>> fandoms({String query = ''}) async => [
|
||||
for (final f in const [
|
||||
OnboardingFandom(id: 'bts', name: 'BTS', members: 12840, nebula: false),
|
||||
OnboardingFandom(
|
||||
id: 'newjeans',
|
||||
name: 'NewJeans',
|
||||
members: 11230,
|
||||
nebula: false,
|
||||
),
|
||||
OnboardingFandom(id: 'aespa', name: 'aespa', members: 8, nebula: true),
|
||||
])
|
||||
if (f.name.toLowerCase().startsWith(query.trim().toLowerCase())) f,
|
||||
];
|
||||
|
||||
@override
|
||||
Future<void> join(List<String> fandomIds) async => joined.addAll(fandomIds);
|
||||
}
|
||||
@@ -0,0 +1,720 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/push/push_messaging.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
import '../home/coach_mark.dart';
|
||||
import '../home/home_data.dart';
|
||||
import '../home/nebula_create_screen.dart';
|
||||
import '../my/my_data.dart'
|
||||
show nicknameMaxLength, nicknameMinLength, nicknameReserved;
|
||||
import 'auth_repository.dart';
|
||||
import 'onboarding_data.dart';
|
||||
import 'signup_screens.dart';
|
||||
|
||||
/// 온보딩 ① 소개 3장 — FR-CMN-03 「소개는 3장으로 고정 · 건너뛰기를 눌러도 로그인 화면으로」.
|
||||
///
|
||||
/// 앱 Figma 사본에 소개 화면이 없다(해석 확인 Q-OB-02 A 「사본의 컴포넌트 · 색 · 글꼴 · 화면 틀로
|
||||
/// 구성」). 문구 · 이미지는 발주사 제공본이 오면 바꾼다(해석 확인 요청 E-Q7).
|
||||
class IntroScreen extends StatefulWidget {
|
||||
const IntroScreen({super.key, required this.onDone});
|
||||
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
State<IntroScreen> createState() => _IntroScreenState();
|
||||
}
|
||||
|
||||
class _IntroScreenState extends State<IntroScreen> {
|
||||
final _pages = PageController();
|
||||
int _page = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final slides = [
|
||||
(l10n.onbIntro1Title, l10n.onbIntro1Body),
|
||||
(l10n.onbIntro2Title, l10n.onbIntro2Body),
|
||||
(l10n.onbIntro3Title, l10n.onbIntro3Body),
|
||||
];
|
||||
final last = _page == slides.length - 1;
|
||||
return Scaffold(
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: TextButton(
|
||||
onPressed: widget.onDone,
|
||||
child: Text(l10n.onbSkip),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: PageView(
|
||||
controller: _pages,
|
||||
onPageChanged: (i) => setState(() => _page = i),
|
||||
children: [
|
||||
for (final (title, body) in slides)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s6),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox.square(
|
||||
dimension: 120,
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cosmicAccent,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s6),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: _title,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Text(body, textAlign: TextAlign.center, style: _body),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
for (var i = 0; i < slides.length; i++)
|
||||
Container(
|
||||
margin: const EdgeInsets.all(4),
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: i == _page
|
||||
? AppColors.cosmicAccent
|
||||
: AppColors.onCosmicMuted,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: _PrimaryButton(
|
||||
label: last ? l10n.commonStart : l10n.commonNext,
|
||||
onPressed: last
|
||||
? widget.onDone
|
||||
: () => _pages.nextPage(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOut,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Step { terms, profile, artist, planet, permissions }
|
||||
|
||||
/// 온보딩 ②~⑥ — 로그인(가입 · 로그인 버튼 구분 없이) 뒤 서버에 약관 동의가 없으면 여기로 온다.
|
||||
///
|
||||
/// 순서는 10/3 통합회신 1항 그대로: 약관 → 프로필 → 관심 아티스트/Primary → 행성 가입 또는 성운
|
||||
/// 생성 → 권한 안내. 약관을 기록한 뒤 앱을 닫으면 다음 실행은 HOME(`홈_초기사용자`)으로 간다
|
||||
/// (Q-OB-02 A 「`홈_초기사용자` — 가입 흐름을 마치기 전에 앱을 닫은 사용자에게 사본대로」).
|
||||
class OnboardingFlow extends StatefulWidget {
|
||||
const OnboardingFlow({
|
||||
super.key,
|
||||
required this.auth,
|
||||
required this.repository,
|
||||
required this.home,
|
||||
this.push,
|
||||
});
|
||||
|
||||
final AuthRepository auth;
|
||||
final OnboardingRepository repository;
|
||||
|
||||
/// 성운 생성(`홈/성운 생성` 사본 화면)이 쓰는 저장소.
|
||||
final HomeRepository home;
|
||||
|
||||
/// 권한 안내의 OS 알림 팝업. 목 모드에서는 null.
|
||||
final PushMessaging? push;
|
||||
|
||||
@override
|
||||
State<OnboardingFlow> createState() => _OnboardingFlowState();
|
||||
}
|
||||
|
||||
class _OnboardingFlowState extends State<OnboardingFlow> {
|
||||
_Step _step = _Step.terms;
|
||||
bool _busy = false;
|
||||
|
||||
/// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary).
|
||||
final _picked = <OnboardingFandom>[];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// 가입 모드는 제공자 인증 전에 약관을 받았다 — 다시 묻지 않고 기록만 한다.
|
||||
final pending = widget.auth.pendingConsent;
|
||||
if (pending != null) _agree(pending);
|
||||
}
|
||||
|
||||
Future<void> _run(Future<void> Function() task) async {
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await task();
|
||||
} on BackendException catch (e) {
|
||||
_toast(e.message);
|
||||
} catch (_) {
|
||||
if (mounted) _toast(context.l10n.commonRequestFailed);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _toast(String message) => ScaffoldMessenger.maybeOf(context)
|
||||
?..hideCurrentSnackBar()
|
||||
..showSnackBar(SnackBar(content: Text(message)));
|
||||
|
||||
void _agree(Set<String> agreed) {
|
||||
if (_busy) return;
|
||||
_run(() async {
|
||||
await widget.repository.agree(agreed, signupTermsVersion);
|
||||
if (mounted) setState(() => _step = _Step.profile);
|
||||
});
|
||||
}
|
||||
|
||||
void _back() => setState(
|
||||
() => _step = switch (_step) {
|
||||
_Step.artist => _Step.profile,
|
||||
_Step.planet => _Step.artist,
|
||||
final s => s,
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> _finish() async {
|
||||
try {
|
||||
await widget.push?.requestPermission();
|
||||
} catch (_) {
|
||||
// 거부 · 플러그인 오류여도 다음으로 간다(FR-CMN-08).
|
||||
}
|
||||
await armCoachMark();
|
||||
widget.auth.completeOnboarding();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final body = switch (_step) {
|
||||
_Step.terms => TermsScreen(onAgreed: _agree, onBack: widget.auth.signOut),
|
||||
_Step.profile => _ProfileStep(
|
||||
repository: widget.repository,
|
||||
onDone: () => setState(() => _step = _Step.artist),
|
||||
),
|
||||
_Step.artist => _ArtistStep(
|
||||
repository: widget.repository,
|
||||
picked: _picked,
|
||||
onNext: () => setState(() => _step = _Step.planet),
|
||||
onBack: _back,
|
||||
),
|
||||
_Step.planet => _PlanetStep(
|
||||
picked: _picked,
|
||||
busy: _busy,
|
||||
onBack: _back,
|
||||
onJoin: () => _run(() async {
|
||||
await widget.repository.join([for (final f in _picked) f.id]);
|
||||
if (mounted) setState(() => _step = _Step.permissions);
|
||||
}),
|
||||
onCreateNebula: () async {
|
||||
final created = await Navigator.of(context).push<String>(
|
||||
MaterialPageRoute(
|
||||
builder: (_) => NebulaCreateScreen(repository: widget.home),
|
||||
),
|
||||
);
|
||||
if (created != null && mounted) {
|
||||
setState(() => _step = _Step.permissions);
|
||||
}
|
||||
},
|
||||
),
|
||||
_Step.permissions => _PermissionStep(onContinue: _finish),
|
||||
};
|
||||
return PopScope(
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, _) {
|
||||
if (!didPop) _back();
|
||||
},
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ③ 프로필 — 닉네임 2~20(중복 허용 · B-52) · 국가/지역 · 표시 언어 · 추천인 코드(선택).
|
||||
/// 사본 `MY/프로필 설정` 구성을 따른다. 국가 기본값은 기기 지역(Figma `554:24126` 「앱 기동 단계에서
|
||||
/// 조회된 국가/언어를 디폴트」).
|
||||
class _ProfileStep extends StatefulWidget {
|
||||
const _ProfileStep({required this.repository, required this.onDone});
|
||||
|
||||
final OnboardingRepository repository;
|
||||
final VoidCallback onDone;
|
||||
|
||||
@override
|
||||
State<_ProfileStep> createState() => _ProfileStepState();
|
||||
}
|
||||
|
||||
class _ProfileStepState extends State<_ProfileStep> {
|
||||
final _nickname = TextEditingController();
|
||||
final _referral = TextEditingController();
|
||||
String? _country = _deviceCountry();
|
||||
Locale? _locale;
|
||||
String? _nicknameError;
|
||||
String? _referralError;
|
||||
bool _busy = false;
|
||||
|
||||
/// 기기 지역. 없으면 비워 두고 사용자가 고르게 한다(임의 국가로 채우지 않는다).
|
||||
static String? _deviceCountry() {
|
||||
final code = WidgetsBinding.instance.platformDispatcher.locale.countryCode
|
||||
?.toUpperCase();
|
||||
return code != null && isoCountries.contains(code) ? code : null;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_nickname.addListener(() => setState(() => _nicknameError = null));
|
||||
widget.repository.currentProfile().then((p) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
if (_nickname.text.isEmpty) _nickname.text = p.nickname;
|
||||
if (p.country != null && isoCountries.contains(p.country)) {
|
||||
_country = p.country!;
|
||||
}
|
||||
});
|
||||
}, onError: (_) {});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nickname.dispose();
|
||||
_referral.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
bool get _valid {
|
||||
final n = _nickname.text.trim().characters.length;
|
||||
return n >= nicknameMinLength && n <= nicknameMaxLength && _country != null;
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final l10n = context.l10n;
|
||||
final nickname = _nickname.text.trim();
|
||||
if (nicknameReserved(nickname)) {
|
||||
setState(() => _nicknameError = l10n.nicknameReserved);
|
||||
return;
|
||||
}
|
||||
final scope = LocaleScope.maybeOf(context);
|
||||
final locale = _locale ?? scope?.locale ?? fallbackLocale;
|
||||
setState(() => _busy = true);
|
||||
try {
|
||||
await widget.repository.saveProfile(
|
||||
nickname: nickname,
|
||||
country: _country!,
|
||||
locale: localeTag(locale),
|
||||
);
|
||||
if (_locale != null) await scope?.select(_locale!);
|
||||
final code = _referral.text.trim().toUpperCase();
|
||||
if (code.isNotEmpty) {
|
||||
try {
|
||||
await widget.repository.attributeReferral(code);
|
||||
} on BackendException catch (e) {
|
||||
if (mounted) setState(() => _referralError = e.message);
|
||||
return;
|
||||
}
|
||||
}
|
||||
widget.onDone();
|
||||
} on BackendException catch (e) {
|
||||
if (mounted) setState(() => _nicknameError = e.message);
|
||||
} finally {
|
||||
if (mounted) setState(() => _busy = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final current = _locale ?? LocaleScope.maybeOf(context)?.locale;
|
||||
return _StepScaffold(
|
||||
title: l10n.onbProfileTitle,
|
||||
action: _PrimaryButton(
|
||||
label: l10n.commonNext,
|
||||
onPressed: _valid && !_busy ? _save : null,
|
||||
),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _nickname,
|
||||
maxLength: nicknameMaxLength,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.settingsNickname,
|
||||
helperText: l10n.onbNicknameHint,
|
||||
errorText: _nicknameError,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
DropdownButtonFormField<String>(
|
||||
initialValue: _country,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: l10n.onbCountry),
|
||||
items: [
|
||||
for (final c in isoCountries)
|
||||
DropdownMenuItem(value: c, child: Text('${countryFlag(c)} $c')),
|
||||
],
|
||||
onChanged: (v) => setState(() => _country = v ?? _country),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
DropdownButtonFormField<Locale>(
|
||||
initialValue: appLocales.contains(current) ? current : null,
|
||||
isExpanded: true,
|
||||
decoration: InputDecoration(labelText: l10n.onbLanguage),
|
||||
items: [
|
||||
for (final l in appLocales)
|
||||
DropdownMenuItem(value: l, child: Text(languageName(l))),
|
||||
],
|
||||
onChanged: (v) => setState(() => _locale = v),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
TextField(
|
||||
controller: _referral,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
maxLength: 10,
|
||||
onChanged: (_) => setState(() => _referralError = null),
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.onbReferral,
|
||||
helperText: l10n.onbReferralHint,
|
||||
errorText: _referralError,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ④ 관심 아티스트 / Primary Fandom — 최소 1개(FR-CMN-06). 처음 고른 팬덤이 대표가 된다.
|
||||
/// 찾는 아티스트가 없으면 ⑤ 에서 성운을 만든다.
|
||||
class _ArtistStep extends StatefulWidget {
|
||||
const _ArtistStep({
|
||||
required this.repository,
|
||||
required this.picked,
|
||||
required this.onNext,
|
||||
required this.onBack,
|
||||
});
|
||||
|
||||
final OnboardingRepository repository;
|
||||
final List<OnboardingFandom> picked;
|
||||
final VoidCallback onNext;
|
||||
final VoidCallback onBack;
|
||||
|
||||
@override
|
||||
State<_ArtistStep> createState() => _ArtistStepState();
|
||||
}
|
||||
|
||||
class _ArtistStepState extends State<_ArtistStep> {
|
||||
late Future<List<OnboardingFandom>> _list = widget.repository.fandoms();
|
||||
final _query = TextEditingController();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_query.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _toggle(OnboardingFandom f) => setState(() {
|
||||
final i = widget.picked.indexWhere((p) => p.id == f.id);
|
||||
i >= 0 ? widget.picked.removeAt(i) : widget.picked.add(f);
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final picked = widget.picked;
|
||||
return _StepScaffold(
|
||||
title: l10n.onbArtistTitle,
|
||||
subtitle: l10n.onbArtistBody,
|
||||
onBack: widget.onBack,
|
||||
action: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_PrimaryButton(
|
||||
label: l10n.commonNext,
|
||||
onPressed: picked.isEmpty ? null : widget.onNext,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
picked.clear();
|
||||
widget.onNext();
|
||||
},
|
||||
child: Text(l10n.onbArtistNone),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
TextField(
|
||||
controller: _query,
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.onbArtistSearch,
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
),
|
||||
onSubmitted: (q) =>
|
||||
setState(() => _list = widget.repository.fandoms(query: q)),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
FutureBuilder<List<OnboardingFandom>>(
|
||||
future: _list,
|
||||
builder: (context, snap) {
|
||||
if (snap.hasError) {
|
||||
return TextButton(
|
||||
onPressed: () => setState(
|
||||
() => _list = widget.repository.fandoms(query: _query.text),
|
||||
),
|
||||
child: Text(l10n.commonRetry),
|
||||
);
|
||||
}
|
||||
final list = snap.data;
|
||||
if (list == null) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (list.isEmpty) return Text(l10n.commonNoResults);
|
||||
return Column(
|
||||
children: [
|
||||
for (final f in list)
|
||||
CheckboxListTile(
|
||||
value: picked.any((p) => p.id == f.id),
|
||||
onChanged: (_) => _toggle(f),
|
||||
title: Text(f.name),
|
||||
subtitle: Text(
|
||||
[
|
||||
if (f.nebula) l10n.onbNebulaTag,
|
||||
l10n.onbMembers('${f.members}'),
|
||||
].join(' · '),
|
||||
),
|
||||
secondary: picked.isNotEmpty && picked.first.id == f.id
|
||||
? Chip(label: Text(l10n.onbPrimary))
|
||||
: null,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⑤ 행성 가입 또는 성운 생성 — 성운 생성은 사본 `홈/성운 생성`(`340:75466`) 그대로.
|
||||
class _PlanetStep extends StatelessWidget {
|
||||
const _PlanetStep({
|
||||
required this.picked,
|
||||
required this.busy,
|
||||
required this.onBack,
|
||||
required this.onJoin,
|
||||
required this.onCreateNebula,
|
||||
});
|
||||
|
||||
final List<OnboardingFandom> picked;
|
||||
final bool busy;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onJoin;
|
||||
final VoidCallback onCreateNebula;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return _StepScaffold(
|
||||
title: l10n.onbPlanetTitle,
|
||||
onBack: onBack,
|
||||
action: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (picked.isNotEmpty)
|
||||
_PrimaryButton(
|
||||
label: l10n.onbPlanetJoin,
|
||||
onPressed: busy ? null : onJoin,
|
||||
),
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
OutlinedButton(
|
||||
onPressed: busy ? null : onCreateNebula,
|
||||
child: Text(l10n.onbPlanetCreate),
|
||||
),
|
||||
],
|
||||
),
|
||||
children: [
|
||||
for (final (i, f) in picked.indexed)
|
||||
ListTile(
|
||||
title: Text(f.name),
|
||||
trailing: i == 0 ? Chip(label: Text(l10n.onbPrimary)) : null,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// ⑥ 권한 안내 — 한 화면에서 쓰임을 설명하고 OS 팝업은 알림만(FR-CMN-08 · C-84). 건강은 걷기 Quest,
|
||||
/// 위치는 위치 Quest 에 처음 들어갈 때 요청한다. 거부해도 다음으로 간다.
|
||||
class _PermissionStep extends StatefulWidget {
|
||||
const _PermissionStep({required this.onContinue});
|
||||
|
||||
final Future<void> Function() onContinue;
|
||||
|
||||
@override
|
||||
State<_PermissionStep> createState() => _PermissionStepState();
|
||||
}
|
||||
|
||||
class _PermissionStepState extends State<_PermissionStep> {
|
||||
bool _busy = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return _StepScaffold(
|
||||
title: l10n.onbPermTitle,
|
||||
subtitle: l10n.onbPermBody,
|
||||
action: _PrimaryButton(
|
||||
label: l10n.onbPermContinue,
|
||||
onPressed: _busy
|
||||
? null
|
||||
: () async {
|
||||
setState(() => _busy = true);
|
||||
await widget.onContinue();
|
||||
},
|
||||
),
|
||||
children: [
|
||||
for (final (icon, title, desc) in [
|
||||
(
|
||||
Icons.notifications_outlined,
|
||||
l10n.onbPermPush,
|
||||
l10n.onbPermPushDesc,
|
||||
),
|
||||
(Icons.directions_walk, l10n.onbPermHealth, l10n.onbPermHealthDesc),
|
||||
(
|
||||
Icons.place_outlined,
|
||||
l10n.onbPermLocation,
|
||||
l10n.onbPermLocationDesc,
|
||||
),
|
||||
])
|
||||
ListTile(
|
||||
leading: Icon(icon, color: AppColors.cosmicAccent),
|
||||
title: Text(title),
|
||||
subtitle: Text(desc),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 단계 화면 틀 — 사본 가입 화면과 같은 코스믹 다크 · 하단 고정 버튼.
|
||||
class _StepScaffold extends StatelessWidget {
|
||||
const _StepScaffold({
|
||||
required this.title,
|
||||
required this.action,
|
||||
required this.children,
|
||||
this.subtitle,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final Widget action;
|
||||
final List<Widget> children;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
leading: onBack == null ? null : BackButton(onPressed: onBack),
|
||||
),
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
children: [
|
||||
Text(title, style: _title),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: AppSpace.s2),
|
||||
Text(subtitle!, style: _body),
|
||||
],
|
||||
const SizedBox(height: AppSpace.s5),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(padding: const EdgeInsets.all(AppSpace.s4), child: action),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _PrimaryButton extends StatelessWidget {
|
||||
const _PrimaryButton({required this.label, required this.onPressed});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
disabledBackgroundColor: const Color(0xFF333333),
|
||||
disabledForegroundColor: const Color(0xFF777777),
|
||||
),
|
||||
child: Text(label),
|
||||
);
|
||||
}
|
||||
|
||||
const _title = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
color: AppColors.onCosmic,
|
||||
);
|
||||
|
||||
const _body = TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 14,
|
||||
height: 1.6,
|
||||
color: Color(0xFFCDCDCD),
|
||||
);
|
||||
|
||||
/// 국기 이모지 — 지역 표시 기호 두 글자.
|
||||
String countryFlag(String code) =>
|
||||
String.fromCharCodes(code.codeUnits.map((c) => c - 0x41 + 0x1F1E6));
|
||||
|
||||
/// ISO 3166-1 alpha-2 전체. 랭킹 집계 · Admin 사용자 일람의 「거주 국가/지역」 값이다.
|
||||
final List<String> isoCountries =
|
||||
('AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ '
|
||||
'BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK '
|
||||
'DM DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ '
|
||||
'GR GS GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH '
|
||||
'KI KM KN KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM '
|
||||
'MN MO MP MQ MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF '
|
||||
'PG PH PK PL PM PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM '
|
||||
'SN SO SR SS ST SV SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US '
|
||||
'UY UZ VA VC VE VG VI VN VU WF WS YE YT ZA ZM ZW')
|
||||
.split(' ');
|
||||
@@ -5,19 +5,34 @@ import '../../core/theme/tokens.dart';
|
||||
|
||||
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
|
||||
///
|
||||
/// 필수 3항(서비스 · 위치기반 · 만 14세)을 모두 체크해야 「완료」가 켜진다. 완료하면 `true` 를
|
||||
/// 돌려주고, 부른 쪽([SimpleLoginScreen] 가입 모드)이 제공자 인증으로 넘어간다.
|
||||
/// 필수 4항을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
|
||||
/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 온보딩은 [onAgreed] 로 받는다.
|
||||
/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다.
|
||||
///
|
||||
/// 약관 본문은 시안의 자리 표시 문구다. 확정 약관이 오면 [_body] 만 바꾼다.
|
||||
/// 필수 항목은 Figma 의 서비스 · 위치기반 · 만 14세에 개인정보 처리방침을 더했다(10/3 통합회신
|
||||
/// 3절 4 「이용약관 · 개인정보처리방침 문구 제공」). 구성은 해석 확인 요청 E-Q1 회신 대기다.
|
||||
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와
|
||||
/// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
|
||||
class TermsScreen extends StatefulWidget {
|
||||
const TermsScreen({super.key});
|
||||
const TermsScreen({super.key, this.onAgreed, this.onBack});
|
||||
|
||||
/// 온보딩 안에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다.
|
||||
final ValueChanged<Set<String>>? onAgreed;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
@override
|
||||
State<TermsScreen> createState() => _TermsScreenState();
|
||||
}
|
||||
|
||||
/// 동의 기록에 남는 약관 버전. 🟡 임시 — 본문이 자리 표시 문구인 동안은 `draft` 다.
|
||||
const signupTermsVersion = 'draft-1';
|
||||
|
||||
/// 서버 `SIGNUP_REQUIRED_TERMS` + 선택 `marketing` 과 같은 키.
|
||||
List<String> get signupTermKeys => [for (final t in _Term.values) t.name];
|
||||
|
||||
enum _Term {
|
||||
service(required: true, body: true),
|
||||
privacy(required: true, body: true),
|
||||
location(required: true, body: true),
|
||||
age(required: true, body: false),
|
||||
marketing(required: false, body: true);
|
||||
@@ -29,6 +44,7 @@ enum _Term {
|
||||
|
||||
String label(AppLocalizations l10n) => switch (this) {
|
||||
service => l10n.termsService,
|
||||
privacy => l10n.termsPrivacy,
|
||||
location => l10n.termsLocation,
|
||||
age => l10n.termsAge,
|
||||
marketing => l10n.termsMarketing,
|
||||
@@ -48,6 +64,12 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
bool get _ready =>
|
||||
_Term.values.where((t) => t.required).every(_agreed.contains);
|
||||
|
||||
void _done() {
|
||||
final agreed = {for (final t in _agreed) t.name};
|
||||
final cb = widget.onAgreed;
|
||||
cb != null ? cb(agreed) : Navigator.of(context).pop(agreed);
|
||||
}
|
||||
|
||||
void _toggle(_Term t) =>
|
||||
setState(() => _agreed.contains(t) ? _agreed.remove(t) : _agreed.add(t));
|
||||
|
||||
@@ -120,7 +142,7 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
onPressed: widget.onBack ?? () => Navigator.of(context).pop(),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: Colors.black,
|
||||
foregroundColor: AppColors.onCosmic,
|
||||
@@ -131,9 +153,7 @@ class _TermsScreenState extends State<TermsScreen> {
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _ready
|
||||
? () => Navigator.of(context).pop(true)
|
||||
: null,
|
||||
onPressed: _ready ? _done : null,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/theme/tokens.dart';
|
||||
|
||||
/// 1회성 로컬 Coach Mark — 9/21 회신서 ④-2 A · 10/3 통합회신 1항 · 해석 확인 Q-OB-02 A.
|
||||
///
|
||||
/// 온보딩 6단계를 마치면 [armCoachMark] 가 이 기기에 「대기」를 남기고, 그 뒤 처음 ZAP 이 보이는
|
||||
/// HOME 에서 [maybeShowCoachMark] 가 2단계(① Zap 버튼 ② 하단 탭 MY)를 띄운 뒤 지운다.
|
||||
/// 서버 데이터는 없다(단말당 1회 · 재설치하면 다시 나올 수 있다).
|
||||
const _pendingKey = 'coach.pending';
|
||||
|
||||
/// 하단 탭 MY 아이콘 위치를 찾는 열쇠 — `AppShell` 이 단다.
|
||||
final coachMyTabKey = GlobalKey(debugLabel: 'coach-my-tab');
|
||||
|
||||
Future<void> armCoachMark() async {
|
||||
try {
|
||||
await (await SharedPreferences.getInstance()).setBool(_pendingKey, true);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> maybeShowCoachMark(BuildContext context, GlobalKey zapKey) async {
|
||||
SharedPreferences prefs;
|
||||
try {
|
||||
prefs = await SharedPreferences.getInstance();
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
if (prefs.getBool(_pendingKey) != true || !context.mounted) return;
|
||||
final zap = _rectOf(zapKey);
|
||||
if (zap == null) return; // ZAP 이 없는 HOME(무소속) — 가입한 뒤 다시 본다.
|
||||
await prefs.setBool(_pendingKey, false);
|
||||
if (!context.mounted) return;
|
||||
// 하단 탭 MY — 셸이 MY 목적지 아이콘에 [coachMyTabKey] 를 단다. 못 찾으면 ①만 보인다.
|
||||
final my = _rectOf(coachMyTabKey)?.inflate(12);
|
||||
final l10n = context.l10n;
|
||||
await showGeneralDialog<void>(
|
||||
context: context,
|
||||
barrierColor: Colors.transparent,
|
||||
pageBuilder: (_, _, _) => _CoachOverlay(
|
||||
steps: [
|
||||
(zap.inflate(8), l10n.coachZap),
|
||||
if (my != null) (my, l10n.coachMy),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Rect? _rectOf(GlobalKey key) {
|
||||
final box = key.currentContext?.findRenderObject() as RenderBox?;
|
||||
if (box == null || !box.hasSize) return null;
|
||||
return box.localToGlobal(Offset.zero) & box.size;
|
||||
}
|
||||
|
||||
class _CoachOverlay extends StatefulWidget {
|
||||
const _CoachOverlay({required this.steps});
|
||||
|
||||
final List<(Rect, String)> steps;
|
||||
|
||||
@override
|
||||
State<_CoachOverlay> createState() => _CoachOverlayState();
|
||||
}
|
||||
|
||||
class _CoachOverlayState extends State<_CoachOverlay> {
|
||||
int _i = 0;
|
||||
|
||||
void _next() => _i + 1 < widget.steps.length
|
||||
? setState(() => _i++)
|
||||
: Navigator.of(context).pop();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (hole, text) = widget.steps[_i];
|
||||
final above = hole.center.dy > MediaQuery.sizeOf(context).height / 2;
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: _next,
|
||||
child: CustomPaint(painter: _Dim(hole)),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 24,
|
||||
right: 24,
|
||||
top: above ? null : hole.bottom + 16,
|
||||
bottom: above
|
||||
? MediaQuery.sizeOf(context).height - hole.top + 16
|
||||
: null,
|
||||
child: Material(
|
||||
color: AppColors.cosmicSurfaceContainer,
|
||||
borderRadius: BorderRadius.circular(AppRadius.md),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(AppSpace.s4),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: AppFonts.body,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.onCosmic,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: AppSpace.s3),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: Text(context.l10n.onbSkip),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _next,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
foregroundColor: AppColors.onCosmicAccent,
|
||||
),
|
||||
child: Text(
|
||||
_i + 1 < widget.steps.length
|
||||
? context.l10n.commonNext
|
||||
: context.l10n.commonConfirm,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 나머지 화면은 어둡게, 가리킨 곳만 둥글게 뚫는다.
|
||||
class _Dim extends CustomPainter {
|
||||
const _Dim(this.hole);
|
||||
|
||||
final Rect hole;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final path = Path()
|
||||
..fillType = PathFillType.evenOdd
|
||||
..addRect(Offset.zero & size)
|
||||
..addRRect(RRect.fromRectAndRadius(hole, const Radius.circular(16)));
|
||||
canvas.drawPath(
|
||||
path,
|
||||
Paint()..color = Colors.black.withValues(alpha: 0.72),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_Dim old) => old.hole != hole;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// formatPop 은 core/format.dart 로 옮겼다. 기존 `show formatPop` 가져오기를 위해 다시 내보낸다.
|
||||
export '../../core/format.dart' show formatPop;
|
||||
|
||||
import 'coach_mark.dart';
|
||||
import '../planet/planet_history_screen.dart';
|
||||
import '../galaxy/widgets/galaxy_web_view.dart';
|
||||
import '../galaxy/galaxy_search_screen.dart';
|
||||
@@ -121,6 +122,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
_snapshot = widget.repository.load();
|
||||
if (widget.galaxyRepository case final repository?) {
|
||||
_galaxy = repository.load();
|
||||
// 온보딩을 마친 뒤 첫 HOME — ZAP 이 그려진 다음 Coach Mark(1회)를 띄운다.
|
||||
Future.wait<Object?>([_snapshot, _galaxy!]).then((_) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) maybeShowCoachMark(context, _zapKey);
|
||||
});
|
||||
}, onError: (_) {});
|
||||
_zap = ZapController(
|
||||
repository,
|
||||
onFailure: (message) {
|
||||
|
||||
@@ -814,12 +814,18 @@ abstract interface class MyRepository {
|
||||
|
||||
/// 앱 버전. 명세 — *최신버전 → 「최신 버전」 · 이전버전 → 「n.n」*.
|
||||
Future<({String current, bool latest})> appVersion();
|
||||
|
||||
/// 서비스 탈퇴 — `fnAccountWithdraw`(Figma `554:24885`). SNS 연동이 남아 있으면 E-AUTH-14.
|
||||
Future<void> withdraw();
|
||||
}
|
||||
|
||||
/// 서버가 붙기 전까지 쓰는 표본. **정책값이 아니다.**
|
||||
class SampleMyRepository implements MyRepository {
|
||||
SampleMyRepository();
|
||||
|
||||
@override
|
||||
Future<void> withdraw() async {}
|
||||
|
||||
static final _base = DateTime(2026, 9, 1, 12);
|
||||
|
||||
String _nickname = '아미보이';
|
||||
|
||||
@@ -112,7 +112,8 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
// **이메일 앞부분을 쓰지 않는다** — 프로필 수정이 그 값을 그대로
|
||||
// 닉네임으로 저장해 홈 기여자명으로 퍼졌다. 이메일은 설정 「계정 정보」
|
||||
// (명세 `554:22634` *닉네임 · 가입 이메일 값*)에만 둔다.
|
||||
final email = _backend.email ?? '';
|
||||
// Kakao(커스텀 토큰)는 Auth 이메일이 비어 있어 서버가 사용자 문서에 남긴 동의 이메일을 먼저 본다.
|
||||
final email = user.strOr('email', _backend.email ?? '');
|
||||
|
||||
final photoUrl = user.strOr('photoUrl');
|
||||
|
||||
@@ -167,6 +168,9 @@ class FirestoreMyRepository extends SampleMyRepository {
|
||||
}, SetOptions(merge: true));
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> withdraw() => _backend.call('fnAccountWithdraw');
|
||||
|
||||
@override
|
||||
Future<void> updateNickname(String nickname) async {
|
||||
final uid = _backend.requireUid;
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import '../../core/data/backend.dart' show BackendException;
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/push/push_messaging.dart';
|
||||
@@ -230,7 +234,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
title: l10n.settingsWithdrawConfirm,
|
||||
body: l10n.settingsWithdrawBody,
|
||||
action: l10n.settingsWithdraw,
|
||||
withdraw: true,
|
||||
onConfirm: _withdraw,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -339,6 +343,34 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
await LocaleScope.maybeOf(context)?.select(picked);
|
||||
}
|
||||
|
||||
/// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 ·
|
||||
/// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다.
|
||||
/// 서버도 같은 조건을 다시 본다(E-AUTH-14).
|
||||
Future<void> _withdraw() async {
|
||||
final l10n = context.l10n;
|
||||
if (_linked.values.where((e) => e != null).length > 1) {
|
||||
await _notice(l10n.settingsWithdrawLinked);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// 푸시 정리를 먼저 — 계정이 지워지면 이 기기 토큰을 지울 수 없다.
|
||||
await widget.push?.clearOnSignOut();
|
||||
await widget.repository.withdraw();
|
||||
} on BackendException catch (e) {
|
||||
if (mounted) {
|
||||
await _notice(
|
||||
e.code == 'E-AUTH-14' ? l10n.settingsWithdrawLinked : e.message,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
await widget.auth?.signOut();
|
||||
// 「앱 종료」 — Android 만. iOS 는 앱이 스스로 종료하지 않는다(스토어 지침) — 로그인 화면으로 간다.
|
||||
if (defaultTargetPlatform == TargetPlatform.android) {
|
||||
await SystemNavigator.pop();
|
||||
}
|
||||
}
|
||||
|
||||
/// 안내 팝업(확인 하나) — 시안 `554:23103`.
|
||||
Future<void> _notice(String message) => showAlertDialog(context, message);
|
||||
|
||||
@@ -347,7 +379,6 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
required String title,
|
||||
String? body,
|
||||
required String action,
|
||||
bool withdraw = false,
|
||||
Future<void> Function()? onConfirm,
|
||||
}) async {
|
||||
final ok = await showConfirmDialog(
|
||||
@@ -356,11 +387,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
body: body,
|
||||
confirmLabel: action,
|
||||
);
|
||||
if (ok == true && onConfirm != null) {
|
||||
await onConfirm();
|
||||
} else if (ok == true && withdraw && mounted) {
|
||||
_toast(context.l10n.settingsWithdrawLater);
|
||||
}
|
||||
if (ok == true && onConfirm != null) await onConfirm();
|
||||
return ok;
|
||||
}
|
||||
}
|
||||
|
||||
+38
-1
@@ -830,5 +830,42 @@
|
||||
"questModerationRejected": "Rejected in review: {reason}. Your deposit and fee were returned.",
|
||||
"questSubmittedForReview": "Submitted. It will be published after review.",
|
||||
"popReasonQuestFeeRefund": "Quest fee refund",
|
||||
"questGoalSteps": "Step goal"
|
||||
"questGoalSteps": "Step goal",
|
||||
"eclipseTapToJoin": "Can't shake? Tap to join",
|
||||
"termsPrivacy": "Privacy Policy and Personal Data Collection (required)",
|
||||
"onbSkip": "Skip",
|
||||
"onbIntro1Title": "Grow your fandom's planet",
|
||||
"onbIntro1Body": "Collect POP and send it with ZAP\nto grow your fandom planet together.",
|
||||
"onbIntro2Title": "Cheer with quests",
|
||||
"onbIntro2Body": "Complete walking, attendance and streaming quests\nand earn POP rewards.",
|
||||
"onbIntro3Title": "Protect it with fellow fans",
|
||||
"onbIntro3Body": "Stop the Eclipse together and support\nyour artist through campaigns.",
|
||||
"onbProfileTitle": "Set up your profile",
|
||||
"onbNicknameHint": "2–20 characters · duplicates are allowed",
|
||||
"onbCountry": "Country/Region",
|
||||
"onbLanguage": "Display language",
|
||||
"onbReferral": "Referral code (optional)",
|
||||
"onbReferralHint": "Enter a code if a friend shared one",
|
||||
"onbArtistTitle": "Choose the artists you follow",
|
||||
"onbArtistBody": "The first artist you pick becomes your main planet. You can join several fandoms.",
|
||||
"onbArtistSearch": "Search artist name",
|
||||
"onbArtistNone": "I can't find my artist",
|
||||
"onbPrimary": "Main",
|
||||
"onbNebulaTag": "Nebula",
|
||||
"onbMembers": "{count} members",
|
||||
"onbPlanetTitle": "Join a planet or create a nebula",
|
||||
"onbPlanetJoin": "Join selected planets",
|
||||
"onbPlanetCreate": "Create a new nebula",
|
||||
"onbPermTitle": "App permissions",
|
||||
"onbPermBody": "These permissions are needed only for the related features. You can use the app without them.",
|
||||
"onbPermPush": "Notifications",
|
||||
"onbPermPushDesc": "Get Eclipse, HERO TIME and quest updates.",
|
||||
"onbPermHealth": "Health data (steps)",
|
||||
"onbPermHealthDesc": "Counts steps for walking quests. Requested when you first start one.",
|
||||
"onbPermLocation": "Location",
|
||||
"onbPermLocationDesc": "Checks arrival for location quests. Requested when you first start one.",
|
||||
"onbPermContinue": "Continue",
|
||||
"coachZap": "Tap to send POP to our planet",
|
||||
"coachMy": "Your records are here",
|
||||
"settingsWithdrawLinked": "Please unlink your connected accounts before deleting your account."
|
||||
}
|
||||
|
||||
+38
-1
@@ -830,5 +830,42 @@
|
||||
"questModerationRejected": "Rechazada en revisión: {reason}. Te devolvimos el depósito y la comisión.",
|
||||
"questSubmittedForReview": "Enviada. Se publicará tras la revisión.",
|
||||
"popReasonQuestFeeRefund": "Reembolso de comisión de misión",
|
||||
"questGoalSteps": "Meta de pasos"
|
||||
"questGoalSteps": "Meta de pasos",
|
||||
"eclipseTapToJoin": "¿No puedes agitar? Toca para participar",
|
||||
"termsPrivacy": "Política de privacidad y recopilación de datos personales (obligatorio)",
|
||||
"onbSkip": "Omitir",
|
||||
"onbIntro1Title": "Haz crecer el planeta de tu fandom",
|
||||
"onbIntro1Body": "Reúne POP y envíalo con ZAP\npara que el planeta de tu fandom crezca.",
|
||||
"onbIntro2Title": "Apoya con misiones",
|
||||
"onbIntro2Body": "Completa misiones de caminar, asistencia y streaming\ny gana POP.",
|
||||
"onbIntro3Title": "Protégelo junto a otros fans",
|
||||
"onbIntro3Body": "Detengan juntos el Eclipse y apoyen\na su artista con campañas.",
|
||||
"onbProfileTitle": "Configura tu perfil",
|
||||
"onbNicknameHint": "2–20 caracteres · se permiten duplicados",
|
||||
"onbCountry": "País/Región",
|
||||
"onbLanguage": "Idioma de visualización",
|
||||
"onbReferral": "Código de referido (opcional)",
|
||||
"onbReferralHint": "Introduce el código si un amigo te lo compartió",
|
||||
"onbArtistTitle": "Elige a los artistas que sigues",
|
||||
"onbArtistBody": "El primer artista que elijas será tu planeta principal. Puedes unirte a varios fandoms.",
|
||||
"onbArtistSearch": "Buscar nombre del artista",
|
||||
"onbArtistNone": "No encuentro a mi artista",
|
||||
"onbPrimary": "Principal",
|
||||
"onbNebulaTag": "Nebulosa",
|
||||
"onbMembers": "{count} miembros",
|
||||
"onbPlanetTitle": "Únete a un planeta o crea una nebulosa",
|
||||
"onbPlanetJoin": "Unirse a los planetas elegidos",
|
||||
"onbPlanetCreate": "Crear una nebulosa nueva",
|
||||
"onbPermTitle": "Permisos de la app",
|
||||
"onbPermBody": "Estos permisos solo se necesitan para las funciones relacionadas. Puedes usar la app sin ellos.",
|
||||
"onbPermPush": "Notificaciones",
|
||||
"onbPermPushDesc": "Recibe avisos de Eclipse, HERO TIME y misiones.",
|
||||
"onbPermHealth": "Datos de salud (pasos)",
|
||||
"onbPermHealthDesc": "Cuenta pasos para misiones de caminar. Se solicita al empezar la primera.",
|
||||
"onbPermLocation": "Ubicación",
|
||||
"onbPermLocationDesc": "Comprueba la llegada en misiones de ubicación. Se solicita al empezar la primera.",
|
||||
"onbPermContinue": "Continuar",
|
||||
"coachZap": "Toca para enviar POP a nuestro planeta",
|
||||
"coachMy": "Aquí puedes ver tus registros",
|
||||
"settingsWithdrawLinked": "Desvincula tus cuentas conectadas antes de eliminar tu cuenta."
|
||||
}
|
||||
|
||||
+38
-1
@@ -830,5 +830,42 @@
|
||||
"questModerationRejected": "審査で差し戻されました:{reason}。預けたPOPと手数料は返還済みです。",
|
||||
"questSubmittedForReview": "公開を申請しました。審査後に公開されます。",
|
||||
"popReasonQuestFeeRefund": "クエスト手数料の返還",
|
||||
"questGoalSteps": "目標歩数"
|
||||
"questGoalSteps": "目標歩数",
|
||||
"eclipseTapToJoin": "振るのが難しければタップで参加",
|
||||
"termsPrivacy": "個人情報の収集・利用およびプライバシーポリシーへの同意(必須)",
|
||||
"onbSkip": "スキップ",
|
||||
"onbIntro1Title": "私たちのファンダムの惑星を育てよう",
|
||||
"onbIntro1Body": "POPを集めてZAPで送ると\nファンダムの惑星が一緒に成長します。",
|
||||
"onbIntro2Title": "クエストで応援しよう",
|
||||
"onbIntro2Body": "ウォーキング・出席・ストリーミングのクエストを達成して\nPOPを受け取りましょう。",
|
||||
"onbIntro3Title": "ファンと一緒に守ろう",
|
||||
"onbIntro3Body": "みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。",
|
||||
"onbProfileTitle": "プロフィールを設定してください",
|
||||
"onbNicknameHint": "2〜20文字・他の人と同じでも大丈夫です",
|
||||
"onbCountry": "国・地域",
|
||||
"onbLanguage": "表示言語",
|
||||
"onbReferral": "紹介コード(任意)",
|
||||
"onbReferralHint": "友だちからもらったコードがあれば入力してください",
|
||||
"onbArtistTitle": "好きなアーティストを選んでください",
|
||||
"onbArtistBody": "最初に選んだアーティストが基本の惑星になります。複数のファンダムに加入できます。",
|
||||
"onbArtistSearch": "アーティスト名で検索",
|
||||
"onbArtistNone": "探しているアーティストがいません",
|
||||
"onbPrimary": "基本",
|
||||
"onbNebulaTag": "星雲",
|
||||
"onbMembers": "{count}人",
|
||||
"onbPlanetTitle": "惑星に加入するか、星雲を作りましょう",
|
||||
"onbPlanetJoin": "選んだ惑星に加入する",
|
||||
"onbPlanetCreate": "新しい星雲を作る",
|
||||
"onbPermTitle": "アプリの権限について",
|
||||
"onbPermBody": "以下の権限は該当機能を使うときに必要です。許可しなくてもアプリは利用できます。",
|
||||
"onbPermPush": "通知",
|
||||
"onbPermPushDesc": "イクリプス・HERO TIME・クエストのお知らせを受け取れます。",
|
||||
"onbPermHealth": "ヘルスケアデータ(歩数)",
|
||||
"onbPermHealthDesc": "ウォーキングクエストの歩数を数えます。初めて開始するときにリクエストします。",
|
||||
"onbPermLocation": "位置情報",
|
||||
"onbPermLocationDesc": "位置クエストの到着を確認します。初めて開始するときにリクエストします。",
|
||||
"onbPermContinue": "続ける",
|
||||
"coachZap": "タップしてPOPを私たちの惑星に送ろう",
|
||||
"coachMy": "自分の記録はここで見られます",
|
||||
"settingsWithdrawLinked": "連携中のアカウントを解除してから退会してください。"
|
||||
}
|
||||
|
||||
+52
-2
@@ -1761,8 +1761,58 @@
|
||||
"questCancelPublished": "공개된 퀘스트를 취소하면 참여가 바로 마감되고 남은 보상 POP 은 돌려드려요. 수수료는 돌려드리지 않아요.",
|
||||
"questUnderReview": "운영 검수 중이에요. 승인되면 공개됩니다.",
|
||||
"questModerationRejected": "운영 검수에서 반려되었어요: {reason}. 예치한 POP 과 수수료는 돌려드렸어요.",
|
||||
"@questModerationRejected": {"placeholders": {"reason": {"type": "String"}}},
|
||||
"@questModerationRejected": {
|
||||
"placeholders": {
|
||||
"reason": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"questSubmittedForReview": "공개 요청했어요. 운영 검수 후 공개됩니다.",
|
||||
"popReasonQuestFeeRefund": "퀘스트 수수료 반환",
|
||||
"questGoalSteps": "목표 걸음 수"
|
||||
"questGoalSteps": "목표 걸음 수",
|
||||
"eclipseTapToJoin": "흔들기 어려우면 눌러서 참여",
|
||||
"termsPrivacy": "개인정보 수집·이용 및 처리방침 동의 (필수)",
|
||||
"onbSkip": "건너뛰기",
|
||||
"onbIntro1Title": "우리 팬덤의 행성을 키워요",
|
||||
"onbIntro1Body": "POP 을 모아 ZAP 으로 보내면\n팬덤 행성이 함께 성장합니다.",
|
||||
"onbIntro2Title": "퀘스트로 응원해요",
|
||||
"onbIntro2Body": "걷기 · 출석 · 스트리밍 퀘스트를 완료하고\nPOP 보상을 받으세요.",
|
||||
"onbIntro3Title": "팬들과 함께 지켜요",
|
||||
"onbIntro3Body": "이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.",
|
||||
"onbProfileTitle": "프로필을 설정해 주세요",
|
||||
"onbNicknameHint": "2~20자 · 다른 사람과 같아도 괜찮아요",
|
||||
"onbCountry": "국가/지역",
|
||||
"onbLanguage": "표시 언어",
|
||||
"onbReferral": "추천인 코드 (선택)",
|
||||
"onbReferralHint": "친구에게 받은 코드가 있으면 입력해 주세요",
|
||||
"onbArtistTitle": "관심 아티스트를 선택해 주세요",
|
||||
"onbArtistBody": "처음 고른 아티스트가 기본 행성이 됩니다. 여러 팬덤에 함께 가입할 수 있어요.",
|
||||
"onbArtistSearch": "아티스트 이름 검색",
|
||||
"onbArtistNone": "찾는 아티스트가 없어요",
|
||||
"onbPrimary": "기본",
|
||||
"onbNebulaTag": "성운",
|
||||
"onbMembers": "{count}명",
|
||||
"@onbMembers": {
|
||||
"placeholders": {
|
||||
"count": {
|
||||
"type": "String"
|
||||
}
|
||||
}
|
||||
},
|
||||
"onbPlanetTitle": "행성에 가입하거나 성운을 만들어 보세요",
|
||||
"onbPlanetJoin": "선택한 행성 가입하기",
|
||||
"onbPlanetCreate": "새 성운 만들기",
|
||||
"onbPermTitle": "앱 권한 안내",
|
||||
"onbPermBody": "아래 권한은 해당 기능을 쓸 때 필요합니다. 허용하지 않아도 앱을 이용할 수 있어요.",
|
||||
"onbPermPush": "알림",
|
||||
"onbPermPushDesc": "이클립스 · HERO TIME · 퀘스트 소식을 알려 드려요.",
|
||||
"onbPermHealth": "건강 데이터(걸음 수)",
|
||||
"onbPermHealthDesc": "걷기 퀘스트의 걸음 수를 셉니다. 걷기 퀘스트를 처음 시작할 때 요청해요.",
|
||||
"onbPermLocation": "위치",
|
||||
"onbPermLocationDesc": "위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.",
|
||||
"onbPermContinue": "계속",
|
||||
"coachZap": "눌러서 POP 을 우리 행성에 보내요",
|
||||
"coachMy": "내 기록은 여기서 볼 수 있어요",
|
||||
"settingsWithdrawLinked": "연동된 계정 해제 후 계정 탈퇴를 진행해주세요."
|
||||
}
|
||||
|
||||
@@ -5101,6 +5101,228 @@ abstract class AppLocalizations {
|
||||
/// In ko, this message translates to:
|
||||
/// **'목표 걸음 수'**
|
||||
String get questGoalSteps;
|
||||
|
||||
/// No description provided for @eclipseTapToJoin.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'흔들기 어려우면 눌러서 참여'**
|
||||
String get eclipseTapToJoin;
|
||||
|
||||
/// No description provided for @termsPrivacy.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'개인정보 수집·이용 및 처리방침 동의 (필수)'**
|
||||
String get termsPrivacy;
|
||||
|
||||
/// No description provided for @onbSkip.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'건너뛰기'**
|
||||
String get onbSkip;
|
||||
|
||||
/// No description provided for @onbIntro1Title.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'우리 팬덤의 행성을 키워요'**
|
||||
String get onbIntro1Title;
|
||||
|
||||
/// No description provided for @onbIntro1Body.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'POP 을 모아 ZAP 으로 보내면\n팬덤 행성이 함께 성장합니다.'**
|
||||
String get onbIntro1Body;
|
||||
|
||||
/// No description provided for @onbIntro2Title.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'퀘스트로 응원해요'**
|
||||
String get onbIntro2Title;
|
||||
|
||||
/// No description provided for @onbIntro2Body.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'걷기 · 출석 · 스트리밍 퀘스트를 완료하고\nPOP 보상을 받으세요.'**
|
||||
String get onbIntro2Body;
|
||||
|
||||
/// No description provided for @onbIntro3Title.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'팬들과 함께 지켜요'**
|
||||
String get onbIntro3Title;
|
||||
|
||||
/// No description provided for @onbIntro3Body.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.'**
|
||||
String get onbIntro3Body;
|
||||
|
||||
/// No description provided for @onbProfileTitle.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'프로필을 설정해 주세요'**
|
||||
String get onbProfileTitle;
|
||||
|
||||
/// No description provided for @onbNicknameHint.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'2~20자 · 다른 사람과 같아도 괜찮아요'**
|
||||
String get onbNicknameHint;
|
||||
|
||||
/// No description provided for @onbCountry.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'국가/지역'**
|
||||
String get onbCountry;
|
||||
|
||||
/// No description provided for @onbLanguage.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'표시 언어'**
|
||||
String get onbLanguage;
|
||||
|
||||
/// No description provided for @onbReferral.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'추천인 코드 (선택)'**
|
||||
String get onbReferral;
|
||||
|
||||
/// No description provided for @onbReferralHint.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'친구에게 받은 코드가 있으면 입력해 주세요'**
|
||||
String get onbReferralHint;
|
||||
|
||||
/// No description provided for @onbArtistTitle.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'관심 아티스트를 선택해 주세요'**
|
||||
String get onbArtistTitle;
|
||||
|
||||
/// No description provided for @onbArtistBody.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'처음 고른 아티스트가 기본 행성이 됩니다. 여러 팬덤에 함께 가입할 수 있어요.'**
|
||||
String get onbArtistBody;
|
||||
|
||||
/// No description provided for @onbArtistSearch.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'아티스트 이름 검색'**
|
||||
String get onbArtistSearch;
|
||||
|
||||
/// No description provided for @onbArtistNone.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'찾는 아티스트가 없어요'**
|
||||
String get onbArtistNone;
|
||||
|
||||
/// No description provided for @onbPrimary.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'기본'**
|
||||
String get onbPrimary;
|
||||
|
||||
/// No description provided for @onbNebulaTag.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'성운'**
|
||||
String get onbNebulaTag;
|
||||
|
||||
/// No description provided for @onbMembers.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'{count}명'**
|
||||
String onbMembers(String count);
|
||||
|
||||
/// No description provided for @onbPlanetTitle.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'행성에 가입하거나 성운을 만들어 보세요'**
|
||||
String get onbPlanetTitle;
|
||||
|
||||
/// No description provided for @onbPlanetJoin.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'선택한 행성 가입하기'**
|
||||
String get onbPlanetJoin;
|
||||
|
||||
/// No description provided for @onbPlanetCreate.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'새 성운 만들기'**
|
||||
String get onbPlanetCreate;
|
||||
|
||||
/// No description provided for @onbPermTitle.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'앱 권한 안내'**
|
||||
String get onbPermTitle;
|
||||
|
||||
/// No description provided for @onbPermBody.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'아래 권한은 해당 기능을 쓸 때 필요합니다. 허용하지 않아도 앱을 이용할 수 있어요.'**
|
||||
String get onbPermBody;
|
||||
|
||||
/// No description provided for @onbPermPush.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'알림'**
|
||||
String get onbPermPush;
|
||||
|
||||
/// No description provided for @onbPermPushDesc.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'이클립스 · HERO TIME · 퀘스트 소식을 알려 드려요.'**
|
||||
String get onbPermPushDesc;
|
||||
|
||||
/// No description provided for @onbPermHealth.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'건강 데이터(걸음 수)'**
|
||||
String get onbPermHealth;
|
||||
|
||||
/// No description provided for @onbPermHealthDesc.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'걷기 퀘스트의 걸음 수를 셉니다. 걷기 퀘스트를 처음 시작할 때 요청해요.'**
|
||||
String get onbPermHealthDesc;
|
||||
|
||||
/// No description provided for @onbPermLocation.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'위치'**
|
||||
String get onbPermLocation;
|
||||
|
||||
/// No description provided for @onbPermLocationDesc.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.'**
|
||||
String get onbPermLocationDesc;
|
||||
|
||||
/// No description provided for @onbPermContinue.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'계속'**
|
||||
String get onbPermContinue;
|
||||
|
||||
/// No description provided for @coachZap.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'눌러서 POP 을 우리 행성에 보내요'**
|
||||
String get coachZap;
|
||||
|
||||
/// No description provided for @coachMy.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'내 기록은 여기서 볼 수 있어요'**
|
||||
String get coachMy;
|
||||
|
||||
/// No description provided for @settingsWithdrawLinked.
|
||||
///
|
||||
/// In ko, this message translates to:
|
||||
/// **'연동된 계정 해제 후 계정 탈퇴를 진행해주세요.'**
|
||||
String get settingsWithdrawLinked;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
||||
@@ -2907,4 +2907,126 @@ class AppLocalizationsEn extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get questGoalSteps => 'Step goal';
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => 'Can\'t shake? Tap to join';
|
||||
|
||||
@override
|
||||
String get termsPrivacy =>
|
||||
'Privacy Policy and Personal Data Collection (required)';
|
||||
|
||||
@override
|
||||
String get onbSkip => 'Skip';
|
||||
|
||||
@override
|
||||
String get onbIntro1Title => 'Grow your fandom\'s planet';
|
||||
|
||||
@override
|
||||
String get onbIntro1Body =>
|
||||
'Collect POP and send it with ZAP\nto grow your fandom planet together.';
|
||||
|
||||
@override
|
||||
String get onbIntro2Title => 'Cheer with quests';
|
||||
|
||||
@override
|
||||
String get onbIntro2Body =>
|
||||
'Complete walking, attendance and streaming quests\nand earn POP rewards.';
|
||||
|
||||
@override
|
||||
String get onbIntro3Title => 'Protect it with fellow fans';
|
||||
|
||||
@override
|
||||
String get onbIntro3Body =>
|
||||
'Stop the Eclipse together and support\nyour artist through campaigns.';
|
||||
|
||||
@override
|
||||
String get onbProfileTitle => 'Set up your profile';
|
||||
|
||||
@override
|
||||
String get onbNicknameHint => '2–20 characters · duplicates are allowed';
|
||||
|
||||
@override
|
||||
String get onbCountry => 'Country/Region';
|
||||
|
||||
@override
|
||||
String get onbLanguage => 'Display language';
|
||||
|
||||
@override
|
||||
String get onbReferral => 'Referral code (optional)';
|
||||
|
||||
@override
|
||||
String get onbReferralHint => 'Enter a code if a friend shared one';
|
||||
|
||||
@override
|
||||
String get onbArtistTitle => 'Choose the artists you follow';
|
||||
|
||||
@override
|
||||
String get onbArtistBody =>
|
||||
'The first artist you pick becomes your main planet. You can join several fandoms.';
|
||||
|
||||
@override
|
||||
String get onbArtistSearch => 'Search artist name';
|
||||
|
||||
@override
|
||||
String get onbArtistNone => 'I can\'t find my artist';
|
||||
|
||||
@override
|
||||
String get onbPrimary => 'Main';
|
||||
|
||||
@override
|
||||
String get onbNebulaTag => 'Nebula';
|
||||
|
||||
@override
|
||||
String onbMembers(String count) {
|
||||
return '$count members';
|
||||
}
|
||||
|
||||
@override
|
||||
String get onbPlanetTitle => 'Join a planet or create a nebula';
|
||||
|
||||
@override
|
||||
String get onbPlanetJoin => 'Join selected planets';
|
||||
|
||||
@override
|
||||
String get onbPlanetCreate => 'Create a new nebula';
|
||||
|
||||
@override
|
||||
String get onbPermTitle => 'App permissions';
|
||||
|
||||
@override
|
||||
String get onbPermBody =>
|
||||
'These permissions are needed only for the related features. You can use the app without them.';
|
||||
|
||||
@override
|
||||
String get onbPermPush => 'Notifications';
|
||||
|
||||
@override
|
||||
String get onbPermPushDesc => 'Get Eclipse, HERO TIME and quest updates.';
|
||||
|
||||
@override
|
||||
String get onbPermHealth => 'Health data (steps)';
|
||||
|
||||
@override
|
||||
String get onbPermHealthDesc =>
|
||||
'Counts steps for walking quests. Requested when you first start one.';
|
||||
|
||||
@override
|
||||
String get onbPermLocation => 'Location';
|
||||
|
||||
@override
|
||||
String get onbPermLocationDesc =>
|
||||
'Checks arrival for location quests. Requested when you first start one.';
|
||||
|
||||
@override
|
||||
String get onbPermContinue => 'Continue';
|
||||
|
||||
@override
|
||||
String get coachZap => 'Tap to send POP to our planet';
|
||||
|
||||
@override
|
||||
String get coachMy => 'Your records are here';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLinked =>
|
||||
'Please unlink your connected accounts before deleting your account.';
|
||||
}
|
||||
|
||||
@@ -2922,4 +2922,128 @@ class AppLocalizationsEs extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get questGoalSteps => 'Meta de pasos';
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '¿No puedes agitar? Toca para participar';
|
||||
|
||||
@override
|
||||
String get termsPrivacy =>
|
||||
'Política de privacidad y recopilación de datos personales (obligatorio)';
|
||||
|
||||
@override
|
||||
String get onbSkip => 'Omitir';
|
||||
|
||||
@override
|
||||
String get onbIntro1Title => 'Haz crecer el planeta de tu fandom';
|
||||
|
||||
@override
|
||||
String get onbIntro1Body =>
|
||||
'Reúne POP y envíalo con ZAP\npara que el planeta de tu fandom crezca.';
|
||||
|
||||
@override
|
||||
String get onbIntro2Title => 'Apoya con misiones';
|
||||
|
||||
@override
|
||||
String get onbIntro2Body =>
|
||||
'Completa misiones de caminar, asistencia y streaming\ny gana POP.';
|
||||
|
||||
@override
|
||||
String get onbIntro3Title => 'Protégelo junto a otros fans';
|
||||
|
||||
@override
|
||||
String get onbIntro3Body =>
|
||||
'Detengan juntos el Eclipse y apoyen\na su artista con campañas.';
|
||||
|
||||
@override
|
||||
String get onbProfileTitle => 'Configura tu perfil';
|
||||
|
||||
@override
|
||||
String get onbNicknameHint => '2–20 caracteres · se permiten duplicados';
|
||||
|
||||
@override
|
||||
String get onbCountry => 'País/Región';
|
||||
|
||||
@override
|
||||
String get onbLanguage => 'Idioma de visualización';
|
||||
|
||||
@override
|
||||
String get onbReferral => 'Código de referido (opcional)';
|
||||
|
||||
@override
|
||||
String get onbReferralHint =>
|
||||
'Introduce el código si un amigo te lo compartió';
|
||||
|
||||
@override
|
||||
String get onbArtistTitle => 'Elige a los artistas que sigues';
|
||||
|
||||
@override
|
||||
String get onbArtistBody =>
|
||||
'El primer artista que elijas será tu planeta principal. Puedes unirte a varios fandoms.';
|
||||
|
||||
@override
|
||||
String get onbArtistSearch => 'Buscar nombre del artista';
|
||||
|
||||
@override
|
||||
String get onbArtistNone => 'No encuentro a mi artista';
|
||||
|
||||
@override
|
||||
String get onbPrimary => 'Principal';
|
||||
|
||||
@override
|
||||
String get onbNebulaTag => 'Nebulosa';
|
||||
|
||||
@override
|
||||
String onbMembers(String count) {
|
||||
return '$count miembros';
|
||||
}
|
||||
|
||||
@override
|
||||
String get onbPlanetTitle => 'Únete a un planeta o crea una nebulosa';
|
||||
|
||||
@override
|
||||
String get onbPlanetJoin => 'Unirse a los planetas elegidos';
|
||||
|
||||
@override
|
||||
String get onbPlanetCreate => 'Crear una nebulosa nueva';
|
||||
|
||||
@override
|
||||
String get onbPermTitle => 'Permisos de la app';
|
||||
|
||||
@override
|
||||
String get onbPermBody =>
|
||||
'Estos permisos solo se necesitan para las funciones relacionadas. Puedes usar la app sin ellos.';
|
||||
|
||||
@override
|
||||
String get onbPermPush => 'Notificaciones';
|
||||
|
||||
@override
|
||||
String get onbPermPushDesc =>
|
||||
'Recibe avisos de Eclipse, HERO TIME y misiones.';
|
||||
|
||||
@override
|
||||
String get onbPermHealth => 'Datos de salud (pasos)';
|
||||
|
||||
@override
|
||||
String get onbPermHealthDesc =>
|
||||
'Cuenta pasos para misiones de caminar. Se solicita al empezar la primera.';
|
||||
|
||||
@override
|
||||
String get onbPermLocation => 'Ubicación';
|
||||
|
||||
@override
|
||||
String get onbPermLocationDesc =>
|
||||
'Comprueba la llegada en misiones de ubicación. Se solicita al empezar la primera.';
|
||||
|
||||
@override
|
||||
String get onbPermContinue => 'Continuar';
|
||||
|
||||
@override
|
||||
String get coachZap => 'Toca para enviar POP a nuestro planeta';
|
||||
|
||||
@override
|
||||
String get coachMy => 'Aquí puedes ver tus registros';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLinked =>
|
||||
'Desvincula tus cuentas conectadas antes de eliminar tu cuenta.';
|
||||
}
|
||||
|
||||
@@ -2811,4 +2811,117 @@ class AppLocalizationsJa extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get questGoalSteps => '目標歩数';
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '振るのが難しければタップで参加';
|
||||
|
||||
@override
|
||||
String get termsPrivacy => '個人情報の収集・利用およびプライバシーポリシーへの同意(必須)';
|
||||
|
||||
@override
|
||||
String get onbSkip => 'スキップ';
|
||||
|
||||
@override
|
||||
String get onbIntro1Title => '私たちのファンダムの惑星を育てよう';
|
||||
|
||||
@override
|
||||
String get onbIntro1Body => 'POPを集めてZAPで送ると\nファンダムの惑星が一緒に成長します。';
|
||||
|
||||
@override
|
||||
String get onbIntro2Title => 'クエストで応援しよう';
|
||||
|
||||
@override
|
||||
String get onbIntro2Body => 'ウォーキング・出席・ストリーミングのクエストを達成して\nPOPを受け取りましょう。';
|
||||
|
||||
@override
|
||||
String get onbIntro3Title => 'ファンと一緒に守ろう';
|
||||
|
||||
@override
|
||||
String get onbIntro3Body => 'みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。';
|
||||
|
||||
@override
|
||||
String get onbProfileTitle => 'プロフィールを設定してください';
|
||||
|
||||
@override
|
||||
String get onbNicknameHint => '2〜20文字・他の人と同じでも大丈夫です';
|
||||
|
||||
@override
|
||||
String get onbCountry => '国・地域';
|
||||
|
||||
@override
|
||||
String get onbLanguage => '表示言語';
|
||||
|
||||
@override
|
||||
String get onbReferral => '紹介コード(任意)';
|
||||
|
||||
@override
|
||||
String get onbReferralHint => '友だちからもらったコードがあれば入力してください';
|
||||
|
||||
@override
|
||||
String get onbArtistTitle => '好きなアーティストを選んでください';
|
||||
|
||||
@override
|
||||
String get onbArtistBody => '最初に選んだアーティストが基本の惑星になります。複数のファンダムに加入できます。';
|
||||
|
||||
@override
|
||||
String get onbArtistSearch => 'アーティスト名で検索';
|
||||
|
||||
@override
|
||||
String get onbArtistNone => '探しているアーティストがいません';
|
||||
|
||||
@override
|
||||
String get onbPrimary => '基本';
|
||||
|
||||
@override
|
||||
String get onbNebulaTag => '星雲';
|
||||
|
||||
@override
|
||||
String onbMembers(String count) {
|
||||
return '$count人';
|
||||
}
|
||||
|
||||
@override
|
||||
String get onbPlanetTitle => '惑星に加入するか、星雲を作りましょう';
|
||||
|
||||
@override
|
||||
String get onbPlanetJoin => '選んだ惑星に加入する';
|
||||
|
||||
@override
|
||||
String get onbPlanetCreate => '新しい星雲を作る';
|
||||
|
||||
@override
|
||||
String get onbPermTitle => 'アプリの権限について';
|
||||
|
||||
@override
|
||||
String get onbPermBody => '以下の権限は該当機能を使うときに必要です。許可しなくてもアプリは利用できます。';
|
||||
|
||||
@override
|
||||
String get onbPermPush => '通知';
|
||||
|
||||
@override
|
||||
String get onbPermPushDesc => 'イクリプス・HERO TIME・クエストのお知らせを受け取れます。';
|
||||
|
||||
@override
|
||||
String get onbPermHealth => 'ヘルスケアデータ(歩数)';
|
||||
|
||||
@override
|
||||
String get onbPermHealthDesc => 'ウォーキングクエストの歩数を数えます。初めて開始するときにリクエストします。';
|
||||
|
||||
@override
|
||||
String get onbPermLocation => '位置情報';
|
||||
|
||||
@override
|
||||
String get onbPermLocationDesc => '位置クエストの到着を確認します。初めて開始するときにリクエストします。';
|
||||
|
||||
@override
|
||||
String get onbPermContinue => '続ける';
|
||||
|
||||
@override
|
||||
String get coachZap => 'タップしてPOPを私たちの惑星に送ろう';
|
||||
|
||||
@override
|
||||
String get coachMy => '自分の記録はここで見られます';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLinked => '連携中のアカウントを解除してから退会してください。';
|
||||
}
|
||||
|
||||
@@ -2814,4 +2814,118 @@ class AppLocalizationsKo extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get questGoalSteps => '목표 걸음 수';
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '흔들기 어려우면 눌러서 참여';
|
||||
|
||||
@override
|
||||
String get termsPrivacy => '개인정보 수집·이용 및 처리방침 동의 (필수)';
|
||||
|
||||
@override
|
||||
String get onbSkip => '건너뛰기';
|
||||
|
||||
@override
|
||||
String get onbIntro1Title => '우리 팬덤의 행성을 키워요';
|
||||
|
||||
@override
|
||||
String get onbIntro1Body => 'POP 을 모아 ZAP 으로 보내면\n팬덤 행성이 함께 성장합니다.';
|
||||
|
||||
@override
|
||||
String get onbIntro2Title => '퀘스트로 응원해요';
|
||||
|
||||
@override
|
||||
String get onbIntro2Body => '걷기 · 출석 · 스트리밍 퀘스트를 완료하고\nPOP 보상을 받으세요.';
|
||||
|
||||
@override
|
||||
String get onbIntro3Title => '팬들과 함께 지켜요';
|
||||
|
||||
@override
|
||||
String get onbIntro3Body => '이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.';
|
||||
|
||||
@override
|
||||
String get onbProfileTitle => '프로필을 설정해 주세요';
|
||||
|
||||
@override
|
||||
String get onbNicknameHint => '2~20자 · 다른 사람과 같아도 괜찮아요';
|
||||
|
||||
@override
|
||||
String get onbCountry => '국가/지역';
|
||||
|
||||
@override
|
||||
String get onbLanguage => '표시 언어';
|
||||
|
||||
@override
|
||||
String get onbReferral => '추천인 코드 (선택)';
|
||||
|
||||
@override
|
||||
String get onbReferralHint => '친구에게 받은 코드가 있으면 입력해 주세요';
|
||||
|
||||
@override
|
||||
String get onbArtistTitle => '관심 아티스트를 선택해 주세요';
|
||||
|
||||
@override
|
||||
String get onbArtistBody => '처음 고른 아티스트가 기본 행성이 됩니다. 여러 팬덤에 함께 가입할 수 있어요.';
|
||||
|
||||
@override
|
||||
String get onbArtistSearch => '아티스트 이름 검색';
|
||||
|
||||
@override
|
||||
String get onbArtistNone => '찾는 아티스트가 없어요';
|
||||
|
||||
@override
|
||||
String get onbPrimary => '기본';
|
||||
|
||||
@override
|
||||
String get onbNebulaTag => '성운';
|
||||
|
||||
@override
|
||||
String onbMembers(String count) {
|
||||
return '$count명';
|
||||
}
|
||||
|
||||
@override
|
||||
String get onbPlanetTitle => '행성에 가입하거나 성운을 만들어 보세요';
|
||||
|
||||
@override
|
||||
String get onbPlanetJoin => '선택한 행성 가입하기';
|
||||
|
||||
@override
|
||||
String get onbPlanetCreate => '새 성운 만들기';
|
||||
|
||||
@override
|
||||
String get onbPermTitle => '앱 권한 안내';
|
||||
|
||||
@override
|
||||
String get onbPermBody => '아래 권한은 해당 기능을 쓸 때 필요합니다. 허용하지 않아도 앱을 이용할 수 있어요.';
|
||||
|
||||
@override
|
||||
String get onbPermPush => '알림';
|
||||
|
||||
@override
|
||||
String get onbPermPushDesc => '이클립스 · HERO TIME · 퀘스트 소식을 알려 드려요.';
|
||||
|
||||
@override
|
||||
String get onbPermHealth => '건강 데이터(걸음 수)';
|
||||
|
||||
@override
|
||||
String get onbPermHealthDesc => '걷기 퀘스트의 걸음 수를 셉니다. 걷기 퀘스트를 처음 시작할 때 요청해요.';
|
||||
|
||||
@override
|
||||
String get onbPermLocation => '위치';
|
||||
|
||||
@override
|
||||
String get onbPermLocationDesc =>
|
||||
'위치 퀘스트의 장소 도착을 확인합니다. 위치 퀘스트를 처음 시작할 때 요청해요.';
|
||||
|
||||
@override
|
||||
String get onbPermContinue => '계속';
|
||||
|
||||
@override
|
||||
String get coachZap => '눌러서 POP 을 우리 행성에 보내요';
|
||||
|
||||
@override
|
||||
String get coachMy => '내 기록은 여기서 볼 수 있어요';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLinked => '연동된 계정 해제 후 계정 탈퇴를 진행해주세요.';
|
||||
}
|
||||
|
||||
@@ -2807,4 +2807,117 @@ class AppLocalizationsZh extends AppLocalizations {
|
||||
|
||||
@override
|
||||
String get questGoalSteps => '目标步数';
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => '不方便摇动?点击参与';
|
||||
|
||||
@override
|
||||
String get termsPrivacy => '个人信息收集·使用及隐私政策(必选)';
|
||||
|
||||
@override
|
||||
String get onbSkip => '跳过';
|
||||
|
||||
@override
|
||||
String get onbIntro1Title => '一起培养我们粉丝团的星球';
|
||||
|
||||
@override
|
||||
String get onbIntro1Body => '收集POP并通过ZAP发送,\n粉丝团星球就会一起成长。';
|
||||
|
||||
@override
|
||||
String get onbIntro2Title => '用任务来应援';
|
||||
|
||||
@override
|
||||
String get onbIntro2Body => '完成步行·签到·播放任务,\n领取POP奖励。';
|
||||
|
||||
@override
|
||||
String get onbIntro3Title => '和粉丝们一起守护';
|
||||
|
||||
@override
|
||||
String get onbIntro3Body => '一起阻止日食,通过活动\n为艺人应援。';
|
||||
|
||||
@override
|
||||
String get onbProfileTitle => '请设置个人资料';
|
||||
|
||||
@override
|
||||
String get onbNicknameHint => '2~20个字符·可与他人重复';
|
||||
|
||||
@override
|
||||
String get onbCountry => '国家/地区';
|
||||
|
||||
@override
|
||||
String get onbLanguage => '显示语言';
|
||||
|
||||
@override
|
||||
String get onbReferral => '推荐码(选填)';
|
||||
|
||||
@override
|
||||
String get onbReferralHint => '如有朋友分享的推荐码请输入';
|
||||
|
||||
@override
|
||||
String get onbArtistTitle => '请选择关注的艺人';
|
||||
|
||||
@override
|
||||
String get onbArtistBody => '第一个选择的艺人将成为默认星球。可以同时加入多个粉丝团。';
|
||||
|
||||
@override
|
||||
String get onbArtistSearch => '搜索艺人名称';
|
||||
|
||||
@override
|
||||
String get onbArtistNone => '找不到我的艺人';
|
||||
|
||||
@override
|
||||
String get onbPrimary => '默认';
|
||||
|
||||
@override
|
||||
String get onbNebulaTag => '星云';
|
||||
|
||||
@override
|
||||
String onbMembers(String count) {
|
||||
return '$count人';
|
||||
}
|
||||
|
||||
@override
|
||||
String get onbPlanetTitle => '加入星球或创建星云';
|
||||
|
||||
@override
|
||||
String get onbPlanetJoin => '加入所选星球';
|
||||
|
||||
@override
|
||||
String get onbPlanetCreate => '创建新星云';
|
||||
|
||||
@override
|
||||
String get onbPermTitle => '应用权限说明';
|
||||
|
||||
@override
|
||||
String get onbPermBody => '以下权限仅在使用相关功能时需要。不允许也可以使用应用。';
|
||||
|
||||
@override
|
||||
String get onbPermPush => '通知';
|
||||
|
||||
@override
|
||||
String get onbPermPushDesc => '接收日食·HERO TIME·任务消息。';
|
||||
|
||||
@override
|
||||
String get onbPermHealth => '健康数据(步数)';
|
||||
|
||||
@override
|
||||
String get onbPermHealthDesc => '用于统计步行任务的步数。首次开始时请求。';
|
||||
|
||||
@override
|
||||
String get onbPermLocation => '位置';
|
||||
|
||||
@override
|
||||
String get onbPermLocationDesc => '用于确认位置任务的到达。首次开始时请求。';
|
||||
|
||||
@override
|
||||
String get onbPermContinue => '继续';
|
||||
|
||||
@override
|
||||
String get coachZap => '点击将POP发送到我们的星球';
|
||||
|
||||
@override
|
||||
String get coachMy => '我的记录可以在这里查看';
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLinked => '请先解除关联账号后再注销账号。';
|
||||
}
|
||||
|
||||
+38
-1
@@ -830,5 +830,42 @@
|
||||
"questModerationRejected": "审核未通过:{reason}。已退还存入的POP和手续费。",
|
||||
"questSubmittedForReview": "已提交公开申请,审核后公开。",
|
||||
"popReasonQuestFeeRefund": "任务手续费退还",
|
||||
"questGoalSteps": "目标步数"
|
||||
"questGoalSteps": "目标步数",
|
||||
"eclipseTapToJoin": "不方便摇动?点击参与",
|
||||
"termsPrivacy": "个人信息收集·使用及隐私政策(必选)",
|
||||
"onbSkip": "跳过",
|
||||
"onbIntro1Title": "一起培养我们粉丝团的星球",
|
||||
"onbIntro1Body": "收集POP并通过ZAP发送,\n粉丝团星球就会一起成长。",
|
||||
"onbIntro2Title": "用任务来应援",
|
||||
"onbIntro2Body": "完成步行·签到·播放任务,\n领取POP奖励。",
|
||||
"onbIntro3Title": "和粉丝们一起守护",
|
||||
"onbIntro3Body": "一起阻止日食,通过活动\n为艺人应援。",
|
||||
"onbProfileTitle": "请设置个人资料",
|
||||
"onbNicknameHint": "2~20个字符·可与他人重复",
|
||||
"onbCountry": "国家/地区",
|
||||
"onbLanguage": "显示语言",
|
||||
"onbReferral": "推荐码(选填)",
|
||||
"onbReferralHint": "如有朋友分享的推荐码请输入",
|
||||
"onbArtistTitle": "请选择关注的艺人",
|
||||
"onbArtistBody": "第一个选择的艺人将成为默认星球。可以同时加入多个粉丝团。",
|
||||
"onbArtistSearch": "搜索艺人名称",
|
||||
"onbArtistNone": "找不到我的艺人",
|
||||
"onbPrimary": "默认",
|
||||
"onbNebulaTag": "星云",
|
||||
"onbMembers": "{count}人",
|
||||
"onbPlanetTitle": "加入星球或创建星云",
|
||||
"onbPlanetJoin": "加入所选星球",
|
||||
"onbPlanetCreate": "创建新星云",
|
||||
"onbPermTitle": "应用权限说明",
|
||||
"onbPermBody": "以下权限仅在使用相关功能时需要。不允许也可以使用应用。",
|
||||
"onbPermPush": "通知",
|
||||
"onbPermPushDesc": "接收日食·HERO TIME·任务消息。",
|
||||
"onbPermHealth": "健康数据(步数)",
|
||||
"onbPermHealthDesc": "用于统计步行任务的步数。首次开始时请求。",
|
||||
"onbPermLocation": "位置",
|
||||
"onbPermLocationDesc": "用于确认位置任务的到达。首次开始时请求。",
|
||||
"onbPermContinue": "继续",
|
||||
"coachZap": "点击将POP发送到我们的星球",
|
||||
"coachMy": "我的记录可以在这里查看",
|
||||
"settingsWithdrawLinked": "请先解除关联账号后再注销账号。"
|
||||
}
|
||||
|
||||
@@ -394,6 +394,12 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String campaignYearMonth(String year, String month) => _fill('campaignYearMonth', {'year': year, 'month': month}) ?? _base.campaignYearMonth(year, month);
|
||||
|
||||
@override
|
||||
String get coachMy => _remote['coachMy'] ?? _base.coachMy;
|
||||
|
||||
@override
|
||||
String get coachZap => _remote['coachZap'] ?? _base.coachZap;
|
||||
|
||||
@override
|
||||
String get commentDeleteConfirm => _remote['commentDeleteConfirm'] ?? _base.commentDeleteConfirm;
|
||||
|
||||
@@ -664,6 +670,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get eclipseSuccessTitle => _remote['eclipseSuccessTitle'] ?? _base.eclipseSuccessTitle;
|
||||
|
||||
@override
|
||||
String get eclipseTapToJoin => _remote['eclipseTapToJoin'] ?? _base.eclipseTapToJoin;
|
||||
|
||||
@override
|
||||
String get eclipseTimeLeft => _remote['eclipseTimeLeft'] ?? _base.eclipseTimeLeft;
|
||||
|
||||
@@ -1321,6 +1330,102 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get notifTargetQuest => _remote['notifTargetQuest'] ?? _base.notifTargetQuest;
|
||||
|
||||
@override
|
||||
String get onbArtistBody => _remote['onbArtistBody'] ?? _base.onbArtistBody;
|
||||
|
||||
@override
|
||||
String get onbArtistNone => _remote['onbArtistNone'] ?? _base.onbArtistNone;
|
||||
|
||||
@override
|
||||
String get onbArtistSearch => _remote['onbArtistSearch'] ?? _base.onbArtistSearch;
|
||||
|
||||
@override
|
||||
String get onbArtistTitle => _remote['onbArtistTitle'] ?? _base.onbArtistTitle;
|
||||
|
||||
@override
|
||||
String get onbCountry => _remote['onbCountry'] ?? _base.onbCountry;
|
||||
|
||||
@override
|
||||
String get onbIntro1Body => _remote['onbIntro1Body'] ?? _base.onbIntro1Body;
|
||||
|
||||
@override
|
||||
String get onbIntro1Title => _remote['onbIntro1Title'] ?? _base.onbIntro1Title;
|
||||
|
||||
@override
|
||||
String get onbIntro2Body => _remote['onbIntro2Body'] ?? _base.onbIntro2Body;
|
||||
|
||||
@override
|
||||
String get onbIntro2Title => _remote['onbIntro2Title'] ?? _base.onbIntro2Title;
|
||||
|
||||
@override
|
||||
String get onbIntro3Body => _remote['onbIntro3Body'] ?? _base.onbIntro3Body;
|
||||
|
||||
@override
|
||||
String get onbIntro3Title => _remote['onbIntro3Title'] ?? _base.onbIntro3Title;
|
||||
|
||||
@override
|
||||
String get onbLanguage => _remote['onbLanguage'] ?? _base.onbLanguage;
|
||||
|
||||
@override
|
||||
String onbMembers(String count) => _fill('onbMembers', {'count': count}) ?? _base.onbMembers(count);
|
||||
|
||||
@override
|
||||
String get onbNebulaTag => _remote['onbNebulaTag'] ?? _base.onbNebulaTag;
|
||||
|
||||
@override
|
||||
String get onbNicknameHint => _remote['onbNicknameHint'] ?? _base.onbNicknameHint;
|
||||
|
||||
@override
|
||||
String get onbPermBody => _remote['onbPermBody'] ?? _base.onbPermBody;
|
||||
|
||||
@override
|
||||
String get onbPermContinue => _remote['onbPermContinue'] ?? _base.onbPermContinue;
|
||||
|
||||
@override
|
||||
String get onbPermHealth => _remote['onbPermHealth'] ?? _base.onbPermHealth;
|
||||
|
||||
@override
|
||||
String get onbPermHealthDesc => _remote['onbPermHealthDesc'] ?? _base.onbPermHealthDesc;
|
||||
|
||||
@override
|
||||
String get onbPermLocation => _remote['onbPermLocation'] ?? _base.onbPermLocation;
|
||||
|
||||
@override
|
||||
String get onbPermLocationDesc => _remote['onbPermLocationDesc'] ?? _base.onbPermLocationDesc;
|
||||
|
||||
@override
|
||||
String get onbPermPush => _remote['onbPermPush'] ?? _base.onbPermPush;
|
||||
|
||||
@override
|
||||
String get onbPermPushDesc => _remote['onbPermPushDesc'] ?? _base.onbPermPushDesc;
|
||||
|
||||
@override
|
||||
String get onbPermTitle => _remote['onbPermTitle'] ?? _base.onbPermTitle;
|
||||
|
||||
@override
|
||||
String get onbPlanetCreate => _remote['onbPlanetCreate'] ?? _base.onbPlanetCreate;
|
||||
|
||||
@override
|
||||
String get onbPlanetJoin => _remote['onbPlanetJoin'] ?? _base.onbPlanetJoin;
|
||||
|
||||
@override
|
||||
String get onbPlanetTitle => _remote['onbPlanetTitle'] ?? _base.onbPlanetTitle;
|
||||
|
||||
@override
|
||||
String get onbPrimary => _remote['onbPrimary'] ?? _base.onbPrimary;
|
||||
|
||||
@override
|
||||
String get onbProfileTitle => _remote['onbProfileTitle'] ?? _base.onbProfileTitle;
|
||||
|
||||
@override
|
||||
String get onbReferral => _remote['onbReferral'] ?? _base.onbReferral;
|
||||
|
||||
@override
|
||||
String get onbReferralHint => _remote['onbReferralHint'] ?? _base.onbReferralHint;
|
||||
|
||||
@override
|
||||
String get onbSkip => _remote['onbSkip'] ?? _base.onbSkip;
|
||||
|
||||
@override
|
||||
String get openSettings => _remote['openSettings'] ?? _base.openSettings;
|
||||
|
||||
@@ -2245,6 +2350,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get settingsWithdrawLater => _remote['settingsWithdrawLater'] ?? _base.settingsWithdrawLater;
|
||||
|
||||
@override
|
||||
String get settingsWithdrawLinked => _remote['settingsWithdrawLinked'] ?? _base.settingsWithdrawLinked;
|
||||
|
||||
@override
|
||||
String shakeCombo(String n) => _fill('shakeCombo', {'n': n}) ?? _base.shakeCombo(n);
|
||||
|
||||
@@ -2377,6 +2485,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
||||
@override
|
||||
String get termsMarketing => _remote['termsMarketing'] ?? _base.termsMarketing;
|
||||
|
||||
@override
|
||||
String get termsPrivacy => _remote['termsPrivacy'] ?? _base.termsPrivacy;
|
||||
|
||||
@override
|
||||
String get termsService => _remote['termsService'] ?? _base.termsService;
|
||||
|
||||
|
||||
+23
-2
@@ -1,14 +1,35 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'core/firebase/firebase_boot.dart';
|
||||
import 'features/auth/auth_repository.dart';
|
||||
import 'features/auth/firebase_auth_session.dart';
|
||||
import 'features/auth/onboarding_data.dart';
|
||||
|
||||
/// 소개 3장(온보딩 ①)을 이 기기에서 넘겼는가.
|
||||
const _introSeenKey = 'onboarding.introSeen';
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
// 설정 파일이 없으면 목 모드로 내려간다. 여기서 throw 하지 않는다 — FirebaseBoot 참고.
|
||||
await FirebaseBoot.init();
|
||||
final session = FirebaseBoot.isLive ? FirebaseAuthSession() : null;
|
||||
runApp(FancakeApp(auth: AuthRepository(session: session)));
|
||||
final live = FirebaseBoot.isLive;
|
||||
final session = live ? FirebaseAuthSession() : null;
|
||||
var introSeen = true;
|
||||
try {
|
||||
introSeen =
|
||||
(await SharedPreferences.getInstance()).getBool(_introSeenKey) ?? false;
|
||||
} catch (_) {}
|
||||
runApp(
|
||||
FancakeApp(
|
||||
auth: AuthRepository(
|
||||
session: session,
|
||||
needsOnboarding: live ? needsOnboardingFirestore : null,
|
||||
introPending: !introSeen,
|
||||
onIntroDone: () async => (await SharedPreferences.getInstance())
|
||||
.setBool(_introSeenKey, true),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user