generated from ttipo/project-base
feat(flutter): 함께 쓰는 기록을 앱을 보는 동안 다시 읽기
다른 멤버가 남긴 글·일정과 내 코드로 새로 연동한 사람이 앱을 다시 켜지 않아도 보이게 한다. 실시간 알림이 붙기 전까지의 대신이다. - 앱으로 돌아올 때와 앱을 보는 동안 3분마다 공유 다이어리·연동 상태(연동 중이면 일정도)를 다시 읽는다 - 뒤에서 읽을 때는 실패해도 알림을 띄우지 않는다 (목록 읽기·저장소 fetch 에 quiet) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -33,7 +33,7 @@ class _FakeSharingRepo implements CalendarSharingRepository {
|
||||
bool get available => true;
|
||||
|
||||
@override
|
||||
Future<CalendarSharing?> fetch() async => sharing;
|
||||
Future<CalendarSharing?> fetch({bool quiet = false}) async => sharing;
|
||||
|
||||
@override
|
||||
Future<CalendarInvitation> previewInvitation(String code) async {
|
||||
@@ -63,7 +63,7 @@ class _FakeScheduleRepo implements ScheduleRepository {
|
||||
ScheduleBook book;
|
||||
|
||||
@override
|
||||
Future<ScheduleBook> fetch() async => book;
|
||||
Future<ScheduleBook> fetch({bool quiet = false}) async => book;
|
||||
|
||||
@override
|
||||
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) async => [
|
||||
|
||||
@@ -28,7 +28,7 @@ class _RecordingRepository implements ScheduleRepository {
|
||||
bool failSetDone = false;
|
||||
|
||||
@override
|
||||
Future<ScheduleBook> fetch() => _inner.fetch();
|
||||
Future<ScheduleBook> fetch({bool quiet = false}) => _inner.fetch();
|
||||
|
||||
@override
|
||||
Future<List<ScheduleEvent>> createEvents(List<EventDraft> drafts) {
|
||||
|
||||
@@ -56,7 +56,7 @@ class _FakeSharedRepo implements DiaryRepository {
|
||||
bool get canShare => true;
|
||||
|
||||
@override
|
||||
Future<DiaryBook> fetch() async => book;
|
||||
Future<DiaryBook> fetch({bool quiet = false}) async => book;
|
||||
|
||||
@override
|
||||
Future<Diary?> createDiary({
|
||||
|
||||
@@ -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<CalendarSharing?> 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<CalendarInvitation> previewInvitation(String code) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<CalendarSharing> join(String code) => throw UnimplementedError();
|
||||
|
||||
@override
|
||||
Future<void> 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user