INSIDE · STATION · 참여형 번역 · 알림 · MY 화면
This commit is contained in:
@@ -0,0 +1,709 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/i18n/app_locale.dart';
|
||||
import '../../core/push/push_messaging.dart';
|
||||
import '../../core/theme/tokens.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';
|
||||
|
||||
/// 설정 — 화면 대조 원장 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: 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: () => _confirm(
|
||||
title: l10n.settingsLogoutConfirm,
|
||||
action: l10n.settingsLogout,
|
||||
// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를
|
||||
// `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다.
|
||||
onConfirm: () async {
|
||||
await widget.push?.clearOnSignOut();
|
||||
await widget.auth?.signOut();
|
||||
},
|
||||
),
|
||||
),
|
||||
const MyDivider(),
|
||||
MyLinkRow(
|
||||
label: l10n.settingsWithdraw,
|
||||
// 정책 §P-13 이용자 고지 — POP 잔액 소멸. 사본 팝업 틀에 본문 한 줄만 더한다.
|
||||
onTap: () => _confirm(
|
||||
title: l10n.settingsWithdrawConfirm,
|
||||
body: l10n.settingsWithdrawBody,
|
||||
action: l10n.settingsWithdraw,
|
||||
withdraw: true,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 시안 `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(:final missing):
|
||||
_toast(
|
||||
context.l10n.settingsLinkNotReady(
|
||||
_snsName(context, provider),
|
||||
missing.join(' · '),
|
||||
),
|
||||
);
|
||||
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:23103`.
|
||||
Future<void> _notice(String message) => showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => _MyDialog(
|
||||
title: message,
|
||||
actions: [
|
||||
_DialogButton(
|
||||
label: context.l10n.commonConfirm,
|
||||
filled: true,
|
||||
onTap: () => Navigator.of(context).pop(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
|
||||
Future<bool> _confirm({
|
||||
required String title,
|
||||
String? body,
|
||||
required String action,
|
||||
bool withdraw = false,
|
||||
Future<void> Function()? onConfirm,
|
||||
}) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => _MyDialog(
|
||||
title: title,
|
||||
body: body,
|
||||
actions: [
|
||||
_DialogButton(
|
||||
label: context.l10n.commonCancel,
|
||||
onTap: () => Navigator.of(context).pop(false),
|
||||
),
|
||||
_DialogButton(
|
||||
label: action,
|
||||
filled: true,
|
||||
onTap: () => Navigator.of(context).pop(true),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true && onConfirm != null) {
|
||||
await onConfirm();
|
||||
} else if (ok == true && withdraw && mounted) {
|
||||
_toast(context.l10n.settingsWithdrawLater);
|
||||
}
|
||||
return ok ?? false;
|
||||
}
|
||||
}
|
||||
|
||||
/// 「닉네임 · 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.
|
||||
class _MyDialog extends StatelessWidget {
|
||||
const _MyDialog({required this.title, this.body, required this.actions});
|
||||
|
||||
final String title;
|
||||
final String? body;
|
||||
final List<Widget> actions;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 31.5),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 24, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: myText(16, FontWeight.w600),
|
||||
),
|
||||
if (body case final b?) ...[
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
b,
|
||||
textAlign: TextAlign.center,
|
||||
style: myText(16, FontWeight.w500),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
Row(
|
||||
children: [
|
||||
for (final (i, a) in actions.indexed) ...[
|
||||
if (i > 0) const SizedBox(width: 8),
|
||||
Expanded(child: a),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DialogButton extends StatelessWidget {
|
||||
const _DialogButton({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.filled = false,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool filled;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shape = RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||
);
|
||||
final text = Text(
|
||||
label,
|
||||
style: myText(
|
||||
16,
|
||||
FontWeight.w600,
|
||||
height: 1,
|
||||
color: filled ? AppColors.onCosmicAccent : AppColors.onCosmic,
|
||||
),
|
||||
);
|
||||
return SizedBox(
|
||||
height: 48,
|
||||
child: filled
|
||||
? FilledButton(
|
||||
onPressed: onTap,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
padding: EdgeInsets.zero,
|
||||
backgroundColor: AppColors.cosmicAccent,
|
||||
shape: shape,
|
||||
),
|
||||
child: text,
|
||||
)
|
||||
: OutlinedButton(
|
||||
onPressed: onTap,
|
||||
style: OutlinedButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(48),
|
||||
padding: EdgeInsets.zero,
|
||||
side: const BorderSide(color: AppColors.onCosmic),
|
||||
shape: shape,
|
||||
),
|
||||
child: text,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 언어 설정 시트 — 시안 `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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user