import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.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_dialog.dart'; import '../../core/ui/app_icons.dart'; import '../auth/auth.dart'; import '../auth/auth_repository.dart'; import '../home/home_screen.dart' show formatPop; import 'my_data.dart'; import 'notice_screens.dart'; import 'payment_screens.dart'; import 'pop_history_screen.dart'; import 'push_settings_screen.dart'; import 'support_screens.dart'; import 'widgets/my_cards.dart'; import '../../core/ui/bottom_inset.dart'; /// 설정 — 화면 대조 원장 F6 · Figma `354:222008`. /// /// 명세 `554:22634` — 계정 정보(닉네임 · 가입 이메일) /// 명세 `554:22797` — SNS 연동. *동일 이메일만 연동 가능*. 행의 「연결」에서 바로 제공자 /// 시트를 띄운다(시안 F6-05~07 · 대표 결정 — 별도 연동 화면을 거치지 않는다). /// 명세 `554:22831` — 푸시 알림 · 차단 · 언어 /// 명세 `554:22857` — 공지사항 · 1:1 문의접수 · 앱 버전 /// 명세 `554:22903` — 로그아웃 · 서비스 탈퇴는 **안내 모달**을 거친다 class SettingsScreen extends StatefulWidget { SettingsScreen({ super.key, required this.repository, AccountLinker? linker, this.push, this.auth, }) : linker = linker ?? SampleAccountLinker( seed: const { AuthProviderKind.google: 'fan@gmail.com', AuthProviderKind.kakao: 'fan@kakao.com', }, ); final MyRepository repository; /// 푸시 수신 경계. 푸시 알림 설정 화면이 권한 상태와 재요청에 쓴다. final PushMessaging? push; /// 로그아웃 배선. null 이면 확인 모달만 띄운다(표본·테스트 경로). final AuthRepository? auth; /// 계정 연결 경계 — 별첨 B v1.4 「하나의 UID 에 복수 소셜 Provider」. final AccountLinker linker; @override State createState() => _SettingsScreenState(); } class _SettingsScreenState extends State { MyProfile? _profile; Map _linked = const {}; ({String current, bool latest})? _version; @override void initState() { super.initState(); _load(); } Future _load() async { final profile = await widget.repository.profile(); final version = await widget.repository.appVersion(); if (!mounted) return; setState(() { _profile = profile; _linked = widget.linker.linkedAccounts; _version = version; }); } @override Widget build(BuildContext context) { final profile = _profile; if (profile == null) { return const Scaffold(body: Center(child: CircularProgressIndicator())); } final version = _version; final l10n = context.l10n; return Scaffold( appBar: AppBar(title: Text(l10n.settingsTitle)), body: ListView( padding: withBottomInset(context, const EdgeInsets.fromLTRB(16, 16, 16, 0)), children: [ MyGroup( title: l10n.settingsAccount, children: [ _ValueRow(label: l10n.settingsNickname, value: profile.nickname), const MyDivider(), _ValueRow(label: l10n.settingsEmail, value: profile.email), ], ), MyGroup( title: l10n.settingsSns, children: [ // 별첨 B v1.4 확정 4종만 건다. X 로그인은 1차 범위에서 빠졌다. for (final provider in _providerOrder) _SnsRow( provider: provider, email: _linked.containsKey(provider) ? _linked[provider] ?? '' : null, onTap: () => _toggleLink(provider), ), ], ), MyGroup( title: l10n.settingsPopHistory, children: [ MyLinkRow( onTap: () => _push(PopHistoryScreen(repository: widget.repository)), child: Row( children: [ Image.asset( 'assets/figma/icon-pop-small.png', width: 20, height: 20.8, ), const SizedBox(width: 4), Text( formatPop(profile.balancePop), style: myText(18, FontWeight.w800, height: 1), ), ], ), ), ], ), MyGroup( title: l10n.settingsPayments, children: [ MyLinkRow( label: l10n.settingsServicePayments, onTap: () => _push(PaymentHistoryScreen(repository: widget.repository)), ), ], ), MyGroup( title: l10n.settingsService, children: [ MyLinkRow(label: l10n.settingsPush, onTap: _openPushSettings), MyLinkRow( label: l10n.settingsBlocked, onTap: () => _push(BlockedScreen(repository: widget.repository)), ), // 시안은 현재 언어를 적고 아래 화살표를 둔다 — 누르면 언어 설정 시트. MyLinkRow( label: languageName(_currentLocale), down: true, onTap: _pickLanguage, ), ], ), MyGroup( title: l10n.settingsInfo, children: [ MyLinkRow( label: l10n.settingsNotice, onTap: () => _push(NoticeListScreen(repository: widget.repository)), ), MyLinkRow( label: l10n.settingsInquiry, onTap: () => _push(InquiryScreen(repository: widget.repository)), ), const MyDivider(), SizedBox( height: 24, child: Row( children: [ Expanded( child: Text( l10n.settingsAppVersion, style: myText(16, FontWeight.w600, height: 1), ), ), // 명세 — 최신이면 「최신 버전」, 아니면 버전 번호. Text( version == null ? '—' : version.latest ? l10n.settingsLatest : version.current, style: myText( 14, FontWeight.w500, color: const Color(0xFFCDCDCD), height: 1, ), ), ], ), ), ], ), MyGroup( children: [ MyLinkRow( label: l10n.settingsLogout, color: MyColors.danger, onTap: _logout, ), const MyDivider(), MyLinkRow( label: l10n.settingsWithdraw, // 정책 §P-13 이용자 고지 — POP 잔액 소멸. 사본 팝업 틀에 본문 한 줄만 더한다. onTap: () => _confirm( title: l10n.settingsWithdrawConfirm, body: l10n.settingsWithdrawBody, action: l10n.settingsWithdraw, onConfirm: _withdraw, ), ), ], ), ], ), ); } /// 시안 `354:222008` 의 목록 순서(Apple · Google · Kakao) 뒤에 원장 1차 로그인 4종의 LINE. static const _providerOrder = [ AuthProviderKind.apple, AuthProviderKind.google, AuthProviderKind.kakao, AuthProviderKind.line, ]; /// 표시 언어. 목록은 정책 원장 B-76 확정 5종이다([appLocales]). /// 앱 언어 상태([LocaleScope])가 없는 트리(단독 화면 테스트)에서는 이 화면 안에서만 바뀐다. Locale? _pickedLocale; Locale get _currentLocale => LocaleScope.maybeOf(context)?.locale ?? _pickedLocale ?? fallbackLocale; /// 설정 행에서 바로 연결 · 해제한다(시안 F6-05~07 — 설정 위에 OS 제공자 시트가 뜬다). /// /// 연결은 제공자 시트로 곧장 가고, 해제는 확인 팝업(시안 `554:23257`)을 거친다. /// 이메일이 다르면(E-AUTH-08 · [sameAccountIdentity]) 시안 `554:23103` 안내 팝업. Future _toggleLink(AuthProviderKind provider) async { final connected = _linked.containsKey(provider); if (connected) { final l10n = context.l10n; final ok = await _confirm( title: l10n.settingsUnlinkConfirm, body: l10n.settingsUnlinkBody, action: l10n.settingsUnlink, ); if (!ok) return; } final outcome = connected ? await widget.linker.unlink(provider) : await widget.linker.link(provider); if (!mounted) return; // 화면 상태를 추측하지 않고 연결 경계에 다시 물어본다 — 성공했다고 믿고 // 로컬 값을 뒤집으면 서버가 거절한 연결이 붙은 것처럼 보인다. setState(() => _linked = widget.linker.linkedAccounts); switch (outcome) { case AuthFailed(code: 'E-AUTH-08'): await _notice(context.l10n.settingsSameEmailOnly); case AuthFailed(:final code, :final message): _toast('[$code] $message'); case AuthNotConfigured(): _toast(context.l10n.settingsLinkNotReady(_snsName(context, provider))); case AuthSucceeded() || AuthCancelled(): break; } } void _toast(String message) { ScaffoldMessenger.of(context) ..hideCurrentSnackBar() ..showSnackBar( SnackBar(content: Text(message), behavior: SnackBarBehavior.floating), ); } Future _push(Widget screen) async { await Navigator.of( context, ).push(MaterialPageRoute(builder: (_) => screen)); if (mounted) await _load(); } /// 푸시 알림 설정 진입. /// /// 권한 상태를 **들어가기 전에** 읽는다. 화면 안에서 읽으면 로딩 한 프레임 동안 /// 토글이 보이고 곧 「꺼져 있습니다」로 바뀌어, 방금 끈 것처럼 보인다. /// 푸시 경계가 없으면(목 모드) 허용 상태로 두어 토글 동선은 그대로 확인된다. Future _openPushSettings() async { final push = widget.push; final allowed = push == null || (await push.refreshStatus()).isAllowed; if (!mounted) return; await _push( PushSettingsScreen( repository: widget.repository, deviceAllowed: allowed, onRequestPermission: push == null ? null : () async => (await push.requestPermission()).isAllowed, // 토글을 바꾸면 그 자리에서 토픽 구독까지 맞춘다. 앱 시작 시 한 번만 // 맞추면 설정을 바꿔도 OFF 구분의 알림이 계속 온다. onSettingsChanged: push == null ? null : push.syncTopics, ), ); } /// 언어 설정 — 명세 `554:22831` *언어 설정 바텀시트 노출* · 시안 `357:224379`. Future _pickLanguage() async { final picked = await showModalBottomSheet( context: context, backgroundColor: const Color(0xFF101010), builder: (context) => _LanguageSheet(current: _currentLocale), ); if (picked == null || !mounted) return; setState(() => _pickedLocale = picked); await LocaleScope.maybeOf(context)?.select(picked); } /// 서비스 탈퇴 — 명세 `554:24885` 「연동 상태값이 있을 경우 비정상 탈퇴 처리 · 게시물 보존 · /// 탈퇴 처리 후 앱 종료」. 연동이 남아 있으면 비정상 탈퇴 안내(`554:24908` · `374:14699`)만 띄운다. /// 서버도 같은 조건을 다시 본다(E-AUTH-14). /// 「연동 상태값」을 로그인 제공자 **외 추가 연결**로 읽는다 — 별첨 B 로 소셜 전용 로그인이라 /// 문자 그대로면 모든 계정이 비정상 탈퇴가 된다. 해석 확인 Q-E9 회신 대기(임시값). Future _withdraw() async { final l10n = context.l10n; if (_linked.values.where((e) => e != null).length > 1) { await _notice(l10n.settingsWithdrawLinked); return; } try { // 푸시 정리를 먼저 — 계정이 지워지면 이 기기 토큰을 지울 수 없다. await widget.push?.clearOnSignOut(); await widget.repository.withdraw(); } on BackendException catch (e) { if (mounted) { await _notice( e.code == 'E-AUTH-14' ? l10n.settingsWithdrawLinked : e.message, ); } return; } await widget.auth?.signOut(); // 「앱 종료」 — Android 만. iOS 는 앱이 스스로 종료하지 않는다(스토어 지침) — 로그인 화면으로 간다. if (defaultTargetPlatform == TargetPlatform.android) { await SystemNavigator.pop(); } } /// 안내 팝업(확인 하나) — 시안 `554:23103`. Future _notice(String message) => showAlertDialog(context, message); /// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`. /// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 `isSelf(uid)` 로 막아, /// 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. 정리는 확인 버튼이 진행 중으로 바뀐 /// 채 돌고, 실패하든 [_cleanupTimeout] 안에 끝나지 않든 로그아웃은 한다 — 토픽 해제 · /// 토큰 쓰기가 응답 없이 멈추면 signOut 에 닿지 못해 첫 확인이 무시된 것처럼 /// 보였다(10/6 실기기 2회). Future _logout() async { final l10n = context.l10n; final ok = await showConfirmDialog( context, l10n.settingsLogoutConfirm, confirmLabel: l10n.settingsLogout, onConfirm: () async { try { await widget.push?.clearOnSignOut().timeout(_cleanupTimeout); } on Object catch (error) { debugPrint('[Settings] push cleanup skipped: ${error.runtimeType}'); } }, ); if (ok) await widget.auth?.signOut(); } /// 로그아웃 전 정리의 상한. 넘기면 정리를 두고 로그아웃한다. static const _cleanupTimeout = Duration(seconds: 3); Future _confirm({ required String title, String? body, required String action, Future Function()? onConfirm, }) async { final ok = await showConfirmDialog( context, title, body: body, confirmLabel: action, ); if (ok == true && onConfirm != null) await onConfirm(); return ok; } } /// 「닉네임 · BTS_Hong」 — 둘 다 16/600, 값은 #bbbbbb. class _ValueRow extends StatelessWidget { const _ValueRow({required this.label, required this.value}); final String label; final String value; @override Widget build(BuildContext context) => Row( children: [ Text(label, style: myText(16, FontWeight.w600, height: 1)), const SizedBox(width: 16), Expanded( child: Text( value, textAlign: TextAlign.right, overflow: TextOverflow.ellipsis, style: myText(16, FontWeight.w600, color: MyColors.value, height: 1), ), ), ], ); } /// 시안 표기 이름. 앱 공통 [AuthProviderKind.shortName] 은 한글(카카오)이라 따로 둔다. String _snsName(BuildContext context, AuthProviderKind p) => switch (p) { AuthProviderKind.apple => 'Apple', AuthProviderKind.google => 'Google', AuthProviderKind.kakao => 'Kakao', AuthProviderKind.line => 'LINE', AuthProviderKind.email => context.l10n.settingsEmailProvider, }; /// SNS 연동 한 줄 — 아이콘 20 · 이름 14/500 · 오른쪽 45×29 버튼(미연결 분홍 「연결」, /// 연결 흰 「연결해제」 · 시안 `374:14705`). 연결되어 있으면 아래에 제공자 이메일. class _SnsRow extends StatelessWidget { const _SnsRow({ required this.provider, required this.email, required this.onTap, }); final AuthProviderKind provider; /// null 이면 미연결. 연결됐지만 제공자가 이메일을 안 준 경우는 빈 문자열. final String? email; final VoidCallback onTap; Widget get _icon => switch (provider) { AuthProviderKind.apple => const ImageIcon( AssetImage('assets/figma/brand-apple.png'), size: 20, color: AppColors.onCosmic, ), AuthProviderKind.google => Image.asset( 'assets/figma/brand-google.png', width: 20, height: 20, ), AuthProviderKind.kakao => const ImageIcon( AssetImage('assets/figma/brand-kakao.png'), size: 20, color: AppColors.onCosmic, ), // 시안에 LINE 아이콘이 없어(사본은 X 자리) 브랜드 색 말풍선으로 대신한다. _ => const Icon(Icons.chat_bubble, size: 18, color: BrandColors.line), }; @override Widget build(BuildContext context) { final connected = email != null; final action = connected ? context.l10n.settingsDisconnect : context.l10n.settingsConnect; return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 29, child: Row( children: [ SizedBox(width: 20, height: 20, child: Center(child: _icon)), const SizedBox(width: 6), Expanded( child: Text( _snsName(context, provider), style: myText(14, FontWeight.w500), ), ), Semantics( button: true, label: '${_snsName(context, provider)} $action', child: GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 6, ), decoration: BoxDecoration( color: connected ? AppColors.onCosmic : AppColors.cosmicAccent, borderRadius: BorderRadius.circular(AppRadius.xs), ), child: Text( action, style: myText( 12, FontWeight.w500, color: AppColors.onCosmicAccent, ), ), ), ), ), ], ), ), if (email case final e? when e.isNotEmpty) ...[ const SizedBox(height: 8), Text(e, style: myText(14, FontWeight.w500)), ], ], ); } } /// 팝업 — 바탕 #2d2d2d · 좌우 31.5 · 안쪽 24/16 · 제목 16/600 가운데 · 버튼 높이 48 간격 8. /// 언어 설정 시트 — 시안 `357:224379`: 제목 + 닫기 · 언어 목록(고른 것에 분홍 체크) · 「선택」. class _LanguageSheet extends StatefulWidget { const _LanguageSheet({required this.current}); final Locale current; @override State<_LanguageSheet> createState() => _LanguageSheetState(); } class _LanguageSheetState extends State<_LanguageSheet> { late Locale _picked = widget.current; @override Widget build(BuildContext context) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Center( child: Container( width: 36, height: 5, decoration: BoxDecoration( color: AppColors.cosmicOutline, borderRadius: BorderRadius.circular(AppRadius.full), ), ), ), const SizedBox(height: 16), Row( children: [ Expanded( child: Text( context.l10n.languageSettingsTitle, style: myText(18, FontWeight.w600), ), ), IconButton( tooltip: context.l10n.commonClose, onPressed: () => Navigator.of(context).pop(), icon: const ImageIcon( AppIcons.x, size: 24, color: AppColors.onCosmic, ), ), ], ), const SizedBox(height: 8), for (final lang in appLocales) GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => setState(() => _picked = lang), child: SizedBox( height: 50, child: Row( children: [ Expanded( child: Text( languageName(lang), style: myText(16, FontWeight.w500), ), ), if (lang == _picked) const Icon( Icons.check_circle_outline, size: 22, color: AppColors.cosmicAccent, ), ], ), ), ), const SizedBox(height: 24), FilledButton( onPressed: () => Navigator.of(context).pop(_picked), style: FilledButton.styleFrom( backgroundColor: AppColors.cosmicAccent, foregroundColor: AppColors.onCosmicAccent, ), child: Text(context.l10n.commonSelect), ), ], ), ), ); }