FPI 랭킹 내 행성·내 국가 판정 · 콜드 스타트 약관 깜빡임 · 로그아웃 상한

This commit is contained in:
Sn-Kinos
2026-10-06 04:01:15 +09:00
parent abe5f1ae50
commit 1539ae283e
9 changed files with 205 additions and 45 deletions
+7 -5
View File
@@ -151,11 +151,13 @@ class PushMessaging {
/// 토픽 해지를 먼저 하는 이유는 실패 순서다. 토큰 삭제가 실패해도 구독은 끊겨
/// 알림이 멈춘다. 반대로 두면 토큰만 지워진 채 토픽 알림이 계속 온다.
Future<void> 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) {
+7 -1
View File
@@ -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,
+47 -10
View File
@@ -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<void> Function()? onConfirm;
@override
State<AppDialog> createState() => _AppDialogState();
}
class _AppDialogState extends State<AppDialog> {
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<void> 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<bool> showConfirmDialog(
Widget? content,
String? cancelLabel,
String? confirmLabel,
Future<void> Function()? onConfirm,
}) async {
final r = await showDialog<bool>(
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;
+3 -1
View File
@@ -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 {
+26 -16
View File
@@ -216,22 +216,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
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<SettingsScreen> {
Future<void> _notice(String message) => showAlertDialog(context, message);
/// 확인 팝업(취소 · 실행) — 시안 `554:24669` · `374:14364` · `554:23257`.
/// 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 `isSelf(uid)` 로 막아,
/// 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. 정리는 확인 버튼이 진행 중으로 바뀐
/// 채 돌고, 실패하든 [_cleanupTimeout] 안에 끝나지 않든 로그아웃은 한다 — 토픽 해제 ·
/// 토큰 쓰기가 응답 없이 멈추면 signOut 에 닿지 못해 첫 확인이 무시된 것처럼
/// 보였다(10/6 실기기 2회).
Future<void> _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<bool> _confirm({
required String title,
String? body,
@@ -287,30 +287,52 @@ class FirestorePlanetRepository implements PlanetRepository {
// 서버 값이 없을 때의 메움도 1·1·3 공동 순위다(C-19). 실값(반올림 전)으로 비교하고
// 동점 내부는 fandomId 오름차순 — Firestore 의 내림차순 정렬은 동점을 ID
// 내림차순으로 세우므로 여기서 다시 세운다.
final myId = await _myPlanetId();
final rows = <FpiRankRow>[
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<String?> _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<String, dynamic> 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 <String, dynamic>{}).strOr('countryCode', 'ZZ');
final list = <FpiRankRow>[];
+29 -1
View File
@@ -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);
+34
View File
@@ -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<void> clearOnSignOut() => Completer<void>().future;
@override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
}
/// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다.
final class _LogoutSession implements AuthSession {
_LogoutSession(this._current);
+21 -1
View File
@@ -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);