Files
fanuzu/app/lib/features/my/account_link_screen.dart

132 lines
4.7 KiB
Dart

import 'package:flutter/material.dart';
import '../../core/i18n/app_locale.dart';
import '../../core/theme/tokens.dart';
import '../auth/auth.dart';
import '../../core/ui/bottom_inset.dart';
/// SNS 연동 — 화면 대조 원장 F6.
///
/// Figma `357:222740`(Apple) · `357:222899`(Google) · `357:222671`(Kakao).
/// **화면들이 제공자만 다르고 같다** — 따로 만들면 연동 규칙이 여러 벌이 되고,
/// 규칙이 바뀔 때 한 곳만 고치는 사고가 난다.
///
/// 별첨 B v1.4 — *하나의 FANUZU UID 에 복수 소셜 Provider 연결은 허용합니다.*
/// X(트위터)는 확정 4종에 없어 목록에서 빠졌다([linkableProviders]).
///
/// 명세 `554:22797` — *플랫폼별 연동 상태 반영*. 명세의 「동일 이메일만 연동 가능」은 통합검토회신
/// v2.3 **Q-E8** 이 대체했다(이메일 일치는 소유 확인이 아니다). 연결 검증은 [AccountLinker] 구현체
/// (Firebase `linkWithCredential` · 카카오 `fnAuthKakao`)가 서버에서 한다.
class AccountLinkScreen extends StatefulWidget {
const AccountLinkScreen({
super.key,
required this.provider,
required this.linker,
this.linkedEmail,
});
final AuthProviderKind provider;
/// 연결 경계. Firebase 어댑터이거나 [SampleAccountLinker] 다.
final AccountLinker linker;
/// 이미 연동된 이메일. 없으면 미연동.
final String? linkedEmail;
@override
State<AccountLinkScreen> createState() => _AccountLinkScreenState();
}
class _AccountLinkScreenState extends State<AccountLinkScreen> {
late String? _linked = widget.linkedEmail;
bool _busy = false;
bool get _connected => _linked != null;
String get _name => widget.provider.shortName;
@override
Widget build(BuildContext context) {
final t = Theme.of(context).textTheme;
return Scaffold(
appBar: AppBar(title: Text(context.l10n.linkTitle(_name))),
body: ListView(
padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)),
children: [
Container(
padding: const EdgeInsets.all(AppSpace.s4),
decoration: BoxDecoration(
color: AppColors.cosmicSurfaceContainer,
borderRadius: BorderRadius.circular(AppRadius.md),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_name, style: t.titleMedium),
const SizedBox(height: AppSpace.s2),
Text(_linked ?? context.l10n.linkNotLinked, style: t.bodySmall),
],
),
),
const SizedBox(height: AppSpace.s5),
FilledButton(
onPressed: _busy ? null : _toggle,
style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(AppSizing.touchMinApp),
backgroundColor: _connected
? AppColors.cosmicSurfaceRaised
: AppColors.cosmicAccent,
foregroundColor: _connected
? AppColors.onCosmic
: AppColors.onCosmicAccent,
),
child: Text(
_connected ? context.l10n.linkUnlink : context.l10n.linkLink,
),
),
const SizedBox(height: AppSpace.s3),
// 명세 `554:23411` — 연동 해제가 계정 삭제로 오인되지 않게 한다.
Text(context.l10n.settingsUnlinkBody, style: t.labelSmall),
],
),
);
}
Future<void> _toggle() async {
final wasConnected = _connected;
setState(() => _busy = true);
final outcome = wasConnected
? await widget.linker.unlink(widget.provider)
: await widget.linker.link(widget.provider);
if (!mounted) return;
// 화면 상태를 추측하지 않고 연결 경계에 다시 물어본다 — 성공했다고 믿고
// 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다.
final accounts = widget.linker.linkedAccounts;
setState(() {
_busy = false;
_linked = accounts.containsKey(widget.provider)
// 붙었는데 제공자가 이메일을 안 준 경우(Apple 재연동)도 연결 상태다.
? accounts[widget.provider] ?? context.l10n.linkLinked
: null;
});
switch (outcome) {
case AuthFailed(:final code, :final message):
_toast('[$code] $message');
case AuthNotConfigured():
_toast(context.l10n.settingsLinkNotReady(_name));
case AuthSucceeded() || AuthCancelled():
break;
}
}
void _toast(String message) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(
SnackBar(content: Text(message), behavior: SnackBarBehavior.floating),
);
}
}