From b10e90fe5cd2f85e8f7e2239c455637572bf0002 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Sun, 11 Oct 2026 22:50:19 +0900 Subject: [PATCH] =?UTF-8?q?feat(flutter):=20=ED=95=A8=EA=BB=98=20=EC=93=B0?= =?UTF-8?q?=EB=8A=94=20=EA=B8=B0=EB=A1=9D=EC=9D=84=20=EC=95=B1=EC=9D=84=20?= =?UTF-8?q?=EB=B3=B4=EB=8A=94=20=EB=8F=99=EC=95=88=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=BD=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 다른 멤버가 남긴 글·일정과 내 코드로 새로 연동한 사람이 앱을 다시 켜지 않아도 보이게 한다. 실시간 알림이 붙기 전까지의 대신이다. - 앱으로 돌아올 때와 앱을 보는 동안 3분마다 공유 다이어리·연동 상태(연동 중이면 일정도)를 다시 읽는다 - 뒤에서 읽을 때는 실패해도 알림을 띄우지 않는다 (목록 읽기·저장소 fetch 에 quiet) Co-Authored-By: Claude Opus 5.5 --- flutter/lib/core/api/api_paging.dart | 8 ++- .../diary/data/api_diary_repository.dart | 8 +-- .../features/diary/data/diary_repository.dart | 5 +- .../providers/diary_providers.dart | 5 +- .../data/api_schedule_repository.dart | 7 +- .../data/calendar_sharing_repository.dart | 14 ++-- .../schedule/data/schedule_repository.dart | 5 +- .../providers/calendar_sharing_providers.dart | 3 + .../providers/schedule_providers.dart | 3 +- .../shell/presentation/pages/main_shell.dart | 4 +- .../widgets/shared_data_refresher.dart | 70 +++++++++++++++++++ flutter/test/calendar_sharing_flow_test.dart | 4 +- flutter/test/schedule_notifier_test.dart | 2 +- flutter/test/shared_diary_flow_test.dart | 2 +- flutter/test/shared_refresh_test.dart | 56 +++++++++++++++ 15 files changed, 170 insertions(+), 26 deletions(-) create mode 100644 flutter/lib/features/shell/presentation/widgets/shared_data_refresher.dart create mode 100644 flutter/test/shared_refresh_test.dart diff --git a/flutter/lib/core/api/api_paging.dart b/flutter/lib/core/api/api_paging.dart index 8ec89bb..0b829fc 100644 --- a/flutter/lib/core/api/api_paging.dart +++ b/flutter/lib/core/api/api_paging.dart @@ -1,4 +1,5 @@ import 'package:dio/dio.dart'; +import 'package:tma_app/core/api/dio_client.dart'; /// 목록 API 한 번에 읽는 최대 개수 (backend PAGINATION_MAX_LIMIT) const int kApiPageLimit = 100; @@ -8,17 +9,20 @@ const int _kMaxPages = 1000; /// 커서 페이지네이션 목록({items, pageInfo})을 마지막 페이지까지 읽어 이어 붙인다. /// 앱은 지금 전체 기록을 들고 화면에서 거른다 — 기록이 아주 많아지면 기간 조회로 바꾼다. +/// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때 — 실패는 그대로 던진다) Future> fetchAllPages( Dio dio, String path, - T Function(Map json) parse, -) async { + T Function(Map json) parse, { + bool quiet = false, +}) async { final items = []; String? cursor; for (var page = 0; page < _kMaxPages; page++) { final response = await dio.get>( path, queryParameters: {'limit': kApiPageLimit, 'cursor': ?cursor}, + options: quiet ? Options(extra: {kSilentExtra: true}) : null, ); final data = response.data!; for (final item in data['items']! as List) { diff --git a/flutter/lib/features/diary/data/api_diary_repository.dart b/flutter/lib/features/diary/data/api_diary_repository.dart index 08aa1b9..5e4ff9e 100644 --- a/flutter/lib/features/diary/data/api_diary_repository.dart +++ b/flutter/lib/features/diary/data/api_diary_repository.dart @@ -144,12 +144,12 @@ class ApiDiaryRepository implements DiaryRepository { } @override - Future fetch() async { + Future fetch({bool quiet = false}) async { if (!_session.hasSession) return DiaryBook.empty; return DiaryBook( - diaries: await fetchAllPages(_dio, _kDiariesPath, _diary), - posts: await fetchAllPages(_dio, _kPostsPath, _post), - comments: await fetchAllPages(_dio, _kCommentsPath, _comment), + diaries: await fetchAllPages(_dio, _kDiariesPath, _diary, quiet: quiet), + posts: await fetchAllPages(_dio, _kPostsPath, _post, quiet: quiet), + comments: await fetchAllPages(_dio, _kCommentsPath, _comment, quiet: quiet), ); } diff --git a/flutter/lib/features/diary/data/diary_repository.dart b/flutter/lib/features/diary/data/diary_repository.dart index 01a97d3..efa661f 100644 --- a/flutter/lib/features/diary/data/diary_repository.dart +++ b/flutter/lib/features/diary/data/diary_repository.dart @@ -18,7 +18,8 @@ import 'package:tma_app/features/diary/domain/mood.dart'; /// 다이어리 저장소 계약 — 서버(ApiDiaryRepository) / 기기(LocalDiaryRepository). /// 바꾼 결과(id 가 붙은 다이어리·기록·댓글)를 돌려주면 알림기가 [DiaryBook] 에 반영한다. abstract interface class DiaryRepository { - Future fetch(); + /// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때) + Future fetch({bool quiet = false}); /// 공유 다이어리(초대·참여·멤버)를 쓸 수 있는지 — 다른 회원과 함께 쓰므로 서버가 있어야 한다 bool get canShare; @@ -114,7 +115,7 @@ class LocalDiaryRepository implements DiaryRepository { } @override - Future fetch() async => _read(); + Future fetch({bool quiet = false}) async => _read(); @override bool get canShare => false; diff --git a/flutter/lib/features/diary/presentation/providers/diary_providers.dart b/flutter/lib/features/diary/presentation/providers/diary_providers.dart index 9758ead..4968cd9 100644 --- a/flutter/lib/features/diary/presentation/providers/diary_providers.dart +++ b/flutter/lib/features/diary/presentation/providers/diary_providers.dart @@ -73,10 +73,11 @@ class DiaryNotifier extends AsyncNotifier { // ── 공유 다이어리 ──────────────────────────────────────── /// 다른 멤버가 새로 남긴 기록·댓글을 다시 읽는다 — 불러오는 중 화면 없이 바꿔 끼운다 - Future refresh() async { + /// [quiet] 면 실패해도 알림을 띄우지 않는다 (앱이 뒤에서 다시 읽을 때) + Future refresh({bool quiet = false}) async { // 기기 저장 모드는 나만 쓰므로 다시 읽을 것이 없다 if (!_repo.canShare) return; - _set(await _repo.fetch()); + _set(await _repo.fetch(quiet: quiet)); } /// 개인 ↔ 공유 전환 (주인만) diff --git a/flutter/lib/features/schedule/data/api_schedule_repository.dart b/flutter/lib/features/schedule/data/api_schedule_repository.dart index 3ebf992..8de0828 100644 --- a/flutter/lib/features/schedule/data/api_schedule_repository.dart +++ b/flutter/lib/features/schedule/data/api_schedule_repository.dart @@ -105,20 +105,21 @@ class ApiScheduleRepository implements ScheduleRepository { RepeatRule _rule(Map json) => repeatRuleFromApi(json, myUserId: _myUserId()); @override - Future fetch() async { + Future fetch({bool quiet = false}) async { if (!_session.hasSession) return ScheduleBook.empty; final done = {}; - final events = await fetchAllPages(_dio, _kEventsPath, (json) { + final events = await fetchAllPages(_dio, _kEventsPath, quiet: quiet, (json) { final event = _event(json); if (json['isDone'] == true) done.add(event.id); return event; }); - final rules = await fetchAllPages(_dio, _kRulesPath, _rule); + final rules = await fetchAllPages(_dio, _kRulesPath, _rule, quiet: quiet); final completions = await fetchAllPages( _dio, _kCompletionsPath, (json) => '${json['ruleId']! as String}@${json['date']! as String}', + quiet: quiet, ); return ScheduleBook(events: events, rules: rules, doneIds: {...done, ...completions}); } diff --git a/flutter/lib/features/schedule/data/calendar_sharing_repository.dart b/flutter/lib/features/schedule/data/calendar_sharing_repository.dart index 307c2df..b12c392 100644 --- a/flutter/lib/features/schedule/data/calendar_sharing_repository.dart +++ b/flutter/lib/features/schedule/data/calendar_sharing_repository.dart @@ -1,5 +1,6 @@ import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/dio_client.dart'; import 'package:tma_app/core/api/dio_provider.dart'; import 'package:tma_app/core/api/server_file.dart'; import 'package:tma_app/core/api/session_store.dart'; @@ -17,8 +18,8 @@ abstract interface class CalendarSharingRepository { /// 함께 관리를 쓸 수 있는지 — 기기 저장 모드에서는 쓸 수 없다 bool get available; - /// 내 초대 코드와 연동된 사람들 (기기 저장 모드·로그인 전에는 null) - Future fetch(); + /// 내 초대 코드와 연동된 사람들 (기기 저장 모드·로그인 전에는 null). [quiet] 면 실패해도 알림을 띄우지 않는다 + Future fetch({bool quiet = false}); /// 받은 초대 코드를 미리 본다 Future previewInvitation(String code); @@ -38,7 +39,7 @@ class UnavailableCalendarSharingRepository implements CalendarSharingRepository bool get available => false; @override - Future fetch() async => null; + Future fetch({bool quiet = false}) async => null; @override Future previewInvitation(String code) => throw StateError(_kLocalOnly); @@ -89,9 +90,12 @@ class ApiCalendarSharingRepository implements CalendarSharingRepository { bool get available => true; @override - Future fetch() async { + Future fetch({bool quiet = false}) async { if (!_session.hasSession) return null; - final response = await _dio.get>(_kSharingPath); + final response = await _dio.get>( + _kSharingPath, + options: quiet ? Options(extra: {kSilentExtra: true}) : null, + ); return calendarSharingFromApi(response.data!); } diff --git a/flutter/lib/features/schedule/data/schedule_repository.dart b/flutter/lib/features/schedule/data/schedule_repository.dart index 8eb57c0..6bf71ea 100644 --- a/flutter/lib/features/schedule/data/schedule_repository.dart +++ b/flutter/lib/features/schedule/data/schedule_repository.dart @@ -17,7 +17,8 @@ import 'package:tma_app/features/schedule/domain/schedule_event.dart'; /// 일정 저장소 계약 — 서버(ApiScheduleRepository) / 기기(LocalScheduleRepository). /// 바꾼 결과(id 가 붙은 일정·규칙)를 돌려주면 알림기가 [ScheduleBook] 에 반영한다. abstract interface class ScheduleRepository { - Future fetch(); + /// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때) + Future fetch({bool quiet = false}); /// 날짜 일정 여러 건 — id 를 붙여 보낸 순서대로 돌려준다 Future> createEvents(List drafts); @@ -76,7 +77,7 @@ class LocalScheduleRepository implements ScheduleRepository { } @override - Future fetch() async => _read(); + Future fetch({bool quiet = false}) async => _read(); @override Future> createEvents(List drafts) async { diff --git a/flutter/lib/features/schedule/presentation/providers/calendar_sharing_providers.dart b/flutter/lib/features/schedule/presentation/providers/calendar_sharing_providers.dart index 44e28ef..1f9ed42 100644 --- a/flutter/lib/features/schedule/presentation/providers/calendar_sharing_providers.dart +++ b/flutter/lib/features/schedule/presentation/providers/calendar_sharing_providers.dart @@ -25,6 +25,9 @@ class CalendarSharingNotifier extends AsyncNotifier { /// 함께 관리를 쓸 수 있는지 (서버 모드) bool get available => _repo.available; + /// 다른 사람이 내 코드로 연동했는지 등을 다시 읽는다. [quiet] 면 실패해도 알림을 띄우지 않는다 + Future refresh({bool quiet = false}) async => _set(await _repo.fetch(quiet: quiet)); + Future previewInvitation(String code) => _repo.previewInvitation(code); /// 받은 코드로 연동 — 함께 보는 일정이 바뀌므로 일정도 다시 읽는다 diff --git a/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart index d743bb4..762fbf1 100644 --- a/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart +++ b/flutter/lib/features/schedule/presentation/providers/schedule_providers.dart @@ -74,7 +74,8 @@ class ScheduleNotifier extends AsyncNotifier { /// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다 /// 함께 관리를 연동·해제했을 때 — 보이는 일정이 바뀌므로 다시 읽는다 (불러오는 중 화면 없이 바꿔 끼운다) - Future refresh() async => _set(await _repo.fetch()); + /// [quiet] 면 실패해도 알림을 띄우지 않는다 (앱이 뒤에서 다시 읽을 때) + Future refresh({bool quiet = false}) async => _set(await _repo.fetch(quiet: quiet)); Future delete(ScheduleEvent target) async { await _deleteTarget(target); diff --git a/flutter/lib/features/shell/presentation/pages/main_shell.dart b/flutter/lib/features/shell/presentation/pages/main_shell.dart index 34fc49e..4f17db4 100644 --- a/flutter/lib/features/shell/presentation/pages/main_shell.dart +++ b/flutter/lib/features/shell/presentation/pages/main_shell.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:tma_app/features/shell/domain/app_tab.dart'; import 'package:tma_app/features/shell/presentation/widgets/rup_tab_bar.dart'; +import 'package:tma_app/features/shell/presentation/widgets/shared_data_refresher.dart'; /// 로그인 후 앱 뼈대 — 탭별 화면 상태를 유지한 채 하단 탭으로 오간다. class MainShell extends StatelessWidget { @@ -22,7 +23,8 @@ class MainShell extends StatelessWidget { return Scaffold( // 콘텐츠가 탭바 뒤까지 이어져 흐림 효과가 보이게 한다 extendBody: true, - body: navigationShell, + // 함께 쓰는 기록은 앱을 보는 동안에도 다시 읽는다 + body: SharedDataRefresher(child: navigationShell), bottomNavigationBar: RupTabBar( current: AppTab.values[navigationShell.currentIndex], onSelect: _select, diff --git a/flutter/lib/features/shell/presentation/widgets/shared_data_refresher.dart b/flutter/lib/features/shell/presentation/widgets/shared_data_refresher.dart new file mode 100644 index 0000000..4982fcc --- /dev/null +++ b/flutter/lib/features/shell/presentation/widgets/shared_data_refresher.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:tma_app/core/api/api_guard.dart'; +import 'package:tma_app/features/diary/presentation/providers/diary_providers.dart'; +import 'package:tma_app/features/schedule/presentation/providers/calendar_sharing_providers.dart'; +import 'package:tma_app/features/schedule/presentation/providers/schedule_providers.dart'; + +/// 앱을 보고 있는 동안 함께 쓰는 기록을 다시 읽는 간격 — 실시간 알림이 붙기 전까지의 대신 +const Duration kSharedRefreshInterval = Duration(minutes: 3); + +/// 함께 쓰는 기록(공유 다이어리 · 연동된 달력)을 다시 읽는다 — 앱으로 돌아올 때와, 앱을 보는 동안 일정 간격으로. +/// 다른 사람이 남긴 글·일정, 내 코드로 새로 연동한 사람이 앱을 다시 켜지 않아도 보인다. +/// 뒤에서 읽는 것이라 실패해도 알리지 않는다(다음 차례에 다시 읽는다). +class SharedDataRefresher extends ConsumerStatefulWidget { + const SharedDataRefresher({super.key, required this.child}); + + final Widget child; + + @override + ConsumerState createState() => _SharedDataRefresherState(); +} + +class _SharedDataRefresherState extends ConsumerState { + late final AppLifecycleListener _lifecycle; + Timer? _timer; + + @override + void initState() { + super.initState(); + _lifecycle = AppLifecycleListener( + onResume: () { + unawaited(_refresh()); + _startTimer(); + }, + // 화면을 보지 않을 때는 읽지 않는다 + onHide: () => _timer?.cancel(), + ); + _startTimer(); + } + + @override + void dispose() { + _timer?.cancel(); + _lifecycle.dispose(); + super.dispose(); + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(kSharedRefreshInterval, (_) => unawaited(_refresh())); + } + + Future _refresh() async { + if (!mounted) return; + final shareDiary = ref.read(diaryProvider).value?.diaries.any((d) => d.shared) ?? false; + if (shareDiary) unawaited(succeeded(ref.read(diaryProvider.notifier).refresh(quiet: true))); + + // 기기 저장 모드(null)는 함께 관리가 없다. 연동 상태부터 읽고 — 누가 내 코드로 연동했을 수 있다 — 연동 중이면 일정도 + if (ref.read(calendarSharingProvider).value == null) return; + if (!await succeeded(ref.read(calendarSharingProvider.notifier).refresh(quiet: true)) || !mounted) return; + if (ref.read(calendarLinkedProvider)) { + unawaited(succeeded(ref.read(scheduleProvider.notifier).refresh(quiet: true))); + } + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/flutter/test/calendar_sharing_flow_test.dart b/flutter/test/calendar_sharing_flow_test.dart index aa8e6f5..7d449f8 100644 --- a/flutter/test/calendar_sharing_flow_test.dart +++ b/flutter/test/calendar_sharing_flow_test.dart @@ -33,7 +33,7 @@ class _FakeSharingRepo implements CalendarSharingRepository { bool get available => true; @override - Future fetch() async => sharing; + Future fetch({bool quiet = false}) async => sharing; @override Future previewInvitation(String code) async { @@ -63,7 +63,7 @@ class _FakeScheduleRepo implements ScheduleRepository { ScheduleBook book; @override - Future fetch() async => book; + Future fetch({bool quiet = false}) async => book; @override Future> createEvents(List drafts) async => [ diff --git a/flutter/test/schedule_notifier_test.dart b/flutter/test/schedule_notifier_test.dart index fb04f5d..c808f76 100644 --- a/flutter/test/schedule_notifier_test.dart +++ b/flutter/test/schedule_notifier_test.dart @@ -28,7 +28,7 @@ class _RecordingRepository implements ScheduleRepository { bool failSetDone = false; @override - Future fetch() => _inner.fetch(); + Future fetch({bool quiet = false}) => _inner.fetch(); @override Future> createEvents(List drafts) { diff --git a/flutter/test/shared_diary_flow_test.dart b/flutter/test/shared_diary_flow_test.dart index 47ba6e9..3799d35 100644 --- a/flutter/test/shared_diary_flow_test.dart +++ b/flutter/test/shared_diary_flow_test.dart @@ -56,7 +56,7 @@ class _FakeSharedRepo implements DiaryRepository { bool get canShare => true; @override - Future fetch() async => book; + Future fetch({bool quiet = false}) async => book; @override Future createDiary({ diff --git a/flutter/test/shared_refresh_test.dart b/flutter/test/shared_refresh_test.dart new file mode 100644 index 0000000..bd78014 --- /dev/null +++ b/flutter/test/shared_refresh_test.dart @@ -0,0 +1,56 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:tma_app/features/schedule/data/calendar_sharing_repository.dart'; +import 'package:tma_app/features/schedule/domain/calendar_sharing.dart'; +import 'package:tma_app/features/shell/presentation/widgets/shared_data_refresher.dart'; + +import 'support/app_harness.dart'; + +/// 연동 상태를 몇 번 읽었는지 세는 저장소 +class _SharingRepo implements CalendarSharingRepository { + bool linked = false; + int fetches = 0; + + @override + bool get available => true; + + @override + Future fetch({bool quiet = false}) async { + fetches++; + return CalendarSharing( + inviteCode: 'RUP-MINE00', + linked: linked, + members: [ + const CalendarPerson(id: 'me', isMe: true), + if (linked) const CalendarPerson(id: 'dad', nickname: '콩이아빠'), + ], + ); + } + + @override + Future previewInvitation(String code) => throw UnimplementedError(); + + @override + Future join(String code) => throw UnimplementedError(); + + @override + Future leave() async {} +} + +void main() { + testWidgets('앱을 보는 동안 함께 관리 상태를 다시 읽는다', (tester) async { + final sharing = _SharingRepo(); + await launchSignedIn(tester, overrides: [calendarSharingRepositoryProvider.overrideWithValue(sharing)]); + // 일정 탭에서 함께 관리 카드가 상태를 읽어 둔다 + await tester.tap(find.bySemanticsLabel('일정·기록')); + await settle(tester); + final before = sharing.fetches; + + // 다른 사람이 내 코드로 연동했다 — 다음 차례에 읽혀 카드에 나타난다 + sharing.linked = true; + await tester.pump(kSharedRefreshInterval); + await settle(tester); + + expect(sharing.fetches, greaterThan(before)); + expect(find.text('2명이 같은 달력을 함께 써요'), findsOneWidget); + }); +}