The problem
I built XRooster as an alternative to ConnectMe, which made me log in repeatedly to check my MyX schedule. My school now accesses MyX through Eduarte, which doesn't require those repeated logins, so I no longer need XRooster. Other schools still use MyX directly, so the app may still be useful to them.
Why these choices
- Flutter. One codebase for Android and iOS when the entire product is a list view and some navigation. That's the clearest case for it that exists.
- Dio over the built-in HTTP client. The reason is one interceptor. Every response and every error passes through it, which is where connectivity state, token expiry, and error mapping all live - in one place instead of scattered across every call site.
- Demo mode built into the API client. A
demoModeflag swaps the network for a bundled JSON fixture. Screenshots, reviews, and my own testing all work with no account and no school token. It ships in the app, not behind a build flag, so it's always testable. - Schools as a generated list. The README's school table is generated from
assets/schools.json, so adding a school doesn't mean editing markdown.
Handling connection and sign-in errors
Connectivity and auth failures are detected in a single
InterceptorsWrapper and pushed into a
ValueNotifier the whole widget tree listens to. The UI never
has to inspect an error object to decide whether to show an offline
banner - it just reads a bool.
_dio.interceptors.add(
InterceptorsWrapper(
onResponse: (response, handler) {
isOnlineNotifier.value = true;
handler.next(response);
},
onError: (DioException e, handler) async {
if (e.error is SocketException) {
isOnlineNotifier.value = false;
}
if (isOnlineNotifier.value != true) {
handler.next(e);
return;
}
final statusCode = e.response?.statusCode ?? 000;
if (statusCode == 401) {
debugPrint("Interceptor: MyX Token invalid!");
notifyListeners();
}
handler.next(e);
},
),
);
The early return on isOnlineNotifier.value != true stops
token-expiry handling from firing on requests that only failed because
the network was already down. Otherwise a school with bad wifi logs
every user out.
Demo mode is the same interface
_getDemoList has the same signature as the real call, so
nothing above the API layer knows or cares which one it's talking to.
Appointments get their timestamps rebuilt relative to today, which means
the demo shows a plausible current week instead of a frozen date.
Future<Map<String, dynamic>> _loadDemoData() async => _demoDataCache ??=
jsonDecode(await rootBundle.loadString('assets/demo_schedule.json'));
final appointments = rawAppointments.map((rawItem) {
final start = dayStart.add(
Duration(
days: data['dayOffset'],
hours: data['startHour'],
minutes: data['startMinute'],
),
);
return Appointment.fromJson({
...data,
'start': formatter.format(start),
'endTimeUnit': start.hour * 60 + start.minute,
});
}).toList();
appointments.sort((first, second) => first.start.compareTo(second.start));
Sorting last because the fixture isn't guaranteed to be ordered - the timetable groups by day, so anything out of order would render in the wrong column.
What I'd change
- Certificate validation is disabled outright.
badCertificateCallbackreturns true for everything, which was a workaround for school servers with expired certs. It should be scoped to specific hosts instead of trusting all of them. - The auth token is a global mutable variable. There's already a
tokenOverrideparameter to inject it per instance, but the global is still the default path. The global should go. - Offline caching is partial. The API has a
SharedPreferencesWithCache, but the whole schedule should be cached as a unit so opening the app on bad signal still shows today.