193 lines
6.5 KiB
Dart
193 lines
6.5 KiB
Dart
import 'package:flutter/widgets.dart' show StringCharacters;
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
|
|
import '../../core/data/backend.dart';
|
|
import '../my/my_data.dart'
|
|
show nicknameMaxLength, nicknameMinLength, nicknameReserved;
|
|
|
|
/// 온보딩 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;
|
|
}
|
|
|
|
/// 소셜 계정 표시 이름을 닉네임 칸 기본값으로 — 해석 확인 Q-E11 임시값.
|
|
/// 닉네임 규칙(2~20자 · 사칭 금지어)에 걸리면 빈칸이다. 길이는 글자(grapheme) 기준.
|
|
String suggestedNickname(String? providerName) {
|
|
final n = (providerName ?? '').trim();
|
|
final len = n.characters.length;
|
|
return len < nicknameMinLength || len > nicknameMaxLength || nicknameReserved(n)
|
|
? ''
|
|
: n;
|
|
}
|
|
|
|
/// 계정이 온보딩을 거쳐야 하는가 — 서버에 가입 약관 동의(`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.isNotEmpty
|
|
? nickname
|
|
: suggestedNickname(_backend.providerDisplayName),
|
|
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 {
|
|
// 서버 `fnReferralAttribute` 처럼 1인 1회 — 두 번째는 거절(E-POP-03).
|
|
if (referral != null) {
|
|
throw const BackendException('E-POP-03', '이미 처리된 추천입니다.');
|
|
}
|
|
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);
|
|
}
|