feat(flutter): 함께 쓰는 기록을 앱을 보는 동안 다시 읽기

다른 멤버가 남긴 글·일정과 내 코드로 새로 연동한 사람이 앱을 다시 켜지 않아도 보이게 한다.
실시간 알림이 붙기 전까지의 대신이다.
- 앱으로 돌아올 때와 앱을 보는 동안 3분마다 공유 다이어리·연동 상태(연동 중이면 일정도)를 다시 읽는다
- 뒤에서 읽을 때는 실패해도 알림을 띄우지 않는다 (목록 읽기·저장소 fetch 에 quiet)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-11 22:50:19 +09:00
parent 14e2bddb96
commit b10e90fe5c
15 changed files with 170 additions and 26 deletions
+6 -2
View File
@@ -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<List<T>> fetchAllPages<T>(
Dio dio,
String path,
T Function(Map<String, Object?> json) parse,
) async {
T Function(Map<String, Object?> json) parse, {
bool quiet = false,
}) async {
final items = <T>[];
String? cursor;
for (var page = 0; page < _kMaxPages; page++) {
final response = await dio.get<Map<String, dynamic>>(
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<Object?>) {
@@ -144,12 +144,12 @@ class ApiDiaryRepository implements DiaryRepository {
}
@override
Future<DiaryBook> fetch() async {
Future<DiaryBook> 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),
);
}
@@ -18,7 +18,8 @@ import 'package:tma_app/features/diary/domain/mood.dart';
/// 다이어리 저장소 계약 — 서버(ApiDiaryRepository) / 기기(LocalDiaryRepository).
/// 바꾼 결과(id 가 붙은 다이어리·기록·댓글)를 돌려주면 알림기가 [DiaryBook] 에 반영한다.
abstract interface class DiaryRepository {
Future<DiaryBook> fetch();
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때)
Future<DiaryBook> fetch({bool quiet = false});
/// 공유 다이어리(초대·참여·멤버)를 쓸 수 있는지 — 다른 회원과 함께 쓰므로 서버가 있어야 한다
bool get canShare;
@@ -114,7 +115,7 @@ class LocalDiaryRepository implements DiaryRepository {
}
@override
Future<DiaryBook> fetch() async => _read();
Future<DiaryBook> fetch({bool quiet = false}) async => _read();
@override
bool get canShare => false;
@@ -73,10 +73,11 @@ class DiaryNotifier extends AsyncNotifier<DiaryBook> {
// ── 공유 다이어리 ────────────────────────────────────────
/// 다른 멤버가 새로 남긴 기록·댓글을 다시 읽는다 — 불러오는 중 화면 없이 바꿔 끼운다
Future<void> refresh() async {
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (앱이 뒤에서 다시 읽을 때)
Future<void> refresh({bool quiet = false}) async {
// 기기 저장 모드는 나만 쓰므로 다시 읽을 것이 없다
if (!_repo.canShare) return;
_set(await _repo.fetch());
_set(await _repo.fetch(quiet: quiet));
}
/// 개인 ↔ 공유 전환 (주인만)
@@ -105,20 +105,21 @@ class ApiScheduleRepository implements ScheduleRepository {
RepeatRule _rule(Map<String, Object?> json) => repeatRuleFromApi(json, myUserId: _myUserId());
@override
Future<ScheduleBook> fetch() async {
Future<ScheduleBook> fetch({bool quiet = false}) async {
if (!_session.hasSession) return ScheduleBook.empty;
final done = <String>{};
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});
}
@@ -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<CalendarSharing?> fetch();
/// 내 초대 코드와 연동된 사람들 (기기 저장 모드·로그인 전에는 null). [quiet] 면 실패해도 알림을 띄우지 않는다
Future<CalendarSharing?> fetch({bool quiet = false});
/// 받은 초대 코드를 미리 본다
Future<CalendarInvitation> previewInvitation(String code);
@@ -38,7 +39,7 @@ class UnavailableCalendarSharingRepository implements CalendarSharingRepository
bool get available => false;
@override
Future<CalendarSharing?> fetch() async => null;
Future<CalendarSharing?> fetch({bool quiet = false}) async => null;
@override
Future<CalendarInvitation> previewInvitation(String code) => throw StateError(_kLocalOnly);
@@ -89,9 +90,12 @@ class ApiCalendarSharingRepository implements CalendarSharingRepository {
bool get available => true;
@override
Future<CalendarSharing?> fetch() async {
Future<CalendarSharing?> fetch({bool quiet = false}) async {
if (!_session.hasSession) return null;
final response = await _dio.get<Map<String, dynamic>>(_kSharingPath);
final response = await _dio.get<Map<String, dynamic>>(
_kSharingPath,
options: quiet ? Options(extra: {kSilentExtra: true}) : null,
);
return calendarSharingFromApi(response.data!);
}
@@ -17,7 +17,8 @@ import 'package:tma_app/features/schedule/domain/schedule_event.dart';
/// 일정 저장소 계약 — 서버(ApiScheduleRepository) / 기기(LocalScheduleRepository).
/// 바꾼 결과(id 가 붙은 일정·규칙)를 돌려주면 알림기가 [ScheduleBook] 에 반영한다.
abstract interface class ScheduleRepository {
Future<ScheduleBook> fetch();
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (뒤에서 다시 읽을 때)
Future<ScheduleBook> fetch({bool quiet = false});
/// 날짜 일정 여러 건 — id 를 붙여 보낸 순서대로 돌려준다
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts);
@@ -76,7 +77,7 @@ class LocalScheduleRepository implements ScheduleRepository {
}
@override
Future<ScheduleBook> fetch() async => _read();
Future<ScheduleBook> fetch({bool quiet = false}) async => _read();
@override
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) async {
@@ -25,6 +25,9 @@ class CalendarSharingNotifier extends AsyncNotifier<CalendarSharing?> {
/// 함께 관리를 쓸 수 있는지 (서버 모드)
bool get available => _repo.available;
/// 다른 사람이 내 코드로 연동했는지 등을 다시 읽는다. [quiet] 면 실패해도 알림을 띄우지 않는다
Future<void> refresh({bool quiet = false}) async => _set(await _repo.fetch(quiet: quiet));
Future<CalendarInvitation> previewInvitation(String code) => _repo.previewInvitation(code);
/// 받은 코드로 연동 — 함께 보는 일정이 바뀌므로 일정도 다시 읽는다
@@ -74,7 +74,8 @@ class ScheduleNotifier extends AsyncNotifier<ScheduleBook> {
/// 일정 삭제 — 반복 일정이면 규칙 전체를 지운다
/// 함께 관리를 연동·해제했을 때 — 보이는 일정이 바뀌므로 다시 읽는다 (불러오는 중 화면 없이 바꿔 끼운다)
Future<void> refresh() async => _set(await _repo.fetch());
/// [quiet] 면 실패해도 알림을 띄우지 않는다 (앱이 뒤에서 다시 읽을 때)
Future<void> refresh({bool quiet = false}) async => _set(await _repo.fetch(quiet: quiet));
Future<void> delete(ScheduleEvent target) async {
await _deleteTarget(target);
@@ -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,
@@ -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<SharedDataRefresher> createState() => _SharedDataRefresherState();
}
class _SharedDataRefresherState extends ConsumerState<SharedDataRefresher> {
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<void> _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;
}