로그아웃 SDK 대기 제거 · 온보딩 국가 시트 · 프로필 머리줄
This commit is contained in:
@@ -4,6 +4,10 @@ import android.app.NotificationChannel
|
|||||||
import android.app.NotificationManager
|
import android.app.NotificationManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||||
|
import io.flutter.embedding.engine.FlutterEngine
|
||||||
|
import io.flutter.plugin.common.MethodChannel
|
||||||
|
import java.text.Collator
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
// Health Connect 권한 요청(health 플러그인)이 `registerForActivityResult` 를 쓰므로 FragmentActivity 여야 한다.
|
// Health Connect 권한 요청(health 플러그인)이 `registerForActivityResult` 를 쓰므로 FragmentActivity 여야 한다.
|
||||||
class MainActivity : FlutterFragmentActivity() {
|
class MainActivity : FlutterFragmentActivity() {
|
||||||
@@ -29,4 +33,25 @@ class MainActivity : FlutterFragmentActivity() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 국가 표시명 — 앱 언어로 OS 가 아는 이름(`Locale.getDisplayCountry`)을 OS 정렬(`Collator`) 순서로 준다.
|
||||||
|
* 국가 이름을 앱이 번역하지 않으려는 통로다(Dart `loadCountryNames`). 결과는 `[[코드, 이름], …]`.
|
||||||
|
*/
|
||||||
|
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||||
|
super.configureFlutterEngine(flutterEngine)
|
||||||
|
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, "kr.co.fanuzu.app/locale")
|
||||||
|
.setMethodCallHandler { call, result ->
|
||||||
|
if (call.method != "countryNames") {
|
||||||
|
result.notImplemented()
|
||||||
|
return@setMethodCallHandler
|
||||||
|
}
|
||||||
|
val locale = Locale.forLanguageTag(call.argument<String>("locale") ?: "ko")
|
||||||
|
val collator = Collator.getInstance(locale)
|
||||||
|
val pairs = Locale.getISOCountries()
|
||||||
|
.map { listOf(it, Locale("", it).getDisplayCountry(locale)) }
|
||||||
|
.sortedWith(compareBy(collator) { it[1] })
|
||||||
|
result.success(pairs)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,5 +12,20 @@ import UIKit
|
|||||||
|
|
||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
// 국가 표시명 — 앱 언어로 OS 가 아는 이름을 OS 정렬 순서로 준다(Dart `loadCountryNames`). `[[코드, 이름], …]`.
|
||||||
|
guard let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "FanuzuLocale") else { return }
|
||||||
|
FlutterMethodChannel(name: "kr.co.fanuzu.app/locale", binaryMessenger: registrar.messenger())
|
||||||
|
.setMethodCallHandler { call, result in
|
||||||
|
guard call.method == "countryNames" else {
|
||||||
|
result(FlutterMethodNotImplemented)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let tag = (call.arguments as? [String: Any])?["locale"] as? String ?? "ko"
|
||||||
|
let locale = Locale(identifier: tag)
|
||||||
|
let pairs: [[String]] = Locale.isoRegionCodes
|
||||||
|
.compactMap { code in locale.localizedString(forRegionCode: code).map { [code, $0] } }
|
||||||
|
.sorted { $0[1].compare($1[1], locale: locale) == .orderedAscending }
|
||||||
|
result(pairs)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -205,14 +206,42 @@ class RemoteL10nDelegate extends LocalizationsDelegate<AppLocalizations> {
|
|||||||
AppLocalizations.delegate.isSupported(locale);
|
AppLocalizations.delegate.isSupported(locale);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<AppLocalizations> load(Locale locale) => SynchronousFuture(
|
Future<AppLocalizations> load(Locale locale) {
|
||||||
appL10n = RemoteAppLocalizations(lookupAppLocalizations(locale), remote),
|
unawaited(loadCountryNames(locale));
|
||||||
);
|
return SynchronousFuture(
|
||||||
|
appL10n = RemoteAppLocalizations(lookupAppLocalizations(locale), remote),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool shouldReload(RemoteL10nDelegate old) => !identical(old.remote, remote);
|
bool shouldReload(RemoteL10nDelegate old) => !identical(old.remote, remote);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 기기 OS 가 아는 국가 표시명(현재 앱 언어) — 코드 → 이름, **OS 정렬 순서**(Android `Collator` ·
|
||||||
|
/// iOS `localizedCompare`)대로 들어 있다. 국가 이름을 우리가 번역하지 않으려고 플랫폼 로케일 이름을 쓴다
|
||||||
|
/// (Android `Locale.getDisplayCountry` · iOS `Locale.localizedString(forRegionCode:)`).
|
||||||
|
/// 앱 언어가 바뀔 때마다 [RemoteL10nDelegate] 가 다시 읽는다. 채널이 없는 곳(위젯 테스트)은 빈 맵.
|
||||||
|
Map<String, String> platformCountryNames = const {};
|
||||||
|
|
||||||
|
const _localeChannel = MethodChannel('kr.co.fanuzu.app/locale');
|
||||||
|
|
||||||
|
Future<void> loadCountryNames(Locale locale) async {
|
||||||
|
try {
|
||||||
|
final pairs = await _localeChannel.invokeListMethod<List<Object?>>(
|
||||||
|
'countryNames',
|
||||||
|
{'locale': localeTag(locale)},
|
||||||
|
);
|
||||||
|
if (pairs == null) return;
|
||||||
|
platformCountryNames = {
|
||||||
|
for (final p in pairs)
|
||||||
|
if (p.length == 2 && p[0] is String && p[1] is String)
|
||||||
|
p[0]! as String: p[1]! as String,
|
||||||
|
};
|
||||||
|
} on Object catch (e) {
|
||||||
|
debugPrint('[Locale] country names unavailable: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 기준 언어(ko) 문자열. `BuildContext` 가 없는 곳의 기본값으로 쓴다.
|
/// 기준 언어(ko) 문자열. `BuildContext` 가 없는 곳의 기본값으로 쓴다.
|
||||||
final AppLocalizations koL10n = lookupAppLocalizations(fallbackLocale);
|
final AppLocalizations koL10n = lookupAppLocalizations(fallbackLocale);
|
||||||
|
|
||||||
|
|||||||
@@ -241,13 +241,19 @@ class AuthRepository extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SDK 로그아웃(Google · Kakao 등)은 기다리지 않는다. 로그인 상태를 정하는 것은 Firebase 세션이고,
|
||||||
|
// SDK 쪽은 다음 로그인의 자동 선택을 지우는 정리일 뿐이다. Android Google 은 Credential Manager
|
||||||
|
// `clearCredentialStateAsync` 가 응답 없이 멈춰 3초 시간 제한을 다 쓰고, 그 TimeoutException 이
|
||||||
|
// 처리되지 않은 채 로그에 남았다(10/6 실기기). 실패는 로그로만 남긴다.
|
||||||
|
if (provider != null) {
|
||||||
|
unawaited(
|
||||||
|
provider.signOut().catchError((Object e) {
|
||||||
|
debugPrint('[Auth] provider sign-out skipped: $e');
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
await Future.wait([
|
await _session?.signOut();
|
||||||
// SDK 로그아웃(Google · Kakao 등)이 응답 없이 멈춰도 로컬 로그아웃은 한다.
|
|
||||||
if (provider != null)
|
|
||||||
provider.signOut().timeout(const Duration(seconds: 3)),
|
|
||||||
if (_session != null) _session.signOut(),
|
|
||||||
], eagerError: false);
|
|
||||||
} finally {
|
} finally {
|
||||||
// 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다.
|
// 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다.
|
||||||
_replaceAccount(null);
|
_replaceAccount(null);
|
||||||
|
|||||||
@@ -381,6 +381,8 @@ class _ProfileStepState extends State<_ProfileStep> {
|
|||||||
context: context,
|
context: context,
|
||||||
isScrollControlled: true,
|
isScrollControlled: true,
|
||||||
backgroundColor: const Color(0xFF101010),
|
backgroundColor: const Color(0xFF101010),
|
||||||
|
// 사본 `178:23489` 상단 손잡이.
|
||||||
|
showDragHandle: true,
|
||||||
builder: (_) => _CountrySheet(current: _country),
|
builder: (_) => _CountrySheet(current: _country),
|
||||||
);
|
);
|
||||||
if (picked != null && mounted) setState(() => _country = picked);
|
if (picked != null && mounted) setState(() => _country = picked);
|
||||||
@@ -390,9 +392,12 @@ class _ProfileStepState extends State<_ProfileStep> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final l10n = context.l10n;
|
final l10n = context.l10n;
|
||||||
final current = _locale ?? LocaleScope.maybeOf(context)?.locale;
|
final current = _locale ?? LocaleScope.maybeOf(context)?.locale;
|
||||||
|
// Figma `511:86825` — 머리줄 「<」 좌측 · 타이틀 중앙(사본 `178:23489` 「회원가입」), 본문 큰 제목 없음.
|
||||||
|
// 입력란은 사본처럼 테두리 상자(`511:86809`).
|
||||||
return _StepScaffold(
|
return _StepScaffold(
|
||||||
title: l10n.onbProfileTitle,
|
barTitle: l10n.authSignup,
|
||||||
onBack: widget.onBack,
|
onBack: widget.onBack,
|
||||||
|
boxedInputs: true,
|
||||||
action: _PrimaryButton(
|
action: _PrimaryButton(
|
||||||
label: l10n.commonNext,
|
label: l10n.commonNext,
|
||||||
onPressed: _valid && !_busy ? _save : null,
|
onPressed: _valid && !_busy ? _save : null,
|
||||||
@@ -475,10 +480,12 @@ class _CountrySheetState extends State<_CountrySheet> {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 4, 0),
|
padding: const EdgeInsets.fromLTRB(16, 0, 4, 0),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(child: Text(context.l10n.onbCountry, style: _title)),
|
Expanded(
|
||||||
|
child: Text(context.l10n.onbCountrySheetTitle, style: _title),
|
||||||
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
tooltip: context.l10n.commonClose,
|
tooltip: context.l10n.commonClose,
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
@@ -490,7 +497,7 @@ class _CountrySheetState extends State<_CountrySheet> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: ListView(
|
||||||
children: [
|
children: [
|
||||||
for (final c in isoCountries)
|
for (final c in sortedCountries())
|
||||||
ListTile(
|
ListTile(
|
||||||
onTap: () => setState(() => _picked = c),
|
onTap: () => setState(() => _picked = c),
|
||||||
leading: Text(
|
leading: Text(
|
||||||
@@ -739,14 +746,18 @@ class _PermissionStepState extends State<_PermissionStep> {
|
|||||||
/// 단계 화면 틀 — 사본 가입 화면과 같은 코스믹 다크 · 하단 고정 버튼.
|
/// 단계 화면 틀 — 사본 가입 화면과 같은 코스믹 다크 · 하단 고정 버튼.
|
||||||
class _StepScaffold extends StatelessWidget {
|
class _StepScaffold extends StatelessWidget {
|
||||||
const _StepScaffold({
|
const _StepScaffold({
|
||||||
required this.title,
|
this.title,
|
||||||
required this.action,
|
required this.action,
|
||||||
required this.children,
|
required this.children,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
this.onBack,
|
this.onBack,
|
||||||
|
this.barTitle,
|
||||||
|
this.boxedInputs = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String title;
|
final String? title;
|
||||||
|
final String? barTitle;
|
||||||
|
final bool boxedInputs;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final Widget action;
|
final Widget action;
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
@@ -757,6 +768,8 @@ class _StepScaffold extends StatelessWidget {
|
|||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
automaticallyImplyLeading: false,
|
automaticallyImplyLeading: false,
|
||||||
leading: onBack == null ? null : BackButton(onPressed: onBack),
|
leading: onBack == null ? null : BackButton(onPressed: onBack),
|
||||||
|
title: barTitle == null ? null : Text(barTitle!),
|
||||||
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
@@ -764,17 +777,22 @@ class _StepScaffold extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView(
|
child: _boxed(
|
||||||
padding: const EdgeInsets.all(AppSpace.s4),
|
context,
|
||||||
children: [
|
ListView(
|
||||||
Text(title, style: _title),
|
padding: const EdgeInsets.all(AppSpace.s4),
|
||||||
if (subtitle != null) ...[
|
children: [
|
||||||
const SizedBox(height: AppSpace.s2),
|
if (title != null) ...[
|
||||||
Text(subtitle!, style: _body),
|
Text(title!, style: _title),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: AppSpace.s2),
|
||||||
|
Text(subtitle!, style: _body),
|
||||||
|
],
|
||||||
|
const SizedBox(height: AppSpace.s5),
|
||||||
|
],
|
||||||
|
...children,
|
||||||
],
|
],
|
||||||
const SizedBox(height: AppSpace.s5),
|
),
|
||||||
...children,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Padding(padding: const EdgeInsets.all(AppSpace.s4), child: action),
|
Padding(padding: const EdgeInsets.all(AppSpace.s4), child: action),
|
||||||
@@ -782,6 +800,28 @@ class _StepScaffold extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// 사본 가입 화면 입력란 — 회색 테두리 상자(약관 상자와 같은 선 · 모서리 8).
|
||||||
|
Widget _boxed(BuildContext context, Widget child) {
|
||||||
|
if (!boxedInputs) return child;
|
||||||
|
OutlineInputBorder line(Color c, [double w = 1]) => OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppRadius.sm),
|
||||||
|
borderSide: BorderSide(color: c, width: w),
|
||||||
|
);
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Theme(
|
||||||
|
data: theme.copyWith(
|
||||||
|
inputDecorationTheme: theme.inputDecorationTheme.copyWith(
|
||||||
|
border: line(_stroke),
|
||||||
|
enabledBorder: line(_stroke),
|
||||||
|
focusedBorder: line(AppColors.cosmicAccent, 2),
|
||||||
|
errorBorder: line(AppColors.error),
|
||||||
|
focusedErrorBorder: line(AppColors.error, 2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _PrimaryButton extends StatelessWidget {
|
class _PrimaryButton extends StatelessWidget {
|
||||||
@@ -803,6 +843,9 @@ class _PrimaryButton extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 사본 입력란 테두리 — 약관 상자(`signup_screens.dart`)와 같은 값.
|
||||||
|
const _stroke = Color(0xFF545454);
|
||||||
|
|
||||||
const _title = TextStyle(
|
const _title = TextStyle(
|
||||||
fontFamily: AppFonts.body,
|
fontFamily: AppFonts.body,
|
||||||
fontSize: 20,
|
fontSize: 20,
|
||||||
@@ -822,6 +865,19 @@ const _body = TextStyle(
|
|||||||
String countryFlag(String code) =>
|
String countryFlag(String code) =>
|
||||||
String.fromCharCodes(code.codeUnits.map((c) => c - 0x41 + 0x1F1E6));
|
String.fromCharCodes(code.codeUnits.map((c) => c - 0x41 + 0x1F1E6));
|
||||||
|
|
||||||
|
/// 국가 시트 순서 — 기기 OS 정렬([platformCountryNames] 순서)을 따르고, OS 가 모르는 코드는 표시명 순으로 뒤에 둔다.
|
||||||
|
/// 사본 `178:23489` 목록은 (첫 줄 독일을 빼면) 국가명 가나다순이고 검색 칸이 없다. 범위는 Q-E10 회신 대기.
|
||||||
|
List<String> sortedCountries() {
|
||||||
|
final rest = isoCountries.toSet();
|
||||||
|
final ordered = [
|
||||||
|
for (final c in platformCountryNames.keys)
|
||||||
|
if (rest.remove(c)) c,
|
||||||
|
];
|
||||||
|
return ordered..addAll(
|
||||||
|
rest.toList()..sort((a, b) => countryName(a).compareTo(countryName(b))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// ISO 3166-1 alpha-2 전체. 랭킹 집계 · Admin 사용자 일람의 「거주 국가/지역」 값이다.
|
/// ISO 3166-1 alpha-2 전체. 랭킹 집계 · Admin 사용자 일람의 「거주 국가/지역」 값이다.
|
||||||
final List<String> isoCountries =
|
final List<String> isoCountries =
|
||||||
('AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ '
|
('AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ '
|
||||||
|
|||||||
@@ -126,7 +126,15 @@ class _TermsScreenState extends State<TermsScreen> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(context.l10n.termsTitle)),
|
// Figma `179:24230` 머리줄 좌측 「<」 — 「이전」과 같은 동작(온보딩 안에서는 로그아웃 후 로그인 화면).
|
||||||
|
appBar: AppBar(
|
||||||
|
automaticallyImplyLeading: false,
|
||||||
|
leading: BackButton(
|
||||||
|
onPressed: widget.onBack ?? () => Navigator.of(context).pop(),
|
||||||
|
),
|
||||||
|
title: Text(context.l10n.termsTitle),
|
||||||
|
centerTitle: true,
|
||||||
|
),
|
||||||
// Figma `179:24230` — 「이전 · 완료」는 목록 끝이 아니라 화면 하단 고정(56 높이 · 간격 10 · 아래 16).
|
// Figma `179:24230` — 「이전 · 완료」는 목록 끝이 아니라 화면 하단 고정(56 높이 · 간격 10 · 아래 16).
|
||||||
// 시스템 내비게이션 바 위로 올린다(SafeArea) — 10/6 실기기에서 첫 탭이 바에 먹혔다.
|
// 시스템 내비게이션 바 위로 올린다(SafeArea) — 10/6 실기기에서 첫 탭이 바에 먹혔다.
|
||||||
bottomNavigationBar: SafeArea(
|
bottomNavigationBar: SafeArea(
|
||||||
|
|||||||
@@ -137,12 +137,15 @@ class FpiRankRow {
|
|||||||
final bool isMine;
|
final bool isMine;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 국가 코드 → 표시명(ko). 없으면 코드를 그대로 띄운다.
|
/// 국가 코드 → 표시명(현재 앱 언어). 기기 OS 의 국가 이름([platformCountryNames])을 먼저 쓰고,
|
||||||
|
/// 없으면(위젯 테스트 · `ZZ` 미지정) arb 표시명, 그것도 없으면 코드를 그대로 띄운다.
|
||||||
///
|
///
|
||||||
/// **표시명을 아는 곳은 여기 한 곳뿐이다.** 서버 집계(`fnRankingCountryAggregate`)는
|
/// **표시명을 아는 곳은 여기 한 곳뿐이다.** 서버 집계(`fnRankingCountryAggregate`)는
|
||||||
/// 국가 코드만 적는다 — 표기 언어는 화면의 몫이고, 두 곳에 두면 서버에 없는 나라가
|
/// 국가 코드만 적는다 — 표기 언어는 화면의 몫이고, 두 곳에 두면 서버에 없는 나라가
|
||||||
/// 늘 때마다 배포가 필요해진다. 지원 국가 목록·표기 언어는 대조표 7-③ 확인 요청 중.
|
/// 늘 때마다 배포가 필요해진다. 지원 국가 목록은 해석 확인 요청(Q-E10) 중.
|
||||||
String countryName(String code) {
|
String countryName(String code) {
|
||||||
|
final platform = platformCountryNames[code];
|
||||||
|
if (platform != null && platform.isNotEmpty) return platform;
|
||||||
final name = appL10n.countryName(code);
|
final name = appL10n.countryName(code);
|
||||||
return name == '-' ? code : name;
|
return name == '-' ? code : name;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -856,7 +856,8 @@
|
|||||||
"onbIntro3Body": "Stop the Eclipse together and support\nyour artist through campaigns.",
|
"onbIntro3Body": "Stop the Eclipse together and support\nyour artist through campaigns.",
|
||||||
"onbProfileTitle": "Set up your profile",
|
"onbProfileTitle": "Set up your profile",
|
||||||
"onbNicknameHint": "2–20 characters · duplicates are allowed",
|
"onbNicknameHint": "2–20 characters · duplicates are allowed",
|
||||||
"onbCountry": "Country/Region",
|
"onbCountry": "Country/Region of residence",
|
||||||
|
"onbCountrySheetTitle": "Select your country/region of residence",
|
||||||
"onbLanguage": "Display language",
|
"onbLanguage": "Display language",
|
||||||
"onbReferral": "Referral code (optional)",
|
"onbReferral": "Referral code (optional)",
|
||||||
"onbReferralHint": "Enter a code if a friend shared one",
|
"onbReferralHint": "Enter a code if a friend shared one",
|
||||||
|
|||||||
@@ -856,7 +856,8 @@
|
|||||||
"onbIntro3Body": "Detengan juntos el Eclipse y apoyen\na su artista con campañas.",
|
"onbIntro3Body": "Detengan juntos el Eclipse y apoyen\na su artista con campañas.",
|
||||||
"onbProfileTitle": "Configura tu perfil",
|
"onbProfileTitle": "Configura tu perfil",
|
||||||
"onbNicknameHint": "2–20 caracteres · se permiten duplicados",
|
"onbNicknameHint": "2–20 caracteres · se permiten duplicados",
|
||||||
"onbCountry": "País/Región",
|
"onbCountry": "País/Región de residencia",
|
||||||
|
"onbCountrySheetTitle": "Selecciona tu país/región de residencia",
|
||||||
"onbLanguage": "Idioma de visualización",
|
"onbLanguage": "Idioma de visualización",
|
||||||
"onbReferral": "Código de referido (opcional)",
|
"onbReferral": "Código de referido (opcional)",
|
||||||
"onbReferralHint": "Introduce el código si un amigo te lo compartió",
|
"onbReferralHint": "Introduce el código si un amigo te lo compartió",
|
||||||
|
|||||||
@@ -856,7 +856,8 @@
|
|||||||
"onbIntro3Body": "みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。",
|
"onbIntro3Body": "みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。",
|
||||||
"onbProfileTitle": "プロフィールを設定してください",
|
"onbProfileTitle": "プロフィールを設定してください",
|
||||||
"onbNicknameHint": "2〜20文字・他の人と同じでも大丈夫です",
|
"onbNicknameHint": "2〜20文字・他の人と同じでも大丈夫です",
|
||||||
"onbCountry": "国・地域",
|
"onbCountry": "居住国・地域",
|
||||||
|
"onbCountrySheetTitle": "居住国・地域を選択",
|
||||||
"onbLanguage": "表示言語",
|
"onbLanguage": "表示言語",
|
||||||
"onbReferral": "紹介コード(任意)",
|
"onbReferral": "紹介コード(任意)",
|
||||||
"onbReferralHint": "友だちからもらったコードがあれば入力してください",
|
"onbReferralHint": "友だちからもらったコードがあれば入力してください",
|
||||||
|
|||||||
@@ -1799,7 +1799,8 @@
|
|||||||
"onbIntro3Body": "이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.",
|
"onbIntro3Body": "이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.",
|
||||||
"onbProfileTitle": "프로필을 설정해 주세요",
|
"onbProfileTitle": "프로필을 설정해 주세요",
|
||||||
"onbNicknameHint": "2~20자 · 다른 사람과 같아도 괜찮아요",
|
"onbNicknameHint": "2~20자 · 다른 사람과 같아도 괜찮아요",
|
||||||
"onbCountry": "국가/지역",
|
"onbCountry": "거주 국가/지역",
|
||||||
|
"onbCountrySheetTitle": "거주 국가/지역 선택",
|
||||||
"onbLanguage": "표시 언어",
|
"onbLanguage": "표시 언어",
|
||||||
"onbReferral": "추천인 코드 (선택)",
|
"onbReferral": "추천인 코드 (선택)",
|
||||||
"onbReferralHint": "친구에게 받은 코드가 있으면 입력해 주세요",
|
"onbReferralHint": "친구에게 받은 코드가 있으면 입력해 주세요",
|
||||||
|
|||||||
@@ -5255,9 +5255,15 @@ abstract class AppLocalizations {
|
|||||||
/// No description provided for @onbCountry.
|
/// No description provided for @onbCountry.
|
||||||
///
|
///
|
||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
/// **'국가/지역'**
|
/// **'거주 국가/지역'**
|
||||||
String get onbCountry;
|
String get onbCountry;
|
||||||
|
|
||||||
|
/// No description provided for @onbCountrySheetTitle.
|
||||||
|
///
|
||||||
|
/// In ko, this message translates to:
|
||||||
|
/// **'거주 국가/지역 선택'**
|
||||||
|
String get onbCountrySheetTitle;
|
||||||
|
|
||||||
/// No description provided for @onbLanguage.
|
/// No description provided for @onbLanguage.
|
||||||
///
|
///
|
||||||
/// In ko, this message translates to:
|
/// In ko, this message translates to:
|
||||||
|
|||||||
@@ -2991,7 +2991,10 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
String get onbNicknameHint => '2–20 characters · duplicates are allowed';
|
String get onbNicknameHint => '2–20 characters · duplicates are allowed';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbCountry => 'Country/Region';
|
String get onbCountry => 'Country/Region of residence';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onbCountrySheetTitle => 'Select your country/region of residence';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbLanguage => 'Display language';
|
String get onbLanguage => 'Display language';
|
||||||
|
|||||||
@@ -3006,7 +3006,10 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
String get onbNicknameHint => '2–20 caracteres · se permiten duplicados';
|
String get onbNicknameHint => '2–20 caracteres · se permiten duplicados';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbCountry => 'País/Región';
|
String get onbCountry => 'País/Región de residencia';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onbCountrySheetTitle => 'Selecciona tu país/región de residencia';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbLanguage => 'Idioma de visualización';
|
String get onbLanguage => 'Idioma de visualización';
|
||||||
|
|||||||
@@ -2891,7 +2891,10 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
String get onbNicknameHint => '2〜20文字・他の人と同じでも大丈夫です';
|
String get onbNicknameHint => '2〜20文字・他の人と同じでも大丈夫です';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbCountry => '国・地域';
|
String get onbCountry => '居住国・地域';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onbCountrySheetTitle => '居住国・地域を選択';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbLanguage => '表示言語';
|
String get onbLanguage => '表示言語';
|
||||||
|
|||||||
@@ -2894,7 +2894,10 @@ class AppLocalizationsKo extends AppLocalizations {
|
|||||||
String get onbNicknameHint => '2~20자 · 다른 사람과 같아도 괜찮아요';
|
String get onbNicknameHint => '2~20자 · 다른 사람과 같아도 괜찮아요';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbCountry => '국가/지역';
|
String get onbCountry => '거주 국가/지역';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onbCountrySheetTitle => '거주 국가/지역 선택';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbLanguage => '표시 언어';
|
String get onbLanguage => '표시 언어';
|
||||||
|
|||||||
@@ -2886,7 +2886,10 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
String get onbNicknameHint => '2~20个字符·可与他人重复';
|
String get onbNicknameHint => '2~20个字符·可与他人重复';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbCountry => '国家/地区';
|
String get onbCountry => '居住国家/地区';
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onbCountrySheetTitle => '选择居住国家/地区';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbLanguage => '显示语言';
|
String get onbLanguage => '显示语言';
|
||||||
|
|||||||
@@ -856,7 +856,8 @@
|
|||||||
"onbIntro3Body": "一起阻止日食,通过活动\n为艺人应援。",
|
"onbIntro3Body": "一起阻止日食,通过活动\n为艺人应援。",
|
||||||
"onbProfileTitle": "请设置个人资料",
|
"onbProfileTitle": "请设置个人资料",
|
||||||
"onbNicknameHint": "2~20个字符·可与他人重复",
|
"onbNicknameHint": "2~20个字符·可与他人重复",
|
||||||
"onbCountry": "国家/地区",
|
"onbCountry": "居住国家/地区",
|
||||||
|
"onbCountrySheetTitle": "选择居住国家/地区",
|
||||||
"onbLanguage": "显示语言",
|
"onbLanguage": "显示语言",
|
||||||
"onbReferral": "推荐码(选填)",
|
"onbReferral": "推荐码(选填)",
|
||||||
"onbReferralHint": "如有朋友分享的推荐码请输入",
|
"onbReferralHint": "如有朋友分享的推荐码请输入",
|
||||||
|
|||||||
@@ -1387,6 +1387,9 @@ class RemoteAppLocalizations extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get onbCountry => _remote['onbCountry'] ?? _base.onbCountry;
|
String get onbCountry => _remote['onbCountry'] ?? _base.onbCountry;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get onbCountrySheetTitle => _remote['onbCountrySheetTitle'] ?? _base.onbCountrySheetTitle;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get onbIntro1Body => _remote['onbIntro1Body'] ?? _base.onbIntro1Body;
|
String get onbIntro1Body => _remote['onbIntro1Body'] ?? _base.onbIntro1Body;
|
||||||
|
|
||||||
|
|||||||
@@ -228,7 +228,32 @@ void main() {
|
|||||||
releaseMode: true,
|
releaseMode: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
await expectLater(auth.signOut(), throwsA(isA<StateError>()));
|
// SDK 로그아웃 실패는 로그만 남기고 호출자에게 던지지 않는다(처리되지 않은 예외 방지).
|
||||||
|
await auth.signOut();
|
||||||
|
|
||||||
|
expect(auth.isSignedIn, isFalse);
|
||||||
|
expect(provider.signOutCalls, 1);
|
||||||
|
expect(session.signOutCalls, 1);
|
||||||
|
auth.dispose();
|
||||||
|
await session.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('SDK 로그아웃이 응답 없이 멈춰도 기다리지 않는다', () async {
|
||||||
|
final provider = _FakeProvider(
|
||||||
|
SocialProvider.google,
|
||||||
|
result: const AuthCancelled(),
|
||||||
|
signOutHangs: true,
|
||||||
|
);
|
||||||
|
final session = _FakeSession(
|
||||||
|
const AuthAccount(uid: 'google-user', provider: SocialProvider.google),
|
||||||
|
);
|
||||||
|
final auth = AuthRepository(
|
||||||
|
providers: [provider],
|
||||||
|
session: session,
|
||||||
|
releaseMode: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
await auth.signOut().timeout(const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(auth.isSignedIn, isFalse);
|
expect(auth.isSignedIn, isFalse);
|
||||||
expect(provider.signOutCalls, 1);
|
expect(provider.signOutCalls, 1);
|
||||||
@@ -245,6 +270,7 @@ final class _FakeProvider implements SocialAuthProvider {
|
|||||||
this.result,
|
this.result,
|
||||||
this.signInError,
|
this.signInError,
|
||||||
this.signOutError,
|
this.signOutError,
|
||||||
|
this.signOutHangs = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -252,6 +278,7 @@ final class _FakeProvider implements SocialAuthProvider {
|
|||||||
final AuthOutcome? result;
|
final AuthOutcome? result;
|
||||||
final Object? signInError;
|
final Object? signInError;
|
||||||
final Object? signOutError;
|
final Object? signOutError;
|
||||||
|
final bool signOutHangs;
|
||||||
int signOutCalls = 0;
|
int signOutCalls = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -269,6 +296,7 @@ final class _FakeProvider implements SocialAuthProvider {
|
|||||||
@override
|
@override
|
||||||
Future<void> signOut() async {
|
Future<void> signOut() async {
|
||||||
signOutCalls += 1;
|
signOutCalls += 1;
|
||||||
|
if (signOutHangs) return Completer<void>().future;
|
||||||
if (signOutError case final error?) throw error;
|
if (signOutError case final error?) throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,15 @@ import 'dart:async';
|
|||||||
import 'package:fancake/app.dart';
|
import 'package:fancake/app.dart';
|
||||||
import 'package:fancake/core/data/repositories.dart';
|
import 'package:fancake/core/data/repositories.dart';
|
||||||
import 'package:fancake/core/firebase/firebase_boot.dart';
|
import 'package:fancake/core/firebase/firebase_boot.dart';
|
||||||
|
import 'package:fancake/core/i18n/app_locale.dart';
|
||||||
|
import 'package:fancake/features/planet/planet_data.dart' show countryName;
|
||||||
import 'package:fancake/features/auth/auth.dart';
|
import 'package:fancake/features/auth/auth.dart';
|
||||||
import 'package:fancake/features/auth/auth_repository.dart';
|
import 'package:fancake/features/auth/auth_repository.dart';
|
||||||
import 'package:fancake/features/auth/login_screen.dart';
|
import 'package:fancake/features/auth/login_screen.dart';
|
||||||
import 'package:fancake/features/auth/onboarding_data.dart';
|
import 'package:fancake/features/auth/onboarding_data.dart';
|
||||||
import 'package:fancake/features/auth/onboarding_screens.dart';
|
import 'package:fancake/features/auth/onboarding_screens.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
@@ -57,7 +60,9 @@ void main() {
|
|||||||
expect(find.byType(NavigationBar), findsOneWidget);
|
expect(find.byType(NavigationBar), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('동의한 계정은 셸로 가는 동안 약관 화면이 한 프레임도 그려지지 않는다 — 10/6 콜드 스타트 깜빡임', (tester) async {
|
testWidgets('동의한 계정은 셸로 가는 동안 약관 화면이 한 프레임도 그려지지 않는다 — 10/6 콜드 스타트 깜빡임', (
|
||||||
|
tester,
|
||||||
|
) async {
|
||||||
// 서버 판정이 몇 프레임 걸리는 실기기처럼 늦춘다.
|
// 서버 판정이 몇 프레임 걸리는 실기기처럼 늦춘다.
|
||||||
final (auth, _) = await boot(
|
final (auth, _) = await boot(
|
||||||
tester,
|
tester,
|
||||||
@@ -148,8 +153,11 @@ void main() {
|
|||||||
await tester.tap(find.text('완료'));
|
await tester.tap(find.text('완료'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
await tester.tap(find.text('국가/지역').first);
|
await tester.tap(
|
||||||
|
find.ancestor(of: find.text('거주 국가/지역'), matching: find.byType(InkWell)),
|
||||||
|
);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
expect(find.text('거주 국가/지역 선택'), findsOneWidget);
|
||||||
await tester.scrollUntilVisible(
|
await tester.scrollUntilVisible(
|
||||||
find.text('대한민국'),
|
find.text('대한민국'),
|
||||||
300,
|
300,
|
||||||
@@ -210,7 +218,34 @@ void main() {
|
|||||||
await auth.signIn(SocialProvider.google, signUp: true);
|
await auth.signIn(SocialProvider.google, signUp: true);
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(repo.agreed, contains('marketing'));
|
expect(repo.agreed, contains('marketing'));
|
||||||
expect(find.text('프로필을 설정해 주세요'), findsOneWidget);
|
expect(find.text('거주 국가/지역'), findsOneWidget); // ③ 프로필
|
||||||
|
expect(find.text('회원가입'), findsOneWidget); // 511:86825 머리줄 가운데 타이틀
|
||||||
|
});
|
||||||
|
|
||||||
|
test('국가명은 OS 이름 · OS 정렬 순서 — 이름 없는 나라도 코드로 보이지 않는다', () async {
|
||||||
|
TestWidgetsFlutterBinding.ensureInitialized();
|
||||||
|
const channel = MethodChannel('kr.co.fanuzu.app/locale');
|
||||||
|
final messenger =
|
||||||
|
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
|
||||||
|
String? asked;
|
||||||
|
messenger.setMockMethodCallHandler(channel, (call) async {
|
||||||
|
asked = (call.arguments as Map)['locale'] as String;
|
||||||
|
return [
|
||||||
|
['GH', '가나'],
|
||||||
|
['AD', '안도라'],
|
||||||
|
['KR', '대한민국'],
|
||||||
|
];
|
||||||
|
});
|
||||||
|
addTearDown(() {
|
||||||
|
messenger.setMockMethodCallHandler(channel, null);
|
||||||
|
platformCountryNames = const {};
|
||||||
|
});
|
||||||
|
|
||||||
|
await loadCountryNames(const Locale('ko'));
|
||||||
|
expect(asked, 'ko');
|
||||||
|
expect(countryName('AD'), '안도라');
|
||||||
|
expect(sortedCountries().take(3), ['GH', 'AD', 'KR']);
|
||||||
|
expect(sortedCountries().length, isoCountries.length);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('첫 실행은 소개 3장 — 건너뛰기를 눌러도 로그인 화면', (tester) async {
|
testWidgets('첫 실행은 소개 3장 — 건너뛰기를 눌러도 로그인 화면', (tester) async {
|
||||||
|
|||||||
Reference in New Issue
Block a user