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 createState() => _AccountLinkScreenState(); } class _AccountLinkScreenState extends State { 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 _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), ); } }