1차 보완(0.1.1+14) 내용 합치기

This commit is contained in:
Sn-Kinos
2026-10-16 17:58:55 +09:00
62 changed files with 2480 additions and 447 deletions
+5 -13
View File
@@ -133,19 +133,6 @@ abstract interface class AuthSession {
Future<void> signOut();
}
/// 「동일 이메일만 연동 가능」 판정 — 명세 `554:22797` · 안내 문구 `554:23251`.
///
/// **제공자가 인증한 이메일 전체**를 정규화(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();
}
/// 연동 화면에 거는 제공자 — 별첨 B v1.4 확정 4종.
/// 이메일은 QA 내부 경로라 목록에 없다.
const List<AuthProviderKind> linkableProviders = [
@@ -159,6 +146,11 @@ const List<AuthProviderKind> linkableProviders = [
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// Firebase 는 `User.linkWithCredential` / `User.unlink` 로 이걸 지원한다.
///
/// 통합검토회신 v2.3 **Q-E8**(C 안 보완) — 연결은 현재 계정의 유효한 인증 상태와 새 Provider 인증 자격을
/// **서버가** 모두 확인해 붙인다(Firebase Auth `linkWithCredential` · 카카오는 `fnAuthKakao`). 이메일 일치는
/// 소유 확인이 아니어서 비교하지 않는다 — 이메일을 주지 않는 카카오 · LINE 계정도 붙는다. 그 Provider 가
/// 다른 FANUZU UID 에 붙어 있으면 거부하고(E-AUTH-08) 자동 병합하지 않는다.
/// 저장소와 화면은 이 인터페이스만 알고 SDK 는 어댑터 뒤에 둔다 — 덕분에 설정
/// 파일 없이도 연결·해제 규칙을 단위 테스트할 수 있다.
abstract interface class AccountLinker {
+3 -13
View File
@@ -4,7 +4,6 @@ import 'package:flutter/foundation.dart';
import '../../core/firebase/firebase_boot.dart';
import 'auth.dart';
import 'firebase_account_linker.dart';
import 'social_providers.dart';
import '../../core/i18n/app_locale.dart';
@@ -120,9 +119,7 @@ class AuthRepository extends ChangeNotifier {
/// 사용자가 닿는 경로가 없다 — 읽는 분기가 없는 기능 플래그로 닫는 것보다 확실하다.
/// QA 경로는 [qaEmailProvider] 하나뿐이다.
///
/// 카카오는 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider]), LINE 은 Firebase OIDC
/// 제공자다. OIDC 를 콘솔에서 켜지 않았으면 Firebase 가 `operation-not-allowed`(E-AUTH-11)를
/// 돌려준다.
/// 카카오 · LINE 은 공식 SDK + 서버 커스텀 토큰([KakaoSignInProvider] · [LineSignInProvider])이다.
static List<SocialAuthProvider> _defaultProviders() => [
const GoogleSignInProvider(),
// Android 는 Apple SDK 가 없어 Firebase 웹 흐름(`apple.com` 제공자)으로 간다 —
@@ -138,15 +135,8 @@ class AuthRepository extends ChangeNotifier {
// 동의 항목은 카카오 콘솔 설정을 따른다(지금은 닉네임). 이메일(account_email)은 비즈 앱
// 전환 후 콘솔에서 켜면 서버가 동의·인증된 값만 받는다.
KakaoSignInProvider(),
// LINE 동의 항목: profile·email(이메일 권한 신청 승인 후).
OidcSignInProvider(
AuthProviderKind.line,
providerId: FirebaseAccountLinker.providerIds[AuthProviderKind.line]!,
scopes: const ['profile', 'email'],
// LINE 앱 자동 로그인으로 넘어가면 콜백이 Custom Tab 밖에서 열려 로그인이
// 깨진다(Android). LINE Login 공식 파라미터로 끈다.
androidParameters: const {'disable_auto_login': 'true'},
),
// LINE 동의 항목: profile · openid · email(이메일 권한 신청 승인 후에만 온다 — 없어도 로그인된다).
LineSignInProvider(),
];
/// QA 계정(qa-test@fanuzu.co.kr) 전용 이메일 로그인 — **디버그 빌드에서만**.
@@ -27,8 +27,8 @@ final class FirebaseAccountLinker implements AccountLinker {
AuthProviderKind.google: 'google.com',
AuthProviderKind.apple: 'apple.com',
// 카카오·LINE 은 Firebase 기본 제공자가 아니라 OIDC 커스텀 제공자로 붙는다.
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 카카오는 로그인을
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao)가 같은 `oidc.kakao` 로 연결을 남긴다.
// 접두사 `oidc.` 는 Firebase 콘솔 등록 시 강제되는 형식이다. 두 제공자 모두 로그인을
// 서버 커스텀 토큰으로 옮겼지만, 서버(fnAuthKakao · fnAuthLine)가 같은 ID 로 연결을 남긴다.
AuthProviderKind.kakao: 'oidc.kakao',
AuthProviderKind.line: 'oidc.line',
AuthProviderKind.email: 'password',
@@ -59,38 +59,28 @@ final class FirebaseAccountLinker implements AccountLinker {
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
if (impl is! CredentialProvider &&
impl is! OidcSignInProvider &&
impl is! KakaoSignInProvider) {
impl is! KakaoSignInProvider &&
impl is! LineSignInProvider) {
return AuthNotConfigured(provider, const ['자격증명 구현']);
}
try {
if (impl is KakaoSignInProvider) return await impl.link(user);
if (impl is LineSignInProvider) return await impl.link(user);
if (impl is OidcSignInProvider) return await _linkOidc(user, impl);
final source = impl as CredentialProvider;
final obtained = await source.obtainCredential();
// 사용자가 소셜 창을 닫았다. 실패로 세지 않는다.
if (obtained == null) return const AuthCancelled();
// 명세 `554:22797` — *동일 이메일만 연동 가능*. 붙이기 **전에** 막는다.
// 붙인 뒤 되돌리는 방식은 unlink 가 실패하면 남의 계정이 붙은 채로 남는다.
//
// ponytail: 제공자가 이메일을 알려 줄 때만 판정한다. Apple 은 최초 인가
// 때만 이메일을 주므로 재연동 시 통과한다 — 서버(fn-account-link)에서
// 다시 보려면 Functions 층에 같은 판정을 둔다.
final mine = user.email;
final theirs = obtained.email;
if (mine != null &&
theirs != null &&
!sameAccountIdentity(mine, theirs)) {
return AuthFailed('E-AUTH-08', appL10n.authDifferentEmail);
}
// 이메일을 비교하지 않는다(Q-E8). Firebase 가 지금 로그인 토큰과 새 자격증명을 서버에서 함께
// 검증하고, 그 Provider 가 다른 UID 에 붙어 있으면 credential-already-in-use 로 거부한다.
final result = await user.linkWithCredential(obtained.credential);
return AuthSucceeded(_accountOf(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
if (isWebFlowCancelled(error)) return const AuthCancelled();
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.link),
authMessage(error, flow: AuthFlow.link),
);
} on Object catch (error) {
@@ -120,39 +110,20 @@ final class FirebaseAccountLinker implements AccountLinker {
return AuthSucceeded(_accountOf(_firebaseAuth.currentUser, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.link),
authMessage(error, flow: AuthFlow.link),
);
}
}
/// 카카오·LINE(OIDC) 연결.
///
/// ponytail: OIDC 웹 흐름은 자격증명을 먼저 내주지 않고 연결까지 한 번에 끝낸다.
/// 그래서 「동일 이메일만 연동」을 **붙인 뒤** 판정하고 어긋나면 바로 뗀다.
/// 떼기가 실패하면 남의 이메일 계정이 붙은 채 남는다 — 막아야 하면 Functions 의
/// blocking 함수(beforeSignIn)로 서버에서 거른다.
/// 웹 흐름(OIDC — Android Apple) 연결 — 웹 흐름이 인증과 연결을 한 번에 끝낸다. 검증 · 충돌 거부는 Firebase 가 한다(Q-E8).
Future<AuthOutcome> _linkOidc(fb.User user, OidcSignInProvider impl) async {
final mine = user.email;
final result = await untilAbandoned(
user.linkWithProvider(impl.authProvider),
final result = await retryStaleWebContext(
() => untilAbandoned(user.linkWithProvider(impl.authProvider)),
);
// 연결된 제공자 정보에 그 계정의 이메일이 실린다(동의 항목에 이메일이 있을 때).
final theirs = _emailOf(result.user, impl.providerId);
if (mine != null && theirs != null && !sameAccountIdentity(mine, theirs)) {
await user.unlink(impl.providerId);
return AuthFailed('E-AUTH-08', appL10n.authDifferentEmail);
}
return AuthSucceeded(_accountOf(result.user, impl.provider));
}
static String? _emailOf(fb.User? user, String providerId) {
for (final info in user?.providerData ?? const <fb.UserInfo>[]) {
if (info.providerId == providerId) return info.email;
}
return null;
}
SocialAuthProvider? _implOf(AuthProviderKind provider) {
for (final candidate in _providers) {
if (candidate.provider == provider) return candidate;
+24 -2
View File
@@ -215,6 +215,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
bool _busy = false;
Future<void> _signIn(SocialProvider provider) async {
if (_busy) return;
if (widget.signUp) {
final agreed = await Navigator.of(context).push<Set<String>>(
MaterialPageRoute(builder: (_) => const TermsScreen()),
@@ -223,6 +224,8 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
// 인증 뒤 온보딩이 이 동의를 서버에 기록한다(약관 단계를 다시 묻지 않는다).
widget.auth.pendingConsent = agreed;
}
// 제공자 화면에서 돌아온 뒤에도 서버 토큰 교환 · 로그인에 몇 초 걸린다(Kakao · LINE).
// 끝날 때까지 화면 전체를 막고 진행 표시를 띄운다 — 다시 누르지 못하게.
setState(() => _busy = true);
final outcome = await widget.auth.signIn(provider, signUp: widget.signUp);
if (!mounted) return;
@@ -264,7 +267,7 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
for (final kind in _figmaOrder)
...widget.auth.providers.where((p) => p.provider == kind),
];
return Scaffold(
final scaffold = Scaffold(
appBar: AppBar(
automaticallyImplyLeading: false,
actions: [
@@ -347,9 +350,26 @@ class _SimpleLoginScreenState extends State<SimpleLoginScreen> {
),
),
);
return PopScope(
canPop: !_busy,
child: Stack(children: [scaffold, if (_busy) const BlockingProgress()]),
);
}
}
/// 진행 중 화면 전체를 막는 반투명 막 + 가운데 진행 표시.
class BlockingProgress extends StatelessWidget {
const BlockingProgress({super.key});
@override
Widget build(BuildContext context) => const Stack(
children: [
ModalBarrier(dismissible: false, color: Color(0x8A000000)),
Center(child: CircularProgressIndicator()),
],
);
}
const _figmaOrder = [
AuthProviderKind.apple,
AuthProviderKind.google,
@@ -371,10 +391,12 @@ String providerButtonLabel(
: t.authProviderLogin(kind.name);
}
/// 로그인 결과 안내. 취소 · 중단도 안내한다 — 1차 검수 R01 「취소 · 실패 시 로그인 성공으로 처리하지 않음 ·
/// 오류 안내와 정상 재시도 가능」. 아무 말 없이 화면이 그대로면 사용자는 앱이 멈춘 줄 안다.
String? authNotice(AuthOutcome outcome, [AppLocalizations? l10n]) =>
switch (outcome) {
AuthSucceeded() => null,
AuthCancelled() => null,
AuthCancelled() => (l10n ?? koL10n).authSocialCancelled,
AuthFailed(:final code, :final message) => '[$code] $message',
AuthNotConfigured(:final provider) =>
(l10n ?? koL10n).authNotConfiguredNotice(provider.shortName),
+74 -21
View File
@@ -2,17 +2,24 @@ import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
/// 회원가입 약관 — Figma `179:24230`(미동의) · `179:24324`(전체 동의).
///
/// 필수 4항을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
/// 필수 항목을 모두 체크해야 「완료」가 켜진다. 완료하면 동의한 항목 키([signupTermKeys] 중)를
/// 돌려준다 — 가입 모드([SimpleLoginScreen])는 `pop` 결과로, 온보딩은 [onAgreed] 로 받는다.
/// 서버(`fnSignupConsent`)가 같은 필수 목록을 다시 검사하고 항목별 동의 행을 남긴다.
///
/// 필수 항목은 Figma 의 서비스 · 위치기반 · 만 14세에 개인정보 처리방침을 더했다(10/3 통합회신
/// 3절 4 「이용약관 · 개인정보처리방침 문구 제공」). 구성은 해석 확인 요청 E-Q1 회신 대기다.
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면 [_body] 와
/// [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
/// 항목은 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02** 를 따른다(공통 기준 2항 — Figma 의
/// 과거 구성보다 확정 회신이 우선).
/// - 필수: 서비스 이용약관 · 개인정보 수집·이용 동의 · 만 14세 이상.
/// - 선택: 마케팅 수신(기본 OFF).
/// - **위치기반 서비스 약관은 여기서 받지 않는다** — Figma 에는 있으나 위치기능 최초 사용 직전에
/// 따로 받는다(`features/quest/location_consent.dart` 의 `ensureLocationConsent`).
/// - 개인정보처리방침은 **동의 항목이 아니라 고지**다 — 체크 칸 없이 하단 버튼 위에 안내와 보기 링크만 둔다.
///
/// 약관 본문은 발주사 실서비스 문구를 받기 전까지 시안의 자리 표시 문구다 — 받으면
/// [termsPlaceholderBody] · l10n `terms*Body` 와 [signupTermsVersion] 을 함께 바꾼다(동의 기록에 버전이 남는다).
class TermsScreen extends StatefulWidget {
const TermsScreen({
super.key,
@@ -39,27 +46,33 @@ const signupTermsVersion = 'draft-1';
List<String> get signupTermKeys => [for (final t in _Term.values) t.name];
enum _Term {
service(required: true, body: true),
privacy(required: true, body: true),
location(required: true, body: true),
age(required: true, body: false),
marketing(required: false, body: true);
service(required: true),
privacy(required: true),
age(required: true),
marketing(required: false);
const _Term({required this.required, required this.body});
const _Term({required this.required});
final bool required;
final bool body;
String label(AppLocalizations l10n) => switch (this) {
service => l10n.termsService,
privacy => l10n.termsPrivacy,
location => l10n.termsLocation,
age => l10n.termsAge,
marketing => l10n.termsMarketing,
};
/// 항목 아래에 보이는 본문 — 항목마다 제목이 다르다. 만 14세는 본문 없이 체크 줄만 그린다.
String? body(AppLocalizations l10n) => switch (this) {
service => termsPlaceholderBody,
privacy => l10n.termsPrivacyBody,
age => null,
marketing => l10n.termsMarketingBody,
};
}
const _body =
/// 서비스 이용약관 본문 자리 표시 문구(시안 문구). 다른 약관 · 방침은 각자의 자리 표시 문구(l10n)를 쓴다.
const termsPlaceholderBody =
'FANUZU 서비스 이용약관\n\n제1조 (적용범위)\n서비스 이용약관에 대한 더미 텍스트 입니다.\n'
'추후 작성해주시기 바랍니다. 서비스 이용약관에 대한 더미 텍스트 입니다.';
@@ -140,7 +153,43 @@ class _TermsScreenState extends State<TermsScreen> {
bottomNavigationBar: SafeArea(
child: Padding(
padding: const EdgeInsets.all(AppSpace.s4),
child: _termsButtons(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 개인정보처리방침 — 동의 항목과 구분한 고지(Q-E1). 체크 칸이 없고 「보기」로 본문을 연다.
// 목록 끝에 두면 iPhone 11 높이에서 하단 버튼 뒤로 가려지므로 버튼 위에 고정한다.
Padding(
key: const ValueKey('privacy-policy-notice'),
padding: const EdgeInsets.only(bottom: AppSpace.s2),
child: Row(
children: [
Expanded(
child: Text(
context.l10n.termsPrivacyPolicyNotice,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.36,
color: AppColors.onCosmicMuted,
),
),
),
TextButton(
key: const ValueKey('privacy-policy-link'),
onPressed: () => showAlertDialog(
context,
context.l10n.termsPrivacyPolicy,
body: context.l10n.termsPrivacyPolicyBody,
),
child: Text(context.l10n.commonView),
),
],
),
),
_termsButtons(context),
],
),
),
),
body: ListView(
@@ -156,10 +205,12 @@ class _TermsScreenState extends State<TermsScreen> {
),
),
),
for (final t in _Term.values) ...[
for (final (t, body) in [
for (final t in _Term.values) (t, t.body(context.l10n)),
]) ...[
const SizedBox(height: AppSpace.s3),
_Box(
child: t.body
child: body != null
? Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -174,15 +225,17 @@ class _TermsScreenState extends State<TermsScreen> {
onTap: () => _toggle(t),
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: AppSpace.s4),
Padding(
padding: const EdgeInsets.symmetric(
vertical: AppSpace.s4,
),
child: SizedBox(
height: 80,
child: ClipRect(
child: Text(
_body,
body,
overflow: TextOverflow.clip,
style: TextStyle(
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
+260 -57
View File
@@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:google_sign_in/google_sign_in.dart' as gsi;
import 'package:flutter_line_sdk/flutter_line_sdk.dart' as line;
import 'package:kakao_flutter_sdk_user/kakao_flutter_sdk_user.dart' as kakao;
import 'package:sign_in_with_apple/sign_in_with_apple.dart' as apple;
@@ -26,12 +27,12 @@ abstract interface class CredentialProvider {
/// 사용자가 취소하면 null. 쓸 수 없는 상태면 [AuthNotConfigured] 를 던지는 대신
/// 호출부가 [SocialAuthProvider.missingConfig] 를 먼저 본다.
///
/// [SocialCredential.email] 은 「동일 이메일만 연동 가능」(명세 `554:22797`)
/// 판정에 쓴다. 제공자가 알려 주지 않으면 null 이다.
/// [SocialCredential.email] 은 제공자가 알려 줄 때만 있다(null 가능). 계정 연결의 소유 확인에는
/// 쓰지 않는다(Q-E8 — 이메일 일치는 소유 확인이 아니다).
Future<SocialCredential?> obtainCredential();
}
/// 자격증명 한 벌과, 판정에 쓸 그 계정의 이메일.
/// 자격증명 한 벌과 그 계정의 이메일(제공자가 줄 때만).
///
/// [name] 은 제공자가 자격증명과 함께만 주는 표시 이름(Apple fullName — 최초 인가 때만). Firebase 가
/// 이 값을 계정 이름으로 옮기지 않아, 로그인 직후 우리가 계정에 써 둔다(온보딩 닉네임 기본값).
@@ -99,7 +100,7 @@ final class GoogleSignInProvider
return AuthFailed('E-AUTH-03', error.description ?? error.code.name);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -165,13 +166,12 @@ final class AppleSignInProvider
accessToken: credential.authorizationCode,
rawNonce: rawNonce,
),
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이라
// 「동일 이메일만 연동」 판정을 할 수 없다 — 그때는 통과시킨다.
// Apple 은 **최초 인가 때만** 이메일을 준다. 두 번째부터는 null 이다.
email: credential.email,
name: [credential.givenName, credential.familyName]
.whereType<String>()
.where((part) => part.isNotEmpty)
.join(' '),
name: [
credential.givenName,
credential.familyName,
].whereType<String>().where((part) => part.isNotEmpty).join(' '),
);
}
@@ -192,7 +192,9 @@ final class AppleSignInProvider
obtained.credential,
);
final name = obtained.name;
if (name != null && name.isNotEmpty && (result.user?.displayName ?? '').isEmpty) {
if (name != null &&
name.isNotEmpty &&
(result.user?.displayName ?? '').isEmpty) {
await result.user?.updateDisplayName(name);
}
return AuthSucceeded(_toAccount(result.user, provider));
@@ -206,7 +208,7 @@ final class AppleSignInProvider
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -221,8 +223,9 @@ final class AppleSignInProvider
kIsWeb || defaultTargetPlatform == TargetPlatform.android;
}
/// LINE — Firebase Auth 의 OpenID Connect 제공자(`oidc.line`). 카카오도 이 경로였으나
/// Android 앱 전환 문제로 [KakaoSignInProvider] 로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
/// Firebase Auth 웹 흐름 제공자 — 지금은 Android 의 Apple(`apple.com`)만 쓴다. 카카오 · LINE 도 이
/// 경로(`oidc.kakao` · `oidc.line`)였으나 앱 전환 · 「missing initial state」 문제로 공식 SDK + 서버 커스텀
/// 토큰([KakaoSignInProvider] · [LineSignInProvider])으로 옮겼다(클래스는 제공자 무관이라 그대로 둔다).
///
/// 두 회사 모두 OIDC 표준(issuer `https://kauth.kakao.com` · `https://access.line.me`)을
/// 지원해 Firebase 가 코드 교환·ID 토큰 검증까지 맡는다. 앱은 SDK 없이
@@ -242,7 +245,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
this.provider, {
required this.providerId,
this.scopes = const [],
this.androidParameters = const {},
this.firebaseAuth,
});
@@ -257,12 +259,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
/// KOE205 로 로그인 자체를 막는다.
final List<String> scopes;
/// Android 에서만 인가 URL 에 붙일 파라미터. Android Custom Tab 은 다른 앱으로
/// 넘어갔다 돌아오면 콜백이 일반 Chrome 탭에서 열려 Firebase 핸들러가
/// 「missing initial state」로 끝난다 — 앱 전환을 막는 공식 파라미터를 여기 둔다.
/// iOS 는 정상이라 건드리지 않는다.
final Map<String, String> androidParameters;
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
final fb.FirebaseAuth? firebaseAuth;
@@ -278,9 +274,6 @@ final class OidcSignInProvider implements SocialAuthProvider {
for (final scope in scopes) {
p.addScope(scope);
}
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.android) {
p.setCustomParameters(androidParameters);
}
return p;
}
@@ -289,16 +282,18 @@ final class OidcSignInProvider implements SocialAuthProvider {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
final result = await untilAbandoned(
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
authProvider,
final result = await retryStaleWebContext(
() => untilAbandoned(
(firebaseAuth ?? fb.FirebaseAuth.instance).signInWithProvider(
authProvider,
),
),
);
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
if (isWebFlowCancelled(error)) return const AuthCancelled();
return AuthFailed(
authCatalogCode(error),
authCatalogCode(error, flow: AuthFlow.signIn),
authMessage(error, flow: AuthFlow.signIn),
);
}
@@ -389,7 +384,8 @@ final class KakaoSignInProvider implements SocialAuthProvider {
return AuthSucceeded(_toAccount(result.user, provider));
});
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 「동일 이메일만 연동」은 서버가 붙이기 전에 본다.
/// SNS 연동 — 로그인한 UID 에 카카오를 붙인다. 현재 세션 · 카카오 토큰 검증과 다른 UID 연결 거부는
/// 서버(`fnAuthKakao` link)가 한다. 이메일은 보지 않는다(Q-E8).
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
await _exchange({'accessToken': await _accessToken(), 'link': true});
await user.reload();
@@ -415,28 +411,20 @@ final class KakaoSignInProvider implements SocialAuthProvider {
}
// 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다 —
// 10/6 릴리스 빌드 E-AUTH-03 은 이 분기로 들어와 아무 로그도 남지 않았다.
debugPrint('[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}');
debugPrint(
'[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}',
);
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on kakao.KakaoException catch (error) {
debugPrint('[KakaoSignInProvider] sdk error: $error');
return AuthFailed('E-AUTH-03', appL10n.authSocialFailed);
} on FirebaseFunctionsException catch (error) {
final details = error.details;
final code = details is Map && details['code'] is String
? details['code'] as String
: 'E-AUTH-03';
return AuthFailed(code, switch (code) {
_ when details is Map && details['reason'] == 'different-email' =>
appL10n.authDifferentEmail,
'E-AUTH-08' => appL10n.authCredentialInUse,
'E-AUTH-09' => appL10n.authAlreadyLinked,
_ =>
flow == AuthFlow.link
? appL10n.authLinkFailed
: appL10n.authSocialFailed,
});
return _serverFailure(error, flow);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(authCatalogCode(error), authMessage(error, flow: flow));
return AuthFailed(
authCatalogCode(error, flow: flow),
authMessage(error, flow: flow),
);
}
}
@@ -453,10 +441,195 @@ final class KakaoSignInProvider implements SocialAuthProvider {
}
}
/// 커스텀 토큰 교환 서버(`fnAuthKakao` · `fnAuthLine`)의 거부를 카탈로그 코드 · 문구로 옮긴다.
AuthFailed _serverFailure(FirebaseFunctionsException error, AuthFlow flow) {
final details = error.details;
final code = details is Map && details['code'] is String
? details['code'] as String
: 'E-AUTH-03';
return AuthFailed(code, switch (code) {
_ when details is Map && details['reason'] == 'session' =>
appL10n.authRecentLogin,
'E-AUTH-08' => appL10n.authCredentialInUse,
'E-AUTH-09' => appL10n.authAlreadyLinked,
_ =>
flow == AuthFlow.link ? appL10n.authLinkFailed : appL10n.authSocialFailed,
});
}
/// LINE Login 채널 ID. 카카오 앱 키와 같은 `config/kakao.env` 를 `--dart-define-from-file` 로 넘긴다.
const lineChannelId = String.fromEnvironment('FANUZU_LINE_CHANNEL_ID');
/// LINE 이 돌려준 토큰. ID 토큰은 `openid` 동의가 있을 때만 온다 — 서버는 있으면 ID 토큰으로 검증한다.
typedef LineTokens = ({String? idToken, String accessToken});
/// LINE — 공식 SDK 로그인 + 서버 커스텀 토큰(`fnAuthLine`). [KakaoSignInProvider] 와 같은 구조다.
///
/// Firebase OIDC(`oidc.line`) 웹 흐름은 iOS 에서 첫 시도에 「missing initial state」로 깨진다(10/11
/// iPhone 11 · iOS 26 — `disable_ios_auto_login` 을 붙여도 재현). SDK 가 LINE 앱(없으면 SDK 웹 화면)으로
/// 로그인해 받은 토큰을 서버가 LINE API 로 검증하고 커스텀 토큰을 내준다.
///
/// 서버는 사용자 제공자 정보에 `oidc.line` 을 그대로 남긴다 — OIDC 시절 가입자는 같은 UID 로 들어오고,
/// [FirebaseAccountLinker.linkedAccounts] 도 지금처럼 「연결됨」을 그린다.
///
/// 동의 항목: profile · openid · email. 이메일은 LINE 이메일 권한 승인 · 사용자 동의가 있을 때만 ID 토큰에
/// 오고, 서버가 그때만 남긴다(C-143). 없어도 로그인 · 연결이 된다.
final class LineSignInProvider implements SocialAuthProvider {
LineSignInProvider({
Future<LineTokens> Function()? login,
Future<Map<String, dynamic>> Function(Map<String, dynamic> payload)?
exchange,
this.firebaseAuth,
this.channelId = lineChannelId,
}) : _login = login ?? _sdkLogin,
_exchange = exchange ?? _callServer;
final Future<LineTokens> Function() _login;
final Future<Map<String, dynamic>> Function(Map<String, dynamic>) _exchange;
/// 테스트 주입용. 기본은 [fb.FirebaseAuth.instance].
final fb.FirebaseAuth? firebaseAuth;
final String channelId;
static Future<void>? _setup;
/// 사용자 취소 — Android `CANCEL`(LineApiResponseCode) · iOS `3003`(LineSDKError userCancelled).
static const _cancelCodes = {'CANCEL', '3003'};
@override
SocialProvider get provider => SocialProvider.line;
@override
bool get isLive => true;
@override
List<String> get missingConfig => [
if (!FirebaseBoot.isLive) ...FirebaseBoot.missingLabels,
if (channelId.isEmpty) 'LINE 채널 ID',
];
fb.FirebaseAuth get _auth => firebaseAuth ?? fb.FirebaseAuth.instance;
static Future<LineTokens> _sdkLogin() async {
await (_setup ??= line.LineSDK.instance.setup(lineChannelId));
final result = await line.LineSDK.instance.login(
scopes: const ['profile', 'openid', 'email'],
);
return (
idToken: result.accessToken.idTokenRaw,
accessToken: result.accessToken.value,
);
}
static Future<Map<String, dynamic>> _callServer(
Map<String, dynamic> payload,
) async {
final result = await FirebaseFunctions.instanceFor(
region: Backend.region,
).httpsCallable('fnAuthLine').call<Object?>(payload);
final data = result.data;
return data is Map ? Map<String, dynamic>.from(data) : const {};
}
Future<Map<String, dynamic>> _tokens() async {
final t = await _login();
return {
if (t.idToken != null) 'idToken': t.idToken,
'accessToken': t.accessToken,
};
}
@override
Future<AuthOutcome> signIn() => _run(AuthFlow.signIn, () async {
final data = await _exchange(await _tokens());
final result = await _auth.signInWithCustomToken(data['token'] as String);
return AuthSucceeded(_toAccount(result.user, provider));
});
/// SNS 연동 — 로그인한 UID 에 LINE 을 붙인다. 현재 세션 · LINE 토큰 검증과 다른 UID 연결 거부는
/// 서버(`fnAuthLine` link)가 한다. 이메일은 보지 않는다(Q-E8).
Future<AuthOutcome> link(fb.User user) => _run(AuthFlow.link, () async {
await _exchange({...await _tokens(), 'link': true});
await user.reload();
return AuthSucceeded(_toAccount(_auth.currentUser ?? user, provider));
});
Future<AuthOutcome> _run(
AuthFlow flow,
Future<AuthOutcome> Function() body,
) async {
final missing = missingConfig;
if (missing.isNotEmpty) return AuthNotConfigured(provider, missing);
try {
return await body();
} on PlatformException catch (error) {
if (_cancelCodes.contains(error.code)) return const AuthCancelled();
debugPrint(
'[LineSignInProvider] sdk failed: ${error.code} ${error.message}',
);
return AuthFailed(
'E-AUTH-03',
flow == AuthFlow.link
? appL10n.authLinkFailed
: appL10n.authSocialFailed,
);
} on FirebaseFunctionsException catch (error) {
return _serverFailure(error, flow);
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error, flow: flow),
authMessage(error, flow: flow),
);
}
}
@override
Future<void> signOut() async {
// SDK 를 한 번도 띄우지 않았으면 지울 LINE 토큰도 없다.
if (_setup == null) return;
try {
await line.LineSDK.instance.logout();
} on Object catch (error) {
// 토큰이 이미 만료 · 폐기됐어도 로그아웃을 막을 이유가 없다.
debugPrint('[LineSignInProvider] logout: ${error.runtimeType}');
}
}
}
/// 사용자가 인가 웹 화면을 닫았다. Android 는 `canceled`, iOS 는 `cancelled` 철자다.
/// `user-cancelled` 는 동의 화면에서 거절한 경우다(웹 흐름).
bool isWebFlowCancelled(fb.FirebaseAuthException error) =>
error.code == 'web-context-canceled' ||
error.code == 'web-context-cancelled';
error.code == 'web-context-cancelled' ||
error.code == 'user-cancelled';
const _authChannel = MethodChannel('kr.co.fanuzu.app/auth');
/// Android — 결과 없이 남은 Firebase 웹 인가 대기 작업을 취소로 끝낸다(MainActivity `cancelPendingWebSignIn`).
///
/// LINE 로그인 도중 본인 확인을 하러 LINE 앱으로 갔다 돌아오는 식으로 흐름이 끊기면, Firebase 는 대기 작업을
/// 들고 있어 다음 시도를 `web-context-already-presented` 로 막는다(10/8 실기기 — 다시 눌러도 로그인이 안 됨).
/// 대기 작업이 없으면 아무 일도 하지 않는다. iOS · 웹은 SDK 가 스스로 정리한다.
Future<void> clearStaleWebSignIn() async {
if (kIsWeb || defaultTargetPlatform != TargetPlatform.android) return;
try {
await _authChannel.invokeMethod<void>('cancelPendingWebSignIn');
} on Object catch (error) {
debugPrint('[Auth] clear stale web sign-in skipped: $error');
}
}
/// 웹 인가가 앞선 흐름에 막혔으면(`web-context-already-presented`) 남은 대기를 정리하고 **한 번만** 다시 연다.
/// 1차 검수 R01 「취소 · 실패 후 정상 재시도 가능」.
Future<T> retryStaleWebContext<T>(Future<T> Function() attempt) async {
try {
return await attempt();
} on fb.FirebaseAuthException catch (error) {
if (error.code != 'web-context-already-presented') rethrow;
await clearStaleWebSignIn();
return attempt();
}
}
/// Android 에서 웹 인가 흐름이 결과 없이 버려졌으면 취소(`web-context-canceled`)로 끝낸다.
///
@@ -481,6 +654,8 @@ Future<T> untilAbandoned<T>(
if (state != AppLifecycleState.resumed) return;
timer = Timer(grace, () {
if (done.isCompleted) return;
// Firebase 쪽 대기 작업도 끝낸다 — 남기면 다음 시도가 web-context-already-presented 로 막힌다.
unawaited(clearStaleWebSignIn());
done.completeError(
fb.FirebaseAuthException(code: 'web-context-canceled'),
);
@@ -569,8 +744,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
return const AuthCancelled();
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authMessage(error, flow: AuthFlow.signIn),
authCatalogCode(error, flow: AuthFlow.email),
authMessage(error, flow: AuthFlow.email),
);
}
}
@@ -583,8 +758,8 @@ final class EmailPasswordProvider implements EmailAuthProvider {
return AuthSucceeded(_toAccount(result.user, provider));
} on fb.FirebaseAuthException catch (error) {
return AuthFailed(
authCatalogCode(error),
authMessage(error, flow: AuthFlow.signIn),
authCatalogCode(error, flow: AuthFlow.email),
authMessage(error, flow: AuthFlow.email),
);
}
}
@@ -599,7 +774,14 @@ final class EmailPasswordProvider implements EmailAuthProvider {
///
/// **계정 존재 여부를 드러내지 않는다.** 「등록되지 않은 이메일」과 「비밀번호
/// 오류」를 갈라 주면 어느 이메일이 가입돼 있는지 훑을 수 있다.
String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
///
/// 알 수 없는 코드는 경로별로 갈린다 — 이메일 폼([AuthFlow.email])은 E-AUTH-01, 소셜 로그인 · 연결은
/// 소셜 실패 E-AUTH-03 이다. 예전에는 하나로 E-AUTH-01 이라 LINE 웹 로그인이 끊기면(`web-*`)
/// 「이메일 또는 비밀번호를 확인해 주세요」가 떴다(10/8 실기기 · 1차 검수 R01).
String authCatalogCode(
fb.FirebaseAuthException error, {
required AuthFlow flow,
}) => switch (error.code) {
'email-already-in-use' => 'E-AUTH-04',
'weak-password' => 'E-AUTH-05',
'invalid-email' => 'E-AUTH-06',
@@ -613,8 +795,13 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
'no-such-provider' => 'E-AUTH-10',
// Firebase 콘솔에서 제공자를 켜지 않았을 때. 사유가 사용자가 아니라 설정에 있다.
'operation-not-allowed' => 'E-AUTH-11',
'requires-recent-login' => 'E-AUTH-12',
_ => 'E-AUTH-01',
// 현재 계정의 세션이 만료 · 폐기됐다(Q-E8 「세션 만료 · 인증 취소」). 다시 로그인해야 한다.
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => 'E-AUTH-12',
_ => flow == AuthFlow.email ? 'E-AUTH-01' : 'E-AUTH-03',
};
/// 오류 문구가 갈리는 경로.
@@ -623,9 +810,12 @@ String authCatalogCode(fb.FirebaseAuthException error) => switch (error.code) {
/// 밝힌다. 기본값을 두지 않는 것은 의도한 것이다 — 새 호출부가 경로를 밝히지
/// 않고 지나가면 다시 한쪽 문구가 양쪽에 나간다.
enum AuthFlow {
/// 로그인 화면에서 계정으로 들어가려는 경로(`signInWithCredential`).
/// 소셜 로그인 경로(`signInWithCredential` · `signInWithProvider`).
signIn,
/// 이메일 · 비밀번호 폼(QA 내부 경로). 알 수 없는 실패를 「이메일 또는 비밀번호」로 안내하는 유일한 경로다.
email,
/// 이미 로그인한 UID 에 소셜 제공자를 붙이거나 떼는 경로
/// (`linkWithCredential` · `unlink`) — 별첨 B v1.4.
link,
@@ -645,7 +835,8 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
'invalid-email' => appL10n.authInvalidEmail,
'too-many-requests' => appL10n.commonTryLater,
'account-exists-with-different-credential' => switch (flow) {
AuthFlow.signIn => _otherProviderSignInGuide(error.email),
AuthFlow.signIn ||
AuthFlow.email => _otherProviderSignInGuide(error.email),
AuthFlow.link => appL10n.authCredentialInUse,
},
// 붙이려는 자격증명이 남의 UID 에 이미 달려 있다. 연결 경로에서만 난다.
@@ -653,8 +844,20 @@ String authMessage(fb.FirebaseAuthException error, {required AuthFlow flow}) =>
'provider-already-linked' => appL10n.authAlreadyLinked,
'no-such-provider' => appL10n.authNotLinked,
'operation-not-allowed' => appL10n.authMethodUnavailable,
'requires-recent-login' => appL10n.authRecentLogin,
_ => appL10n.authCheckCredentials,
'requires-recent-login' ||
'user-token-expired' ||
'invalid-user-token' ||
'user-mismatch' ||
'user-disabled' => appL10n.authRecentLogin,
// 웹 인가 흐름이 끊기거나 망이 끊겼다 — 소셜 경로의 사실대로 알리고 다시 시도하게 한다(R01).
'web-context-already-presented' => appL10n.authSocialCancelled,
'web-network-request-failed' ||
'network-request-failed' => appL10n.authSocialNetwork,
_ => switch (flow) {
AuthFlow.email => appL10n.authCheckCredentials,
AuthFlow.signIn => appL10n.authSocialFailed,
AuthFlow.link => appL10n.authLinkFailed,
},
};
/// 「먼저 쓰던 수단으로 로그인한 뒤 SNS 연동에서 붙이라」는 안내.
+3 -5
View File
@@ -14,9 +14,9 @@ import '../../core/ui/bottom_inset.dart';
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// X(트위터)는 확정 4종에 없어 목록에서 빠졌다([linkableProviders]).
///
/// 명세 `554:22797` — *플랫폼별 연동 상태 반영 · **동일 이메일만 연동 가능***
/// 판정은 [sameAccountIdentity] 가 하고, 실제 연결은 [AccountLinker] 구현체
/// (Firebase `linkWithCredential`)가 한다.
/// 명세 `554:22797` — *플랫폼별 연동 상태 반영*. 명세의 「동일 이메일만 연동 가능」은 통합검토회신
/// v2.3 **Q-E8** 이 대체했다(이메일 일치는 소유 확인이 아니다). 연결 검증은 [AccountLinker] 구현체
/// (Firebase `linkWithCredential` · 카카오 `fnAuthKakao`)가 서버에서 한다.
class AccountLinkScreen extends StatefulWidget {
const AccountLinkScreen({
super.key,
@@ -68,8 +68,6 @@ class _AccountLinkScreenState extends State<AccountLinkScreen> {
],
),
),
const SizedBox(height: AppSpace.s4),
Text(context.l10n.linkSameEmailOnly, style: t.labelSmall),
const SizedBox(height: AppSpace.s5),
FilledButton(
onPressed: _busy ? null : _toggle,
+7
View File
@@ -13,6 +13,7 @@ import 'dart:typed_data';
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
import '../quest/location_consent.dart';
/// POP 종류 — 정책 원장 **B-01**.
///
@@ -775,6 +776,9 @@ void checkProfilePhoto(PickedPhoto photo) {
}
abstract interface class MyRepository {
/// 위치기반 서비스 약관 동의 상태 — 설정의 동의 · 철회 토글(Q-E1 · R02).
LocationConsent get locationConsent;
Future<MyProfile> profile();
/// 명세 — *디폴트 최신순으로 20개 노출*.
@@ -836,6 +840,9 @@ abstract interface class MyRepository {
class SampleMyRepository implements MyRepository {
SampleMyRepository();
@override
LocationConsent get locationConsent => SampleLocationConsent.shared;
@override
Future<void> withdraw() async {}
@@ -6,6 +6,7 @@ import 'package:firebase_storage/firebase_storage.dart';
import '../../core/data/backend.dart';
import '../home/home_repository_firestore.dart' show kstDayKey;
import 'my_data.dart';
import '../quest/location_consent.dart';
import '../../core/i18n/app_locale.dart';
/// MY 의 실 서버 구현 — 화면 대조 원장 F6.
@@ -42,6 +43,9 @@ import '../../core/i18n/app_locale.dart';
class FirestoreMyRepository extends SampleMyRepository {
FirestoreMyRepository(this._backend);
@override
late final LocationConsent locationConsent = FirestoreLocationConsent(_backend);
final Backend _backend;
@override
+75 -4
View File
@@ -12,6 +12,7 @@ import '../../core/ui/app_icons.dart';
import '../auth/auth.dart';
import '../auth/auth_repository.dart';
import '../home/home_screen.dart' show formatPop;
import '../quest/location_consent.dart';
import 'my_data.dart';
import 'notice_screens.dart';
import 'payment_screens.dart';
@@ -24,7 +25,7 @@ import '../../core/ui/bottom_inset.dart';
/// 설정 — 화면 대조 원장 F6 · Figma `354:222008`.
///
/// 명세 `554:22634` — 계정 정보(닉네임 · 가입 이메일)
/// 명세 `554:22797` — SNS 연동. *동일 이메일만 연동 가능*. 행의 「연결」에서 바로 제공자
/// 명세 `554:22797` — SNS 연동. 행의 「연결」에서 바로 제공자
/// 시트를 띄운다(시안 F6-05~07 · 대표 결정 — 별도 연동 화면을 거치지 않는다).
/// 명세 `554:22831` — 푸시 알림 · 차단 · 언어
/// 명세 `554:22857` — 공지사항 · 1:1 문의접수 · 앱 버전
@@ -65,6 +66,9 @@ class _SettingsScreenState extends State<SettingsScreen> {
Map<AuthProviderKind, String?> _linked = const {};
({String current, bool latest})? _version;
/// 위치기반 서비스 약관 동의 상태(Q-E1 · R02). 못 읽었으면 null — 토글을 그리지 않는다.
bool? _locationConsent;
@override
void initState() {
super.initState();
@@ -74,8 +78,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
Future<void> _load() async {
final profile = await widget.repository.profile();
final version = await widget.repository.appVersion();
bool? location;
try {
location = await widget.repository.locationConsent.granted();
} catch (_) {
location = null;
}
if (!mounted) return;
setState(() {
_locationConsent = location;
_profile = profile;
_linked = widget.linker.linkedAccounts;
_version = version;
@@ -166,6 +177,12 @@ class _SettingsScreenState extends State<SettingsScreen> {
down: true,
onTap: _pickLanguage,
),
if (_locationConsent case final on?)
_SwitchRow(
label: l10n.settingsLocationConsent,
value: on,
onChanged: _setLocationConsent,
),
],
),
MyGroup(
@@ -254,7 +271,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
/// 설정 행에서 바로 연결 · 해제한다(시안 F6-05~07 — 설정 위에 OS 제공자 시트가 뜬다).
///
/// 연결은 제공자 시트로 곧장 가고, 해제는 확인 팝업(시안 `554:23257`)을 거친다.
/// 이메일이 다르면(E-AUTH-08 · [sameAccountIdentity]) 시안 `554:23103` 안내 팝업.
/// 그 소셜 계정이 다른 FANUZU 계정에 이미 붙어 있으면(E-AUTH-08) 시안 `554:23103` 안내 팝업.
/// 명세의 「동일 이메일만 연동 가능」은 통합검토회신 v2.3 **Q-E8** 이 대체했다 — 이메일은 소유 확인이 아니다.
Future<void> _toggleLink(AuthProviderKind provider) async {
final connected = _linked.containsKey(provider);
if (connected) {
@@ -274,8 +292,8 @@ class _SettingsScreenState extends State<SettingsScreen> {
// 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다.
setState(() => _linked = widget.linker.linkedAccounts);
switch (outcome) {
case AuthFailed(code: 'E-AUTH-08'):
await _notice(context.l10n.settingsSameEmailOnly);
case AuthFailed(code: 'E-AUTH-08', :final message):
await _notice(message);
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured():
@@ -335,6 +353,24 @@ class _SettingsScreenState extends State<SettingsScreen> {
await LocaleScope.maybeOf(context)?.select(picked);
}
/// 위치기반 서비스 약관 동의 · 철회(Q-E1 · R02). 켜면 동의 창을 거쳐 기록하고, 끄면 철회 행을 남긴다.
/// Figma 설정 시안에 없는 줄이다 — 동의를 철회할 수단이 있어야 해 언어 아래에 토글 하나를 둔다.
Future<void> _setLocationConsent(bool on) async {
final consent = widget.repository.locationConsent;
var now = on;
if (on) {
now = await ensureLocationConsent(context, consent);
} else {
try {
await consent.record(false);
} catch (_) {
now = true;
if (mounted) _toast(context.l10n.commonRequestFailed);
}
}
if (mounted) setState(() => _locationConsent = now);
}
/// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 ·
/// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다.
/// 서버도 같은 조건을 다시 본다(E-AUTH-14).
@@ -400,6 +436,41 @@ class _SettingsScreenState extends State<SettingsScreen> {
}
}
/// 켜고 끄는 줄 — 푸시 알림 설정 화면의 토글과 같은 모양.
class _SwitchRow extends StatelessWidget {
const _SwitchRow({
required this.label,
required this.value,
required this.onChanged,
});
final String label;
final bool value;
final ValueChanged<bool> onChanged;
@override
Widget build(BuildContext context) => SizedBox(
height: 24,
child: Row(
children: [
Expanded(
child: Text(label, style: myText(16, FontWeight.w600, height: 1)),
),
Switch(
value: value,
activeThumbColor: AppColors.onCosmic,
activeTrackColor: AppColors.cosmicAccent,
inactiveThumbColor: const Color(0xFF3A3A3A),
inactiveTrackColor: const Color(0xFF2C2C2C),
trackOutlineColor: const WidgetStatePropertyAll(Colors.transparent),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
onChanged: onChanged,
),
],
),
);
}
/// 「닉네임 · BTS_Hong」 — 둘 다 16/600, 값은 #bbbbbb.
class _ValueRow extends StatelessWidget {
const _ValueRow({required this.label, required this.value});
@@ -0,0 +1,146 @@
/// 위치기반 서비스 약관 — 위치기능 **최초 사용 직전**에 받는 동의.
///
/// 통합검토회신 v2.3 **Q-E1** · 1차 검수 회신서 **R02** — 가입 때 위치기반 약관을
/// 일괄 필수로 받지 않는다. 위치 약관 동의와 OS 위치권한은 위치기능을 처음 쓰기 전에 **각각** 처리하고,
/// 거부해도 일반 기능은 막지 않는다. 그래서 이 동의는 위치 Quest 인증 · 장소 선택 지도의 현재 위치처럼
/// 기기 위치를 쓰는 지점에서만 묻고, OS 권한은 그 뒤 [devicePlaceFix] 가 따로 요청한다.
///
/// 기록은 서버 `fnConsentRecord`(`users/{uid}/consents` append-only 행 + `consentState.location`)가 하고,
/// 위치 Quest 인증(`fnQuestVerifyPlace`)도 같은 상태를 다시 본다. 철회는 설정 화면의 토글이다.
///
/// Figma 에는 이 시점의 동의 화면이 없다(`179:24230` 은 가입 약관). 앱 공통 확인 창([showConfirmDialog])
/// 틀에 약관 칸을 넣어 쓴다.
library;
import 'package:flutter/material.dart';
import '../../core/data/backend.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../../core/ui/app_dialog.dart';
/// 동의 기록에 남는 위치기반 약관 버전. 🟡 임시 — 발주사 확정 문안(R02) 수령 전 자리 표시 문구다.
const locationTermsVersion = 'draft-1';
/// 위치기반 서비스 동의 상태 · 기록 경계.
abstract interface class LocationConsent {
Future<bool> granted();
/// 동의(true) · 철회(false)를 기록한다. 철회도 새 행으로 남는다(서버 append-only).
Future<void> record(bool granted);
}
class FirestoreLocationConsent implements LocationConsent {
FirestoreLocationConsent(this._backend);
final Backend _backend;
/// 서버 `hasLocationConsent` 와 같은 판정 — `consentState.location` 이 없으면 예전 가입 화면
/// (위치 약관이 가입 필수였던 0.2.0 이전)의 동의 행으로 본다.
@override
Future<bool> granted() async {
final uid = _backend.requireUid;
final user =
(await _backend.db.doc('users/$uid').get()).data() ??
const <String, dynamic>{};
final state = user['consentState'];
if (state is Map && state['location'] is Map) {
return (state['location'] as Map)['granted'] == true;
}
final version = user['termsVersion'];
if (version is! String || version.isEmpty) return false;
return (await _backend.db
.doc('users/$uid/consents/location_${version}_grant')
.get())
.exists;
}
@override
Future<void> record(bool granted) => _backend.call('fnConsentRecord', {
'docType': 'location',
'docVersion': locationTermsVersion,
'granted': granted,
});
}
/// 목 모드 · 테스트용 — 앱 안에서 하나를 같이 쓴다(퀘스트 · 설정 화면이 같은 상태를 본다).
class SampleLocationConsent implements LocationConsent {
SampleLocationConsent([this.value = false]);
static final shared = SampleLocationConsent();
bool value;
final records = <bool>[];
@override
Future<bool> granted() async => value;
@override
Future<void> record(bool granted) async {
records.add(granted);
value = granted;
}
}
/// 위치기능을 쓰기 직전에 부른다. 이미 동의했으면 묻지 않고 true, 처음이면 동의 창을 띄워
/// 동의를 기록한 뒤 true. 「나중에」 · 기록 실패면 false — 호출부는 그 위치기능만 건너뛴다.
Future<bool> ensureLocationConsent(
BuildContext context,
LocationConsent consent,
) async {
try {
if (await consent.granted()) return true;
} catch (_) {
// 상태를 못 읽었으면 묻는다 — 동의는 다시 기록해도 행이 하나 더 남을 뿐이다.
}
if (!context.mounted) return false;
final agreed = await showLocationConsentDialog(context);
if (!agreed) return false;
try {
await consent.record(true);
return true;
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.maybeOf(context)?.showSnackBar(
SnackBar(
content: Text(context.l10n.commonRequestFailed),
behavior: SnackBarBehavior.floating,
),
);
}
return false;
}
}
/// 동의 창 — 제목 · 사용 목적 · 약관 본문 칸 · 「나중에 / 동의」. 동의면 true.
Future<bool> showLocationConsentDialog(BuildContext context) {
final l10n = context.l10n;
return showConfirmDialog(
context,
l10n.termsLocation,
body: l10n.locationConsentBody,
content: Container(
key: const ValueKey('location-terms-body'),
constraints: const BoxConstraints(maxHeight: 120),
padding: const EdgeInsets.all(AppSpace.s3),
decoration: BoxDecoration(
border: Border.all(color: const Color(0xFF545454)),
borderRadius: BorderRadius.circular(AppRadius.sm),
),
child: SingleChildScrollView(
child: Text(
l10n.termsLocationBody,
style: const TextStyle(
fontFamily: AppFonts.display,
fontFamilyFallback: [AppFonts.body],
fontSize: 12,
height: 1.36,
color: AppColors.onCosmic,
),
),
),
),
cancelLabel: l10n.locationConsentLater,
confirmLabel: l10n.locationConsentAgree,
);
}
@@ -26,8 +26,13 @@ class PlacePickerScreen extends StatefulWidget {
super.key,
this.mapBuilder = _googleMap,
this.lastKnown = _deviceLastKnown,
this.useDeviceLocation = true,
});
/// 기기 위치(마지막 위치 · 현재 위치)를 쓸지. 위치기반 서비스 약관에 동의하지 않았으면 false 다 —
/// 지도에서 직접 고르는 것은 그대로 된다(Q-E1 · R02 「거부해도 일반 기능 이용 가능」).
final bool useDeviceLocation;
/// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
final PickerMapBuilder mapBuilder;
@@ -68,6 +73,10 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
@override
void initState() {
super.initState();
if (!widget.useDeviceLocation) {
_start = PlacePickerScreen.fallbackCenter;
return;
}
_startFromLastKnown();
_locate();
}
@@ -7,6 +7,7 @@ import '../../core/data/server_clock.dart';
import '../../core/theme/tokens.dart';
import '../home/home_screen.dart' show formatPop;
import 'place_picker_screen.dart';
import 'location_consent.dart';
import 'quest_data.dart';
import 'widgets/quest_card_tile.dart';
import '../../core/i18n/app_locale.dart';
@@ -441,9 +442,13 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
final _extraVerify = <String>{};
Future<void> _pickPlace() async {
// 지도의 현재 위치는 위치기능이다 — 위치기반 약관에 먼저 동의받는다(Q-E1 · R02). 거부해도 지도에서
// 직접 고를 수 있다.
final located = await ensureLocationConsent(context, widget.repository.locationConsent);
if (!mounted) return;
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
SheetRoute(builder: (_) => const PlacePickerScreen()),
SheetRoute(builder: (_) => PlacePickerScreen(useDeviceLocation: located)),
);
if (picked == null || !mounted) return;
setState(() => _place = picked);
+7
View File
@@ -14,6 +14,7 @@ import '../../core/data/backend.dart' show BackendException;
import '../../core/monetization/rewarded_ad.dart';
import '../../core/i18n/app_locale.dart';
import 'place_picker_screen.dart' show PlacePickerScreen;
import 'location_consent.dart';
import 'quest_evidence.dart' show ShakeJudge, ShakeWindows;
/// 진행 상태 — 명세가 **다섯 가지**를 못박았다.
@@ -684,6 +685,9 @@ class QuestDraft {
}
abstract interface class QuestRepository {
/// 위치기반 서비스 약관 동의(Q-E1 · R02) — 위치 Quest 인증 · 장소 선택의 현재 위치 직전에 확인한다.
LocationConsent get locationConsent;
/// 명세 — *퀘스트 생성 최신순 소팅(최초 10개 노출, 스크롤 시 10개씩 로드)*.
///
/// [limit] 장보다 적게 돌려주면 더 읽을 문서가 없다는 뜻이다 — 화면은 이것으로 끝을 판정한다.
@@ -798,6 +802,9 @@ abstract interface class QuestRepository {
class SampleQuestRepository implements QuestRepository {
const SampleQuestRepository();
@override
LocationConsent get locationConsent => SampleLocationConsent.shared;
/// 원장 C-01 매핑 중 사용자 생성 초기값(C-02 A안)으로 열린 활동만.
static QuestCatalog get _catalog => QuestCatalog(
categories: [
@@ -19,6 +19,7 @@ import '../planet/moderation.dart';
import 'music_shake_play_screen.dart';
import 'quest_ad_screens.dart';
import 'quest_create_screen.dart' show QuestLinkCard;
import 'location_consent.dart';
import 'quest_data.dart';
import 'quest_evidence.dart';
import 'qr_scan_screen.dart';
@@ -1025,6 +1026,13 @@ class _QuestDetailScreenState extends State<QuestDetailScreen> {
// 위치 Quest 의 추가 인증(C-03) — 고른 수단의 증거를 먼저 모은다. 취소하면 아무것도 보내지 않는다.
var proof = const PlaceProof();
if (detail.card.type == QuestType.place && detail.card.endsAt == null) {
// 위치기반 서비스 약관 — 위치기능 최초 사용 직전(Q-E1 · R02). 거부하면 이 인증만 하지 않는다.
// OS 위치권한은 그 뒤 좌표를 읽을 때 따로 묻는다.
if (!await ensureLocationConsent(context, widget.repository.locationConsent)) {
if (mounted) _toast(context.l10n.locationConsentNeeded);
return;
}
if (!mounted) return;
final collected = await _placeProof(detail.verifyMethods);
if (collected == null || !mounted) return;
proof = collected;
@@ -6,6 +6,7 @@ import 'package:firebase_storage/firebase_storage.dart' show SettableMetadata;
import '../../core/data/backend.dart';
import '../../core/data/server_clock.dart';
import '../../core/monetization/rewarded_ad.dart';
import 'location_consent.dart';
import 'quest_data.dart';
import 'quest_evidence.dart';
import '../../core/i18n/app_locale.dart';
@@ -40,6 +41,10 @@ class FirestoreQuestRepository implements QuestRepository {
_baseline = baseline ?? WalkBaseline();
final Backend _backend;
@override
late final LocationConsent locationConsent = FirestoreLocationConsent(_backend);
final StepCountProbe _stepCount;
final TodayStepsProbe _todaySteps;
final PlaceProbe _placeFix;