Figma 설명 프레임 반영 — 로그인·온보딩·스플래시 · HOME · GALAXY · 퀘스트·이클립스·알림

This commit is contained in:
Sn-Kinos
2026-10-06 03:03:20 +09:00
parent 64825e72b0
commit ecf5998725
77 changed files with 2703 additions and 640 deletions
+3
View File
@@ -138,6 +138,9 @@ abstract interface class AuthSession {
/// **제공자가 인증한 이메일 전체**를 정규화(trim·소문자)해 비교한다.
/// 로컬 파트만 보면 `test123@naver.com` 과 `test123@gmail.com` 처럼 남의 계정이
/// 붙는다. 비었거나 `@` 가 없는 값은 이메일로 보지 않아 false.
///
/// Figma 예시(앞부분 같으면 O)는 **2026-09-16 회신 3절 7항**(「다른 도메인 이메일의 앞부분만으로
/// 동일 소유자를 판단하지 않습니다」)이 대체했다. 이메일을 주지 않는 제공자의 연결은 해석 확인 Q-E8.
bool sameAccountIdentity(String a, String b) {
final x = a.trim().toLowerCase();
return x.contains('@') && x == b.trim().toLowerCase();
+9 -6
View File
@@ -102,6 +102,8 @@ class _LoginScreenState extends State<LoginScreen> {
void _open({required bool signUp}) {
Navigator.of(context).push(
MaterialPageRoute<void>(
// 명세 `510:86374` 「닫기 — 아래 방향 Move Out 으로 이전 화면」 — 아래에서 올라오는 전체 화면.
fullscreenDialog: true,
builder: (_) => SimpleLoginScreen(auth: widget.auth, signUp: signUp),
),
);
@@ -114,20 +116,20 @@ class _LoginScreenState extends State<LoginScreen> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 시안 173:1905 — 상단 386 영역 가운데에 핑크 원(120) 하나. 로고 원본 미확정(Q6).
// 시안 173:1905 — 상단 386 영역 가운데 로고(120). 명세 `510:86330` 「컨테인 중앙 정렬」.
// 로고는 발주사 인수 자산 `fanuzu logo only.png` 의 마크(공통 기준 4항 FANUZU).
SizedBox(
height: 386,
child: Center(
child: GestureDetector(
key: loginLogoKey,
onLongPress: _toggleDevPanel,
child: Container(
child: Image.asset(
'assets/brand/fanuzu-mark.png',
width: 120,
height: 120,
decoration: const BoxDecoration(
color: AppColors.cosmicAccent,
shape: BoxShape.circle,
),
fit: BoxFit.contain,
semanticLabel: 'FANUZU',
),
),
),
@@ -247,6 +249,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
void _switchMode() {
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
fullscreenDialog: true,
builder: (_) =>
SimpleLoginScreen(auth: widget.auth, signUp: !widget.signUp),
),
+126 -11
View File
@@ -4,11 +4,13 @@ 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 '../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';
@@ -159,6 +161,9 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
_Step _step = _Step.terms;
bool _busy = false;
/// ② 에서 서버에 기록한 동의 항목. ③ 에서 뒤로 오면 이 상태로 체크해 보여 준다.
Set<String>? _agreed;
/// ④ 에서 고른 팬덤 — 고른 순서. 첫 번째가 대표(Primary).
final _picked = <OnboardingFandom>[];
@@ -189,14 +194,25 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
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,
@@ -216,9 +232,15 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
@override
Widget build(BuildContext context) {
final body = switch (_step) {
_Step.terms => TermsScreen(onAgreed: _agree, onBack: widget.auth.signOut),
_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(
@@ -262,10 +284,15 @@ class _OnboardingFlowState extends State<OnboardingFlow> {
/// 사본 `MY/프로필 설정` 구성을 따른다. 국가 기본값은 기기 지역(Figma `554:24126` 「앱 기동 단계에서
/// 조회된 국가/언어를 디폴트」).
class _ProfileStep extends StatefulWidget {
const _ProfileStep({required this.repository, required this.onDone});
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();
@@ -348,12 +375,23 @@ class _ProfileStepState extends State<_ProfileStep> {
}
}
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,
@@ -369,15 +407,20 @@ class _ProfileStepState extends State<_ProfileStep> {
),
),
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),
// 사본 국가 리스트 모달 `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>(
@@ -407,6 +450,78 @@ class _ProfileStepState extends State<_ProfileStep> {
}
}
/// 국가 리스트 모달 — 사본 `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 {
+13 -2
View File
@@ -14,7 +14,15 @@ import '../../core/theme/tokens.dart';
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와
/// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
class TermsScreen extends StatefulWidget {
const TermsScreen({super.key, this.onAgreed, this.onBack});
const TermsScreen({
super.key,
this.onAgreed,
this.onBack,
this.initial = const {},
});
/// 처음부터 체크해 둘 항목 키 — 온보딩 ③ 에서 뒤로 왔을 때 이미 동의한 항목.
final Set<String> initial;
/// 온보딩 안에서 쓸 때 — 「완료」가 화면을 닫지 않고 이걸 부른다.
final ValueChanged<Set<String>>? onAgreed;
@@ -58,7 +66,10 @@ const _body =
const _stroke = Color(0xFF545454);
class _TermsScreenState extends State<TermsScreen> {
final _agreed = <_Term>{};
late final _agreed = <_Term>{
for (final t in _Term.values)
if (widget.initial.contains(t.name)) t,
};
bool get _all => _agreed.length == _Term.values.length;
bool get _ready =>