From 9b74c9fd81198359330804cdd9992e9a039f1c7d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elias=20M=C3=BCller?= Date: Tue, 7 Jul 2026 20:24:16 +0200 Subject: [PATCH] implemented a comprehensive client-side demo mode triggered by a "demo@" username prefix, serving curated fixture data for all app modules --- lib/api/demo/data/demo_breaker.dart | 9 + lib/api/demo/data/demo_capabilities.dart | 33 ++ lib/api/demo/data/demo_files.dart | 155 +++++++ lib/api/demo/data/demo_holidays.dart | 34 ++ lib/api/demo/data/demo_marianum_dates.dart | 54 +++ lib/api/demo/data/demo_marianum_message.dart | 24 + lib/api/demo/data/demo_talk.dart | 429 ++++++++++++++++++ lib/api/demo/data/demo_timetable.dart | 202 +++++++++ lib/api/demo/demo_marianumconnect.dart | 54 +++ .../demo_marianumconnect_interceptor.dart | 35 ++ lib/api/demo/demo_mode.dart | 27 ++ lib/api/demo/demo_persona.dart | 58 +++ .../auth/session_validator.dart | 3 + .../marianumconnect/marianumconnect_api.dart | 2 + lib/model/account_data.dart | 26 ++ lib/push/push_registration.dart | 3 + .../data_provider/breaker_data_provider.dart | 10 +- .../capabilities/bloc/capabilities_cubit.dart | 6 + .../data_provider/chat_data_provider.dart | 8 + .../chat_list_data_provider.dart | 23 +- .../data_provider/files_data_provider.dart | 28 +- .../bloc/grade_averages_bloc.dart | 8 +- .../repository/marianum_dates_repository.dart | 7 +- .../marianum_message_repository.dart | 8 +- .../bloc/nextcloud_capabilities_cubit.dart | 6 + .../timetable_data_provider.dart | 39 +- lib/view/login/login_controller.dart | 15 + .../files/sharing/share_options_sheet.dart | 2 + lib/view/pages/files/sharing/share_sheet.dart | 2 + .../pages/files/widgets/add_file_menu.dart | 2 + .../pages/files/widgets/file_element.dart | 6 + .../pages/more/feedback/feedback_dialog.dart | 2 + .../settings/sections/account_section.dart | 2 + .../settings/widgets/push_status_sheet.dart | 3 + lib/view/pages/talk/chat_list.dart | 2 + .../pages/talk/data/open_direct_chat.dart | 2 + lib/view/pages/talk/widgets/chat_bubble.dart | 2 + .../talk/widgets/chat_bubble_reactions.dart | 2 + .../widgets/chat_message_options_dialog.dart | 6 + .../pages/talk/widgets/chat_textfield.dart | 3 + .../custom_event_edit_dialog.dart | 2 + .../details/delete_custom_event.dart | 2 + lib/view/pages/timetable/timetable.dart | 2 + lib/widget/demo_restricted.dart | 21 + test/demo/demo_marianumconnect_test.dart | 83 ++++ 45 files changed, 1409 insertions(+), 43 deletions(-) create mode 100644 lib/api/demo/data/demo_breaker.dart create mode 100644 lib/api/demo/data/demo_capabilities.dart create mode 100644 lib/api/demo/data/demo_files.dart create mode 100644 lib/api/demo/data/demo_holidays.dart create mode 100644 lib/api/demo/data/demo_marianum_dates.dart create mode 100644 lib/api/demo/data/demo_marianum_message.dart create mode 100644 lib/api/demo/data/demo_talk.dart create mode 100644 lib/api/demo/data/demo_timetable.dart create mode 100644 lib/api/demo/demo_marianumconnect.dart create mode 100644 lib/api/demo/demo_marianumconnect_interceptor.dart create mode 100644 lib/api/demo/demo_mode.dart create mode 100644 lib/api/demo/demo_persona.dart create mode 100644 lib/widget/demo_restricted.dart create mode 100644 test/demo/demo_marianumconnect_test.dart diff --git a/lib/api/demo/data/demo_breaker.dart b/lib/api/demo/data/demo_breaker.dart new file mode 100644 index 0000000..27917d9 --- /dev/null +++ b/lib/api/demo/data/demo_breaker.dart @@ -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([]); +} diff --git a/lib/api/demo/data/demo_capabilities.dart b/lib/api/demo/data/demo_capabilities.dart new file mode 100644 index 0000000..d43bdf8 --- /dev/null +++ b/lib/api/demo/data/demo_capabilities.dart @@ -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, + ); +} diff --git a/lib/api/demo/data/demo_files.dart b/lib/api/demo/data/demo_files.dart new file mode 100644 index 0000000..3e7437e --- /dev/null +++ b/lib/api/demo/data/demo_files.dart @@ -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'; + } + } +} diff --git a/lib/api/demo/data/demo_holidays.dart b/lib/api/demo/data/demo_holidays.dart new file mode 100644 index 0000000..8e9801c --- /dev/null +++ b/lib/api/demo/data/demo_holidays.dart @@ -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 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), + ), + ]; + } +} diff --git a/lib/api/demo/data/demo_marianum_dates.dart b/lib/api/demo/data/demo_marianum_dates.dart new file mode 100644 index 0000000..f7528c4 --- /dev/null +++ b/lib/api/demo/data/demo_marianum_dates.dart @@ -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 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, + ), + ]; + } +} diff --git a/lib/api/demo/data/demo_marianum_message.dart b/lib/api/demo/data/demo_marianum_message.dart new file mode 100644 index 0000000..071c8d5 --- /dev/null +++ b/lib/api/demo/data/demo_marianum_message.dart @@ -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', + ), + ], + ); + } +} diff --git a/lib/api/demo/data/demo_talk.dart b/lib/api/demo/data/demo_talk.dart new file mode 100644 index 0000000..0b045bf --- /dev/null +++ b/lib/api/demo/data/demo_talk.dart @@ -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 _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 _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 _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 _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 _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 _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 _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? 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? 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; +} diff --git a/lib/api/demo/data/demo_timetable.dart b/lib/api/demo/data/demo_timetable.dart new file mode 100644 index 0000000..96efea0 --- /dev/null +++ b/lib/api/demo/data/demo_timetable.dart @@ -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 _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> _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 _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 = []; + 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 = []; + 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 _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; +} diff --git a/lib/api/demo/demo_marianumconnect.dart b/lib/api/demo/demo_marianumconnect.dart new file mode 100644 index 0000000..b98ebf1 --- /dev/null +++ b/lib/api/demo/demo_marianumconnect.dart @@ -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 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 []; + } + // A foreign element's week (timetable//) shows the same demo week. + if (path.startsWith('timetable/')) { + return DemoTimetable.week( + _date(query['from']), + _date(query['until']), + ).toJson(); + } + return const {}; + } + + static DateTime _date(Object? raw) => + DateTime.tryParse(raw?.toString() ?? '') ?? DateTime.now(); +} diff --git a/lib/api/demo/demo_marianumconnect_interceptor.dart b/lib/api/demo/demo_marianumconnect_interceptor.dart new file mode 100644 index 0000000..fd36572 --- /dev/null +++ b/lib/api/demo/demo_marianumconnect_interceptor.dart @@ -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( + 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; + } +} diff --git a/lib/api/demo/demo_mode.dart b/lib/api/demo/demo_mode.dart new file mode 100644 index 0000000..4da95d8 --- /dev/null +++ b/lib/api/demo/demo_mode.dart @@ -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; +} diff --git a/lib/api/demo/demo_persona.dart b/lib/api/demo/demo_persona.dart new file mode 100644 index 0000000..cee3cbf --- /dev/null +++ b/lib/api/demo/demo_persona.dart @@ -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 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 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 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); +} diff --git a/lib/api/marianumconnect/auth/session_validator.dart b/lib/api/marianumconnect/auth/session_validator.dart index bea519d..4280b4b 100644 --- a/lib/api/marianumconnect/auth/session_validator.dart +++ b/lib/api/marianumconnect/auth/session_validator.dart @@ -14,6 +14,9 @@ class SessionValidator { required Future 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 { diff --git a/lib/api/marianumconnect/marianumconnect_api.dart b/lib/api/marianumconnect/marianumconnect_api.dart index 0d7c7bc..e9d226e 100644 --- a/lib/api/marianumconnect/marianumconnect_api.dart +++ b/lib/api/marianumconnect/marianumconnect_api.dart @@ -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; } diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index c033578..8ba8a3a 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -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 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 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( diff --git a/lib/push/push_registration.dart b/lib/push/push_registration.dart index 08580e4..4c98d7a 100644 --- a/lib/push/push_registration.dart +++ b/lib/push/push_registration.dart @@ -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 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 logoutCleanup() async { + if (DemoMode.active) return; await unregister(); try { await DeleteAppPassword().run(); diff --git a/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart index be5a131..fd22cf3 100644 --- a/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart +++ b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart @@ -12,9 +12,9 @@ class BreakerDataProvider { bool renew = true, void Function(Object)? onError, }) => resolveFromCache( - (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', + ); } diff --git a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart index 4217c9c..bd76d13 100644 --- a/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart +++ b/lib/state/app/modules/capabilities/bloc/capabilities_cubit.dart @@ -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 { /// state is marked `loaded` — a failed fetch never silently grants a /// capability, and an offline launch keeps whatever was cached. Future load() async { + if (DemoMode.active) { + emit(DemoCapabilities.state()); + return; + } try { final response = await GetCapabilities().run(); emit( diff --git a/lib/state/app/modules/chat/data_provider/chat_data_provider.dart b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart index 9be4252..123baac 100644 --- a/lib/state/app/modules/chat/data_provider/chat_data_provider.dart +++ b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart @@ -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, diff --git a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart index 8786baa..6189bc3 100644 --- a/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -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 getRooms({ void Function(Object)? onError, bool renew = false, - }) => resolveFromCache( - (onUpdate, onError) => - GetRoomCache(renew: renew, onUpdate: onUpdate, onError: onError), - onError: onError, - operationName: 'getRooms', - ); + }) { + if (DemoMode.active) return Future.value(DemoTalk.rooms()); + return resolveFromCache( + (onUpdate, onError) => + GetRoomCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getRooms', + ); + } - Future createDirectRoom(String invite) => - CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); + Future createDirectRoom(String invite) { + if (DemoMode.active) return Future.value(); + return CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); + } } diff --git a/lib/state/app/modules/files/data_provider/files_data_provider.dart b/lib/state/app/modules/files/data_provider/files_data_provider.dart index e4a1d5f..17cf211 100644 --- a/lib/state/app/modules/files/data_provider/files_data_provider.dart +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -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( - (onUpdate, onError) => ListFilesCache( - path: path, - onUpdate: onUpdate, - onCacheData: onCacheData, + }) { + if (DemoMode.active) return Future.value(DemoFiles.listing(path)); + return resolveFromCache( + (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 createFolder(String fullPath) async { + if (DemoMode.active) return; final webdav = await WebdavApi.webdav; await webdav.mkcol(PathUri.parse(fullPath)); } diff --git a/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart index 2d3b89b..af2ba03 100644 --- a/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart +++ b/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart @@ -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 { + // Seeded with a realistic spread in demo mode (average 2.0); empty otherwise. + static const List _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((event, emit) { diff --git a/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart b/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart index ead14c9..316b195 100644 --- a/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart +++ b/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart @@ -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 { - Future> getEvents() => MarianumDatesGetEvents().run(); + Future> getEvents() { + if (DemoMode.active) return Future.value(DemoMarianumDates.events()); + return MarianumDatesGetEvents().run(); + } } diff --git a/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart index 3d26cb4..faa43ee 100644 --- a/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart +++ b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart @@ -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 { - Future getMessages() => - MarianumMessageGetMessages().run(); + Future getMessages() { + if (DemoMode.active) return Future.value(DemoMarianumMessage.list()); + return MarianumMessageGetMessages().run(); + } } diff --git a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart index f2c60bf..6933eab 100644 --- a/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart +++ b/lib/state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart @@ -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 load() async { + if (DemoMode.active) { + emit(DemoNextcloudCapabilities.state()); + return; + } try { final caps = await GetNextcloudCapabilities().run(); emit( diff --git a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart index c7aea5c..77287b4 100644 --- a/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -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 getCustomEvents({ bool renew = false, void Function(Object)? onError, - }) => resolveFromCache( + }) { + if (DemoMode.active) return Future.value(DemoTimetable.customEvents()); + return resolveFromCache( (onUpdate, onError) => GetCustomTimetableEventCache( GetCustomTimetableEventParams(AccountData().getUserSecret()), renew: renew, onUpdate: onUpdate, onError: onError, ), - onError: onError, - operationName: 'getCustomEvents', - ); + onError: onError, + operationName: 'getCustomEvents', + ); + } - Future addCustomEvent(CustomTimetableEvent event) => - AddCustomTimetableEvent( - AddCustomTimetableEventParams(AccountData().getUserSecret(), event), - ).run(); + Future addCustomEvent(CustomTimetableEvent event) { + if (DemoMode.active) return Future.value(); + return AddCustomTimetableEvent( + AddCustomTimetableEventParams(AccountData().getUserSecret(), event), + ).run(); + } - Future updateCustomEvent(String id, CustomTimetableEvent event) => - UpdateCustomTimetableEvent( - UpdateCustomTimetableEventParams(id, event), - ).run(); + Future updateCustomEvent(String id, CustomTimetableEvent event) { + if (DemoMode.active) return Future.value(); + return UpdateCustomTimetableEvent( + UpdateCustomTimetableEventParams(id, event), + ).run(); + } - Future removeCustomEvent(String id) => - RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run(); + Future removeCustomEvent(String id) { + if (DemoMode.active) return Future.value(); + return RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run(); + } } diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart index bae4600..83ca987 100644 --- a/lib/view/login/login_controller.dart +++ b/lib/view/login/login_controller.dart @@ -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 diff --git a/lib/view/pages/files/sharing/share_options_sheet.dart b/lib/view/pages/files/sharing/share_options_sheet.dart index 9141682..2405c2c 100644 --- a/lib/view/pages/files/sharing/share_options_sheet.dart +++ b/lib/view/pages/files/sharing/share_options_sheet.dart @@ -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( diff --git a/lib/view/pages/files/sharing/share_sheet.dart b/lib/view/pages/files/sharing/share_sheet.dart index aba800d..c736259 100644 --- a/lib/view/pages/files/sharing/share_sheet.dart +++ b/lib/view/pages/files/sharing/share_sheet.dart @@ -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( diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart index 9f8565d..6d50052 100644 --- a/lib/view/pages/files/widgets/add_file_menu.dart +++ b/lib/view/pages/files/widgets/add_file_menu.dart @@ -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 Function(List? paths) onPickedFiles, }) { + if (guardDemoAction(context)) return; showDetailsBottomSheet( context, children: (sheetCtx) => [ diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart index 9b21a74..2813944 100644 --- a/lib/view/pages/files/widgets/file_element.dart +++ b/lib/view/pages/files/widgets/file_element.dart @@ -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 ); return; } + if (guardDemoAction(context)) return; if (EndpointData().getEndpointMode() == EndpointMode.stage) { InfoDialog.show( context, @@ -135,6 +137,7 @@ class _FileElementState extends State isDirectory ? '$folder$name/' : '$folder$name'; Future _rename() async { + if (guardDemoAction(context)) return; final controller = TextEditingController(text: widget.file.name); try { final newName = await showDialog( @@ -182,6 +185,7 @@ class _FileElementState extends State } 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 } Future _delete() async { + if (guardDemoAction(context)) return; await showDialog( context: context, builder: (context) => ConfirmDialog( @@ -269,6 +274,7 @@ class _FileElementState extends State title: const Text('Im Talk-Chat versenden'), onTap: () { Navigator.of(sheetCtx).pop(); + if (guardDemoAction(context)) return; AppRoutes.openInternalShareToChat( context, RemoteFileRef.fromCacheable(widget.file), diff --git a/lib/view/pages/more/feedback/feedback_dialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart index b3d10f8..15b5892 100644 --- a/lib/view/pages/more/feedback/feedback_dialog.dart +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -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 { } Future _submit() async { + if (guardDemoAction(context)) return; if (_feedbackInput.text.trim().isEmpty) { setState(() => _textFieldEmpty = true); return; diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart index 14beb54..df33022 100644 --- a/lib/view/pages/settings/sections/account_section.dart +++ b/lib/view/pages/settings/sections/account_section.dart @@ -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 { } Future _editAvatar() async { + if (guardDemoAction(context)) return; final result = await showAvatarActionsSheet(context, allowRemove: true); if (result == null || !mounted) return; diff --git a/lib/view/pages/settings/widgets/push_status_sheet.dart b/lib/view/pages/settings/widgets/push_status_sheet.dart index 276a618..4fa6402 100644 --- a/lib/view/pages/settings/widgets/push_status_sheet.dart +++ b/lib/view/pages/settings/widgets/push_status_sheet.dart @@ -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 _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 _sendTest() async { + if (guardDemoAction(context)) return; if (_busy) return; setState(() => _busy = true); final messenger = ScaffoldMessenger.of(context); diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart index a927c45..3199bd6 100644 --- a/lib/view/pages/talk/chat_list.dart +++ b/lib/view/pages/talk/chat_list.dart @@ -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, ) { diff --git a/lib/view/pages/talk/data/open_direct_chat.dart b/lib/view/pages/talk/data/open_direct_chat.dart index 196dd1f..0a5ecbd 100644 --- a/lib/view/pages/talk/data/open_direct_chat.dart +++ b/lib/view/pages/talk/data/open_direct_chat.dart @@ -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(); GetRoomResponseObject? findExisting() { diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart index c7712fe..ed18b1a 100644 --- a/lib/view/pages/talk/widgets/chat_bubble.dart +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -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 return; } if (message.file == null) return; + if (guardDemoAction(context)) return; if (isDownloading) { confirmCancelDownload(); } else { diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart index 34831b0..05e6a57 100644 --- a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -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( diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart index eccf7e9..c679790 100644 --- a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -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().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 _react(String emoji) async { + if (guardDemoAction(context)) return; final ok = await _controller.run(() async { await ReactMessage( chatToken: widget.chatToken, diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart index 153d6c5..143f5af 100644 --- a/lib/view/pages/talk/widgets/chat_textfield.dart +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -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 { } void _showAttachmentSheet() { + if (guardDemoAction(context)) return; showDetailsBottomSheet( context, children: (sheetCtx) => [ @@ -187,6 +189,7 @@ class _ChatTextfieldState extends State { Future _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; diff --git a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart index 1cc736b..cd401d9 100644 --- a/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -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 { end.second == 0; Future _save() async { + if (guardDemoAction(context)) return; if (_name.text.trim().isEmpty) { throw Exception('Bitte einen Terminnamen eingeben.'); } diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart index 33d186c..fa6f70c 100644 --- a/lib/view/pages/timetable/details/delete_custom_event.dart +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -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 showDeleteCustomEventDialog( BuildContext context, CustomTimetableEvent event, ) { final completer = Completer(); + if (guardDemoAction(context)) return completer; final bloc = context.read(); ConfirmDialog( title: 'Termin löschen', diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 6b12a72..d3da3c7 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -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 { state.startDate == _mondayOf(_initialDisplayDate()); Future _openPicker() async { + if (guardDemoAction(context)) return; final ref = await AppRoutes.openElementPicker(context); if (!mounted || ref == null) return; setState(() => _selected = ref); diff --git a/lib/widget/demo_restricted.dart b/lib/widget/demo_restricted.dart new file mode 100644 index 0000000..81b4f74 --- /dev/null +++ b/lib/widget/demo_restricted.dart @@ -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; +} diff --git a/test/demo/demo_marianumconnect_test.dart b/test/demo/demo_marianumconnect_test.dart new file mode 100644 index 0000000..80ff6e6 --- /dev/null +++ b/test/demo/demo_marianumconnect_test.dart @@ -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 asMap(String path, [Map query = const {}]) => + DemoMarianumConnect.responseBody(path, query) as Map; + + List> asList(String path) => + (DemoMarianumConnect.responseBody(path, const {}) as List) + .cast>(); + + 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//) 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); + }); + }); +}