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 createState() => _IntroScreenState(); } class _IntroScreenState extends State { 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 createState() => _OnboardingFlowState(); } class _OnboardingFlowState extends State { _Step _step = _Step.terms; bool _busy = false; /// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary). final _picked = []; @override void initState() { super.initState(); // 가입 모드는 제공자 인증 전에 약관을 받았다 — 다시 묻지 않고 기록만 한다. final pending = widget.auth.pendingConsent; if (pending != null) _agree(pending); } Future _run(Future 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 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 _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( 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 _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( 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( 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 picked; final VoidCallback onNext; final VoidCallback onBack; @override State<_ArtistStep> createState() => _ArtistStepState(); } class _ArtistStepState extends State<_ArtistStep> { late Future> _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>( 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 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 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 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 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(' ');