가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 6단계 · Coach Mark

This commit is contained in:
Sn-Kinos
2026-10-05 22:48:25 +09:00
parent e7010010bb
commit 55424bd4a2
36 changed files with 2884 additions and 77 deletions
+72 -2
View File
@@ -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();
}
+6 -4
View File
@@ -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);
+171
View File
@@ -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(' ');
+28 -8
View File
@@ -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,
+161
View File
@@ -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;
}
+7
View File
@@ -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) {
+6
View File
@@ -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;
+34 -7
View File
@@ -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;
}
}