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;
}
+26
View File
@@ -18,6 +18,10 @@ class AccountData {
// token, so two registrations need two app passwords.
static const _appPasswordField = 'nextcloud_app_password';
static const _appPasswordTalkField = 'nextcloud_app_password_talk';
// Persists the demo session across cold starts (see DemoMode).
static const _demoField = 'is_demo';
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
static const _demoPasswordPlaceholder = 'demo';
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
@@ -34,6 +38,10 @@ class AccountData {
String? _password;
String? _appPassword;
String? _appPasswordTalk;
bool _isDemo = false;
/// True while the active session is a local demo session (see DemoMode).
bool get isDemo => _isDemo;
String getUsername() {
if (_username == null) throw Exception('Username not initialized');
@@ -65,14 +73,31 @@ class AccountData {
if (!_populated.isCompleted) _populated.complete();
}
/// Enters a local demo session for [username] — no real credentials or token;
/// every backend is served from fixtures while [isDemo] is true (see DemoMode).
Future<void> setDemo(String username) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(
key: _passwordField,
value: _demoPasswordPlaceholder,
);
await _secureStorage.write(key: _demoField, value: 'true');
_username = username;
_password = _demoPasswordPlaceholder;
_isDemo = true;
if (!_populated.isCompleted) _populated.complete();
}
Future<void> removeData() async {
_populated = Completer();
_username = null;
_password = null;
_appPassword = null;
_appPasswordTalk = null;
_isDemo = false;
await _secureStorage.delete(key: _usernameField);
await _secureStorage.delete(key: _passwordField);
await _secureStorage.delete(key: _demoField);
await _clearAppPasswordStorage();
await _clearAppPasswordTalkStorage();
}
@@ -139,6 +164,7 @@ class AccountData {
await _migrateFromLegacyStorage();
_username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField);
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
_appPasswordTalk = await pushSecureStorage.read(
+3
View File
@@ -5,6 +5,7 @@ import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:nextcloud/notifications.dart' show generatePushTokenHash;
import 'package:package_info_plus/package_info_plus.dart';
import '../api/demo/demo_mode.dart';
import '../api/marianumcloud/app_password/delete_app_password.dart';
import '../api/marianumcloud/app_password/get_app_password.dart';
import '../api/marianumconnect/marianumconnect_endpoint.dart';
@@ -93,6 +94,7 @@ class PushRegistration {
/// transport failures are logged and swallowed so callers can
/// fire-and-forget (and simply ignore the result).
Future<bool> register() async {
if (DemoMode.active) return false;
final String? fcmToken;
try {
fcmToken = await FirebaseMessaging.instance.getToken();
@@ -383,6 +385,7 @@ class PushRegistration {
/// is called with), then clear them locally. Ordered so the proxy stops
/// pushing before credentials are gone.
Future<void> logoutCleanup() async {
if (DemoMode.active) return;
await unregister();
try {
await DeleteAppPassword().run();
@@ -12,9 +12,9 @@ class BreakerDataProvider {
bool renew = true,
void Function(Object)? onError,
}) => resolveFromCache<GetBreakersResponse>(
(onUpdate, onError) =>
GetBreakersCache(renew: renew, onUpdate: onUpdate, onError: onError),
onError: onError,
operationName: 'getBreakers',
);
(onUpdate, onError) =>
GetBreakersCache(renew: renew, onUpdate: onUpdate, onError: onError),
onError: onError,
operationName: 'getBreakers',
);
}
@@ -2,6 +2,8 @@ import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/get_capabilities/get_capabilities.dart';
import 'capabilities_state.dart';
@@ -20,6 +22,10 @@ class CapabilitiesCubit extends HydratedCubit<CapabilitiesState> {
/// state is marked `loaded` — a failed fetch never silently grants a
/// capability, and an offline launch keeps whatever was cached.
Future<void> load() async {
if (DemoMode.active) {
emit(DemoCapabilities.state());
return;
}
try {
final response = await GetCapabilities().run();
emit(
@@ -1,3 +1,5 @@
import '../../../../../api/demo/data/demo_talk.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_cache.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
@@ -8,6 +10,12 @@ class ChatDataProvider {
void Function(GetChatResponse data)? onNetworkData,
void Function(Object)? onError,
}) async {
if (DemoMode.active) {
final data = DemoTalk.chat(token);
onCacheData?.call(data);
onNetworkData?.call(data);
return;
}
final cache = GetChatCache(
chatToken: token,
onCacheData: onCacheData,
@@ -1,3 +1,5 @@
import '../../../../../api/demo/data/demo_talk.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumcloud/talk/create_room/create_room.dart';
import '../../../../../api/marianumcloud/talk/create_room/create_room_params.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_cache.dart';
@@ -8,13 +10,18 @@ class ChatListDataProvider {
Future<GetRoomResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) => resolveFromCache<GetRoomResponse>(
(onUpdate, onError) =>
GetRoomCache(renew: renew, onUpdate: onUpdate, onError: onError),
onError: onError,
operationName: 'getRooms',
);
}) {
if (DemoMode.active) return Future.value(DemoTalk.rooms());
return resolveFromCache<GetRoomResponse>(
(onUpdate, onError) =>
GetRoomCache(renew: renew, onUpdate: onUpdate, onError: onError),
onError: onError,
operationName: 'getRooms',
);
}
Future<void> createDirectRoom(String invite) =>
CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run();
Future<void> createDirectRoom(String invite) {
if (DemoMode.active) return Future.value();
return CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run();
}
}
@@ -1,5 +1,7 @@
import 'package:nextcloud/nextcloud.dart';
import '../../../../../api/demo/data/demo_files.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart';
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../../../api/marianumcloud/webdav/webdav_api.dart';
@@ -19,20 +21,24 @@ class FilesDataProvider {
void Function(Object)? onError,
bool renew = false,
void Function(int nextAttempt, int maxAttempts)? onRetry,
}) => resolveFromCache<ListFilesResponse>(
(onUpdate, onError) => ListFilesCache(
path: path,
onUpdate: onUpdate,
onCacheData: onCacheData,
}) {
if (DemoMode.active) return Future.value(DemoFiles.listing(path));
return resolveFromCache<ListFilesResponse>(
(onUpdate, onError) => ListFilesCache(
path: path,
onUpdate: onUpdate,
onCacheData: onCacheData,
onError: onError,
renew: renew,
onRetry: onRetry,
),
onError: onError,
renew: renew,
onRetry: onRetry,
),
onError: onError,
operationName: 'listFiles',
);
operationName: 'listFiles',
);
}
Future<void> createFolder(String fullPath) async {
if (DemoMode.active) return;
final webdav = await WebdavApi.webdav;
await webdav.mkcol(PathUri.parse(fullPath));
}
@@ -1,15 +1,19 @@
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/demo_mode.dart';
import 'grade_averages_event.dart';
import 'grade_averages_state.dart';
class GradeAveragesBloc
extends HydratedBloc<GradeAveragesEvent, GradeAveragesState> {
// Seeded with a realistic spread in demo mode (average 2.0); empty otherwise.
static const List<int> _demoGrades = [1, 2, 2, 2, 3, 2, 1, 3, 2, 2];
GradeAveragesBloc()
: super(
const GradeAveragesState(
GradeAveragesState(
gradingSystem: GradeAveragesGradingSystem.middleSchool,
grades: [],
grades: DemoMode.active ? _demoGrades : const [],
),
) {
on<GradingSystemChanged>((event, emit) {
@@ -1,7 +1,12 @@
import '../../../../../api/demo/data/demo_marianum_dates.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/marianum_dates_state.dart';
import '../data_provider/marianum_dates_get_events.dart';
class MarianumDatesRepository extends Repository<MarianumDatesState> {
Future<List<MarianumDate>> getEvents() => MarianumDatesGetEvents().run();
Future<List<MarianumDate>> getEvents() {
if (DemoMode.active) return Future.value(DemoMarianumDates.events());
return MarianumDatesGetEvents().run();
}
}
@@ -1,8 +1,12 @@
import '../../../../../api/demo/data/demo_marianum_message.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../infrastructure/repository/repository.dart';
import '../bloc/marianum_message_state.dart';
import '../data_provider/marianum_message_get_messages.dart';
class MarianumMessageRepository extends Repository<MarianumMessageState> {
Future<MarianumMessageList> getMessages() =>
MarianumMessageGetMessages().run();
Future<MarianumMessageList> getMessages() {
if (DemoMode.active) return Future.value(DemoMarianumMessage.list());
return MarianumMessageGetMessages().run();
}
}
@@ -2,6 +2,8 @@ import 'dart:developer';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../../api/demo/data/demo_capabilities.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumcloud/capabilities/get_nextcloud_capabilities.dart';
import 'nextcloud_capabilities_state.dart';
@@ -57,6 +59,10 @@ class NextcloudCapabilitiesCubit
/// Refreshes capabilities from the server. On any failure the previously
/// hydrated flags are kept but the state is marked `loaded`.
Future<void> load() async {
if (DemoMode.active) {
emit(DemoNextcloudCapabilities.state());
return;
}
try {
final caps = await GetNextcloudCapabilities().run();
emit(
@@ -1,3 +1,5 @@
import '../../../../../api/demo/data/demo_timetable.dart';
import '../../../../../api/demo/demo_mode.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
@@ -95,27 +97,36 @@ class TimetableDataProvider {
Future<GetCustomTimetableEventResponse> getCustomEvents({
bool renew = false,
void Function(Object)? onError,
}) => resolveFromCache<GetCustomTimetableEventResponse>(
}) {
if (DemoMode.active) return Future.value(DemoTimetable.customEvents());
return resolveFromCache<GetCustomTimetableEventResponse>(
(onUpdate, onError) => GetCustomTimetableEventCache(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
renew: renew,
onUpdate: onUpdate,
onError: onError,
),
onError: onError,
operationName: 'getCustomEvents',
);
onError: onError,
operationName: 'getCustomEvents',
);
}
Future<void> addCustomEvent(CustomTimetableEvent event) =>
AddCustomTimetableEvent(
AddCustomTimetableEventParams(AccountData().getUserSecret(), event),
).run();
Future<void> addCustomEvent(CustomTimetableEvent event) {
if (DemoMode.active) return Future.value();
return AddCustomTimetableEvent(
AddCustomTimetableEventParams(AccountData().getUserSecret(), event),
).run();
}
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) =>
UpdateCustomTimetableEvent(
UpdateCustomTimetableEventParams(id, event),
).run();
Future<void> updateCustomEvent(String id, CustomTimetableEvent event) {
if (DemoMode.active) return Future.value();
return UpdateCustomTimetableEvent(
UpdateCustomTimetableEventParams(id, event),
).run();
}
Future<void> removeCustomEvent(String id) =>
RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run();
Future<void> removeCustomEvent(String id) {
if (DemoMode.active) return Future.value();
return RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run();
}
}
+15
View File
@@ -3,6 +3,7 @@ import 'dart:developer';
import 'package:flutter/foundation.dart';
import '../../api/demo/demo_mode.dart';
import '../../api/errors/auth_exception.dart';
import '../../api/errors/error_mapper.dart';
import '../../api/marianumconnect/auth/device_token_name.dart';
@@ -33,6 +34,20 @@ class LoginController extends ChangeNotifier {
notifyListeners();
final user = username.trim().toLowerCase();
// Demo login: the prefix enters local demo mode, password ignored, no
// network (see DemoMode).
if (DemoMode.matches(user)) {
await AccountData().removeData();
await const MarianumConnectTokenStorage().clear();
await WidgetSync.clear();
await WidgetSync.triggerUpdate();
await AccountData().setDemo(user);
_loading = false;
notifyListeners();
return true;
}
try {
await AccountData().removeData();
// Vorherigen Token revoken bevor wir einen neuen anfordern — ein altes
@@ -11,6 +11,7 @@ import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capa
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart';
import 'share_password_sheet.dart';
@@ -35,6 +36,7 @@ void showShareOptionsSheet(
Share share, {
required VoidCallback onChanged,
}) {
if (guardDemoAction(context)) return;
showDetailsBottomSheet(
context,
header: ListTile(
@@ -13,6 +13,7 @@ import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capa
import '../../../../utils/clipboard_helper.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/info_dialog.dart';
import '../widgets/file_leading.dart';
@@ -23,6 +24,7 @@ import 'share_password_sheet.dart';
/// gated actions to create new ones (public link, user/group). Capability
/// gating mirrors the Nextcloud web UI.
void showShareSheet(BuildContext context, CacheableFile file) {
if (guardDemoAction(context)) return;
showDetailsBottomSheet(
context,
header: ListTile(
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../../state/app/modules/files/bloc/files_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/file_pick.dart';
@@ -13,6 +14,7 @@ void showAddFileSheet(
required FilesBloc bloc,
required Future<void> Function(List<String>? paths) onPickedFiles,
}) {
if (guardDemoAction(context)) return;
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
@@ -15,6 +15,7 @@ import '../../../../utils/file_clipboard.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/downloads/download_trigger.dart';
import '../../../../widget/info_dialog.dart';
@@ -105,6 +106,7 @@ class _FileElementState extends State<FileElement>
);
return;
}
if (guardDemoAction(context)) return;
if (EndpointData().getEndpointMode() == EndpointMode.stage) {
InfoDialog.show(
context,
@@ -135,6 +137,7 @@ class _FileElementState extends State<FileElement>
isDirectory ? '$folder$name/' : '$folder$name';
Future<void> _rename() async {
if (guardDemoAction(context)) return;
final controller = TextEditingController(text: widget.file.name);
try {
final newName = await showDialog<String>(
@@ -182,6 +185,7 @@ class _FileElementState extends State<FileElement>
}
void _putOnClipboard({required bool copy}) {
if (guardDemoAction(context)) return;
if (copy) {
FileClipboard.instance.copy([widget.file]);
} else {
@@ -198,6 +202,7 @@ class _FileElementState extends State<FileElement>
}
Future<void> _delete() async {
if (guardDemoAction(context)) return;
await showDialog<void>(
context: context,
builder: (context) => ConfirmDialog(
@@ -269,6 +274,7 @@ class _FileElementState extends State<FileElement>
title: const Text('Im Talk-Chat versenden'),
onTap: () {
Navigator.of(sheetCtx).pop();
if (guardDemoAction(context)) return;
AppRoutes.openInternalShareToChat(
context,
RemoteFileRef.fromCacheable(widget.file),
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
import '../../../../api/marianumconnect/queries/submit_feedback/submit_feedback.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/file_pick.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../../../widget/info_dialog.dart';
@@ -50,6 +51,7 @@ class _FeedbackDialogState extends State<FeedbackDialog> {
}
Future<void> _submit() async {
if (guardDemoAction(context)) return;
if (_feedbackInput.text.trim().isEmpty) {
setState(() => _textFieldEmpty = true);
return;
@@ -11,6 +11,7 @@ import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/avatar_actions_sheet.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/large_profile_picture_view.dart';
import '../../../../widget/user_avatar.dart';
@@ -51,6 +52,7 @@ class _AccountSectionState extends State<AccountSection> {
}
Future<void> _editAvatar() async {
if (guardDemoAction(context)) return;
final result = await showAvatarActionsSheet(context, allowRemove: true);
if (result == null || !mounted) return;
@@ -12,6 +12,7 @@ import '../../../../push/push_status.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
/// Opens the push status checklist: one row per link in the push chain with
@@ -92,6 +93,7 @@ class _PushStatusBodyState extends State<_PushStatusBody>
}
Future<void> _reRegister() async {
if (guardDemoAction(context)) return;
if (_busy) return;
setState(() => _busy = true);
final messenger = ScaffoldMessenger.of(context);
@@ -111,6 +113,7 @@ class _PushStatusBodyState extends State<_PushStatusBody>
}
Future<void> _sendTest() async {
if (guardDemoAction(context)) return;
if (_busy) return;
setState(() => _busy = true);
final messenger = ScaffoldMessenger.of(context);
+2
View File
@@ -11,6 +11,7 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/demo_restricted.dart';
import '../../../widget/placeholder_view.dart';
import 'join_chat.dart';
import 'notification_permission_prompt.dart';
@@ -100,6 +101,7 @@ class _ChatListViewState extends State<_ChatListView> {
heroTag: 'createChat',
backgroundColor: Theme.of(context).primaryColor,
onPressed: () {
if (guardDemoAction(context)) return;
showSearch(context: context, delegate: JoinChat()).then((
username,
) {
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
/// Opens an existing 1:1 chat with [actorId] or, after confirmation, creates
/// one. Shared between the group-message context menu and the reactions
@@ -14,6 +15,7 @@ void openOrCreateDirectChat(
required String actorId,
required String actorDisplayName,
}) {
if (guardDemoAction(context)) return;
final chatListBloc = context.read<ChatListBloc>();
GetRoomResponseObject? findExisting() {
@@ -9,6 +9,7 @@ import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../utils/downloads/download_job.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/downloads/download_trigger.dart';
import '../data/chat_bubble_styles.dart';
import '../data/chat_message.dart';
@@ -177,6 +178,7 @@ class _ChatBubbleState extends State<ChatBubble>
return;
}
if (message.file == null) return;
if (guardDemoAction(context)) return;
if (isDownloading) {
confirmCancelDownload();
} else {
@@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/emoji_text.dart';
/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles
@@ -60,6 +61,7 @@ class ChatBubbleReactions extends StatelessWidget {
? Theme.of(context).primaryColor
: null,
onPressed: () {
if (guardDemoAction(context)) return;
runWithErrorDialog(context, () async {
if (hasSelfReacted) {
await DeleteReactMessage(
@@ -14,6 +14,7 @@ import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/emoji_picker_dialog.dart';
import '../../../../widget/emoji_text.dart';
@@ -64,6 +65,7 @@ void showChatMessageOptionsDialog(
leading: const Icon(Icons.reply_outlined),
title: const Text('Antworten'),
onTap: () {
if (guardDemoAction(sheetCtx)) return;
sheetCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
Navigator.of(sheetCtx).pop();
},
@@ -96,6 +98,7 @@ void showChatMessageOptionsDialog(
leading: const Icon(Icons.cloud_outlined),
title: const Text('In Cloud speichern'),
onTap: () {
if (guardDemoAction(sheetCtx)) return;
Navigator.of(sheetCtx).pop();
if (!parentContext.mounted) return;
AppRoutes.openInternalSaveToFolder(
@@ -109,6 +112,7 @@ void showChatMessageOptionsDialog(
leading: const Icon(Icons.shortcut_outlined),
title: const Text('Weiterleiten'),
onTap: () {
if (guardDemoAction(sheetCtx)) return;
Navigator.of(sheetCtx).pop();
if (!parentContext.mounted) return;
AppRoutes.openForwardMessageToChat(
@@ -143,6 +147,7 @@ void showChatMessageOptionsDialog(
leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'),
onTap: () {
if (guardDemoAction(sheetCtx)) return;
ConfirmDialog(
title: 'Nachricht löschen?',
content: 'Die Nachricht wird für alle Teilnehmer gelöscht.',
@@ -189,6 +194,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
}
Future<void> _react(String emoji) async {
if (guardDemoAction(context)) return;
final ok = await _controller.run(() async {
await ReactMessage(
chatToken: widget.chatToken,
@@ -12,6 +12,7 @@ import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/downloads/download_tray.dart';
import '../../../../widget/emoji_picker_dialog.dart';
@@ -125,6 +126,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
}
void _showAttachmentSheet() {
if (guardDemoAction(context)) return;
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
@@ -187,6 +189,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
Future<void> _sendMessage(ChatBloc chatBloc) async {
if (_textBoxController.text.isEmpty) return;
if (guardDemoAction(context)) return;
final text = _textBoxController.text;
final replyTo = chatBloc.state.data?.referenceMessageId?.toString();
final ownToken = widget.sendToToken;
@@ -10,6 +10,7 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'
import '../../../../extensions/date_time.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/focus_behaviour.dart';
import 'custom_event_colors.dart';
@@ -130,6 +131,7 @@ class _CustomEventEditDialogState extends State<CustomEventEditDialog> {
end.second == 0;
Future<void> _save() async {
if (guardDemoAction(context)) return;
if (_name.text.trim().isEmpty) {
throw Exception('Bitte einen Terminnamen eingeben.');
}
@@ -6,12 +6,14 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
Completer<void> showDeleteCustomEventDialog(
BuildContext context,
CustomTimetableEvent event,
) {
final completer = Completer<void>();
if (guardDemoAction(context)) return completer;
final bloc = context.read<TimetableBloc>();
ConfirmDialog(
title: 'Termin löschen',
+2
View File
@@ -11,6 +11,7 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
import '../../../utils/haptics.dart';
import '../../../widget/demo_restricted.dart';
import 'custom_events/custom_event_edit_dialog.dart';
import 'details/appointment_details_dispatcher.dart';
import 'widgets/timetable_calendar_view.dart';
@@ -42,6 +43,7 @@ class _TimetableState extends State<Timetable> {
state.startDate == _mondayOf(_initialDisplayDate());
Future<void> _openPicker() async {
if (guardDemoAction(context)) return;
final ref = await AppRoutes.openElementPicker(context);
if (!mounted || ref == null) return;
setState(() => _selected = ref);
+21
View File
@@ -0,0 +1,21 @@
import 'package:flutter/widgets.dart';
import '../api/demo/demo_mode.dart';
import 'info_dialog.dart';
/// Guards an action the demo mode can't back with real data (writes, uploads,
/// downloads, user/element lookups). Shows an info dialog and returns `true`
/// when a demo session is active — callers abort on `true`:
///
/// ```dart
/// if (guardDemoAction(context)) return;
/// ```
bool guardDemoAction(BuildContext context) {
if (!DemoMode.active) return false;
InfoDialog.show(
context,
'Diese Funktion steht im Demo-Modus nicht zur Verfügung.',
title: 'Demo-Modus',
);
return true;
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:marianum_mobile/api/demo/demo_marianumconnect.dart';
import 'package:marianum_mobile/api/marianumconnect/models/mc_holiday.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import 'package:marianum_mobile/api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
/// The demo interceptor answers MarianumConnect calls with these bodies; each
/// must round-trip through the same parser the real query uses. A shape
/// mismatch (bare list vs. object) would only surface at runtime otherwise.
void main() {
Map<String, dynamic> asMap(String path, [Map<String, dynamic> query = const {}]) =>
DemoMarianumConnect.responseBody(path, query) as Map<String, dynamic>;
List<Map<String, dynamic>> asList(String path) =>
(DemoMarianumConnect.responseBody(path, const {}) as List)
.cast<Map<String, dynamic>>();
group('DemoMarianumConnect responseBody parses with the real query parser', () {
test('timetable/me → populated week', () {
final week = TimetableGetWeekResponse.fromJson(
asMap('timetable/me', {'from': '2026-05-04', 'until': '2026-05-10'}),
);
expect(week.entries, isNotEmpty);
});
test('timetable/schoolyear', () {
expect(
() => TimetableGetSchoolyearResponse.fromJson(asMap('timetable/schoolyear')),
returnsNormally,
);
});
test('timetable/rooms → non-empty list of McRoom', () {
final rooms = asList('timetable/rooms').map(McRoom.fromJson).toList();
expect(rooms, isNotEmpty);
});
test('timetable/subjects → non-empty list of McSubject', () {
final subjects = asList('timetable/subjects').map(McSubject.fromJson).toList();
expect(subjects, isNotEmpty);
});
test('timetable/timegrid → non-empty list of McTimegridUnit', () {
final units = asList('timetable/timegrid').map(McTimegridUnit.fromJson).toList();
expect(units, isNotEmpty);
});
test('timetable/holidays → list of McHoliday', () {
expect(
() => asList('timetable/holidays').map(McHoliday.fromJson).toList(),
returnsNormally,
);
});
test('holidays → non-empty list of McHoliday', () {
final holidays = asList('holidays').map(McHoliday.fromJson).toList();
expect(holidays, isNotEmpty);
});
test('breaker → GetBreakersResponse with no active rules', () {
final breakers = GetBreakersResponse.fromJson(asMap('breaker'));
expect(breakers.rules, isEmpty);
});
test('foreign element week (timetable/<type>/<id>) parses as a week', () {
expect(
() => TimetableGetWeekResponse.fromJson(
asMap('timetable/teacher/42', {'from': '2026-05-04', 'until': '2026-05-10'}),
),
returnsNormally,
);
});
test('side-effect endpoint returns an empty map body', () {
expect(asMap('me/telemetry'), isEmpty);
});
});
}