Files
fanuzu/app/lib/features/my/settings_screen.dart
T

633 lines
22 KiB
Dart

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<SettingsScreen> createState() => _SettingsScreenState();
}
class _SettingsScreenState extends State<SettingsScreen> {
MyProfile? _profile;
Map<AuthProviderKind, String?> _linked = const {};
({String current, bool latest})? _version;
@override
void initState() {
super.initState();
_load();
}
Future<void> _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<void> _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<void> _push(Widget screen) async {
await Navigator.of(
context,
).push<void>(MaterialPageRoute(builder: (_) => screen));
if (mounted) await _load();
}
/// 푸시 알림 설정 진입.
///
/// 권한 상태를 **들어가기 전에** 읽는다. 화면 안에서 읽으면 로딩 한 프레임 동안
/// 토글이 보이고 곧 「꺼져 있습니다」로 바뀌어, 방금 끈 것처럼 보인다.
/// 푸시 경계가 없으면(목 모드) 허용 상태로 두어 토글 동선은 그대로 확인된다.
Future<void> _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<void> _pickLanguage() async {
final picked = await showModalBottomSheet<Locale>(
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<void> _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<void> _notice(String message) => showAlertDialog(context, message);
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
/// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 `isSelf(uid)` 로 막아,
/// 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. 정리는 확인 버튼이 진행 중으로 바뀐
/// 채 돌고, 실패하든 [_cleanupTimeout] 안에 끝나지 않든 로그아웃은 한다 — 토픽 해제 ·
/// 토큰 쓰기가 응답 없이 멈추면 signOut 에 닿지 못해 첫 확인이 무시된 것처럼
/// 보였다(10/6 실기기 2회).
Future<void> _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<bool> _confirm({
required String title,
String? body,
required String action,
Future<void> 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),
),
],
),
),
);
}