로그아웃 SDK 대기 제거 · 온보딩 국가 시트 · 프로필 머리줄

This commit is contained in:
Sn-Kinos
2026-10-06 04:47:37 +09:00
parent 976e569a56
commit 40756d5c86
21 changed files with 277 additions and 43 deletions
@@ -4,6 +4,10 @@ import android.app.NotificationChannel
import android.app.NotificationManager
import android.os.Bundle
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 여야 한다.
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)
}
}
}
+15
View File
@@ -12,5 +12,20 @@ import UIKit
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
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)
}
}
}
+32 -3
View File
@@ -1,6 +1,7 @@
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -205,14 +206,42 @@ class RemoteL10nDelegate extends LocalizationsDelegate<AppLocalizations> {
AppLocalizations.delegate.isSupported(locale);
@override
Future<AppLocalizations> load(Locale locale) => SynchronousFuture(
appL10n = RemoteAppLocalizations(lookupAppLocalizations(locale), remote),
);
Future<AppLocalizations> load(Locale locale) {
unawaited(loadCountryNames(locale));
return SynchronousFuture(
appL10n = RemoteAppLocalizations(lookupAppLocalizations(locale), remote),
);
}
@override
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` 가 없는 곳의 기본값으로 쓴다.
final AppLocalizations koL10n = lookupAppLocalizations(fallbackLocale);
+12 -6
View File
@@ -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 {
await Future.wait([
// SDK 로그아웃(Google · Kakao 등)이 응답 없이 멈춰도 로컬 로그아웃은 한다.
if (provider != null)
provider.signOut().timeout(const Duration(seconds: 3)),
if (_session != null) _session.signOut(),
], eagerError: false);
await _session?.signOut();
} finally {
// 원격 SDK 정리가 실패해도 로컬 라우터는 로그인 상태에 고착되지 않는다.
_replaceAccount(null);
+72 -16
View File
@@ -381,6 +381,8 @@ class _ProfileStepState extends State<_ProfileStep> {
context: context,
isScrollControlled: true,
backgroundColor: const Color(0xFF101010),
// 사본 `178:23489` 상단 손잡이.
showDragHandle: true,
builder: (_) => _CountrySheet(current: _country),
);
if (picked != null && mounted) setState(() => _country = picked);
@@ -390,9 +392,12 @@ class _ProfileStepState extends State<_ProfileStep> {
Widget build(BuildContext context) {
final l10n = context.l10n;
final current = _locale ?? LocaleScope.maybeOf(context)?.locale;
// Figma `511:86825` — 머리줄 「<」 좌측 · 타이틀 중앙(사본 `178:23489` 「회원가입」), 본문 큰 제목 없음.
// 입력란은 사본처럼 테두리 상자(`511:86809`).
return _StepScaffold(
title: l10n.onbProfileTitle,
barTitle: l10n.authSignup,
onBack: widget.onBack,
boxedInputs: true,
action: _PrimaryButton(
label: l10n.commonNext,
onPressed: _valid && !_busy ? _save : null,
@@ -475,10 +480,12 @@ class _CountrySheetState extends State<_CountrySheet> {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 4, 0),
padding: const EdgeInsets.fromLTRB(16, 0, 4, 0),
child: Row(
children: [
Expanded(child: Text(context.l10n.onbCountry, style: _title)),
Expanded(
child: Text(context.l10n.onbCountrySheetTitle, style: _title),
),
IconButton(
tooltip: context.l10n.commonClose,
onPressed: () => Navigator.of(context).pop(),
@@ -490,7 +497,7 @@ class _CountrySheetState extends State<_CountrySheet> {
Expanded(
child: ListView(
children: [
for (final c in isoCountries)
for (final c in sortedCountries())
ListTile(
onTap: () => setState(() => _picked = c),
leading: Text(
@@ -739,14 +746,18 @@ class _PermissionStepState extends State<_PermissionStep> {
/// 단계 화면 틀 — 사본 가입 화면과 같은 코스믹 다크 · 하단 고정 버튼.
class _StepScaffold extends StatelessWidget {
const _StepScaffold({
required this.title,
this.title,
required this.action,
required this.children,
this.subtitle,
this.onBack,
this.barTitle,
this.boxedInputs = false,
});
final String title;
final String? title;
final String? barTitle;
final bool boxedInputs;
final String? subtitle;
final Widget action;
final List<Widget> children;
@@ -757,6 +768,8 @@ class _StepScaffold extends StatelessWidget {
appBar: AppBar(
automaticallyImplyLeading: false,
leading: onBack == null ? null : BackButton(onPressed: onBack),
title: barTitle == null ? null : Text(barTitle!),
centerTitle: true,
),
body: SafeArea(
top: false,
@@ -764,17 +777,22 @@ class _StepScaffold extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
Text(title, style: _title),
if (subtitle != null) ...[
const SizedBox(height: AppSpace.s2),
Text(subtitle!, style: _body),
child: _boxed(
context,
ListView(
padding: const EdgeInsets.all(AppSpace.s4),
children: [
if (title != null) ...[
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),
@@ -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 {
@@ -803,6 +843,9 @@ class _PrimaryButton extends StatelessWidget {
);
}
/// 사본 입력란 테두리 — 약관 상자(`signup_screens.dart`)와 같은 값.
const _stroke = Color(0xFF545454);
const _title = TextStyle(
fontFamily: AppFonts.body,
fontSize: 20,
@@ -822,6 +865,19 @@ const _body = TextStyle(
String countryFlag(String code) =>
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 사용자 일람의 「거주 국가/지역」 값이다.
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 '
+9 -1
View File
@@ -126,7 +126,15 @@ class _TermsScreenState extends State<TermsScreen> {
@override
Widget build(BuildContext context) {
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).
// 시스템 내비게이션 바 위로 올린다(SafeArea) — 10/6 실기기에서 첫 탭이 바에 먹혔다.
bottomNavigationBar: SafeArea(
+5 -2
View File
@@ -137,12 +137,15 @@ class FpiRankRow {
final bool isMine;
}
/// 국가 코드 → 표시명(ko). 없으면 코드를 그대로 띄운다.
/// 국가 코드 → 표시명(현재 앱 언어). 기기 OS 의 국가 이름([platformCountryNames])을 먼저 쓰고,
/// 없으면(위젯 테스트 · `ZZ` 미지정) arb 표시명, 그것도 없으면 코드를 그대로 띄운다.
///
/// **표시명을 아는 곳은 여기 한 곳뿐이다.** 서버 집계(`fnRankingCountryAggregate`)는
/// 국가 코드만 적는다 — 표기 언어는 화면의 몫이고, 두 곳에 두면 서버에 없는 나라가
/// 늘 때마다 배포가 필요해진다. 지원 국가 목록·표기 언어는 대조표 7-③ 확인 요청 중.
/// 늘 때마다 배포가 필요해진다. 지원 국가 목록은 해석 확인 요청(Q-E10) 중.
String countryName(String code) {
final platform = platformCountryNames[code];
if (platform != null && platform.isNotEmpty) return platform;
final name = appL10n.countryName(code);
return name == '-' ? code : name;
}
+2 -1
View File
@@ -856,7 +856,8 @@
"onbIntro3Body": "Stop the Eclipse together and support\nyour artist through campaigns.",
"onbProfileTitle": "Set up your profile",
"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",
"onbReferral": "Referral code (optional)",
"onbReferralHint": "Enter a code if a friend shared one",
+2 -1
View File
@@ -856,7 +856,8 @@
"onbIntro3Body": "Detengan juntos el Eclipse y apoyen\na su artista con campañas.",
"onbProfileTitle": "Configura tu perfil",
"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",
"onbReferral": "Código de referido (opcional)",
"onbReferralHint": "Introduce el código si un amigo te lo compartió",
+2 -1
View File
@@ -856,7 +856,8 @@
"onbIntro3Body": "みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。",
"onbProfileTitle": "プロフィールを設定してください",
"onbNicknameHint": "2〜20文字・他の人と同じでも大丈夫です",
"onbCountry": "国・地域",
"onbCountry": "居住国・地域",
"onbCountrySheetTitle": "居住国・地域を選択",
"onbLanguage": "表示言語",
"onbReferral": "紹介コード(任意)",
"onbReferralHint": "友だちからもらったコードがあれば入力してください",
+2 -1
View File
@@ -1799,7 +1799,8 @@
"onbIntro3Body": "이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.",
"onbProfileTitle": "프로필을 설정해 주세요",
"onbNicknameHint": "2~20자 · 다른 사람과 같아도 괜찮아요",
"onbCountry": "국가/지역",
"onbCountry": "거주 국가/지역",
"onbCountrySheetTitle": "거주 국가/지역 선택",
"onbLanguage": "표시 언어",
"onbReferral": "추천인 코드 (선택)",
"onbReferralHint": "친구에게 받은 코드가 있으면 입력해 주세요",
+7 -1
View File
@@ -5255,9 +5255,15 @@ abstract class AppLocalizations {
/// No description provided for @onbCountry.
///
/// In ko, this message translates to:
/// **'국가/지역'**
/// **'거주 국가/지역'**
String get onbCountry;
/// No description provided for @onbCountrySheetTitle.
///
/// In ko, this message translates to:
/// **'거주 국가/지역 선택'**
String get onbCountrySheetTitle;
/// No description provided for @onbLanguage.
///
/// In ko, this message translates to:
+4 -1
View File
@@ -2991,7 +2991,10 @@ class AppLocalizationsEn extends AppLocalizations {
String get onbNicknameHint => '2–20 characters · duplicates are allowed';
@override
String get onbCountry => 'Country/Region';
String get onbCountry => 'Country/Region of residence';
@override
String get onbCountrySheetTitle => 'Select your country/region of residence';
@override
String get onbLanguage => 'Display language';
+4 -1
View File
@@ -3006,7 +3006,10 @@ class AppLocalizationsEs extends AppLocalizations {
String get onbNicknameHint => '2–20 caracteres · se permiten duplicados';
@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
String get onbLanguage => 'Idioma de visualización';
+4 -1
View File
@@ -2891,7 +2891,10 @@ class AppLocalizationsJa extends AppLocalizations {
String get onbNicknameHint => '2〜20文字・他の人と同じでも大丈夫です';
@override
String get onbCountry => '国・地域';
String get onbCountry => '居住国・地域';
@override
String get onbCountrySheetTitle => '居住国・地域を選択';
@override
String get onbLanguage => '表示言語';
+4 -1
View File
@@ -2894,7 +2894,10 @@ class AppLocalizationsKo extends AppLocalizations {
String get onbNicknameHint => '2~20자 · 다른 사람과 같아도 괜찮아요';
@override
String get onbCountry => '국가/지역';
String get onbCountry => '거주 국가/지역';
@override
String get onbCountrySheetTitle => '거주 국가/지역 선택';
@override
String get onbLanguage => '표시 언어';
+4 -1
View File
@@ -2886,7 +2886,10 @@ class AppLocalizationsZh extends AppLocalizations {
String get onbNicknameHint => '2~20个字符·可与他人重复';
@override
String get onbCountry => '国家/地区';
String get onbCountry => '居住国家/地区';
@override
String get onbCountrySheetTitle => '选择居住国家/地区';
@override
String get onbLanguage => '显示语言';
+2 -1
View File
@@ -856,7 +856,8 @@
"onbIntro3Body": "一起阻止日食,通过活动\n为艺人应援。",
"onbProfileTitle": "请设置个人资料",
"onbNicknameHint": "2~20个字符·可与他人重复",
"onbCountry": "国家/地区",
"onbCountry": "居住国家/地区",
"onbCountrySheetTitle": "选择居住国家/地区",
"onbLanguage": "显示语言",
"onbReferral": "推荐码(选填)",
"onbReferralHint": "如有朋友分享的推荐码请输入",
@@ -1387,6 +1387,9 @@ class RemoteAppLocalizations extends AppLocalizations {
@override
String get onbCountry => _remote['onbCountry'] ?? _base.onbCountry;
@override
String get onbCountrySheetTitle => _remote['onbCountrySheetTitle'] ?? _base.onbCountrySheetTitle;
@override
String get onbIntro1Body => _remote['onbIntro1Body'] ?? _base.onbIntro1Body;
+29 -1
View File
@@ -228,7 +228,32 @@ void main() {
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(provider.signOutCalls, 1);
@@ -245,6 +270,7 @@ final class _FakeProvider implements SocialAuthProvider {
this.result,
this.signInError,
this.signOutError,
this.signOutHangs = false,
});
@override
@@ -252,6 +278,7 @@ final class _FakeProvider implements SocialAuthProvider {
final AuthOutcome? result;
final Object? signInError;
final Object? signOutError;
final bool signOutHangs;
int signOutCalls = 0;
@override
@@ -269,6 +296,7 @@ final class _FakeProvider implements SocialAuthProvider {
@override
Future<void> signOut() async {
signOutCalls += 1;
if (signOutHangs) return Completer<void>().future;
if (signOutError case final error?) throw error;
}
}
+38 -3
View File
@@ -3,12 +3,15 @@ import 'dart:async';
import 'package:fancake/app.dart';
import 'package:fancake/core/data/repositories.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_repository.dart';
import 'package:fancake/features/auth/login_screen.dart';
import 'package:fancake/features/auth/onboarding_data.dart';
import 'package:fancake/features/auth/onboarding_screens.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -57,7 +60,9 @@ void main() {
expect(find.byType(NavigationBar), findsOneWidget);
});
testWidgets('동의한 계정은 셸로 가는 동안 약관 화면이 한 프레임도 그려지지 않는다 — 10/6 콜드 스타트 깜빡임', (tester) async {
testWidgets('동의한 계정은 셸로 가는 동안 약관 화면이 한 프레임도 그려지지 않는다 — 10/6 콜드 스타트 깜빡임', (
tester,
) async {
// 서버 판정이 몇 프레임 걸리는 실기기처럼 늦춘다.
final (auth, _) = await boot(
tester,
@@ -148,8 +153,11 @@ void main() {
await tester.tap(find.text('완료'));
await tester.pumpAndSettle();
await tester.tap(find.text('국가/지역').first);
await tester.tap(
find.ancestor(of: find.text('거주 국가/지역'), matching: find.byType(InkWell)),
);
await tester.pumpAndSettle();
expect(find.text('거주 국가/지역 선택'), findsOneWidget);
await tester.scrollUntilVisible(
find.text('대한민국'),
300,
@@ -210,7 +218,34 @@ void main() {
await auth.signIn(SocialProvider.google, signUp: true);
await tester.pumpAndSettle();
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 {