가입 약관 동의 기록 · 서비스 탈퇴 · 온보딩 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
+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);
}