From 40756d5c86a7e2156905b7f4c334ddbd10c6d850 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 04:47:37 +0900 Subject: [PATCH] =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20SDK=20?= =?UTF-8?q?=EB=8C=80=EA=B8=B0=20=EC=A0=9C=EA=B1=B0=20=C2=B7=20=EC=98=A8?= =?UTF-8?q?=EB=B3=B4=EB=94=A9=20=EA=B5=AD=EA=B0=80=20=EC=8B=9C=ED=8A=B8=20?= =?UTF-8?q?=C2=B7=20=ED=94=84=EB=A1=9C=ED=95=84=20=EB=A8=B8=EB=A6=AC?= =?UTF-8?q?=EC=A4=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/kr/co/fanuzu/app/MainActivity.kt | 25 ++++++ app/ios/Runner/AppDelegate.swift | 15 ++++ app/lib/core/i18n/app_locale.dart | 35 +++++++- app/lib/features/auth/auth_repository.dart | 18 ++-- app/lib/features/auth/onboarding_screens.dart | 88 +++++++++++++++---- app/lib/features/auth/signup_screens.dart | 10 ++- app/lib/features/planet/planet_data.dart | 7 +- app/lib/l10n/app_en.arb | 3 +- app/lib/l10n/app_es.arb | 3 +- app/lib/l10n/app_ja.arb | 3 +- app/lib/l10n/app_ko.arb | 3 +- app/lib/l10n/app_localizations.dart | 8 +- app/lib/l10n/app_localizations_en.dart | 5 +- app/lib/l10n/app_localizations_es.dart | 5 +- app/lib/l10n/app_localizations_ja.dart | 5 +- app/lib/l10n/app_localizations_ko.dart | 5 +- app/lib/l10n/app_localizations_zh.dart | 5 +- app/lib/l10n/app_zh.arb | 3 +- app/lib/l10n/remote_app_localizations.dart | 3 + app/test/auth_repository_test.dart | 30 ++++++- app/test/onboarding_test.dart | 41 ++++++++- 21 files changed, 277 insertions(+), 43 deletions(-) diff --git a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt index e7b6530..80b66a4 100644 --- a/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt +++ b/app/android/app/src/main/kotlin/kr/co/fanuzu/app/MainActivity.kt @@ -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("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) + } + } } diff --git a/app/ios/Runner/AppDelegate.swift b/app/ios/Runner/AppDelegate.swift index c30b367..596fb49 100644 --- a/app/ios/Runner/AppDelegate.swift +++ b/app/ios/Runner/AppDelegate.swift @@ -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) + } } } diff --git a/app/lib/core/i18n/app_locale.dart b/app/lib/core/i18n/app_locale.dart index a7a6d09..ae66dd7 100644 --- a/app/lib/core/i18n/app_locale.dart +++ b/app/lib/core/i18n/app_locale.dart @@ -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.delegate.isSupported(locale); @override - Future load(Locale locale) => SynchronousFuture( - appL10n = RemoteAppLocalizations(lookupAppLocalizations(locale), remote), - ); + Future 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 platformCountryNames = const {}; + +const _localeChannel = MethodChannel('kr.co.fanuzu.app/locale'); + +Future loadCountryNames(Locale locale) async { + try { + final pairs = await _localeChannel.invokeListMethod>( + '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); diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index 836044b..14375f5 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -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); diff --git a/app/lib/features/auth/onboarding_screens.dart b/app/lib/features/auth/onboarding_screens.dart index ad402fd..93688b6 100644 --- a/app/lib/features/auth/onboarding_screens.dart +++ b/app/lib/features/auth/onboarding_screens.dart @@ -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 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 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 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 ' diff --git a/app/lib/features/auth/signup_screens.dart b/app/lib/features/auth/signup_screens.dart index c18e45e..294b054 100644 --- a/app/lib/features/auth/signup_screens.dart +++ b/app/lib/features/auth/signup_screens.dart @@ -126,7 +126,15 @@ class _TermsScreenState extends State { @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( diff --git a/app/lib/features/planet/planet_data.dart b/app/lib/features/planet/planet_data.dart index f860981..9bb804c 100644 --- a/app/lib/features/planet/planet_data.dart +++ b/app/lib/features/planet/planet_data.dart @@ -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; } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index 46265dd..60d6718 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -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", diff --git a/app/lib/l10n/app_es.arb b/app/lib/l10n/app_es.arb index 9517bb0..ba06ece 100644 --- a/app/lib/l10n/app_es.arb +++ b/app/lib/l10n/app_es.arb @@ -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ó", diff --git a/app/lib/l10n/app_ja.arb b/app/lib/l10n/app_ja.arb index b0c268e..685375e 100644 --- a/app/lib/l10n/app_ja.arb +++ b/app/lib/l10n/app_ja.arb @@ -856,7 +856,8 @@ "onbIntro3Body": "みんなでイクリプスを防ぎ、キャンペーンで\nアーティストを応援しましょう。", "onbProfileTitle": "プロフィールを設定してください", "onbNicknameHint": "2〜20文字・他の人と同じでも大丈夫です", - "onbCountry": "国・地域", + "onbCountry": "居住国・地域", + "onbCountrySheetTitle": "居住国・地域を選択", "onbLanguage": "表示言語", "onbReferral": "紹介コード(任意)", "onbReferralHint": "友だちからもらったコードがあれば入力してください", diff --git a/app/lib/l10n/app_ko.arb b/app/lib/l10n/app_ko.arb index 426dce7..bf852c8 100644 --- a/app/lib/l10n/app_ko.arb +++ b/app/lib/l10n/app_ko.arb @@ -1799,7 +1799,8 @@ "onbIntro3Body": "이클립스를 함께 막고 캠페인으로\n아티스트를 응원하세요.", "onbProfileTitle": "프로필을 설정해 주세요", "onbNicknameHint": "2~20자 · 다른 사람과 같아도 괜찮아요", - "onbCountry": "국가/지역", + "onbCountry": "거주 국가/지역", + "onbCountrySheetTitle": "거주 국가/지역 선택", "onbLanguage": "표시 언어", "onbReferral": "추천인 코드 (선택)", "onbReferralHint": "친구에게 받은 코드가 있으면 입력해 주세요", diff --git a/app/lib/l10n/app_localizations.dart b/app/lib/l10n/app_localizations.dart index 36aba94..39f1b9c 100644 --- a/app/lib/l10n/app_localizations.dart +++ b/app/lib/l10n/app_localizations.dart @@ -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: diff --git a/app/lib/l10n/app_localizations_en.dart b/app/lib/l10n/app_localizations_en.dart index c8a893e..1e4a38c 100644 --- a/app/lib/l10n/app_localizations_en.dart +++ b/app/lib/l10n/app_localizations_en.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_es.dart b/app/lib/l10n/app_localizations_es.dart index dc1c0b7..613e516 100644 --- a/app/lib/l10n/app_localizations_es.dart +++ b/app/lib/l10n/app_localizations_es.dart @@ -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'; diff --git a/app/lib/l10n/app_localizations_ja.dart b/app/lib/l10n/app_localizations_ja.dart index 8b2a4ed..d3424b2 100644 --- a/app/lib/l10n/app_localizations_ja.dart +++ b/app/lib/l10n/app_localizations_ja.dart @@ -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 => '表示言語'; diff --git a/app/lib/l10n/app_localizations_ko.dart b/app/lib/l10n/app_localizations_ko.dart index 0d247b1..3143334 100644 --- a/app/lib/l10n/app_localizations_ko.dart +++ b/app/lib/l10n/app_localizations_ko.dart @@ -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 => '표시 언어'; diff --git a/app/lib/l10n/app_localizations_zh.dart b/app/lib/l10n/app_localizations_zh.dart index 7f77df8..383f33e 100644 --- a/app/lib/l10n/app_localizations_zh.dart +++ b/app/lib/l10n/app_localizations_zh.dart @@ -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 => '显示语言'; diff --git a/app/lib/l10n/app_zh.arb b/app/lib/l10n/app_zh.arb index 9fef03b..a26c9fb 100644 --- a/app/lib/l10n/app_zh.arb +++ b/app/lib/l10n/app_zh.arb @@ -856,7 +856,8 @@ "onbIntro3Body": "一起阻止日食,通过活动\n为艺人应援。", "onbProfileTitle": "请设置个人资料", "onbNicknameHint": "2~20个字符·可与他人重复", - "onbCountry": "国家/地区", + "onbCountry": "居住国家/地区", + "onbCountrySheetTitle": "选择居住国家/地区", "onbLanguage": "显示语言", "onbReferral": "推荐码(选填)", "onbReferralHint": "如有朋友分享的推荐码请输入", diff --git a/app/lib/l10n/remote_app_localizations.dart b/app/lib/l10n/remote_app_localizations.dart index a293b84..e1ba17f 100644 --- a/app/lib/l10n/remote_app_localizations.dart +++ b/app/lib/l10n/remote_app_localizations.dart @@ -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; diff --git a/app/test/auth_repository_test.dart b/app/test/auth_repository_test.dart index ab760d9..8336aab 100644 --- a/app/test/auth_repository_test.dart +++ b/app/test/auth_repository_test.dart @@ -228,7 +228,32 @@ void main() { releaseMode: true, ); - await expectLater(auth.signOut(), throwsA(isA())); + // 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 signOut() async { signOutCalls += 1; + if (signOutHangs) return Completer().future; if (signOutError case final error?) throw error; } } diff --git a/app/test/onboarding_test.dart b/app/test/onboarding_test.dart index 5ea58ec..9f77c5b 100644 --- a/app/test/onboarding_test.dart +++ b/app/test/onboarding_test.dart @@ -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 {