837 lines
27 KiB
Dart
837 lines
27 KiB
Dart
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 '../../core/ui/app_icons.dart';
|
|
import '../../core/ui/sheet_route.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 '../planet/planet_data.dart' show countryName;
|
|
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;
|
|
|
|
/// ② 에서 서버에 기록한 동의 항목. ③ 에서 뒤로 오면 이 상태로 체크해 보여 준다.
|
|
Set<String>? _agreed;
|
|
|
|
/// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(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;
|
|
// ③ 에서 뒤로 왔다가 그대로 「완료」면 기록을 다시 남기지 않는다(서버 동의 기록 유지).
|
|
final prev = _agreed;
|
|
if (prev != null &&
|
|
prev.length == agreed.length &&
|
|
prev.containsAll(agreed)) {
|
|
setState(() => _step = _Step.profile);
|
|
return;
|
|
}
|
|
_run(() async {
|
|
await widget.repository.agree(agreed, signupTermsVersion);
|
|
_agreed = agreed;
|
|
if (mounted) setState(() => _step = _Step.profile);
|
|
});
|
|
}
|
|
|
|
void _back() => setState(
|
|
() => _step = switch (_step) {
|
|
// 명세 `511:86825` 「상단 뒤로가기 — 이전 화면」. 이전 = ② 약관(동의 체크 유지).
|
|
_Step.profile => _Step.terms,
|
|
_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(
|
|
key: ValueKey(_agreed),
|
|
initial: _agreed ?? const {},
|
|
onAgreed: _agree,
|
|
onBack: widget.auth.signOut,
|
|
),
|
|
_Step.profile => _ProfileStep(
|
|
repository: widget.repository,
|
|
onBack: _back,
|
|
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>(
|
|
SheetRoute(
|
|
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,
|
|
required this.onBack,
|
|
});
|
|
|
|
final OnboardingRepository repository;
|
|
final VoidCallback onDone;
|
|
final VoidCallback onBack;
|
|
|
|
@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);
|
|
}
|
|
}
|
|
|
|
Future<void> _pickCountry() async {
|
|
final picked = await showModalBottomSheet<String>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: const Color(0xFF101010),
|
|
builder: (_) => _CountrySheet(current: _country),
|
|
);
|
|
if (picked != null && mounted) setState(() => _country = picked);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final current = _locale ?? LocaleScope.maybeOf(context)?.locale;
|
|
return _StepScaffold(
|
|
title: l10n.onbProfileTitle,
|
|
onBack: widget.onBack,
|
|
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),
|
|
// 사본 국가 리스트 모달 `178:23489`(명세 `511:86786` · `511:86796`) — 국기 · 국가명 ·
|
|
// 단수 선택 체크, 「선택」을 눌러야 적용하고 닫힌다.
|
|
InkWell(
|
|
onTap: _pickCountry,
|
|
child: InputDecorator(
|
|
decoration: InputDecoration(
|
|
labelText: l10n.onbCountry,
|
|
suffixIcon: const Icon(Icons.expand_more),
|
|
),
|
|
isEmpty: _country == null,
|
|
child: _country == null
|
|
? null
|
|
: Text('${countryFlag(_country!)} ${countryName(_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,
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 국가 리스트 모달 — 사본 `178:23489`.
|
|
///
|
|
/// 명세 `511:86762` 타이틀 좌측 · 닫기 우측(누르면 모달 종료) / `511:86786` 국기 좌측 · 국가명 ·
|
|
/// 선택한 국가에만 오른쪽 체크(단수) / `511:86796` 국가를 골라야 「선택」이 켜지고, 누르면 적용 후 닫힘.
|
|
class _CountrySheet extends StatefulWidget {
|
|
const _CountrySheet({required this.current});
|
|
|
|
final String? current;
|
|
|
|
@override
|
|
State<_CountrySheet> createState() => _CountrySheetState();
|
|
}
|
|
|
|
class _CountrySheetState extends State<_CountrySheet> {
|
|
late String? _picked = widget.current;
|
|
|
|
@override
|
|
Widget build(BuildContext context) => SafeArea(
|
|
child: SizedBox(
|
|
height: MediaQuery.sizeOf(context).height * 0.8,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 12, 4, 0),
|
|
child: Row(
|
|
children: [
|
|
Expanded(child: Text(context.l10n.onbCountry, style: _title)),
|
|
IconButton(
|
|
tooltip: context.l10n.commonClose,
|
|
onPressed: () => Navigator.of(context).pop(),
|
|
icon: const ImageIcon(AppIcons.x, size: 24),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Expanded(
|
|
child: ListView(
|
|
children: [
|
|
for (final c in isoCountries)
|
|
ListTile(
|
|
onTap: () => setState(() => _picked = c),
|
|
leading: Text(
|
|
countryFlag(c),
|
|
style: const TextStyle(fontSize: 22),
|
|
),
|
|
title: Text(countryName(c)),
|
|
trailing: c == _picked
|
|
? const Icon(
|
|
Icons.check_circle_outline,
|
|
color: AppColors.cosmicAccent,
|
|
)
|
|
: null,
|
|
),
|
|
],
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.all(AppSpace.s4),
|
|
child: _PrimaryButton(
|
|
label: context.l10n.commonSelect,
|
|
onPressed: _picked == null
|
|
? null
|
|
: () => Navigator.of(context).pop(_picked),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
/// ④ 관심 아티스트 / 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(' ');
|