← All projects

XRooster

An open-source mobile app for MyX school schedules. I built it as an alternative to ConnectMe's repeated login prompts.

In production Dart Flutter Dio Android / iOS
XRooster mobile app screenshot

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

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.

dartlib/api/myx.dart
_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.

dartlib/api/myx.dart
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