diff --git a/app/lib/app.dart b/app/lib/app.dart index 91e15cb..5ace112 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -12,7 +12,6 @@ import 'core/push/push_messaging.dart'; import 'core/router/app_router.dart'; import 'features/home/home_screen.dart' show homeRefreshSignal; import 'core/theme/app_theme.dart'; -import 'core/theme/tokens.dart'; import 'features/auth/auth_repository.dart'; import 'features/campaign/campaign_detail_screen.dart'; import 'features/notification/notification_screen.dart'; @@ -242,19 +241,11 @@ class _FancakeAppState extends State with WidgetsBindingObserver { screenFor: _notificationScreen, child: MaterialApp.router( title: 'FANUZU', - // 하단 시스템 내비게이션 바(Android 15 edge-to-edge · iOS 홈 표시줄) 위로 모든 화면을 올린다. - // 본문이 `ListView(padding: …)` 처럼 하단 여백을 직접 정하면 안전 영역이 빠져 마지막 칸 · - // 하단 버튼이 바에 가렸다(10/6 실기기 — 약관 「이전 · 완료」 · 퀘스트 생성 「총 예치 · 수수료」 · - // 설정 「서비스 탈퇴」). 화면마다 고치지 않고 여기서 한 번에. 띠는 앱 바탕색이다. - builder: (context, child) => ColoredBox( - color: AppColors.cosmicVoid, - child: SafeArea( - top: false, - left: false, - right: false, - child: AdaptivePlanetEffects(child: child ?? const SizedBox()), - ), - ), + // 루트에서 하단 안전 영역을 잘라 내지 않는다 — 잘라 내면 바 높이만큼 cosmicVoid 띠가 생기고 + // 바텀시트가 화면 끝까지 내려오지 못한다(10/6 실기기 · Figma 는 하단까지 화면 배경). + // 하단 여백을 직접 정하는 화면이 `withBottomInset`(core/ui/bottom_inset.dart)으로 바 높이를 더한다. + builder: (context, child) => + AdaptivePlanetEffects(child: child ?? const SizedBox()), debugShowCheckedModeBanner: false, scaffoldMessengerKey: _messengerKey, locale: _locale.locale, diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index 220a990..c2f9fce 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -188,6 +188,8 @@ ThemeData buildAppTheme() { snackBarTheme: SnackBarThemeData( backgroundColor: AppColors.cosmicSurfaceRaised, contentTextStyle: _style(AppType.bodyM, AppColors.onCosmic), + // 「보기」 등 액션 글자 — 기본(inversePrimary)은 어두운 바탕에 묻힌다(10/6 실기기). 강조색 토큰. + actionTextColor: AppColors.cosmicAccent, behavior: SnackBarBehavior.floating, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.md), diff --git a/app/lib/core/ui/bottom_inset.dart b/app/lib/core/ui/bottom_inset.dart new file mode 100644 index 0000000..a2fa9fc --- /dev/null +++ b/app/lib/core/ui/bottom_inset.dart @@ -0,0 +1,9 @@ +import 'package:flutter/widgets.dart'; + +/// [padding] 아래에 하단 시스템 바(Android 내비게이션 바 · iOS 홈 표시줄) 높이를 더한다. +/// +/// 화면이 스크롤 여백을 직접 정하면(`ListView(padding: …)`) 안전 영역이 빠져 마지막 칸이 바에 가린다. +/// 앱 루트에서 한 번에 올리면 바 높이만큼 띠가 생기고 바텀시트가 화면 끝까지 내려오지 못해 +/// (10/6 실기기 · Figma 는 하단까지 화면 배경), 여백을 직접 정하는 화면이 이걸로 더한다. +EdgeInsets withBottomInset(BuildContext context, EdgeInsets padding) => + padding.copyWith(bottom: padding.bottom + MediaQuery.paddingOf(context).bottom); diff --git a/app/lib/features/auth/social_providers.dart b/app/lib/features/auth/social_providers.dart index dcddc05..0c78f70 100644 --- a/app/lib/features/auth/social_providers.dart +++ b/app/lib/features/auth/social_providers.dart @@ -397,6 +397,12 @@ final class KakaoSignInProvider implements SocialAuthProvider { if (error.error == kakao.AuthErrorCause.accessDenied) { return const AuthCancelled(); } + // 카카오톡 왕복 뒤 인가 코드 · 토큰 단계 실패(KOE · misconfigured 등). 원인을 로그로만 남긴다 — + // 10/6 릴리스 빌드 E-AUTH-03 은 이 분기로 들어와 아무 로그도 남지 않았다. + debugPrint('[KakaoSignInProvider] auth failed: ${error.error} ${error.errorDescription}'); + return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); + } on kakao.KakaoException catch (error) { + debugPrint('[KakaoSignInProvider] sdk error: $error'); return AuthFailed('E-AUTH-03', appL10n.authSocialFailed); } on FirebaseFunctionsException catch (error) { final details = error.details; diff --git a/app/lib/features/campaign/campaign_create_screen.dart b/app/lib/features/campaign/campaign_create_screen.dart index 809408d..142325b 100644 --- a/app/lib/features/campaign/campaign_create_screen.dart +++ b/app/lib/features/campaign/campaign_create_screen.dart @@ -10,6 +10,7 @@ import '../home/home_screen.dart' show formatPop; import 'campaign_data.dart'; import 'widgets/campaign_period_sheet.dart'; import 'widgets/campaign_style.dart'; +import '../../core/ui/bottom_inset.dart'; /// 새 캠페인 — 화면 대조 원장 F5 · Figma `287:16991`. /// @@ -141,7 +142,7 @@ class _CampaignCreateScreenState extends State { ], ), body: ListView( - padding: const EdgeInsets.only(bottom: AppSpace.s4), + padding: withBottomInset(context, const EdgeInsets.only(bottom: AppSpace.s4)), children: [ // 사진·영상 등록은 캠페인 문서에 필드가 없어 아직 연결하지 않는다(시안 자리만). Container( diff --git a/app/lib/features/home/home_repository_firestore.dart b/app/lib/features/home/home_repository_firestore.dart index 5ee255a..e4f19e7 100644 --- a/app/lib/features/home/home_repository_firestore.dart +++ b/app/lib/features/home/home_repository_firestore.dart @@ -4,12 +4,14 @@ import 'package:cloud_firestore/cloud_firestore.dart' import '../../core/data/backend.dart'; import '../../core/data/invite_code.dart'; import '../../core/data/server_clock.dart'; -import '../../core/ui/planet_25d.dart' show planetColors, planetColorsFrom, setPlanetColorMaster; +import '../../core/ui/planet_25d.dart' + show planetColors, planetColorsFrom, setPlanetColorMaster; import 'package:flutter/painting.dart' show Color; import 'home_data.dart'; import '../inside/inside_repository_firestore.dart' show insideQuestionFrom; import '../quest/quest_data.dart' show QuestType; -import '../quest/quest_repository_firestore.dart' show openTimeAttack, questTitle, questToday; +import '../quest/quest_repository_firestore.dart' + show openTimeAttack, questTitle, questToday; import '../../core/i18n/app_locale.dart'; /// 홈의 실 서버 구현 — 화면 대조 원장 F1. @@ -117,23 +119,6 @@ class FirestoreHomeRepository implements HomeRepository { .where('active', isEqualTo: true) .limit(30) .get(); - // 긴급 돌발 타임어택(FR-QST-06) — 내 팬덤의 **열린 창**만. 위 목록과 따로 읽어 맨 앞에 - // 둔다. 위 질의는 정렬이 없어 5 건 안에 든다는 보장이 없다. 색인 `firestore.indexes.json`. - // 질의는 단말 시계 기준 1시간 여유로 넓게 받고, 마감 판정은 아래에서 서버 시각으로 한다. - // ponytail: 단말 시계가 1시간 넘게 빠르면 열린 창을 놓친다. 그때는 여유를 늘린다. - final timeAttacksF = fandomId.isEmpty - ? null - : db - .collection('quests') - .where('timeAttack.fandomId', isEqualTo: fandomId) - .where( - 'timeAttack.activeTo', - isGreaterThan: Timestamp.fromDate( - DateTime.now().subtract(const Duration(hours: 1)), - ), - ) - .limit(10) - .get(); final progressF = db.collection('users/$uid/questProgress').get(); // 출석 보상은 서버가 `config/policy.attendPop` 으로 지급한다. 같은 곳을 읽는다. final policyF = db.doc('config/policy').get(); @@ -199,24 +184,12 @@ class FirestoreHomeRepository implements HomeRepository { final fandom = fandomF == null ? const {} : ((await fandomF).data() ?? const {}); - final timeAttacks = [ - for (final doc in (await timeAttacksF)?.docs ?? const []) - if (doc.data()['active'] != false) doc, - ]; - if (timeAttacks.isNotEmpty) await ServerClock.ensure(_backend); - final open = [ - for (final d in timeAttacks) - if (openTimeAttack(d.data()) != null) d, - ]; - final openIds = {for (final d in open) d.id}; - final quests = [ - ...open, - // 회차가 열린 Quest 는 위에서 맨 앞에 넣었다. 두 번 넣지 않는다. - ...adminQuestsLatest([ - for (final d in (await questsF).docs) - if (!openIds.contains(d.id)) d, - ], (d) => d.data()), - ].take(3).toList(); + // 설명 프레임 `2097:16397` 그대로 — 관리자 퀘스트 최신순 최대 3개. 열린 타임어택 회차를 + // 맨 앞에 끼우던 처리는 발주사 자료에 근거가 없어 뺐다(회차는 Push · 퀘스트 목록으로 안내). + final quests = adminQuestsLatest( + (await questsF).docs, + (d) => d.data(), + ).take(3).toList(); final progress = (await progressF).docs; final briefings = (await briefingsF).docs; final blocked = {for (final d in (await blocksF).docs) d.id}; @@ -330,7 +303,11 @@ class FirestoreHomeRepository implements HomeRepository { /// 이번 주 내가 [fandomId] 행성 성장에 보탠 Growth POP — 원장과 같은 트랜잭션에서 쓰는 /// `users/{uid}/growthEvents`(역분개는 음수 행)의 [since] 이후 합. 읽지 못하면 null. // ponytail: 한 주치 성장 행을 전부 읽어 합한다. 한 사용자가 주에 수천 건을 넘기면 서버 집계 필드로 옮긴다. - Future _myWeekGrowth(String uid, String fandomId, DateTime since) async { + Future _myWeekGrowth( + String uid, + String fandomId, + DateTime since, + ) async { try { final q = await _backend.db .collection('users/$uid/growthEvents') @@ -340,7 +317,8 @@ class FirestoreHomeRepository implements HomeRepository { for (final d in q.docs) { final data = d.data(); final amount = data['amount']; - if (data['fandomIdAtPayout'] == fandomId && amount is num) sum += amount; + if (data['fandomIdAtPayout'] == fandomId && amount is num) + sum += amount; } return sum; } catch (_) { @@ -429,7 +407,8 @@ class FirestoreHomeRepository implements HomeRepository { title: questTitle(quest), type: QuestType.parse(quest.strOr('type')), category: QuestType.parse(quest.strOr('type')).label, - rewardPop: openTimeAttack(quest)?.rewardPop ?? + rewardPop: + openTimeAttack(quest)?.rewardPop ?? QuestType.parse(quest.strOr('type')).rewardFrom(quest, policy), state: done ? QuestState.done diff --git a/app/lib/features/my/account_link_screen.dart b/app/lib/features/my/account_link_screen.dart index b61b17d..c3ac7a4 100644 --- a/app/lib/features/my/account_link_screen.dart +++ b/app/lib/features/my/account_link_screen.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; import '../auth/auth.dart'; +import '../../core/ui/bottom_inset.dart'; /// SNS 연동 — 화면 대조 원장 F6. /// @@ -50,7 +51,7 @@ class _AccountLinkScreenState extends State { return Scaffold( appBar: AppBar(title: Text(context.l10n.linkTitle(_name))), body: ListView( - padding: const EdgeInsets.all(AppSpace.s4), + padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)), children: [ Container( padding: const EdgeInsets.all(AppSpace.s4), diff --git a/app/lib/features/my/notice_screens.dart b/app/lib/features/my/notice_screens.dart index 3b8272f..92c631b 100644 --- a/app/lib/features/my/notice_screens.dart +++ b/app/lib/features/my/notice_screens.dart @@ -4,6 +4,7 @@ import '../../core/i18n/app_locale.dart'; import '../../core/theme/tokens.dart'; import 'my_data.dart'; import 'widgets/my_cards.dart'; +import '../../core/ui/bottom_inset.dart'; /// 「2026.04.30」 — 시안 공지 날짜 표기. String _date(DateTime at) => @@ -116,7 +117,7 @@ class NoticeDetailScreen extends StatelessWidget { Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: Text(context.l10n.noticeDetailTitle)), body: ListView( - padding: const EdgeInsets.all(16), + padding: withBottomInset(context, const EdgeInsets.all(16)), children: [ _NoticeHead(notice: notice), const SizedBox(height: 12), diff --git a/app/lib/features/my/profile_edit_screen.dart b/app/lib/features/my/profile_edit_screen.dart index cc73355..aa733ae 100644 --- a/app/lib/features/my/profile_edit_screen.dart +++ b/app/lib/features/my/profile_edit_screen.dart @@ -5,6 +5,7 @@ import 'my_data.dart'; import 'photo_picker.dart'; import 'widgets/my_cards.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/bottom_inset.dart'; /// 닉네임 수정 — 화면 대조 원장 F6 · 시안 `374:16029`(MY 연필에서 진입). /// @@ -68,7 +69,7 @@ class _ProfileEditScreenState extends State { onDone: _changed && !_saving ? _save : null, ), body: ListView( - padding: const EdgeInsets.all(16), + padding: withBottomInset(context, const EdgeInsets.all(16)), children: [ // 시안 — 카드 #19191c · 모서리 16 · 높이 58 · 글자 18/500. Container( diff --git a/app/lib/features/my/settings_screen.dart b/app/lib/features/my/settings_screen.dart index 1f0f54b..2a7898f 100644 --- a/app/lib/features/my/settings_screen.dart +++ b/app/lib/features/my/settings_screen.dart @@ -19,6 +19,7 @@ import 'pop_history_screen.dart'; import 'push_settings_screen.dart'; import 'support_screens.dart'; import 'widgets/my_cards.dart'; +import '../../core/ui/bottom_inset.dart'; /// 설정 — 화면 대조 원장 F6 · Figma `354:222008`. /// @@ -93,7 +94,7 @@ class _SettingsScreenState extends State { return Scaffold( appBar: AppBar(title: Text(l10n.settingsTitle)), body: ListView( - padding: const EdgeInsets.fromLTRB(16, 16, 16, 0), + padding: withBottomInset(context, const EdgeInsets.fromLTRB(16, 16, 16, 0)), children: [ MyGroup( title: l10n.settingsAccount, @@ -220,8 +221,14 @@ class _SettingsScreenState extends State { action: l10n.settingsLogout, // 푸시 정리를 **먼저** 한다. 보안 규칙이 `users/{uid}` 쓰기를 // `isSelf(uid)` 로 막아, 로그아웃 뒤에는 이 기기 토큰을 지울 수 없다. + // 정리가 실패해도(토픽 해제 · 토큰 쓰기 네트워크 오류) 로그아웃은 한다 — 예외가 + // 그대로 올라가면 signOut 이 돌지 않아 첫 탭이 무시된 것처럼 보인다(10/6 실기기 1회). onConfirm: () async { - await widget.push?.clearOnSignOut(); + try { + await widget.push?.clearOnSignOut(); + } on Object catch (error) { + debugPrint('[Settings] push cleanup failed: ${error.runtimeType}'); + } await widget.auth?.signOut(); }, ), diff --git a/app/lib/features/my/support_screens.dart b/app/lib/features/my/support_screens.dart index 5e1737d..baa6254 100644 --- a/app/lib/features/my/support_screens.dart +++ b/app/lib/features/my/support_screens.dart @@ -5,6 +5,7 @@ import '../../core/ui/relative_time.dart'; import 'my_data.dart'; import 'widgets/my_cards.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/bottom_inset.dart'; /// 1:1 문의 목록 — 화면 대조 원장 F6 · Figma `373:634916` 「1:1 문의 접수」. /// @@ -205,7 +206,7 @@ class InquiryDetailScreen extends StatelessWidget { ], ), body: ListView( - padding: const EdgeInsets.all(AppSpace.s4), + padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)), children: [ Text(inquiry.title, style: t.titleMedium), const SizedBox(height: AppSpace.s2), @@ -296,7 +297,7 @@ class _InquiryComposeScreenState extends State { ], ), body: ListView( - padding: const EdgeInsets.all(AppSpace.s4), + padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)), children: [ TextField( controller: _title, diff --git a/app/lib/features/my/tier_screens.dart b/app/lib/features/my/tier_screens.dart index 48ffc66..a427779 100644 --- a/app/lib/features/my/tier_screens.dart +++ b/app/lib/features/my/tier_screens.dart @@ -8,6 +8,7 @@ import '../planet/planet_history_screen.dart'; import 'my_data.dart'; import 'widgets/my_cards.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/bottom_inset.dart'; /// 티어 정보 — 화면 대조 원장 F6 · Figma `373:633256`. /// @@ -61,7 +62,7 @@ class TierInfoScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: Text(context.l10n.tierInfoTitle)), body: ListView( - padding: const EdgeInsets.fromLTRB(20, 20, 20, 0), + padding: withBottomInset(context, const EdgeInsets.fromLTRB(20, 20, 20, 0)), children: [ Text.rich( TextSpan( @@ -142,7 +143,7 @@ class TierDetailScreen extends StatelessWidget { return Scaffold( appBar: AppBar(title: const Text('TIER')), body: ListView( - padding: const EdgeInsets.all(16), + padding: withBottomInset(context, const EdgeInsets.all(16)), children: [ for (final (i, planet) in list.indexed) ...[ if (i > 0) const SizedBox(height: 16), diff --git a/app/lib/features/planet/planet_history_screen.dart b/app/lib/features/planet/planet_history_screen.dart index e6a8254..bcf57b0 100644 --- a/app/lib/features/planet/planet_history_screen.dart +++ b/app/lib/features/planet/planet_history_screen.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'planet_data.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/bottom_inset.dart'; class PlanetHistoryScreen extends StatefulWidget { const PlanetHistoryScreen({ @@ -57,7 +58,7 @@ class _PlanetHistoryScreenState extends State { Widget build(BuildContext context) => Scaffold( appBar: AppBar(title: Text(context.l10n.historyTitle)), body: ListView( - padding: const EdgeInsets.all(16), + padding: withBottomInset(context, const EdgeInsets.all(16)), children: [ Text(widget.fandomName, style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 16), diff --git a/app/lib/features/quest/place_picker_screen.dart b/app/lib/features/quest/place_picker_screen.dart index 92ec688..95addba 100644 --- a/app/lib/features/quest/place_picker_screen.dart +++ b/app/lib/features/quest/place_picker_screen.dart @@ -257,6 +257,10 @@ class _PlacePickerScreenState extends State { /// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`) /// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 — /// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다. +/// +/// 지오코더가 지번 줄(「마포구 서교동 353-5」)만 주면 지번 그대로 둔다 — Figma 퀘스트 생성 「위치 선택」 +/// 예시가 지번이다(`548:19930` · `372:357949` 등 「서울특별시 동작구 동작동 산22-11」). 도로명으로 바꾸는 +/// 역지오코딩은 기준자료에 없다(2026-10-06 재확인). String placeAddress(Placemark m) { String t(String? v) => v?.trim() ?? ''; final street = t(m.street); diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart index b2ea8b2..c9fbd8f 100644 --- a/app/lib/features/quest/quest_create_screen.dart +++ b/app/lib/features/quest/quest_create_screen.dart @@ -10,6 +10,7 @@ import 'place_picker_screen.dart'; import 'quest_data.dart'; import 'widgets/quest_card_tile.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/bottom_inset.dart'; /// 퀘스트 생성 — 화면 대조 원장 F4 · Figma `254:164081`. /// @@ -178,7 +179,7 @@ class _QuestCreateScreenState extends State { ], ), body: ListView( - padding: const EdgeInsets.all(AppSpace.s4), + padding: withBottomInset(context, const EdgeInsets.all(AppSpace.s4)), children: [ // 명세 — 입력 상태에 따라 실시간 반영. QuestCardTile.preview(card: _preview), diff --git a/app/lib/features/quest/quest_list_screen.dart b/app/lib/features/quest/quest_list_screen.dart index abd7b8c..22b98e7 100644 --- a/app/lib/features/quest/quest_list_screen.dart +++ b/app/lib/features/quest/quest_list_screen.dart @@ -8,6 +8,7 @@ import 'quest_data.dart'; import 'quest_detail_screen.dart'; import 'widgets/quest_card_tile.dart'; import '../../core/i18n/app_locale.dart'; +import '../../core/ui/bottom_inset.dart'; /// 퀘스트 목록 — 화면 대조 원장 F4. /// @@ -339,7 +340,7 @@ class _QuestListScreenState extends State { controller: _scroll, physics: const AlwaysScrollableScrollPhysics(), // 아래 여백은 「새 퀘스트」 버튼(36)이 마지막 카드를 가리지 않게 둔다. - padding: const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16), + padding: withBottomInset(context, const EdgeInsets.fromLTRB(16, 16, 16, 16 + 36 + 16)), children: _body(), ), ), diff --git a/app/test/f6_screens_test.dart b/app/test/f6_screens_test.dart index d38bd1b..27f3190 100644 --- a/app/test/f6_screens_test.dart +++ b/app/test/f6_screens_test.dart @@ -19,6 +19,7 @@ import 'package:fancake/features/my/tier_screens.dart'; import 'package:fancake/features/my/widgets/profile_card.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:fancake/core/push/push_messaging.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:share_plus/share_plus.dart'; @@ -1009,6 +1010,26 @@ void main() { /// /// 이 배선이 없던 동안 모달은 「09-10 회차에 연결합니다」 안내만 띄우고 세션을 /// 그대로 두었다. 화면만 보면 로그아웃된 것처럼 보이지 않아 결함이 드러나지 않는다. + testWidgets('푸시 정리가 실패해도 첫 확인에 로그아웃된다 (10/6 실기기)', (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: _FailingPush()), + ); + 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.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), @@ -1044,6 +1065,15 @@ void main() { }); } +/// 푸시 정리가 실패하는 경계 — 토픽 해제 · 토큰 쓰기가 네트워크 오류로 던진다. +class _FailingPush implements PushMessaging { + @override + Future clearOnSignOut() async => throw Exception('offline'); + + @override + dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); +} + /// 로그아웃 호출을 세는 최소 세션. 실제 Firebase 를 붙이지 않는다. final class _LogoutSession implements AuthSession { _LogoutSession(this._current);