Skip to content

Commit 0f4d55e

Browse files
authored
Merge pull request #58 from FarisZR/feature/pull-to-refresh-schedule
Add pull-to-refresh for weekly schedule
2 parents 53413e8 + 2aa3c54 commit 0f4d55e

5 files changed

Lines changed: 198 additions & 62 deletions

File tree

lib/schedule/ui/viewmodels/weekly_schedule_view_model.dart

Lines changed: 29 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -323,6 +323,15 @@ class WeeklyScheduleViewModel extends BaseViewModel {
323323
_debounceVisibleRefresh(weekStart, weekEnd);
324324
}
325325

326+
Future<void> refreshVisibleWeek() {
327+
return updateSchedule(
328+
currentDateStart,
329+
currentDateEnd,
330+
force: true,
331+
awaitRefresh: true,
332+
);
333+
}
334+
326335
Future openWeekContainingFromWidget(DateTime date) async {
327336
final weekStart = toStartOfDay(toDayOfWeek(date, DateTime.monday));
328337
final weekEnd = toNextWeek(weekStart);
@@ -353,6 +362,7 @@ class WeeklyScheduleViewModel extends BaseViewModel {
353362
DateTime end, {
354363
bool force = false,
355364
bool applyToVisibleState = true,
365+
bool awaitRefresh = false,
356366
}) async {
357367
if (_isDisposed) return;
358368

@@ -409,6 +419,7 @@ class WeeklyScheduleViewModel extends BaseViewModel {
409419
end,
410420
visibleUpdateRequestId: visibleUpdateRequestId,
411421
applyToVisibleState: applyToVisibleState,
422+
awaitRefresh: awaitRefresh,
412423
origin: applyToVisibleState
413424
? ScheduleRefreshOrigin.userBrowsing
414425
: ScheduleRefreshOrigin.foregroundMaintenance,
@@ -426,6 +437,7 @@ class WeeklyScheduleViewModel extends BaseViewModel {
426437
DateTime end, {
427438
int? visibleUpdateRequestId,
428439
bool applyToVisibleState = true,
440+
bool awaitRefresh = false,
429441
ScheduleRefreshOrigin origin = ScheduleRefreshOrigin.userBrowsing,
430442
}) async {
431443
final task = PerformanceTelemetry.instance.startTask(
@@ -462,27 +474,32 @@ class WeeklyScheduleViewModel extends BaseViewModel {
462474
}
463475

464476
final nowValue = now;
465-
final shouldForceFetch = cachedSchedule.entries.isEmpty &&
466-
scheduleSourceProvider.currentScheduleSource.canQuery();
477+
final shouldForceFetch = awaitRefresh ||
478+
(cachedSchedule.entries.isEmpty &&
479+
scheduleSourceProvider.currentScheduleSource.canQuery());
467480
final isStale = shouldForceFetch || _isWindowStale(start, end, nowValue);
468481

469482
if (!isStale) {
470483
unawaited(task.finish());
471484
return false;
472485
}
473486

474-
unawaited(
475-
_refreshScheduleInBackground(
476-
start,
477-
end,
478-
cancellationToken,
479-
task,
480-
visibleUpdateRequestId: visibleUpdateRequestId,
481-
applyToVisibleState: applyToVisibleState,
482-
origin: origin,
483-
),
487+
final refreshFuture = _refreshScheduleInBackground(
488+
start,
489+
end,
490+
cancellationToken,
491+
task,
492+
visibleUpdateRequestId: visibleUpdateRequestId,
493+
applyToVisibleState: applyToVisibleState,
494+
origin: origin,
484495
);
485496

497+
if (awaitRefresh) {
498+
await refreshFuture;
499+
} else {
500+
unawaited(refreshFuture);
501+
}
502+
486503
return true;
487504
}
488505

lib/schedule/ui/weeklyschedule/weekly_schedule_page.dart

Lines changed: 69 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -181,58 +181,81 @@ class _WeeklySchedulePageState extends State<WeeklySchedulePage>
181181

182182
return PropertyChangeProvider<WeeklyScheduleViewModel, String>(
183183
value: viewModel,
184-
child: Stack(
185-
fit: StackFit.passthrough,
186-
children: <Widget>[
187-
Column(
188-
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
189-
crossAxisAlignment: CrossAxisAlignment.stretch,
190-
children: <Widget>[
191-
_buildNavigationHeader(context),
192-
Expanded(
184+
child: RefreshIndicator(
185+
onRefresh: () => viewModel.refreshVisibleWeek(),
186+
child: LayoutBuilder(
187+
builder: (context, constraints) {
188+
return SingleChildScrollView(
189+
key: const ValueKey<String>('weekly_schedule_refresh_scroll_view'),
190+
physics: const AlwaysScrollableScrollPhysics(),
191+
child: SizedBox(
192+
height: constraints.maxHeight,
193193
child: Stack(
194+
fit: StackFit.passthrough,
194195
children: <Widget>[
195-
Padding(
196-
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
197-
child: PropertyChangeConsumer<WeeklyScheduleViewModel,
198-
String>(
199-
properties: const ['weekSchedule', 'now'],
200-
builder: (
201-
BuildContext context,
202-
WeeklyScheduleViewModel? model,
203-
Set<String>? properties,
204-
) {
205-
if (model == null) return const SizedBox.shrink();
206-
return _buildAnimatedScheduleViewport(context, model);
207-
},
208-
),
209-
),
210-
PropertyChangeConsumer<WeeklyScheduleViewModel, String>(
211-
properties: const ['isUpdating', 'weekSchedule'],
212-
builder: (
213-
BuildContext context,
214-
WeeklyScheduleViewModel? model,
215-
Set<String>? properties,
216-
) {
217-
if (model == null) return const SizedBox.shrink();
218-
return _TopLoadingIndicator(
219-
isUpdating: model.isUpdating,
220-
showWithoutDelay: model.visibleWeekNeedsInitialFetch,
221-
);
222-
},
223-
),
224-
Positioned(
225-
right: 20,
226-
bottom: 16,
227-
child: _buildCurrentWeekButton(context),
196+
Column(
197+
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
198+
crossAxisAlignment: CrossAxisAlignment.stretch,
199+
children: <Widget>[
200+
_buildNavigationHeader(context),
201+
Expanded(
202+
child: Stack(
203+
children: <Widget>[
204+
Padding(
205+
padding: const EdgeInsets.fromLTRB(8, 0, 8, 8),
206+
child: PropertyChangeConsumer<
207+
WeeklyScheduleViewModel, String>(
208+
properties: const ['weekSchedule', 'now'],
209+
builder: (
210+
BuildContext context,
211+
WeeklyScheduleViewModel? model,
212+
Set<String>? properties,
213+
) {
214+
if (model == null) {
215+
return const SizedBox.shrink();
216+
}
217+
return _buildAnimatedScheduleViewport(
218+
context,
219+
model,
220+
);
221+
},
222+
),
223+
),
224+
PropertyChangeConsumer<WeeklyScheduleViewModel,
225+
String>(
226+
properties: const ['isUpdating', 'weekSchedule'],
227+
builder: (
228+
BuildContext context,
229+
WeeklyScheduleViewModel? model,
230+
Set<String>? properties,
231+
) {
232+
if (model == null) {
233+
return const SizedBox.shrink();
234+
}
235+
return _TopLoadingIndicator(
236+
isUpdating: model.isUpdating,
237+
showWithoutDelay:
238+
model.visibleWeekNeedsInitialFetch,
239+
);
240+
},
241+
),
242+
Positioned(
243+
right: 20,
244+
bottom: 16,
245+
child: _buildCurrentWeekButton(context),
246+
),
247+
],
248+
),
249+
),
250+
],
228251
),
252+
buildErrorDisplay(context),
229253
],
230254
),
231255
),
232-
],
233-
),
234-
buildErrorDisplay(context)
235-
],
256+
);
257+
},
258+
),
236259
),
237260
);
238261
}

pubspec.lock

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -439,10 +439,10 @@ packages:
439439
dependency: transitive
440440
description:
441441
name: meta
442-
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
442+
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
443443
url: "https://pub.dev"
444444
source: hosted
445-
version: "1.18.0"
445+
version: "1.17.0"
446446
mutex:
447447
dependency: "direct main"
448448
description:
@@ -828,10 +828,10 @@ packages:
828828
dependency: transitive
829829
description:
830830
name: test_api
831-
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
831+
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
832832
url: "https://pub.dev"
833833
source: hosted
834-
version: "0.7.11"
834+
version: "0.7.10"
835835
timezone:
836836
dependency: "direct main"
837837
description:

test/schedule/ui/viewmodels/weekly_schedule_background_refresh_test.dart

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,66 @@ void main() {
183183

184184
expect(viewModel.isUpdating, isFalse);
185185
});
186+
187+
test(
188+
'refreshVisibleWeek awaits network refresh before completing',
189+
() async {
190+
final provider = _BlockingScheduleProvider();
191+
final sourceProvider = _FakeScheduleSourceProvider();
192+
final viewModel = WeeklyScheduleViewModel(provider, sourceProvider);
193+
194+
final weekStart = DateTime(2026, 2, 9);
195+
final weekEnd = DateTime(2026, 2, 16);
196+
197+
viewModel.currentDateStart = weekStart;
198+
viewModel.currentDateEnd = weekEnd;
199+
200+
var refreshCompleted = false;
201+
final refreshFuture = viewModel.refreshVisibleWeek().then((_) {
202+
refreshCompleted = true;
203+
});
204+
205+
// Let microtasks run but the blocking provider hasn't completed yet.
206+
await Future<void>.delayed(const Duration(milliseconds: 50));
207+
expect(refreshCompleted, isFalse,
208+
reason: 'refreshVisibleWeek should not complete until network '
209+
'request finishes');
210+
211+
// Now complete the network request.
212+
provider.complete(
213+
ScheduleQueryResult(Schedule(), const []),
214+
);
215+
await refreshFuture;
216+
expect(refreshCompleted, isTrue);
217+
},
218+
);
219+
220+
test(
221+
'refreshVisibleWeek forces fetch even when cache is fresh',
222+
() async {
223+
final entries = <ScheduleEntry>[
224+
_entry(DateTime(2026, 2, 9), 'Mon'),
225+
];
226+
final provider = _CountingScheduleProvider(entries);
227+
final sourceProvider = _FakeScheduleSourceProvider();
228+
final viewModel = WeeklyScheduleViewModel(provider, sourceProvider);
229+
230+
final weekStart = DateTime(2026, 2, 9);
231+
final weekEnd = DateTime(2026, 2, 16);
232+
233+
// Initial load populates cache and marks window as fresh.
234+
await viewModel.updateSchedule(weekStart, weekEnd, force: true);
235+
expect(provider.updatedScheduleRequests, 1);
236+
237+
viewModel.currentDateStart = weekStart;
238+
viewModel.currentDateEnd = weekEnd;
239+
240+
// Pull-to-refresh should fetch again even though cache is fresh.
241+
await viewModel.refreshVisibleWeek();
242+
expect(provider.updatedScheduleRequests, 2,
243+
reason: 'pull-to-refresh must bypass staleness gate');
244+
},
245+
);
186246
}
187247

188248
ScheduleEntry _entry(DateTime start, String suffix) {

test/schedule/ui/weeklyschedule/weekly_schedule_page_lifecycle_test.dart

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,42 @@ void main() {
4343
},
4444
);
4545

46+
testWidgets(
47+
'pull to refresh reloads the visible week',
48+
(tester) async {
49+
final provider = _TrackingScheduleProvider(const <ScheduleEntry>[]);
50+
final sourceProvider = _FakeScheduleSourceProvider();
51+
final viewModel = WeeklyScheduleViewModel(
52+
provider,
53+
sourceProvider,
54+
nowProvider: () => DateTime(2026, 2, 10, 10, 0),
55+
);
56+
57+
await viewModel.updateSchedule(
58+
DateTime(2026, 2, 9),
59+
DateTime(2026, 2, 16),
60+
force: true,
61+
);
62+
63+
await tester.pumpWidget(_wrapWithApp(viewModel));
64+
await tester.pump();
65+
66+
await tester.drag(find.byType(SingleChildScrollView), const Offset(0, 300));
67+
await tester.pump();
68+
await tester.pump(const Duration(milliseconds: 300));
69+
await tester.pump(const Duration(milliseconds: 300));
70+
71+
expect(
72+
provider.updatedOrigins,
73+
contains(ScheduleRefreshOrigin.userBrowsing),
74+
);
75+
76+
await tester.pumpWidget(const SizedBox.shrink());
77+
await tester.pump();
78+
viewModel.dispose();
79+
},
80+
);
81+
4682
testWidgets(
4783
'resume-triggered background refresh keeps monday lessons visible',
4884
(tester) async {

0 commit comments

Comments
 (0)