implemented a comprehensive client-side demo mode triggered by a "demo@" username prefix, serving curated fixture data for all app modules

This commit is contained in:
2026-07-07 20:24:16 +02:00
parent 2668b111f1
commit 9b74c9fd81
45 changed files with 1409 additions and 43 deletions
+9
View File
@@ -0,0 +1,9 @@
import '../../marianumconnect/queries/get_breakers/get_breakers_response.dart';
/// Demo fixtures for the app-maintenance breaker.
class DemoBreaker {
const DemoBreaker._();
/// No active rules — the demo app is never blocked.
static GetBreakersResponse none() => GetBreakersResponse([]);
}
+33
View File
@@ -0,0 +1,33 @@
import '../../../state/app/modules/capabilities/bloc/capabilities_state.dart';
import '../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_state.dart';
/// Demo fixtures for the mobile capability flags — everything granted so the
/// demo persona sees every feature (incl. push) as available.
class DemoCapabilities {
const DemoCapabilities._();
static CapabilitiesState state() => const CapabilitiesState(
viewForeignTimetables: true,
pushNotifications: true,
loaded: true,
);
}
/// Demo fixtures for the Nextcloud `files_sharing` capabilities — a permissive
/// "everything enabled, nothing enforced" server profile.
class DemoNextcloudCapabilities {
const DemoNextcloudCapabilities._();
static NextcloudCapabilitiesState state() => const NextcloudCapabilitiesState(
apiEnabled: true,
publicEnabled: true,
publicMultipleLinks: true,
publicUploadEnabled: true,
publicPasswordEnforced: false,
publicExpireEnabled: false,
publicExpireEnforced: false,
groupEnabled: true,
resharing: true,
loaded: true,
);
}
+155
View File
@@ -0,0 +1,155 @@
import '../../marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../marianumcloud/webdav/queries/list_files/list_files_response.dart';
/// Curated WebDAV listings for the demo mode. Everything is built on
/// [CacheableFile] (the app-level model the file UI renders) so no `nextcloud`
/// DAV objects need to be faked and no network is ever touched.
///
/// `hasPreview`/`fileId` are left null on purpose: without them the file list
/// won't try to fetch server-side thumbnails for the fixtures.
class DemoFiles {
const DemoFiles._();
/// Returns the listing for [path] as passed to `FilesDataProvider.listFiles`
/// (`/` or empty for the root, otherwise the joined folder display names).
/// Unknown folders resolve to an empty listing rather than an error.
static ListFilesResponse listing(String path) {
final key = path.replaceAll('/', '').trim();
switch (key) {
case '':
return _root();
case 'Hausaufgaben':
return _hausaufgaben();
case 'Arbeitsblätter':
return _arbeitsblaetter();
case 'Klausuren':
return _klausuren();
case 'Schuljahr 2025-26':
return _schuljahr();
default:
return ListFilesResponse({});
}
}
static ListFilesResponse _root() => ListFilesResponse({
_folder('Hausaufgaben', daysAgo: 1),
_folder('Arbeitsblätter', daysAgo: 3),
_folder('Klausuren', daysAgo: 12),
_folder('Schuljahr 2025-26', daysAgo: 30),
_file('Deutsch Lektüre.pdf', size: 2_480_000, daysAgo: 2),
_file('Mathe Formelsammlung.pdf', size: 890_000, daysAgo: 9),
_file('Bio Referat.pptx', size: 5_120_000, daysAgo: 5),
_file('Stundenplan.pdf', size: 145_000, daysAgo: 14),
});
static ListFilesResponse _hausaufgaben() => ListFilesResponse({
_file(
'Deutsch Aufsatz.docx',
parent: 'Hausaufgaben',
size: 42_000,
daysAgo: 1,
),
_file(
'Mathe Übungen KW28.pdf',
parent: 'Hausaufgaben',
size: 310_000,
daysAgo: 2,
),
_file(
'Englisch Vokabeln.pdf',
parent: 'Hausaufgaben',
size: 96_000,
daysAgo: 4,
),
});
static ListFilesResponse _arbeitsblaetter() => ListFilesResponse({
_file(
'Physik Optik.pdf',
parent: 'Arbeitsblätter',
size: 720_000,
daysAgo: 6,
),
_file(
'Geschichte Weimar.pdf',
parent: 'Arbeitsblätter',
size: 540_000,
daysAgo: 8,
),
});
static ListFilesResponse _klausuren() => ListFilesResponse({
_file(
'Mathe Klausur 1.pdf',
parent: 'Klausuren',
size: 630_000,
daysAgo: 20,
),
_file(
'Deutsch Klausur 1.pdf',
parent: 'Klausuren',
size: 580_000,
daysAgo: 34,
),
});
static ListFilesResponse _schuljahr() => ListFilesResponse({
_folder('Halbjahr 1', parent: 'Schuljahr 2025-26', daysAgo: 40),
_file(
'Klassenliste 10b.pdf',
parent: 'Schuljahr 2025-26',
size: 78_000,
daysAgo: 60,
),
});
static CacheableFile _folder(
String name, {
String? parent,
required int daysAgo,
}) {
final base = parent == null ? '' : '/$parent';
return CacheableFile(
path: '$base/$name/',
isDirectory: true,
name: name,
modifiedAt: _relative(daysAgo),
createdAt: _relative(daysAgo + 30),
);
}
static CacheableFile _file(
String name, {
String? parent,
required int size,
required int daysAgo,
}) {
final base = parent == null ? '' : '/$parent';
return CacheableFile(
path: '$base/$name',
isDirectory: false,
name: name,
mimeType: _mimeFor(name),
size: size,
modifiedAt: _relative(daysAgo),
createdAt: _relative(daysAgo + 30),
);
}
static DateTime _relative(int daysAgo) =>
DateTime.now().subtract(Duration(days: daysAgo, hours: 3));
static String _mimeFor(String name) {
final ext = name.split('.').last.toLowerCase();
switch (ext) {
case 'pdf':
return 'application/pdf';
case 'pptx':
return 'application/vnd.openxmlformats-officedocument.presentationml.presentation';
case 'docx':
return 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
default:
return 'application/octet-stream';
}
}
}
+34
View File
@@ -0,0 +1,34 @@
import '../../marianumconnect/models/mc_holiday.dart';
/// Demo fixtures for the holiday calendar — a couple of upcoming breaks,
/// positioned relative to "now" so they always read as still ahead.
class DemoHolidays {
const DemoHolidays._();
static List<McHoliday> upcoming() {
final today = DateTime.now();
DateTime day(int offset) =>
DateTime(today.year, today.month, today.day).add(Duration(days: offset));
return [
McHoliday(
shortName: 'Herbst',
longName: 'Herbstferien',
startDate: day(28),
endDate: day(42),
),
McHoliday(
shortName: 'Weihnachten',
longName: 'Weihnachtsferien',
startDate: day(90),
endDate: day(104),
),
McHoliday(
shortName: 'Ostern',
longName: 'Osterferien',
startDate: day(180),
endDate: day(194),
),
];
}
}
@@ -0,0 +1,54 @@
import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart';
import '../demo_persona.dart';
/// Demo fixtures for the school calendar — a handful of upcoming events built
/// relative to "now" and consistent with the demo persona (class 10b).
class DemoMarianumDates {
const DemoMarianumDates._();
static List<MarianumDate> events() {
final today = DateTime.now();
DateTime at(int offset, [int hour = 0, int minute = 0]) => DateTime(
today.year,
today.month,
today.day,
hour,
minute,
).add(Duration(days: offset));
return [
MarianumDate(
uid: 'demo-klausur-mathe',
title: 'Klausur Mathematik ${DemoPersona.className}',
description: 'Erste Klausur bei Herr Müller. Themen: Analysis.',
start: at(4, 9, 45),
end: at(4, 11, 15),
isAllDay: false,
),
MarianumDate(
uid: 'demo-wandertag',
title: 'Wandertag',
description: 'Gemeinsamer Ausflug der Jahrgangsstufe 10.',
start: at(11),
end: at(12),
isAllDay: true,
),
MarianumDate(
uid: 'demo-elternsprechtag',
title: 'Elternsprechtag',
description: 'Anmeldung zu den Gesprächen über das Sekretariat.',
start: at(18, 14, 0),
end: at(18, 19, 0),
isAllDay: false,
),
MarianumDate(
uid: 'demo-zeugniskonferenz',
title: 'Zeugniskonferenz',
description: 'Unterrichtsfrei ab der 4. Stunde.',
start: at(32),
end: at(33),
isAllDay: true,
),
];
}
}
@@ -0,0 +1,24 @@
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
/// Demo fixtures for the info messages — a single friendly welcome entry so the
/// section is populated rather than empty.
class DemoMarianumMessage {
const DemoMarianumMessage._();
static MarianumMessageList list() {
final today = DateTime.now();
final date =
'${today.day.toString().padLeft(2, '0')}.${today.month.toString().padLeft(2, '0')}.${today.year}';
return MarianumMessageList(
base: 'https://marianum-fulda.de',
messages: [
MarianumMessage(
name: 'Willkommen in der Marianum-App',
date: date,
url: 'https://marianum-fulda.de',
),
],
);
}
}
+429
View File
@@ -0,0 +1,429 @@
import '../../../model/account_data.dart';
import '../../marianumcloud/talk/chat/get_chat_response.dart';
import '../../marianumcloud/talk/room/get_room_response.dart';
import '../demo_persona.dart';
/// Fixtures for the Talk module while [DemoMode] is active: a curated set of
/// rooms plus a believable German conversation per room. Room tokens are the
/// join between [rooms] and [chat] — they must stay in sync.
class DemoTalk {
const DemoTalk._();
static const String tokenClass = 'demo-klasse-10b';
static const String tokenMathLk = 'demo-mathe-lk';
static const String tokenWeber = 'demo-herr-weber';
static const String tokenWeimar = 'demo-weimar';
static const String tokenSv = 'demo-sv';
static GetRoomResponse rooms() {
return GetRoomResponse({
_room(
id: 100,
token: tokenClass,
type: GetRoomResponseObjectConversationType.group,
name: 'Klasse 10b',
displayName: 'Klasse ${DemoPersona.className}',
lastMessage: _lastOf(tokenClass),
unreadMessages: 2,
isFavorite: true,
),
_room(
id: 200,
token: tokenMathLk,
type: GetRoomResponseObjectConversationType.group,
name: 'Mathe-LK',
displayName: 'Mathe-LK · Herr Müller',
lastMessage: _lastOf(tokenMathLk),
),
_room(
id: 300,
token: tokenWeber,
type: GetRoomResponseObjectConversationType.oneToOne,
name: 'weber',
displayName: 'Herr Weber',
lastMessage: _lastOf(tokenWeber),
unreadMessages: 1,
),
_room(
id: 400,
token: tokenWeimar,
type: GetRoomResponseObjectConversationType.group,
name: 'Fahrt nach Weimar',
displayName: 'Fahrt nach Weimar 🚌',
lastMessage: _lastOf(tokenWeimar),
),
_room(
id: 500,
token: tokenSv,
type: GetRoomResponseObjectConversationType.group,
name: 'SV',
displayName: 'SV Schülervertretung',
lastMessage: _lastOf(tokenSv),
),
});
}
static GetChatResponse chat(String token) =>
GetChatResponse(_messages(token).toSet());
static GetChatResponseObject _lastOf(String token) => _messages(token).last;
static List<GetChatResponseObject> _messages(String token) {
switch (token) {
case tokenClass:
return _classChat();
case tokenMathLk:
return _mathLkChat();
case tokenWeber:
return _weberChat();
case tokenWeimar:
return _weimarChat();
case tokenSv:
return _svChat();
default:
return _genericChat(token);
}
}
static List<GetChatResponseObject> _classChat() {
const token = tokenClass;
var base = 10000;
return [
_msg(
id: base + 1,
token: token,
actor: 'sophie',
display: 'Sophie',
ago: const Duration(days: 1, hours: 4),
message: 'Weiß jemand, was wir in Bio auf haben?',
),
_msg(
id: base + 2,
token: token,
actor: 'max',
display: 'Max',
ago: const Duration(days: 1, hours: 3, minutes: 55),
message: 'S. 42 Nr. 3 und 4, glaube ich',
),
_selfMsg(
id: base + 3,
token: token,
ago: const Duration(days: 1, hours: 3, minutes: 50),
message: 'Ja genau, und das Arbeitsblatt zur Zellteilung 🙌',
reactions: {'👍': 3},
),
_msg(
id: base + 4,
token: token,
actor: 'jonas',
display: 'Jonas',
ago: const Duration(days: 1, hours: 3, minutes: 40),
message: 'Danke euch! 🙏',
),
_msg(
id: base + 5,
token: token,
actor: 'emma',
display: 'Emma',
ago: const Duration(hours: 20),
message: 'Ist die Mathe-Arbeit jetzt am Donnerstag oder Freitag?',
),
_msg(
id: base + 6,
token: token,
actor: 'mueller',
display: 'Herr Müller',
ago: const Duration(hours: 19, minutes: 30),
message: 'Die Arbeit findet am Freitag in der 3. Stunde statt.',
),
_msg(
id: base + 7,
token: token,
actor: 'emma',
display: 'Emma',
ago: const Duration(hours: 19, minutes: 20),
message: 'Danke, Herr Müller!',
),
_msg(
id: base + 8,
token: token,
actor: 'paul',
display: 'Paul',
ago: const Duration(hours: 6),
message: 'Wer kommt morgen mit in die Mensa?',
),
_msg(
id: base + 9,
token: token,
actor: 'mia',
display: 'Mia',
ago: const Duration(hours: 5, minutes: 50),
message: 'Ich bin dabei 🍕',
),
_selfMsg(
id: base + 10,
token: token,
ago: const Duration(hours: 5, minutes: 45),
message: 'Ich auch!',
),
_msg(
id: base + 11,
token: token,
actor: 'sophie',
display: 'Sophie',
ago: const Duration(hours: 3),
message:
'Vergesst nicht, die Einverständniserklärung für Weimar abzugeben 🚌',
reactions: {'👍': 5, '🚌': 2},
),
_msg(
id: base + 12,
token: token,
actor: 'ben',
display: 'Ben',
ago: const Duration(hours: 2, minutes: 10),
message: 'Oh stimmt, hab ich fast vergessen 😅',
),
];
}
static List<GetChatResponseObject> _mathLkChat() {
const token = tokenMathLk;
var base = 20000;
return [
_msg(
id: base + 1,
token: token,
actor: 'mueller',
display: 'Herr Müller',
ago: const Duration(days: 2, hours: 2),
message:
'Zur Vorbereitung auf die Arbeit findet ihr die Übungsblätter in den Dateien.',
),
_selfMsg(
id: base + 2,
token: token,
ago: const Duration(days: 1, hours: 6),
message: 'Danke! Können wir Aufgabe 5 nächste Stunde nochmal besprechen?',
),
_msg(
id: base + 3,
token: token,
actor: 'mueller',
display: 'Herr Müller',
ago: const Duration(days: 1, hours: 5, minutes: 30),
message: 'Klar, wir schauen uns die Kurvendiskussion gemeinsam an.',
reactions: {'👍': 4},
),
_msg(
id: base + 4,
token: token,
actor: 'jonas',
display: 'Jonas',
ago: const Duration(hours: 8),
message: 'Super, danke!',
),
];
}
static List<GetChatResponseObject> _weberChat() {
const token = tokenWeber;
var base = 30000;
return [
_selfMsg(
id: base + 1,
token: token,
ago: const Duration(days: 1, hours: 2),
message:
'Guten Tag Herr Weber, ich war letzte Woche krank. Was muss ich in Englisch nachholen?',
),
_msg(
id: base + 2,
token: token,
actor: 'weber',
display: 'Herr Weber',
ago: const Duration(hours: 4),
message:
'Hallo Lena, gute Besserung! Lies bitte Unit 4 und bereite die Vokabeln vor.',
),
_msg(
id: base + 3,
token: token,
actor: 'weber',
display: 'Herr Weber',
ago: const Duration(hours: 3, minutes: 58),
message: 'Die Präsentation kannst du nächste Woche nachholen.',
),
];
}
static List<GetChatResponseObject> _weimarChat() {
const token = tokenWeimar;
var base = 40000;
return [
_msg(
id: base + 1,
token: token,
actor: 'wagner',
display: 'Frau Wagner',
ago: const Duration(days: 3),
message:
'Wir treffen uns am Montag um 7:45 Uhr am Haupteingang. Bitte pünktlich sein!',
reactions: {'👍': 8},
),
_msg(
id: base + 2,
token: token,
actor: 'paul',
display: 'Paul',
ago: const Duration(days: 2, hours: 20),
message: 'Wie lange sind wir ungefähr unterwegs?',
),
_msg(
id: base + 3,
token: token,
actor: 'wagner',
display: 'Frau Wagner',
ago: const Duration(days: 2, hours: 19),
message: 'Etwa zwei Stunden mit dem Bus. Denkt an Verpflegung!',
),
_selfMsg(
id: base + 4,
token: token,
ago: const Duration(hours: 12),
message: 'Freue mich schon! 🎉',
reactions: {'🎉': 6},
),
];
}
static List<GetChatResponseObject> _svChat() {
const token = tokenSv;
var base = 50000;
return [
_msg(
id: base + 1,
token: token,
actor: 'ben',
display: 'Ben',
ago: const Duration(days: 2, hours: 5),
message: 'Nächste SV-Sitzung ist am Mittwoch in der großen Pause.',
),
_msg(
id: base + 2,
token: token,
actor: 'mia',
display: 'Mia',
ago: const Duration(days: 1, hours: 8),
message: 'Können wir über den Ablauf vom Sommerfest sprechen?',
),
_selfMsg(
id: base + 3,
token: token,
ago: const Duration(hours: 9),
message: 'Gute Idee, ich sammle schon mal Vorschläge.',
reactions: {'👍': 3},
),
];
}
static List<GetChatResponseObject> _genericChat(String token) {
return [
_msg(
id: 90001,
token: token,
actor: 'info',
display: 'MarianumConnect',
ago: const Duration(hours: 1),
message: 'Willkommen im Demo-Chat 👋',
),
];
}
static GetChatResponseObject _selfMsg({
required int id,
required String token,
required Duration ago,
required String message,
Map<String, int>? reactions,
}) => _msg(
id: id,
token: token,
actor: AccountData().getUsername(),
display: DemoPersona.studentName,
ago: ago,
message: message,
reactions: reactions,
);
static GetChatResponseObject _msg({
required int id,
required String token,
required String actor,
required String display,
required Duration ago,
required String message,
Map<String, int>? reactions,
}) => GetChatResponseObject(
id,
token,
GetRoomResponseObjectMessageActorType.user,
actor,
display,
_epoch(ago),
'',
GetRoomResponseObjectMessageType.comment,
true,
'demo-ref-$id',
message,
null,
reactions,
null,
null,
);
static GetRoomResponseObject _room({
required int id,
required String token,
required GetRoomResponseObjectConversationType type,
required String name,
required String displayName,
required GetChatResponseObject lastMessage,
int unreadMessages = 0,
bool isFavorite = false,
}) => GetRoomResponseObject(
id,
token,
type,
name,
displayName,
'',
3,
0,
0,
0,
0,
'demo-session-$id',
false,
false,
0,
false,
false,
true,
lastMessage.timestamp,
isFavorite,
GetRoomResponseObjectParticipantNotificationLevel.defaultLevel,
unreadMessages,
false,
false,
lastMessage.id - unreadMessages,
lastMessage.id,
lastMessage,
null,
null,
null,
);
static int _epoch(Duration ago) =>
DateTime.now().subtract(ago).millisecondsSinceEpoch ~/ 1000;
}
+202
View File
@@ -0,0 +1,202 @@
import '../../marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../../marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
import '../../marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../demo_persona.dart';
/// Curated timetable fixtures for the demo mode. Everything is derived from
/// [DemoPersona] so subjects, teachers and rooms line up with the rest of the
/// demo app, and every week is generated relative to the requested range so
/// navigating forward/backward always lands on a populated 10b schedule.
class DemoTimetable {
const DemoTimetable._();
/// Period → (startHour, startMinute, endHour, endMinute).
static const Map<int, (int, int, int, int)> _periods = {
1: (7, 55, 8, 40),
2: (8, 40, 9, 25),
3: (9, 45, 10, 30),
4: (10, 30, 11, 15),
5: (11, 35, 12, 20),
6: (12, 20, 13, 5),
7: (13, 50, 14, 35),
8: (14, 35, 15, 20),
};
/// Weekday (DateTime.monday..friday) → period → subject short code.
static const Map<int, Map<int, String>> _plan = {
DateTime.monday: {1: 'M', 2: 'M', 3: 'D', 4: 'E', 5: 'BIO', 6: 'GE', 7: 'SP', 8: 'SP'},
DateTime.tuesday: {1: 'E', 2: 'E', 3: 'PH', 4: 'M', 5: 'D', 6: 'REL', 7: 'GE'},
DateTime.wednesday: {1: 'BIO', 2: 'BIO', 3: 'M', 4: 'D', 5: 'E', 6: 'PH'},
DateTime.thursday: {1: 'D', 2: 'GE', 3: 'M', 4: 'M', 5: 'REL', 6: 'BIO', 7: 'SP', 8: 'SP'},
DateTime.friday: {1: 'PH', 2: 'PH', 3: 'E', 4: 'M', 5: 'D', 6: 'GE'},
};
static const Map<String, String> _roomBySubject = {
'M': 'A101',
'D': 'A101',
'E': 'A102',
'BIO': 'B201',
'PH': 'B203',
'GE': 'A101',
'SP': 'Turnhalle 1',
'REL': 'C305',
};
static TimetableGetWeekResponse week(DateTime from, DateTime until) {
final monday = _mondayOf(from);
final entries = <McTimetableEntry>[];
var id = 1;
for (final weekday in _plan.keys) {
final date = monday.add(Duration(days: weekday - DateTime.monday));
final day = _plan[weekday]!;
for (final entry in day.entries) {
entries.add(_entry(id++, date, weekday, entry.key, entry.value));
}
}
return TimetableGetWeekResponse(from: from, until: until, entries: entries);
}
static McTimetableEntry _entry(
int id,
DateTime date,
int weekday,
int period,
String subjectShort,
) {
final times = _periods[period]!;
final teacher = _teacherFor(subjectShort);
// A couple of recurring irregularities so the demo shows off the cancelled
// and substitution rendering.
final cancelled = weekday == DateTime.monday && period == 4;
final substituted = weekday == DateTime.tuesday && period == 3;
return McTimetableEntry(
id: id,
date: date,
startTime: DateTime(1970, 1, 1, times.$1, times.$2),
endTime: DateTime(1970, 1, 1, times.$3, times.$4),
subjects: [subjectShort],
teachers: [
if (substituted)
McTimetableTeacher(
shortName: 'MÜL',
displayName: 'Herr Müller',
originalShortName: teacher.short,
originalDisplayName: teacher.name,
)
else
McTimetableTeacher(shortName: teacher.short, displayName: teacher.name),
],
rooms: [_roomBySubject[subjectShort] ?? DemoPersona.rooms.first],
classNames: [DemoPersona.className],
lessonType: 'LESSON',
status: cancelled
? 'CANCELLED'
: substituted
? 'IRREGULAR'
: 'REGULAR',
substitutionText: substituted ? 'Vertretung für ${teacher.name}' : null,
lessonText: null,
infoText: cancelled ? 'Fällt aus' : null,
);
}
static TimetableGetRoomsResponse rooms() => TimetableGetRoomsResponse(
result: [
for (var i = 0; i < DemoPersona.rooms.length; i++)
McRoom(id: i + 1, shortName: DemoPersona.rooms[i], longName: DemoPersona.rooms[i]),
],
);
static TimetableGetSubjectsResponse subjects() => TimetableGetSubjectsResponse(
result: [
for (var i = 0; i < DemoPersona.subjects.length; i++)
McSubject(
id: i + 1,
shortName: DemoPersona.subjects[i].short,
longName: DemoPersona.subjects[i].name,
),
],
);
static TimetableGetTimegridResponse timegrid() {
final units = <McTimegridUnit>[];
for (final day in _weekdays) {
for (final period in _periods.entries) {
final t = period.value;
units.add(
McTimegridUnit(
dayOfWeek: day,
label: '${period.key}',
startTime: DateTime(1970, 1, 1, t.$1, t.$2),
endTime: DateTime(1970, 1, 1, t.$3, t.$4),
),
);
}
}
return TimetableGetTimegridResponse(result: units);
}
static TimetableGetSchoolyearResponse schoolyear() {
final startYear = _schoolyearStart(DateTime.now());
return TimetableGetSchoolyearResponse(
id: 1,
name: '$startYear/${startYear + 1}',
startDate: DateTime(startYear, 8, 1),
endDate: DateTime(startYear + 1, 7, 31),
);
}
static TimetableGetHolidaysResponse holidays() {
final startYear = _schoolyearStart(DateTime.now());
return TimetableGetHolidaysResponse(
result: [
McHoliday(
shortName: 'Herbst',
longName: 'Herbstferien',
startDate: DateTime(startYear, 10, 20),
endDate: DateTime(startYear, 10, 31),
),
McHoliday(
shortName: 'Weihnachten',
longName: 'Weihnachtsferien',
startDate: DateTime(startYear, 12, 22),
endDate: DateTime(startYear + 1, 1, 6),
),
],
);
}
static GetCustomTimetableEventResponse customEvents() =>
GetCustomTimetableEventResponse([]);
static const List<McDayOfWeek> _weekdays = [
McDayOfWeek.monday,
McDayOfWeek.tuesday,
McDayOfWeek.wednesday,
McDayOfWeek.thursday,
McDayOfWeek.friday,
];
static DemoTeacher _teacherFor(String subjectShort) {
final subjectName = DemoPersona.subjects
.firstWhere((s) => s.short == subjectShort)
.name;
return DemoPersona.teachers.firstWhere((t) => t.subject == subjectName);
}
static DateTime _mondayOf(DateTime date) {
final day = DateTime(date.year, date.month, date.day);
return day.subtract(Duration(days: day.weekday - DateTime.monday));
}
static int _schoolyearStart(DateTime now) =>
now.month >= 8 ? now.year : now.year - 1;
}
+54
View File
@@ -0,0 +1,54 @@
import 'data/demo_breaker.dart';
import 'data/demo_holidays.dart';
import 'data/demo_timetable.dart';
/// Single source of truth for the MarianumConnect demo responses. The demo
/// interceptor asks this for the body of any MC request. Read endpoints reuse
/// the typed fixtures and serialize them to the exact shape the matching query
/// parses (a bare list vs. an object); write/side-effect endpoints (telemetry,
/// client-errors, auth, push, feedback) have no consumed body and resolve to an
/// empty success.
class DemoMarianumConnect {
const DemoMarianumConnect._();
/// Body for [path] (relative to `/api/mobile/v1/`), using [query] for the
/// timetable date window.
static Object responseBody(String path, Map<String, dynamic> query) {
switch (path) {
case 'timetable/me':
return DemoTimetable.week(
_date(query['from']),
_date(query['until']),
).toJson();
case 'timetable/schoolyear':
return DemoTimetable.schoolyear().toJson();
case 'timetable/rooms':
return DemoTimetable.rooms().result.map((e) => e.toJson()).toList();
case 'timetable/subjects':
return DemoTimetable.subjects().result.map((e) => e.toJson()).toList();
case 'timetable/timegrid':
return DemoTimetable.timegrid().result.map((e) => e.toJson()).toList();
case 'timetable/holidays':
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'breaker':
return DemoBreaker.none().toJson();
case 'timetable/elements/teachers':
case 'timetable/elements/students':
case 'timetable/elements/classes':
return const <dynamic>[];
}
// A foreign element's week (timetable/<type>/<id>) shows the same demo week.
if (path.startsWith('timetable/')) {
return DemoTimetable.week(
_date(query['from']),
_date(query['until']),
).toJson();
}
return const <String, dynamic>{};
}
static DateTime _date(Object? raw) =>
DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now();
}
@@ -0,0 +1,35 @@
import 'package:dio/dio.dart';
import 'demo_marianumconnect.dart';
import 'demo_mode.dart';
/// Answers every MarianumConnect request from fixtures while a demo session is
/// active, so the app makes no MC network calls. Installed first on the MC dio
/// so it resolves before the auth interceptor tries to attach a (non-existent)
/// bearer token.
class DemoMarianumConnectInterceptor extends Interceptor {
static const _apiMarker = '/api/mobile/v1/';
@override
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
if (!DemoMode.active) {
handler.next(options);
return;
}
handler.resolve(
Response<dynamic>(
requestOptions: options,
statusCode: 200,
data: DemoMarianumConnect.responseBody(
_relativePath(options.uri),
options.queryParameters,
),
),
);
}
static String _relativePath(Uri uri) {
final i = uri.path.indexOf(_apiMarker);
return i >= 0 ? uri.path.substring(i + _apiMarker.length) : uri.path;
}
}
+27
View File
@@ -0,0 +1,27 @@
import '../../model/account_data.dart';
/// Central switch for the client-side demo mode.
///
/// A login whose username starts with [usernamePrefix] (the password is
/// ignored) enters demo mode: instead of talking to the real backends, the app
/// serves curated fixtures from `DemoData` for every feature. This lets Google
/// Play reviewers and automated screenshot runs see a fully populated app
/// without a real account and without any network dependency.
///
/// The flag is persisted through [AccountData.isDemo], so a demo session
/// survives cold starts exactly like a normal login. It works in release builds
/// too — reviewers run the shipped release build.
class DemoMode {
const DemoMode._();
/// Usernames starting with this prefix trigger demo mode. Whatever the
/// reviewer types as the password is accepted.
static const String usernamePrefix = 'demo@';
/// Whether [username], as typed on the login screen, should enter demo mode.
static bool matches(String username) =>
username.trim().toLowerCase().startsWith(usernamePrefix);
/// True while the active session is a demo session.
static bool get active => AccountData().isDemo;
}
+58
View File
@@ -0,0 +1,58 @@
/// Shared identity for the demo mode so fixtures across every feature describe
/// the same fictional student, class, teachers and rooms — the timetable, chats
/// and grades all line up. See DemoMode.
class DemoPersona {
const DemoPersona._();
static const String studentFirstName = 'Lena';
static const String studentName = 'Lena Hoffmann';
static const String className = '10b';
static const List<DemoTeacher> teachers = [
DemoTeacher('Herr Müller', 'MÜL', 'Mathematik'),
DemoTeacher('Frau Schmidt', 'SMT', 'Deutsch'),
DemoTeacher('Herr Weber', 'WEB', 'Englisch'),
DemoTeacher('Frau Becker', 'BEC', 'Biologie'),
DemoTeacher('Herr Hofmann', 'HOF', 'Physik'),
DemoTeacher('Frau Wagner', 'WAG', 'Geschichte'),
DemoTeacher('Herr Klein', 'KLN', 'Sport'),
DemoTeacher('Frau Richter', 'RIC', 'Religion'),
];
static const List<DemoSubject> subjects = [
DemoSubject('Mathematik', 'M', 0xFF4E79A7),
DemoSubject('Deutsch', 'D', 0xFFE15759),
DemoSubject('Englisch', 'E', 0xFFF28E2B),
DemoSubject('Biologie', 'BIO', 0xFF59A14F),
DemoSubject('Physik', 'PH', 0xFF76B7B2),
DemoSubject('Geschichte', 'GE', 0xFFB07AA1),
DemoSubject('Sport', 'SP', 0xFFEDC948),
DemoSubject('Religion', 'REL', 0xFF9C755F),
];
static const List<String> rooms = [
'A101',
'A102',
'B201',
'B203',
'C305',
'Turnhalle 1',
'Aula',
];
}
class DemoTeacher {
final String name;
final String short;
final String subject;
const DemoTeacher(this.name, this.short, this.subject);
}
class DemoSubject {
final String name;
final String short;
/// ARGB color used where the UI colors a lesson/subject.
final int color;
const DemoSubject(this.name, this.short, this.color);
}
@@ -14,6 +14,9 @@ class SessionValidator {
required Future<void> Function() onInvalidated,
}) async {
if (!AccountData().isPopulated()) return;
// AuthVerify uses its own dio (bypassing the demo interceptor), so a demo
// session must be skipped here or its missing token would 401 into a logout.
if (AccountData().isDemo) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
try {
@@ -1,5 +1,6 @@
import 'package:dio/dio.dart';
import '../demo/demo_marianumconnect_interceptor.dart';
import 'auth/auth_interceptor.dart';
/// Singleton dio instance for the Marianum-Connect mobile API. Wired with the
@@ -24,6 +25,7 @@ class MarianumConnectApi {
contentType: 'application/json',
),
);
dio.interceptors.add(DemoMarianumConnectInterceptor());
dio.interceptors.add(MarianumConnectAuthInterceptor());
return dio;
}