From 1539ae283eb4f73c58b005f9aefb67c4bca11ae6 Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 04:01:15 +0900 Subject: [PATCH] =?UTF-8?q?FPI=20=EB=9E=AD=ED=82=B9=20=EB=82=B4=20?= =?UTF-8?q?=ED=96=89=EC=84=B1=C2=B7=EB=82=B4=20=EA=B5=AD=EA=B0=80=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=20=C2=B7=20=EC=BD=9C=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=ED=8A=B8=20=EC=95=BD=EA=B4=80=20=EA=B9=9C=EB=B9=A1?= =?UTF-8?q?=EC=9E=84=20=C2=B7=20=EB=A1=9C=EA=B7=B8=EC=95=84=EC=9B=83=20?= =?UTF-8?q?=EC=83=81=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/lib/core/push/push_messaging.dart | 12 ++-- app/lib/core/router/app_router.dart | 8 ++- app/lib/core/ui/app_dialog.dart | 57 +++++++++++++++---- app/lib/features/auth/auth_repository.dart | 4 +- app/lib/features/my/settings_screen.dart | 42 ++++++++------ .../planet/planet_repository_firestore.dart | 41 +++++++++---- app/test/backend_repository_test.dart | 30 +++++++++- app/test/f6_screens_test.dart | 34 +++++++++++ app/test/onboarding_test.dart | 22 ++++++- 9 files changed, 205 insertions(+), 45 deletions(-) diff --git a/app/lib/core/push/push_messaging.dart b/app/lib/core/push/push_messaging.dart index dad4c42..1f0d41e 100644 --- a/app/lib/core/push/push_messaging.dart +++ b/app/lib/core/push/push_messaging.dart @@ -151,11 +151,13 @@ class PushMessaging { /// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨 /// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다. Future clearOnSignOut() async { - for (final fandomId in await _resolveFandomIds()) { - for (final topic in PushTopic.values) { - await _transport.unsubscribeFromTopic(topic.topicFor(fandomId)); - } - } + // 해제는 한꺼번에 보낸다. 가입 행성 × 구분 수만큼 차례로 기다리면 그것만으로 + // 로그아웃이 수 초 걸린다. + await Future.wait([ + for (final fandomId in await _resolveFandomIds()) + for (final topic in PushTopic.values) + _transport.unsubscribeFromTopic(topic.topicFor(fandomId)), + ]); final uid = _backend.uid; final token = await _transport.getToken(); if (uid != null && token != null && token.isNotEmpty) { diff --git a/app/lib/core/router/app_router.dart b/app/lib/core/router/app_router.dart index aa6e9e2..ecb5c13 100644 --- a/app/lib/core/router/app_router.dart +++ b/app/lib/core/router/app_router.dart @@ -136,7 +136,13 @@ GoRouter buildRouter(AuthRepository auth, {Repositories? repositories}) { path: onboardingPath, builder: (_, _) => ListenableBuilder( listenable: auth, - builder: (_, _) => auth.onboarding == OnboardingGate.checking + // 약관 동의가 **필요하다고 확인된 뒤에만** 온보딩을 그린다. 판정 중이거나 + // 이미 동의한 계정이면 셸로 넘어가는 동안 스피너(스플래시)만 보인다 — + // `!= checking` 으로 가르면 판정이 done 으로 바뀌는 순간 약관 화면이 한 + // 프레임 그려진 채 페이드로 빠졌다(10/6 실기기 콜드 스타트 깜빡임). + // 온보딩을 막 마친 경우(justSignedUp)는 가입 완료로 넘어갈 때까지 그대로 둔다. + builder: (_, _) => + auth.onboarding != OnboardingGate.required && !auth.justSignedUp ? const Scaffold(body: Center(child: CircularProgressIndicator())) : OnboardingFlow( auth: auth, diff --git a/app/lib/core/ui/app_dialog.dart b/app/lib/core/ui/app_dialog.dart index 22cff03..2f98682 100644 --- a/app/lib/core/ui/app_dialog.dart +++ b/app/lib/core/ui/app_dialog.dart @@ -10,7 +10,7 @@ import '../theme/tokens.dart'; /// 단일 버튼 알림(Figma `511:86714` · `554:23103`)은 실행 버튼 하나가 폭을 다 쓴다. /// /// [content] 는 입력 칸이 있는 창(반려 사유 · 인증 제출 등)에서 문구 대신 넣는다. -class AppDialog extends StatelessWidget { +class AppDialog extends StatefulWidget { const AppDialog({ super.key, required this.title, @@ -18,6 +18,7 @@ class AppDialog extends StatelessWidget { this.content, this.cancelLabel, required this.confirmLabel, + this.onConfirm, }); final String title; @@ -28,6 +29,17 @@ class AppDialog extends StatelessWidget { final String? cancelLabel; final String confirmLabel; + /// 실행 버튼을 누르면 창을 닫기 **전에** 돈다. 도는 동안 실행 버튼은 진행 중 + /// 표시로 바뀌고 두 버튼 모두 눌리지 않는다(로그아웃처럼 정리가 걸리는 실행). + final Future Function()? onConfirm; + + @override + State createState() => _AppDialogState(); +} + +class _AppDialogState extends State { + bool _busy = false; + static const _shape = RoundedRectangleBorder( borderRadius: BorderRadius.all(Radius.circular(AppRadius.sm)), ); @@ -55,21 +67,42 @@ class AppDialog extends StatelessWidget { filled ? AppColors.onCosmicAccent : AppColors.onCosmic, ).copyWith(height: 1), ); - void pop() => Navigator.of(context).pop(value); + Future pop() async { + final run = widget.onConfirm; + if (value && run != null) { + setState(() => _busy = true); + try { + await run(); + } finally { + if (mounted) setState(() => _busy = false); + } + } + if (context.mounted) Navigator.of(context).pop(value); + } + return Expanded( child: filled ? FilledButton( - onPressed: pop, + onPressed: _busy ? null : pop, style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(48), padding: const EdgeInsets.symmetric(horizontal: 12), backgroundColor: AppColors.cosmicAccent, + disabledBackgroundColor: AppColors.cosmicAccent, shape: _shape, ), - child: text, + child: _busy + ? const SizedBox.square( + dimension: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: AppColors.onCosmicAccent, + ), + ) + : text, ) : OutlinedButton( - onPressed: pop, + onPressed: _busy ? null : pop, style: OutlinedButton.styleFrom( minimumSize: const Size.fromHeight(48), padding: const EdgeInsets.symmetric(horizontal: 12), @@ -98,11 +131,11 @@ class AppDialog extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - title, + widget.title, textAlign: TextAlign.center, style: _text(16, FontWeight.w600, AppColors.onCosmic), ), - if (body case final b?) ...[ + if (widget.body case final b?) ...[ const SizedBox(height: 16), Text( b, @@ -111,7 +144,7 @@ class AppDialog extends StatelessWidget { .copyWith(height: 1.4), ), ], - if (content case final c?) ...[ + if (widget.content case final c?) ...[ const SizedBox(height: 16), c, ], @@ -122,11 +155,11 @@ class AppDialog extends StatelessWidget { const SizedBox(height: 16), Row( children: [ - if (cancelLabel case final c?) ...[ + if (widget.cancelLabel case final c?) ...[ button(c, false, filled: false), const SizedBox(width: 8), ], - button(confirmLabel, true, filled: true), + button(widget.confirmLabel, true, filled: true), ], ), ], @@ -146,16 +179,20 @@ Future showConfirmDialog( Widget? content, String? cancelLabel, String? confirmLabel, + Future Function()? onConfirm, }) async { final r = await showDialog( context: context, barrierColor: _barrier, + // 실행이 도는 동안 바깥을 눌러 닫히면 진행 중 표시가 사라진 채 실행만 남는다. + barrierDismissible: onConfirm == null, builder: (context) => AppDialog( title: title, body: body, content: content, cancelLabel: cancelLabel ?? context.l10n.commonCancel, confirmLabel: confirmLabel ?? context.l10n.commonChange, + onConfirm: onConfirm, ), ); return r ?? false; diff --git a/app/lib/features/auth/auth_repository.dart b/app/lib/features/auth/auth_repository.dart index e2e8187..836044b 100644 --- a/app/lib/features/auth/auth_repository.dart +++ b/app/lib/features/auth/auth_repository.dart @@ -243,7 +243,9 @@ class AuthRepository extends ChangeNotifier { try { await Future.wait([ - if (provider != null) provider.signOut(), + // SDK 로그아웃(Google · Kakao 등)이 응답 없이 멈춰도 로컬 로그아웃은 한다. + if (provider != null) + provider.signOut().timeout(const Duration(seconds: 3)), if (_session != null) _session.signOut(), ], eagerError: false); } finally { diff --git a/app/lib/features/my/settings_screen.dart b/app/lib/features/my/settings_screen.dart index 2e84259..b746a95 100644 --- a/app/lib/features/my/settings_screen.dart +++ b/app/lib/features/my/settings_screen.dart @@ -216,22 +216,7 @@ class _SettingsScreenState extends State { MyLinkRow( label: l10n.settingsLogout, color: MyColors.danger, - onTap: () => _confirm( - title: l10n.settingsLogoutConfirm, - action: l10n.settingsLogout, - // 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 - // `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. - // 정리가 실패해도(토픽 해제 · 토큰 쓰기 네트워크 오류) 로그아웃은 한다 — 예외가 - // 그대로 올라가면 signOut 이 돌지 않아 첫 탭이 무시된 것처럼 보인다(10/6 실기기 1회). - onConfirm: () async { - try { - await widget.push?.clearOnSignOut(); - } on Object catch (error) { - debugPrint('[Settings] push cleanup failed: ${error.runtimeType}'); - } - await widget.auth?.signOut(); - }, - ), + onTap: _logout, ), const MyDivider(), MyLinkRow( @@ -384,6 +369,31 @@ class _SettingsScreenState extends State { Future _notice(String message) => showAlertDialog(context, message); /// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`. + /// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 `isSelf(uid)` 로 막아, + /// 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. 정리는 확인 버튼이 진행 중으로 바뀐 + /// 채 돌고, 실패하든 [_cleanupTimeout] 안에 끝나지 않든 로그아웃은 한다 — 토픽 해제 · + /// 토큰 쓰기가 응답 없이 멈추면 signOut 에 닿지 못해 첫 확인이 무시된 것처럼 + /// 보였다(10/6 실기기 2회). + Future _logout() async { + final l10n = context.l10n; + final ok = await showConfirmDialog( + context, + l10n.settingsLogoutConfirm, + confirmLabel: l10n.settingsLogout, + onConfirm: () async { + try { + await widget.push?.clearOnSignOut().timeout(_cleanupTimeout); + } on Object catch (error) { + debugPrint('[Settings] push cleanup skipped: ${error.runtimeType}'); + } + }, + ); + if (ok) await widget.auth?.signOut(); + } + + /// 로그아웃 전 정리의 상한. 넘기면 정리를 두고 로그아웃한다. + static const _cleanupTimeout = Duration(seconds: 3); + Future _confirm({ required String title, String? body, diff --git a/app/lib/features/planet/planet_repository_firestore.dart b/app/lib/features/planet/planet_repository_firestore.dart index ed319b2..8de6b4d 100644 --- a/app/lib/features/planet/planet_repository_firestore.dart +++ b/app/lib/features/planet/planet_repository_firestore.dart @@ -287,30 +287,52 @@ class FirestorePlanetRepository implements PlanetRepository { // 서버 값이 없을 때의 메움도 1·1·3 공동 순위다(C-19). 실값(반올림 전)으로 비교하고 // 동점 내부는 fandomId 오름차순 — Firestore 의 내림차순 정렬은 동점을 ID // 내림차순으로 세우므로 여기서 다시 세운다. + final myId = await _myPlanetId(); final rows = [ for (final (doc, rank) in rankWithTies( snap.docs, (d) => _raw(d.data(), axis), (d) => d.id, )) - _planetRow(doc.data(), id: doc.id, axis: axis, fallbackRank: rank), + _planetRow( + doc.data(), + id: doc.id, + axis: axis, + fallbackRank: rank, + myId: myId, + ), ]; final mine = rows.where((r) => r.isMine).firstOrNull; - if (mine != null) return FpiRanking(rows: rows, mine: mine); + if (mine != null || myId == null) return FpiRanking(rows: rows, mine: mine); // 상위 [limit] 밖이면 내 행성 문서를 한 번 더 읽어 「소속 행성 지표 랭킹 // 영역」(Figma `546:14628`)을 채운다. 사용자 랭킹의 count() 폴백과 같은 // 취지다 — 내 행성이 몇 등이든 그 영역은 떠야 한다. - final me = (await _backend.db.doc('fandoms/$fandomId').get()).data(); + final me = (await _backend.db.doc('fandoms/$myId').get()).data(); if (me == null) return FpiRanking(rows: rows); return FpiRanking( rows: rows, // 서버 순위가 없으면 목록 밖이라는 것만 아는 셈이라 0 으로 둔다. - mine: _planetRow(me, id: fandomId, axis: axis, fallbackRank: 0), + mine: _planetRow(me, id: myId, axis: axis, fallbackRank: 0, myId: myId), ); } + /// 「내 행성」(소속 행성) — **실제 가입 여부로** 정한다. 보고 있는 행성에 + /// 가입했으면 그 행성, 아니면 대표 행성(`users.fandomId`), 둘 다 없으면 null. + /// 보고 있는 행성을 그대로 내 행성으로 두면 미가입 행성 랭킹이 그 행성을 + /// 「(나)」로 표시한다(10/6 실기기 C-7). + Future _myPlanetId() async { + final uid = _backend.requireUid; + final joined = + (await _backend.db.doc('users/$uid/memberships/$fandomId').get()) + .exists; + if (joined) return fandomId; + final primary = (await _backend.db.doc('users/$uid').get()).data()?[ + 'fandomId']; + return primary is String && primary.isNotEmpty ? primary : null; + } + /// 축의 실제 산정값(반올림 전). 순위 비교에만 쓴다 — 표시는 [SnapshotReader.intOr]. static num _raw(Map data, String axis) { final v = _map(data, 'fpiAxes')[axis]; @@ -323,6 +345,7 @@ class FirestorePlanetRepository implements PlanetRepository { required String id, required String axis, required int fallbackRank, + required String? myId, }) { final rank = _map(data, 'fpiRanks').intOr(axis, fallbackRank); return FpiRankRow( @@ -332,7 +355,7 @@ class FirestorePlanetRepository implements PlanetRepository { value: _map(data, 'fpiAxes').intOr(axis), // 순위 동향 = 직전 순위 − 현재 순위. 둘 다 서버가 적은 값이다. change: _map(data, 'fpiRanksPrev').intOr(axis, rank) - rank, - isMine: id == fandomId, + isMine: id == myId, planetType: data['planetType'] is String ? data['planetType'] as String : null, colorKey: data['colorKey'] is String ? data['colorKey'] as String : null, ); @@ -485,14 +508,12 @@ class FirestorePlanetRepository implements PlanetRepository { if (rows.isEmpty) { return const FpiRanking(rows: [], mockNote: '국가별 집계가 아직 없습니다'); } - // 내 국가는 멤버 문서에 비정규화된 `countryCode` 다(집계가 세는 필드와 같다). + // 내 국가는 사용자 프로필 `users.countryCode` 다. 보고 있는 행성의 멤버 + // 문서에서 읽으면 미가입 행성에서는 늘 「미지정」이 된다(10/6 실기기 C-7). // 「나의 랭킹 영역」(546:14713)은 국가별 필터에서도 떠야 하므로 내 국가 행을 // 찾아 올린다. final me = - (await _backend.db - .doc('fandoms/$fandomId/members/${_backend.requireUid}') - .get()) - .data(); + (await _backend.db.doc('users/${_backend.requireUid}').get()).data(); final myCode = (me ?? const {}).strOr('countryCode', 'ZZ'); final list = []; diff --git a/app/test/backend_repository_test.dart b/app/test/backend_repository_test.dart index c06e058..799fc6d 100644 --- a/app/test/backend_repository_test.dart +++ b/app/test/backend_repository_test.dart @@ -455,9 +455,11 @@ void main() { }, 'createdAt': DateTime(2026, 9, 1, 0), }); + // 내 행성 · 내 국가는 가입 목록과 사용자 프로필에서 읽는다. + await db.doc('users/$_uid/memberships/bts').set({'fandomId': 'bts'}); + await db.doc('users/$_uid').set({'countryCode': 'KR'}, SetOptions(merge: true)); await db.doc('fandoms/bts/members/$_uid').set({ 'nickname': '나', - // 국가 랭킹의 「나의 랭킹 영역」이 읽는 비정규화 국가 코드. 'countryCode': 'KR', 'topPercentByAxis': {'bpm': 3, 'health': 12}, 'fpiAxes': {'bpm': 2740, 'referral': 3180}, @@ -582,6 +584,32 @@ void main() { expect(ranking.mine!.change, 1); }); + test('미가입 행성에서 연 랭킹은 그 행성이 아니라 내 대표 행성을 「나」로 둔다 — 10/6 C-7', () async { + final db = await seedPlanet(); + // nj 는 가입하지 않은 행성이다. 대표 행성은 bts(users.fandomId). + final ranking = await FirestorePlanetRepository(_backend(db), fandomId: 'nj') + .planetRanking(metric: FpiMetric.bpm); + expect(ranking.rows.where((r) => r.isMine).map((r) => r.id), ['bts']); + expect(ranking.mine!.id, 'bts'); + // 국가는 보고 있는 행성의 멤버 문서가 아니라 내 프로필 국가다. + await db.doc('rankings/country-bpm').set({ + 'rows': [{'code': 'KR', 'value': 1, 'change': 0}], + }); + final country = await FirestorePlanetRepository(_backend(db), fandomId: 'nj') + .countryRanking(metric: FpiMetric.bpm); + expect(country.mine!.id, 'KR'); + }); + + test('가입한 행성이 하나도 없으면 「나」 행이 없다', () async { + final db = await seedPlanet(); + await db.doc('users/$_uid/memberships/bts').delete(); + await db.doc('users/$_uid').update({'fandomId': ''}); + final ranking = await FirestorePlanetRepository(_backend(db), fandomId: 'nj') + .planetRanking(metric: FpiMetric.bpm); + expect(ranking.rows.any((r) => r.isMine), isFalse); + expect(ranking.mine, isNull); + }); + test('랭킹은 마지막 갱신 시각과 갱신 주기를 함께 준다 — 별첨 1 ② 예외처리', () async { final db = await seedPlanet(); final at = DateTime.utc(2026, 10, 7, 3, 4); diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart index 27f3190..3b0696e 100644 --- a/app/test/f6_screens_test.dart +++ b/app/test/f6_screens_test.dart @@ -1030,6 +1030,31 @@ void main() { await session.close(); }); + testWidgets('푸시 정리가 멈춰도 상한 안에 첫 확인으로 로그아웃된다 — 확인 버튼은 진행 중 (10/6 r3)', (tester) async { + final session = _LogoutSession( + const AuthAccount(uid: 'u1', provider: SocialProvider.google), + ); + final auth = AuthRepository(providers: const [], session: session, releaseMode: true); + await pumpWide( + tester, + SettingsScreen(repository: SampleMyRepository(), auth: auth, push: _HangingPush()), + ); + await tester.pumpAndSettle(); + await tester.scrollUntilVisible(find.text('로그아웃'), 220, scrollable: find.byType(Scrollable).first); + await tester.tap(find.text('로그아웃')); + await tester.pumpAndSettle(); + await tester.tap(find.widgetWithText(FilledButton, '로그아웃')); + await tester.pump(); + // 정리가 도는 동안 확인 버튼은 진행 중 표시다. + expect(find.byType(CircularProgressIndicator), findsOneWidget); + expect(session.signOutCalls, 0); + await tester.pump(const Duration(seconds: 4)); + await tester.pumpAndSettle(); + expect(session.signOutCalls, 1); + auth.dispose(); + await session.close(); + }); + testWidgets('로그아웃을 확인하면 세션이 끊긴다 (F6 · 554:22903)', (tester) async { final session = _LogoutSession( const AuthAccount(uid: 'u1', provider: SocialProvider.google), @@ -1074,6 +1099,15 @@ class _FailingPush implements PushMessaging { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } +/// 푸시 정리가 응답 없이 멈추는 경계 — 토픽 해제가 끝나지 않는다. +class _HangingPush implements PushMessaging { + @override + Future clearOnSignOut() => Completer().future; + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다. final class _LogoutSession implements AuthSession { _LogoutSession(this._current); diff --git a/app/test/onboarding_test.dart b/app/test/onboarding_test.dart index fa9e4d6..5ea58ec 100644 --- a/app/test/onboarding_test.dart +++ b/app/test/onboarding_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:fancake/app.dart'; import 'package:fancake/core/data/repositories.dart'; import 'package:fancake/core/firebase/firebase_boot.dart'; @@ -5,6 +7,7 @@ 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_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -20,6 +23,7 @@ void main() { WidgetTester tester, { required bool needs, bool intro = false, + Duration checkDelay = Duration.zero, }) async { tester.view.physicalSize = const Size(1080, 2400); tester.view.devicePixelRatio = 2; @@ -28,7 +32,7 @@ void main() { final auth = AuthRepository( providers: [_Provider()], releaseMode: true, - needsOnboarding: (_) async => needs, + needsOnboarding: (_) => Future.delayed(checkDelay, () => needs), introPending: intro, ); addTearDown(auth.dispose); @@ -53,6 +57,22 @@ void main() { expect(find.byType(NavigationBar), findsOneWidget); }); + testWidgets('동의한 계정은 셸로 가는 동안 약관 화면이 한 프레임도 그려지지 않는다 — 10/6 콜드 스타트 깜빡임', (tester) async { + // 서버 판정이 몇 프레임 걸리는 실기기처럼 늦춘다. + final (auth, _) = await boot( + tester, + needs: false, + checkDelay: const Duration(milliseconds: 200), + ); + unawaited(auth.signIn(SocialProvider.google)); + for (var i = 0; i < 60; i++) { + await tester.pump(const Duration(milliseconds: 16)); + expect(find.byType(OnboardingFlow), findsNothing); + } + await tester.pumpAndSettle(); + expect(find.byType(NavigationBar), findsOneWidget); + }); + testWidgets('약관 → 프로필 → 관심 아티스트 → 행성 가입 → 권한 → 가입 완료 → 셸', (tester) async { final (auth, repo) = await boot(tester, needs: true); await auth.signIn(SocialProvider.google);