Skip to content

Commit 53413e8

Browse files
authored
Merge pull request #57 from FarisZR/fix/dualis-session-refresh
fix: keep Dualis logged in and add manual refresh
2 parents e9e5de0 + 8a5886a commit 53413e8

13 files changed

Lines changed: 718 additions & 147 deletions

AGENTS.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ Important runtime behavior:
5050

5151
- Navigation entry and page under `lib/dualis/ui`
5252
- Service stack includes cache decorator + scraper/authentication in `lib/dualis/service`
53+
- Stored Dualis credentials should restore the session automatically when the
54+
Dualis section is shown again; long-idle returns should trigger a refresh
55+
instead of dropping the user back to the login form.
56+
- Logged-in Dualis pages support manual pull-to-refresh, which should force a
57+
cache-busting reload of overview and semester data.
5358

5459
### Schedule (`lib/schedule`)
5560

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
---
2+
module: Dualis UI
3+
date: 2026-05-19
4+
problem_type: ui_bug
5+
component: frontend_flutter
6+
symptoms:
7+
- "Dualis asks the user to log in again after reopening the app"
8+
- "There is no manual pull-to-refresh path on the Dualis pages"
9+
- "Returning to Dualis after a long time does not automatically refresh stale data"
10+
root_cause: dualis_view_model_only_supported_explicit_login_and_never_revalidated_on_page_visibility
11+
resolution_type: code_fix
12+
severity: medium
13+
tags: [dualis, login, session, refresh, cache, flutter, android]
14+
---
15+
16+
# Troubleshooting: Dualis session persistence and refresh
17+
18+
## Problem
19+
Dualis only stayed usable for the current in-memory session. After reopening the app, the page returned to the login form even when credentials were already stored. The logged-in views also lacked a manual refresh affordance.
20+
21+
## Root Cause
22+
The Dualis view model supported explicit login only. It never attempted to restore a stored session when the page became visible again, and it had no persisted freshness timestamp to decide when stale data should be reloaded automatically.
23+
24+
## Solution
25+
- Updated `StudyGradesViewModel` to:
26+
- start in an initializing state so Dualis does not render the logged-out UI before the first saved-credential check completes
27+
- restore the Dualis session from saved credentials when the Dualis section becomes visible
28+
- refresh stale Dualis data automatically when reopening the section after a long gap
29+
- expose `refreshData(force: true)` so the UI can trigger a full cache-busting reload
30+
- track the last successful Dualis refresh in preferences
31+
- Updated `DualisPage` to:
32+
- observe section visibility inside the main `IndexedStack`
33+
- trigger the stale-session refresh flow on first show and on app resume while Dualis is active
34+
- show a loading state while the session is being restored
35+
- Added pull-to-refresh to:
36+
- `StudyOverviewPage`
37+
- `ExamResultsPage`
38+
- Extended the Dualis service contract with `clearCache()` so forced refreshes bypass cached grade/module data.
39+
40+
## Test Coverage
41+
- `login falls back to LoginFailed on unexpected service errors`
42+
- `loadAllModules keeps loading=true for the newest in-flight request`
43+
- `restores the Dualis session from saved credentials on page open`
44+
- `does not show login page before restoring saved session`
45+
- `refreshData(force: true) clears cached Dualis data before reloading`
46+
- existing Dualis loading animation widget tests
47+
48+
## Commands run
49+
```bash
50+
flutter test test/dualis/ui/viewmodels/study_grades_view_model_test.dart test/dualis/ui/study_overview_loading_animation_test.dart test/dualis/ui/dualis_page_session_restore_test.dart
51+
flutter analyze lib/dualis lib/common/data/preferences/preferences_provider.dart test/dualis/ui/viewmodels/study_grades_view_model_test.dart test/dualis/ui/study_overview_loading_animation_test.dart
52+
```

lib/common/data/preferences/preferences_provider.dart

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ class PreferencesProvider {
1818
static const String DualisStoreCredentials = "StoreDualisCredentials";
1919
static const String DualisUsername = "DualisUsername";
2020
static const String DualisPassword = "DualisPassword";
21+
static const String DualisLastRefreshAt = "DualisLastRefreshAt";
2122
static const String LastViewedSemester = "LastViewedSemester";
2223
static const String LastViewedDateEntryDatabase =
2324
"LastViewedDateEntryDatabase";
@@ -165,6 +166,21 @@ class PreferencesProvider {
165166
await _preferencesAccess.set<bool>(DualisStoreCredentials, value);
166167
}
167168

169+
Future<DateTime?> getDualisLastRefreshAt() async {
170+
final storedMs = await _preferencesAccess.get<int>(DualisLastRefreshAt);
171+
if (storedMs == null || storedMs <= 0) {
172+
return null;
173+
}
174+
return DateTime.fromMillisecondsSinceEpoch(storedMs);
175+
}
176+
177+
Future<void> setDualisLastRefreshAt(DateTime value) async {
178+
await _preferencesAccess.set<int>(
179+
DualisLastRefreshAt,
180+
value.millisecondsSinceEpoch,
181+
);
182+
}
183+
168184
Future<String> getLastViewedSemester() async {
169185
return await _preferencesAccess.get<String>(LastViewedSemester) ?? "";
170186
}

lib/dualis/service/cache_dualis_service_decorator.dart

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,11 @@ class CacheDualisServiceDecorator extends DualisService {
2323
String password, [
2424
CancellationToken? cancellationToken,
2525
]) {
26-
return _service.login(username, password, cancellationToken ?? CancellationToken());
26+
return _service.login(
27+
username,
28+
password,
29+
cancellationToken ?? CancellationToken(),
30+
);
2731
}
2832

2933
@override
@@ -34,7 +38,9 @@ class CacheDualisServiceDecorator extends DualisService {
3438
return _allModulesCached!;
3539
}
3640

37-
var allModules = await _service.queryAllModules(cancellationToken ?? CancellationToken());
41+
var allModules = await _service.queryAllModules(
42+
cancellationToken ?? CancellationToken(),
43+
);
3844

3945
_allModulesCached = allModules;
4046

@@ -49,7 +55,10 @@ class CacheDualisServiceDecorator extends DualisService {
4955
if (_semestersCached.containsKey(name)) {
5056
return Future.value(_semestersCached[name]);
5157
}
52-
var semester = await _service.querySemester(name, cancellationToken ?? CancellationToken());
58+
var semester = await _service.querySemester(
59+
name,
60+
cancellationToken ?? CancellationToken(),
61+
);
5362

5463
_semestersCached[name] = semester;
5564

@@ -64,7 +73,9 @@ class CacheDualisServiceDecorator extends DualisService {
6473
return _allSemesterNamesCached!;
6574
}
6675

67-
var allSemesterNames = await _service.querySemesterNames(cancellationToken ?? CancellationToken());
76+
var allSemesterNames = await _service.querySemesterNames(
77+
cancellationToken ?? CancellationToken(),
78+
);
6879

6980
_allSemesterNamesCached = allSemesterNames;
7081

@@ -79,7 +90,9 @@ class CacheDualisServiceDecorator extends DualisService {
7990
return _studyGradesCached!;
8091
}
8192

82-
var studyGrades = await _service.queryStudyGrades(cancellationToken ?? CancellationToken());
93+
var studyGrades = await _service.queryStudyGrades(
94+
cancellationToken ?? CancellationToken(),
95+
);
8396

8497
_studyGradesCached = studyGrades;
8598

@@ -91,12 +104,14 @@ class CacheDualisServiceDecorator extends DualisService {
91104
_allSemesterNamesCached = null;
92105
_semestersCached = {};
93106
_studyGradesCached = null;
107+
_service.clearCache();
94108
}
95109

96110
@override
97111
Future<void> logout([
98112
CancellationToken? cancellationToken,
99113
]) async {
100-
await _service.logout(cancellationToken ?? CancellationToken()); clearCache();
114+
await _service.logout(cancellationToken ?? CancellationToken());
115+
clearCache();
101116
}
102117
}

lib/dualis/service/dualis_service.dart

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ abstract class DualisService {
3232
Future<void> logout([
3333
CancellationToken? cancellationToken,
3434
]);
35+
36+
void clearCache();
3537
}
3638

3739
enum LoginResult {
@@ -155,4 +157,7 @@ class DualisServiceImpl extends DualisService {
155157
]) async {
156158
await _dualisScraper.logout(cancellationToken ?? CancellationToken());
157159
}
160+
161+
@override
162+
void clearCache() {}
158163
}

lib/dualis/ui/dualis_navigation_entry.dart

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import 'package:kiwi/kiwi.dart';
1111
import 'package:property_change_notifier/property_change_notifier.dart';
1212

1313
class DualisNavigationEntry extends NavigationEntry<StudyGradesViewModel> {
14+
static const int sectionIndex = 2;
15+
1416
@override
1517
Widget icon(BuildContext context) {
1618
return Icon(Icons.data_usage);
@@ -31,7 +33,7 @@ class DualisNavigationEntry extends NavigationEntry<StudyGradesViewModel> {
3133

3234
@override
3335
Widget build(BuildContext context) {
34-
return DualisPage();
36+
return const DualisPage(sectionIndex: sectionIndex);
3537
}
3638

3739
@override

lib/dualis/ui/dualis_page.dart

Lines changed: 97 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,67 @@ import 'package:flutter/material.dart';
88
import 'package:property_change_notifier/property_change_notifier.dart';
99
import 'package:provider/provider.dart';
1010

11-
class DualisPage extends StatelessWidget {
11+
class DualisPage extends StatefulWidget {
12+
final int sectionIndex;
13+
14+
const DualisPage({super.key, required this.sectionIndex});
15+
16+
@override
17+
State<DualisPage> createState() => _DualisPageState();
18+
}
19+
20+
class _DualisPageState extends State<DualisPage> with WidgetsBindingObserver {
21+
ValueNotifier<int>? _currentEntryIndex;
22+
23+
@override
24+
void initState() {
25+
super.initState();
26+
WidgetsBinding.instance.addObserver(this);
27+
}
28+
29+
@override
30+
void didChangeDependencies() {
31+
super.didChangeDependencies();
32+
final notifier = Provider.of<ValueNotifier<int>>(context, listen: false);
33+
if (!identical(_currentEntryIndex, notifier)) {
34+
_currentEntryIndex?.removeListener(_handleSectionChanged);
35+
_currentEntryIndex = notifier;
36+
_currentEntryIndex?.addListener(_handleSectionChanged);
37+
}
38+
39+
WidgetsBinding.instance.addPostFrameCallback((_) {
40+
if (!mounted) {
41+
return;
42+
}
43+
_handleSectionChanged();
44+
});
45+
}
46+
47+
@override
48+
void dispose() {
49+
WidgetsBinding.instance.removeObserver(this);
50+
_currentEntryIndex?.removeListener(_handleSectionChanged);
51+
super.dispose();
52+
}
53+
54+
@override
55+
void didChangeAppLifecycleState(AppLifecycleState state) {
56+
if (state != AppLifecycleState.resumed) {
57+
return;
58+
}
59+
60+
_handleSectionChanged();
61+
}
62+
63+
void _handleSectionChanged() {
64+
if (!mounted || _currentEntryIndex?.value != widget.sectionIndex) {
65+
return;
66+
}
67+
68+
final viewModel = Provider.of<StudyGradesViewModel>(context, listen: false);
69+
viewModel.onPageVisible();
70+
}
71+
1272
@override
1373
Widget build(BuildContext context) {
1474
StudyGradesViewModel viewModel =
@@ -24,26 +84,31 @@ class DualisPage extends StatelessWidget {
2484
Set<String>? properties,
2585
) {
2686
final current = model ?? viewModel;
27-
final child = current.loginState == LoginState.LoggedIn
28-
? PagerWidget(
29-
key: const ValueKey<String>('dualis_logged_in_pager'),
30-
pagesId: "dualis_pager",
31-
pages: <PageDefinition>[
32-
PageDefinition(
33-
text: L.of(context).pageDualisOverview,
34-
icon: const Icon(Icons.dashboard),
35-
builder: (BuildContext context) => StudyOverviewPage(),
36-
),
37-
PageDefinition(
38-
text: L.of(context).pageDualisExams,
39-
icon: const Icon(Icons.book),
40-
builder: (BuildContext context) => ExamResultsPage(),
41-
),
42-
],
43-
)
44-
: const DualisLoginPage(
45-
key: ValueKey<String>('dualis_login_page'),
46-
);
87+
final child = switch (current.loginState) {
88+
LoginState.LoggedIn => PagerWidget(
89+
key: const ValueKey<String>('dualis_logged_in_pager'),
90+
pagesId: "dualis_pager",
91+
pages: <PageDefinition>[
92+
PageDefinition(
93+
text: L.of(context).pageDualisOverview,
94+
icon: const Icon(Icons.dashboard),
95+
builder: (BuildContext context) => StudyOverviewPage(),
96+
),
97+
PageDefinition(
98+
text: L.of(context).pageDualisExams,
99+
icon: const Icon(Icons.book),
100+
builder: (BuildContext context) => ExamResultsPage(),
101+
),
102+
],
103+
),
104+
LoginState.Initializing || LoginState.RestoringSession =>
105+
const _DualisSessionLoadingPage(
106+
key: ValueKey<String>('dualis_restoring_page'),
107+
),
108+
_ => const DualisLoginPage(
109+
key: ValueKey<String>('dualis_login_page'),
110+
),
111+
};
47112

48113
return AnimatedSwitcher(
49114
duration: const Duration(milliseconds: 200),
@@ -54,3 +119,14 @@ class DualisPage extends StatelessWidget {
54119
);
55120
}
56121
}
122+
123+
class _DualisSessionLoadingPage extends StatelessWidget {
124+
const _DualisSessionLoadingPage({super.key});
125+
126+
@override
127+
Widget build(BuildContext context) {
128+
return const Center(
129+
child: CircularProgressIndicator(),
130+
);
131+
}
132+
}

0 commit comments

Comments
 (0)