622 lines
21 KiB
Dart
622 lines
21 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`.
|
|
/// 토큰 제거는 signOut 전에 기다린다(내부 상한 2초) — 남으면 다음 사용자가 개인 알림을 받는다.
|
|
/// 토픽 해지는 [PushMessaging] 이 들고 있다가 다음 로그인의 구독 전에 기다린다(10/6 실기기).
|
|
Future<void> _logout() async {
|
|
final l10n = context.l10n;
|
|
final ok = await showConfirmDialog(
|
|
context,
|
|
l10n.settingsLogoutConfirm,
|
|
confirmLabel: l10n.settingsLogout,
|
|
);
|
|
if (!ok) return;
|
|
await widget.push?.clearOnSignOut();
|
|
await widget.auth?.signOut();
|
|
}
|
|
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|