diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..5202b16 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,77 @@ +# MarianumMobile Client + +Flutter-App für die Schul-Community: Webuntis-Stundenplan, Nextcloud Talk + Files, Custom MHSL-Backend (Breaker, Custom Events, Push). + +## Stack + +- **Flutter** (Dart >= 3.8) +- **State:** `flutter_bloc` + `hydrated_bloc` (persistente BLoCs pro Modul) +- **Navigation:** `persistent_bottom_nav_bar_v2` mit zentraler `AppRoutes`-Klasse als Single Entry Point +- **HTTP:** `dio`, lokales Caching via `localstore` (Generic `RequestCache`) +- **Calendar:** `syncfusion_flutter_calendar` +- **Datum/Zeit:** `jiffy` – wird **nur** über die Extensions in `lib/extensions/date_time.dart` verwendet +- **Code-Gen:** `freezed`, `json_serializable` + +## Ordnerstruktur + +``` +lib/ +├── api/ HTTP-Layer pro Backend (mhsl/, marianumcloud/, webuntis/, holidays/) +├── state/app/modules/ BLoC pro Feature-Modul (timetable, chat, chat_list, files, ...) +├── state/app/infrastructure LoadableState, DataLoader, geteilte BLoC-Bausteine +├── view/ Screens +│ ├── login/ Login-Flow +│ └── pages/ ein Verzeichnis pro Modul (timetable, files, talk, ...) +├── widget/ Geteilte UI-Komponenten (Dialoge, Buttons, Sheets) +├── extensions/ DateTime-, Text-, TimeOfDay-Extensions +├── routing/ AppRoutes (Single Navigation Entry) +├── theming/ Light/Dark Theme +├── storage/ Freezed Settings-Modelle (HydratedBloc-persistent) +├── notification/ Firebase + flutter_local_notifications +└── utils/ Helper (clipboard_helper, debouncer, download_manager, ...) +``` + +## Konventionen + +**Navigation:** Ausschließlich über `AppRoutes.openX(context, ...)`. Direkte `Navigator.push(...)` für volle Pages sind nicht erlaubt – `Navigator.pop` für Sheets/Dialogs bleibt am Call-Site. + +**Dialoge:** +- Info/Fehler: `InfoDialog.show(context, body, copyable: true, title: '...')` aus `lib/widget/info_dialog.dart`. +- Bestätigung: `ConfirmDialog(...).asDialog(context)` aus `lib/widget/confirm_dialog.dart`. Async-Bestätigung nutzt `onConfirmAsync` (zeigt Spinner und Inline-Fehler über `AsyncDialogAction`). +- **Kein** inline `AlertDialog`/`SimpleDialog` mehr. + +**Bottom-Sheets:** Detail-Sheets gehen über `showDetailsBottomSheet(context, header: ..., children: (ctx) => [...])` aus `lib/widget/details_bottom_sheet.dart`. Header ist optional. + +**Async-Actions:** Statt manuelles Spinner+Try/Catch die `AsyncActionButton`-Familie aus `lib/widget/async_action_button.dart` (`AsyncActionButton`, `AsyncTextButton`, `AsyncIconButton`, `AsyncFab`, `AsyncListTile`, `AsyncDialogAction`, `runWithErrorDialog`). Fehler-Mapping läuft über `errorBuilder` oder zentral über `errorToUserMessage` aus `lib/api/errors/error_mapper.dart`. + +**Clipboard:** Über `copyToClipboard(context, text)` aus `lib/utils/clipboard_helper.dart`. Zeigt automatisch SnackBar. + +**Datum/Zeit-Formatierung:** Über die Extensions in `lib/extensions/date_time.dart`: +`dt.formatHm()`, `dt.formatDate()`, `dt.formatDateTime()`, `dt.formatDateShort()`, `dt.formatRelative()`, `start.timeRangeTo(end)`. **Kein** direktes `Jiffy.parseFromDateTime(...).format(pattern: '...')` im View-Code. + +**Settings:** Pro Feature ein Freezed-Modell unter `lib/storage/`, persistiert via HydratedBloc. + +## Build / Run + +```bash +flutter pub get +dart run build_runner build --delete-conflicting-outputs # nach Änderungen an Freezed/JSON-Modellen +flutter run # Debug auf angeschlossenem Device +flutter analyze # statische Analyse, muss 0 Issues melden +flutter test # Tests (siehe test/) +``` + +## Backend-Integrationen + +| Backend | Pfad | Zweck | +|---------------------------|-----------------------|----------------------------------------| +| Webuntis | `lib/api/webuntis/` | Stundenplan, Klassen, Räume, Lehrer | +| Nextcloud (Talk + WebDAV) | `lib/api/marianumcloud/` | Chats, Datei-Verwaltung | +| Custom MHSL-Server | `lib/api/mhsl/` | Breaker, Custom Events, Notify, Noten | +| Holiday-Calendar | `lib/api/holidays/` | Ferien | + +`nextcloud`-Paket ist auf einen Custom-Fork gepinnt (siehe `pubspec.yaml` `dependency_overrides`). + +## Tests + +`test/` deckt aktuell nur Kern-Funktionen ab (DateTime-Extensions, AsyncActionController, LessonResolver). Beim Hinzufügen neuer pure-function-Helper bitte Test mit dazu. diff --git a/analysis_options.yaml b/analysis_options.yaml index a40338c..5dd5f4c 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,44 +1,88 @@ -# This file configures the analyzer, which statically analyzes Dart code to -# check for errors, warnings, and lints. +# Static analysis configuration for the Flutter project. +# https://dart.dev/guides/language/analysis-options # -# The issues identified by the analyzer are surfaced in the UI of Dart-enabled -# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be -# invoked from the command line by running `flutter analyze`. +# Base ruleset: flutter_lints (recommended Flutter defaults). +# Additional lints below catch real bugs and enforce consistent style. -# The following line activates a set of recommended lints for Flutter apps, -# packages, and plugins designed to encourage good coding practices. include: package:flutter_lints/flutter.yaml analyzer: + language: + strict-casts: true + strict-raw-types: true errors: invalid_annotation_target: ignore + todo: ignore + exclude: + - "**/*.g.dart" + - "**/*.freezed.dart" + - "lib/firebase_options.dart" + - "build/**" linter: - # The lint rules applied to this project can be customized in the - # section below to disable rules from the `package:flutter_lints/flutter.yaml` - # included above or to enable additional rules. A list of all available lints - # and their documentation is published at - # https://dart-lang.github.io/linter/lints/index.html. - # - # Instead of disabling a lint rule for the entire project in the - # section below, it can also be suppressed for a single line of code - # or a specific dart file by using the `// ignore: name_of_lint` and - # `// ignore_for_file: name_of_lint` syntax on the line or in the file - # producing the lint. rules: - # avoid_print: false # Uncomment to disable the `avoid_print` rule - # prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule - file_names: false + # === Project conventions === prefer_relative_imports: true - unnecessary_lambdas: true prefer_single_quotes: true - prefer_if_elements_to_conditional_expressions: true - prefer_expression_function_bodies: true - omit_local_variable_types: true eol_at_end_of_file: true - cast_nullable_to_non_nullable: true - avoid_void_async: true + omit_local_variable_types: true avoid_multiple_declarations_per_line: true -# Additional information about this file can be found at -# https://dart.dev/guides/language/analysis-options + # === Bug catchers === + always_declare_return_types: true + avoid_empty_else: true + avoid_slow_async_io: true + avoid_type_to_string: true + avoid_void_async: true + await_only_futures: true + cancel_subscriptions: true + cast_nullable_to_non_nullable: true + close_sinks: true + empty_catches: true + hash_and_equals: true + no_adjacent_strings_in_list: true + no_duplicate_case_values: true + test_types_in_equals: true + throw_in_finally: true + unawaited_futures: true + unnecessary_statements: true + unrelated_type_equality_checks: true + use_build_context_synchronously: true + valid_regexps: true + + # === Flutter widget hygiene === + avoid_unnecessary_containers: true + sized_box_for_whitespace: true + sort_child_properties_last: true + use_colored_box: true + use_decorated_box: true + use_full_hex_values_for_flutter_colors: true + use_key_in_widget_constructors: true + + # === Code clarity === + directives_ordering: true + library_prefixes: true + no_leading_underscores_for_local_identifiers: true + prefer_conditional_assignment: true + prefer_if_elements_to_conditional_expressions: true + prefer_if_null_operators: true + prefer_initializing_formals: true + prefer_interpolation_to_compose_strings: true + prefer_is_empty: true + prefer_is_not_empty: true + prefer_is_not_operator: true + prefer_iterable_whereType: true + prefer_null_aware_operators: true + prefer_spread_collections: true + prefer_void_to_null: true + unnecessary_await_in_return: true + unnecessary_brace_in_string_interps: true + unnecessary_lambdas: true + unnecessary_null_aware_assignments: true + unnecessary_null_checks: true + unnecessary_parenthesis: true + unnecessary_string_interpolations: true + use_super_parameters: true + + # === File naming === + file_names: true diff --git a/android/app/build.gradle b/android/app/build.gradle index b62e5c5..befe6bb 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -25,7 +25,7 @@ if (flutterVersionName == null) { android { namespace "eu.mhsl.marianum.mobile.client" compileSdk flutter.compileSdkVersion - ndkVersion "27.0.12077973" + ndkVersion "28.2.13676358" compileOptions { sourceCompatibility JavaVersion.VERSION_17 diff --git a/assets/background/chat.png b/assets/background/chat.png index a9f5760..0566458 100644 Binary files a/assets/background/chat.png and b/assets/background/chat.png differ diff --git a/assets/img/raumplan.jpg b/assets/img/raumplan.jpg deleted file mode 100644 index 4bdf0e0..0000000 Binary files a/assets/img/raumplan.jpg and /dev/null differ diff --git a/assets/img/raumplan.png b/assets/img/raumplan.png new file mode 100644 index 0000000..275e3fe Binary files /dev/null and b/assets/img/raumplan.png differ diff --git a/assets/logo/icon-android12.png b/assets/logo/icon-android12.png deleted file mode 100644 index 51e192d..0000000 Binary files a/assets/logo/icon-android12.png and /dev/null differ diff --git a/assets/logo/icon.png b/assets/logo/icon.png index dc89db9..f541898 100644 Binary files a/assets/logo/icon.png and b/assets/logo/icon.png differ diff --git a/assets/logo/icon/1024.png b/assets/logo/icon/1024.png index 197c7af..9095987 100644 Binary files a/assets/logo/icon/1024.png and b/assets/logo/icon/1024.png differ diff --git a/assets/logo/icon/adaptive_back.png b/assets/logo/icon/adaptive_back.png deleted file mode 100644 index 7126e69..0000000 Binary files a/assets/logo/icon/adaptive_back.png and /dev/null differ diff --git a/assets/logo/icon/adaptive_fore.png b/assets/logo/icon/adaptive_fore.png deleted file mode 100644 index 7eea023..0000000 Binary files a/assets/logo/icon/adaptive_fore.png and /dev/null differ diff --git a/assets/logo/icon/appIcon.png b/assets/logo/icon/appIcon.png index 5f53154..dcfb12a 100644 Binary files a/assets/logo/icon/appIcon.png and b/assets/logo/icon/appIcon.png differ diff --git a/assets/logo/icon/ic_launcher.png b/assets/logo/icon/ic_launcher.png index 42d8c9a..f5ffbd4 100644 Binary files a/assets/logo/icon/ic_launcher.png and b/assets/logo/icon/ic_launcher.png differ diff --git a/assets/logo/icon/ic_launcher_adaptive_back.png b/assets/logo/icon/ic_launcher_adaptive_back.png index 7126e69..a467285 100644 Binary files a/assets/logo/icon/ic_launcher_adaptive_back.png and b/assets/logo/icon/ic_launcher_adaptive_back.png differ diff --git a/assets/logo/icon/ic_launcher_adaptive_fore.png b/assets/logo/icon/ic_launcher_adaptive_fore.png index 7eea023..3f96112 100644 Binary files a/assets/logo/icon/ic_launcher_adaptive_fore.png and b/assets/logo/icon/ic_launcher_adaptive_fore.png differ diff --git a/assets/logo/icon/icon-android12.png b/assets/logo/icon/icon-android12.png new file mode 100644 index 0000000..e625664 Binary files /dev/null and b/assets/logo/icon/icon-android12.png differ diff --git a/flutter_native_splash.yaml b/flutter_native_splash.yaml index 37bb48d..703f49a 100644 --- a/flutter_native_splash.yaml +++ b/flutter_native_splash.yaml @@ -54,7 +54,7 @@ flutter_native_splash: # 640 pixels in diameter. # App icon without an icon background: This should be 1152×1152 pixels, and fit within a circle # 768 pixels in diameter. - image: assets/logo/icon-android12.png + image: assets/logo/icon/icon-android12.png # Splash screen background color. color: "#993333" diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 006a921..ccbfe9c 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -26,6 +26,8 @@ $(FLUTTER_BUILD_NUMBER) LSRequiresIPhoneOS + NSCameraUsageDescription + Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt. NSPhotoLibraryUsageDescription Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt. UIApplicationSupportsIndirectInputEvents diff --git a/lib/api/apiParams.dart b/lib/api/apiParams.dart deleted file mode 100644 index b679813..0000000 --- a/lib/api/apiParams.dart +++ /dev/null @@ -1,3 +0,0 @@ -class ApiParams { - -} diff --git a/lib/api/apiRequest.dart b/lib/api/apiRequest.dart deleted file mode 100644 index 705ccbc..0000000 --- a/lib/api/apiRequest.dart +++ /dev/null @@ -1,5 +0,0 @@ - - -class ApiRequest { - -} diff --git a/lib/api/apiError.dart b/lib/api/api_error.dart similarity index 73% rename from lib/api/apiError.dart rename to lib/api/api_error.dart index 42fe5c2..9a56012 100644 --- a/lib/api/apiError.dart +++ b/lib/api/api_error.dart @@ -1,4 +1,4 @@ -class ApiError { +class ApiError implements Exception { String message; ApiError(this.message); diff --git a/lib/api/api_params.dart b/lib/api/api_params.dart new file mode 100644 index 0000000..5e9f07f --- /dev/null +++ b/lib/api/api_params.dart @@ -0,0 +1 @@ +class ApiParams {} diff --git a/lib/api/api_request.dart b/lib/api/api_request.dart new file mode 100644 index 0000000..2cdf97c --- /dev/null +++ b/lib/api/api_request.dart @@ -0,0 +1 @@ +class ApiRequest {} diff --git a/lib/api/apiResponse.dart b/lib/api/api_response.dart similarity index 99% rename from lib/api/apiResponse.dart rename to lib/api/api_response.dart index bcbc91c..cef5697 100644 --- a/lib/api/apiResponse.dart +++ b/lib/api/api_response.dart @@ -1,5 +1,6 @@ import 'package:http/http.dart' as http; import 'package:json_annotation/json_annotation.dart'; + abstract class ApiResponse { @JsonKey(includeFromJson: false, includeToJson: false) late http.Response rawResponse; diff --git a/lib/api/errors/app_exception.dart b/lib/api/errors/app_exception.dart new file mode 100644 index 0000000..e90b716 --- /dev/null +++ b/lib/api/errors/app_exception.dart @@ -0,0 +1,16 @@ +abstract class AppException implements Exception { + final String userMessage; + final String? technicalDetails; + final bool allowRetry; + + const AppException({ + required this.userMessage, + this.technicalDetails, + this.allowRetry = true, + }); + + @override + String toString() => technicalDetails == null + ? '$runtimeType: $userMessage' + : '$runtimeType: $userMessage ($technicalDetails)'; +} diff --git a/lib/api/errors/auth_exception.dart b/lib/api/errors/auth_exception.dart new file mode 100644 index 0000000..495bafd --- /dev/null +++ b/lib/api/errors/auth_exception.dart @@ -0,0 +1,24 @@ +import 'app_exception.dart'; + +class AuthException extends AppException { + final int statusCode; + + const AuthException({ + required this.statusCode, + required super.userMessage, + super.technicalDetails, + }) : super(allowRetry: false); + + factory AuthException.unauthorized({String? technicalDetails}) => + AuthException( + statusCode: 401, + userMessage: 'Bitte melde dich erneut an, um fortzufahren.', + technicalDetails: technicalDetails, + ); + + factory AuthException.forbidden({String? technicalDetails}) => AuthException( + statusCode: 403, + userMessage: 'Du hast keine Berechtigung für diese Aktion.', + technicalDetails: technicalDetails, + ); +} diff --git a/lib/api/errors/error_mapper.dart b/lib/api/errors/error_mapper.dart new file mode 100644 index 0000000..a5db580 --- /dev/null +++ b/lib/api/errors/error_mapper.dart @@ -0,0 +1,115 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:http/http.dart' as http; + +import '../api_error.dart'; +import '../marianumcloud/talk/talk_error.dart'; +import '../webuntis/webuntis_error.dart'; +import 'app_exception.dart'; +import 'network_exception.dart'; +import 'parse_exception.dart'; +import 'server_exception.dart'; +import 'talk_exception.dart'; +import 'webuntis_exception.dart'; + +const String _defaultFallback = + 'Etwas ist schiefgelaufen. Bitte versuche es erneut.'; +const String _tlsErrorMessage = + 'Die sichere Verbindung zum Server wurde abgelehnt (Zertifikat oder TLS-Fehler). ' + 'Häufige Ursachen: falsche Geräte-Uhrzeit oder ein WLAN mit Anmeldeseite (z.B. Café/Hotel).'; + +AppException? _dioToAppException(DioException error) { + switch (error.type) { + case DioExceptionType.connectionTimeout: + case DioExceptionType.sendTimeout: + case DioExceptionType.receiveTimeout: + return NetworkException.timeout(technicalDetails: error.message); + case DioExceptionType.connectionError: + return NetworkException(technicalDetails: error.message); + case DioExceptionType.badCertificate: + return const NetworkException(userMessage: _tlsErrorMessage); + case DioExceptionType.badResponse: + final status = error.response?.statusCode; + return ServerException( + statusCode: status ?? -1, + technicalDetails: 'HTTP $status: ${error.message}', + ); + case DioExceptionType.cancel: + case DioExceptionType.unknown: + final inner = error.error; + if (inner is SocketException) { + return NetworkException(technicalDetails: inner.message); + } + if (inner is HandshakeException) { + return const NetworkException(userMessage: _tlsErrorMessage); + } + if (inner is FormatException) { + return ParseException(technicalDetails: inner.message); + } + return null; + } +} + +String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { + if (error == null) return fallback; + if (error is AppException) return error.userMessage; + + if (error is TalkError) return TalkException(error).userMessage; + if (error is WebuntisError) return WebuntisException(error).userMessage; + + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.userMessage; + } + + if (error is SocketException) { + return const NetworkException().userMessage; + } + if (error is TimeoutException) { + return NetworkException.timeout().userMessage; + } + if (error is http.ClientException) { + return const NetworkException().userMessage; + } + if (error is HandshakeException) { + return _tlsErrorMessage; + } + if (error is FormatException) { + return const ParseException().userMessage; + } + if (error is ApiError) { + return _stripDioPrefix(error.message); + } + + return fallback; +} + +String? errorToTechnicalDetails(Object? error) { + if (error == null) return null; + if (error is AppException) return error.technicalDetails ?? error.toString(); + if (error is TalkError) return TalkException(error).technicalDetails; + if (error is WebuntisError) return WebuntisException(error).technicalDetails; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.technicalDetails ?? mapped.toString(); + } + return error.toString(); +} + +bool errorAllowsRetry(Object? error) { + if (error == null) return true; + if (error is AppException) return error.allowRetry; + if (error is DioException) { + final mapped = _dioToAppException(error); + if (mapped != null) return mapped.allowRetry; + } + return true; +} + +String _stripDioPrefix(String raw) { + // ApiError messages embed full request URIs; only surface the first line. + final firstLine = raw.split('\n').first.trim(); + return firstLine.isEmpty ? _defaultFallback : firstLine; +} diff --git a/lib/api/errors/network_exception.dart b/lib/api/errors/network_exception.dart new file mode 100644 index 0000000..06b38df --- /dev/null +++ b/lib/api/errors/network_exception.dart @@ -0,0 +1,16 @@ +import 'app_exception.dart'; + +class NetworkException extends AppException { + const NetworkException({ + super.userMessage = + 'Keine Internetverbindung. Bitte prüfe dein Netzwerk und versuche es erneut.', + super.technicalDetails, + }) : super(allowRetry: true); + + factory NetworkException.timeout({String? technicalDetails}) => + NetworkException( + userMessage: + 'Der Server hat zu lange gebraucht. Bitte versuche es erneut.', + technicalDetails: technicalDetails, + ); +} diff --git a/lib/api/errors/not_found_exception.dart b/lib/api/errors/not_found_exception.dart new file mode 100644 index 0000000..f3d525a --- /dev/null +++ b/lib/api/errors/not_found_exception.dart @@ -0,0 +1,8 @@ +import 'app_exception.dart'; + +class NotFoundException extends AppException { + const NotFoundException({ + super.userMessage = 'Der angeforderte Eintrag wurde nicht gefunden.', + super.technicalDetails, + }) : super(allowRetry: false); +} diff --git a/lib/api/errors/parse_exception.dart b/lib/api/errors/parse_exception.dart new file mode 100644 index 0000000..57a3bf2 --- /dev/null +++ b/lib/api/errors/parse_exception.dart @@ -0,0 +1,8 @@ +import 'app_exception.dart'; + +class ParseException extends AppException { + const ParseException({ + super.userMessage = 'Die Antwort des Servers konnte nicht gelesen werden.', + super.technicalDetails, + }) : super(allowRetry: true); +} diff --git a/lib/api/errors/server_exception.dart b/lib/api/errors/server_exception.dart new file mode 100644 index 0000000..9d5aab1 --- /dev/null +++ b/lib/api/errors/server_exception.dart @@ -0,0 +1,16 @@ +import 'app_exception.dart'; + +class ServerException extends AppException { + final int statusCode; + + ServerException({ + required this.statusCode, + String? userMessage, + super.technicalDetails, + }) : super( + userMessage: + userMessage ?? + 'Der Server hat gerade Probleme (Status $statusCode). Bitte später erneut versuchen.', + allowRetry: true, + ); +} diff --git a/lib/api/errors/talk_exception.dart b/lib/api/errors/talk_exception.dart new file mode 100644 index 0000000..52190c2 --- /dev/null +++ b/lib/api/errors/talk_exception.dart @@ -0,0 +1,36 @@ +import '../marianumcloud/talk/talk_error.dart'; +import 'app_exception.dart'; + +class TalkException extends AppException { + final TalkError source; + + TalkException(this.source) + : super( + userMessage: _mapMessage(source), + technicalDetails: + 'Talk ${source.status} (${source.code}): ${source.message}', + allowRetry: source.code >= 500, + ); + + static String _mapMessage(TalkError e) { + switch (e.code) { + case 401: + return 'Bitte melde dich erneut an, um auf Talk zuzugreifen.'; + case 403: + return 'Du hast keine Berechtigung für diese Talk-Aktion.'; + case 404: + return 'Dieser Chat existiert nicht oder wurde entfernt.'; + case 412: + return 'Diese Aktion ist im aktuellen Chat-Zustand nicht erlaubt.'; + case 429: + return 'Zu viele Anfragen. Bitte kurz warten und erneut versuchen.'; + default: + if (e.code >= 500) { + return 'Talk-Server hat gerade Probleme (${e.code}).'; + } + return e.message.isNotEmpty + ? e.message + : 'Talk meldet einen Fehler (${e.code}).'; + } + } +} diff --git a/lib/api/errors/webuntis_exception.dart b/lib/api/errors/webuntis_exception.dart new file mode 100644 index 0000000..c09f48a --- /dev/null +++ b/lib/api/errors/webuntis_exception.dart @@ -0,0 +1,31 @@ +import '../webuntis/webuntis_error.dart'; +import 'app_exception.dart'; + +class WebuntisException extends AppException { + final WebuntisError source; + + WebuntisException(this.source) + : super( + userMessage: _mapMessage(source), + technicalDetails: 'WebUntis (${source.code}): ${source.message}', + allowRetry: true, + ); + + static String _mapMessage(WebuntisError e) { + switch (e.code) { + case -8504: + case -8502: + return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.'; + case -8520: + return 'Bitte melde dich erneut an.'; + case -7004: + return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.'; + case -32601: + return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.'; + default: + return e.message.isNotEmpty + ? 'WebUntis: ${e.message}' + : 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).'; + } + } +} diff --git a/lib/api/holidays/getHolidays.dart b/lib/api/holidays/getHolidays.dart deleted file mode 100644 index 8ce3325..0000000 --- a/lib/api/holidays/getHolidays.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; - -import 'getHolidaysResponse.dart'; - -class GetHolidays { - Future query() async { - var response = (await http.get(Uri.parse('https://ferien-api.de/api/v1/holidays/HE'))).body; - var data = jsonDecode(response) as List; - return GetHolidaysResponse( - List.from( - data.map((e) => GetHolidaysResponseObject.fromJson(e as Map)) - ) - ); - } -} diff --git a/lib/api/holidays/getHolidaysCache.dart b/lib/api/holidays/getHolidaysCache.dart deleted file mode 100644 index 49e04e1..0000000 --- a/lib/api/holidays/getHolidaysCache.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:convert'; - -import '../requestCache.dart'; -import 'getHolidays.dart'; -import 'getHolidaysResponse.dart'; - -class GetHolidaysCache extends RequestCache { - GetHolidaysCache({onUpdate, renew}) : super(RequestCache.cacheDay, onUpdate, renew: renew) { - start('state-holidays'); - } - - @override - GetHolidaysResponse onLocalData(String json) { - List parsedListJson = jsonDecode(json)['data']; - return GetHolidaysResponse( - List.from( - parsedListJson.map( - (i) => GetHolidaysResponseObject.fromJson(i as Map) - ) - ) - ); - } - - @override - Future onLoad() => GetHolidays().query(); -} diff --git a/lib/api/holidays/getHolidaysResponse.dart b/lib/api/holidays/getHolidaysResponse.dart deleted file mode 100644 index 6ba00bb..0000000 --- a/lib/api/holidays/getHolidaysResponse.dart +++ /dev/null @@ -1,38 +0,0 @@ - -import 'package:json_annotation/json_annotation.dart'; - -import '../apiResponse.dart'; - -part 'getHolidaysResponse.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetHolidaysResponse extends ApiResponse { - List data; - - GetHolidaysResponse(this.data); - - factory GetHolidaysResponse.fromJson(Map json) => _$GetHolidaysResponseFromJson(json); - Map toJson() => _$GetHolidaysResponseToJson(this); -} - -@JsonSerializable() -class GetHolidaysResponseObject { - String start; - String end; - int year; - String stateCode; - String name; - String slug; - - GetHolidaysResponseObject({ - required this.start, - required this.end, - required this.year, - required this.stateCode, - required this.name, - required this.slug - }); - - factory GetHolidaysResponseObject.fromJson(Map json) => _$GetHolidaysResponseObjectFromJson(json); - Map toJson() => _$GetHolidaysResponseObjectToJson(this); -} diff --git a/lib/api/holidays/getHolidaysResponse.g.dart b/lib/api/holidays/getHolidaysResponse.g.dart deleted file mode 100644 index 4642931..0000000 --- a/lib/api/holidays/getHolidaysResponse.g.dart +++ /dev/null @@ -1,49 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'getHolidaysResponse.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -GetHolidaysResponse _$GetHolidaysResponseFromJson(Map json) => - GetHolidaysResponse( - (json['data'] as List) - .map( - (e) => - GetHolidaysResponseObject.fromJson(e as Map), - ) - .toList(), - ) - ..headers = (json['headers'] as Map?)?.map( - (k, e) => MapEntry(k, e as String), - ); - -Map _$GetHolidaysResponseToJson( - GetHolidaysResponse instance, -) => { - 'headers': ?instance.headers, - 'data': instance.data.map((e) => e.toJson()).toList(), -}; - -GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson( - Map json, -) => GetHolidaysResponseObject( - start: json['start'] as String, - end: json['end'] as String, - year: (json['year'] as num).toInt(), - stateCode: json['stateCode'] as String, - name: json['name'] as String, - slug: json['slug'] as String, -); - -Map _$GetHolidaysResponseObjectToJson( - GetHolidaysResponseObject instance, -) => { - 'start': instance.start, - 'end': instance.end, - 'year': instance.year, - 'stateCode': instance.stateCode, - 'name': instance.name, - 'slug': instance.slug, -}; diff --git a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart b/lib/api/marianumcloud/autocomplete/autocompleteApi.dart deleted file mode 100644 index f11b91c..0000000 --- a/lib/api/marianumcloud/autocomplete/autocompleteApi.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:http/http.dart' as http; - -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; -import 'autocompleteResponse.dart'; - -class AutocompleteApi { - Future find(String query) async { - var getParameters = { - 'search': query, - 'itemType': ' ', - 'itemId': ' ', - 'shareTypes[]': ['0'], - 'limit': '10', - }; - - var headers = {}; - headers.putIfAbsent('Accept', () => 'application/json'); - headers.putIfAbsent('OCS-APIRequest', () => 'true'); - - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/core/autocomplete/get', getParameters); - - var response = await http.get(endpoint, headers: headers); - if(response.statusCode != HttpStatus.ok) throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); - var result = response.body; - return AutocompleteResponse.fromJson(jsonDecode(result)['ocs']); - } - -} diff --git a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart new file mode 100644 index 0000000..c18e4d9 --- /dev/null +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -0,0 +1,32 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../nextcloud_ocs.dart'; +import 'autocomplete_response.dart'; + +class AutocompleteApi { + Future find(String query) async { + final endpoint = NextcloudOcs.uri( + 'core/autocomplete/get', + queryParameters: { + 'search': query, + 'itemType': ' ', + 'itemId': ' ', + 'shareTypes[]': ['0'], + 'limit': '10', + }, + ); + final response = await http.get(endpoint, headers: NextcloudOcs.headers()); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'Api call failed with ${response.statusCode}: ${response.body}', + ); + } + final decoded = jsonDecode(response.body) as Map; + return AutocompleteResponse.fromJson( + decoded['ocs'] as Map, + ); + } +} diff --git a/lib/api/marianumcloud/autocomplete/autocompleteResponse.dart b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart similarity index 68% rename from lib/api/marianumcloud/autocomplete/autocompleteResponse.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_response.dart index 8e72772..d3e938d 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteResponse.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_response.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'autocompleteResponse.g.dart'; +part 'autocomplete_response.g.dart'; @JsonSerializable(explicitToJson: true) class AutocompleteResponse { @@ -8,7 +8,8 @@ class AutocompleteResponse { AutocompleteResponse(this.data); - factory AutocompleteResponse.fromJson(Map json) => _$AutocompleteResponseFromJson(json); + factory AutocompleteResponse.fromJson(Map json) => + _$AutocompleteResponseFromJson(json); Map toJson() => _$AutocompleteResponseToJson(this); } @@ -22,9 +23,17 @@ class AutocompleteResponseObject { String? subline; String? shareWithDisplayNameUniqe; - AutocompleteResponseObject(this.id, this.label, this.icon, this.source, this.status, - this.subline, this.shareWithDisplayNameUniqe); + AutocompleteResponseObject( + this.id, + this.label, + this.icon, + this.source, + this.status, + this.subline, + this.shareWithDisplayNameUniqe, + ); - factory AutocompleteResponseObject.fromJson(Map json) => _$AutocompleteResponseObjectFromJson(json); + factory AutocompleteResponseObject.fromJson(Map json) => + _$AutocompleteResponseObjectFromJson(json); Map toJson() => _$AutocompleteResponseObjectToJson(this); } diff --git a/lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart b/lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart similarity index 97% rename from lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart rename to lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart index 41ecf2b..65b9863 100644 --- a/lib/api/marianumcloud/autocomplete/autocompleteResponse.g.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'autocompleteResponse.dart'; +part of 'autocomplete_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart b/lib/api/marianumcloud/files-sharing/fileSharingApi.dart deleted file mode 100644 index 42d5dd8..0000000 --- a/lib/api/marianumcloud/files-sharing/fileSharingApi.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:io'; - -import 'package:http/http.dart' as http; - -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; -import 'fileSharingApiParams.dart'; - -class FileSharingApi { - Future share(FileSharingApiParams query) async { - var headers = {}; - headers.putIfAbsent('Accept', () => 'application/json'); - headers.putIfAbsent('OCS-APIRequest', () => 'true'); - - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/apps/files_sharing/api/v1/shares', query.toJson().map((key, value) => MapEntry(key, value.toString()))); - var response = await http.post(endpoint, headers: headers); - - if(response.statusCode != HttpStatus.ok) { - throw Exception('Api call failed with ${response.statusCode}: ${response.body}'); - } - } -} diff --git a/lib/api/marianumcloud/files_sharing/file_sharing_api.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart new file mode 100644 index 0000000..c78ecaf --- /dev/null +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api.dart @@ -0,0 +1,21 @@ +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../nextcloud_ocs.dart'; +import 'file_sharing_api_params.dart'; + +class FileSharingApi { + Future share(FileSharingApiParams query) async { + final endpoint = NextcloudOcs.uri( + 'apps/files_sharing/api/v1/shares', + queryParameters: query.toJson(), + ); + final response = await http.post(endpoint, headers: NextcloudOcs.headers()); + if (response.statusCode != HttpStatus.ok) { + throw Exception( + 'Api call failed with ${response.statusCode}: ${response.body}', + ); + } + } +} diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart similarity index 81% rename from lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart index edcc6a5..7f70f86 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'fileSharingApiParams.g.dart'; +part 'file_sharing_api_params.g.dart'; @JsonSerializable() class FileSharingApiParams { @@ -15,9 +15,10 @@ class FileSharingApiParams { required this.shareWith, required this.path, this.referenceId, - this.talkMetaData + this.talkMetaData, }); - factory FileSharingApiParams.fromJson(Map json) => _$FileSharingApiParamsFromJson(json); + factory FileSharingApiParams.fromJson(Map json) => + _$FileSharingApiParamsFromJson(json); Map toJson() => _$FileSharingApiParamsToJson(this); } diff --git a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart similarity index 95% rename from lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart rename to lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart index 9fc1e8b..472d1bc 100644 --- a/lib/api/marianumcloud/files-sharing/fileSharingApiParams.g.dart +++ b/lib/api/marianumcloud/files_sharing/file_sharing_api_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'fileSharingApiParams.dart'; +part of 'file_sharing_api_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart new file mode 100644 index 0000000..c7086a0 --- /dev/null +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -0,0 +1,23 @@ +import '../../model/account_data.dart'; +import '../../model/endpoint_data.dart'; + +/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by +/// TalkApi, AutocompleteApi, FileSharingApi. +class NextcloudOcs { + NextcloudOcs._(); + + static Map headers() => { + 'Accept': 'application/json', + 'OCS-APIRequest': 'true', + 'Authorization': AccountData().getBasicAuthHeader(), + }; + + static Uri uri(String pathSuffix, {Map? queryParameters}) { + final endpoint = EndpointData().nextcloud(); + return Uri.https( + endpoint.domain, + '${endpoint.path}/ocs/v2.php/$pathSuffix', + queryParameters?.map((key, value) => MapEntry(key, value.toString())), + ); + } +} diff --git a/lib/api/marianumcloud/talk/actions/talk_actions.dart b/lib/api/marianumcloud/talk/actions/talk_actions.dart new file mode 100644 index 0000000..57a10d0 --- /dev/null +++ b/lib/api/marianumcloud/talk/actions/talk_actions.dart @@ -0,0 +1,64 @@ +import 'package:http/http.dart' as http; + +import '../../../api_params.dart'; +import '../../../api_response.dart'; +import '../talk_api.dart'; + +/// Small POST/DELETE-only Talk endpoints that have no response payload. +/// Each class extends [TalkApi] with `assemble` returning `null`. They share +/// no state — they're collected here purely to avoid eight near-empty files. + +class SetFavorite extends TalkApi { + final String chatToken; + final bool favoriteState; + + SetFavorite(this.chatToken, this.favoriteState) + : super('v4/room/$chatToken/favorite', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => favoriteState + ? http.post(uri, headers: headers) + : http.delete(uri, headers: headers); +} + +class LeaveRoom extends TalkApi { + final String chatToken; + + LeaveRoom(this.chatToken) + : super('v4/room/$chatToken/participants/self', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.delete(uri, headers: headers); +} + +class DeleteMessage extends TalkApi { + final String chatToken; + final int messageId; + + DeleteMessage(this.chatToken, this.messageId) + : super('v1/chat/$chatToken/$messageId', null); + + @override + ApiResponse? assemble(String raw) => null; + + @override + Future request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.delete(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/chat/getChat.dart b/lib/api/marianumcloud/talk/chat/getChat.dart deleted file mode 100644 index fb64466..0000000 --- a/lib/api/marianumcloud/talk/chat/getChat.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../talkApi.dart'; -import 'getChatParams.dart'; -import 'getChatResponse.dart'; - -class GetChat extends TalkApi { - String chatToken; - - GetChatParams params; - GetChat(this.chatToken, this.params) : super('v1/chat/$chatToken', null, getParameters: params.toJson()); - - @override - assemble(String raw) => GetChatResponse.fromJson(jsonDecode(raw)['ocs']); - - @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); - -} diff --git a/lib/api/marianumcloud/talk/chat/getChatCache.dart b/lib/api/marianumcloud/talk/chat/getChatCache.dart deleted file mode 100644 index 3792365..0000000 --- a/lib/api/marianumcloud/talk/chat/getChatCache.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'dart:convert'; - -import '../../../requestCache.dart'; -import 'getChat.dart'; -import 'getChatParams.dart'; -import 'getChatResponse.dart'; - -class GetChatCache extends RequestCache { - String chatToken; - - GetChatCache({required onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { - start('nc-chat-$chatToken'); - } - - @override - Future onLoad() => GetChat( - chatToken, - GetChatParams( - lookIntoFuture: GetChatParamsSwitch.off, - setReadMarker: GetChatParamsSwitch.on, - limit: 200, - ) - ).run(); - - @override - GetChatResponse onLocalData(String json) => GetChatResponse.fromJson(jsonDecode(json)); - -} diff --git a/lib/api/marianumcloud/talk/chat/get_chat.dart b/lib/api/marianumcloud/talk/chat/get_chat.dart new file mode 100644 index 0000000..ff13903 --- /dev/null +++ b/lib/api/marianumcloud/talk/chat/get_chat.dart @@ -0,0 +1,29 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +import '../talk_api.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; + +class GetChat extends TalkApi { + String chatToken; + + GetChatParams params; + GetChat(this.chatToken, this.params) + : super('v1/chat/$chatToken', null, getParameters: params.toJson()); + + @override + GetChatResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetChatResponse.fromJson(decoded['ocs'] as Map); + } + + @override + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/chat/get_chat_cache.dart b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart new file mode 100644 index 0000000..01ee56a --- /dev/null +++ b/lib/api/marianumcloud/talk/chat/get_chat_cache.dart @@ -0,0 +1,26 @@ +import '../../../request_cache.dart'; +import 'get_chat.dart'; +import 'get_chat_params.dart'; +import 'get_chat_response.dart'; + +class GetChatCache extends SimpleCache { + GetChatCache({ + super.onCacheData, + super.onNetworkData, + super.onError, + required String chatToken, + }) : super( + cacheTime: RequestCache.cacheNothing, + loader: () => GetChat( + chatToken, + GetChatParams( + lookIntoFuture: GetChatParamsSwitch.off, + setReadMarker: GetChatParamsSwitch.on, + limit: 200, + ), + ).run(), + fromJson: GetChatResponse.fromJson, + ) { + start('nc-chat-$chatToken'); + } +} diff --git a/lib/api/marianumcloud/talk/chat/getChatParams.dart b/lib/api/marianumcloud/talk/chat/get_chat_params.dart similarity index 55% rename from lib/api/marianumcloud/talk/chat/getChatParams.dart rename to lib/api/marianumcloud/talk/chat/get_chat_params.dart index 08197b2..88b4c3a 100644 --- a/lib/api/marianumcloud/talk/chat/getChatParams.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getChatParams.g.dart'; +part 'get_chat_params.g.dart'; @JsonSerializable(explicitToJson: true, includeIfNull: false) class GetChatParams extends ApiParams { @@ -15,20 +15,23 @@ class GetChatParams extends ApiParams { GetChatParamsSwitch? includeLastKnown; GetChatParams({ - required this.lookIntoFuture, - this.limit, - this.lastKnownMessageId, - this.lastCommonReadId, - this.timeout, - this.setReadMarker, - this.includeLastKnown + required this.lookIntoFuture, + this.limit, + this.lastKnownMessageId, + this.lastCommonReadId, + this.timeout, + this.setReadMarker, + this.includeLastKnown, }); - factory GetChatParams.fromJson(Map json) => _$GetChatParamsFromJson(json); + factory GetChatParams.fromJson(Map json) => + _$GetChatParamsFromJson(json); Map toJson() => _$GetChatParamsToJson(this); } enum GetChatParamsSwitch { - @JsonValue(1) on, - @JsonValue(0) off, + @JsonValue(1) + on, + @JsonValue(0) + off, } diff --git a/lib/api/marianumcloud/talk/chat/getChatParams.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_params.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/chat/getChatParams.g.dart rename to lib/api/marianumcloud/talk/chat/get_chat_params.g.dart index a2c9707..b318eb6 100644 --- a/lib/api/marianumcloud/talk/chat/getChatParams.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getChatParams.dart'; +part of 'get_chat_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.dart similarity index 58% rename from lib/api/marianumcloud/talk/chat/getChatResponse.dart rename to lib/api/marianumcloud/talk/chat/get_chat_response.dart index 840df36..9b54111 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.dart @@ -1,10 +1,10 @@ -import 'package:jiffy/jiffy.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../room/getRoomResponse.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../api_response.dart'; +import '../room/get_room_response.dart'; -part 'getChatResponse.g.dart'; +part 'get_chat_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetChatResponse extends ApiResponse { @@ -12,7 +12,8 @@ class GetChatResponse extends ApiResponse { GetChatResponse(this.data); - factory GetChatResponse.fromJson(Map json) => _$GetChatResponseFromJson(json); + factory GetChatResponse.fromJson(Map json) => + _$GetChatResponseFromJson(json); Map toJson() => _$GetChatResponseToJson(this); List sortByTimestamp() { @@ -37,36 +38,39 @@ class GetChatResponseObject { String message; Map? reactions; List? reactionsSelf; - @JsonKey(fromJson: _fromJson) Map? messageParameters; + @JsonKey(fromJson: _fromJson) + Map? messageParameters; GetChatResponseObject? parent; GetChatResponseObject( - this.id, - this.token, - this.actorType, - this.actorId, - this.actorDisplayName, - this.timestamp, - this.systemMessage, - this.messageType, - this.isReplyable, - this.referenceId, - this.message, - this.messageParameters, - this.reactions, - this.reactionsSelf, - this.parent, + this.id, + this.token, + this.actorType, + this.actorId, + this.actorDisplayName, + this.timestamp, + this.systemMessage, + this.messageType, + this.isReplyable, + this.referenceId, + this.message, + this.messageParameters, + this.reactions, + this.reactionsSelf, + this.parent, ); - factory GetChatResponseObject.fromJson(Map json) => _$GetChatResponseObjectFromJson(json); + factory GetChatResponseObject.fromJson(Map json) => + _$GetChatResponseObjectFromJson(json); Map toJson() => _$GetChatResponseObjectToJson(this); static GetChatResponseObject getDateDummy(int timestamp) { var elementDate = DateTime.fromMillisecondsSinceEpoch(timestamp * 1000); - return getTextDummy(Jiffy.parseFromDateTime(elementDate).format(pattern: 'dd.MM.yyyy')); + return getTextDummy(elementDate.formatDate()); } - static GetChatResponseObject getTextDummy(String text) => GetChatResponseObject( + static GetChatResponseObject getTextDummy(String text) => + GetChatResponseObject( 0, '', GetRoomResponseObjectMessageActorType.user, @@ -82,15 +86,17 @@ class GetChatResponseObject { null, null, null, - ); - + ); } -Map? _fromJson(json) { - if(json is Map) { - var data = {}; - for (var element in json.keys) { - data.putIfAbsent(element, () => RichObjectString.fromJson(json[element])); +Map? _fromJson(dynamic json) { + if (json is Map) { + final data = {}; + for (final element in json.keys) { + data.putIfAbsent( + element, + () => RichObjectString.fromJson(json[element] as Map), + ); } return data; } @@ -109,17 +115,26 @@ class RichObjectString { RichObjectString(this.type, this.id, this.name, this.path, this.link); - factory RichObjectString.fromJson(Map json) => _$RichObjectStringFromJson(json); + factory RichObjectString.fromJson(Map json) => + _$RichObjectStringFromJson(json); Map toJson() => _$RichObjectStringToJson(this); } enum RichObjectStringObjectType { - @JsonValue('user') user, - @JsonValue('group') group, - @JsonValue('file') file, - @JsonValue('guest') guest, - @JsonValue('highlight') highlight, - @JsonValue('talk-poll') talkPoll, - @JsonValue('geo-location') geoLocation, - @JsonValue('call') call, + @JsonValue('user') + user, + @JsonValue('group') + group, + @JsonValue('file') + file, + @JsonValue('guest') + guest, + @JsonValue('highlight') + highlight, + @JsonValue('talk-poll') + talkPoll, + @JsonValue('geo-location') + geoLocation, + @JsonValue('call') + call, } diff --git a/lib/api/marianumcloud/talk/chat/getChatResponse.g.dart b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart similarity index 99% rename from lib/api/marianumcloud/talk/chat/getChatResponse.g.dart rename to lib/api/marianumcloud/talk/chat/get_chat_response.g.dart index 1835359..c8f0276 100644 --- a/lib/api/marianumcloud/talk/chat/getChatResponse.g.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getChatResponse.dart'; +part of 'get_chat_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart similarity index 50% rename from lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart rename to lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart index b61d064..071a51e 100644 --- a/lib/api/marianumcloud/talk/chat/richObjectStringProcessor.dart +++ b/lib/api/marianumcloud/talk/chat/rich_object_string_processor.dart @@ -1,9 +1,11 @@ - -import 'getChatResponse.dart'; +import 'get_chat_response.dart'; class RichObjectStringProcessor { - static String parseToString(String message, Map? data) { - if(data == null) return message; + static String parseToString( + String message, + Map? data, + ) { + if (data == null) return message; data.forEach((key, value) { message = message.replaceAll(RegExp('{$key}'), value.name); diff --git a/lib/api/marianumcloud/talk/createRoom/createRoom.dart b/lib/api/marianumcloud/talk/createRoom/createRoom.dart deleted file mode 100644 index 27d274d..0000000 --- a/lib/api/marianumcloud/talk/createRoom/createRoom.dart +++ /dev/null @@ -1,23 +0,0 @@ - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../talkApi.dart'; -import 'createRoomParams.dart'; -class CreateRoom extends TalkApi { - CreateRoomParams params; - - CreateRoom(this.params) : super('v4/room', params); - - @override - assemble(String raw) => null; - - @override - Future? request(Uri uri, Object? body, Map? headers) { - if(body is CreateRoomParams) { - return http.post(uri, headers: headers, body: body.toJson().map((key, value) => MapEntry(key, value.toString()))); - } - - return null; - } -} diff --git a/lib/api/marianumcloud/talk/create_room/create_room.dart b/lib/api/marianumcloud/talk/create_room/create_room.dart new file mode 100644 index 0000000..626fd11 --- /dev/null +++ b/lib/api/marianumcloud/talk/create_room/create_room.dart @@ -0,0 +1,33 @@ +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +import '../talk_api.dart'; +import 'create_room_params.dart'; + +class CreateRoom extends TalkApi { + CreateRoomParams params; + + CreateRoom(this.params) : super('v4/room', params); + + @override + Null assemble(String raw) => null; + + @override + Future? request( + Uri uri, + Object? body, + Map? headers, + ) { + if (body is CreateRoomParams) { + return http.post( + uri, + headers: headers, + body: body.toJson().map( + (key, value) => MapEntry(key, value.toString()), + ), + ); + } + + return null; + } +} diff --git a/lib/api/marianumcloud/talk/createRoom/createRoomParams.dart b/lib/api/marianumcloud/talk/create_room/create_room_params.dart similarity index 79% rename from lib/api/marianumcloud/talk/createRoom/createRoomParams.dart rename to lib/api/marianumcloud/talk/create_room/create_room_params.dart index cb1d1b5..69aa024 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoomParams.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'createRoomParams.g.dart'; +part 'create_room_params.g.dart'; @JsonSerializable() class CreateRoomParams extends ApiParams { @@ -19,9 +19,10 @@ class CreateRoomParams extends ApiParams { this.source, this.roomName, this.objectType, - this.objectId + this.objectId, }); - factory CreateRoomParams.fromJson(Map json) => _$CreateRoomParamsFromJson(json); + factory CreateRoomParams.fromJson(Map json) => + _$CreateRoomParamsFromJson(json); Map toJson() => _$CreateRoomParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart b/lib/api/marianumcloud/talk/create_room/create_room_params.g.dart similarity index 96% rename from lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart rename to lib/api/marianumcloud/talk/create_room/create_room_params.g.dart index 8592ebb..b873df4 100644 --- a/lib/api/marianumcloud/talk/createRoom/createRoomParams.g.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'createRoomParams.dart'; +part of 'create_room_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/deleteMessage/deleteMessage.dart b/lib/api/marianumcloud/talk/deleteMessage/deleteMessage.dart deleted file mode 100644 index 580f899..0000000 --- a/lib/api/marianumcloud/talk/deleteMessage/deleteMessage.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../../apiParams.dart'; -import '../talkApi.dart'; - -class DeleteMessage extends TalkApi { - String chatToken; - int messageId; - DeleteMessage(this.chatToken, this.messageId) : super('v1/chat/$chatToken/$messageId', null); - - @override - assemble(String raw) => null; - - @override - Future? request(Uri uri, ApiParams? body, Map? headers) => http.delete(uri, headers: headers); - -} diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart b/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart deleted file mode 100644 index d586d5b..0000000 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'deleteReactMessageParams.dart'; - -class DeleteReactMessage extends TalkApi { - String chatToken; - int messageId; - DeleteReactMessage({required this.chatToken, required this.messageId, required DeleteReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); - - @override - assemble(String raw) => null; - - @override - Future? request(Uri uri, ApiParams? body, Map? headers) { - if(body is DeleteReactMessageParams) { - return http.delete(uri, headers: headers, body: body.toJson()); - } - return null; - } - -} diff --git a/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart new file mode 100644 index 0000000..2b365a7 --- /dev/null +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message.dart @@ -0,0 +1,31 @@ +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'delete_react_message_params.dart'; + +class DeleteReactMessage extends TalkApi { + String chatToken; + int messageId; + DeleteReactMessage({ + required this.chatToken, + required this.messageId, + required DeleteReactMessageParams params, + }) : super('v1/reaction/$chatToken/$messageId', params); + + @override + Null assemble(String raw) => null; + + @override + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is DeleteReactMessageParams) { + return http.delete(uri, headers: headers, body: body.toJson()); + } + return null; + } +} diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart similarity index 71% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart index c40c317..181bec6 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'deleteReactMessageParams.g.dart'; +part 'delete_react_message_params.g.dart'; @JsonSerializable() class DeleteReactMessageParams extends ApiParams { @@ -10,6 +10,7 @@ class DeleteReactMessageParams extends ApiParams { DeleteReactMessageParams(this.reaction); - factory DeleteReactMessageParams.fromJson(Map json) => _$DeleteReactMessageParamsFromJson(json); + factory DeleteReactMessageParams.fromJson(Map json) => + _$DeleteReactMessageParamsFromJson(json); Map toJson() => _$DeleteReactMessageParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart similarity index 92% rename from lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart rename to lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart index 7dc6c79..9f50520 100644 --- a/lib/api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/delete_react_message/delete_react_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'deleteReactMessageParams.dart'; +part of 'delete_react_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart deleted file mode 100644 index ec88234..0000000 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipants.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import '../talkApi.dart'; -import 'getParticipantsResponse.dart'; - -class GetParticipants extends TalkApi { - String token; - GetParticipants(this.token) : super('v4/room/$token/participants', null); - - @override - GetParticipantsResponse assemble(String raw) => GetParticipantsResponse.fromJson(jsonDecode(raw)['ocs']); - - @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); - -} diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart b/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart deleted file mode 100644 index 55df84d..0000000 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsCache.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; - -import '../../../requestCache.dart'; -import 'getParticipants.dart'; -import 'getParticipantsResponse.dart'; - -class GetParticipantsCache extends RequestCache { - String chatToken; - - GetParticipantsCache({required onUpdate, required this.chatToken}) : super(RequestCache.cacheNothing, onUpdate) { - start('nc-chat-participants-$chatToken'); - } - - @override - Future onLoad() => GetParticipants( - chatToken, - ).run(); - - @override - GetParticipantsResponse onLocalData(String json) => GetParticipantsResponse.fromJson(jsonDecode(json)); - -} diff --git a/lib/api/marianumcloud/talk/getPoll/getPollState.dart b/lib/api/marianumcloud/talk/getPoll/getPollState.dart deleted file mode 100644 index 503c1d0..0000000 --- a/lib/api/marianumcloud/talk/getPoll/getPollState.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import '../talkApi.dart'; -import 'getPollStateResponse.dart'; - -class GetPollState extends TalkApi { - String token; - int pollId; - GetPollState({required this.token, required this.pollId}) : super('v1/poll/$token/$pollId', null); - - @override - GetPollStateResponse assemble(String raw) => GetPollStateResponse.fromJson(jsonDecode(raw)['ocs']); - - @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); -} diff --git a/lib/api/marianumcloud/talk/getReactions/getReactions.dart b/lib/api/marianumcloud/talk/getReactions/getReactions.dart deleted file mode 100644 index 5b9a8e3..0000000 --- a/lib/api/marianumcloud/talk/getReactions/getReactions.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'getReactionsResponse.dart'; - -class GetReactions extends TalkApi { - String chatToken; - int messageId; - GetReactions({required this.chatToken, required this.messageId}) : super('v1/reaction/$chatToken/$messageId', null); - - @override - assemble(String raw) => GetReactionsResponse.fromJson(jsonDecode(raw)['ocs']); - - @override - Future? request(Uri uri, ApiParams? body, Map? headers) => http.get(uri, headers: headers); - -} diff --git a/lib/api/marianumcloud/talk/get_participants/get_participants.dart b/lib/api/marianumcloud/talk/get_participants/get_participants.dart new file mode 100644 index 0000000..c37d7ce --- /dev/null +++ b/lib/api/marianumcloud/talk/get_participants/get_participants.dart @@ -0,0 +1,26 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../talk_api.dart'; +import 'get_participants_response.dart'; + +class GetParticipants extends TalkApi { + String token; + GetParticipants(this.token) : super('v4/room/$token/participants', null); + + @override + GetParticipantsResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetParticipantsResponse.fromJson( + decoded['ocs'] as Map, + ); + } + + @override + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart new file mode 100644 index 0000000..560ba70 --- /dev/null +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_cache.dart @@ -0,0 +1,17 @@ +import '../../../request_cache.dart'; +import 'get_participants.dart'; +import 'get_participants_response.dart'; + +class GetParticipantsCache extends SimpleCache { + GetParticipantsCache({ + required void Function(GetParticipantsResponse) onUpdate, + required String chatToken, + }) : super( + cacheTime: RequestCache.cacheNothing, + loader: () => GetParticipants(chatToken).run(), + fromJson: GetParticipantsResponse.fromJson, + onUpdate: onUpdate, + ) { + start('nc-chat-participants-$chatToken'); + } +} diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart similarity index 56% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_response.dart index 3d0e9ff..12a362b 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_response.dart @@ -1,9 +1,8 @@ - import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getParticipantsResponse.g.dart'; +part 'get_participants_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetParticipantsResponse extends ApiResponse { @@ -11,7 +10,8 @@ class GetParticipantsResponse extends ApiResponse { GetParticipantsResponse(this.data); - factory GetParticipantsResponse.fromJson(Map json) => _$GetParticipantsResponseFromJson(json); + factory GetParticipantsResponse.fromJson(Map json) => + _$GetParticipantsResponseFromJson(json); Map toJson() => _$GetParticipantsResponseToJson(this); } @@ -34,42 +34,55 @@ class GetParticipantsResponseObject { String? roomToken; GetParticipantsResponseObject( - this.attendeeId, - this.actorType, - this.actorId, - this.displayName, - this.participantType, - this.lastPing, - this.inCall, - this.permissions, - this.attendeePermissions, - this.sessionId, - this.sessionIds, - this.status, - this.statusIcon, - this.statusMessage, - this.roomToken); + this.attendeeId, + this.actorType, + this.actorId, + this.displayName, + this.participantType, + this.lastPing, + this.inCall, + this.permissions, + this.attendeePermissions, + this.sessionId, + this.sessionIds, + this.status, + this.statusIcon, + this.statusMessage, + this.roomToken, + ); - factory GetParticipantsResponseObject.fromJson(Map json) => _$GetParticipantsResponseObjectFromJson(json); + factory GetParticipantsResponseObject.fromJson(Map json) => + _$GetParticipantsResponseObjectFromJson(json); Map toJson() => _$GetParticipantsResponseObjectToJson(this); } enum GetParticipantsResponseObjectParticipantType { - @JsonValue(1) owner('Besitzer'), - @JsonValue(2) moderator('Moderator'), - @JsonValue(3) user('Teilnehmer'), - @JsonValue(4) guest('Gast'), - @JsonValue(5) userFollowingPublicLink('Teilnehmer über Link'), - @JsonValue(6) guestWithModeratorPermissions('Gast Moderator'); + @JsonValue(1) + owner('Besitzer'), + @JsonValue(2) + moderator('Moderator'), + @JsonValue(3) + user('Teilnehmer'), + @JsonValue(4) + guest('Gast'), + @JsonValue(5) + userFollowingPublicLink('Teilnehmer über Link'), + @JsonValue(6) + guestWithModeratorPermissions('Gast Moderator'); const GetParticipantsResponseObjectParticipantType(this.prettyName); final String prettyName; } enum GetParticipantsResponseObjectParticipantsInCallFlags { - @JsonValue(0) disconnected, - @JsonValue(1) inCall, - @JsonValue(2) providesAudio, - @JsonValue(3) providesVideo, - @JsonValue(4) usesSipDialIn + @JsonValue(0) + disconnected, + @JsonValue(1) + inCall, + @JsonValue(2) + providesAudio, + @JsonValue(3) + providesVideo, + @JsonValue(4) + usesSipDialIn, } diff --git a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart b/lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart similarity index 98% rename from lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart rename to lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart index 424b161..f3fd7cb 100644 --- a/lib/api/marianumcloud/talk/getParticipants/getParticipantsResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getParticipantsResponse.dart'; +part of 'get_participants_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart new file mode 100644 index 0000000..3b6ccd2 --- /dev/null +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart @@ -0,0 +1,28 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../talk_api.dart'; +import 'get_poll_state_response.dart'; + +class GetPollState extends TalkApi { + String token; + int pollId; + GetPollState({required this.token, required this.pollId}) + : super('v1/poll/$token/$pollId', null); + + @override + GetPollStateResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetPollStateResponse.fromJson( + decoded['ocs'] as Map, + ); + } + + @override + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart similarity index 65% rename from lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart index 75d20c0..928dba0 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getPollStateResponse.g.dart'; +part 'get_poll_state_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetPollStateResponse extends ApiResponse { @@ -10,7 +10,8 @@ class GetPollStateResponse extends ApiResponse { GetPollStateResponse(this.data); - factory GetPollStateResponse.fromJson(Map json) => _$GetPollStateResponseFromJson(json); + factory GetPollStateResponse.fromJson(Map json) => + _$GetPollStateResponseFromJson(json); Map toJson() => _$GetPollStateResponseToJson(this); } @@ -31,20 +32,22 @@ class GetPollStateResponseObject { List? details; GetPollStateResponseObject( - this.id, - this.question, - this.options, - this.votes, - this.actorType, - this.actorId, - this.actorDisplayName, - this.status, - this.resultMode, - this.maxVotes, - this.votedSelf, - this.numVoters, - this.details); + this.id, + this.question, + this.options, + this.votes, + this.actorType, + this.actorId, + this.actorDisplayName, + this.status, + this.resultMode, + this.maxVotes, + this.votedSelf, + this.numVoters, + this.details, + ); - factory GetPollStateResponseObject.fromJson(Map json) => _$GetPollStateResponseObjectFromJson(json); + factory GetPollStateResponseObject.fromJson(Map json) => + _$GetPollStateResponseObjectFromJson(json); Map toJson() => _$GetPollStateResponseObjectToJson(this); } diff --git a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart rename to lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart index 3015979..c81d2f5 100644 --- a/lib/api/marianumcloud/talk/getPoll/getPollStateResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getPollStateResponse.dart'; +part of 'get_poll_state_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart new file mode 100644 index 0000000..882eb29 --- /dev/null +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'get_reactions_response.dart'; + +class GetReactions extends TalkApi { + String chatToken; + int messageId; + GetReactions({required this.chatToken, required this.messageId}) + : super('v1/reaction/$chatToken/$messageId', null); + + @override + GetReactionsResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetReactionsResponse.fromJson( + decoded['ocs'] as Map, + ); + } + + @override + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) => http.get(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart similarity index 66% rename from lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart index 5a6c9f0..1f58dfc 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getReactionsResponse.g.dart'; +part 'get_reactions_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetReactionsResponse extends ApiResponse { @@ -10,7 +10,8 @@ class GetReactionsResponse extends ApiResponse { GetReactionsResponse(this.data); - factory GetReactionsResponse.fromJson(Map json) => _$GetReactionsResponseFromJson(json); + factory GetReactionsResponse.fromJson(Map json) => + _$GetReactionsResponseFromJson(json); Map toJson() => _$GetReactionsResponseToJson(this); } @@ -21,13 +22,21 @@ class GetReactionsResponseObject { String actorDisplayName; int timestamp; - GetReactionsResponseObject(this.actorType, this.actorId, this.actorDisplayName, this.timestamp); + GetReactionsResponseObject( + this.actorType, + this.actorId, + this.actorDisplayName, + this.timestamp, + ); - factory GetReactionsResponseObject.fromJson(Map json) => _$GetReactionsResponseObjectFromJson(json); + factory GetReactionsResponseObject.fromJson(Map json) => + _$GetReactionsResponseObjectFromJson(json); Map toJson() => _$GetReactionsResponseObjectToJson(this); } enum GetReactionsResponseObjectActorType { - @JsonValue('guests') guests, - @JsonValue('users') users, + @JsonValue('guests') + guests, + @JsonValue('users') + users, } diff --git a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart similarity index 97% rename from lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart rename to lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart index 2fa0d24..a050c59 100644 --- a/lib/api/marianumcloud/talk/getReactions/getReactionsResponse.g.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getReactionsResponse.dart'; +part of 'get_reactions_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/leaveRoom/leaveRoom.dart b/lib/api/marianumcloud/talk/leaveRoom/leaveRoom.dart deleted file mode 100644 index ee1f090..0000000 --- a/lib/api/marianumcloud/talk/leaveRoom/leaveRoom.dart +++ /dev/null @@ -1,16 +0,0 @@ - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../talkApi.dart'; -class LeaveRoom extends TalkApi { - String chatToken; - - LeaveRoom(this.chatToken) : super('v4/room/$chatToken/participants/self', null); - - @override - assemble(String raw) => null; - - @override - Future request(Uri uri, Object? body, Map? headers) => http.delete(uri, headers: headers); -} diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessage.dart b/lib/api/marianumcloud/talk/reactMessage/reactMessage.dart deleted file mode 100644 index ac76bd2..0000000 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessage.dart +++ /dev/null @@ -1,24 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'reactMessageParams.dart'; - -class ReactMessage extends TalkApi { - String chatToken; - int messageId; - ReactMessage({required this.chatToken, required this.messageId, required ReactMessageParams params}) : super('v1/reaction/$chatToken/$messageId', params); - - @override - assemble(String raw) => null; - - @override - Future? request(Uri uri, ApiParams? body, Map? headers) { - if(body is ReactMessageParams) { - return http.post(uri, headers: headers, body: body.toJson()); - } - return null; - } - -} diff --git a/lib/api/marianumcloud/talk/react_message/react_message.dart b/lib/api/marianumcloud/talk/react_message/react_message.dart new file mode 100644 index 0000000..01c78dd --- /dev/null +++ b/lib/api/marianumcloud/talk/react_message/react_message.dart @@ -0,0 +1,31 @@ +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'react_message_params.dart'; + +class ReactMessage extends TalkApi { + String chatToken; + int messageId; + ReactMessage({ + required this.chatToken, + required this.messageId, + required ReactMessageParams params, + }) : super('v1/reaction/$chatToken/$messageId', params); + + @override + Null assemble(String raw) => null; + + @override + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is ReactMessageParams) { + return http.post(uri, headers: headers, body: body.toJson()); + } + return null; + } +} diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart b/lib/api/marianumcloud/talk/react_message/react_message_params.dart similarity index 72% rename from lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart rename to lib/api/marianumcloud/talk/react_message/react_message_params.dart index 0fb6cc1..1315898 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'reactMessageParams.g.dart'; +part 'react_message_params.g.dart'; @JsonSerializable() class ReactMessageParams extends ApiParams { @@ -10,6 +10,7 @@ class ReactMessageParams extends ApiParams { ReactMessageParams(this.reaction); - factory ReactMessageParams.fromJson(Map json) => _$ReactMessageParamsFromJson(json); + factory ReactMessageParams.fromJson(Map json) => + _$ReactMessageParamsFromJson(json); Map toJson() => _$ReactMessageParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart b/lib/api/marianumcloud/talk/react_message/react_message_params.g.dart similarity index 93% rename from lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart rename to lib/api/marianumcloud/talk/react_message/react_message_params.g.dart index 328c53a..054b0c6 100644 --- a/lib/api/marianumcloud/talk/reactMessage/reactMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/react_message/react_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'reactMessageParams.dart'; +part of 'react_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/room/getRoom.dart b/lib/api/marianumcloud/talk/room/getRoom.dart deleted file mode 100644 index dd7cc52..0000000 --- a/lib/api/marianumcloud/talk/room/getRoom.dart +++ /dev/null @@ -1,22 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; - -import '../talkApi.dart'; -import 'getRoomParams.dart'; -import 'getRoomResponse.dart'; - - -class GetRoom extends TalkApi { - GetRoomParams params; - GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); - - - - @override - GetRoomResponse assemble(String raw) => GetRoomResponse.fromJson(jsonDecode(raw)['ocs']); - - @override - Future request(Uri uri, Object? body, Map? headers) => http.get(uri, headers: headers); - -} diff --git a/lib/api/marianumcloud/talk/room/getRoomCache.dart b/lib/api/marianumcloud/talk/room/getRoomCache.dart deleted file mode 100644 index a4ea708..0000000 --- a/lib/api/marianumcloud/talk/room/getRoomCache.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:convert'; - - -import '../../../requestCache.dart'; -import 'getRoom.dart'; -import 'getRoomParams.dart'; -import 'getRoomResponse.dart'; - -class GetRoomCache extends RequestCache { - GetRoomCache({onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { - start('nc-rooms'); - } - - @override - GetRoomResponse onLocalData(String json) => GetRoomResponse.fromJson(jsonDecode(json)); - - @override - Future onLoad() => GetRoom( - GetRoomParams( - includeStatus: true, - ) - ).run(); -} diff --git a/lib/api/marianumcloud/talk/room/getRoomResponse.dart b/lib/api/marianumcloud/talk/room/getRoomResponse.dart deleted file mode 100644 index 36e7b6d..0000000 --- a/lib/api/marianumcloud/talk/room/getRoomResponse.dart +++ /dev/null @@ -1,169 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../apiResponse.dart'; -import '../chat/getChatResponse.dart'; - -part 'getRoomResponse.g.dart'; - -@JsonSerializable(explicitToJson: true) -class GetRoomResponse extends ApiResponse { - Set data; - - GetRoomResponse(this.data); - - factory GetRoomResponse.fromJson(Map json) => _$GetRoomResponseFromJson(json); - Map toJson() => _$GetRoomResponseToJson(this); - - List sortBy({bool lastActivity = true, required bool favoritesToTop, required bool unreadToTop}) { - for (var chat in data) { - final buffer = StringBuffer(); - - if(favoritesToTop) { - buffer.write(chat.isFavorite ? 'b' : 'a'); - } - if(unreadToTop) { - buffer.write(chat.unreadMessages > 0 ? 'b' : 'a'); - } - - buffer.write(chat.lastActivity); - - chat.sort = buffer.toString(); - } - - return data.toList()..sort((a, b) => b.sort!.compareTo(a.sort!)); - } -} - -@JsonSerializable(explicitToJson: true) -class GetRoomResponseObject { - int id; - String token; - GetRoomResponseObjectConversationType type; - String name; - String displayName; - String description; - int participantType; - int participantFlags; - int readOnly; - int listable; - int lastPing; - String sessionId; - bool hasPassword; - bool hasCall; - int callFlag; - bool canStartCall; - bool canDeleteConversation; - bool canLeaveConversation; - int lastActivity; - bool isFavorite; - GetRoomResponseObjectParticipantNotificationLevel notificationLevel; - int unreadMessages; - bool unreadMention; - bool unreadMentionDirect; - int lastReadMessage; - int lastCommonReadMessage; - GetChatResponseObject lastMessage; - String? status; - String? statusIcon; - String? statusMessage; - String? sort; - - GetRoomResponseObject( - this.id, - this.token, - this.type, - this.name, - this.displayName, - this.description, - this.participantType, - this.participantFlags, - this.readOnly, - this.listable, - this.lastPing, - this.sessionId, - this.hasPassword, - this.hasCall, - this.callFlag, - this.canStartCall, - this.canDeleteConversation, - this.canLeaveConversation, - this.lastActivity, - this.isFavorite, - this.notificationLevel, - this.unreadMessages, - this.unreadMention, - this.unreadMentionDirect, - this.lastReadMessage, - this.lastCommonReadMessage, - this.lastMessage, - this.status, - this.statusIcon, - this.statusMessage); - - factory GetRoomResponseObject.fromJson(Map json) => _$GetRoomResponseObjectFromJson(json); - Map toJson() => _$GetRoomResponseObjectToJson(this); -} - -enum GetRoomResponseObjectConversationType { - @JsonValue(1) oneToOne, - @JsonValue(2) group, - @JsonValue(3) public, - @JsonValue(4) changelog, - @JsonValue(5) deleted, - @JsonValue(6) noteToSelf, -} - -enum GetRoomResponseObjectParticipantNotificationLevel { - @JsonValue(0) defaultLevel, - @JsonValue(1) alwaysNotify, - @JsonValue(2) notifyOnMention, - @JsonValue(3) neverNotify, -} - -// @JsonSerializable(explicitToJson: true) -// class GetRoomResponseObjectMessage { -// int id; -// String token; -// GetRoomResponseObjectMessageActorType actorType; -// String actorId; -// String actorDisplayName; -// int timestamp; -// String message; -// String systemMessage; -// GetRoomResponseObjectMessageType messageType; -// bool isReplyable; -// String referenceId; -// -// -// GetRoomResponseObjectMessage( -// this.id, -// this.token, -// this.actorType, -// this.actorId, -// this.actorDisplayName, -// this.timestamp, -// this.message, -// this.systemMessage, -// this.messageType, -// this.isReplyable, -// this.referenceId); -// -// factory GetRoomResponseObjectMessage.fromJson(Map json) => _$GetRoomResponseObjectMessageFromJson(json); -// Map toJson() => _$GetRoomResponseObjectMessageToJson(this); -// } - -enum GetRoomResponseObjectMessageActorType { - @JsonValue('deleted_users') deletedUsers, - @JsonValue('users') user, - @JsonValue('guests') guest, - @JsonValue('bots') bot, - @JsonValue('bridged') bridge, -} - -enum GetRoomResponseObjectMessageType { - @JsonValue('comment') comment, - @JsonValue('voice-message') voiceMessage, - @JsonValue('comment_deleted') deletedComment, - @JsonValue('system') system, - @JsonValue('command') command, -} diff --git a/lib/api/marianumcloud/talk/room/get_room.dart b/lib/api/marianumcloud/talk/room/get_room.dart new file mode 100644 index 0000000..c2049ef --- /dev/null +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -0,0 +1,25 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +import '../talk_api.dart'; +import 'get_room_params.dart'; +import 'get_room_response.dart'; + +class GetRoom extends TalkApi { + GetRoomParams params; + GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); + + @override + GetRoomResponse assemble(String raw) { + final decoded = jsonDecode(raw) as Map; + return GetRoomResponse.fromJson(decoded['ocs'] as Map); + } + + @override + Future request( + Uri uri, + Object? body, + Map? headers, + ) => http.get(uri, headers: headers); +} diff --git a/lib/api/marianumcloud/talk/room/get_room_cache.dart b/lib/api/marianumcloud/talk/room/get_room_cache.dart new file mode 100644 index 0000000..8bcbd55 --- /dev/null +++ b/lib/api/marianumcloud/talk/room/get_room_cache.dart @@ -0,0 +1,15 @@ +import '../../../request_cache.dart'; +import 'get_room.dart'; +import 'get_room_params.dart'; +import 'get_room_response.dart'; + +class GetRoomCache extends SimpleCache { + GetRoomCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheMinute, + loader: () => GetRoom(GetRoomParams(includeStatus: true)).run(), + fromJson: GetRoomResponse.fromJson, + ) { + start('nc-rooms'); + } +} diff --git a/lib/api/marianumcloud/talk/room/getRoomParams.dart b/lib/api/marianumcloud/talk/room/get_room_params.dart similarity index 61% rename from lib/api/marianumcloud/talk/room/getRoomParams.dart rename to lib/api/marianumcloud/talk/room/get_room_params.dart index 70d371d..35dc4b8 100644 --- a/lib/api/marianumcloud/talk/room/getRoomParams.dart +++ b/lib/api/marianumcloud/talk/room/get_room_params.dart @@ -1,25 +1,28 @@ - import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getRoomParams.g.dart'; +part 'get_room_params.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomParams extends ApiParams { GetRoomParamsStatusUpdate? noStatusUpdate; - @JsonKey(toJson: _format) bool? includeStatus; + @JsonKey(toJson: _format) + bool? includeStatus; int? modifiedSince; GetRoomParams({this.noStatusUpdate, this.includeStatus, this.modifiedSince}); - factory GetRoomParams.fromJson(Map json) => _$GetRoomParamsFromJson(json); + factory GetRoomParams.fromJson(Map json) => + _$GetRoomParamsFromJson(json); Map toJson() => _$GetRoomParamsToJson(this); - + static String _format(bool? v) => v.toString(); } enum GetRoomParamsStatusUpdate { - @JsonValue(0) defaults, - @JsonValue(1) keepAlive, + @JsonValue(0) + defaults, + @JsonValue(1) + keepAlive, } diff --git a/lib/api/marianumcloud/talk/room/getRoomParams.g.dart b/lib/api/marianumcloud/talk/room/get_room_params.g.dart similarity index 96% rename from lib/api/marianumcloud/talk/room/getRoomParams.g.dart rename to lib/api/marianumcloud/talk/room/get_room_params.g.dart index 0e20511..ceaa6b1 100644 --- a/lib/api/marianumcloud/talk/room/getRoomParams.g.dart +++ b/lib/api/marianumcloud/talk/room/get_room_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomParams.dart'; +part of 'get_room_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/room/get_room_response.dart b/lib/api/marianumcloud/talk/room/get_room_response.dart new file mode 100644 index 0000000..c18e668 --- /dev/null +++ b/lib/api/marianumcloud/talk/room/get_room_response.dart @@ -0,0 +1,164 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; +import '../chat/get_chat_response.dart'; + +part 'get_room_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class GetRoomResponse extends ApiResponse { + Set data; + + GetRoomResponse(this.data); + + factory GetRoomResponse.fromJson(Map json) => + _$GetRoomResponseFromJson(json); + Map toJson() => _$GetRoomResponseToJson(this); + + List sortBy({ + bool lastActivity = true, + required bool favoritesToTop, + required bool unreadToTop, + }) { + for (var chat in data) { + final buffer = StringBuffer(); + + if (favoritesToTop) { + buffer.write(chat.isFavorite ? 'b' : 'a'); + } + if (unreadToTop) { + buffer.write(chat.unreadMessages > 0 ? 'b' : 'a'); + } + + buffer.write(chat.lastActivity); + + chat.sort = buffer.toString(); + } + + return data.toList()..sort((a, b) => b.sort!.compareTo(a.sort!)); + } +} + +@JsonSerializable(explicitToJson: true) +class GetRoomResponseObject { + int id; + String token; + GetRoomResponseObjectConversationType type; + String name; + String displayName; + String description; + int participantType; + int participantFlags; + int readOnly; + int listable; + int lastPing; + String sessionId; + bool hasPassword; + bool hasCall; + int callFlag; + bool canStartCall; + bool canDeleteConversation; + bool canLeaveConversation; + int lastActivity; + bool isFavorite; + GetRoomResponseObjectParticipantNotificationLevel notificationLevel; + int unreadMessages; + bool unreadMention; + bool unreadMentionDirect; + int lastReadMessage; + int lastCommonReadMessage; + GetChatResponseObject lastMessage; + String? status; + String? statusIcon; + String? statusMessage; + String? sort; + + GetRoomResponseObject( + this.id, + this.token, + this.type, + this.name, + this.displayName, + this.description, + this.participantType, + this.participantFlags, + this.readOnly, + this.listable, + this.lastPing, + this.sessionId, + this.hasPassword, + this.hasCall, + this.callFlag, + this.canStartCall, + this.canDeleteConversation, + this.canLeaveConversation, + this.lastActivity, + this.isFavorite, + this.notificationLevel, + this.unreadMessages, + this.unreadMention, + this.unreadMentionDirect, + this.lastReadMessage, + this.lastCommonReadMessage, + this.lastMessage, + this.status, + this.statusIcon, + this.statusMessage, + ); + + factory GetRoomResponseObject.fromJson(Map json) => + _$GetRoomResponseObjectFromJson(json); + Map toJson() => _$GetRoomResponseObjectToJson(this); +} + +enum GetRoomResponseObjectConversationType { + @JsonValue(1) + oneToOne, + @JsonValue(2) + group, + @JsonValue(3) + public, + @JsonValue(4) + changelog, + @JsonValue(5) + deleted, + @JsonValue(6) + noteToSelf, +} + +enum GetRoomResponseObjectParticipantNotificationLevel { + @JsonValue(0) + defaultLevel, + @JsonValue(1) + alwaysNotify, + @JsonValue(2) + notifyOnMention, + @JsonValue(3) + neverNotify, +} + +enum GetRoomResponseObjectMessageActorType { + @JsonValue('deleted_users') + deletedUsers, + @JsonValue('users') + user, + @JsonValue('guests') + guest, + @JsonValue('bots') + bot, + @JsonValue('bridged') + bridge, +} + +enum GetRoomResponseObjectMessageType { + @JsonValue('comment') + comment, + @JsonValue('voice-message') + voiceMessage, + @JsonValue('comment_deleted') + deletedComment, + @JsonValue('system') + system, + @JsonValue('command') + command, +} diff --git a/lib/api/marianumcloud/talk/room/getRoomResponse.g.dart b/lib/api/marianumcloud/talk/room/get_room_response.g.dart similarity index 99% rename from lib/api/marianumcloud/talk/room/getRoomResponse.g.dart rename to lib/api/marianumcloud/talk/room/get_room_response.g.dart index 92491fb..49362cc 100644 --- a/lib/api/marianumcloud/talk/room/getRoomResponse.g.dart +++ b/lib/api/marianumcloud/talk/room/get_room_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomResponse.dart'; +part of 'get_room_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessage.dart b/lib/api/marianumcloud/talk/sendMessage/sendMessage.dart deleted file mode 100644 index 61af457..0000000 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessage.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../../../apiParams.dart'; -import '../talkApi.dart'; -import 'sendMessageParams.dart'; - -class SendMessage extends TalkApi { - String chatToken; - SendMessage(this.chatToken, SendMessageParams params) : super('v1/chat/$chatToken', params); - - @override - assemble(String raw) => null; - - @override - Future? request(Uri uri, ApiParams? body, Map? headers) { - if(body is SendMessageParams) { - return http.post(uri, headers: headers, body: body.toJson()); - } - return null; - } - -} diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageResponse.dart b/lib/api/marianumcloud/talk/sendMessage/sendMessageResponse.dart deleted file mode 100644 index 5c9500b..0000000 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageResponse.dart +++ /dev/null @@ -1,5 +0,0 @@ -import '../../../apiResponse.dart'; - -class SendMessageResponse extends ApiResponse { - -} diff --git a/lib/api/marianumcloud/talk/send_message/send_message.dart b/lib/api/marianumcloud/talk/send_message/send_message.dart new file mode 100644 index 0000000..b2849ad --- /dev/null +++ b/lib/api/marianumcloud/talk/send_message/send_message.dart @@ -0,0 +1,27 @@ +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +import '../../../api_params.dart'; +import '../talk_api.dart'; +import 'send_message_params.dart'; + +class SendMessage extends TalkApi { + String chatToken; + SendMessage(this.chatToken, SendMessageParams params) + : super('v1/chat/$chatToken', params); + + @override + Null assemble(String raw) => null; + + @override + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ) { + if (body is SendMessageParams) { + return http.post(uri, headers: headers, body: body.toJson()); + } + return null; + } +} diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart b/lib/api/marianumcloud/talk/send_message/send_message_params.dart similarity index 77% rename from lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart rename to lib/api/marianumcloud/talk/send_message/send_message_params.dart index d467246..a84adea 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'sendMessageParams.g.dart'; +part 'send_message_params.g.dart'; @JsonSerializable(explicitToJson: true, includeIfNull: false) class SendMessageParams extends ApiParams { @@ -11,6 +11,7 @@ class SendMessageParams extends ApiParams { SendMessageParams(this.message, {this.replyTo}); - factory SendMessageParams.fromJson(Map json) => _$SendMessageParamsFromJson(json); + factory SendMessageParams.fromJson(Map json) => + _$SendMessageParamsFromJson(json); Map toJson() => _$SendMessageParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart b/lib/api/marianumcloud/talk/send_message/send_message_params.g.dart similarity index 94% rename from lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart rename to lib/api/marianumcloud/talk/send_message/send_message_params.g.dart index 602decc..34e1d0c 100644 --- a/lib/api/marianumcloud/talk/sendMessage/sendMessageParams.g.dart +++ b/lib/api/marianumcloud/talk/send_message/send_message_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'sendMessageParams.dart'; +part of 'send_message_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/setFavorite/setFavorite.dart b/lib/api/marianumcloud/talk/setFavorite/setFavorite.dart deleted file mode 100644 index 5f06d51..0000000 --- a/lib/api/marianumcloud/talk/setFavorite/setFavorite.dart +++ /dev/null @@ -1,25 +0,0 @@ - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../talkApi.dart'; - -class SetFavorite extends TalkApi { - String chatToken; - bool favoriteState; - - SetFavorite(this.chatToken, this.favoriteState) : super('v4/room/$chatToken/favorite', null); - - @override - assemble(String raw) => null; - - @override - Future request(Uri uri, Object? body, Map? headers) { - if(favoriteState) { - return http.post(uri, headers: headers); - } else { - return http.delete(uri, headers: headers); - } - } - -} diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart b/lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart deleted file mode 100644 index c3ae029..0000000 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarker.dart +++ /dev/null @@ -1,30 +0,0 @@ - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../talkApi.dart'; -import 'setReadMarkerParams.dart'; - -class SetReadMarker extends TalkApi { - String chatToken; - bool readState; - SetReadMarkerParams? setReadMarkerParams; - - SetReadMarker(this.chatToken, this.readState, {this.setReadMarkerParams}) : super('v1/chat/$chatToken/read', null, getParameters: setReadMarkerParams?.toJson()) { - if(readState) assert(setReadMarkerParams?.lastReadMessage != null); - } - - @override - assemble(String raw) => null; - - @override - Future request(Uri uri, Object? body, Map? headers) { - if(readState) { - - return http.post(uri, headers: headers); - } else { - return http.delete(uri, headers: headers); - } - } - -} diff --git a/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart new file mode 100644 index 0000000..847a96d --- /dev/null +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker.dart @@ -0,0 +1,36 @@ +import 'package:http/http.dart' as http; +import 'package:http/http.dart'; + +import '../talk_api.dart'; +import 'set_read_marker_params.dart'; + +class SetReadMarker extends TalkApi { + String chatToken; + bool readState; + SetReadMarkerParams? setReadMarkerParams; + + SetReadMarker(this.chatToken, this.readState, {this.setReadMarkerParams}) + : super( + 'v1/chat/$chatToken/read', + null, + getParameters: setReadMarkerParams?.toJson(), + ) { + if (readState) assert(setReadMarkerParams?.lastReadMessage != null); + } + + @override + Null assemble(String raw) => null; + + @override + Future request( + Uri uri, + Object? body, + Map? headers, + ) { + if (readState) { + return http.post(uri, headers: headers); + } else { + return http.delete(uri, headers: headers); + } + } +} diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart similarity index 62% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart index 5f037c2..62c3278 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart @@ -1,17 +1,16 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'setReadMarkerParams.g.dart'; +part 'set_read_marker_params.g.dart'; @JsonSerializable() class SetReadMarkerParams extends ApiParams { int? lastReadMessage; - SetReadMarkerParams({ - this.lastReadMessage - }); + SetReadMarkerParams({this.lastReadMessage}); - factory SetReadMarkerParams.fromJson(Map json) => _$SetReadMarkerParamsFromJson(json); + factory SetReadMarkerParams.fromJson(Map json) => + _$SetReadMarkerParamsFromJson(json); Map toJson() => _$SetReadMarkerParamsToJson(this); } diff --git a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart similarity index 93% rename from lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart rename to lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart index 5c3c941..e6067b1 100644 --- a/lib/api/marianumcloud/talk/setReadMarker/setReadMarkerParams.g.dart +++ b/lib/api/marianumcloud/talk/set_read_marker/set_read_marker_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'setReadMarkerParams.dart'; +part of 'set_read_marker_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/talk/talkApi.dart b/lib/api/marianumcloud/talk/talkApi.dart deleted file mode 100644 index e79340f..0000000 --- a/lib/api/marianumcloud/talk/talkApi.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:developer'; - -import 'package:http/http.dart' as http; - -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; -import '../../apiError.dart'; -import '../../apiParams.dart'; -import '../../apiRequest.dart'; -import '../../apiResponse.dart'; - -enum TalkApiMethod { - get, - post, - put, - delete, -} - -abstract class TalkApi extends ApiRequest { - String path; - ApiParams? body; - Map? headers = {}; - Map? getParameters; - - http.Response? response; - - TalkApi(this.path, this.body, {this.headers, this.getParameters}); - - Future? request(Uri uri, ApiParams? body, Map? headers); - T assemble(String raw); - - Future run() async { - getParameters?.forEach((key, value) { - getParameters?.update(key, (value) => value.toString()); - }); - - var endpoint = Uri.https('${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().domain}', '${EndpointData().nextcloud().path}/ocs/v2.php/apps/spreed/api/$path', getParameters); - - headers ??= {}; - headers?.putIfAbsent('Accept', () => 'application/json'); - headers?.putIfAbsent('OCS-APIRequest', () => 'true'); - - http.Response? data; - - try { - data = await request(endpoint, body, headers); - if(data == null) throw Exception('No response Data'); - if(data.statusCode >= 400 || data.statusCode < 200) throw Exception("Response status code '${data.statusCode}' might indicate an error"); - } catch(e) { - log(e.toString()); - throw ApiError('Request $endpoint could not be dispatched: ${e.toString()}'); - } - //dynamic jsonData = jsonDecode(data.body); - - T assembled; - try { - assembled = assemble(data.body); - assembled?.headers = data.headers; - return assembled; - } catch (e) { - var message = 'Error assembling Talk API ${T.toString()} message: ${e.toString()} response with request body: $body and request headers: ${headers.toString()}'; - log(message); - throw Exception(message); - } - } -} diff --git a/lib/api/marianumcloud/talk/talk_api.dart b/lib/api/marianumcloud/talk/talk_api.dart new file mode 100644 index 0000000..b461221 --- /dev/null +++ b/lib/api/marianumcloud/talk/talk_api.dart @@ -0,0 +1,83 @@ +import 'dart:async'; +import 'dart:developer'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../../api_params.dart'; +import '../../api_request.dart'; +import '../../api_response.dart'; +import '../../errors/auth_exception.dart'; +import '../../errors/network_exception.dart'; +import '../../errors/not_found_exception.dart'; +import '../../errors/parse_exception.dart'; +import '../../errors/server_exception.dart'; +import '../nextcloud_ocs.dart'; + +enum TalkApiMethod { get, post, put, delete } + +abstract class TalkApi extends ApiRequest { + String path; + ApiParams? body; + Map? headers; + Map? getParameters; + + http.Response? response; + + TalkApi(this.path, this.body, {this.headers, this.getParameters}); + + Future? request( + Uri uri, + ApiParams? body, + Map? headers, + ); + T assemble(String raw); + + Future run() async { + final endpoint = NextcloudOcs.uri( + 'apps/spreed/api/$path', + queryParameters: getParameters, + ); + final mergedHeaders = {...NextcloudOcs.headers(), ...?headers}; + + final http.Response data; + try { + final raw = await request(endpoint, body, mergedHeaders); + if (raw == null) { + throw const NetworkException( + userMessage: 'Keine Antwort vom Talk-Server erhalten.', + technicalDetails: 'Talk request returned null', + ); + } + data = raw; + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}'); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}'); + } + + final status = data.statusCode; + if (status < 200 || status >= 300) { + final detail = 'Talk $endpoint -> HTTP $status'; + log(detail); + if (status == 401) { + throw AuthException.unauthorized(technicalDetails: detail); + } + if (status == 403) { + throw AuthException.forbidden(technicalDetails: detail); + } + if (status == 404) throw NotFoundException(technicalDetails: detail); + throw ServerException(statusCode: status, technicalDetails: detail); + } + + try { + final assembled = assemble(data.body); + assembled?.headers = data.headers; + return assembled; + } catch (e) { + throw ParseException(technicalDetails: 'Talk $endpoint assemble: $e'); + } + } +} diff --git a/lib/api/marianumcloud/talk/talkError.dart b/lib/api/marianumcloud/talk/talk_error.dart similarity index 100% rename from lib/api/marianumcloud/talk/talkError.dart rename to lib/api/marianumcloud/talk/talk_error.dart diff --git a/lib/api/marianumcloud/talk/votePoll/votePoll.dart b/lib/api/marianumcloud/talk/votePoll/votePoll.dart deleted file mode 100644 index 7cedfe1..0000000 --- a/lib/api/marianumcloud/talk/votePoll/votePoll.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:http/http.dart'; - -import '../getPoll/getPollStateResponse.dart'; -import '../talkApi.dart'; -import 'votePollParams.dart'; - -@Deprecated('VotePoll is broken') -class VotePoll extends TalkApi { - String token; - int pollId; - VotePoll({required this.token, required this.pollId, required VotePollParams params}) : super('v1/poll/$token/$pollId', params); - - @override - GetPollStateResponse assemble(String raw) => GetPollStateResponse.fromJson(jsonDecode(raw)['ocs']); - - @override - Future? request(Uri uri, Object? body, Map? headers) { - if(body is VotePollParams) { - return http.post(uri, headers: headers, body: body.toJson().toString()); - } - return null; - } -} diff --git a/lib/api/marianumcloud/talk/votePoll/votePollParams.dart b/lib/api/marianumcloud/talk/votePoll/votePollParams.dart deleted file mode 100644 index 151459d..0000000 --- a/lib/api/marianumcloud/talk/votePoll/votePollParams.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../../apiParams.dart'; - -part 'votePollParams.g.dart'; - -@JsonSerializable() -@Deprecated('VotePoll is broken') -class VotePollParams extends ApiParams { - List optionIds; - - VotePollParams({required this.optionIds}); - factory VotePollParams.fromJson(Map json) => _$VotePollParamsFromJson(json); - Map toJson() => _$VotePollParamsToJson(this); -} diff --git a/lib/api/marianumcloud/talk/votePoll/votePollParams.g.dart b/lib/api/marianumcloud/talk/votePoll/votePollParams.g.dart deleted file mode 100644 index 5b43858..0000000 --- a/lib/api/marianumcloud/talk/votePoll/votePollParams.g.dart +++ /dev/null @@ -1,17 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'votePollParams.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -VotePollParams _$VotePollParamsFromJson(Map json) => - VotePollParams( - optionIds: (json['optionIds'] as List) - .map((e) => (e as num).toInt()) - .toList(), - ); - -Map _$VotePollParamsToJson(VotePollParams instance) => - {'optionIds': instance.optionIds}; diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart b/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart deleted file mode 100644 index 30269c6..0000000 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFile.dart +++ /dev/null @@ -1,22 +0,0 @@ - - -import '../../../../apiResponse.dart'; -import '../../webdavApi.dart'; -import 'downloadFileParams.dart'; - -class DownloadFile extends WebdavApi { - DownloadFileParams params; - - DownloadFile(this.params) : super(params); - - @override - Future run() async { - - // final file = await File(localPath).create(); - // Uint8List downloadedFile = await (await WebdavApi.webdav).download(params.webdavPath); - // file.writeAsBytesSync(downloadedFile, flush: true, mode: FileMode.write); - // - // OpenFile.open(localPath); - throw UnimplementedError(); - } -} diff --git a/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart new file mode 100644 index 0000000..160b4a1 --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file.dart @@ -0,0 +1,14 @@ +import '../../../../api_response.dart'; +import '../../webdav_api.dart'; +import 'download_file_params.dart'; + +class DownloadFile extends WebdavApi { + DownloadFileParams params; + + DownloadFile(this.params) : super(params); + + @override + Future run() async { + throw UnimplementedError(); + } +} diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart similarity index 60% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart index 11b6231..d7763b5 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../../apiParams.dart'; +import '../../../../api_params.dart'; -part 'downloadFileParams.g.dart'; +part 'download_file_params.g.dart'; @JsonSerializable() class DownloadFileParams extends ApiParams { @@ -10,8 +10,13 @@ class DownloadFileParams extends ApiParams { String localTargetPath; String filename; - DownloadFileParams(this.webdavSourcePath, this.localTargetPath, this.filename); + DownloadFileParams( + this.webdavSourcePath, + this.localTargetPath, + this.filename, + ); - factory DownloadFileParams.fromJson(Map json) => _$DownloadFileParamsFromJson(json); + factory DownloadFileParams.fromJson(Map json) => + _$DownloadFileParamsFromJson(json); Map toJson() => _$DownloadFileParamsToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart similarity index 95% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart index 3b56332..aa72134 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileParams.g.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'downloadFileParams.dart'; +part of 'download_file_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart similarity index 77% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart index 3368cdd..ca91def 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.dart @@ -1,7 +1,6 @@ - import 'package:json_annotation/json_annotation.dart'; -part 'downloadFileResponse.g.dart'; +part 'download_file_response.g.dart'; @JsonSerializable() class DownloadFileResponse { @@ -9,6 +8,7 @@ class DownloadFileResponse { DownloadFileResponse(this.path); - factory DownloadFileResponse.fromJson(Map json) => _$DownloadFileResponseFromJson(json); + factory DownloadFileResponse.fromJson(Map json) => + _$DownloadFileResponseFromJson(json); Map toJson() => _$DownloadFileResponseToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart similarity index 92% rename from lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart rename to lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart index 745d832..bab583f 100644 --- a/lib/api/marianumcloud/webdav/queries/downloadFile/downloadFileResponse.g.dart +++ b/lib/api/marianumcloud/webdav/queries/download_file/download_file_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'downloadFileResponse.dart'; +part of 'download_file_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart deleted file mode 100644 index 438204d..0000000 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFiles.dart +++ /dev/null @@ -1,33 +0,0 @@ - -import 'package:nextcloud/nextcloud.dart'; - -import '../../webdavApi.dart'; -import 'cacheableFile.dart'; -import 'listFilesParams.dart'; -import 'listFilesResponse.dart'; - -class ListFiles extends WebdavApi { - ListFilesParams params; - - ListFiles(this.params) : super(params); - - @override - Future run() async { - var davFiles = (await (await WebdavApi.webdav).propfind(PathUri.parse(params.path))).toWebDavFiles(); - var files = davFiles.map(CacheableFile.fromDavFile).toSet(); - - // webdav handles subdirectories wrong, this is a fix - // currently this fix is not needed anymore - // if(EndpointData().getEndpointMode() == EndpointMode.stage) { - // files = files.map((e) { // somehow - // e.path = e.path.split("mobile/cloud/remote.php/webdav")[1]; - // return e; - // }).toSet(); - // } - - // somehow the current working folder is also listed, it is filtered here. - files.removeWhere((element) => element.path == '/${params.path}/' || element.path == '/'); - - return ListFilesResponse(files); - } -} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart deleted file mode 100644 index 845dc47..0000000 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'dart:convert'; -import 'package:crypto/crypto.dart'; - -import '../../../../requestCache.dart'; -import 'listFiles.dart'; -import 'listFilesParams.dart'; -import 'listFilesResponse.dart'; - -class ListFilesCache extends RequestCache { - String path; - - ListFilesCache({required onUpdate, required this.path}) : super(RequestCache.cacheNothing, onUpdate) { - var bytes = utf8.encode('MarianumMobile-$path'); - var cacheName = md5.convert(bytes).toString(); - start('wd-folder-$cacheName'); - } - - @override - Future onLoad() async { - var data = await ListFiles(ListFilesParams(path)).run(); - return data; - } - - @override - ListFilesResponse onLocalData(String json) => ListFilesResponse.fromJson(jsonDecode(json)); - -} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart b/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart deleted file mode 100644 index 59f8d0e..0000000 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart +++ /dev/null @@ -1,57 +0,0 @@ -import 'package:jiffy/jiffy.dart'; -import 'package:json_annotation/json_annotation.dart'; - -import '../../../../../view/pages/files/files.dart'; -import '../../../../apiResponse.dart'; -import 'cacheableFile.dart'; - -part 'listFilesResponse.g.dart'; - -@JsonSerializable(explicitToJson: true) -class ListFilesResponse extends ApiResponse { - Set files; - - ListFilesResponse(this.files); - - factory ListFilesResponse.fromJson(Map json) => _$ListFilesResponseFromJson(json); - Map toJson() => _$ListFilesResponseToJson(this); - - List sortBy({bool foldersToTop = true, SortOption sortOption = SortOption.name, bool reversed = false}) { - var list = List.empty(growable: true); - - if(foldersToTop) { - list.addAll(_sort(files.where((element) => element.isDirectory).toSet(), reversed: reversed, sortOption: sortOption)); - list.addAll(_sort(files.where((element) => !element.isDirectory).toSet(), reversed: reversed, sortOption: sortOption)); - } else { - list.addAll(_sort(files, reversed: reversed, sortOption: sortOption)); - } - - return list; - } - - List _sort(Set files, {SortOption sortOption = SortOption.name, bool reversed = false}) { - for (var file in files) { - final buffer = StringBuffer(); - - switch(sortOption) { - case SortOption.date: - buffer.write(Jiffy.parseFromMillisecondsSinceEpoch(file.modifiedAt?.millisecondsSinceEpoch ?? 0).format(pattern: 'yyyyMMddhhmmss')); - break; - - case SortOption.name: - buffer.write(file.name.toLowerCase()); - break; - - case SortOption.size: - buffer.write(file.size); - break; - } - - file.sort = buffer.toString(); - } - - - var list = files.toList()..sort((a, b) => b.sort!.compareTo(a.sort!)); - return reversed ? list.reversed.toList() : list; - } -} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart similarity index 66% rename from lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart rename to lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart index b8a9918..bf23d1e 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart @@ -1,7 +1,7 @@ import 'package:json_annotation/json_annotation.dart'; import 'package:nextcloud/nextcloud.dart'; -part 'cacheableFile.g.dart'; +part 'cacheable_file.g.dart'; @JsonSerializable(explicitToJson: true) class CacheableFile { @@ -15,11 +15,16 @@ class CacheableFile { DateTime? modifiedAt; String? sort; - @JsonKey(includeFromJson: false, includeToJson: false) - bool currentlyDownloading = false; - - - CacheableFile({required this.path, required this.isDirectory, required this.name, this.mimeType, this.size, this.eTag, this.createdAt, this.modifiedAt}); + CacheableFile({ + required this.path, + required this.isDirectory, + required this.name, + this.mimeType, + this.size, + this.eTag, + this.createdAt, + this.modifiedAt, + }); CacheableFile.fromDavFile(WebDavFile file) { path = file.path.path; @@ -32,6 +37,7 @@ class CacheableFile { modifiedAt = file.lastModified; } - factory CacheableFile.fromJson(Map json) => _$CacheableFileFromJson(json); + factory CacheableFile.fromJson(Map json) => + _$CacheableFileFromJson(json); Map toJson() => _$CacheableFileToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart similarity index 97% rename from lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart index c79547b..4e3407d 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/cacheableFile.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/cacheable_file.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'cacheableFile.dart'; +part of 'cacheable_file.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart new file mode 100644 index 0000000..d9b9745 --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files.dart @@ -0,0 +1,40 @@ +import 'package:nextcloud/nextcloud.dart'; + +import '../../webdav_api.dart'; +import 'cacheable_file.dart'; +import 'list_files_params.dart'; +import 'list_files_response.dart'; + +class ListFiles extends WebdavApi { + ListFilesParams params; + + ListFiles(this.params) : super(params); + + // The Nextcloud root listing is significantly slower than subdirectories on + // our instance, so it gets a much longer ceiling. Subfolders fall back to a + // tighter timeout to keep the UI responsive. + static const Duration _rootTimeout = Duration(minutes: 3); + static const Duration _subfolderTimeout = Duration(seconds: 30); + + bool get _isRoot { + final p = params.path.replaceAll('/', '').trim(); + return p.isEmpty; + } + + @override + Future run() async { + final webdav = await WebdavApi.webdav; + final timeout = _isRoot ? _rootTimeout : _subfolderTimeout; + final davFiles = + (await webdav.propfind(PathUri.parse(params.path)).timeout(timeout)) + .toWebDavFiles(); + final files = davFiles.map(CacheableFile.fromDavFile).toSet(); + + // somehow the current working folder is also listed, it is filtered here. + files.removeWhere( + (element) => element.path == '/${params.path}/' || element.path == '/', + ); + + return ListFilesResponse(files); + } +} diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart new file mode 100644 index 0000000..b9a4cd1 --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_cache.dart @@ -0,0 +1,46 @@ +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:localstore/localstore.dart'; + +import '../../../../../utils/cache_invalidation_bus.dart'; +import '../../../../request_cache.dart'; +import 'list_files.dart'; +import 'list_files_params.dart'; +import 'list_files_response.dart'; + +class ListFilesCache extends SimpleCache { + ListFilesCache({ + required void Function(ListFilesResponse) onUpdate, + super.onCacheData, + super.onNetworkData, + super.onError, + required String path, + }) : super( + cacheTime: RequestCache.cacheNothing, + loader: () => ListFiles(ListFilesParams(path)).run(), + fromJson: ListFilesResponse.fromJson, + onUpdate: onUpdate, + ) { + start(_documentId(path)); + } + + static String _documentId(String path) { + final cacheName = md5 + .convert(utf8.encode('MarianumMobile-$path')) + .toString(); + return 'wd-folder-$cacheName'; + } + + /// Drops the cached listing for [path] from local storage so the next + /// `listFiles` call cannot serve stale data, and notifies any live + /// `_FilesView` for that path via [CacheInvalidationBus] so it refetches + /// even while it is sitting in the background of the navigation stack. + static Future invalidate(String path) async { + await Localstore.instance + .collection(RequestCache.collection) + .doc(_documentId(path)) + .delete(); + CacheInvalidationBus.notifyListFiles(path); + } +} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart similarity index 74% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart index f0adf21..ccd6a05 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../../apiParams.dart'; +import '../../../../api_params.dart'; -part 'listFilesParams.g.dart'; +part 'list_files_params.g.dart'; @JsonSerializable(explicitToJson: true) class ListFilesParams extends ApiParams { @@ -10,6 +10,7 @@ class ListFilesParams extends ApiParams { ListFilesParams(this.path); - factory ListFilesParams.fromJson(Map json) => _$ListFilesParamsFromJson(json); + factory ListFilesParams.fromJson(Map json) => + _$ListFilesParamsFromJson(json); Map toJson() => _$ListFilesParamsToJson(this); } diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart similarity index 93% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart index 6a72efd..29f551f 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesParams.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'listFilesParams.dart'; +part of 'list_files_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart new file mode 100644 index 0000000..3c73cd8 --- /dev/null +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.dart @@ -0,0 +1,81 @@ +import 'package:jiffy/jiffy.dart'; +import 'package:json_annotation/json_annotation.dart'; + +import '../../../../../view/pages/files/data/sort_options.dart'; +import '../../../../api_response.dart'; +import 'cacheable_file.dart'; + +part 'list_files_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class ListFilesResponse extends ApiResponse { + Set files; + + ListFilesResponse(this.files); + + factory ListFilesResponse.fromJson(Map json) => + _$ListFilesResponseFromJson(json); + Map toJson() => _$ListFilesResponseToJson(this); + + List sortBy({ + bool foldersToTop = true, + SortOption sortOption = SortOption.name, + bool reversed = false, + }) { + var list = List.empty(growable: true); + + if (foldersToTop) { + list.addAll( + _sort( + files.where((element) => element.isDirectory).toSet(), + reversed: reversed, + sortOption: sortOption, + ), + ); + list.addAll( + _sort( + files.where((element) => !element.isDirectory).toSet(), + reversed: reversed, + sortOption: sortOption, + ), + ); + } else { + list.addAll(_sort(files, reversed: reversed, sortOption: sortOption)); + } + + return list; + } + + List _sort( + Set files, { + SortOption sortOption = SortOption.name, + bool reversed = false, + }) { + for (var file in files) { + final buffer = StringBuffer(); + + switch (sortOption) { + case SortOption.date: + buffer.write( + Jiffy.parseFromMillisecondsSinceEpoch( + file.modifiedAt?.millisecondsSinceEpoch ?? 0, + ).format(pattern: 'yyyyMMddhhmmss'), + ); + break; + + case SortOption.name: + buffer.write(file.name.toLowerCase()); + break; + + case SortOption.size: + buffer.write(file.size); + break; + } + + file.sort = buffer.toString(); + } + + var list = files.toList()..sort((a, b) => b.sort!.compareTo(a.sort!)); + return reversed ? list.reversed.toList() : list; + } +} diff --git a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart similarity index 95% rename from lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart rename to lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart index 77522c2..09123cc 100644 --- a/lib/api/marianumcloud/webdav/queries/listFiles/listFilesResponse.g.dart +++ b/lib/api/marianumcloud/webdav/queries/list_files/list_files_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'listFilesResponse.dart'; +part of 'list_files_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/marianumcloud/webdav/webdavApi.dart b/lib/api/marianumcloud/webdav/webdavApi.dart deleted file mode 100644 index cfa7159..0000000 --- a/lib/api/marianumcloud/webdav/webdavApi.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'package:nextcloud/nextcloud.dart'; - -import '../../../model/accountData.dart'; -import '../../../model/endpointData.dart'; -import '../../apiRequest.dart'; -import '../../apiResponse.dart'; - -abstract class WebdavApi extends ApiRequest { - T genericParams; - - WebdavApi(this.genericParams) { - establishWebdavConnection(); - } - - Future run(); - - static Future webdav = establishWebdavConnection(); - static Future webdavConnectString = buildWebdavConnectString(); - - static Future establishWebdavConnection() async => NextcloudClient(Uri.parse('https://${EndpointData().nextcloud().full()}'), password: AccountData().getPassword(), loginName: AccountData().getUsername()).webdav; - - static Future buildWebdavConnectString() async => 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; -} diff --git a/lib/api/marianumcloud/webdav/webdav_api.dart b/lib/api/marianumcloud/webdav/webdav_api.dart new file mode 100644 index 0000000..5916761 --- /dev/null +++ b/lib/api/marianumcloud/webdav/webdav_api.dart @@ -0,0 +1,30 @@ +import 'package:nextcloud/nextcloud.dart'; + +import '../../../model/account_data.dart'; +import '../../../model/endpoint_data.dart'; +import '../../api_request.dart'; +import '../../api_response.dart'; + +abstract class WebdavApi extends ApiRequest { + T genericParams; + + WebdavApi(this.genericParams) { + establishWebdavConnection(); + } + + Future run(); + + static Future webdav = establishWebdavConnection(); + + static Future establishWebdavConnection() async => + NextcloudClient( + Uri.parse('https://${EndpointData().nextcloud().full()}'), + password: AccountData().getPassword(), + loginName: AccountData().getUsername(), + ).webdav; + + /// Builds the WebDAV download URL without embedded credentials. Callers must + /// authenticate via the [AccountData.authHeaders] header instead. + static String buildWebdavUrl() => + 'https://${EndpointData().nextcloud().full()}/remote.php/dav/files/${AccountData().getUsername()}/'; +} diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart b/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart deleted file mode 100644 index 9f6ef34..0000000 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersCache.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:convert'; -import '../../../requestCache.dart'; -import 'getBreakers.dart'; -import 'getBreakersResponse.dart'; - - -class GetBreakersCache extends RequestCache { - GetBreakersCache({onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { - start('breakers'); - } - - @override - GetBreakersResponse onLocalData(String json) => GetBreakersResponse.fromJson(jsonDecode(json)); - - @override - Future onLoad() => GetBreakers().run(); -} diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakers.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart similarity index 59% rename from lib/api/mhsl/breaker/getBreakers/getBreakers.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers.dart index 63d2fe0..35d70b0 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakers.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers.dart @@ -2,16 +2,16 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import 'package:http/http.dart'; -import '../../mhslApi.dart'; -import 'getBreakersResponse.dart'; +import '../../mhsl_api.dart'; +import 'get_breakers_response.dart'; class GetBreakers extends MhslApi { GetBreakers() : super('breaker/'); @override - GetBreakersResponse assemble(String raw) => GetBreakersResponse.fromJson(jsonDecode(raw)); + GetBreakersResponse assemble(String raw) => + GetBreakersResponse.fromJson(jsonDecode(raw) as Map); @override Future? request(Uri uri) => http.get(uri); - } diff --git a/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart new file mode 100644 index 0000000..1937df5 --- /dev/null +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_cache.dart @@ -0,0 +1,14 @@ +import '../../../request_cache.dart'; +import 'get_breakers.dart'; +import 'get_breakers_response.dart'; + +class GetBreakersCache extends SimpleCache { + GetBreakersCache({super.onUpdate, super.renew}) + : super( + cacheTime: RequestCache.cacheMinute, + loader: () => GetBreakers().run(), + fromJson: GetBreakersResponse.fromJson, + ) { + start('breakers'); + } +} diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart similarity index 68% rename from lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart index 6e0cb73..c0cb39f 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.dart @@ -1,9 +1,8 @@ - import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getBreakersResponse.g.dart'; +part 'get_breakers_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetBreakersResponse extends ApiResponse { @@ -12,7 +11,8 @@ class GetBreakersResponse extends ApiResponse { GetBreakersResponse(this.global, this.regional); - factory GetBreakersResponse.fromJson(Map json) => _$GetBreakersResponseFromJson(json); + factory GetBreakersResponse.fromJson(Map json) => + _$GetBreakersResponseFromJson(json); Map toJson() => _$GetBreakersResponseToJson(this); } @@ -23,14 +23,20 @@ class GetBreakersReponseObject { GetBreakersReponseObject(this.areas, this.message); - factory GetBreakersReponseObject.fromJson(Map json) => _$GetBreakersReponseObjectFromJson(json); + factory GetBreakersReponseObject.fromJson(Map json) => + _$GetBreakersReponseObjectFromJson(json); Map toJson() => _$GetBreakersReponseObjectToJson(this); } enum BreakerArea { - @JsonValue('GLOBAL') global, - @JsonValue('TIMETABLE') timetable, - @JsonValue('TALK') talk, - @JsonValue('FILES') files, - @JsonValue('MORE') more, + @JsonValue('GLOBAL') + global, + @JsonValue('TIMETABLE') + timetable, + @JsonValue('TALK') + talk, + @JsonValue('FILES') + files, + @JsonValue('MORE') + more, } diff --git a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart similarity index 97% rename from lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart rename to lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart index 5a2418c..30d16e6 100644 --- a/lib/api/mhsl/breaker/getBreakers/getBreakersResponse.g.dart +++ b/lib/api/mhsl/breaker/get_breakers/get_breakers_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getBreakersResponse.dart'; +part of 'get_breakers_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart deleted file mode 100644 index bca7fd0..0000000 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEvent.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http/http.dart' as http; - -import '../../mhslApi.dart'; -import 'getCustomTimetableEventParams.dart'; -import 'getCustomTimetableEventResponse.dart'; - -class GetCustomTimetableEvent extends MhslApi { - GetCustomTimetableEventParams params; - GetCustomTimetableEvent(this.params) : super('server/timetable/customEvents?user=${params.user}'); - - @override - GetCustomTimetableEventResponse assemble(String raw) => GetCustomTimetableEventResponse.fromJson({'events': jsonDecode(raw)}); - - @override - Future? request(Uri uri) => http.get(uri); -} diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart b/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart deleted file mode 100644 index 1f67d5f..0000000 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'dart:convert'; - -import '../../../requestCache.dart'; -import 'getCustomTimetableEvent.dart'; -import 'getCustomTimetableEventParams.dart'; -import 'getCustomTimetableEventResponse.dart'; - -class GetCustomTimetableEventCache extends RequestCache { - GetCustomTimetableEventParams params; - - GetCustomTimetableEventCache(this.params, {onUpdate, renew}) : super(RequestCache.cacheMinute, onUpdate, renew: renew) { - start('customTimetableEvents'); - } - - @override - Future onLoad() => GetCustomTimetableEvent(params).run(); - - @override - GetCustomTimetableEventResponse onLocalData(String json) => GetCustomTimetableEventResponse.fromJson(jsonDecode(json)); -} diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart b/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart deleted file mode 100644 index add1c55..0000000 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http/http.dart' as http; - -import '../../mhslApi.dart'; -import 'removeCustomTimetableEventParams.dart'; - -class RemoveCustomTimetableEvent extends MhslApi { - RemoveCustomTimetableEventParams params; - - RemoveCustomTimetableEvent(this.params) : super('server/timetable/customEvents'); - - @override - void assemble(String raw) {} - - @override - Future? request(Uri uri) => http.delete(uri, body: jsonEncode(params.toJson())); -} diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart b/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart deleted file mode 100644 index 3b1d989..0000000 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart +++ /dev/null @@ -1,13 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'removeCustomTimetableEventParams.g.dart'; - -@JsonSerializable() -class RemoveCustomTimetableEventParams { - String id; - - RemoveCustomTimetableEventParams(this.id); - - factory RemoveCustomTimetableEventParams.fromJson(Map json) => _$RemoveCustomTimetableEventParamsFromJson(json); - Map toJson() => _$RemoveCustomTimetableEventParamsToJson(this); -} diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart b/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart deleted file mode 100644 index 9b1a754..0000000 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart'; -import 'package:http/http.dart' as http; - -import '../../mhslApi.dart'; -import 'updateCustomTimetableEventParams.dart'; - -class UpdateCustomTimetableEvent extends MhslApi { - UpdateCustomTimetableEventParams params; - - UpdateCustomTimetableEvent(this.params) : super('server/timetable/customEvents'); - - @override - void assemble(String raw) {} - - @override - Future? request(Uri uri) => http.patch(uri, body: jsonEncode(params.toJson())); -} diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart b/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart deleted file mode 100644 index 4a09c83..0000000 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart +++ /dev/null @@ -1,17 +0,0 @@ - -import 'package:json_annotation/json_annotation.dart'; - -import '../customTimetableEvent.dart'; - -part 'updateCustomTimetableEventParams.g.dart'; - -@JsonSerializable(explicitToJson: true) -class UpdateCustomTimetableEventParams { - String id; - CustomTimetableEvent event; - - UpdateCustomTimetableEventParams(this.id, this.event); - - factory UpdateCustomTimetableEventParams.fromJson(Map json) => _$UpdateCustomTimetableEventParamsFromJson(json); - Map toJson() => _$UpdateCustomTimetableEventParamsToJson(this); -} diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart similarity index 85% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart index 15f237f..abd6683 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart @@ -3,12 +3,12 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'addCustomTimetableEventParams.dart'; +import '../../mhsl_api.dart'; +import 'add_custom_timetable_event_params.dart'; class AddCustomTimetableEvent extends MhslApi { AddCustomTimetableEventParams params; - + AddCustomTimetableEvent(this.params) : super('server/timetable/customEvents'); @override diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart similarity index 70% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart index 125d3ea..fab722d 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../customTimetableEvent.dart'; +import '../custom_timetable_event.dart'; -part 'addCustomTimetableEventParams.g.dart'; +part 'add_custom_timetable_event_params.g.dart'; @JsonSerializable(explicitToJson: true) class AddCustomTimetableEventParams { @@ -11,6 +11,7 @@ class AddCustomTimetableEventParams { AddCustomTimetableEventParams(this.user, this.event); - factory AddCustomTimetableEventParams.fromJson(Map json) => _$AddCustomTimetableEventParamsFromJson(json); + factory AddCustomTimetableEventParams.fromJson(Map json) => + _$AddCustomTimetableEventParamsFromJson(json); Map toJson() => _$AddCustomTimetableEventParamsToJson(this); } diff --git a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart similarity index 92% rename from lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart index e8d2b80..eb39f91 100644 --- a/lib/api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'addCustomTimetableEventParams.dart'; +part of 'add_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart similarity index 65% rename from lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart rename to lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart index d34489b..eb8eab9 100644 --- a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../mhslApi.dart'; +import '../mhsl_api.dart'; -part 'customTimetableEvent.g.dart'; +part 'custom_timetable_event.g.dart'; @JsonSerializable() class CustomTimetableEvent { @@ -20,9 +20,19 @@ class CustomTimetableEvent { @JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson) DateTime updatedAt; - CustomTimetableEvent({required this.id, required this.title, required this.description, required this.startDate, - required this.endDate, required this.color, required this.rrule, required this.createdAt, required this.updatedAt}); + CustomTimetableEvent({ + required this.id, + required this.title, + required this.description, + required this.startDate, + required this.endDate, + required this.color, + required this.rrule, + required this.createdAt, + required this.updatedAt, + }); - factory CustomTimetableEvent.fromJson(Map json) => _$CustomTimetableEventFromJson(json); + factory CustomTimetableEvent.fromJson(Map json) => + _$CustomTimetableEventFromJson(json); Map toJson() => _$CustomTimetableEventToJson(this); } diff --git a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart similarity index 97% rename from lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart rename to lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart index e15d6d8..b83138b 100644 --- a/lib/api/mhsl/customTimetableEvent/customTimetableEvent.g.dart +++ b/lib/api/mhsl/custom_timetable_event/custom_timetable_event.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'customTimetableEvent.dart'; +part of 'custom_timetable_event.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart new file mode 100644 index 0000000..b222705 --- /dev/null +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:http/http.dart' as http; + +import '../../mhsl_api.dart'; +import 'get_custom_timetable_event_params.dart'; +import 'get_custom_timetable_event_response.dart'; + +class GetCustomTimetableEvent extends MhslApi { + GetCustomTimetableEventParams params; + GetCustomTimetableEvent(this.params) + : super('server/timetable/customEvents?user=${params.user}'); + + @override + GetCustomTimetableEventResponse assemble(String raw) => + GetCustomTimetableEventResponse.fromJson({'events': jsonDecode(raw)}); + + @override + Future? request(Uri uri) => http.get(uri); +} diff --git a/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart new file mode 100644 index 0000000..d644acc --- /dev/null +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart @@ -0,0 +1,20 @@ +import '../../../request_cache.dart'; +import 'get_custom_timetable_event.dart'; +import 'get_custom_timetable_event_params.dart'; +import 'get_custom_timetable_event_response.dart'; + +class GetCustomTimetableEventCache + extends SimpleCache { + GetCustomTimetableEventCache( + GetCustomTimetableEventParams params, { + super.onUpdate, + super.onError, + super.renew, + }) : super( + cacheTime: RequestCache.cacheMinute, + loader: () => GetCustomTimetableEvent(params).run(), + fromJson: GetCustomTimetableEventResponse.fromJson, + ) { + start('customTimetableEvents'); + } +} diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart similarity index 73% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart index c4d6c79..98d22ad 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'getCustomTimetableEventParams.g.dart'; +part 'get_custom_timetable_event_params.g.dart'; @JsonSerializable() class GetCustomTimetableEventParams { @@ -8,6 +8,7 @@ class GetCustomTimetableEventParams { GetCustomTimetableEventParams(this.user); - factory GetCustomTimetableEventParams.fromJson(Map json) => _$GetCustomTimetableEventParamsFromJson(json); + factory GetCustomTimetableEventParams.fromJson(Map json) => + _$GetCustomTimetableEventParamsFromJson(json); Map toJson() => _$GetCustomTimetableEventParamsToJson(this); } diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart similarity index 91% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart index 6fc4044..01fc5a5 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getCustomTimetableEventParams.dart'; +part of 'get_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart similarity index 50% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart index c086b73..43d2dc0 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart @@ -1,9 +1,9 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; -import '../customTimetableEvent.dart'; +import '../../../api_response.dart'; +import '../custom_timetable_event.dart'; -part 'getCustomTimetableEventResponse.g.dart'; +part 'get_custom_timetable_event_response.g.dart'; @JsonSerializable() class GetCustomTimetableEventResponse extends ApiResponse { @@ -11,6 +11,8 @@ class GetCustomTimetableEventResponse extends ApiResponse { GetCustomTimetableEventResponse(this.events); - factory GetCustomTimetableEventResponse.fromJson(Map json) => _$GetCustomTimetableEventResponseFromJson(json); - Map toJson() => _$GetCustomTimetableEventResponseToJson(this); + factory GetCustomTimetableEventResponse.fromJson(Map json) => + _$GetCustomTimetableEventResponseFromJson(json); + Map toJson() => + _$GetCustomTimetableEventResponseToJson(this); } diff --git a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart similarity index 94% rename from lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart rename to lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart index 3e16db0..5bed445 100644 --- a/lib/api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.g.dart +++ b/lib/api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getCustomTimetableEventResponse.dart'; +part of 'get_custom_timetable_event_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart new file mode 100644 index 0000000..ca466f6 --- /dev/null +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:http/http.dart' as http; + +import '../../mhsl_api.dart'; +import 'remove_custom_timetable_event_params.dart'; + +class RemoveCustomTimetableEvent extends MhslApi { + RemoveCustomTimetableEventParams params; + + RemoveCustomTimetableEvent(this.params) + : super('server/timetable/customEvents'); + + @override + void assemble(String raw) {} + + @override + Future? request(Uri uri) => + http.delete(uri, body: jsonEncode(params.toJson())); +} diff --git a/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart new file mode 100644 index 0000000..2f99426 --- /dev/null +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart @@ -0,0 +1,16 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'remove_custom_timetable_event_params.g.dart'; + +@JsonSerializable() +class RemoveCustomTimetableEventParams { + String id; + + RemoveCustomTimetableEventParams(this.id); + + factory RemoveCustomTimetableEventParams.fromJson( + Map json, + ) => _$RemoveCustomTimetableEventParamsFromJson(json); + Map toJson() => + _$RemoveCustomTimetableEventParamsToJson(this); +} diff --git a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart similarity index 91% rename from lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart index aa30f6f..4e85a31 100644 --- a/lib/api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'removeCustomTimetableEventParams.dart'; +part of 'remove_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart new file mode 100644 index 0000000..ab537e6 --- /dev/null +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import 'package:http/http.dart'; +import 'package:http/http.dart' as http; + +import '../../mhsl_api.dart'; +import 'update_custom_timetable_event_params.dart'; + +class UpdateCustomTimetableEvent extends MhslApi { + UpdateCustomTimetableEventParams params; + + UpdateCustomTimetableEvent(this.params) + : super('server/timetable/customEvents'); + + @override + void assemble(String raw) {} + + @override + Future? request(Uri uri) => + http.patch(uri, body: jsonEncode(params.toJson())); +} diff --git a/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart new file mode 100644 index 0000000..f4e16f4 --- /dev/null +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart @@ -0,0 +1,19 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../custom_timetable_event.dart'; + +part 'update_custom_timetable_event_params.g.dart'; + +@JsonSerializable(explicitToJson: true) +class UpdateCustomTimetableEventParams { + String id; + CustomTimetableEvent event; + + UpdateCustomTimetableEventParams(this.id, this.event); + + factory UpdateCustomTimetableEventParams.fromJson( + Map json, + ) => _$UpdateCustomTimetableEventParamsFromJson(json); + Map toJson() => + _$UpdateCustomTimetableEventParamsToJson(this); +} diff --git a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart similarity index 92% rename from lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart rename to lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart index 11ec8e9..37c5d71 100644 --- a/lib/api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.g.dart +++ b/lib/api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'updateCustomTimetableEventParams.dart'; +part of 'update_custom_timetable_event_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/mhslApi.dart b/lib/api/mhsl/mhslApi.dart deleted file mode 100644 index eb4910a..0000000 --- a/lib/api/mhsl/mhslApi.dart +++ /dev/null @@ -1,34 +0,0 @@ -import 'dart:convert'; - -import 'package:http/http.dart' as http; -import 'package:jiffy/jiffy.dart'; -import '../apiError.dart'; -import '../apiRequest.dart'; - -abstract class MhslApi extends ApiRequest { - String subpath; - MhslApi(this.subpath); - - http.Response? response; - - Future? request(Uri uri); - T assemble(String raw); - - Future run() async { - var endpoint = Uri.parse('https://mhsl.eu/marianum/marianummobile/$subpath'); - - var data = await request(endpoint); - if(data == null) { - throw ApiError('Request could not be dispatched!'); - } - - if(data.statusCode > 299) { - throw ApiError('Non 200 Status code from mhsl services: $subpath: ${data.statusCode}'); - } - - return assemble(utf8.decode(data.bodyBytes)); - } - - static String dateTimeToJson(DateTime time) => Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss'); - static DateTime dateTimeFromJson(String time) => DateTime.parse(time); -} diff --git a/lib/api/mhsl/mhsl_api.dart b/lib/api/mhsl/mhsl_api.dart new file mode 100644 index 0000000..80b79bb --- /dev/null +++ b/lib/api/mhsl/mhsl_api.dart @@ -0,0 +1,62 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; +import 'package:jiffy/jiffy.dart'; + +import '../api_request.dart'; +import '../errors/network_exception.dart'; +import '../errors/parse_exception.dart'; +import '../errors/server_exception.dart'; + +abstract class MhslApi extends ApiRequest { + String subpath; + MhslApi(this.subpath); + + http.Response? response; + + Future? request(Uri uri); + T assemble(String raw); + + Future run() async { + final endpoint = Uri.parse( + 'https://mhsl.eu/marianum/marianummobile/$subpath', + ); + + final http.Response data; + try { + final raw = await request(endpoint); + if (raw == null) { + throw const NetworkException( + userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.', + technicalDetails: 'mhsl request returned null', + ); + } + data = raw; + } on SocketException catch (e) { + throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}'); + } on TimeoutException catch (e) { + throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e'); + } on http.ClientException catch (e) { + throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}'); + } + + if (data.statusCode > 299) { + throw ServerException( + statusCode: data.statusCode, + technicalDetails: 'mhsl $subpath HTTP ${data.statusCode}', + ); + } + + try { + return assemble(utf8.decode(data.bodyBytes)); + } catch (e) { + throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e'); + } + } + + static String dateTimeToJson(DateTime time) => + Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss'); + static DateTime dateTimeFromJson(String time) => DateTime.parse(time); +} diff --git a/lib/api/mhsl/notify/register/notifyRegister.dart b/lib/api/mhsl/notify/register/notify_register.dart similarity index 82% rename from lib/api/mhsl/notify/register/notifyRegister.dart rename to lib/api/mhsl/notify/register/notify_register.dart index a7053dc..2f9f4b0 100644 --- a/lib/api/mhsl/notify/register/notifyRegister.dart +++ b/lib/api/mhsl/notify/register/notify_register.dart @@ -1,21 +1,17 @@ - import 'dart:convert'; import 'dart:developer'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'notifyRegisterParams.dart'; +import '../../mhsl_api.dart'; +import 'notify_register_params.dart'; class NotifyRegister extends MhslApi { NotifyRegisterParams params; NotifyRegister(this.params) : super('notify/register/'); - @override - void assemble(String raw) { - - } + void assemble(String raw) {} @override Future request(Uri uri) { diff --git a/lib/api/mhsl/notify/register/notifyRegisterParams.dart b/lib/api/mhsl/notify/register/notify_register_params.dart similarity index 77% rename from lib/api/mhsl/notify/register/notifyRegisterParams.dart rename to lib/api/mhsl/notify/register/notify_register_params.dart index 3f18319..243904e 100644 --- a/lib/api/mhsl/notify/register/notifyRegisterParams.dart +++ b/lib/api/mhsl/notify/register/notify_register_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'notifyRegisterParams.g.dart'; +part 'notify_register_params.g.dart'; @JsonSerializable() class NotifyRegisterParams { @@ -11,9 +11,10 @@ class NotifyRegisterParams { NotifyRegisterParams({ required this.username, required this.password, - required this.fcmToken + required this.fcmToken, }); - factory NotifyRegisterParams.fromJson(Map json) => _$NotifyRegisterParamsFromJson(json); + factory NotifyRegisterParams.fromJson(Map json) => + _$NotifyRegisterParamsFromJson(json); Map toJson() => _$NotifyRegisterParamsToJson(this); } diff --git a/lib/api/mhsl/notify/register/notifyRegisterParams.g.dart b/lib/api/mhsl/notify/register/notify_register_params.g.dart similarity index 94% rename from lib/api/mhsl/notify/register/notifyRegisterParams.g.dart rename to lib/api/mhsl/notify/register/notify_register_params.g.dart index d862558..ad53ab5 100644 --- a/lib/api/mhsl/notify/register/notifyRegisterParams.g.dart +++ b/lib/api/mhsl/notify/register/notify_register_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'notifyRegisterParams.dart'; +part of 'notify_register_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/feedback/addFeedback.dart b/lib/api/mhsl/server/feedback/add_feedback.dart similarity index 63% rename from lib/api/mhsl/server/feedback/addFeedback.dart rename to lib/api/mhsl/server/feedback/add_feedback.dart index 54c3ce0..7b8e0ff 100644 --- a/lib/api/mhsl/server/feedback/addFeedback.dart +++ b/lib/api/mhsl/server/feedback/add_feedback.dart @@ -3,9 +3,8 @@ import 'dart:convert'; import 'package:http/http.dart'; import 'package:http/http.dart' as http; -import '../../mhslApi.dart'; -import 'addFeedbackParams.dart'; - +import '../../mhsl_api.dart'; +import 'add_feedback_params.dart'; class AddFeedback extends MhslApi { AddFeedbackParams params; @@ -15,5 +14,6 @@ class AddFeedback extends MhslApi { void assemble(String raw) {} @override - Future? request(Uri uri) => http.post(uri, body: jsonEncode(params.toJson())); + Future? request(Uri uri) => + http.post(uri, body: jsonEncode(params.toJson())); } diff --git a/lib/api/mhsl/server/feedback/addFeedbackParams.dart b/lib/api/mhsl/server/feedback/add_feedback_params.dart similarity index 84% rename from lib/api/mhsl/server/feedback/addFeedbackParams.dart rename to lib/api/mhsl/server/feedback/add_feedback_params.dart index 945b00c..6e7df0e 100644 --- a/lib/api/mhsl/server/feedback/addFeedbackParams.dart +++ b/lib/api/mhsl/server/feedback/add_feedback_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'addFeedbackParams.g.dart'; +part 'add_feedback_params.g.dart'; @JsonSerializable() class AddFeedbackParams { @@ -9,7 +9,6 @@ class AddFeedbackParams { String? screenshot; int appVersion; - AddFeedbackParams({ required this.user, required this.feedback, @@ -17,6 +16,7 @@ class AddFeedbackParams { required this.appVersion, }); - factory AddFeedbackParams.fromJson(Map json) => _$AddFeedbackParamsFromJson(json); + factory AddFeedbackParams.fromJson(Map json) => + _$AddFeedbackParamsFromJson(json); Map toJson() => _$AddFeedbackParamsToJson(this); } diff --git a/lib/api/mhsl/server/feedback/addFeedbackParams.g.dart b/lib/api/mhsl/server/feedback/add_feedback_params.g.dart similarity index 95% rename from lib/api/mhsl/server/feedback/addFeedbackParams.g.dart rename to lib/api/mhsl/server/feedback/add_feedback_params.g.dart index ec85d22..747d43b 100644 --- a/lib/api/mhsl/server/feedback/addFeedbackParams.g.dart +++ b/lib/api/mhsl/server/feedback/add_feedback_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'addFeedbackParams.dart'; +part of 'add_feedback_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart similarity index 79% rename from lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart rename to lib/api/mhsl/server/user_index/update/update_user_index_params.dart index 680edda..9fead47 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.dart +++ b/lib/api/mhsl/server/user_index/update/update_user_index_params.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'updateUserIndexParams.g.dart'; +part 'update_user_index_params.g.dart'; @JsonSerializable() class UpdateUserIndexParams { @@ -10,15 +10,15 @@ class UpdateUserIndexParams { int appVersion; String deviceInfo; - UpdateUserIndexParams({ required this.user, required this.username, required this.device, required this.appVersion, - required this.deviceInfo + required this.deviceInfo, }); - factory UpdateUserIndexParams.fromJson(Map json) => _$UpdateUserIndexParamsFromJson(json); + factory UpdateUserIndexParams.fromJson(Map json) => + _$UpdateUserIndexParamsFromJson(json); Map toJson() => _$UpdateUserIndexParamsToJson(this); } diff --git a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart b/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart similarity index 95% rename from lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart rename to lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart index 285302a..4d8775a 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserIndexParams.g.dart +++ b/lib/api/mhsl/server/user_index/update/update_user_index_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'updateUserIndexParams.dart'; +part of 'update_user_index_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart b/lib/api/mhsl/server/user_index/update/update_userindex.dart similarity index 51% rename from lib/api/mhsl/server/userIndex/update/updateUserindex.dart rename to lib/api/mhsl/server/user_index/update/update_userindex.dart index 9ac1b8c..ed4776f 100644 --- a/lib/api/mhsl/server/userIndex/update/updateUserindex.dart +++ b/lib/api/mhsl/server/user_index/update/update_userindex.dart @@ -1,4 +1,4 @@ - +import 'dart:async'; import 'dart:convert'; import 'dart:developer'; @@ -6,9 +6,9 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:http/http.dart' as http; import 'package:package_info_plus/package_info_plus.dart'; -import '../../../../../model/accountData.dart'; -import '../../../mhslApi.dart'; -import 'updateUserIndexParams.dart'; +import '../../../../../model/account_data.dart'; +import '../../../mhsl_api.dart'; +import 'update_user_index_params.dart'; class UpdateUserIndex extends MhslApi { UpdateUserIndexParams params; @@ -25,14 +25,18 @@ class UpdateUserIndex extends MhslApi { } static Future index() async { - UpdateUserIndex( - UpdateUserIndexParams( - username: AccountData().getUsername(), - user: AccountData().getUserSecret(), - device: await AccountData().getDeviceId(), - appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), - deviceInfo: jsonEncode((await DeviceInfoPlugin().deviceInfo).data).toString(), - ), - ).run(); + unawaited( + UpdateUserIndex( + UpdateUserIndexParams( + username: AccountData().getUsername(), + user: AccountData().getUserSecret(), + device: await AccountData().getDeviceId(), + appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), + deviceInfo: jsonEncode( + (await DeviceInfoPlugin().deviceInfo).data, + ).toString(), + ), + ).run(), + ); } } diff --git a/lib/api/requestCache.dart b/lib/api/requestCache.dart deleted file mode 100644 index 0420415..0000000 --- a/lib/api/requestCache.dart +++ /dev/null @@ -1,50 +0,0 @@ -import 'dart:convert'; - -import 'package:localstore/localstore.dart'; - -import 'apiResponse.dart'; - -abstract class RequestCache { - static const int cacheNothing = 0; - static const int cacheMinute = 60; - static const int cacheHour = 60 * 60; - static const int cacheDay = 60 * 60 * 24; - - static String collection = 'MarianumMobile'; - - int maxCacheTime; - Function(T) onUpdate; - Function(Exception) onError; - bool? renew; - - RequestCache(this.maxCacheTime, this.onUpdate, {this.onError = ignore, this.renew = false}); - - static void ignore(Exception e) {} - - Future start(String document) async { - var tableData = await Localstore.instance.collection(collection).doc(document).get(); - if(tableData != null) { - onUpdate(onLocalData(tableData['json'])); - } - - if(DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < (tableData?['lastupdate'] ?? 0)) { - if(renew == null || !renew!) return; - } - - try { - var newValue = await onLoad(); - onUpdate(newValue); - - Localstore.instance.collection(collection).doc(document).set({ - 'json': jsonEncode(newValue), - 'lastupdate': DateTime.now().millisecondsSinceEpoch - }); - } on Exception catch(e) { - onError(e); - } - } - - T onLocalData(String json); - Future onLoad(); - -} diff --git a/lib/api/request_cache.dart b/lib/api/request_cache.dart new file mode 100644 index 0000000..86d705b --- /dev/null +++ b/lib/api/request_cache.dart @@ -0,0 +1,152 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:localstore/localstore.dart'; + +import 'api_response.dart'; +import 'errors/parse_exception.dart'; + +abstract class RequestCache { + static const int cacheNothing = 0; + static const int cacheMinute = 60; + static const int cacheHour = 60 * 60; + static const int cacheDay = 60 * 60 * 24; + + static String collection = 'MarianumMobile'; + + int maxCacheTime; + void Function(T)? onUpdate; + + /// Called only when [start] finds a cached payload in localstore. Use this + /// (instead of [onUpdate]) when callers need to distinguish stale-but-fast + /// cache hits from authoritative network responses. + void Function(T)? onCacheData; + + /// Called only when [start] receives a fresh payload from the network. + void Function(T)? onNetworkData; + + void Function(Exception) onError; + bool? renew; + + final Completer _ready = Completer(); + + /// Resolves when [start] has finished, regardless of whether the network + /// call succeeded, failed, or was skipped due to a fresh cache. Callers + /// can await this to know when both the cache lookup and the network + /// attempt have settled. + Future get ready => _ready.future; + + RequestCache( + this.maxCacheTime, + this.onUpdate, { + this.onError = ignore, + this.renew = false, + this.onCacheData, + this.onNetworkData, + }); + + static void ignore(Exception e) {} + + Future start(String document) async { + try { + final tableData = await Localstore.instance + .collection(collection) + .doc(document) + .get(); + if (tableData != null) { + final cached = onLocalData(tableData['json'] as String); + onUpdate?.call(cached); + onCacheData?.call(cached); + } + + final lastUpdate = (tableData?['lastupdate'] as num?) ?? 0; + if (DateTime.now().millisecondsSinceEpoch - (maxCacheTime * 1000) < + lastUpdate) { + if (renew == null || !renew!) return; + } + + try { + final newValue = await onLoad(); + onUpdate?.call(newValue); + onNetworkData?.call(newValue); + unawaited( + Localstore.instance.collection(collection).doc(document).set({ + 'json': jsonEncode(newValue), + 'lastupdate': DateTime.now().millisecondsSinceEpoch, + }), + ); + } on Exception catch (e) { + onError(e); + } + } finally { + if (!_ready.isCompleted) _ready.complete(); + } + } + + T onLocalData(String json); + Future onLoad(); +} + +/// Concrete [RequestCache] that takes the two overrides as constructor +/// callbacks instead of requiring a subclass per endpoint. +class SimpleCache extends RequestCache { + final Future Function() _loader; + final T Function(Map json) _fromJson; + + SimpleCache({ + required int cacheTime, + required Future Function() loader, + required T Function(Map json) fromJson, + void Function(T)? onUpdate, + void Function(T)? onCacheData, + void Function(T)? onNetworkData, + void Function(Exception)? onError, + bool? renew, + }) : _loader = loader, + _fromJson = fromJson, + super( + cacheTime, + onUpdate, + onError: onError ?? RequestCache.ignore, + renew: renew, + onCacheData: onCacheData, + onNetworkData: onNetworkData, + ); + + @override + Future onLoad() => _loader(); + + @override + T onLocalData(String json) => + _fromJson(jsonDecode(json) as Map); +} + +/// Captures the latest cache payload (cached or network) and rethrows the +/// captured network error if no payload arrived. Collapses the +/// `latest`/`capturedError`/`await ready` boilerplate that DataProviders +/// otherwise repeat per endpoint. +Future resolveFromCache( + RequestCache Function( + void Function(T) onUpdate, + void Function(Exception) onError, + ) + build, { + void Function(Object)? onError, + String? operationName, +}) async { + T? latest; + Object? capturedError; + final cache = build((data) => latest = data, (e) { + capturedError = e; + onError?.call(e); + }); + await cache.ready; + if (latest != null) return latest as T; + final err = capturedError; + if (err != null) throw err; + throw ParseException( + technicalDetails: operationName != null + ? 'No data and no error from $operationName' + : null, + ); +} diff --git a/lib/api/webuntis/queries/authenticate/authenticate.dart b/lib/api/webuntis/queries/authenticate/authenticate.dart index c42f62c..183b513 100644 --- a/lib/api/webuntis/queries/authenticate/authenticate.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate.dart @@ -1,49 +1,66 @@ import 'dart:async'; import 'dart:convert'; -import '../../../../model/accountData.dart'; -import '../../webuntisApi.dart'; -import 'authenticateParams.dart'; -import 'authenticateResponse.dart'; +import '../../../../model/account_data.dart'; +import '../../webuntis_api.dart'; +import 'authenticate_params.dart'; +import 'authenticate_response.dart'; class Authenticate extends WebuntisApi { AuthenticateParams param; - Authenticate(this.param) : super('authenticate', param, authenticatedResponse: false); + Authenticate(this.param) + : super('authenticate', param, authenticatedResponse: false); @override Future run() async { awaitingResponse = true; - var rawAnswer = await query(this); - AuthenticateResponse response = finalize(AuthenticateResponse.fromJson(jsonDecode(rawAnswer)['result'])); - _lastResponse = response; - if(!awaitedResponse.isCompleted) awaitedResponse.complete(); - return response; + try { + final rawAnswer = await query(this); + final decoded = jsonDecode(rawAnswer) as Map; + final response = finalize( + AuthenticateResponse.fromJson( + decoded['result'] as Map, + ), + ); + _lastResponse = response; + if (!awaitedResponse.isCompleted) awaitedResponse.complete(); + return response; + } catch (e) { + // Surface the error to anyone waiting on the current completer, then + // install a fresh one so a future attempt can succeed. Without this, + // any later call to getSession() would hang forever on a completer + // that is already settled with no listeners (or never settles at all). + if (!awaitedResponse.isCompleted) awaitedResponse.completeError(e); + awaitedResponse = Completer(); + rethrow; + } finally { + awaitingResponse = false; + } } static bool awaitingResponse = false; - static Completer awaitedResponse = Completer(); + static Completer awaitedResponse = Completer(); static AuthenticateResponse? _lastResponse; static Future createSession() async { _lastResponse = await Authenticate( - AuthenticateParams( - user: AccountData().getUsername(), - password: AccountData().getPassword(), - ) + AuthenticateParams( + user: AccountData().getUsername(), + password: AccountData().getPassword(), + ), ).run(); } static Future getSession() async { - if(awaitingResponse) { + if (awaitingResponse) { await awaitedResponse.future; } - if(_lastResponse == null) { + if (_lastResponse == null) { awaitingResponse = true; await createSession(); } return _lastResponse!; - } } diff --git a/lib/api/webuntis/queries/authenticate/authenticateParams.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.dart similarity index 75% rename from lib/api/webuntis/queries/authenticate/authenticateParams.dart rename to lib/api/webuntis/queries/authenticate/authenticate_params.dart index bfa65e6..4af2cec 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateParams.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_params.dart @@ -1,17 +1,17 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'authenticateParams.g.dart'; +part 'authenticate_params.g.dart'; @JsonSerializable() class AuthenticateParams extends ApiParams { - String user; String password; AuthenticateParams({required this.user, required this.password}); - factory AuthenticateParams.fromJson(Map json) => _$AuthenticateParamsFromJson(json); + factory AuthenticateParams.fromJson(Map json) => + _$AuthenticateParamsFromJson(json); Map toJson() => _$AuthenticateParamsToJson(this); } diff --git a/lib/api/webuntis/queries/authenticate/authenticateParams.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart similarity index 94% rename from lib/api/webuntis/queries/authenticate/authenticateParams.g.dart rename to lib/api/webuntis/queries/authenticate/authenticate_params.g.dart index ba8b65e..9765ad8 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateParams.g.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticateParams.dart'; +part of 'authenticate_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/authenticate/authenticateResponse.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.dart similarity index 59% rename from lib/api/webuntis/queries/authenticate/authenticateResponse.dart rename to lib/api/webuntis/queries/authenticate/authenticate_response.dart index 509b1dc..b9c661e 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateResponse.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_response.dart @@ -1,19 +1,24 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'authenticateResponse.g.dart'; +part 'authenticate_response.g.dart'; @JsonSerializable() class AuthenticateResponse extends ApiResponse { - String sessionId; int personType; int personId; int klasseId; - AuthenticateResponse(this.sessionId, this.personType, this.personId, this.klasseId); + AuthenticateResponse( + this.sessionId, + this.personType, + this.personId, + this.klasseId, + ); - factory AuthenticateResponse.fromJson(Map json) => _$AuthenticateResponseFromJson(json); + factory AuthenticateResponse.fromJson(Map json) => + _$AuthenticateResponseFromJson(json); Map toJson() => _$AuthenticateResponseToJson(this); } diff --git a/lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart b/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart similarity index 96% rename from lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart rename to lib/api/webuntis/queries/authenticate/authenticate_response.g.dart index c3ca62a..e7d7dd0 100644 --- a/lib/api/webuntis/queries/authenticate/authenticateResponse.g.dart +++ b/lib/api/webuntis/queries/authenticate/authenticate_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'authenticateResponse.dart'; +part of 'authenticate_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart b/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart deleted file mode 100644 index a91decd..0000000 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysCache.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'dart:convert'; - -import '../../../requestCache.dart'; -import 'getHolidays.dart'; -import 'getHolidaysResponse.dart'; - -class GetHolidaysCache extends RequestCache { - GetHolidaysCache({onUpdate}) : super(RequestCache.cacheDay, onUpdate) { - start('wu-holidays'); - } - - @override - Future onLoad() => GetHolidays().run(); - - @override - GetHolidaysResponse onLocalData(String json) => GetHolidaysResponse.fromJson(jsonDecode(json)); -} diff --git a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart b/lib/api/webuntis/queries/getRooms/getRoomsCache.dart deleted file mode 100644 index e33589b..0000000 --- a/lib/api/webuntis/queries/getRooms/getRoomsCache.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; - -import '../../../requestCache.dart'; -import 'getRooms.dart'; -import 'getRoomsResponse.dart'; - -class GetRoomsCache extends RequestCache { - GetRoomsCache({onUpdate}) : super(RequestCache.cacheHour, onUpdate) { - start('wu-rooms'); - } - - @override - Future onLoad() => GetRooms().run(); - - @override - GetRoomsResponse onLocalData(String json) => GetRoomsResponse.fromJson(jsonDecode(json)); - -} diff --git a/lib/api/webuntis/queries/getSubjects/getSubjects.dart b/lib/api/webuntis/queries/getSubjects/getSubjects.dart deleted file mode 100644 index 8505381..0000000 --- a/lib/api/webuntis/queries/getSubjects/getSubjects.dart +++ /dev/null @@ -1,14 +0,0 @@ -import 'dart:convert'; - -import '../../webuntisApi.dart'; -import 'getSubjectsResponse.dart'; - -class GetSubjects extends WebuntisApi { - GetSubjects() : super('getSubjects', null); - - @override - Future run() async { - var rawAnswer = await query(this); - return finalize(GetSubjectsResponse.fromJson(jsonDecode(rawAnswer))); - } -} diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart b/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart deleted file mode 100644 index 07a5ede..0000000 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsCache.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; - -import '../../../requestCache.dart'; -import 'getSubjects.dart'; -import 'getSubjectsResponse.dart'; - -class GetSubjectsCache extends RequestCache { - GetSubjectsCache({onUpdate}) : super(RequestCache.cacheHour, onUpdate) { - start('wu-subjects'); - } - - @override - Future onLoad() => GetSubjects().run(); - - @override - onLocalData(String json) => GetSubjectsResponse.fromJson(jsonDecode(json)); - -} diff --git a/lib/api/webuntis/queries/getTimetable/getTimetable.dart b/lib/api/webuntis/queries/getTimetable/getTimetable.dart deleted file mode 100644 index e9da26d..0000000 --- a/lib/api/webuntis/queries/getTimetable/getTimetable.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:convert'; - -import '../../webuntisApi.dart'; -import 'getTimetableParams.dart'; -import 'getTimetableResponse.dart'; - -class GetTimetable extends WebuntisApi { - GetTimetableParams params; - - GetTimetable(this.params) : super('getTimetable', params); - - @override - Future run() async { - var rawAnswer = await query(this); - return finalize(GetTimetableResponse.fromJson(jsonDecode(rawAnswer))); - } - -} diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart b/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart deleted file mode 100644 index 3b6d87a..0000000 --- a/lib/api/webuntis/queries/getTimetable/getTimetableCache.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'dart:convert'; - -import '../../../requestCache.dart'; -import '../authenticate/authenticate.dart'; -import 'getTimetable.dart'; -import 'getTimetableParams.dart'; -import 'getTimetableResponse.dart'; - -class GetTimetableCache extends RequestCache { - int startdate; - int enddate; - - GetTimetableCache({required onUpdate, onError, required this.startdate, required this.enddate}) : super(RequestCache.cacheMinute, onUpdate, onError: onError) { - start('wu-timetable-$startdate-$enddate'); - } - - @override - GetTimetableResponse onLocalData(String json) => GetTimetableResponse.fromJson(jsonDecode(json)); - - @override - Future onLoad() async => GetTimetable( - GetTimetableParams( - options: GetTimetableParamsOptions( - element: GetTimetableParamsOptionsElement( - id: (await Authenticate.getSession()).personId, - type: (await Authenticate.getSession()).personType, - keyType: GetTimetableParamsOptionsElementKeyType.id, - ), - startDate: startdate, - endDate: enddate, - teacherFields: GetTimetableParamsOptionsFields.all, - subjectFields: GetTimetableParamsOptionsFields.all, - roomFields: GetTimetableParamsOptionsFields.all, - klasseFields: GetTimetableParamsOptionsFields.all, - ) - ) - ).run(); -} diff --git a/lib/api/webuntis/queries/getHolidays/getHolidays.dart b/lib/api/webuntis/queries/get_holidays/get_holidays.dart similarity index 52% rename from lib/api/webuntis/queries/getHolidays/getHolidays.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays.dart index 145cb6e..d314004 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidays.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays.dart @@ -1,18 +1,25 @@ import 'dart:convert'; -import '../../webuntisApi.dart'; -import 'getHolidaysResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_holidays_response.dart'; class GetHolidays extends WebuntisApi { GetHolidays() : super('getHolidays', null); @override Future run() async { - var rawAnswer = await query(this); - return finalize(GetHolidaysResponse.fromJson(jsonDecode(rawAnswer))); + final rawAnswer = await query(this); + return finalize( + GetHolidaysResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); } - static GetHolidaysResponseObject? find(GetHolidaysResponse holidaysResponse, {DateTime? time}) { + static GetHolidaysResponseObject? find( + GetHolidaysResponse holidaysResponse, { + DateTime? time, + }) { time ??= DateTime.now(); time = DateTime(time.year, time.month, time.day, 0, 0, 0, 0, 0); @@ -20,9 +27,8 @@ class GetHolidays extends WebuntisApi { var start = DateTime.parse(element.startDate.toString()); var end = DateTime.parse(element.endDate.toString()); - if(!start.isAfter(time) && !end.isBefore(time)) return element; + if (!start.isAfter(time) && !end.isBefore(time)) return element; } return null; } - } diff --git a/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart new file mode 100644 index 0000000..1a9393d --- /dev/null +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_cache.dart @@ -0,0 +1,14 @@ +import '../../../request_cache.dart'; +import 'get_holidays.dart'; +import 'get_holidays_response.dart'; + +class GetHolidaysCache extends SimpleCache { + GetHolidaysCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetHolidays().run(), + fromJson: GetHolidaysResponse.fromJson, + ) { + start('wu-holidays'); + } +} diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart similarity index 68% rename from lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_response.dart index f087c4a..019603e 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getHolidaysResponse.g.dart'; +part 'get_holidays_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetHolidaysResponse extends ApiResponse { @@ -10,7 +10,8 @@ class GetHolidaysResponse extends ApiResponse { GetHolidaysResponse(this.result); - factory GetHolidaysResponse.fromJson(Map json) => _$GetHolidaysResponseFromJson(json); + factory GetHolidaysResponse.fromJson(Map json) => + _$GetHolidaysResponseFromJson(json); Map toJson() => _$GetHolidaysResponseToJson(this); } @@ -22,8 +23,15 @@ class GetHolidaysResponseObject { int startDate; int endDate; - GetHolidaysResponseObject(this.id, this.name, this.longName, this.startDate, this.endDate); + GetHolidaysResponseObject( + this.id, + this.name, + this.longName, + this.startDate, + this.endDate, + ); - factory GetHolidaysResponseObject.fromJson(Map json) => _$GetHolidaysResponseObjectFromJson(json); + factory GetHolidaysResponseObject.fromJson(Map json) => + _$GetHolidaysResponseObjectFromJson(json); Map toJson() => _$GetHolidaysResponseObjectToJson(this); } diff --git a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart b/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart rename to lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart index 323b3fb..e373642 100644 --- a/lib/api/webuntis/queries/getHolidays/getHolidaysResponse.g.dart +++ b/lib/api/webuntis/queries/get_holidays/get_holidays_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getHolidaysResponse.dart'; +part of 'get_holidays_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getRooms/getRooms.dart b/lib/api/webuntis/queries/get_rooms/get_rooms.dart similarity index 58% rename from lib/api/webuntis/queries/getRooms/getRooms.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms.dart index 45c53c5..d7d32b3 100644 --- a/lib/api/webuntis/queries/getRooms/getRooms.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms.dart @@ -1,23 +1,26 @@ import 'dart:convert'; import 'dart:developer'; -import '../../webuntisApi.dart'; -import 'getRoomsResponse.dart'; +import '../../webuntis_api.dart'; +import 'get_rooms_response.dart'; class GetRooms extends WebuntisApi { GetRooms() : super('getRooms', null); @override Future run() async { - var rawAnswer = await query(this); + final rawAnswer = await query(this); try { - return finalize(GetRoomsResponse.fromJson(jsonDecode(rawAnswer))); - } catch(e, trace) { + return finalize( + GetRoomsResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); + } catch (e, trace) { log(trace.toString()); log('Failed to parse getRoom data with server response: $rawAnswer'); } throw Exception('Failed to parse getRoom server response: $rawAnswer'); } - } diff --git a/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart new file mode 100644 index 0000000..df62f87 --- /dev/null +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_cache.dart @@ -0,0 +1,14 @@ +import '../../../request_cache.dart'; +import 'get_rooms.dart'; +import 'get_rooms_response.dart'; + +class GetRoomsCache extends SimpleCache { + GetRoomsCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheHour, + loader: () => GetRooms().run(), + fromJson: GetRoomsResponse.fromJson, + ) { + start('wu-rooms'); + } +} diff --git a/lib/api/webuntis/queries/getRooms/getRoomsResponse.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart similarity index 69% rename from lib/api/webuntis/queries/getRooms/getRoomsResponse.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_response.dart index fe4dc84..83bff1d 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsResponse.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getRoomsResponse.g.dart'; +part 'get_rooms_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetRoomsResponse extends ApiResponse { @@ -10,7 +10,8 @@ class GetRoomsResponse extends ApiResponse { GetRoomsResponse(this.result); - factory GetRoomsResponse.fromJson(Map json) => _$GetRoomsResponseFromJson(json); + factory GetRoomsResponse.fromJson(Map json) => + _$GetRoomsResponseFromJson(json); Map toJson() => _$GetRoomsResponseToJson(this); } @@ -22,8 +23,15 @@ class GetRoomsResponseObject { bool active; String building; - GetRoomsResponseObject(this.id, this.name, this.longName, this.active, this.building); + GetRoomsResponseObject( + this.id, + this.name, + this.longName, + this.active, + this.building, + ); - factory GetRoomsResponseObject.fromJson(Map json) => _$GetRoomsResponseObjectFromJson(json); + factory GetRoomsResponseObject.fromJson(Map json) => + _$GetRoomsResponseObjectFromJson(json); Map toJson() => _$GetRoomsResponseObjectToJson(this); } diff --git a/lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart b/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart rename to lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart index a88ade3..2bf9998 100644 --- a/lib/api/webuntis/queries/getRooms/getRoomsResponse.g.dart +++ b/lib/api/webuntis/queries/get_rooms/get_rooms_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getRoomsResponse.dart'; +part of 'get_rooms_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects.dart b/lib/api/webuntis/queries/get_subjects/get_subjects.dart new file mode 100644 index 0000000..736de80 --- /dev/null +++ b/lib/api/webuntis/queries/get_subjects/get_subjects.dart @@ -0,0 +1,18 @@ +import 'dart:convert'; + +import '../../webuntis_api.dart'; +import 'get_subjects_response.dart'; + +class GetSubjects extends WebuntisApi { + GetSubjects() : super('getSubjects', null); + + @override + Future run() async { + final rawAnswer = await query(this); + return finalize( + GetSubjectsResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); + } +} diff --git a/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart new file mode 100644 index 0000000..0064607 --- /dev/null +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_cache.dart @@ -0,0 +1,14 @@ +import '../../../request_cache.dart'; +import 'get_subjects.dart'; +import 'get_subjects_response.dart'; + +class GetSubjectsCache extends SimpleCache { + GetSubjectsCache({super.onUpdate, super.onError, super.renew}) + : super( + cacheTime: RequestCache.cacheHour, + loader: () => GetSubjects().run(), + fromJson: GetSubjectsResponse.fromJson, + ) { + start('wu-subjects'); + } +} diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart similarity index 68% rename from lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_response.dart index cfd2cf1..386933e 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getSubjectsResponse.g.dart'; +part 'get_subjects_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetSubjectsResponse extends ApiResponse { @@ -10,7 +10,8 @@ class GetSubjectsResponse extends ApiResponse { GetSubjectsResponse(this.result); - factory GetSubjectsResponse.fromJson(Map json) => _$GetSubjectsResponseFromJson(json); + factory GetSubjectsResponse.fromJson(Map json) => + _$GetSubjectsResponseFromJson(json); Map toJson() => _$GetSubjectsResponseToJson(this); } @@ -22,8 +23,15 @@ class GetSubjectsResponseObject { String alternateName; bool active; - GetSubjectsResponseObject(this.id, this.name, this.longName, this.alternateName, this.active); + GetSubjectsResponseObject( + this.id, + this.name, + this.longName, + this.alternateName, + this.active, + ); - factory GetSubjectsResponseObject.fromJson(Map json) => _$GetSubjectsResponseObjectFromJson(json); + factory GetSubjectsResponseObject.fromJson(Map json) => + _$GetSubjectsResponseObjectFromJson(json); Map toJson() => _$GetSubjectsResponseObjectToJson(this); } diff --git a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart b/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart similarity index 97% rename from lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart rename to lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart index 35bbb8b..9ce84d5 100644 --- a/lib/api/webuntis/queries/getSubjects/getSubjectsResponse.g.dart +++ b/lib/api/webuntis/queries/get_subjects/get_subjects_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getSubjectsResponse.dart'; +part of 'get_subjects_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart new file mode 100644 index 0000000..a872c5c --- /dev/null +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units.dart @@ -0,0 +1,30 @@ +import 'dart:convert'; +import 'dart:developer'; + +import '../../webuntis_api.dart'; +import 'get_timegrid_units_response.dart'; + +class GetTimegridUnits extends WebuntisApi { + GetTimegridUnits() : super('getTimegridUnits', null); + + @override + Future run() async { + final rawAnswer = await query(this); + try { + return finalize( + GetTimegridUnitsResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); + } catch (e, trace) { + log(trace.toString()); + log( + 'Failed to parse getTimegridUnits data with server response: $rawAnswer', + ); + } + + throw Exception( + 'Failed to parse getTimegridUnits server response: $rawAnswer', + ); + } +} diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart new file mode 100644 index 0000000..45ba202 --- /dev/null +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart @@ -0,0 +1,14 @@ +import '../../../request_cache.dart'; +import 'get_timegrid_units.dart'; +import 'get_timegrid_units_response.dart'; + +class GetTimegridUnitsCache extends SimpleCache { + GetTimegridUnitsCache({super.onUpdate, super.renew}) + : super( + cacheTime: RequestCache.cacheDay, + loader: () => GetTimegridUnits().run(), + fromJson: GetTimegridUnitsResponse.fromJson, + ) { + start('wu-timegrid'); + } +} diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart new file mode 100644 index 0000000..b2cfc43 --- /dev/null +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart @@ -0,0 +1,41 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../../../api_response.dart'; + +part 'get_timegrid_units_response.g.dart'; + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponse extends ApiResponse { + List result; + + GetTimegridUnitsResponse(this.result); + + factory GetTimegridUnitsResponse.fromJson(Map json) => + _$GetTimegridUnitsResponseFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponseDay { + int day; + List timeUnits; + + GetTimegridUnitsResponseDay(this.day, this.timeUnits); + + factory GetTimegridUnitsResponseDay.fromJson(Map json) => + _$GetTimegridUnitsResponseDayFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseDayToJson(this); +} + +@JsonSerializable(explicitToJson: true) +class GetTimegridUnitsResponseUnit { + String name; + int startTime; + int endTime; + + GetTimegridUnitsResponseUnit(this.name, this.startTime, this.endTime); + + factory GetTimegridUnitsResponseUnit.fromJson(Map json) => + _$GetTimegridUnitsResponseUnitFromJson(json); + Map toJson() => _$GetTimegridUnitsResponseUnitToJson(this); +} diff --git a/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart new file mode 100644 index 0000000..250b0fd --- /dev/null +++ b/lib/api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.g.dart @@ -0,0 +1,64 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'get_timegrid_units_response.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +GetTimegridUnitsResponse _$GetTimegridUnitsResponseFromJson( + Map json, +) => + GetTimegridUnitsResponse( + (json['result'] as List) + .map( + (e) => GetTimegridUnitsResponseDay.fromJson( + e as Map, + ), + ) + .toList(), + ) + ..headers = (json['headers'] as Map?)?.map( + (k, e) => MapEntry(k, e as String), + ); + +Map _$GetTimegridUnitsResponseToJson( + GetTimegridUnitsResponse instance, +) => { + 'headers': ?instance.headers, + 'result': instance.result.map((e) => e.toJson()).toList(), +}; + +GetTimegridUnitsResponseDay _$GetTimegridUnitsResponseDayFromJson( + Map json, +) => GetTimegridUnitsResponseDay( + (json['day'] as num).toInt(), + (json['timeUnits'] as List) + .map( + (e) => GetTimegridUnitsResponseUnit.fromJson(e as Map), + ) + .toList(), +); + +Map _$GetTimegridUnitsResponseDayToJson( + GetTimegridUnitsResponseDay instance, +) => { + 'day': instance.day, + 'timeUnits': instance.timeUnits.map((e) => e.toJson()).toList(), +}; + +GetTimegridUnitsResponseUnit _$GetTimegridUnitsResponseUnitFromJson( + Map json, +) => GetTimegridUnitsResponseUnit( + json['name'] as String, + (json['startTime'] as num).toInt(), + (json['endTime'] as num).toInt(), +); + +Map _$GetTimegridUnitsResponseUnitToJson( + GetTimegridUnitsResponseUnit instance, +) => { + 'name': instance.name, + 'startTime': instance.startTime, + 'endTime': instance.endTime, +}; diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable.dart b/lib/api/webuntis/queries/get_timetable/get_timetable.dart new file mode 100644 index 0000000..0c60f88 --- /dev/null +++ b/lib/api/webuntis/queries/get_timetable/get_timetable.dart @@ -0,0 +1,21 @@ +import 'dart:convert'; + +import '../../webuntis_api.dart'; +import 'get_timetable_params.dart'; +import 'get_timetable_response.dart'; + +class GetTimetable extends WebuntisApi { + GetTimetableParams params; + + GetTimetable(this.params) : super('getTimetable', params); + + @override + Future run() async { + final rawAnswer = await query(this); + return finalize( + GetTimetableResponse.fromJson( + jsonDecode(rawAnswer) as Map, + ), + ); + } +} diff --git a/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart new file mode 100644 index 0000000..440e1bb --- /dev/null +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_cache.dart @@ -0,0 +1,43 @@ +import '../../../request_cache.dart'; +import '../authenticate/authenticate.dart'; +import 'get_timetable.dart'; +import 'get_timetable_params.dart'; +import 'get_timetable_response.dart'; + +class GetTimetableCache extends SimpleCache { + GetTimetableCache({ + required void Function(GetTimetableResponse) onUpdate, + super.onError, + required int startdate, + required int enddate, + super.renew, + }) : super( + cacheTime: RequestCache.cacheMinute, + loader: () => _load(startdate, enddate), + fromJson: GetTimetableResponse.fromJson, + onUpdate: onUpdate, + ) { + start('wu-timetable-$startdate-$enddate'); + } + + static Future _load(int startdate, int enddate) async { + final session = await Authenticate.getSession(); + return GetTimetable( + GetTimetableParams( + options: GetTimetableParamsOptions( + element: GetTimetableParamsOptionsElement( + id: session.personId, + type: session.personType, + keyType: GetTimetableParamsOptionsElementKeyType.id, + ), + startDate: startdate, + endDate: enddate, + teacherFields: GetTimetableParamsOptionsFields.all, + subjectFields: GetTimetableParamsOptionsFields.all, + roomFields: GetTimetableParamsOptionsFields.all, + klasseFields: GetTimetableParamsOptionsFields.all, + ), + ), + ).run(); + } +} diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableParams.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart similarity index 70% rename from lib/api/webuntis/queries/getTimetable/getTimetableParams.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_params.dart index 48ba379..727ba9f 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableParams.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_params.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiParams.dart'; +import '../../../api_params.dart'; -part 'getTimetableParams.g.dart'; +part 'get_timetable_params.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimetableParams extends ApiParams { @@ -10,11 +10,11 @@ class GetTimetableParams extends ApiParams { GetTimetableParams({required this.options}); - factory GetTimetableParams.fromJson(Map json) => _$GetTimetableParamsFromJson(json); + factory GetTimetableParams.fromJson(Map json) => + _$GetTimetableParamsFromJson(json); Map toJson() => _$GetTimetableParamsToJson(this); } - @JsonSerializable(explicitToJson: true) class GetTimetableParamsOptions { GetTimetableParamsOptionsElement element; @@ -59,20 +59,30 @@ class GetTimetableParamsOptions { this.klasseFields, this.roomFields, this.subjectFields, - this.teacherFields + this.teacherFields, }); - factory GetTimetableParamsOptions.fromJson(Map json) => _$GetTimetableParamsOptionsFromJson(json); + factory GetTimetableParamsOptions.fromJson(Map json) => + _$GetTimetableParamsOptionsFromJson(json); Map toJson() => _$GetTimetableParamsOptionsToJson(this); } enum GetTimetableParamsOptionsFields { - @JsonValue('id') id, - @JsonValue('name') name, - @JsonValue('longname') longname, - @JsonValue('externalkey') externalkey; + @JsonValue('id') + id, + @JsonValue('name') + name, + @JsonValue('longname') + longname, + @JsonValue('externalkey') + externalkey; - static List all = [id, name, longname, externalkey]; + static List all = [ + id, + name, + longname, + externalkey, + ]; } @JsonSerializable() @@ -82,13 +92,23 @@ class GetTimetableParamsOptionsElement { @JsonKey(includeIfNull: false) GetTimetableParamsOptionsElementKeyType? keyType; - GetTimetableParamsOptionsElement({required this.id, required this.type, this.keyType}); - factory GetTimetableParamsOptionsElement.fromJson(Map json) => _$GetTimetableParamsOptionsElementFromJson(json); - Map toJson() => _$GetTimetableParamsOptionsElementToJson(this); + GetTimetableParamsOptionsElement({ + required this.id, + required this.type, + this.keyType, + }); + factory GetTimetableParamsOptionsElement.fromJson( + Map json, + ) => _$GetTimetableParamsOptionsElementFromJson(json); + Map toJson() => + _$GetTimetableParamsOptionsElementToJson(this); } enum GetTimetableParamsOptionsElementKeyType { - @JsonValue('id') id, - @JsonValue('name') name, - @JsonValue('externalkey') externalkey + @JsonValue('id') + id, + @JsonValue('name') + name, + @JsonValue('externalkey') + externalkey, } diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart similarity index 99% rename from lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart index 92e2a1d..2d7ff35 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableParams.g.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_params.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimetableParams.dart'; +part of 'get_timetable_params.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart similarity index 59% rename from lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_response.dart index fc6663c..76ff345 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_response.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../../apiResponse.dart'; +import '../../../api_response.dart'; -part 'getTimetableResponse.g.dart'; +part 'get_timetable_response.g.dart'; @JsonSerializable(explicitToJson: true) class GetTimetableResponse extends ApiResponse { @@ -10,9 +10,9 @@ class GetTimetableResponse extends ApiResponse { GetTimetableResponse(this.result); - factory GetTimetableResponse.fromJson(Map json) => _$GetTimetableResponseFromJson(json); + factory GetTimetableResponse.fromJson(Map json) => + _$GetTimetableResponseFromJson(json); Map toJson() => _$GetTimetableResponseToJson(this); - } @JsonSerializable(explicitToJson: true) @@ -55,10 +55,11 @@ class GetTimetableResponseObject { required this.kl, required this.te, required this.su, - required this.ro + required this.ro, }); - factory GetTimetableResponseObject.fromJson(Map json) => _$GetTimetableResponseObjectFromJson(json); + factory GetTimetableResponseObject.fromJson(Map json) => + _$GetTimetableResponseObjectFromJson(json); Map toJson() => _$GetTimetableResponseObjectToJson(this); } @@ -68,8 +69,11 @@ class GetTimetableResponseObjectFields { GetTimetableResponseObjectFields(this.te); - factory GetTimetableResponseObjectFields.fromJson(Map json) => _$GetTimetableResponseObjectFieldsFromJson(json); - Map toJson() => _$GetTimetableResponseObjectFieldsToJson(this); + factory GetTimetableResponseObjectFields.fromJson( + Map json, + ) => _$GetTimetableResponseObjectFieldsFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectFieldsToJson(this); } @JsonSerializable() @@ -79,10 +83,18 @@ class GetTimetableResponseObjectFieldsObject { String? longname; String? externalkey; - GetTimetableResponseObjectFieldsObject({this.id, this.name, this.longname, this.externalkey}); + GetTimetableResponseObjectFieldsObject({ + this.id, + this.name, + this.longname, + this.externalkey, + }); - factory GetTimetableResponseObjectFieldsObject.fromJson(Map json) => _$GetTimetableResponseObjectFieldsObjectFromJson(json); - Map toJson() => _$GetTimetableResponseObjectFieldsObjectToJson(this); + factory GetTimetableResponseObjectFieldsObject.fromJson( + Map json, + ) => _$GetTimetableResponseObjectFieldsObjectFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectFieldsObjectToJson(this); } @JsonSerializable() @@ -92,10 +104,17 @@ class GetTimetableResponseObjectClass { String longname; String? externalkey; - GetTimetableResponseObjectClass(this.id, this.name, this.longname, this.externalkey); + GetTimetableResponseObjectClass( + this.id, + this.name, + this.longname, + this.externalkey, + ); - factory GetTimetableResponseObjectClass.fromJson(Map json) => _$GetTimetableResponseObjectClassFromJson(json); - Map toJson() => _$GetTimetableResponseObjectClassToJson(this); + factory GetTimetableResponseObjectClass.fromJson(Map json) => + _$GetTimetableResponseObjectClassFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectClassToJson(this); } @JsonSerializable() @@ -107,11 +126,20 @@ class GetTimetableResponseObjectTeacher { String? orgname; String? externalkey; + GetTimetableResponseObjectTeacher( + this.id, + this.name, + this.longname, + this.orgid, + this.orgname, + this.externalkey, + ); - GetTimetableResponseObjectTeacher(this.id, this.name, this.longname, this.orgid, this.orgname, this.externalkey); - - factory GetTimetableResponseObjectTeacher.fromJson(Map json) => _$GetTimetableResponseObjectTeacherFromJson(json); - Map toJson() => _$GetTimetableResponseObjectTeacherToJson(this); + factory GetTimetableResponseObjectTeacher.fromJson( + Map json, + ) => _$GetTimetableResponseObjectTeacherFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectTeacherToJson(this); } @JsonSerializable() @@ -122,8 +150,11 @@ class GetTimetableResponseObjectSubject { GetTimetableResponseObjectSubject(this.id, this.name, this.longname); - factory GetTimetableResponseObjectSubject.fromJson(Map json) => _$GetTimetableResponseObjectSubjectFromJson(json); - Map toJson() => _$GetTimetableResponseObjectSubjectToJson(this); + factory GetTimetableResponseObjectSubject.fromJson( + Map json, + ) => _$GetTimetableResponseObjectSubjectFromJson(json); + Map toJson() => + _$GetTimetableResponseObjectSubjectToJson(this); } @JsonSerializable() @@ -134,6 +165,7 @@ class GetTimetableResponseObjectRoom { GetTimetableResponseObjectRoom(this.id, this.name, this.longname); - factory GetTimetableResponseObjectRoom.fromJson(Map json) => _$GetTimetableResponseObjectRoomFromJson(json); + factory GetTimetableResponseObjectRoom.fromJson(Map json) => + _$GetTimetableResponseObjectRoomFromJson(json); Map toJson() => _$GetTimetableResponseObjectRoomToJson(this); } diff --git a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart b/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart similarity index 99% rename from lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart rename to lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart index effce44..9a3b20b 100644 --- a/lib/api/webuntis/queries/getTimetable/getTimetableResponse.g.dart +++ b/lib/api/webuntis/queries/get_timetable/get_timetable_response.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'getTimetableResponse.dart'; +part of 'get_timetable_response.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/api/webuntis/services/lesson_resolver.dart b/lib/api/webuntis/services/lesson_resolver.dart new file mode 100644 index 0000000..5403001 --- /dev/null +++ b/lib/api/webuntis/services/lesson_resolver.dart @@ -0,0 +1,75 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../queries/get_rooms/get_rooms_response.dart'; +import '../queries/get_subjects/get_subjects_response.dart'; + +/// Resolves Webuntis IDs (subject, room) against the cached `TimetableState`. +/// When a record is missing the resolver returns a placeholder fallback +/// instead of `null` so call sites stay branch-free. +class LessonResolver { + static GetSubjectsResponseObject resolveSubject( + TimetableState state, + int? id, + ) { + final fallback = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); + if (id == null) return fallback; + return state.subjects?.result.firstWhereOrNull((s) => s.id == id) ?? + fallback; + } + + static GetRoomsResponseObject resolveRoom(TimetableState state, int? id) { + final fallback = GetRoomsResponseObject(0, '?', 'Unbekannt', true, ''); + if (id == null) return fallback; + return state.rooms?.result.firstWhereOrNull((r) => r.id == id) ?? fallback; + } +} + +/// Pure formatting/labelling helpers for Webuntis lessons (status code → +/// icon/label, "Name (Longname) · Extra" lines, subject prefix). No widgets, +/// safe to unit-test. +class LessonFormatter { + static IconData iconForCode(String? code) { + switch (code) { + case 'cancelled': + return Icons.event_busy_outlined; + case 'irregular': + return Icons.swap_horiz; + default: + return Icons.school_outlined; + } + } + + static String statusLabel(String? code) { + switch (code) { + case null: + case '': + return 'Regulär'; + case 'cancelled': + return 'Entfällt'; + case 'irregular': + return 'Geändert'; + default: + return code; + } + } + + static String codePrefix(String? code) { + if (code == 'cancelled') return 'Entfällt: '; + if (code == 'irregular') return 'Änderung: '; + return code ?? ''; + } + + /// Builds a single display line from the typical Webuntis triple of name, + /// optional longname (rendered in parentheses if it differs from `name`), + /// and optional extra info (joined with `·`). + static String formatLine(String name, {String? longname, String? extra}) { + final parts = [if (name.isNotEmpty) name else '?']; + final ln = (longname ?? '').trim(); + if (ln.isNotEmpty && ln != name) parts.add('($ln)'); + final ex = (extra ?? '').trim(); + if (ex.isNotEmpty) parts.add('· $ex'); + return parts.join(' '); + } +} diff --git a/lib/api/webuntis/webuntisApi.dart b/lib/api/webuntis/webuntisApi.dart deleted file mode 100644 index 6b48e01..0000000 --- a/lib/api/webuntis/webuntisApi.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'dart:convert'; -import 'package:http/http.dart' as http; - -import '../../model/endpointData.dart'; -import '../apiParams.dart'; -import '../apiRequest.dart'; -import '../apiResponse.dart'; -import 'queries/authenticate/authenticate.dart'; -import 'webuntisError.dart'; - -abstract class WebuntisApi extends ApiRequest { - Uri endpoint = Uri.parse('https://${EndpointData().webuntis().full()}/WebUntis/jsonrpc.do?school=marianum-fulda'); - String method; - ApiParams? genericParam; - http.Response? response; - - bool authenticatedResponse; - - WebuntisApi(this.method, this.genericParam, {this.authenticatedResponse = true}); - - - Future query(WebuntisApi untis, {bool retry = false}) async { - var query = '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; - - var sessionId = '0'; - if(authenticatedResponse) { - sessionId = (await Authenticate.getSession()).sessionId; - } - var data = await post(query, {'Cookie': 'JSESSIONID=$sessionId'}); - response = data; - - dynamic jsonData = jsonDecode(data.body); - if(jsonData['error'] != null) { - if(jsonData['error']['code'] == -8520) { - if(retry) throw WebuntisError('Authentication was tried (probably session timeout), but was not successful!', 1); - await Authenticate.createSession(); - return await this.query(untis, retry: true); - } else { - throw WebuntisError(jsonData['error']['message'], jsonData['error']['code']); - } - } - return data.body; - } - - dynamic finalize(dynamic response) { - response.rawResponse = this.response!; - return response; - } - - Future run(); - - String _body() => genericParam == null ? '{}' : jsonEncode(genericParam); - - Future post(String data, Map? headers) async => await http - .post(endpoint, body: data, headers: headers) - .timeout( - const Duration(seconds: 10), - onTimeout: () => throw WebuntisError('Timeout', 1) - ); -} diff --git a/lib/api/webuntis/webuntis_api.dart b/lib/api/webuntis/webuntis_api.dart new file mode 100644 index 0000000..93d7d87 --- /dev/null +++ b/lib/api/webuntis/webuntis_api.dart @@ -0,0 +1,99 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:http/http.dart' as http; + +import '../../model/endpoint_data.dart'; +import '../api_params.dart'; +import '../api_request.dart'; +import '../api_response.dart'; +import '../errors/network_exception.dart'; +import '../errors/parse_exception.dart'; +import 'queries/authenticate/authenticate.dart'; +import 'webuntis_error.dart'; + +abstract class WebuntisApi extends ApiRequest { + Uri endpoint = Uri.parse( + 'https://${EndpointData().webuntis().full()}/WebUntis/jsonrpc.do?school=marianum-fulda', + ); + String method; + ApiParams? genericParam; + http.Response? response; + + bool authenticatedResponse; + + WebuntisApi( + this.method, + this.genericParam, { + this.authenticatedResponse = true, + }); + + Future query(WebuntisApi untis, {bool retry = false}) async { + final body = + '{"id":"ID","method":"$method","params":${untis._body()},"jsonrpc":"2.0"}'; + + var sessionId = '0'; + if (authenticatedResponse) { + sessionId = (await Authenticate.getSession()).sessionId; + } + final data = await post(body, {'Cookie': 'JSESSIONID=$sessionId'}); + response = data; + + final Map jsonData; + try { + jsonData = jsonDecode(data.body) as Map; + } on FormatException catch (e) { + throw ParseException( + technicalDetails: 'WebUntis JSON decode: ${e.message}', + ); + } + final error = jsonData['error'] as Map?; + if (error != null) { + final code = error['code'] as int; + if (code == -8520) { + if (retry) { + throw WebuntisError( + 'Authentication was tried (probably session timeout), but was not successful!', + -8520, + ); + } + await Authenticate.createSession(); + return query(untis, retry: true); + } else { + throw WebuntisError(error['message'] as String, code); + } + } + return data.body; + } + + T finalize(T response) { + response.rawResponse = this.response!; + return response; + } + + Future run(); + + String _body() => genericParam == null ? '{}' : jsonEncode(genericParam); + + Future post(String data, Map? headers) async { + try { + return await http + .post(endpoint, body: data, headers: headers) + .timeout( + const Duration(seconds: 10), + onTimeout: () => throw NetworkException.timeout( + technicalDetails: 'WebUntis $method timed out after 10s', + ), + ); + } on SocketException catch (e) { + throw NetworkException( + technicalDetails: 'WebUntis $method: ${e.message}', + ); + } on http.ClientException catch (e) { + throw NetworkException( + technicalDetails: 'WebUntis $method: ${e.message}', + ); + } + } +} diff --git a/lib/api/webuntis/webuntisError.dart b/lib/api/webuntis/webuntis_error.dart similarity index 100% rename from lib/api/webuntis/webuntisError.dart rename to lib/api/webuntis/webuntis_error.dart diff --git a/lib/app.dart b/lib/app.dart index 0b4a03b..6ca7924 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -1,27 +1,27 @@ - import 'dart:async'; import 'dart:developer'; -import 'package:easy_debounce/easy_throttle.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/material.dart'; -import 'state/app/modules/app_modules.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; -import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import 'api/mhsl/server/userIndex/update/updateUserindex.dart'; +import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import 'api/mhsl/server/user_index/update/update_userindex.dart'; import 'main.dart'; -import 'model/breakers/Breaker.dart'; -import 'model/breakers/BreakerProps.dart'; -import 'model/chatList/chatListProps.dart'; -import 'model/dataCleaner.dart'; -import 'model/timetable/timetableProps.dart'; -import 'notification/notificationController.dart'; -import 'notification/notificationTasks.dart'; -import 'notification/notifyUpdater.dart'; -import 'storage/base/settingsProvider.dart'; +import 'model/data_cleaner.dart'; +import 'notification/notification_controller.dart'; +import 'notification/notification_tasks.dart'; +import 'notification/notify_updater.dart'; +import 'state/app/modules/app_modules.dart'; +import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; +import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'storage/settings.dart' as model; +import 'utils/debouncer.dart'; import 'view/pages/overhang.dart'; +import 'widget/breaker/breaker.dart'; class App extends StatefulWidget { const App({super.key}); @@ -31,101 +31,179 @@ class App extends StatefulWidget { } class _AppState extends State with WidgetsBindingObserver { + late Timer _refetchChats; + late Timer _updateTimings; + // Tracked via the bottom-nav controller's listener so it always reflects the + // user's actual position, even between rapid setting emits where the + // controller hasn't caught up to a scheduled jump yet. + int _knownTotalTabs = 1; + bool _userOnLastTab = false; - late Timer refetchChats; - late Timer updateTimings; + void _onTabControllerChanged() { + _userOnLastTab = Main.bottomNavigator.index == _knownTotalTabs - 1; + } @override void didChangeAppLifecycleState(AppLifecycleState state) { - log('AppLifecycle: ${state.toString()}'); - - if(state == AppLifecycleState.resumed) { - EasyThrottle.throttle( - 'appLifecycleState', - const Duration(seconds: 10), - () { - log('Refreshing due to LifecycleChange'); - NotificationTasks.updateProviders(context); - Provider.of(context, listen: false).run(); - } - ); + log('AppLifecycle: $state'); + if (state == AppLifecycleState.resumed) { + Debouncer.throttle('appLifecycleState', const Duration(seconds: 10), () { + if (!mounted) return; + log('Refreshing due to LifecycleChange'); + NotificationTasks.updateProviders(context); + }); } } - @override void initState() { + super.initState(); Main.bottomNavigator = PersistentTabController(initialIndex: 0); + Main.bottomNavigator.addListener(_onTabControllerChanged); WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); - Provider.of(context, listen: false).run(); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().refresh(); + context.read().refresh(); + // App is freshly mounted on every login (BlocConsumer in main.dart + // swaps it in for Login), so this also covers the post-logout case + // where the bloc was reset to an empty state and needs a fresh fetch. + context.read().refresh(); }); - updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){})); + _updateTimings = Timer.periodic(const Duration(seconds: 30), (_) { + if (mounted) setState(() {}); + }); - refetchChats = Timer.periodic(const Duration(seconds: 60), (timer) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); + _refetchChats = Timer.periodic(const Duration(seconds: 60), (_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + context.read().refresh(); }); }); - // User index UpdateUserIndex.index(); - // User Notifications - if(Provider.of(context, listen: false).val().notificationSettings.enabled) { - update() => NotifyUpdater.registerToServer(); - FirebaseMessaging.instance.onTokenRefresh.listen((event) => update()); + if (context.read().val().notificationSettings.enabled) { + void update() => NotifyUpdater.registerToServer(); + FirebaseMessaging.instance.onTokenRefresh.listen((_) => update()); update(); } - - FirebaseMessaging.onMessage.listen((message) => NotificationController.onForegroundMessageHandler(message, context)); - FirebaseMessaging.onBackgroundMessage(NotificationController.onBackgroundMessageHandler); - FirebaseMessaging.onMessageOpenedApp.listen((message) => NotificationController.onAppOpenedByNotification(message, context)); - FirebaseMessaging.instance.getInitialMessage().then((message) => message == null ? null : NotificationController.onAppOpenedByNotification(message, context)); + FirebaseMessaging.onMessage.listen((message) { + if (!mounted) return; + NotificationController.onForegroundMessageHandler(message, context); + }); + FirebaseMessaging.onBackgroundMessage( + NotificationController.onBackgroundMessageHandler, + ); + + FirebaseMessaging.onMessageOpenedApp.listen((message) { + if (!mounted) return; + NotificationController.onAppOpenedByNotification(message, context); + }); + FirebaseMessaging.instance.getInitialMessage().then((message) { + if (message == null || !mounted) return; + NotificationController.onAppOpenedByNotification(message, context); + }); DataCleaner.cleanOldCache(); - - super.initState(); } - @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => PersistentTabView( - controller: Main.bottomNavigator, - navBarOverlap: const NavBarOverlap.none(), - backgroundColor: Theme.of(context).colorScheme.primary, - handleAndroidBackButtonPress: false, - - screenTransitionAnimation: const ScreenTransitionAnimation(curve: Curves.easeOutQuad, duration: Duration(milliseconds: 200)), - tabs: [ - ...AppModule.getBottomBarModules(context).map((e) => e.toBottomTab(context)), - - PersistentTabConfig( - screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), - item: ItemConfig( - activeForegroundColor: Theme.of(context).primaryColor, - inactiveForegroundColor: Theme.of(context).colorScheme.secondary, - icon: const Icon(Icons.apps), - title: 'Mehr' - ), - ), - ], - navBarBuilder: (config) => Style6BottomNavBar( - navBarConfig: config, - navBarDecoration: NavBarDecoration( - border: const Border(top: BorderSide(width: 1, color: Colors.grey)), - color: Theme.of(context).colorScheme.surface, - ), - ), - )); - @override void dispose() { - refetchChats.cancel(); - updateTimings.cancel(); + _refetchChats.cancel(); + _updateTimings.cancel(); + Main.bottomNavigator.removeListener(_onTabControllerChanged); WidgetsBinding.instance.removeObserver(this); super.dispose(); } + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + builder: (context, _) { + final bottomBarModules = AppModule.getBottomBarModules(context); + final totalTabs = bottomBarModules.length + 1; + final currentIndex = Main.bottomNavigator.index; + + // The bottom-bar layout is identified by the ordered list of module + // names plus the trailing 'more' slot. Whenever this layout changes + // — slot count, reordering, or hiding a module — we recreate the + // entire PersistentTabView via the [layoutKey] below. The package + // caches per-tab navigator state by index in `_navigatorKeys`, and + // its internal `alignLength` only ever appends or trims at the end. + // So when the module sitting at e.g. index 3 changes, the navigator + // at that index still serves the old screen's route stack and the + // user sees stale content. Re-mounting clears those stacks; the + // trade-off (losing in-tab pushed routes on a settings change) is + // acceptable since the user explicitly re-shaped the bar. + final layoutKey = ValueKey( + '${bottomBarModules.map((m) => m.module.name).join('|')}|more', + ); + + if (totalTabs != _knownTotalTabs) { + var targetIndex = currentIndex; + if (_userOnLastTab) { + targetIndex = totalTabs - 1; + } else if (currentIndex >= totalTabs) { + targetIndex = totalTabs - 1; + } + // Re-mounting PTV with a new key constructs fresh internals from + // its controller's current index. If the controller still points + // past the new tab list, Style6BottomNavBar (and others) crash on + // out-of-range access during initState. Replace the controller + // atomically with one initialised at the safe target index so the + // new PTV mounts cleanly. + if (targetIndex != currentIndex) { + Main.bottomNavigator.removeListener(_onTabControllerChanged); + Main.bottomNavigator = PersistentTabController( + initialIndex: targetIndex, + ); + Main.bottomNavigator.addListener(_onTabControllerChanged); + _userOnLastTab = targetIndex == totalTabs - 1; + } + } + + _knownTotalTabs = totalTabs; + + return PersistentTabView( + key: layoutKey, + controller: Main.bottomNavigator, + navBarOverlap: const NavBarOverlap.none(), + backgroundColor: Theme.of(context).colorScheme.primary, + handleAndroidBackButtonPress: true, + screenTransitionAnimation: const ScreenTransitionAnimation( + curve: Curves.easeOutQuad, + duration: Duration(milliseconds: 200), + ), + tabs: [ + ...bottomBarModules.map((e) => e.toBottomTab(context)), + PersistentTabConfig( + screen: const Breaker(breaker: BreakerArea.more, child: Overhang()), + item: ItemConfig( + activeForegroundColor: Theme.of(context).primaryColor, + inactiveForegroundColor: Theme.of(context).colorScheme.secondary, + icon: const Icon(Icons.apps), + title: 'Mehr', + ), + ), + ], + navBarBuilder: (config) => Style6BottomNavBar( + // Style6BottomNavBar builds its internal animation controller list + // in initState and never grows it on didUpdateWidget. Keying by the + // item count forces a fresh State whenever the slot count changes, + // which avoids a RangeError when more tabs slide in. + key: ValueKey(config.items.length), + navBarConfig: config, + navBarDecoration: NavBarDecoration( + border: const Border(top: BorderSide(width: 1, color: Colors.grey)), + color: Theme.of(context).colorScheme.surface, + ), + ), + ); + }, + ); } diff --git a/lib/extensions/dateTime.dart b/lib/extensions/dateTime.dart deleted file mode 100644 index 7852873..0000000 --- a/lib/extensions/dateTime.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; - -extension IsSameDay on DateTime { - bool isSameDay(DateTime other) => year == other.year && month == other.month && day == other.day; - - DateTime nextWeekday(int day) => add(Duration(days: (day - weekday) % DateTime.daysPerWeek)); - - DateTime withTime(TimeOfDay time) => copyWith(hour: time.hour, minute: time.minute); - - TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute); - - bool isSameDateTime(DateTime other) { - var isSameDay = this.isSameDay(other); - var isSameTimeOfDay = (toTimeOfDay() == other.toTimeOfDay()); - - return isSameDay && isSameTimeOfDay; - } - - bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other); -} diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart new file mode 100644 index 0000000..830f2b7 --- /dev/null +++ b/lib/extensions/date_time.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; + +extension IsSameDay on DateTime { + bool isSameDay(DateTime other) => + year == other.year && month == other.month && day == other.day; + + DateTime nextWeekday(int day) => + add(Duration(days: (day - weekday) % DateTime.daysPerWeek)); + + DateTime withTime(TimeOfDay time) => + copyWith(hour: time.hour, minute: time.minute); + + TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute); + + bool isSameDateTime(DateTime other) { + var isSameDay = this.isSameDay(other); + var isSameTimeOfDay = (toTimeOfDay() == other.toTimeOfDay()); + + return isSameDay && isSameTimeOfDay; + } + + bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other); +} + +/// Formatting helpers backed by Jiffy. Centralises the patterns that previously +/// were repeated as `Jiffy.parseFromDateTime(dt).format(pattern: '...')`. +extension DateTimeFormatting on DateTime { + String formatHm() => Jiffy.parseFromDateTime(this).format(pattern: 'HH:mm'); + + String formatDate() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy'); + + String formatDateTime() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.yyyy HH:mm'); + + String formatDateShort() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM.'); + + String formatDateShortHm() => + Jiffy.parseFromDateTime(this).format(pattern: 'dd.MM. HH:mm'); + + String formatMonthYear() => + Jiffy.parseFromDateTime(this).format(pattern: 'MMMM yyyy'); + + String formatRelative() => Jiffy.parseFromDateTime(this).fromNow(); + + String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}'; +} diff --git a/lib/extensions/renderNotNull.dart b/lib/extensions/render_not_null.dart similarity index 62% rename from lib/extensions/renderNotNull.dart rename to lib/extensions/render_not_null.dart index 3d267a0..b7be60f 100644 --- a/lib/extensions/renderNotNull.dart +++ b/lib/extensions/render_not_null.dart @@ -1,3 +1,4 @@ extension RenderNotNullExt on T? { - R? wrapNullable(R Function(T data) defaultValueCallback) => this != null ? defaultValueCallback(this as T) : null; + R? wrapNullable(R Function(T data) defaultValueCallback) => + this != null ? defaultValueCallback(this as T) : null; } diff --git a/lib/extensions/text.dart b/lib/extensions/text.dart index 0cf90a2..879e7ec 100644 --- a/lib/extensions/text.dart +++ b/lib/extensions/text.dart @@ -3,10 +3,18 @@ import 'package:flutter/material.dart'; extension TextExt on Text { Size get size { final textPainter = TextPainter( - text: TextSpan(text: data, style: style), - maxLines: 1, - textDirection: TextDirection.ltr + text: TextSpan(text: data, style: style), + maxLines: 1, + textDirection: TextDirection.ltr, )..layout(minWidth: 0, maxWidth: double.infinity); return textPainter.size; } } + +/// Returns the first non-empty (after trim) entry, or '' if none match. +String firstNonEmpty(List values) { + for (final v in values) { + if (v != null && v.trim().isNotEmpty) return v; + } + return ''; +} diff --git a/lib/extensions/timeOfDay.dart b/lib/extensions/time_of_day.dart similarity index 67% rename from lib/extensions/timeOfDay.dart rename to lib/extensions/time_of_day.dart index c99a47e..b2c39e0 100644 --- a/lib/extensions/timeOfDay.dart +++ b/lib/extensions/time_of_day.dart @@ -5,5 +5,6 @@ extension TimeOfDayExt on TimeOfDay { bool isAfter(TimeOfDay other) => hour > other.hour && minute > other.minute; - TimeOfDay add({int hours = 0, int minutes = 0}) => replacing(hour: hour + hours, minute: minute + minutes); + TimeOfDay add({int hours = 0, int minutes = 0}) => + replacing(hour: hour + hours, minute: minute + minutes); } diff --git a/lib/firebase_options.dart b/lib/firebase_options.dart index e389256..9521cbc 100644 --- a/lib/firebase_options.dart +++ b/lib/firebase_options.dart @@ -63,7 +63,8 @@ class DefaultFirebaseOptions { messagingSenderId: '522850592536', projectId: 'marmobile-33b10', storageBucket: 'marmobile-33b10.appspot.com', - iosClientId: '522850592536-edj90sbbnkjqe3aqui37j8enu93v4fk8.apps.googleusercontent.com', + iosClientId: + '522850592536-edj90sbbnkjqe3aqui37j8enu93v4fk8.apps.googleusercontent.com', iosBundleId: 'eu.mhsl.marianum.mobile.client', ); } diff --git a/lib/main.dart b/lib/main.dart index fb1415e..3d02fec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,169 +1,293 @@ import 'dart:async'; import 'dart:developer'; import 'dart:io'; +import 'dart:ui'; import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:jiffy/jiffy.dart'; import 'package:loader_overlay/loader_overlay.dart'; import 'package:path_provider/path_provider.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; -import 'api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; +import 'api/mhsl/breaker/get_breakers/get_breakers_response.dart'; import 'app.dart'; import 'firebase_options.dart'; -import 'model/accountData.dart'; -import 'model/accountModel.dart'; -import 'model/breakers/Breaker.dart'; -import 'model/breakers/BreakerProps.dart'; -import 'model/chatList/chatListProps.dart'; -import 'model/chatList/chatProps.dart'; -import 'model/files/filesProps.dart'; -import 'model/holidays/holidaysProps.dart'; -import 'model/timetable/timetableProps.dart'; -import 'storage/base/settingsProvider.dart'; -import 'theming/darkAppTheme.dart'; -import 'theming/lightAppTheme.dart'; +import 'model/account_data.dart'; +import 'state/app/modules/account/bloc/account_bloc.dart'; +import 'state/app/modules/account/bloc/account_state.dart'; +import 'state/app/modules/breaker/bloc/breaker_bloc.dart'; +import 'state/app/modules/chat/bloc/chat_bloc.dart'; +import 'state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import 'state/app/modules/settings/bloc/settings_cubit.dart'; +import 'state/app/modules/timetable/bloc/timetable_bloc.dart'; +import 'storage/settings.dart'; +import 'theming/dark_app_theme.dart'; +import 'theming/light_app_theme.dart'; import 'view/login/login.dart'; -import 'widget/placeholderView.dart'; +import 'widget/app_progress_indicator.dart'; +import 'widget/breaker/breaker.dart'; +import 'widget/debug/cache_view.dart'; Future main() async { log('MarianumMobile started'); WidgetsFlutterBinding.ensureInitialized(); - addCertificateAsTrusted(ByteData certificate) => SecurityContext.defaultContext.setTrustedCertificatesBytes(certificate.buffer.asUint8List()); + void addCertificateAsTrusted(ByteData certificate) => SecurityContext + .defaultContext + .setTrustedCertificatesBytes(certificate.buffer.asUint8List()); - var initialisationTasks = [ + final initialisationTasks = [ Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) - .then((value) async => log("Firebase token: ${await FirebaseMessaging.instance.getToken() ?? "Error: no Firebase token!"}")) - .onError((error, stackTrace) => log('Error initializing Firebase: $error')), - - PlatformAssetBundle().load('assets/ca/lets-encrypt-r3.pem').then(addCertificateAsTrusted), - PlatformAssetBundle().load('assets/ca/lets-encrypt-r10.pem').then(addCertificateAsTrusted), - PlatformAssetBundle().load('assets/ca/lets-encrypt-r13.pem').then(addCertificateAsTrusted), - + .then((_) {}) + .onError((error, _) => log('Error initializing Firebase: $error')), + PlatformAssetBundle() + .load('assets/ca/lets-encrypt-r3.pem') + .then(addCertificateAsTrusted), + PlatformAssetBundle() + .load('assets/ca/lets-encrypt-r10.pem') + .then(addCertificateAsTrusted), + PlatformAssetBundle() + .load('assets/ca/lets-encrypt-r13.pem') + .then(addCertificateAsTrusted), Future(() async { - await HydratedStorage.build( - storageDirectory: HydratedStorageDirectory((await getTemporaryDirectory()).path) - ).then((storage) => HydratedBloc.storage = storage); - }) + final storage = await HydratedStorage.build( + storageDirectory: HydratedStorageDirectory( + (await getTemporaryDirectory()).path, + ), + ); + HydratedBloc.storage = storage; + }), + AccountData().waitForPopulation(), ]; log('starting app initialisation...'); await Future.wait(initialisationTasks); log('app initialisation done!'); - if(kReleaseMode) { - ErrorWidget.builder = (error) => PlaceholderView( - icon: Icons.phonelink_erase_rounded, - text: error.toStringShort(), + unawaited( + FirebaseMessaging.instance.getToken().then( + (token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'), + ), + ); + + if (kReleaseMode) { + ErrorWidget.builder = (error) => Material( + color: Colors.white, + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.phonelink_erase_rounded, size: 40), + const SizedBox(height: 12), + Text(error.toStringShort(), textAlign: TextAlign.center), + ], + ), + ), + ), ); } + // Capture uncaught Flutter and platform errors so they show up in logs + // instead of being silently swallowed. + FlutterError.onError = (details) { + log( + 'Uncaught Flutter error: ${details.exception}', + stackTrace: details.stack, + ); + FlutterError.presentError(details); + }; + PlatformDispatcher.instance.onError = (error, stack) { + log('Uncaught platform error: $error', stackTrace: stack); + return true; + }; + log('running app...'); runApp( - MultiProvider( - providers: [ - ChangeNotifierProvider(create: (context) => BreakerProps()), - - ChangeNotifierProvider(create: (context) => SettingsProvider()), - ChangeNotifierProvider(create: (context) => AccountModel()), - - ChangeNotifierProvider(create: (context) => TimetableProps()), - ChangeNotifierProvider(create: (context) => ChatListProps()), - ChangeNotifierProvider(create: (context) => ChatProps()), - ChangeNotifierProvider(create: (context) => FilesProps()), - - ChangeNotifierProvider(create: (context) => HolidaysProps()), - ], - child: const Main(), - ) + MultiBlocProvider( + providers: [ + BlocProvider(create: (_) => SettingsCubit()), + BlocProvider( + create: (_) => AccountBloc( + initialStatus: AccountData().isPopulated() + ? AccountStatus.loggedIn + : AccountStatus.loggedOut, + ), + ), + BlocProvider(create: (_) => BreakerBloc()), + BlocProvider(create: (_) => ChatListBloc()), + BlocProvider(create: (_) => ChatBloc()), + BlocProvider(create: (_) => TimetableBloc()), + ], + child: const Main(), + ), ); } class Main extends StatefulWidget { const Main({super.key}); - static PersistentTabController bottomNavigator = PersistentTabController(initialIndex: 0); + static PersistentTabController bottomNavigator = PersistentTabController( + initialIndex: 0, + ); @override State
createState() => _MainState(); } class _MainState extends State
{ - late Timer refetchProps; - @override void initState() { + super.initState(); Jiffy.setLocale('de'); AccountData().waitForPopulation().then((value) { - Provider.of(context, listen: false) - .setState(value ? AccountModelState.loggedIn : AccountModelState.loggedOut); + if (!mounted) return; + context.read().setStatus( + value ? AccountStatus.loggedIn : AccountStatus.loggedOut, + ); }); - - refetchProps = Timer.periodic(const Duration(seconds: 60), (timer) { - Provider.of(context, listen: false).run(); - }); - - super.initState(); } @override Widget build(BuildContext context) => Directionality( - textDirection: TextDirection.ltr, - child: Consumer( - builder: (context, settings, child) { - var devToolsSettings = settings.val().devToolsSettings; - return MaterialApp( - showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, - checkerboardOffscreenLayers: devToolsSettings.checkerboardOffscreenLayers, - checkerboardRasterCacheImages: devToolsSettings.checkerboardRasterCacheImages, - - debugShowCheckedModeBanner: false, - localizationsDelegates: const [ - ...GlobalMaterialLocalizations.delegates, - GlobalWidgetsLocalizations.delegate, - ], - supportedLocales: const [ - Locale('de'), - Locale('en'), - ], - locale: const Locale('de'), - - title: 'Marianum Fulda', - - themeMode: settings.val().appTheme, - theme: LightAppTheme.theme, - darkTheme: DarkAppTheme.theme, - home: LoaderOverlay( - child: Breaker( - breaker: BreakerArea.global, - child: Consumer( - builder: (context, accountModel, child) { - switch(accountModel.state) { - case AccountModelState.loggedIn: return const App(); - case AccountModelState.loggedOut: return const Login(); - case AccountModelState.undefined: return const PlaceholderView(icon: Icons.timer, text: 'Daten werden geladen'); - } - }, - ) + textDirection: TextDirection.ltr, + child: BlocBuilder( + builder: (context, settings) { + final devToolsSettings = settings.devToolsSettings; + return MaterialApp( + showPerformanceOverlay: devToolsSettings.showPerformanceOverlay, + checkerboardOffscreenLayers: + devToolsSettings.checkerboardOffscreenLayers, + checkerboardRasterCacheImages: + devToolsSettings.checkerboardRasterCacheImages, + debugShowCheckedModeBanner: false, + localizationsDelegates: const [ + ...GlobalMaterialLocalizations.delegates, + GlobalWidgetsLocalizations.delegate, + ], + supportedLocales: const [Locale('de'), Locale('en')], + locale: const Locale('de'), + title: 'Marianum Fulda', + themeMode: settings.appTheme, + theme: LightAppTheme.theme, + darkTheme: DarkAppTheme.theme, + // Brand-colored backdrop behind every route. During the logout + // home-swap and route pop animations the framework can briefly + // expose the layer below the topmost Scaffold; without this + // the dark Material default shows through and the user sees a + // black flash. + builder: (context, child) => ColoredBox( + color: LightAppTheme.marianumRed, + child: child ?? const SizedBox.shrink(), + ), + home: LoaderOverlay( + child: Breaker( + breaker: BreakerArea.global, + child: BlocConsumer( + listenWhen: (previous, current) => + previous.status != current.status, + listener: (context, accountState) { + if (accountState.status != AccountStatus.loggedOut) return; + // Routes pushed via AppRoutes (e.g. Settings) live on the + // root navigator and survive the home swap below, so they + // would still cover the Login screen after logout. Pop + // them here so the user immediately sees Login. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + // Capture bloc references before the post-frame callback + // — by the time it runs the dialog/Settings context is + // gone but this listener context is still valid. + final settingsCubit = context.read(); + final timetableBloc = context.read(); + final chatListBloc = context.read(); + final chatBloc = context.read(); + final breakerBloc = context.read(); + // Defer the actual wipe until after this frame so the + // App tree (TimetableBloc/ChatListBloc watchers etc.) + // is already torn down. Resetting blocs while App is + // still in front caused a black-frame race. + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited( + _wipeUserState( + settingsCubit: settingsCubit, + timetableBloc: timetableBloc, + chatListBloc: chatListBloc, + chatBloc: chatBloc, + breakerBloc: breakerBloc, + ), + ); + }); + }, + builder: (context, accountState) { + switch (accountState.status) { + case AccountStatus.loggedIn: + return const App(); + case AccountStatus.loggedOut: + return const Login(); + case AccountStatus.undefined: + return Scaffold( + backgroundColor: LightAppTheme.marianumRed, + body: const Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppProgressIndicator.large(color: Colors.white), + SizedBox(height: 16), + Text( + 'Konto wird geladen…', + style: TextStyle(color: Colors.white), + ), + ], + ), + ), + ); + } + }, ), ), - ); - }, - ), - ); + ), + ); + }, + ), + ); +} - @override - void dispose() { - refetchProps.cancel(); - super.dispose(); +Future _wipeUserState({ + required SettingsCubit settingsCubit, + required TimetableBloc timetableBloc, + required ChatListBloc chatListBloc, + required ChatBloc chatBloc, + required BreakerBloc breakerBloc, +}) async { + try { + // Reset user-data blocs whose tree is no longer mounted after the + // home swap. We do NOT touch SettingsCubit here — its outer BlocBuilder + // wraps MaterialApp, so emit'ing a fresh state would tear down the + // freshly-mounted Login tree and leave the user with a blank screen + // (the MaterialApp.builder backdrop) until the next interaction. + await Future.wait([ + timetableBloc.reset(), + chatListBloc.reset(), + chatBloc.reset(), + breakerBloc.reset(), + ]); + final prefs = await SharedPreferences.getInstance(); + await prefs.clear(); + await HydratedBloc.storage.clear(); + await const CacheView().clear(); + } catch (e, s) { + log('User state wipe failed: $e', stackTrace: s); } } diff --git a/lib/model/accountData.dart b/lib/model/accountData.dart deleted file mode 100644 index 419146a..0000000 --- a/lib/model/accountData.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'dart:async'; -import 'dart:convert'; - -import 'package:crypto/crypto.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import 'accountModel.dart'; - -class AccountData { - static const _usernameField = 'username'; - static const _passwordField = 'password'; - - static final AccountData _instance = AccountData._construct(); - final Future _storage = SharedPreferences.getInstance(); - Completer _populated = Completer(); - - factory AccountData() => _instance; - - AccountData._construct() { - _updateFromStorage(); - } - - String? _username; - String? _password; - - String getUsername() { - if(_username == null) throw Exception('Username not initialized'); - return _username!; - } - - String getPassword() { - if(_password == null) throw Exception('Password not initialized'); - return _password!; - } - - String getUserSecret() => sha512.convert(utf8.encode('${AccountData().getUsername()}:${AccountData().getPassword()}')).toString(); - - Future getDeviceId() async => sha512.convert(utf8.encode('${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}')).toString(); - - Future setData(String username, String password) async { - var storage = await _storage; - - storage.setString(_usernameField, username); - storage.setString(_passwordField, password); - await _updateFromStorage(); - } - - Future removeData({BuildContext? context}) async { - _populated = Completer(); - - if(context != null) Provider.of(context, listen: false).setState(AccountModelState.loggedOut); - - var storage = await _storage; - await storage.remove(_usernameField); - await storage.remove(_passwordField); - } - - Future _updateFromStorage() async { - var storage = await _storage; - //await storage.reload(); // This line was the cause of the first rejected google play upload :( - if(storage.containsKey(_usernameField) && storage.containsKey(_passwordField)) { - _username = storage.getString(_usernameField); - _password = storage.getString(_passwordField); - } - if(!_populated.isCompleted) _populated.complete(); - } - - Future waitForPopulation() async { - await _populated.future; - return isPopulated(); - } - - bool isPopulated() => _username != null && _password != null; - - String buildHttpAuthString() { - if(!isPopulated()) throw Exception('AccountData (e.g. username or password) is not initialized!'); - return '$_username:$_password'; - } -} diff --git a/lib/model/accountModel.dart b/lib/model/accountModel.dart deleted file mode 100644 index 26abcfd..0000000 --- a/lib/model/accountModel.dart +++ /dev/null @@ -1,17 +0,0 @@ -import 'package:flutter/cupertino.dart'; - -class AccountModel extends ChangeNotifier { - AccountModelState _accountState = AccountModelState.undefined; - AccountModelState get state => _accountState; - - void setState(AccountModelState state) { - _accountState = state; - notifyListeners(); - } -} - -enum AccountModelState { - undefined, - loggedIn, - loggedOut, -} diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart new file mode 100644 index 0000000..6ec006a --- /dev/null +++ b/lib/model/account_data.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:crypto/crypto.dart'; +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class AccountData { + static const _usernameField = 'username'; + static const _passwordField = 'password'; + + static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(); + + static final AccountData _instance = AccountData._construct(); + Completer _populated = Completer(); + + factory AccountData() => _instance; + + AccountData._construct() { + _migrateAndLoad(); + } + + String? _username; + String? _password; + + String getUsername() { + if (_username == null) throw Exception('Username not initialized'); + return _username!; + } + + String getPassword() { + if (_password == null) throw Exception('Password not initialized'); + return _password!; + } + + String getUserSecret() => sha512 + .convert(utf8.encode('${getUsername()}:${getPassword()}')) + .toString(); + + Future getDeviceId() async => sha512 + .convert( + utf8.encode( + '${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}', + ), + ) + .toString(); + + Future setData(String username, String password) async { + await _secureStorage.write(key: _usernameField, value: username); + await _secureStorage.write(key: _passwordField, value: password); + _username = username; + _password = password; + if (!_populated.isCompleted) _populated.complete(); + } + + Future removeData() async { + _populated = Completer(); + _username = null; + _password = null; + await _secureStorage.delete(key: _usernameField); + await _secureStorage.delete(key: _passwordField); + } + + Future _migrateAndLoad() async { + await _migrateFromLegacyStorage(); + _username = await _secureStorage.read(key: _usernameField); + _password = await _secureStorage.read(key: _passwordField); + if (!_populated.isCompleted) _populated.complete(); + } + + // Move credentials from the old SharedPreferences plain-text storage into the + // platform's secure keystore. Run once per install and clear the legacy keys. + Future _migrateFromLegacyStorage() async { + final prefs = await SharedPreferences.getInstance(); + final legacyUsername = prefs.getString(_usernameField); + final legacyPassword = prefs.getString(_passwordField); + if (legacyUsername == null || legacyPassword == null) return; + + final hasSecure = (await _secureStorage.read(key: _usernameField)) != null; + if (!hasSecure) { + await _secureStorage.write(key: _usernameField, value: legacyUsername); + await _secureStorage.write(key: _passwordField, value: legacyPassword); + } + await prefs.remove(_usernameField); + await prefs.remove(_passwordField); + } + + Future waitForPopulation() async { + await _populated.future; + return isPopulated(); + } + + bool isPopulated() => _username != null && _password != null; + + /// Returns the value for an HTTP `Authorization` header using HTTP Basic. + /// Prefer this over embedding credentials in URLs — error logs and crash + /// reports often capture the URL but not headers. + String getBasicAuthHeader() { + if (!isPopulated()) { + throw Exception( + 'AccountData (e.g. username or password) is not initialized!', + ); + } + return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}'; + } + + /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry + /// header map ready to merge into HTTP request headers. + Map authHeaders() => {'Authorization': getBasicAuthHeader()}; +} diff --git a/lib/model/breakers/Breaker.dart b/lib/model/breakers/Breaker.dart deleted file mode 100644 index d9ad93e..0000000 --- a/lib/model/breakers/Breaker.dart +++ /dev/null @@ -1,36 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../widget/placeholderView.dart'; -import 'BreakerProps.dart'; - - -class Breaker extends StatefulWidget { - final BreakerArea breaker; - final Widget child; - - const Breaker({required this.breaker, required this.child, super.key}); - - @override - State createState() => _BreakerState(); -} - -class _BreakerState extends State { - @override - Widget build(BuildContext context) => Consumer( - builder: (context, value, child) { - var blocked = value.isBlocked(widget.breaker); - if(blocked != null) { - return PlaceholderView( - icon: Icons.app_blocking_outlined, - text: 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' - "${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}" - ); - } - - return widget.child; - }, - ); -} diff --git a/lib/model/breakers/BreakerProps.dart b/lib/model/breakers/BreakerProps.dart deleted file mode 100644 index 4386c2f..0000000 --- a/lib/model/breakers/BreakerProps.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:package_info_plus/package_info_plus.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersCache.dart'; -import '../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../dataHolder.dart'; - -class BreakerProps extends DataHolder { - GetBreakersResponse? _getBreakersResponse; - GetBreakersResponse get getBreakersResponse => _getBreakersResponse!; - - PackageInfo? packageInfo; - - String? isBlocked(BreakerArea? type) { - if(kDebugMode) return null; - - if(packageInfo == null) { - PackageInfo.fromPlatform().then((value) => packageInfo = value); - return null; - } - - if(primaryLoading()) return null; - var breakers = _getBreakersResponse!; - - if(breakers.global.areas.contains(type)) return breakers.global.message; - - var selfVersion = int.parse(packageInfo!.buildNumber); - for(var key in breakers.regional.keys) { - var value = breakers.regional[key]!; - - if(int.parse(key.split('b')[1]) >= selfVersion) { - if(value.areas.contains(type)) return value.message; - } - } - - return null; - } - - @override - List properties() => [_getBreakersResponse]; - - @override - void run() { - GetBreakersCache( - onUpdate: (GetBreakersResponse getBreakersResponse) { - _getBreakersResponse = getBreakersResponse; - notifyListeners(); - } - ); - } -} diff --git a/lib/model/chatList/chatListProps.dart b/lib/model/chatList/chatListProps.dart deleted file mode 100644 index f1b7005..0000000 --- a/lib/model/chatList/chatListProps.dart +++ /dev/null @@ -1,28 +0,0 @@ - -import 'package:flutter_app_badge/flutter_app_badge.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/talk/room/getRoomCache.dart'; -import '../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../dataHolder.dart'; - -class ChatListProps extends DataHolder { - GetRoomResponse? _getRoomResponse; - GetRoomResponse get getRoomsResponse => _getRoomResponse!; - - @override - List properties() => [_getRoomResponse]; - - @override - void run({renew}) { - GetRoomCache( - renew: renew, - onUpdate: (GetRoomResponse data) => { - _getRoomResponse = data, - notifyListeners(), - FlutterAppBadge.count(data.data.map((e) => e.unreadMessages).reduce((a, b) => a+b)) - } - ); - } - -} diff --git a/lib/model/chatList/chatProps.dart b/lib/model/chatList/chatProps.dart deleted file mode 100644 index 80b1038..0000000 --- a/lib/model/chatList/chatProps.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:provider/provider.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/talk/chat/getChatCache.dart'; -import '../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../storage/base/settingsProvider.dart'; -import '../dataHolder.dart'; - -class ChatProps extends DataHolder { - String _queryToken = ''; - DateTime _lastTokenSet = DateTime.now(); - int? _referenceMessageId; - - GetChatResponse? _getChatResponse; - GetChatResponse get getChatResponse => _getChatResponse!; - - int? get getReferenceMessageId => _referenceMessageId; - set unsafeInternalSetReferenceMessageId(int? reference) => _referenceMessageId = reference; - - @override - List properties() => [_getChatResponse]; - - @override - void run() { - notifyListeners(); - if(_queryToken.isEmpty) return; - var requestStart = DateTime.now(); - - GetChatCache( - chatToken: _queryToken, - onUpdate: (GetChatResponse data) { - if(_lastTokenSet.isAfter(requestStart)) return; // Another request was faster - - _getChatResponse = data; - notifyListeners(); - } - ); - } - - void setReferenceMessageId(int? messageId, BuildContext context, String sendToToken) { - Future.microtask(() { - _referenceMessageId = messageId; - notifyListeners(); - - var settings = Provider.of(context, listen: false); - if(messageId != null) { - settings.val(write: true).talkSettings.draftReplies[sendToToken] = messageId; - } else { - settings.val(write: true).talkSettings.draftReplies.removeWhere((key, value) => key == sendToToken); - } - }); - } - - void setQueryToken(String token) { - _queryToken = token; - _getChatResponse = null; - _lastTokenSet = DateTime.now(); - run(); - } - - String currentToken() => _queryToken; -} diff --git a/lib/model/dataCleaner.dart b/lib/model/dataCleaner.dart deleted file mode 100644 index f24fb04..0000000 --- a/lib/model/dataCleaner.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:localstore/localstore.dart'; - -import '../api/requestCache.dart'; - -class DataCleaner { - static Future cleanOldCache() async { - var cacheData = await Localstore.instance.collection(RequestCache.collection).get(); - cacheData?.forEach((key, value) async { - var lastUpdate = DateTime.fromMillisecondsSinceEpoch(value['lastupdate']); - if(DateTime.now().subtract(const Duration(days: 200)).isAfter(lastUpdate)) { - await Localstore.instance.collection(RequestCache.collection).doc(key.split('/').last).delete(); - } - }); - } -} diff --git a/lib/model/dataHolder.dart b/lib/model/dataHolder.dart deleted file mode 100644 index 84b3dbd..0000000 --- a/lib/model/dataHolder.dart +++ /dev/null @@ -1,22 +0,0 @@ - -import 'package:flutter/cupertino.dart'; -import 'package:localstore/localstore.dart'; - -import '../api/apiResponse.dart'; - -abstract class DataHolder extends ChangeNotifier { - - CollectionRef storage(String path) => Localstore.instance.collection(path); - - void run(); - List properties(); - - bool primaryLoading() { - // log("${toString()} ${properties().map((e) => e != null ? "1" : "0").join(", ")}"); - for(var element in properties()) { - if(element == null) return true; - } - return false; - //return properties().where((element) => element != null).isEmpty; - } -} diff --git a/lib/model/data_cleaner.dart b/lib/model/data_cleaner.dart new file mode 100644 index 0000000..ed88004 --- /dev/null +++ b/lib/model/data_cleaner.dart @@ -0,0 +1,24 @@ +import 'package:localstore/localstore.dart'; + +import '../api/request_cache.dart'; + +class DataCleaner { + static Future cleanOldCache() async { + final cacheData = await Localstore.instance + .collection(RequestCache.collection) + .get(); + cacheData?.forEach((key, value) async { + final lastUpdate = DateTime.fromMillisecondsSinceEpoch( + value['lastupdate'] as int, + ); + if (DateTime.now() + .subtract(const Duration(days: 200)) + .isAfter(lastUpdate)) { + await Localstore.instance + .collection(RequestCache.collection) + .doc(key.split('/').last) + .delete(); + } + }); + } +} diff --git a/lib/model/endpointData.dart b/lib/model/endpoint_data.dart similarity index 50% rename from lib/model/endpointData.dart rename to lib/model/endpoint_data.dart index d5d7893..6bbe016 100644 --- a/lib/model/endpointData.dart +++ b/lib/model/endpoint_data.dart @@ -1,10 +1,6 @@ +import 'account_data.dart'; -import 'accountData.dart'; - -enum EndpointMode { - live, - stage, -} +enum EndpointMode { live, stage } class EndpointOptions { Endpoint live; @@ -12,7 +8,7 @@ class EndpointOptions { EndpointOptions({required this.live, required this.staged}); Endpoint get(EndpointMode mode) { - if(staged == null || mode == EndpointMode.live) return live; + if (staged == null || mode == EndpointMode.live) return live; return staged!; } } @@ -36,27 +32,21 @@ class EndpointData { EndpointMode getEndpointMode() { late String existingName; existingName = AccountData().getUsername(); - return existingName.startsWith('google') ? EndpointMode.stage : EndpointMode.live; + return existingName.startsWith('google') + ? EndpointMode.stage + : EndpointMode.live; } Endpoint webuntis() => EndpointOptions( - live: Endpoint( - domain: 'marianum-fulda.webuntis.com', - ), - staged: Endpoint( - domain: 'mhsl.eu', - path: '/marianum/marianummobile/webuntis/public/index.php/api' - ), - ).get(getEndpointMode()); + live: Endpoint(domain: 'marianum-fulda.webuntis.com'), + staged: Endpoint( + domain: 'mhsl.eu', + path: '/marianum/marianummobile/webuntis/public/index.php/api', + ), + ).get(getEndpointMode()); Endpoint nextcloud() => EndpointOptions( - live: Endpoint( - domain: 'cloud.marianum-fulda.de', - ), - staged: Endpoint( - domain: 'mhsl.eu', - path: '/marianum/marianummobile/cloud', - ) - ).get(getEndpointMode()); - + live: Endpoint(domain: 'cloud.marianum-fulda.de'), + staged: Endpoint(domain: 'mhsl.eu', path: '/marianum/marianummobile/cloud'), + ).get(getEndpointMode()); } diff --git a/lib/model/files/filesProps.dart b/lib/model/files/filesProps.dart deleted file mode 100644 index 979c708..0000000 --- a/lib/model/files/filesProps.dart +++ /dev/null @@ -1,52 +0,0 @@ - -import '../../api/apiResponse.dart'; -import '../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../dataHolder.dart'; - -extension ExtendedList on List { - T indexOrNull(int index) => index +1 <= length ? this[index] : null; - T firstOrNull() => isEmpty ? null : first; - T lastOrNull() => isEmpty ? null : last; -} - -class FilesProps extends DataHolder { - List folderPath = List.empty(growable: true); - String currentFolderName = 'Home'; - - ListFilesResponse? _listFilesResponse; - ListFilesResponse get listFilesResponse => _listFilesResponse!; - - void runPath(List path) { - folderPath = path; - run(); - } - - @override - List properties() => [_listFilesResponse]; - - @override - void run() { - _listFilesResponse = null; - notifyListeners(); - ListFilesCache( - path: folderPath.isEmpty ? '/' : folderPath.join('/'), - onUpdate: (ListFilesResponse data) => { - _listFilesResponse = data, - notifyListeners(), - } - ); - } - - void enterFolder(String name) { - folderPath.add(name); - currentFolderName = name; - run(); - } - - void popFolder() { - folderPath.removeLast(); - if(folderPath.isEmpty) currentFolderName = 'Home'; - run(); - } -} diff --git a/lib/model/holidays/holidaysProps.dart b/lib/model/holidays/holidaysProps.dart deleted file mode 100644 index a3e153f..0000000 --- a/lib/model/holidays/holidaysProps.dart +++ /dev/null @@ -1,24 +0,0 @@ - -import '../../api/apiResponse.dart'; -import '../../api/holidays/getHolidaysCache.dart'; -import '../../api/holidays/getHolidaysResponse.dart'; -import '../dataHolder.dart'; - - -class HolidaysProps extends DataHolder { - GetHolidaysResponse? _getHolidaysResponse; - GetHolidaysResponse get getHolidaysResponse => _getHolidaysResponse!; - - @override - List properties() => [_getHolidaysResponse]; - - @override - void run() { - GetHolidaysCache( - onUpdate: (GetHolidaysResponse data) => { - _getHolidaysResponse = data, - notifyListeners(), - }, - ); - } -} diff --git a/lib/model/timetable/timetableProps.dart b/lib/model/timetable/timetableProps.dart deleted file mode 100644 index 2d7be91..0000000 --- a/lib/model/timetable/timetableProps.dart +++ /dev/null @@ -1,130 +0,0 @@ -import 'package:intl/intl.dart'; - -import '../../api/apiResponse.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventCache.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventParams.dart'; -import '../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../api/webuntis/queries/getHolidays/getHolidaysCache.dart'; -import '../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../api/webuntis/queries/getRooms/getRoomsCache.dart'; -import '../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../api/webuntis/queries/getSubjects/getSubjectsCache.dart'; -import '../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../api/webuntis/queries/getTimetable/getTimetableCache.dart'; -import '../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../api/webuntis/webuntisError.dart'; -import '../accountData.dart'; -import '../dataHolder.dart'; - -class TimetableProps extends DataHolder { - final _queryWeek = DateTime.now().add(const Duration(days: 2)); - - late DateTime startDate = getDate(_queryWeek.subtract(Duration(days: _queryWeek.weekday - 1))); - late DateTime endDate = getDate(_queryWeek.add(Duration(days: DateTime.daysPerWeek - _queryWeek.weekday - 2))); - - GetTimetableResponse? _getTimetableResponse; - GetTimetableResponse get getTimetableResponse => _getTimetableResponse!; - - GetRoomsResponse? _getRoomsResponse; - GetRoomsResponse get getRoomsResponse => _getRoomsResponse!; - - GetSubjectsResponse? _getSubjectsResponse; - GetSubjectsResponse get getSubjectsResponse => _getSubjectsResponse!; - - GetHolidaysResponse? _getHolidaysResponse; - GetHolidaysResponse get getHolidaysResponse => _getHolidaysResponse!; - - GetCustomTimetableEventResponse? _getCustomTimetableEventResponse; - GetCustomTimetableEventResponse get getCustomTimetableEventResponse => _getCustomTimetableEventResponse!; - - WebuntisError? error; - WebuntisError? get getError => error; - bool get hasError => error != null; - - @override - List properties() => [_getTimetableResponse, _getRoomsResponse, _getSubjectsResponse, _getHolidaysResponse, _getCustomTimetableEventResponse]; - - @override - void run({renew}) { - GetTimetableCache( - startdate: int.parse(DateFormat('yyyyMMdd').format(startDate)), - enddate: int.parse(DateFormat('yyyyMMdd').format(endDate)), - onUpdate: (GetTimetableResponse data) => { - _getTimetableResponse = data, - notifyListeners(), - }, - onError: (Exception e) => { - error = e as WebuntisError?, - notifyListeners(), - } - ); - - GetRoomsCache( - onUpdate: (GetRoomsResponse data) => { - _getRoomsResponse = data, - notifyListeners(), - } - ); - - GetSubjectsCache( - onUpdate: (GetSubjectsResponse data) => { - _getSubjectsResponse = data, - notifyListeners(), - } - ); - - GetHolidaysCache( // This broke in the past. Below here is backup simulation for an empty holiday block - onUpdate: (GetHolidaysResponse data) => { - _getHolidaysResponse = data, - notifyListeners(), - } - ); - // _getHolidaysResponse = GetHolidaysResponse.fromJson(jsonDecode(""" - // {"jsonrpc":"2.0","id":"ID","result":[]} - // """)); - - GetCustomTimetableEventCache( - renew: renew, - GetCustomTimetableEventParams( - AccountData().getUserSecret() - ), - onUpdate: (GetCustomTimetableEventResponse data) => { - _getCustomTimetableEventResponse = data, - notifyListeners(), - } - ); - - notifyListeners(); - } - - DateTime getDate(DateTime d) => DateTime(d.year, d.month, d.day); - - bool isWeekend(DateTime queryDate) => queryDate.weekday == DateTime.saturday || queryDate.weekday == DateTime.sunday; - - void updateWeek(DateTime start, DateTime end) { - properties().forEach((element) => element = null); - error = null; - notifyListeners(); - startDate = start; - endDate = end; - try { - run(); - } on WebuntisError catch(e) { - error = e; - notifyListeners(); - } - } - - void resetWeek() { - error = null; - notifyListeners(); - - var queryWeek = DateTime.now().add(const Duration(days: 2)); - - startDate = getDate(queryWeek.subtract(Duration(days: queryWeek.weekday - 1))); - endDate = getDate(queryWeek.add(Duration(days: DateTime.daysPerWeek - queryWeek.weekday))); - - run(); - notifyListeners(); - } -} diff --git a/lib/notification/notificationController.dart b/lib/notification/notificationController.dart deleted file mode 100644 index babe229..0000000 --- a/lib/notification/notificationController.dart +++ /dev/null @@ -1,63 +0,0 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart'; - -import '../widget/debug/debugTile.dart'; -import '../widget/debug/jsonViewer.dart'; -import 'notificationTasks.dart'; - -class NotificationController { - @pragma('vm:entry-point') - static Future onBackgroundMessageHandler(RemoteMessage message) async { - NotificationTasks.updateBadgeCount(message); - return; // Displaying the notification is curently done via the Firebase SDK itself. The Message is server-generated. - - // - // await Firebase.initializeApp(); - // AccountData().waitForPopulation().then((value) { - // log("User account status: $value"); - // if(value) { - // GetRoom( - // GetRoomParams( - // includeStatus: false, - // ), - // ).run().then((value) { - // var messageCount = value.data.map((e) => e.unreadMessages).reduce((a, b) => a + b); - // var chatCount = value.data.where((e) => e.unreadMessages > 0).length; - // var people = value.data.where((e) => e.unreadMessages > 0).map((e) => e.displayName.split(" ")[0]); - // - // final NotificationService service = NotificationService(); - // service.initializeNotifications().then((value) { - // service.showNotification( - // title: "Du hast $messageCount ungelesene Nachrichten!", - // body: "In $chatCount Chats, von ${people.join(", ")}", - // badgeCount: messageCount, - // ); - // }); - // }); - // } - // }); - } - - static Future onForegroundMessageHandler(RemoteMessage message, BuildContext context) async { - NotificationTasks.updateProviders(context); - NotificationTasks.updateBadgeCount(message); - } - - static Future onAppOpenedByNotification(RemoteMessage message, BuildContext context) async { - NotificationTasks.navigateToTalk(context); - NotificationTasks.updateProviders(context); - - DebugTile(context).run(() { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Notification report'), - content: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Text('Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.'), - Text(JsonViewer.format(message.data)), - ], - ), - )); - }); - } -} diff --git a/lib/notification/notificationTasks.dart b/lib/notification/notificationTasks.dart deleted file mode 100644 index 7fb59df..0000000 --- a/lib/notification/notificationTasks.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/cupertino.dart'; -import 'package:flutter_app_badge/flutter_app_badge.dart'; -import 'package:provider/provider.dart'; - -import '../main.dart'; -import '../model/chatList/chatListProps.dart'; -import '../model/chatList/chatProps.dart'; -import '../state/app/modules/app_modules.dart'; - -class NotificationTasks { - static void updateBadgeCount(RemoteMessage notification) { - FlutterAppBadge.count(int.parse(notification.data['unreadCount'] ?? 0)); - } - - static void updateProviders(BuildContext context) { - Provider.of(context, listen: false).run(renew: true); - Provider.of(context, listen: false).run(); - } - - static void navigateToTalk(BuildContext context) { - var talkTab = AppModule.getBottomBarModules(context).map((e) => e.module).toList().indexOf(Modules.talk); - if(talkTab == -1) return; - Main.bottomNavigator.jumpToTab(talkTab); - } -} diff --git a/lib/notification/notification_controller.dart b/lib/notification/notification_controller.dart new file mode 100644 index 0000000..88cf348 --- /dev/null +++ b/lib/notification/notification_controller.dart @@ -0,0 +1,52 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; + +import '../widget/debug/debug_tile.dart'; +import '../widget/debug/json_viewer.dart'; +import '../widget/info_dialog.dart'; +import 'notification_tasks.dart'; + +class NotificationController { + // Notification display is handled by the Firebase SDK using server-generated payloads. + @pragma('vm:entry-point') + static Future onBackgroundMessageHandler(RemoteMessage message) async { + NotificationTasks.updateBadgeCount(message); + } + + static Future onForegroundMessageHandler( + RemoteMessage message, + BuildContext context, + ) async { + NotificationTasks.updateProviders(context); + NotificationTasks.updateBadgeCount(message); + } + + static Future onAppOpenedByNotification( + RemoteMessage message, + BuildContext context, + ) async { + NotificationTasks.navigateToTalk( + context, + chatToken: _extractChatToken(message), + ); + NotificationTasks.updateProviders(context); + + DebugTile(context).run(() { + InfoDialog.show( + context, + 'Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.\n\n' + '${JsonViewer.format(message.data)}', + copyable: true, + title: 'Notification report', + ); + }); + } + + static String? _extractChatToken(RemoteMessage message) { + for (final key in const ['chatToken', 'token', 'roomToken']) { + final value = message.data[key]; + if (value is String && value.isNotEmpty) return value; + } + return null; + } +} diff --git a/lib/notification/notificationService.dart b/lib/notification/notification_service.dart similarity index 70% rename from lib/notification/notificationService.dart rename to lib/notification/notification_service.dart index 5c5ae6c..7ee3d86 100644 --- a/lib/notification/notificationService.dart +++ b/lib/notification/notification_service.dart @@ -7,16 +7,15 @@ class NotificationService { NotificationService._internal(); - FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = FlutterLocalNotificationsPlugin(); + FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = + FlutterLocalNotificationsPlugin(); Future initializeNotifications() async { const androidSettings = AndroidInitializationSettings( - '@mipmap/ic_launcher' - ); - - final iosSettings = DarwinInitializationSettings( + '@mipmap/ic_launcher', ); + final iosSettings = DarwinInitializationSettings(); final initializationSettings = InitializationSettings( android: androidSettings, @@ -24,13 +23,16 @@ class NotificationService { ); await flutterLocalNotificationsPlugin.initialize( - settings: initializationSettings + settings: initializationSettings, ); } - Future showNotification({required String title, required String body, required int badgeCount}) async { - const androidPlatformChannelSpecifics = - AndroidNotificationDetails( + Future showNotification({ + required String title, + required String body, + required int badgeCount, + }) async { + const androidPlatformChannelSpecifics = AndroidNotificationDetails( 'marmobile', 'Marianum Fulda', importance: Importance.defaultImportance, @@ -42,14 +44,14 @@ class NotificationService { const platformChannelSpecifics = NotificationDetails( android: androidPlatformChannelSpecifics, - iOS: iosPlatformChannelSpecifics + iOS: iosPlatformChannelSpecifics, ); await flutterLocalNotificationsPlugin.show( id: 0, title: title, body: body, - notificationDetails: platformChannelSpecifics + notificationDetails: platformChannelSpecifics, ); } } diff --git a/lib/notification/notification_tasks.dart b/lib/notification/notification_tasks.dart new file mode 100644 index 0000000..9a2f167 --- /dev/null +++ b/lib/notification/notification_tasks.dart @@ -0,0 +1,32 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter_app_badge/flutter_app_badge.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../routing/app_routes.dart'; +import '../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; + +class NotificationTasks { + static void updateBadgeCount(RemoteMessage notification) { + FlutterAppBadge.count( + int.parse((notification.data['unreadCount'] as String?) ?? '0'), + ); + } + + static void updateProviders(BuildContext context) { + context.read().refresh(); + context.read().refresh(); + } + + /// Switches to the Talk tab. If [chatToken] is provided, also schedules + /// the matching chat to be opened automatically once the chat list view + /// resolves the token (handled inside [ChatList]). + static void navigateToTalk(BuildContext context, {String? chatToken}) { + if (chatToken != null && chatToken.isNotEmpty) { + AppRoutes.openChatByToken(context, chatToken); + } else { + AppRoutes.goToTalkTab(context); + } + } +} diff --git a/lib/notification/notifyUpdater.dart b/lib/notification/notifyUpdater.dart deleted file mode 100644 index dc3dff0..0000000 --- a/lib/notification/notifyUpdater.dart +++ /dev/null @@ -1,41 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; - -import '../api/mhsl/notify/register/notifyRegister.dart'; -import '../api/mhsl/notify/register/notifyRegisterParams.dart'; -import '../model/accountData.dart'; -import '../storage/base/settingsProvider.dart'; -import '../widget/confirmDialog.dart'; - -class NotifyUpdater { - static ConfirmDialog enableAfterDisclaimer(SettingsProvider settings) => ConfirmDialog( - title: 'Warnung', - icon: Icons.warning_amber, - content: '' - 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' - 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' - 'Für mehr Informationen drücke lange auf die Einstellungsoption!', - confirmButton: 'Aktivieren', - onConfirm: () { - FirebaseMessaging.instance.requestPermission( - provisional: false - ); - settings.val(write: true).notificationSettings.enabled = true; - NotifyUpdater.registerToServer(); - }, - ); - - static Future registerToServer() async { - var fcmToken = await FirebaseMessaging.instance.getToken(); - - if(fcmToken == null) throw Exception('Failed to register push notification because there is no FBC token!'); - - NotifyRegister( - NotifyRegisterParams( - username: AccountData().getUsername(), - password: AccountData().getPassword(), - fcmToken: fcmToken, - ), - ).run(); - } -} diff --git a/lib/notification/notify_updater.dart b/lib/notification/notify_updater.dart new file mode 100644 index 0000000..260c884 --- /dev/null +++ b/lib/notification/notify_updater.dart @@ -0,0 +1,51 @@ +import 'dart:async'; + +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; + +import '../api/mhsl/notify/register/notify_register.dart'; +import '../api/mhsl/notify/register/notify_register_params.dart'; +import '../model/account_data.dart'; +import '../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../widget/confirm_dialog.dart'; + +class NotifyUpdater { + static ConfirmDialog enableAfterDisclaimer( + SettingsCubit settings, + ) => ConfirmDialog( + title: 'Warnung', + icon: Icons.warning_amber, + content: + '' + 'Die Push-Benachrichtigungen werden durch mhsl.eu versendet.\n\n' + 'Durch das aktivieren dieser Funktion wird dein Nutzername, dein Password und eine Geräte-ID von mhsl dauerhaft gespeichert und verarbeitet.\n\n' + 'Für mehr Informationen drücke lange auf die Einstellungsoption!', + confirmButton: 'Aktivieren', + onConfirm: () { + unawaited( + FirebaseMessaging.instance.requestPermission(provisional: false), + ); + settings.val(write: true).notificationSettings.enabled = true; + unawaited(NotifyUpdater.registerToServer()); + }, + ); + + static Future registerToServer() async { + final fcmToken = await FirebaseMessaging.instance.getToken(); + if (fcmToken == null) { + throw Exception( + 'Failed to register push notification because there is no FBC token!', + ); + } + + unawaited( + NotifyRegister( + NotifyRegisterParams( + username: AccountData().getUsername(), + password: AccountData().getPassword(), + fcmToken: fcmToken, + ), + ).run(), + ); + } +} diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart new file mode 100644 index 0000000..6f79929 --- /dev/null +++ b/lib/routing/app_routes.dart @@ -0,0 +1,201 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; + +import '../api/marianumcloud/talk/room/get_room_response.dart'; +import '../main.dart'; +import '../model/account_data.dart'; +import '../state/app/modules/app_modules.dart'; +import '../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import '../view/pages/files/files.dart'; +import '../view/pages/marianum_message/marianum_message_view.dart'; +import '../view/pages/more/feedback/feedback_dialog.dart'; +import '../view/pages/more/roomplan/roomplan.dart'; +import '../view/pages/more/share/qr_share_view.dart'; +import '../view/pages/settings/modules_settings_page.dart'; +import '../view/pages/settings/settings.dart'; +import '../view/pages/talk/chat_view.dart'; +import '../view/pages/talk/details/message_reactions.dart'; +import '../view/pages/talk/talk_navigator.dart'; +import '../view/pages/timetable/custom_events/custom_events_view.dart'; +import '../widget/debug/cache_view.dart'; +import '../widget/file_viewer.dart'; +import '../widget/user_avatar.dart'; + +/// Single entry point for full-page navigations. Dialogs and bottom sheets +/// stay at the call sites; only full-page pushes go through here. +class AppRoutes { + AppRoutes._(); + + /// Set by [openChatByToken] (e.g. from a tapped notification) and consumed + /// by `ChatList` once the matching room is loaded. + static final ValueNotifier pendingChatToken = ValueNotifier(null); + + static void openFolder(BuildContext context, List path) { + pushScreen(context, withNavBar: false, screen: Files(path: path)); + } + + static void openFileViewer( + BuildContext context, + String localPath, { + bool openExternal = false, + }) { + pushScreen( + context, + withNavBar: false, + screen: FileViewer(path: localPath, openExternal: openExternal), + ); + } + + static void openCustomEvents(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const CustomEventsView()); + } + + static void openMarianumMessage( + BuildContext context, + String basePath, + MarianumMessage message, + ) { + pushScreen( + context, + withNavBar: false, + screen: MessageView(basePath: basePath, message: message), + ); + } + + static void openQrShare(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const QrShareView()); + } + + static void openSettings(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const Settings()); + } + + static void openModulesSettings(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const ModulesSettingsPage()); + } + + static void openFeedback(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const FeedbackDialog()); + } + + static void openCacheView(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const CacheView()); + } + + static void openRoomplan(BuildContext context) { + pushScreen(context, withNavBar: false, screen: const Roomplan()); + } + + static void openMessageReactions( + BuildContext context, + String token, + int messageId, + ) { + pushScreen( + context, + withNavBar: false, + screen: MessageReactions(token: token, messageId: messageId), + ); + } + + static void openChatView( + BuildContext context, { + required GetRoomResponseObject room, + required String selfId, + required UserAvatar avatar, + bool overrideToSingleSubScreen = true, + }) { + TalkNavigator.pushSplitView( + context, + ChatView(room: room, selfId: selfId, avatar: avatar), + overrideToSingleSubScreen: overrideToSingleSubScreen, + ); + context.read().setToken(room.token); + } + + /// Schedules a chat to be opened in the Talk tab once the room is loaded. + /// Use when only the token is known (e.g. from a tapped notification). + static void openChatByToken(BuildContext context, String token) { + pendingChatToken.value = token; + goToTalkTab(context); + try { + context.read().refresh(); + } catch (e) { + if (kDebugMode) { + debugPrint('openChatByToken: ChatListBloc refresh failed: $e'); + } + } + } + + /// Resolves a pending chat token (set via [openChatByToken]). Returns null + /// if the room or account is not ready yet — callers should retry when + /// [pendingChatToken] or the bloc state change. + static ResolvedPendingChat? resolvePendingChat(BuildContext context) { + final token = pendingChatToken.value; + if (token == null) return null; + if (!AccountData().isPopulated()) return null; + + final rooms = context.read().state.data?.rooms; + final room = _findRoomByToken(rooms, token); + if (room == null) return null; + + final isGroup = room.type != GetRoomResponseObjectConversationType.oneToOne; + final avatar = UserAvatar( + id: isGroup ? room.token : room.name, + isGroup: isGroup, + ); + return ResolvedPendingChat( + room: room, + selfId: AccountData().getUsername(), + avatar: avatar, + ); + } + + static GetRoomResponseObject? _findRoomByToken( + GetRoomResponse? rooms, + String token, + ) { + if (rooms == null) return null; + for (final room in rooms.data) { + if (room.token == token) return room; + } + return null; + } + + /// Pushes a module from the "Mehr" tab list. Modules already in the bottom + /// bar are switched to via [goToTab] instead. + static void openModule(BuildContext context, AppModule module) { + pushScreen(context, withNavBar: false, screen: module.create()); + } + + /// Switches the bottom navigation to [module]. Returns false when the + /// module is not currently in the bottom bar. + static bool goToTab(BuildContext context, Modules module) { + final index = AppModule.getBottomBarModules( + context, + ).map((e) => e.module).toList().indexOf(module); + if (index == -1) return false; + Main.bottomNavigator.jumpToTab(index); + return true; + } + + static void goToTalkTab(BuildContext context) { + goToTab(context, Modules.talk); + } +} + +class ResolvedPendingChat { + final GetRoomResponseObject room; + final String selfId; + final UserAvatar avatar; + + const ResolvedPendingChat({ + required this.room, + required this.selfId, + required this.avatar, + }); +} diff --git a/lib/state/app/basis/dataloader/holiday_data_loader.dart b/lib/state/app/basis/dataloader/holiday_data_loader.dart index 19c345f..b148ebe 100644 --- a/lib/state/app/basis/dataloader/holiday_data_loader.dart +++ b/lib/state/app/basis/dataloader/holiday_data_loader.dart @@ -1,9 +1,8 @@ import 'package:dio/dio.dart'; -import '../../infrastructure/dataLoader/data_loader.dart'; +import '../../infrastructure/data_loader/data_loader.dart'; abstract class HolidayDataLoader extends DataLoader { - HolidayDataLoader() : super(Dio(BaseOptions( - baseUrl: 'https://ferien-api.de/api/v1/', - ))); + HolidayDataLoader() + : super(Dio(BaseOptions(baseUrl: 'https://ferien-api.de/api/v1/'))); } diff --git a/lib/state/app/basis/dataloader/mhsl_data_loader.dart b/lib/state/app/basis/dataloader/mhsl_data_loader.dart index 6b4baab..737dc7b 100644 --- a/lib/state/app/basis/dataloader/mhsl_data_loader.dart +++ b/lib/state/app/basis/dataloader/mhsl_data_loader.dart @@ -1,9 +1,10 @@ import 'package:dio/dio.dart'; -import '../../infrastructure/dataLoader/data_loader.dart'; +import '../../infrastructure/data_loader/data_loader.dart'; abstract class MhslDataLoader extends DataLoader { - MhslDataLoader() : super(Dio(BaseOptions( - baseUrl: 'https://mhsl.eu/marianum/marianummobile/' - ))); + MhslDataLoader() + : super( + Dio(BaseOptions(baseUrl: 'https://mhsl.eu/marianum/marianummobile/')), + ); } diff --git a/lib/state/app/infrastructure/dataLoader/data_loader.dart b/lib/state/app/infrastructure/dataLoader/data_loader.dart deleted file mode 100644 index 64c1aa7..0000000 --- a/lib/state/app/infrastructure/dataLoader/data_loader.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; - -import 'package:dio/dio.dart'; - -abstract class DataLoader { - final Dio dio; - DataLoader(this.dio) { - dio.options.connectTimeout = const Duration(seconds: 10).inMilliseconds; - dio.options.sendTimeout = const Duration(seconds: 30).inMilliseconds; - dio.options.receiveTimeout = const Duration(seconds: 30).inMilliseconds; - } - - Future run() async { - var fetcher = fetch(); - await Future.wait([ - fetcher, - Future.delayed(const Duration(milliseconds: 500)) // TODO tune or remove - ]); - - var response = await fetcher; - try { - return assemble(DataLoaderResult( - json: jsonDecode(response.data!), - headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))), - )); - } catch(trace, e) { - log(trace.toString()); - throw(e); - } - } - - Future> fetch(); - TResult assemble(DataLoaderResult data); -} - -class DataLoaderResult { - final dynamic json; - final Map headers; - - Map asMap() => json as Map; - List asList() => json as List; - List> asListOfMaps() => asList().map((e) => e as Map).toList(); - - DataLoaderResult({required this.json, required this.headers}); -} diff --git a/lib/state/app/infrastructure/data_loader/data_loader.dart b/lib/state/app/infrastructure/data_loader/data_loader.dart new file mode 100644 index 0000000..8b1dc93 --- /dev/null +++ b/lib/state/app/infrastructure/data_loader/data_loader.dart @@ -0,0 +1,45 @@ +import 'dart:convert'; +import 'dart:developer'; + +import 'package:dio/dio.dart'; + +abstract class DataLoader { + final Dio dio; + DataLoader(this.dio) { + dio.options.connectTimeout = const Duration(seconds: 10); + dio.options.sendTimeout = const Duration(seconds: 30); + dio.options.receiveTimeout = const Duration(seconds: 30); + } + + Future run() async { + final response = await fetch(); + try { + return assemble( + DataLoaderResult( + json: jsonDecode(response.data!), + headers: response.headers.map.map( + (key, value) => MapEntry(key, value.join(';')), + ), + ), + ); + } catch (e, stack) { + log('DataLoader assemble failed', error: e, stackTrace: stack); + rethrow; + } + } + + Future> fetch(); + TResult assemble(DataLoaderResult data); +} + +class DataLoaderResult { + final dynamic json; + final Map headers; + + Map asMap() => json as Map; + List asList() => json as List; + List> asListOfMaps() => + asList().map((e) => e as Map).toList(); + + DataLoaderResult({required this.json, required this.headers}); +} diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart deleted file mode 100644 index b246417..0000000 --- a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_bloc.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; -import 'package:connectivity_plus/connectivity_plus.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; - -import 'loadable_state_event.dart'; -import 'loadable_state_state.dart'; - -class LoadableStateBloc extends Bloc { - late StreamSubscription> _updateStream; - void Function()? reFetch; - - LoadableStateBloc() : super(const LoadableStateState(connections: null)) { - on((event, emit) { - emit(event.state); - if(connectivityStatusKnown() && isConnected()) { - if(reFetch == null) return; - reFetch!(); - } - }); - - emitConnectivity(List result) => add(ConnectivityChanged(LoadableStateState(connections: result))); - - Connectivity().checkConnectivity().then(emitConnectivity); - _updateStream = Connectivity().onConnectivityChanged.listen(emitConnectivity); - } - - bool connectivityStatusKnown() => state.connections != null; - bool isConnected() => !(state.connections?.contains(ConnectivityResult.none) ?? true); - bool allowRetry() => reFetch != null; - - IconData connectionIcon() => connectivityStatusKnown() - ? isConnected() - ? Icons.nearby_error - : Icons.signal_wifi_connected_no_internet_4 - : Icons.device_unknown; - - Color connectionColor(BuildContext context) => connectivityStatusKnown() && !isConnected() - ? Colors.grey.shade600 - : Theme.of(context).primaryColor; - - String connectionText({int? lastUpdated}) => connectivityStatusKnown() - ? isConnected() - ? 'Verbindung fehlgeschlagen' - : 'Offline${lastUpdated == null ? '' : ' - Stand von ${Jiffy.parseFromMillisecondsSinceEpoch(lastUpdated).fromNow()}'}' - : 'Unbekannte Fehlerursache'; - - @override - Future close() { - _updateStream.cancel(); - return super.close(); - } -} diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_background_loading.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_background_loading.dart deleted file mode 100644 index 5147e45..0000000 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_background_loading.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'loadable_state_consumer.dart'; - -class LoadableStateBackgroundLoading extends StatelessWidget { - final bool visible; - const LoadableStateBackgroundLoading({required this.visible, super.key}); - - @override - Widget build(BuildContext context) => AnimatedSwitcher( - duration: LoadableStateConsumer.animationDuration, - transitionBuilder: (Widget child, Animation animation) => SlideTransition( - position: Tween( - begin: const Offset(0.0, -1.0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - child: visible ? const LinearProgressIndicator() : const SizedBox.shrink(), - ); -} diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart deleted file mode 100644 index e1613ba..0000000 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_consumer.dart +++ /dev/null @@ -1,84 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../../../../widget/conditional_wrapper.dart'; -import '../../utilityWidgets/bloc_module.dart'; -import '../../utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; -import '../bloc/loadable_state_bloc.dart'; -import '../bloc/loadable_state_state.dart'; -import '../loadable_state.dart'; -import 'loadable_state_background_loading.dart'; -import 'loadable_state_error_bar.dart'; -import 'loadable_state_error_screen.dart'; -import 'loadable_state_primary_loading.dart'; - -class LoadableStateConsumer, LoadableState>, TState> extends StatelessWidget { - final Widget Function(TState state, bool loading) child; - final void Function(TState state)? onLoad; - final bool wrapWithScrollView; - const LoadableStateConsumer({required this.child, this.onLoad, this.wrapWithScrollView = false, super.key}); - - static Duration animationDuration = const Duration(milliseconds: 200); - - @override - Widget build(BuildContext context) { - var loadableState = context.watch().state; - - if(!loadableState.isLoading && onLoad != null) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) => onLoad!(loadableState.data!)); - } - - var childWidget = ConditionalWrapper( - condition: loadableState.reFetch != null, - wrapper: (child) => RefreshIndicator( - onRefresh: () { - if(loadableState.reFetch != null) loadableState.reFetch!(); - return Future.value(); - }, - child: ConditionalWrapper( - condition: wrapWithScrollView, - wrapper: (child) => SingleChildScrollView( - physics: const AlwaysScrollableScrollPhysics(), - child: child - ), - child: child, - ) - ), - child: SizedBox( - height: MediaQuery.of(context).size.height, - child: loadableState.showContent() - ? child(loadableState.data!, loadableState.isLoading) - : const SizedBox.shrink(), - ), - ); - - return BlocModule( - create: (context) => LoadableStateBloc(), - child: (context, bloc, state) { - bloc.reFetch = loadableState.reFetch; - return Column( - children: [ - LoadableStateErrorBar(visible: loadableState.showErrorBar(), message: loadableState.error?.message, lastUpdated: loadableState.lastFetch), - Expanded( - child: Stack( - children: [ - LoadableStatePrimaryLoading(visible: loadableState.showPrimaryLoading()), - LoadableStateBackgroundLoading(visible: loadableState.showBackgroundLoading()), - LoadableStateErrorScreen(visible: loadableState.showError(), message: loadableState.error?.message), - - AnimatedOpacity( - opacity: loadableState.showContent() ? 1.0 : 0.0, - duration: animationDuration, - curve: Curves.easeInOut, - child: childWidget, - ), - ], - ), - ) - ], - ); - } - ); - } -} diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart deleted file mode 100644 index fd27b2b..0000000 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_bar.dart +++ /dev/null @@ -1,91 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../../../widget/infoDialog.dart'; -import '../bloc/loadable_state_bloc.dart'; - -class LoadableStateErrorBar extends StatelessWidget { - final bool visible; - final String? message; - final int? lastUpdated; - const LoadableStateErrorBar({required this.visible, this.message, this.lastUpdated, super.key}); - - final Duration animationDuration = const Duration(milliseconds: 200); - - @override - Widget build(BuildContext context) => AnimatedSize( - duration: animationDuration, - child: AnimatedSwitcher( - duration: animationDuration, - transitionBuilder: (Widget child, Animation animation) => SlideTransition( - position: Tween( - begin: const Offset(0.0, -1.0), - end: Offset.zero, - ).animate(animation), - child: child, - ), - child: Visibility( - key: Key(visible.hashCode.toString()), - visible: visible, - replacement: const SizedBox(width: double.infinity), - child: Builder( - builder: (context) { - var bloc = context.watch(); - return InkWell( - onTap: () { - if(!bloc.isConnected()) return; - InfoDialog.show(context, 'Exception: ${message.toString()}'); - }, - child: Container( - height: 20, - decoration: BoxDecoration( - color: bloc.connectionColor(context), - ), - child: LoadableStateErrorBarText(lastUpdated: lastUpdated), - ), - ); - }, - ) - ) - ), - ); -} - -class LoadableStateErrorBarText extends StatefulWidget { - final int? lastUpdated; - const LoadableStateErrorBarText({required this.lastUpdated, super.key}); - - @override - State createState() => _LoadableStateErrorBarTextState(); -} - -class _LoadableStateErrorBarTextState extends State { - late Timer _rebuildTimer; - @override - void initState() { - _rebuildTimer = Timer.periodic(const Duration(seconds: 10), (timer) => setState(() {})); - super.initState(); - } - - @override - Widget build(BuildContext context) { - var bloc = context.watch(); - - return Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(bloc.connectionIcon(), size: 14), - const SizedBox(width: 10), - Text(bloc.connectionText(lastUpdated: widget.lastUpdated), style: const TextStyle(fontSize: 12)) - ], - ); - } - - @override - void dispose() { - _rebuildTimer.cancel(); - super.dispose(); - } -} diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart deleted file mode 100644 index cb68e76..0000000 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_error_screen.dart +++ /dev/null @@ -1,51 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../bloc/loadable_state_bloc.dart'; -import 'loadable_state_consumer.dart'; - -class LoadableStateErrorScreen extends StatelessWidget { - final bool visible; - final String? message; - const LoadableStateErrorScreen({required this.visible, this.message, super.key}); - - - @override - Widget build(BuildContext context) { - final bloc = context.watch(); - return AnimatedOpacity( - opacity: visible ? 1.0 : 0.0, - duration: LoadableStateConsumer.animationDuration, - curve: Curves.easeInOut, - child: !visible ? null : Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Icon(bloc.connectionIcon(), size: 40), - const SizedBox(height: 10), - Text(bloc.connectionText(), style: const TextStyle(fontSize: 20)), - - if(bloc.allowRetry()) ...[ - const SizedBox(height: 10), - TextButton(onPressed: () => bloc.reFetch!(), child: const Text('Erneut versuschen')), - const SizedBox(height: 40), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Text( - message ?? 'Task failed successfully :)', - style: TextStyle( - color: Theme.of(context).hintColor, - fontSize: 12 - ), - maxLines: 10, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ], - ), - ), - ); - } -} diff --git a/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart new file mode 100644 index 0000000..a17c85f --- /dev/null +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_bloc.dart @@ -0,0 +1,97 @@ +import 'dart:async'; + +import 'package:connectivity_plus/connectivity_plus.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../extensions/date_time.dart'; +import 'loadable_state_event.dart'; +import 'loadable_state_state.dart'; + +class LoadableStateBloc extends Bloc + with WidgetsBindingObserver { + late StreamSubscription> _updateStream; + void Function()? reFetch; + + /// Last time [reFetch] was triggered by an [AppLifecycleState.resumed] + /// event. Used to coalesce rapid foreground/background flips so we don't + /// spam the network when the user briefly checks notifications. + DateTime _lastResumeRefetch = DateTime.fromMillisecondsSinceEpoch(0); + + LoadableStateBloc() : super(const LoadableStateState(connections: null)) { + on((event, emit) { + emit(event.state); + if (connectivityStatusKnown() && isConnected()) { + if (reFetch == null) return; + reFetch!(); + } + }); + + void emitConnectivity(List result) => + add(ConnectivityChanged(LoadableStateState(connections: result))); + + Connectivity().checkConnectivity().then(emitConnectivity); + _updateStream = Connectivity().onConnectivityChanged.listen( + emitConnectivity, + ); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state != AppLifecycleState.resumed) return; + final now = DateTime.now(); + if (now.difference(_lastResumeRefetch) < const Duration(seconds: 10)) { + return; + } + _lastResumeRefetch = now; + // Re-check connectivity. The resulting [ConnectivityChanged] event takes + // it from there: its handler updates the offline/online indicator and + // triggers [reFetch] when the device is connected, so a stale + // "Verbindung fehlgeschlagen" bar from a suspend-time fetch clears as + // soon as the network is reachable again. + unawaited( + Connectivity().checkConnectivity().then( + (result) => + add(ConnectivityChanged(LoadableStateState(connections: result))), + ), + ); + } + + bool connectivityStatusKnown() => state.connections != null; + bool isConnected() => + !(state.connections?.contains(ConnectivityResult.none) ?? true); + bool allowRetry() => reFetch != null; + + IconData connectionIcon() => connectivityStatusKnown() + ? isConnected() + ? Icons.nearby_error + : Icons.signal_wifi_connected_no_internet_4 + : Icons.device_unknown; + + Color connectionColor(BuildContext context) => + connectivityStatusKnown() && !isConnected() + ? Colors.grey.shade600 + : Theme.of(context).primaryColor; + + Color connectionForegroundColor(BuildContext context) => + connectivityStatusKnown() && !isConnected() + ? Colors.white + : ThemeData.estimateBrightnessForColor(Theme.of(context).primaryColor) == + Brightness.dark + ? Colors.white + : Colors.black; + + String connectionText({int? lastUpdated}) => connectivityStatusKnown() + ? isConnected() + ? 'Verbindung fehlgeschlagen' + : 'Offline${lastUpdated == null ? '' : ' - Stand von ${DateTime.fromMillisecondsSinceEpoch(lastUpdated).formatRelative()}'}' + : 'Unbekannte Fehlerursache'; + + @override + Future close() { + WidgetsBinding.instance.removeObserver(this); + _updateStream.cancel(); + return super.close(); + } +} diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_event.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart similarity index 99% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_event.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart index 80f3791..40955b6 100644 --- a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_event.dart +++ b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_event.dart @@ -1,6 +1,7 @@ import 'loadable_state_state.dart'; sealed class LoadableStateEvent {} + final class ConnectivityChanged extends LoadableStateEvent { final LoadableStateState state; ConnectivityChanged(this.state); diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.dart diff --git a/lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.freezed.dart b/lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/bloc/loadable_state_state.freezed.dart rename to lib/state/app/infrastructure/loadable_state/bloc/loadable_state_state.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/loadable_state.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loadable_state.dart rename to lib/state/app/infrastructure/loadable_state/loadable_state.dart diff --git a/lib/state/app/infrastructure/loadableState/loadable_state.freezed.dart b/lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/loadableState/loadable_state.freezed.dart rename to lib/state/app/infrastructure/loadable_state/loadable_state.freezed.dart diff --git a/lib/state/app/infrastructure/loadableState/loading_error.dart b/lib/state/app/infrastructure/loadable_state/loading_error.dart similarity index 90% rename from lib/state/app/infrastructure/loadableState/loading_error.dart rename to lib/state/app/infrastructure/loadable_state/loading_error.dart index 9f82716..77bbf22 100644 --- a/lib/state/app/infrastructure/loadableState/loading_error.dart +++ b/lib/state/app/infrastructure/loadable_state/loading_error.dart @@ -6,6 +6,7 @@ part 'loading_error.freezed.dart'; abstract class LoadingError with _$LoadingError { const factory LoadingError({ required String message, + String? technicalDetails, @Default(false) bool allowRetry, }) = _LoadingError; } diff --git a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart b/lib/state/app/infrastructure/loadable_state/loading_error.freezed.dart similarity index 72% rename from lib/state/app/infrastructure/loadableState/loading_error.freezed.dart rename to lib/state/app/infrastructure/loadable_state/loading_error.freezed.dart index c4c3924..958c021 100644 --- a/lib/state/app/infrastructure/loadableState/loading_error.freezed.dart +++ b/lib/state/app/infrastructure/loadable_state/loading_error.freezed.dart @@ -14,7 +14,7 @@ T _$identity(T value) => value; /// @nodoc mixin _$LoadingError { - String get message; bool get allowRetry; + String get message; String? get technicalDetails; bool get allowRetry; /// Create a copy of LoadingError /// with the given fields replaced by the non-null parameter values. @JsonKey(includeFromJson: false, includeToJson: false) @@ -25,16 +25,16 @@ $LoadingErrorCopyWith get copyWith => _$LoadingErrorCopyWithImpl Object.hash(runtimeType,message,allowRetry); +int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry); @override String toString() { - return 'LoadingError(message: $message, allowRetry: $allowRetry)'; + return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)'; } @@ -45,7 +45,7 @@ abstract mixin class $LoadingErrorCopyWith<$Res> { factory $LoadingErrorCopyWith(LoadingError value, $Res Function(LoadingError) _then) = _$LoadingErrorCopyWithImpl; @useResult $Res call({ - String message, bool allowRetry + String message, String? technicalDetails, bool allowRetry }); @@ -62,10 +62,11 @@ class _$LoadingErrorCopyWithImpl<$Res> /// Create a copy of LoadingError /// with the given fields replaced by the non-null parameter values. -@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? allowRetry = null,}) { +@pragma('vm:prefer-inline') @override $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) { return _then(_self.copyWith( message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable -as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable +as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable +as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable as bool, )); } @@ -151,10 +152,10 @@ return $default(_that);case _: /// } /// ``` -@optionalTypeArgs TResult maybeWhen(TResult Function( String message, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this; +@optionalTypeArgs TResult maybeWhen(TResult Function( String message, String? technicalDetails, bool allowRetry)? $default,{required TResult orElse(),}) {final _that = this; switch (_that) { case _LoadingError() when $default != null: -return $default(_that.message,_that.allowRetry);case _: +return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _: return orElse(); } @@ -172,10 +173,10 @@ return $default(_that.message,_that.allowRetry);case _: /// } /// ``` -@optionalTypeArgs TResult when(TResult Function( String message, bool allowRetry) $default,) {final _that = this; +@optionalTypeArgs TResult when(TResult Function( String message, String? technicalDetails, bool allowRetry) $default,) {final _that = this; switch (_that) { case _LoadingError(): -return $default(_that.message,_that.allowRetry);case _: +return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _: throw StateError('Unexpected subclass'); } @@ -192,10 +193,10 @@ return $default(_that.message,_that.allowRetry);case _: /// } /// ``` -@optionalTypeArgs TResult? whenOrNull(TResult? Function( String message, bool allowRetry)? $default,) {final _that = this; +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String message, String? technicalDetails, bool allowRetry)? $default,) {final _that = this; switch (_that) { case _LoadingError() when $default != null: -return $default(_that.message,_that.allowRetry);case _: +return $default(_that.message,_that.technicalDetails,_that.allowRetry);case _: return null; } @@ -207,10 +208,11 @@ return $default(_that.message,_that.allowRetry);case _: class _LoadingError implements LoadingError { - const _LoadingError({required this.message, this.allowRetry = false}); + const _LoadingError({required this.message, this.technicalDetails, this.allowRetry = false}); @override final String message; +@override final String? technicalDetails; @override@JsonKey() final bool allowRetry; /// Create a copy of LoadingError @@ -223,16 +225,16 @@ _$LoadingErrorCopyWith<_LoadingError> get copyWith => __$LoadingErrorCopyWithImp @override bool operator ==(Object other) { - return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry)); + return identical(this, other) || (other.runtimeType == runtimeType&&other is _LoadingError&&(identical(other.message, message) || other.message == message)&&(identical(other.technicalDetails, technicalDetails) || other.technicalDetails == technicalDetails)&&(identical(other.allowRetry, allowRetry) || other.allowRetry == allowRetry)); } @override -int get hashCode => Object.hash(runtimeType,message,allowRetry); +int get hashCode => Object.hash(runtimeType,message,technicalDetails,allowRetry); @override String toString() { - return 'LoadingError(message: $message, allowRetry: $allowRetry)'; + return 'LoadingError(message: $message, technicalDetails: $technicalDetails, allowRetry: $allowRetry)'; } @@ -243,7 +245,7 @@ abstract mixin class _$LoadingErrorCopyWith<$Res> implements $LoadingErrorCopyWi factory _$LoadingErrorCopyWith(_LoadingError value, $Res Function(_LoadingError) _then) = __$LoadingErrorCopyWithImpl; @override @useResult $Res call({ - String message, bool allowRetry + String message, String? technicalDetails, bool allowRetry }); @@ -260,10 +262,11 @@ class __$LoadingErrorCopyWithImpl<$Res> /// Create a copy of LoadingError /// with the given fields replaced by the non-null parameter values. -@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? allowRetry = null,}) { +@override @pragma('vm:prefer-inline') $Res call({Object? message = null,Object? technicalDetails = freezed,Object? allowRetry = null,}) { return _then(_LoadingError( message: null == message ? _self.message : message // ignore: cast_nullable_to_non_nullable -as String,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable +as String,technicalDetails: freezed == technicalDetails ? _self.technicalDetails : technicalDetails // ignore: cast_nullable_to_non_nullable +as String?,allowRetry: null == allowRetry ? _self.allowRetry : allowRetry // ignore: cast_nullable_to_non_nullable as bool, )); } diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart new file mode 100644 index 0000000..b4f6d49 --- /dev/null +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_background_loading.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; + +import 'loadable_state_consumer.dart'; + +class LoadableStateBackgroundLoading extends StatelessWidget { + final bool visible; + const LoadableStateBackgroundLoading({required this.visible, super.key}); + + @override + Widget build(BuildContext context) => AnimatedSwitcher( + duration: LoadableStateConsumer.animationDuration, + transitionBuilder: (Widget child, Animation animation) => + SlideTransition( + position: Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + child: visible ? const LinearProgressIndicator() : const SizedBox.shrink(), + ); +} diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart new file mode 100644 index 0000000..16d3c7f --- /dev/null +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../widget/conditional_wrapper.dart'; +import '../../utility_widgets/bloc_module.dart'; +import '../../utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../bloc/loadable_state_bloc.dart'; +import '../bloc/loadable_state_state.dart'; +import '../loadable_state.dart'; +import 'loadable_state_background_loading.dart'; +import 'loadable_state_error_bar.dart'; +import 'loadable_state_error_screen.dart'; +import 'loadable_state_primary_loading.dart'; + +class LoadableStateConsumer< + TController + extends Bloc, LoadableState>, + TState +> + extends StatelessWidget { + final Widget Function(TState state, bool loading) child; + final void Function(TState state)? onLoad; + final bool wrapWithScrollView; + + /// Optional predicate for callers whose [TState] always contains a non-null + /// envelope but where actual content (e.g. a nested response) is loaded + /// lazily. When provided, this overrides the default `data != null` check + /// so primary loading / error screens / content visibility correctly reflect + /// whether the inner content is ready. + final bool Function(TState state)? isReady; + + const LoadableStateConsumer({ + required this.child, + this.onLoad, + this.wrapWithScrollView = false, + this.isReady, + super.key, + }); + + static Duration animationDuration = const Duration(milliseconds: 200); + + @override + Widget build(BuildContext context) { + var loadableState = context.watch().state; + + final loadedData = loadableState.data; + if (!loadableState.isLoading && onLoad != null && loadedData is TState) { + WidgetsBinding.instance.addPostFrameCallback( + (timeStamp) => onLoad!(loadedData), + ); + } + + final typedData = loadedData is TState ? loadedData : null; + final hasContent = + typedData != null && + (isReady?.call(typedData) ?? loadableState.showContent()); + final hasError = loadableState.error != null; + final isLoading = loadableState.isLoading; + + final showPrimaryLoading = isLoading && !hasContent; + final showBackgroundLoading = isLoading && hasContent; + final showError = hasError && !hasContent; + final showErrorBar = hasError && hasContent; + + var childWidget = ConditionalWrapper( + condition: loadableState.reFetch != null, + wrapper: (child) => RefreshIndicator( + onRefresh: () { + if (loadableState.reFetch != null) loadableState.reFetch!(); + return Future.value(); + }, + child: ConditionalWrapper( + condition: wrapWithScrollView, + wrapper: (child) => SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: child, + ), + child: child, + ), + ), + child: SizedBox( + height: MediaQuery.of(context).size.height, + child: hasContent + ? child(typedData as TState, isLoading) + : const SizedBox.shrink(), + ), + ); + + return BlocModule( + create: (context) => LoadableStateBloc(), + child: (context, bloc, state) { + bloc.reFetch = loadableState.reFetch; + return Column( + children: [ + LoadableStateErrorBar( + visible: showErrorBar, + hasContent: hasContent, + message: loadableState.error?.message, + technicalDetails: loadableState.error?.technicalDetails, + lastUpdated: loadableState.lastFetch, + ), + Expanded( + child: Stack( + children: [ + LoadableStatePrimaryLoading(visible: showPrimaryLoading), + LoadableStateBackgroundLoading( + visible: showBackgroundLoading, + ), + LoadableStateErrorScreen( + visible: showError, + message: loadableState.error?.message, + technicalDetails: loadableState.error?.technicalDetails, + ), + + AnimatedOpacity( + opacity: hasContent ? 1.0 : 0.0, + duration: animationDuration, + curve: Curves.easeInOut, + child: childWidget, + ), + ], + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart new file mode 100644 index 0000000..021952b --- /dev/null +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_bar.dart @@ -0,0 +1,128 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../widget/info_dialog.dart'; +import '../bloc/loadable_state_bloc.dart'; + +class LoadableStateErrorBar extends StatelessWidget { + final bool visible; + final bool hasContent; + final String? message; + final String? technicalDetails; + final int? lastUpdated; + const LoadableStateErrorBar({ + required this.visible, + this.hasContent = false, + this.message, + this.technicalDetails, + this.lastUpdated, + super.key, + }); + + final Duration animationDuration = const Duration(milliseconds: 200); + + @override + Widget build(BuildContext context) { + final bloc = context.watch(); + final isOfflineWithCache = + hasContent && bloc.connectivityStatusKnown() && !bloc.isConnected(); + final shouldShow = visible || isOfflineWithCache; + + return AnimatedSize( + duration: animationDuration, + child: AnimatedSwitcher( + duration: animationDuration, + transitionBuilder: (Widget child, Animation animation) => + SlideTransition( + position: Tween( + begin: const Offset(0.0, -1.0), + end: Offset.zero, + ).animate(animation), + child: child, + ), + child: Visibility( + key: Key(shouldShow.hashCode.toString()), + visible: shouldShow, + replacement: const SizedBox(width: double.infinity), + child: Builder( + builder: (context) { + var bloc = context.watch(); + return InkWell( + onTap: () { + if (!bloc.isConnected()) return; + final body = [ + if (message != null && message!.isNotEmpty) message!, + if (technicalDetails != null && + technicalDetails!.isNotEmpty) + technicalDetails!, + ].join('\n\n'); + if (body.isEmpty) return; + InfoDialog.show( + context, + body, + copyable: true, + title: 'Fehlerdetails', + ); + }, + child: Container( + height: 20, + decoration: BoxDecoration( + color: bloc.connectionColor(context), + ), + child: LoadableStateErrorBarText(lastUpdated: lastUpdated), + ), + ); + }, + ), + ), + ), + ); + } +} + +class LoadableStateErrorBarText extends StatefulWidget { + final int? lastUpdated; + const LoadableStateErrorBarText({required this.lastUpdated, super.key}); + + @override + State createState() => + _LoadableStateErrorBarTextState(); +} + +class _LoadableStateErrorBarTextState extends State { + late Timer _rebuildTimer; + @override + void initState() { + _rebuildTimer = Timer.periodic( + const Duration(seconds: 10), + (timer) => setState(() {}), + ); + super.initState(); + } + + @override + Widget build(BuildContext context) { + var bloc = context.watch(); + final foreground = bloc.connectionForegroundColor(context); + + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(bloc.connectionIcon(), size: 14, color: foreground), + const SizedBox(width: 10), + Text( + bloc.connectionText(lastUpdated: widget.lastUpdated), + style: TextStyle(fontSize: 12, color: foreground), + ), + ], + ); + } + + @override + void dispose() { + _rebuildTimer.cancel(); + super.dispose(); + } +} diff --git a/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart new file mode 100644 index 0000000..7f58eb5 --- /dev/null +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_error_screen.dart @@ -0,0 +1,85 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../../widget/info_dialog.dart'; +import '../bloc/loadable_state_bloc.dart'; +import 'loadable_state_consumer.dart'; + +class LoadableStateErrorScreen extends StatelessWidget { + final bool visible; + final String? message; + final String? technicalDetails; + const LoadableStateErrorScreen({ + required this.visible, + this.message, + this.technicalDetails, + super.key, + }); + + @override + Widget build(BuildContext context) { + final bloc = context.watch(); + final isOffline = bloc.connectivityStatusKnown() && !bloc.isConnected(); + final headline = isOffline + ? bloc.connectionText() + : (message ?? bloc.connectionText()); + + return AnimatedOpacity( + opacity: visible ? 1.0 : 0.0, + duration: LoadableStateConsumer.animationDuration, + curve: Curves.easeInOut, + child: !visible + ? null + : Center( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Icon(bloc.connectionIcon(), size: 40), + const SizedBox(height: 12), + Text( + headline, + style: const TextStyle(fontSize: 20), + textAlign: TextAlign.center, + ), + if (!isOffline && + message != null && + message != headline) ...[ + const SizedBox(height: 8), + Text( + message!, + style: TextStyle( + color: Theme.of(context).hintColor, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), + ], + if (bloc.allowRetry()) ...[ + const SizedBox(height: 16), + TextButton( + onPressed: () => bloc.reFetch!(), + child: const Text('Erneut versuchen'), + ), + ], + if (technicalDetails != null) ...[ + const SizedBox(height: 4), + TextButton( + onPressed: () => InfoDialog.show( + context, + technicalDetails!, + copyable: true, + title: 'Fehlerdetails', + ), + child: const Text('Details anzeigen'), + ), + ], + ], + ), + ), + ), + ); + } +} diff --git a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart similarity index 55% rename from lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart rename to lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart index 053aaba..44d359b 100644 --- a/lib/state/app/infrastructure/loadableState/view/loadable_state_primary_loading.dart +++ b/lib/state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../../../../../widget/app_progress_indicator.dart'; import 'loadable_state_consumer.dart'; class LoadableStatePrimaryLoading extends StatelessWidget { @@ -8,9 +9,9 @@ class LoadableStatePrimaryLoading extends StatelessWidget { @override Widget build(BuildContext context) => AnimatedOpacity( - opacity: visible ? 1.0 : 0.0, - duration: LoadableStateConsumer.animationDuration, - curve: Curves.easeInOut, - child: const Center(child: CircularProgressIndicator()), - ); + opacity: visible ? 1.0 : 0.0, + duration: LoadableStateConsumer.animationDuration, + curve: Curves.easeInOut, + child: const Center(child: AppProgressIndicator.large()), + ); } diff --git a/lib/state/app/infrastructure/utilityWidgets/bloc_module.dart b/lib/state/app/infrastructure/utilityWidgets/bloc_module.dart deleted file mode 100644 index fa1bee7..0000000 --- a/lib/state/app/infrastructure/utilityWidgets/bloc_module.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class BlocModule, TState> extends StatelessWidget { - final TBloc Function(BuildContext context) create; - final Widget Function(BuildContext context, TBloc bloc, TState state) child; - final bool autoRebuild; - final void Function(BuildContext context, TBloc bloc)? onInitialisation; - const BlocModule({required this.create, required this.child, this.autoRebuild = false, this.onInitialisation, super.key}); - - Widget rebuildChild(BuildContext context) => child(context, context.watch(), context.watch().state); - Widget staticChild(BuildContext context) => child(context, context.read(), context.read().state); - - @override - Widget build(BuildContext context) => BlocProvider( - create: (context) { - var bloc = create(context); - this.onInitialisation != null ? this.onInitialisation!(context, bloc) : null; - return bloc; - }, - child: Builder( - builder: (context) => autoRebuild - ? rebuildChild(context) - : staticChild(context) - ) - ); -} diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart deleted file mode 100644 index f384924..0000000 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart +++ /dev/null @@ -1,123 +0,0 @@ -import 'dart:developer'; - -import 'package:hydrated_bloc/hydrated_bloc.dart'; - -import '../../loadableState/loading_error.dart'; -import '../../repository/repository.dart'; -import 'loadable_hydrated_bloc_event.dart'; -import '../../loadableState/loadable_state.dart'; -import 'loadable_save_context.dart'; - -abstract class LoadableHydratedBloc< - TEvent extends LoadableHydratedBlocEvent, - TState, - TRepository extends Repository -> extends HydratedBloc< - LoadableHydratedBlocEvent, - LoadableState -> { - late TRepository _repository; - LoadableHydratedBloc() : super(const LoadableState( - error: null, - data: null, - isLoading: true, - lastFetch: null, - reFetch: null, - )) { - - on>((event, emit) { - emit(LoadableState( - isLoading: state.isLoading, - data: event.state(innerState ?? fromNothing()), - lastFetch: state.lastFetch, - reFetch: retry, - error: state.error, - )); - }); - - on>((event, emit) => emit(LoadableState( - isLoading: false, - data: event.state(innerState ?? fromNothing()), - lastFetch: DateTime.now().millisecondsSinceEpoch, - reFetch: retry, - error: null, - ))); - - on>((event, emit) => emit(LoadableState( - isLoading: true, - data: innerState, - lastFetch: state.lastFetch, - reFetch: null, - error: null, - ))); - - on>((event, emit) => emit(LoadableState( - isLoading: false, - data: innerState, - lastFetch: state.lastFetch, - reFetch: retry, - error: event.error - ))); - - _repository = repository(); - fetch(); - } - - TState? get innerState => state.data; - TRepository get repo => _repository; - - void retry() { - log('Fetch retry triggered for ${TState.toString()}'); - add(RefetchStarted()); - fetch(); - } - - void fetch() { - log('Fetching data for ${TState.toString()}'); - gatherData().catchError( - (e) { - log('Error while fetching ${TState.toString()}: ${e.toString()}'); - add(Error(LoadingError( - message: e.message ?? e.toString(), - allowRetry: true, - ))); - }, - ).then((value) { - log('Fetch for ${TState.toString()} completed!'); - }); - } - - @override - fromJson(Map json) { - var rawData = LoadableSaveContext.unwrap(json); - return LoadableState( - isLoading: true, - data: fromStorage(rawData.data), - lastFetch: rawData.meta.timestamp, - reFetch: null, - error: null, - ); - } - - @override - Map? toJson(LoadableState state) { - Map? data; - try { - data = state.data == null ? null : toStorage(state.data!); - } catch(e) { - log('Failed to save state ${TState.toString()}: ${e.toString()}'); - } - - return LoadableSaveContext.wrap( - data, - state.lastFetch ?? DateTime.now().millisecondsSinceEpoch - ); - } - - Future gatherData(); - TRepository repository(); - - TState fromNothing(); - TState fromStorage(Map json); - Map? toStorage(TState state); -} diff --git a/lib/state/app/infrastructure/utility_widgets/bloc_module.dart b/lib/state/app/infrastructure/utility_widgets/bloc_module.dart new file mode 100644 index 0000000..7b032d0 --- /dev/null +++ b/lib/state/app/infrastructure/utility_widgets/bloc_module.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class BlocModule, TState> + extends StatelessWidget { + final TBloc Function(BuildContext context) create; + final Widget Function(BuildContext context, TBloc bloc, TState state) child; + final bool autoRebuild; + final void Function(BuildContext context, TBloc bloc)? onInitialisation; + const BlocModule({ + required this.create, + required this.child, + this.autoRebuild = false, + this.onInitialisation, + super.key, + }); + + Widget rebuildChild(BuildContext context) => + child(context, context.watch(), context.watch().state); + Widget staticChild(BuildContext context) => + child(context, context.read(), context.read().state); + + @override + Widget build(BuildContext context) => BlocProvider( + create: (context) { + final bloc = create(context); + onInitialisation?.call(context, bloc); + return bloc; + }, + child: Builder( + builder: (context) => + autoRebuild ? rebuildChild(context) : staticChild(context), + ), + ); +} diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart new file mode 100644 index 0000000..8f23f7c --- /dev/null +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -0,0 +1,171 @@ +import 'dart:developer'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../../../../api/errors/error_mapper.dart'; +import '../../loadable_state/loadable_state.dart'; +import '../../loadable_state/loading_error.dart'; +import '../../repository/repository.dart'; +import 'loadable_hydrated_bloc_event.dart'; +import 'loadable_save_context.dart'; + +abstract class LoadableHydratedBloc< + TEvent extends LoadableHydratedBlocEvent, + TState, + TRepository extends Repository +> + extends + HydratedBloc, LoadableState> { + late TRepository _repository; + LoadableHydratedBloc() + : super( + const LoadableState( + error: null, + data: null, + isLoading: true, + lastFetch: null, + reFetch: null, + ), + ) { + on>((event, emit) { + emit( + LoadableState( + isLoading: state.isLoading, + data: event.state(innerState ?? fromNothing()), + lastFetch: state.lastFetch, + reFetch: retry, + error: state.error, + ), + ); + }); + + on>( + (event, emit) => emit( + LoadableState( + isLoading: false, + data: event.state(innerState ?? fromNothing()), + lastFetch: DateTime.now().millisecondsSinceEpoch, + reFetch: retry, + error: null, + ), + ), + ); + + on>( + (event, emit) => emit( + LoadableState( + isLoading: true, + data: innerState, + lastFetch: state.lastFetch, + reFetch: null, + error: null, + ), + ), + ); + + on>( + (event, emit) => emit( + LoadableState( + isLoading: false, + data: innerState, + lastFetch: state.lastFetch, + reFetch: retry, + error: event.error, + ), + ), + ); + + on>( + (event, emit) => emit( + const LoadableState( + isLoading: false, + data: null, + lastFetch: null, + reFetch: null, + error: null, + ), + ), + ); + + _repository = repository(); + fetch(); + } + + /// Wipes this bloc's persisted state and resets the in-memory state to an + /// empty, non-loading shell. Intended for logout: callers must trigger a + /// fresh [fetch] (e.g. via [retry] or page-specific refresh) once the user + /// is authenticated again, otherwise the UI would stay blank. + Future reset() async { + await clear(); + add(Reset()); + } + + TState? get innerState => state.data; + TRepository get repo => _repository; + + void retry() { + log('Fetch retry triggered for ${TState.toString()}'); + add(RefetchStarted()); + fetch(); + } + + void fetch() { + log('Fetching data for ${TState.toString()}'); + gatherData() + .catchError((e) { + log('Error while fetching ${TState.toString()}: ${e.toString()}'); + // The bloc may have been closed before this async error landed (e.g. + // when its scoping widget tree was disposed mid-fetch). Adding to a + // closed bloc throws "Cannot add new events after calling close", + // so swallow that case quietly. + if (isClosed) return; + add( + Error( + LoadingError( + message: errorToUserMessage(e), + technicalDetails: errorToTechnicalDetails(e), + allowRetry: errorAllowsRetry(e), + ), + ), + ); + }) + .then((value) { + log('Fetch for ${TState.toString()} completed!'); + }); + } + + @override + LoadableState fromJson(Map json) { + var rawData = LoadableSaveContext.unwrap(json); + return LoadableState( + isLoading: true, + data: fromStorage(rawData.data), + lastFetch: rawData.meta.timestamp, + reFetch: null, + error: null, + ); + } + + @override + Map? toJson(LoadableState state) { + Map? data; + try { + final stateData = state.data; + data = stateData is TState ? toStorage(stateData) : null; + } catch (e) { + log('Failed to save state ${TState.toString()}: ${e.toString()}'); + } + + return LoadableSaveContext.wrap( + data, + state.lastFetch ?? DateTime.now().millisecondsSinceEpoch, + ); + } + + Future gatherData(); + TRepository repository(); + + TState fromNothing(); + TState fromStorage(Map json); + Map? toStorage(TState state); +} diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart similarity index 80% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart index 55b8e6a..8f3150e 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart @@ -1,16 +1,22 @@ -import '../../loadableState/loading_error.dart'; +import '../../loadable_state/loading_error.dart'; class LoadableHydratedBlocEvent {} + class Emit extends LoadableHydratedBlocEvent { final TState Function(TState state) state; Emit(this.state); } + class DataGathered extends LoadableHydratedBlocEvent { final TState Function(TState state) state; DataGathered(this.state); } + class Error extends LoadableHydratedBlocEvent { final LoadingError error; Error(this.error); } + class RefetchStarted extends LoadableHydratedBlocEvent {} + +class Reset extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart similarity index 55% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart index 095f2b6..240582c 100644 --- a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.dart @@ -6,18 +6,25 @@ part 'loadable_save_context.g.dart'; @freezed abstract class LoadableSaveContext with _$LoadableSaveContext { const LoadableSaveContext._(); - const factory LoadableSaveContext({ - required int timestamp, - }) = _LoadableSaveContext; + const factory LoadableSaveContext({required int timestamp}) = + _LoadableSaveContext; - factory LoadableSaveContext.fromJson(Map json) => _$LoadableSaveContextFromJson(json); + factory LoadableSaveContext.fromJson(Map json) => + _$LoadableSaveContextFromJson(json); static String dataKey = 'data'; static String metaKey = 'meta'; static Map wrap(Map? data, int lastFetch) => - {dataKey: data, metaKey: LoadableSaveContext(timestamp: lastFetch).toJson()}; + { + dataKey: data, + metaKey: LoadableSaveContext(timestamp: lastFetch).toJson(), + }; - static ({Map data, LoadableSaveContext meta}) unwrap(Map data) => - (data: data[dataKey] as Map, meta: LoadableSaveContext.fromJson(data[metaKey])); + static ({Map data, LoadableSaveContext meta}) unwrap( + Map data, + ) => ( + data: data[dataKey] as Map, + meta: LoadableSaveContext.fromJson(data[metaKey] as Map), + ); } diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.freezed.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.freezed.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.freezed.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.freezed.dart diff --git a/lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.g.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.g.dart similarity index 100% rename from lib/state/app/infrastructure/utilityWidgets/loadableHydratedBloc/loadable_save_context.g.dart rename to lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_save_context.g.dart diff --git a/lib/state/app/modules/account/bloc/account_bloc.dart b/lib/state/app/modules/account/bloc/account_bloc.dart new file mode 100644 index 0000000..f1d2b6a --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_bloc.dart @@ -0,0 +1,15 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'account_event.dart'; +import 'account_state.dart'; + +class AccountBloc extends Bloc { + AccountBloc({AccountStatus initialStatus = AccountStatus.undefined}) + : super(AccountState(status: initialStatus)) { + on( + (event, emit) => emit(state.copyWith(status: event.status)), + ); + } + + void setStatus(AccountStatus status) => add(AccountStatusChanged(status)); +} diff --git a/lib/state/app/modules/account/bloc/account_event.dart b/lib/state/app/modules/account/bloc/account_event.dart new file mode 100644 index 0000000..a3a0f8a --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_event.dart @@ -0,0 +1,10 @@ +import 'account_state.dart'; + +sealed class AccountEvent { + const AccountEvent(); +} + +class AccountStatusChanged extends AccountEvent { + final AccountStatus status; + const AccountStatusChanged(this.status); +} diff --git a/lib/state/app/modules/account/bloc/account_state.dart b/lib/state/app/modules/account/bloc/account_state.dart new file mode 100644 index 0000000..878407d --- /dev/null +++ b/lib/state/app/modules/account/bloc/account_state.dart @@ -0,0 +1,9 @@ +enum AccountStatus { undefined, loggedIn, loggedOut } + +class AccountState { + final AccountStatus status; + const AccountState({this.status = AccountStatus.undefined}); + + AccountState copyWith({AccountStatus? status}) => + AccountState(status: status ?? this.status); +} diff --git a/lib/state/app/modules/app_modules.dart b/lib/state/app/modules/app_modules.dart index 744feef..aa05517 100644 --- a/lib/state/app/modules/app_modules.dart +++ b/lib/state/app/modules/app_modules.dart @@ -1,21 +1,24 @@ -import 'package:flutter/material.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; - -import '../../../api/mhsl/breaker/getBreakers/getBreakersResponse.dart'; -import '../../../model/breakers/Breaker.dart'; -import '../../../model/chatList/chatListProps.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../view/pages/files/files.dart'; -import '../../../view/pages/more/roomplan/roomplan.dart'; -import '../../../view/pages/talk/chatList.dart'; -import '../../../view/pages/timetable/timetable.dart'; -import '../../../widget/centeredLeading.dart'; -import 'gradeAverages/view/grade_averages_view.dart'; -import 'holidays/view/holidays_view.dart'; -import 'marianumMessage/view/marianum_message_list_view.dart'; - import 'package:badges/badges.dart' as badges; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; + +import '../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../../routing/app_routes.dart'; +import '../../../view/pages/files/files.dart'; +import '../../../view/pages/grade_averages/grade_averages_view.dart'; +import '../../../view/pages/holidays/holidays_view.dart'; +import '../../../view/pages/marianum_dates/marianum_dates_view.dart'; +import '../../../view/pages/marianum_message/marianum_message_list_view.dart'; +import '../../../view/pages/more/roomplan/roomplan.dart'; +import '../../../view/pages/talk/chat_list.dart'; +import '../../../view/pages/timetable/timetable.dart'; +import '../../../widget/breaker/breaker.dart'; +import '../../../widget/centered_leading.dart'; +import '../infrastructure/loadable_state/loadable_state.dart'; +import 'chat_list/bloc/chat_list_bloc.dart'; +import 'chat_list/bloc/chat_list_state.dart'; +import 'settings/bloc/settings_cubit.dart'; class AppModule { Modules module; @@ -24,10 +27,19 @@ class AppModule { BreakerArea breakerArea; Widget Function() create; - AppModule(this.module, {required this.name, required this.icon, this.breakerArea = BreakerArea.global, required this.create}); + AppModule( + this.module, { + required this.name, + required this.icon, + this.breakerArea = BreakerArea.global, + required this.create, + }); - static Map modules(BuildContext context, { showFiltered = false }) { - var settings = Provider.of(context, listen: false); + static Map modules( + BuildContext context, { + bool showFiltered = false, + }) { + final settings = context.read(); var available = { Modules.timetable: AppModule( Modules.timetable, @@ -39,10 +51,15 @@ class AppModule { Modules.talk: AppModule( Modules.talk, name: 'Talk', - icon: () => Consumer( - builder: (context, value, child) { - if(value.primaryLoading()) return Icon(Icons.chat); - var messages = value.getRoomsResponse.data.map((e) => e.unreadMessages).reduce((a, b) => a+b); + icon: () => BlocBuilder>( + builder: (context, state) { + final rooms = state.data?.rooms; + if (rooms == null || rooms.data.isEmpty) { + return const Icon(Icons.chat); + } + final messages = rooms.data + .map((e) => e.unreadMessages) + .reduce((a, b) => a + b); return badges.Badge( showBadge: messages > 0, position: badges.BadgePosition.topEnd(top: -3, end: -3), @@ -52,8 +69,15 @@ class AppModule { badgeColor: Theme.of(context).primaryColor, elevation: 1, ), - badgeContent: Text('$messages', style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold)), - child: Icon(Icons.chat), + badgeContent: Text( + '$messages', + style: const TextStyle( + color: Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold, + ), + ), + child: const Icon(Icons.chat), ); }, ), @@ -95,36 +119,106 @@ class AppModule { breakerArea: BreakerArea.more, create: HolidaysView.new, ), + Modules.marianumDates: AppModule( + Modules.marianumDates, + name: 'Marianum Termine', + icon: () => Icon(Icons.event_note), + breakerArea: BreakerArea.more, + create: MarianumDatesView.new, + ), }; - if(!showFiltered) available.removeWhere((key, value) => settings.val().modulesSettings.hiddenModules.contains(key)); + if (!showFiltered) { + available.removeWhere( + (key, value) => + settings.val().modulesSettings.hiddenModules.contains(key), + ); + } - return { for (var element in settings.val().modulesSettings.moduleOrder.where((element) => available.containsKey(element))) element : available[element]! }; + return { + for (var element in settings.val().modulesSettings.moduleOrder.where( + (element) => available.containsKey(element), + )) + element: available[element]!, + }; } - static List getBottomBarModules(BuildContext context) => modules(context).values.toList().getRange(0, 3).toList(); - static List getOverhangModules(BuildContext context) => modules(context).values.skip(3).toList(); + static const int minBottomBarSlots = 3; + static const int maxBottomBarSlots = 5; - Widget toListTile(BuildContext context, {Key? key, bool isReorder = false, Function()? onVisibleChange, bool isVisible = true}) => ListTile( + static int resolveBottomBarSlotCount(BuildContext context) { + final settings = context.read().val().modulesSettings; + final available = modules(context).length; + + int desired; + if (settings.autoFillBottomBar) { + final width = MediaQuery.of(context).size.width; + if (width >= 840) { + desired = 5; + } else if (width >= 600) { + desired = 4; + } else { + desired = 3; + } + } else { + desired = settings.fixedBottomBarSlots; + } + + desired = desired.clamp(minBottomBarSlots, maxBottomBarSlots); + return desired.clamp(0, available); + } + + static List getBottomBarModules(BuildContext context) { + final all = modules(context).values.toList(); + final slots = resolveBottomBarSlotCount(context); + return all.take(slots).toList(); + } + + static List getOverhangModules(BuildContext context) { + final all = modules(context).values.toList(); + final slots = resolveBottomBarSlotCount(context); + return all.skip(slots).toList(); + } + + Widget toListTile( + BuildContext context, { + Key? key, + bool isReorder = false, + Function()? onVisibleChange, + bool isVisible = true, + }) => ListTile( key: key, leading: CenteredLeading(icon()), title: Text(name), - onTap: isReorder ? null : () => pushScreen(context, withNavBar: false, screen: create()), + onTap: isReorder ? null : () => AppRoutes.openModule(context, this), trailing: isReorder - ? Row(mainAxisSize: MainAxisSize.min, children: [ - IconButton(onPressed: onVisibleChange, icon: Icon(isVisible ? Icons.visibility_outlined : Icons.visibility_off_outlined)), - Icon(Icons.drag_handle_outlined) - ]) + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + onPressed: onVisibleChange, + icon: Icon( + isVisible + ? Icons.visibility_outlined + : Icons.visibility_off_outlined, + ), + ), + Icon(Icons.drag_handle_outlined), + ], + ) : const Icon(Icons.arrow_right), ); - PersistentTabConfig toBottomTab(BuildContext context, {Widget Function(IconData icon)? iconBuilder}) => PersistentTabConfig( + PersistentTabConfig toBottomTab( + BuildContext context, { + Widget Function(IconData icon)? iconBuilder, + }) => PersistentTabConfig( screen: Breaker(breaker: breakerArea, child: create()), item: ItemConfig( - activeForegroundColor: Theme.of(context).primaryColor, - inactiveForegroundColor: Theme.of(context).colorScheme.secondary, - icon: icon(), - title: name + activeForegroundColor: Theme.of(context).primaryColor, + inactiveForegroundColor: Theme.of(context).colorScheme.secondary, + icon: icon(), + title: name, ), ); } @@ -137,4 +231,5 @@ enum Modules { roomPlan, gradeAveragesCalculator, holidays, + marianumDates, } diff --git a/lib/state/app/modules/breaker/bloc/breaker_bloc.dart b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart new file mode 100644 index 0000000..24af779 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_bloc.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../repository/breaker_repository.dart'; +import 'breaker_event.dart'; +import 'breaker_state.dart'; + +class BreakerBloc + extends + LoadableHydratedBloc { + PackageInfo? _packageInfo; + + @override + BreakerRepository repository() => BreakerRepository(); + + @override + BreakerState fromNothing() => const BreakerState(); + + @override + BreakerState fromStorage(Map json) => + BreakerState.fromJson(json); + + @override + Map? toStorage(BreakerState state) => state.toJson(); + + @override + Future gatherData() async { + _packageInfo ??= await PackageInfo.fromPlatform(); + final response = await repo.data.getBreakers(); + add(DataGathered((s) => s.copyWith(response: response))); + } + + void refresh() => fetch(); + + String? isBlocked(BreakerArea? type) { + if (kDebugMode) return null; + final response = innerState?.response; + if (response == null || _packageInfo == null) return null; + + if (response.global.areas.contains(type)) return response.global.message; + + final selfBuild = int.parse(_packageInfo!.buildNumber); + for (final entry in response.regional.entries) { + final affectedBuild = int.parse(entry.key.split('b')[1]); + if (affectedBuild >= selfBuild && entry.value.areas.contains(type)) { + return entry.value.message; + } + } + return null; + } +} diff --git a/lib/state/app/modules/breaker/bloc/breaker_event.dart b/lib/state/app/modules/breaker/bloc/breaker_event.dart new file mode 100644 index 0000000..e5b6030 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import 'breaker_state.dart'; + +sealed class BreakerEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.dart b/lib/state/app/modules/breaker/bloc/breaker_state.dart new file mode 100644 index 0000000..367688f --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; + +part 'breaker_state.freezed.dart'; +part 'breaker_state.g.dart'; + +@freezed +abstract class BreakerState with _$BreakerState { + const factory BreakerState({GetBreakersResponse? response}) = _BreakerState; + + factory BreakerState.fromJson(Map json) => + _$BreakerStateFromJson(json); +} diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart b/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart new file mode 100644 index 0000000..7af9941 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'breaker_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$BreakerState { + + GetBreakersResponse? get response; +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$BreakerStateCopyWith get copyWith => _$BreakerStateCopyWithImpl(this as BreakerState, _$identity); + + /// Serializes this BreakerState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is BreakerState&&(identical(other.response, response) || other.response == response)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,response); + +@override +String toString() { + return 'BreakerState(response: $response)'; +} + + +} + +/// @nodoc +abstract mixin class $BreakerStateCopyWith<$Res> { + factory $BreakerStateCopyWith(BreakerState value, $Res Function(BreakerState) _then) = _$BreakerStateCopyWithImpl; +@useResult +$Res call({ + GetBreakersResponse? response +}); + + + + +} +/// @nodoc +class _$BreakerStateCopyWithImpl<$Res> + implements $BreakerStateCopyWith<$Res> { + _$BreakerStateCopyWithImpl(this._self, this._then); + + final BreakerState _self; + final $Res Function(BreakerState) _then; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? response = freezed,}) { + return _then(_self.copyWith( +response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as GetBreakersResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [BreakerState]. +extension BreakerStatePatterns on BreakerState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _BreakerState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _BreakerState value) $default,){ +final _that = this; +switch (_that) { +case _BreakerState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _BreakerState value)? $default,){ +final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( GetBreakersResponse? response)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that.response);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( GetBreakersResponse? response) $default,) {final _that = this; +switch (_that) { +case _BreakerState(): +return $default(_that.response);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( GetBreakersResponse? response)? $default,) {final _that = this; +switch (_that) { +case _BreakerState() when $default != null: +return $default(_that.response);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _BreakerState implements BreakerState { + const _BreakerState({this.response}); + factory _BreakerState.fromJson(Map json) => _$BreakerStateFromJson(json); + +@override final GetBreakersResponse? response; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$BreakerStateCopyWith<_BreakerState> get copyWith => __$BreakerStateCopyWithImpl<_BreakerState>(this, _$identity); + +@override +Map toJson() { + return _$BreakerStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _BreakerState&&(identical(other.response, response) || other.response == response)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,response); + +@override +String toString() { + return 'BreakerState(response: $response)'; +} + + +} + +/// @nodoc +abstract mixin class _$BreakerStateCopyWith<$Res> implements $BreakerStateCopyWith<$Res> { + factory _$BreakerStateCopyWith(_BreakerState value, $Res Function(_BreakerState) _then) = __$BreakerStateCopyWithImpl; +@override @useResult +$Res call({ + GetBreakersResponse? response +}); + + + + +} +/// @nodoc +class __$BreakerStateCopyWithImpl<$Res> + implements _$BreakerStateCopyWith<$Res> { + __$BreakerStateCopyWithImpl(this._self, this._then); + + final _BreakerState _self; + final $Res Function(_BreakerState) _then; + +/// Create a copy of BreakerState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? response = freezed,}) { + return _then(_BreakerState( +response: freezed == response ? _self.response : response // ignore: cast_nullable_to_non_nullable +as GetBreakersResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/breaker/bloc/breaker_state.g.dart b/lib/state/app/modules/breaker/bloc/breaker_state.g.dart new file mode 100644 index 0000000..d47f3e3 --- /dev/null +++ b/lib/state/app/modules/breaker/bloc/breaker_state.g.dart @@ -0,0 +1,19 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'breaker_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_BreakerState _$BreakerStateFromJson(Map json) => + _BreakerState( + response: json['response'] == null + ? null + : GetBreakersResponse.fromJson( + json['response'] as Map, + ), + ); + +Map _$BreakerStateToJson(_BreakerState instance) => + {'response': instance.response}; 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 new file mode 100644 index 0000000..1f8ed6b --- /dev/null +++ b/lib/state/app/modules/breaker/data_provider/breaker_data_provider.dart @@ -0,0 +1,16 @@ +import 'dart:async'; + +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_cache.dart'; +import '../../../../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; + +class BreakerDataProvider { + Future getBreakers() { + final completer = Completer(); + GetBreakersCache( + onUpdate: (data) { + if (!completer.isCompleted) completer.complete(data); + }, + ); + return completer.future; + } +} diff --git a/lib/state/app/modules/breaker/repository/breaker_repository.dart b/lib/state/app/modules/breaker/repository/breaker_repository.dart new file mode 100644 index 0000000..7a22aed --- /dev/null +++ b/lib/state/app/modules/breaker/repository/breaker_repository.dart @@ -0,0 +1,12 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/breaker_state.dart'; +import '../data_provider/breaker_data_provider.dart'; + +class BreakerRepository extends Repository { + final BreakerDataProvider _provider; + + BreakerRepository([BreakerDataProvider? provider]) + : _provider = provider ?? BreakerDataProvider(); + + BreakerDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart new file mode 100644 index 0000000..a79d169 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -0,0 +1,101 @@ +import '../../../../../api/errors/error_mapper.dart'; +import '../../../infrastructure/loadable_state/loading_error.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../repository/chat_repository.dart'; +import 'chat_event.dart'; +import 'chat_state.dart'; + +class ChatBloc + extends LoadableHydratedBloc { + DateTime _lastTokenSet = DateTime.fromMillisecondsSinceEpoch(0); + + @override + ChatRepository repository() => ChatRepository(); + + @override + ChatState fromNothing() => const ChatState(); + + @override + ChatState fromStorage(Map json) => ChatState.fromJson(json); + + @override + Map? toStorage(ChatState state) => state.toJson(); + + @override + Future gatherData() async { + final token = innerState?.currentToken ?? ''; + if (token.isEmpty) { + add(DataGathered((s) => s)); + return; + } + await _loadChat(token); + } + + void setToken(String token) { + if (token == (innerState?.currentToken ?? '')) { + refresh(); + return; + } + add(Emit((s) => s.copyWith(currentToken: token, chatResponse: null))); + add(RefetchStarted()); + _loadChat(token); + } + + void setReferenceMessageId(int? messageId) { + add(Emit((s) => s.copyWith(referenceMessageId: messageId))); + } + + void refresh() { + final token = innerState?.currentToken ?? ''; + if (token.isEmpty) return; + add(RefetchStarted()); + _loadChat(token); + } + + Future _loadChat(String token) async { + final requestStart = DateTime.now(); + _lastTokenSet = requestStart; + + bool stillCurrent() { + if (_lastTokenSet.isAfter(requestStart)) return false; + if ((innerState?.currentToken ?? '') != token) return false; + return true; + } + + Object? capturedError; + try { + await repo.data.getChat( + token: token, + onCacheData: (data) { + if (!stillCurrent()) return; + // Cache hit: show data immediately but preserve lastFetch — the + // cached payload may be stale and we don't want the UI to claim a + // fresh fetch just happened. + add(Emit((s) => s.copyWith(chatResponse: data))); + }, + onNetworkData: (data) { + if (!stillCurrent()) return; + add(DataGathered((s) => s.copyWith(chatResponse: data))); + }, + onError: (e) => capturedError = e, + ); + } catch (e) { + capturedError = e; + } + + if (!stillCurrent()) return; + + if (capturedError != null) { + add( + Error( + LoadingError( + message: errorToUserMessage(capturedError), + technicalDetails: errorToTechnicalDetails(capturedError), + allowRetry: errorAllowsRetry(capturedError), + ), + ), + ); + } + } +} diff --git a/lib/state/app/modules/chat/bloc/chat_event.dart b/lib/state/app/modules/chat/bloc/chat_event.dart new file mode 100644 index 0000000..015577f --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import 'chat_state.dart'; + +sealed class ChatEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chat/bloc/chat_state.dart b/lib/state/app/modules/chat/bloc/chat_state.dart new file mode 100644 index 0000000..f41438e --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.dart @@ -0,0 +1,18 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; + +part 'chat_state.freezed.dart'; +part 'chat_state.g.dart'; + +@freezed +abstract class ChatState with _$ChatState { + const factory ChatState({ + @Default('') String currentToken, + GetChatResponse? chatResponse, + int? referenceMessageId, + }) = _ChatState; + + factory ChatState.fromJson(Map json) => + _$ChatStateFromJson(json); +} diff --git a/lib/state/app/modules/chat/bloc/chat_state.freezed.dart b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart new file mode 100644 index 0000000..0467823 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.freezed.dart @@ -0,0 +1,283 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'chat_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ChatState { + + String get currentToken; GetChatResponse? get chatResponse; int? get referenceMessageId; +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ChatStateCopyWith get copyWith => _$ChatStateCopyWithImpl(this as ChatState, _$identity); + + /// Serializes this ChatState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); + +@override +String toString() { + return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class $ChatStateCopyWith<$Res> { + factory $ChatStateCopyWith(ChatState value, $Res Function(ChatState) _then) = _$ChatStateCopyWithImpl; +@useResult +$Res call({ + String currentToken, GetChatResponse? chatResponse, int? referenceMessageId +}); + + + + +} +/// @nodoc +class _$ChatStateCopyWithImpl<$Res> + implements $ChatStateCopyWith<$Res> { + _$ChatStateCopyWithImpl(this._self, this._then); + + final ChatState _self; + final $Res Function(ChatState) _then; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { + return _then(_self.copyWith( +currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable +as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable +as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ChatState]. +extension ChatStatePatterns on ChatState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ChatState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ChatState value) $default,){ +final _that = this; +switch (_that) { +case _ChatState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ChatState value)? $default,){ +final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId) $default,) {final _that = this; +switch (_that) { +case _ChatState(): +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String currentToken, GetChatResponse? chatResponse, int? referenceMessageId)? $default,) {final _that = this; +switch (_that) { +case _ChatState() when $default != null: +return $default(_that.currentToken,_that.chatResponse,_that.referenceMessageId);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ChatState implements ChatState { + const _ChatState({this.currentToken = '', this.chatResponse, this.referenceMessageId}); + factory _ChatState.fromJson(Map json) => _$ChatStateFromJson(json); + +@override@JsonKey() final String currentToken; +@override final GetChatResponse? chatResponse; +@override final int? referenceMessageId; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ChatStateCopyWith<_ChatState> get copyWith => __$ChatStateCopyWithImpl<_ChatState>(this, _$identity); + +@override +Map toJson() { + return _$ChatStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatState&&(identical(other.currentToken, currentToken) || other.currentToken == currentToken)&&(identical(other.chatResponse, chatResponse) || other.chatResponse == chatResponse)&&(identical(other.referenceMessageId, referenceMessageId) || other.referenceMessageId == referenceMessageId)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,currentToken,chatResponse,referenceMessageId); + +@override +String toString() { + return 'ChatState(currentToken: $currentToken, chatResponse: $chatResponse, referenceMessageId: $referenceMessageId)'; +} + + +} + +/// @nodoc +abstract mixin class _$ChatStateCopyWith<$Res> implements $ChatStateCopyWith<$Res> { + factory _$ChatStateCopyWith(_ChatState value, $Res Function(_ChatState) _then) = __$ChatStateCopyWithImpl; +@override @useResult +$Res call({ + String currentToken, GetChatResponse? chatResponse, int? referenceMessageId +}); + + + + +} +/// @nodoc +class __$ChatStateCopyWithImpl<$Res> + implements _$ChatStateCopyWith<$Res> { + __$ChatStateCopyWithImpl(this._self, this._then); + + final _ChatState _self; + final $Res Function(_ChatState) _then; + +/// Create a copy of ChatState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? currentToken = null,Object? chatResponse = freezed,Object? referenceMessageId = freezed,}) { + return _then(_ChatState( +currentToken: null == currentToken ? _self.currentToken : currentToken // ignore: cast_nullable_to_non_nullable +as String,chatResponse: freezed == chatResponse ? _self.chatResponse : chatResponse // ignore: cast_nullable_to_non_nullable +as GetChatResponse?,referenceMessageId: freezed == referenceMessageId ? _self.referenceMessageId : referenceMessageId // ignore: cast_nullable_to_non_nullable +as int?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/chat/bloc/chat_state.g.dart b/lib/state/app/modules/chat/bloc/chat_state.g.dart new file mode 100644 index 0000000..b685b00 --- /dev/null +++ b/lib/state/app/modules/chat/bloc/chat_state.g.dart @@ -0,0 +1,22 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ChatState _$ChatStateFromJson(Map json) => _ChatState( + currentToken: json['currentToken'] as String? ?? '', + chatResponse: json['chatResponse'] == null + ? null + : GetChatResponse.fromJson(json['chatResponse'] as Map), + referenceMessageId: (json['referenceMessageId'] as num?)?.toInt(), +); + +Map _$ChatStateToJson(_ChatState instance) => + { + 'currentToken': instance.currentToken, + 'chatResponse': instance.chatResponse, + 'referenceMessageId': instance.referenceMessageId, + }; 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 new file mode 100644 index 0000000..9be4252 --- /dev/null +++ b/lib/state/app/modules/chat/data_provider/chat_data_provider.dart @@ -0,0 +1,19 @@ +import '../../../../../api/marianumcloud/talk/chat/get_chat_cache.dart'; +import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; + +class ChatDataProvider { + Future getChat({ + required String token, + void Function(GetChatResponse data)? onCacheData, + void Function(GetChatResponse data)? onNetworkData, + void Function(Object)? onError, + }) async { + final cache = GetChatCache( + chatToken: token, + onCacheData: onCacheData, + onNetworkData: onNetworkData, + onError: (e) => onError?.call(e), + ); + await cache.ready; + } +} diff --git a/lib/state/app/modules/chat/repository/chat_repository.dart b/lib/state/app/modules/chat/repository/chat_repository.dart new file mode 100644 index 0000000..ba3edf8 --- /dev/null +++ b/lib/state/app/modules/chat/repository/chat_repository.dart @@ -0,0 +1,12 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/chat_state.dart'; +import '../data_provider/chat_data_provider.dart'; + +class ChatRepository extends Repository { + final ChatDataProvider _provider; + + ChatRepository([ChatDataProvider? provider]) + : _provider = provider ?? ChatDataProvider(); + + ChatDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart new file mode 100644 index 0000000..d05895d --- /dev/null +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -0,0 +1,96 @@ +import 'dart:developer'; + +import 'package:flutter_app_badge/flutter_app_badge.dart'; + +import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../repository/chat_list_repository.dart'; +import 'chat_list_event.dart'; +import 'chat_list_state.dart'; + +class ChatListBloc + extends + LoadableHydratedBloc { + bool _forceRenew = false; + + @override + void retry() { + _forceRenew = true; + super.retry(); + } + + @override + ChatListRepository repository() => ChatListRepository(); + + @override + ChatListState fromNothing() => const ChatListState(); + + @override + ChatListState fromStorage(Map json) => + ChatListState.fromJson(json); + + @override + Map? toStorage(ChatListState state) => state.toJson(); + + @override + Future gatherData() async { + final renew = _forceRenew; + _forceRenew = false; + + Object? capturedError; + final rooms = await repo.data.getRooms( + renew: renew, + onError: (e) => capturedError = e, + ); + add(DataGathered((s) => s.copyWith(rooms: rooms))); + _updateAppBadge(rooms); + + if (capturedError != null) throw capturedError!; + } + + Future refresh({bool renew = true}) async { + add(RefetchStarted()); + Object? capturedError; + try { + final rooms = await repo.data.getRooms( + renew: renew, + onError: (e) => capturedError = e, + ); + add(DataGathered((s) => s.copyWith(rooms: rooms))); + _updateAppBadge(rooms); + } catch (e) { + capturedError = e; + } + if (capturedError != null) { + add( + Error( + LoadingError( + message: errorToUserMessage(capturedError), + technicalDetails: errorToTechnicalDetails(capturedError), + allowRetry: errorAllowsRetry(capturedError), + ), + ), + ); + } + } + + Future createDirectChat(String invite) async { + await repo.data.createDirectRoom(invite); + await refresh(); + } + + void _updateAppBadge(GetRoomResponse rooms) { + try { + final unread = rooms.data.fold( + 0, + (a, room) => a + room.unreadMessages, + ); + FlutterAppBadge.count(unread); + } on Object catch (e) { + log('Failed to update app badge: $e'); + } + } +} diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_event.dart b/lib/state/app/modules/chat_list/bloc/chat_list_event.dart new file mode 100644 index 0000000..302bb02 --- /dev/null +++ b/lib/state/app/modules/chat_list/bloc/chat_list_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import 'chat_list_state.dart'; + +sealed class ChatListEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_state.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart new file mode 100644 index 0000000..adcfc62 --- /dev/null +++ b/lib/state/app/modules/chat_list/bloc/chat_list_state.dart @@ -0,0 +1,14 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; + +part 'chat_list_state.freezed.dart'; +part 'chat_list_state.g.dart'; + +@freezed +abstract class ChatListState with _$ChatListState { + const factory ChatListState({GetRoomResponse? rooms}) = _ChatListState; + + factory ChatListState.fromJson(Map json) => + _$ChatListStateFromJson(json); +} diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_state.freezed.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.freezed.dart new file mode 100644 index 0000000..ff2714b --- /dev/null +++ b/lib/state/app/modules/chat_list/bloc/chat_list_state.freezed.dart @@ -0,0 +1,277 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'chat_list_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$ChatListState { + + GetRoomResponse? get rooms; +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$ChatListStateCopyWith get copyWith => _$ChatListStateCopyWithImpl(this as ChatListState, _$identity); + + /// Serializes this ChatListState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is ChatListState&&(identical(other.rooms, rooms) || other.rooms == rooms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rooms); + +@override +String toString() { + return 'ChatListState(rooms: $rooms)'; +} + + +} + +/// @nodoc +abstract mixin class $ChatListStateCopyWith<$Res> { + factory $ChatListStateCopyWith(ChatListState value, $Res Function(ChatListState) _then) = _$ChatListStateCopyWithImpl; +@useResult +$Res call({ + GetRoomResponse? rooms +}); + + + + +} +/// @nodoc +class _$ChatListStateCopyWithImpl<$Res> + implements $ChatListStateCopyWith<$Res> { + _$ChatListStateCopyWithImpl(this._self, this._then); + + final ChatListState _self; + final $Res Function(ChatListState) _then; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? rooms = freezed,}) { + return _then(_self.copyWith( +rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [ChatListState]. +extension ChatListStatePatterns on ChatListState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _ChatListState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _ChatListState value) $default,){ +final _that = this; +switch (_that) { +case _ChatListState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _ChatListState value)? $default,){ +final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( GetRoomResponse? rooms)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that.rooms);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( GetRoomResponse? rooms) $default,) {final _that = this; +switch (_that) { +case _ChatListState(): +return $default(_that.rooms);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( GetRoomResponse? rooms)? $default,) {final _that = this; +switch (_that) { +case _ChatListState() when $default != null: +return $default(_that.rooms);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _ChatListState implements ChatListState { + const _ChatListState({this.rooms}); + factory _ChatListState.fromJson(Map json) => _$ChatListStateFromJson(json); + +@override final GetRoomResponse? rooms; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$ChatListStateCopyWith<_ChatListState> get copyWith => __$ChatListStateCopyWithImpl<_ChatListState>(this, _$identity); + +@override +Map toJson() { + return _$ChatListStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _ChatListState&&(identical(other.rooms, rooms) || other.rooms == rooms)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,rooms); + +@override +String toString() { + return 'ChatListState(rooms: $rooms)'; +} + + +} + +/// @nodoc +abstract mixin class _$ChatListStateCopyWith<$Res> implements $ChatListStateCopyWith<$Res> { + factory _$ChatListStateCopyWith(_ChatListState value, $Res Function(_ChatListState) _then) = __$ChatListStateCopyWithImpl; +@override @useResult +$Res call({ + GetRoomResponse? rooms +}); + + + + +} +/// @nodoc +class __$ChatListStateCopyWithImpl<$Res> + implements _$ChatListStateCopyWith<$Res> { + __$ChatListStateCopyWithImpl(this._self, this._then); + + final _ChatListState _self; + final $Res Function(_ChatListState) _then; + +/// Create a copy of ChatListState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? rooms = freezed,}) { + return _then(_ChatListState( +rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_state.g.dart b/lib/state/app/modules/chat_list/bloc/chat_list_state.g.dart new file mode 100644 index 0000000..1a28f8c --- /dev/null +++ b/lib/state/app/modules/chat_list/bloc/chat_list_state.g.dart @@ -0,0 +1,17 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'chat_list_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_ChatListState _$ChatListStateFromJson(Map json) => + _ChatListState( + rooms: json['rooms'] == null + ? null + : GetRoomResponse.fromJson(json['rooms'] as Map), + ); + +Map _$ChatListStateToJson(_ChatListState instance) => + {'rooms': instance.rooms}; 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 new file mode 100644 index 0000000..8786baa --- /dev/null +++ b/lib/state/app/modules/chat_list/data_provider/chat_list_data_provider.dart @@ -0,0 +1,20 @@ +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'; +import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../../api/request_cache.dart'; + +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', + ); + + Future createDirectRoom(String invite) => + CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run(); +} diff --git a/lib/state/app/modules/chat_list/repository/chat_list_repository.dart b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart new file mode 100644 index 0000000..880d15f --- /dev/null +++ b/lib/state/app/modules/chat_list/repository/chat_list_repository.dart @@ -0,0 +1,12 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/chat_list_state.dart'; +import '../data_provider/chat_list_data_provider.dart'; + +class ChatListRepository extends Repository { + final ChatListDataProvider _provider; + + ChatListRepository([ChatListDataProvider? provider]) + : _provider = provider ?? ChatListDataProvider(); + + ChatListDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart new file mode 100644 index 0000000..27e5f5e --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -0,0 +1,94 @@ +import '../../../../../api/errors/error_mapper.dart'; +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import '../../../infrastructure/loadable_state/loading_error.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../repository/files_repository.dart'; +import 'files_event.dart'; +import 'files_state.dart'; + +class FilesBloc + extends LoadableHydratedBloc { + final List initialPath; + + FilesBloc({this.initialPath = const []}); + + @override + FilesRepository repository() => FilesRepository(); + + @override + FilesState fromNothing() => FilesState(currentPath: initialPath); + + @override + FilesState fromStorage(Map json) => + FilesState.fromJson(json); + + @override + Map? toStorage(FilesState state) => null; + + @override + Future gatherData() async { + final path = innerState?.currentPath ?? initialPath; + await _query(path); + } + + Future refresh() async { + add(RefetchStarted()); + final path = innerState?.currentPath ?? initialPath; + await _query(path); + } + + Future setPath(List path) async { + add(Emit((s) => s.copyWith(currentPath: path, listing: null))); + add(RefetchStarted()); + await _query(path); + } + + Future createFolder(String name) async { + final path = innerState?.currentPath ?? initialPath; + await repo.data.createFolder('${path.join('/')}/$name'); + await refresh(); + } + + Future _query(List path) async { + final pathString = path.isEmpty ? '/' : path.join('/'); + + Object? capturedError; + ListFilesResponse? listing; + try { + listing = await repo.data.listFiles( + pathString, + onCacheData: (cached) { + // Cached payload arrives before the network call settles. Surface it + // immediately via Emit so the listing is visible while isLoading + // stays true and the top loading bar keeps spinning. + cached.files.removeWhere( + (file) => file.name.isEmpty || file.name == path.lastOrNull, + ); + add(Emit((s) => s.copyWith(listing: cached))); + }, + onError: (e) => capturedError = e, + ); + } catch (e) { + capturedError = e; + } + + if (listing != null) { + listing.files.removeWhere( + (file) => file.name.isEmpty || file.name == path.lastOrNull, + ); + add(DataGathered((s) => s.copyWith(listing: listing))); + } + if (capturedError != null) { + add( + Error( + LoadingError( + message: errorToUserMessage(capturedError), + technicalDetails: errorToTechnicalDetails(capturedError), + allowRetry: errorAllowsRetry(capturedError), + ), + ), + ); + } + } +} diff --git a/lib/state/app/modules/files/bloc/files_event.dart b/lib/state/app/modules/files/bloc/files_event.dart new file mode 100644 index 0000000..2757b8b --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import 'files_state.dart'; + +sealed class FilesEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/files/bloc/files_state.dart b/lib/state/app/modules/files/bloc/files_state.dart new file mode 100644 index 0000000..de1920a --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.dart @@ -0,0 +1,17 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; + +part 'files_state.freezed.dart'; +part 'files_state.g.dart'; + +@freezed +abstract class FilesState with _$FilesState { + const factory FilesState({ + @Default([]) List currentPath, + ListFilesResponse? listing, + }) = _FilesState; + + factory FilesState.fromJson(Map json) => + _$FilesStateFromJson(json); +} diff --git a/lib/state/app/modules/files/bloc/files_state.freezed.dart b/lib/state/app/modules/files/bloc/files_state.freezed.dart new file mode 100644 index 0000000..03e46f9 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.freezed.dart @@ -0,0 +1,286 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'files_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$FilesState { + + List get currentPath; ListFilesResponse? get listing; +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$FilesStateCopyWith get copyWith => _$FilesStateCopyWithImpl(this as FilesState, _$identity); + + /// Serializes this FilesState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is FilesState&&const DeepCollectionEquality().equals(other.currentPath, currentPath)&&(identical(other.listing, listing) || other.listing == listing)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(currentPath),listing); + +@override +String toString() { + return 'FilesState(currentPath: $currentPath, listing: $listing)'; +} + + +} + +/// @nodoc +abstract mixin class $FilesStateCopyWith<$Res> { + factory $FilesStateCopyWith(FilesState value, $Res Function(FilesState) _then) = _$FilesStateCopyWithImpl; +@useResult +$Res call({ + List currentPath, ListFilesResponse? listing +}); + + + + +} +/// @nodoc +class _$FilesStateCopyWithImpl<$Res> + implements $FilesStateCopyWith<$Res> { + _$FilesStateCopyWithImpl(this._self, this._then); + + final FilesState _self; + final $Res Function(FilesState) _then; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? currentPath = null,Object? listing = freezed,}) { + return _then(_self.copyWith( +currentPath: null == currentPath ? _self.currentPath : currentPath // ignore: cast_nullable_to_non_nullable +as List,listing: freezed == listing ? _self.listing : listing // ignore: cast_nullable_to_non_nullable +as ListFilesResponse?, + )); +} + +} + + +/// Adds pattern-matching-related methods to [FilesState]. +extension FilesStatePatterns on FilesState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _FilesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _FilesState value) $default,){ +final _that = this; +switch (_that) { +case _FilesState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _FilesState value)? $default,){ +final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( List currentPath, ListFilesResponse? listing)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that.currentPath,_that.listing);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( List currentPath, ListFilesResponse? listing) $default,) {final _that = this; +switch (_that) { +case _FilesState(): +return $default(_that.currentPath,_that.listing);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( List currentPath, ListFilesResponse? listing)? $default,) {final _that = this; +switch (_that) { +case _FilesState() when $default != null: +return $default(_that.currentPath,_that.listing);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _FilesState implements FilesState { + const _FilesState({final List currentPath = const [], this.listing}): _currentPath = currentPath; + factory _FilesState.fromJson(Map json) => _$FilesStateFromJson(json); + + final List _currentPath; +@override@JsonKey() List get currentPath { + if (_currentPath is EqualUnmodifiableListView) return _currentPath; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_currentPath); +} + +@override final ListFilesResponse? listing; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$FilesStateCopyWith<_FilesState> get copyWith => __$FilesStateCopyWithImpl<_FilesState>(this, _$identity); + +@override +Map toJson() { + return _$FilesStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _FilesState&&const DeepCollectionEquality().equals(other._currentPath, _currentPath)&&(identical(other.listing, listing) || other.listing == listing)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_currentPath),listing); + +@override +String toString() { + return 'FilesState(currentPath: $currentPath, listing: $listing)'; +} + + +} + +/// @nodoc +abstract mixin class _$FilesStateCopyWith<$Res> implements $FilesStateCopyWith<$Res> { + factory _$FilesStateCopyWith(_FilesState value, $Res Function(_FilesState) _then) = __$FilesStateCopyWithImpl; +@override @useResult +$Res call({ + List currentPath, ListFilesResponse? listing +}); + + + + +} +/// @nodoc +class __$FilesStateCopyWithImpl<$Res> + implements _$FilesStateCopyWith<$Res> { + __$FilesStateCopyWithImpl(this._self, this._then); + + final _FilesState _self; + final $Res Function(_FilesState) _then; + +/// Create a copy of FilesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? currentPath = null,Object? listing = freezed,}) { + return _then(_FilesState( +currentPath: null == currentPath ? _self._currentPath : currentPath // ignore: cast_nullable_to_non_nullable +as List,listing: freezed == listing ? _self.listing : listing // ignore: cast_nullable_to_non_nullable +as ListFilesResponse?, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/files/bloc/files_state.g.dart b/lib/state/app/modules/files/bloc/files_state.g.dart new file mode 100644 index 0000000..7d5d345 --- /dev/null +++ b/lib/state/app/modules/files/bloc/files_state.g.dart @@ -0,0 +1,24 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'files_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_FilesState _$FilesStateFromJson(Map json) => _FilesState( + currentPath: + (json['currentPath'] as List?) + ?.map((e) => e as String) + .toList() ?? + const [], + listing: json['listing'] == null + ? null + : ListFilesResponse.fromJson(json['listing'] as Map), +); + +Map _$FilesStateToJson(_FilesState instance) => + { + 'currentPath': instance.currentPath, + 'listing': instance.listing, + }; 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 new file mode 100644 index 0000000..e721fbb --- /dev/null +++ b/lib/state/app/modules/files/data_provider/files_data_provider.dart @@ -0,0 +1,33 @@ +import 'package:nextcloud/nextcloud.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'; +import '../../../../../api/request_cache.dart'; + +class FilesDataProvider { + /// Lists files at [path]. Cached payload is delivered via [onCacheData] as + /// soon as it is read from disk, so callers can render stale data while the + /// network call is still pending. The Future itself resolves once both the + /// cache lookup and the network attempt have settled, throwing if no payload + /// could be obtained at all. + Future listFiles( + String path, { + void Function(ListFilesResponse)? onCacheData, + void Function(Object)? onError, + }) => resolveFromCache( + (onUpdate, onError) => ListFilesCache( + path: path, + onUpdate: onUpdate, + onCacheData: onCacheData, + onError: onError, + ), + onError: onError, + operationName: 'listFiles', + ); + + Future createFolder(String fullPath) async { + final webdav = await WebdavApi.webdav; + await webdav.mkcol(PathUri.parse(fullPath)); + } +} diff --git a/lib/state/app/modules/files/repository/files_repository.dart b/lib/state/app/modules/files/repository/files_repository.dart new file mode 100644 index 0000000..35f316c --- /dev/null +++ b/lib/state/app/modules/files/repository/files_repository.dart @@ -0,0 +1,12 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/files_state.dart'; +import '../data_provider/files_data_provider.dart'; + +class FilesRepository extends Repository { + final FilesDataProvider _provider; + + FilesRepository([FilesDataProvider? provider]) + : _provider = provider ?? FilesDataProvider(); + + FilesDataProvider get data => _provider; +} diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart b/lib/state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart deleted file mode 100644 index 6a084ca..0000000 --- a/lib/state/app/modules/gradeAverages/bloc/grade_averages_bloc.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:hydrated_bloc/hydrated_bloc.dart'; - -import 'grade_averages_event.dart'; -import 'grade_averages_state.dart'; - -class GradeAveragesBloc extends HydratedBloc { - GradeAveragesBloc() : super(const GradeAveragesState(gradingSystem: GradeAveragesGradingSystem.middleSchool, grades: [])) { - - on((event, emit) { - add(ResetAll()); - emit( - state.copyWith( - gradingSystem: event.isMiddleSchool - ? GradeAveragesGradingSystem.middleSchool - : GradeAveragesGradingSystem.highSchool - ) - ); - }); - - on((event, emit) { - emit(state.copyWith(grades: [])); - }); - - on((event, emit) { - emit(state.copyWith(grades: [...state.grades]..removeWhere((grade) => grade == event.grade))); - }); - - on((event, emit) { - emit(state.copyWith(grades: [...state.grades, event.grade])); - }); - - on((event, emit) { - emit(state.copyWith(grades: List.from(state.grades)..remove(event.grade))); - }); - - } - - double average() => state.grades.isEmpty ? 0 : state.grades.reduce((a, b) => a + b) / state.grades.length; - bool isMiddleSchool() => state.gradingSystem == GradeAveragesGradingSystem.middleSchool; - bool canDecrementOrDelete(int grade) => state.grades.contains(grade); - int countOfGrade(int grade) => state.grades.where((g) => g == grade).length; - int gradesInGradingSystem() => state.gradingSystem == GradeAveragesGradingSystem.middleSchool ? 6 : 16; - int getGradeFromIndex(int index) => isMiddleSchool() ? index + 1 : 15 - index; - - @override - GradeAveragesState? fromJson(Map json) => GradeAveragesState.fromJson(json); - @override - Map? toJson(GradeAveragesState state) => state.toJson(); -} diff --git a/lib/state/app/modules/gradeAverages/view/grade_averages_view.dart b/lib/state/app/modules/gradeAverages/view/grade_averages_view.dart deleted file mode 100644 index 03d396a..0000000 --- a/lib/state/app/modules/gradeAverages/view/grade_averages_view.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -import '../../../../../widget/confirmDialog.dart'; -import '../bloc/grade_averages_bloc.dart'; -import '../bloc/grade_averages_event.dart'; -import '../bloc/grade_averages_state.dart'; -import 'grade_averages_list_view.dart'; - -class GradeAveragesView extends StatelessWidget { - const GradeAveragesView({super.key}); - - @override - Widget build(BuildContext context) => BlocProvider( - create: (context) => GradeAveragesBloc(), - child: BlocBuilder( - builder: (context, state) { - var bloc = context.watch(); - - return Scaffold( - appBar: AppBar( - title: const Text('Notendurschnittsrechner'), - actions: [ - Visibility( - visible: bloc.state.grades.isNotEmpty, - child: IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Zurücksetzen?', - content: 'Alle Einträge werden entfernt.', - confirmButton: 'Zurücksetzen', - onConfirm: () { - bloc.add(ResetAll()); - }, - ), - ); - }, - icon: const Icon(Icons.delete_forever)), - ), - PopupMenuButton( - initialValue: bloc.isMiddleSchool(), - icon: const Icon(Icons.more_horiz), - itemBuilder: (context) => [true, false].map((isMiddleSchool) => PopupMenuItem( - value: isMiddleSchool, - child: Row( - children: [ - Icon( - isMiddleSchool ? Icons.calculate_outlined : Icons.school_outlined, - color: Theme.of(context).colorScheme.onSurface - ), - const SizedBox(width: 15), - Text(isMiddleSchool ? 'Realschule' : 'Oberstufe'), - ], - ), - )).toList(), - onSelected: (isMiddleSchool) { - if (bloc.state.grades.isNotEmpty) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Notensystem wechseln', - content: - 'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.', - confirmButton: 'Fortfahren', - onConfirm: () => bloc.add(GradingSystemChanged(isMiddleSchool)), - ), - ); - } else { - bloc.add(GradingSystemChanged(isMiddleSchool)); - } - }, - ), - ], - ), - - body: Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const SizedBox(height: 30), - Row( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Ø', style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold)), - SizedBox(width: 5), - Text(bloc.average().toStringAsFixed(2), style: const TextStyle(fontSize: 30, fontWeight: FontWeight.bold)) - ], - ), - const SizedBox(height: 10), - const Divider(), - const SizedBox(height: 10), - Text(bloc.isMiddleSchool() ? 'Wähle die Anzahl deiner jeweiligen Noten aus' : 'Wähle die Anzahl deiner jeweiligen Punkte aus'), - const SizedBox(height: 10), - const Expanded( - child: GradeAveragesListView() - ), - ], - ), - ); - }, - ), - ); -} 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 new file mode 100644 index 0000000..2d3b89b --- /dev/null +++ b/lib/state/app/modules/grade_averages/bloc/grade_averages_bloc.dart @@ -0,0 +1,66 @@ +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import 'grade_averages_event.dart'; +import 'grade_averages_state.dart'; + +class GradeAveragesBloc + extends HydratedBloc { + GradeAveragesBloc() + : super( + const GradeAveragesState( + gradingSystem: GradeAveragesGradingSystem.middleSchool, + grades: [], + ), + ) { + on((event, emit) { + add(ResetAll()); + emit( + state.copyWith( + gradingSystem: event.isMiddleSchool + ? GradeAveragesGradingSystem.middleSchool + : GradeAveragesGradingSystem.highSchool, + ), + ); + }); + + on((event, emit) { + emit(state.copyWith(grades: [])); + }); + + on((event, emit) { + emit( + state.copyWith( + grades: [...state.grades] + ..removeWhere((grade) => grade == event.grade), + ), + ); + }); + + on((event, emit) { + emit(state.copyWith(grades: [...state.grades, event.grade])); + }); + + on((event, emit) { + emit( + state.copyWith(grades: List.from(state.grades)..remove(event.grade)), + ); + }); + } + + double average() => state.grades.isEmpty + ? 0 + : state.grades.reduce((a, b) => a + b) / state.grades.length; + bool isMiddleSchool() => + state.gradingSystem == GradeAveragesGradingSystem.middleSchool; + bool canDecrementOrDelete(int grade) => state.grades.contains(grade); + int countOfGrade(int grade) => state.grades.where((g) => g == grade).length; + int gradesInGradingSystem() => + state.gradingSystem == GradeAveragesGradingSystem.middleSchool ? 6 : 16; + int getGradeFromIndex(int index) => isMiddleSchool() ? index + 1 : 15 - index; + + @override + GradeAveragesState? fromJson(Map json) => + GradeAveragesState.fromJson(json); + @override + Map? toJson(GradeAveragesState state) => state.toJson(); +} diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_event.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart similarity index 99% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_event.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart index 0be46eb..cbf3ba4 100644 --- a/lib/state/app/modules/gradeAverages/bloc/grade_averages_event.dart +++ b/lib/state/app/modules/grade_averages/bloc/grade_averages_event.dart @@ -1,19 +1,22 @@ - sealed class GradeAveragesEvent {} final class GradingSystemChanged extends GradeAveragesEvent { final bool isMiddleSchool; GradingSystemChanged(this.isMiddleSchool); } + final class ResetAll extends GradeAveragesEvent {} + final class ResetGrade extends GradeAveragesEvent { final int grade; ResetGrade(this.grade); } + final class IncrementGrade extends GradeAveragesEvent { final int grade; IncrementGrade(this.grade); } + final class DecrementGrade extends GradeAveragesEvent { final int grade; DecrementGrade(this.grade); diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart similarity index 80% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart index 44e7f8f..6abb004 100644 --- a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.dart +++ b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.dart @@ -10,10 +10,8 @@ abstract class GradeAveragesState with _$GradeAveragesState { required List grades, }) = _GradeAveragesState; - factory GradeAveragesState.fromJson(Map json) => _$GradeAveragesStateFromJson(json); + factory GradeAveragesState.fromJson(Map json) => + _$GradeAveragesStateFromJson(json); } -enum GradeAveragesGradingSystem { - highSchool, - middleSchool, -} +enum GradeAveragesGradingSystem { highSchool, middleSchool } diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.freezed.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.freezed.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.freezed.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.freezed.dart diff --git a/lib/state/app/modules/gradeAverages/bloc/grade_averages_state.g.dart b/lib/state/app/modules/grade_averages/bloc/grade_averages_state.g.dart similarity index 100% rename from lib/state/app/modules/gradeAverages/bloc/grade_averages_state.g.dart rename to lib/state/app/modules/grade_averages/bloc/grade_averages_state.g.dart diff --git a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart index 3f82d04..bf2de75 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_bloc.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_bloc.dart @@ -1,37 +1,56 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/holidays_repository.dart'; import 'holidays_event.dart'; import 'holidays_state.dart'; -class HolidaysBloc extends LoadableHydratedBloc { +class HolidaysBloc + extends + LoadableHydratedBloc { HolidaysBloc() { on((event, emit) { - add(Emit((state) => state.copyWith(showPastHolidays: event.shouldBeVisible))); + add( + Emit( + (state) => state.copyWith(showPastHolidays: event.shouldBeVisible), + ), + ); }); - on((event, emit) => add( - Emit((state) => state.copyWith(showDisclaimer: false)) - )); + on( + (event, emit) => + add(Emit((state) => state.copyWith(showDisclaimer: false))), + ); } bool showPastHolidays() => innerState?.showPastHolidays ?? false; bool showDisclaimerOnEntry() => innerState?.showDisclaimer ?? false; - List? getHolidays() => innerState?.holidays - .where((element) => showPastHolidays() || DateTime.parse(element.end).isAfter(DateTime.now())) - .toList() ?? []; + List? getHolidays() => + innerState?.holidays + .where( + (element) => + showPastHolidays() || + DateTime.parse(element.end).isAfter(DateTime.now()), + ) + .toList() ?? + []; @override - fromNothing() => const HolidaysState(showPastHolidays: false, holidays: [], showDisclaimer: true); + HolidaysState fromNothing() => const HolidaysState( + showPastHolidays: false, + holidays: [], + showDisclaimer: true, + ); @override - fromStorage(Map json) => HolidaysState.fromJson(json); + HolidaysState fromStorage(Map json) => + HolidaysState.fromJson(json); @override Future gatherData() async { var holidays = await repo.getHolidays(); add(DataGathered((state) => state.copyWith(holidays: holidays))); } + @override - repository() => HolidaysRepository(); + HolidaysRepository repository() => HolidaysRepository(); @override Map? toStorage(state) => state.toJson(); } diff --git a/lib/state/app/modules/holidays/bloc/holidays_event.dart b/lib/state/app/modules/holidays/bloc/holidays_event.dart index 8565250..99d2574 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_event.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_event.dart @@ -1,9 +1,11 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import 'holidays_state.dart'; sealed class HolidaysEvent extends LoadableHydratedBlocEvent {} + class SetPastHolidaysVisible extends HolidaysEvent { final bool shouldBeVisible; SetPastHolidaysVisible(this.shouldBeVisible); } + class DisclaimerDismissed extends HolidaysEvent {} diff --git a/lib/state/app/modules/holidays/bloc/holidays_state.dart b/lib/state/app/modules/holidays/bloc/holidays_state.dart index 1a7eef0..d2755e4 100644 --- a/lib/state/app/modules/holidays/bloc/holidays_state.dart +++ b/lib/state/app/modules/holidays/bloc/holidays_state.dart @@ -1,5 +1,5 @@ -import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; part 'holidays_state.freezed.dart'; part 'holidays_state.g.dart'; @@ -12,7 +12,8 @@ abstract class HolidaysState with _$HolidaysState { required List holidays, }) = _HolidaysState; - factory HolidaysState.fromJson(Map json) => _$HolidaysStateFromJson(json); + factory HolidaysState.fromJson(Map json) => + _$HolidaysStateFromJson(json); } @freezed @@ -26,5 +27,6 @@ abstract class Holiday with _$Holiday { required String slug, }) = _Holiday; - factory Holiday.fromJson(Map json) => _$HolidayFromJson(json); + factory Holiday.fromJson(Map json) => + _$HolidayFromJson(json); } diff --git a/lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart similarity index 62% rename from lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart rename to lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart index cd52128..79c9c60 100644 --- a/lib/state/app/modules/holidays/dataProvider/holidays_get_holidays.dart +++ b/lib/state/app/modules/holidays/data_provider/holidays_get_holidays.dart @@ -1,12 +1,13 @@ import 'package:dio/dio.dart'; import '../../../basis/dataloader/holiday_data_loader.dart'; -import '../../../infrastructure/dataLoader/data_loader.dart'; +import '../../../infrastructure/data_loader/data_loader.dart'; import '../bloc/holidays_state.dart'; class HolidaysGetHolidays extends HolidayDataLoader> { @override - List assemble(DataLoaderResult data) => data.asListOfMaps().map(Holiday.fromJson).toList(); + List assemble(DataLoaderResult data) => + data.asListOfMaps().map(Holiday.fromJson).toList(); @override Future> fetch() => dio.get('/holidays/HE'); diff --git a/lib/state/app/modules/holidays/repository/holidays_repository.dart b/lib/state/app/modules/holidays/repository/holidays_repository.dart index 72ec949..7e964c6 100644 --- a/lib/state/app/modules/holidays/repository/holidays_repository.dart +++ b/lib/state/app/modules/holidays/repository/holidays_repository.dart @@ -1,6 +1,6 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/holidays_state.dart'; -import '../dataProvider/holidays_get_holidays.dart'; +import '../data_provider/holidays_get_holidays.dart'; class HolidaysRepository extends Repository { Future> getHolidays() => HolidaysGetHolidays().run(); diff --git a/lib/state/app/modules/holidays/view/holidays_view.dart b/lib/state/app/modules/holidays/view/holidays_view.dart deleted file mode 100644 index 41be1f3..0000000 --- a/lib/state/app/modules/holidays/view/holidays_view.dart +++ /dev/null @@ -1,119 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; - -import '../../../../../widget/animatedTime.dart'; -import '../../../../../widget/list_view_util.dart'; -import '../../../../../widget/centeredLeading.dart'; -import '../../../../../widget/debug/debugTile.dart'; -import '../../../../../widget/string_extensions.dart'; -import '../../../infrastructure/loadableState/loadable_state.dart'; -import '../../../infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../infrastructure/utilityWidgets/bloc_module.dart'; -import '../bloc/holidays_bloc.dart'; -import '../bloc/holidays_event.dart'; -import '../bloc/holidays_state.dart'; - -class HolidaysView extends StatelessWidget { - const HolidaysView({super.key}); - - @override - Widget build(BuildContext context) => BlocModule>( - create: (context) => HolidaysBloc(), - autoRebuild: true, - child: (context, bloc, state) { - void showDisclaimer() { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Richtigkeit und Bereitstellung der Daten'), - content: const Text('' - 'Sämtliche Datumsangaben sind ohne Gewähr.\n' - 'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n' - 'Die Daten stammen von https://ferien-api.de/'), - actions: [ - TextButton(child: const Text('Okay'), onPressed: () => Navigator.of(context).pop()), - ], - )); - } - - return Scaffold( - appBar: AppBar( - title: const Text('Schulferien in Hessen'), - actions: [ - IconButton( - icon: const Icon(Icons.info_outline), - onPressed: showDisclaimer, - ), - PopupMenuButton( - initialValue: bloc.showPastHolidays(), - icon: const Icon(Icons.history), - itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( - value: e, - enabled: e != bloc.showPastHolidays(), - child: Row( - children: [ - Icon(e ? Icons.history_outlined : Icons.history_toggle_off_outlined, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen') - ], - ) - )).toList(), - onSelected: (e) => bloc.add(SetPastHolidaysVisible(e)), - ), - ], - ), - body: LoadableStateConsumer( - onLoad: (state) { - if(state.showDisclaimer) showDisclaimer(); - bloc.add(DisclaimerDismissed()); - }, - child: (state, loading) => ListViewUtil.fromList(bloc.getHolidays(), (holiday) { - var holidayType = holiday.name.split(' ').first.capitalize(); - String formatDate(String date) => Jiffy.parse(date).format(pattern: 'dd.MM.yyyy'); - String getYear(String date, {String format = 'yyyy'}) => Jiffy.parse(date).format(pattern: format); - - String getHolidayYear(String startDate, String endDate) => getYear(startDate) == getYear(endDate) - ? getYear(startDate) - : '${getYear(startDate)}/${getYear(endDate, format: 'yy')}'; - - return ListTile( - leading: const CenteredLeading(Icon(Icons.calendar_month)), - title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'), - subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'), - onTap: () => showDialog(context: context, builder: (context) => SimpleDialog( - title: Text('$holidayType ${holiday.year} in Hessen'), - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.signpost_outlined)), - title: Text(holiday.name.capitalize()), - subtitle: Text(holiday.slug.capitalize()), - ), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text('vom ${formatDate(holiday.start)}'), - ), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text('bis zum ${formatDate(holiday.end)}'), - ), - Visibility( - visible: !DateTime.parse(holiday.start).difference(DateTime.now()).isNegative, - replacement: ListTile( - leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)), - title: Text(Jiffy.parse(holiday.start).fromNow()), - ), - child: ListTile( - leading: const CenteredLeading(Icon(Icons.timer_outlined)), - title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())), - subtitle: Text(Jiffy.parse(holiday.start).fromNow()), - ), - ), - DebugTile(context).jsonData(holiday.toJson()), - ], - )), - trailing: const Icon(Icons.arrow_right), - ); - }), - ), - ); - }, - ); -} diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart b/lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart deleted file mode 100644 index 43cbf2a..0000000 --- a/lib/state/app/modules/marianumMessage/bloc/marianum_message_event.dart +++ /dev/null @@ -1,5 +0,0 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; -import 'marianum_message_state.dart'; - -sealed class MarianumMessageEvent extends LoadableHydratedBlocEvent {} -class MessageEvent extends MarianumMessageEvent {} diff --git a/lib/state/app/modules/marianumMessage/view/marianum_message_list_view.dart b/lib/state/app/modules/marianumMessage/view/marianum_message_list_view.dart deleted file mode 100644 index 1957886..0000000 --- a/lib/state/app/modules/marianumMessage/view/marianum_message_list_view.dart +++ /dev/null @@ -1,42 +0,0 @@ -import 'package:flutter/material.dart'; - -import 'marianum_message_view.dart'; -import '../../../infrastructure/loadableState/loadable_state.dart'; -import '../../../infrastructure/loadableState/view/loadable_state_consumer.dart'; -import '../../../infrastructure/utilityWidgets/bloc_module.dart'; -import '../bloc/marianum_message_bloc.dart'; -import '../bloc/marianum_message_state.dart'; - -class MarianumMessageListView extends StatelessWidget { - const MarianumMessageListView({super.key}); - - @override - Widget build(BuildContext context) => BlocModule>( - create: (context) => MarianumMessageBloc(), - child: (context, bloc, state) => Scaffold( - appBar: AppBar( - title: const Text('Marianum Message'), - ), - body: LoadableStateConsumer( - child: (state, loading) => ListView.builder( - itemCount: state.messageList.messages.length, - itemBuilder: (context, index) { - var message = state.messageList.messages.toList()[index]; - return ListTile( - leading: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.newspaper)], - ), - title: Text(message.name, overflow: TextOverflow.ellipsis), - subtitle: Text('vom ${message.date}'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => MessageView(basePath: state.messageList.base, message: message))); - }, - ); - } - ), - ), - ) - ); -} diff --git a/lib/state/app/modules/marianumMessage/view/marianum_message_view.dart b/lib/state/app/modules/marianumMessage/view/marianum_message_view.dart deleted file mode 100644 index ae8fd36..0000000 --- a/lib/state/app/modules/marianumMessage/view/marianum_message_view.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; -import 'package:url_launcher/url_launcher.dart'; - -import '../bloc/marianum_message_state.dart'; -import '../../../../../widget/confirmDialog.dart'; - -class MessageView extends StatefulWidget { - final String basePath; - final MarianumMessage message; - const MessageView({super.key, required this.basePath, required this.message}); - - @override - State createState() => _MessageViewState(); -} - -class _MessageViewState extends State { - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text(widget.message.name), - ), - body: SfPdfViewer.network( - widget.basePath + widget.message.url, - enableHyperlinkNavigation: true, - onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) { - Navigator.of(context).pop(); - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Fehler beim öffnen'), - content: Text("Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}"), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Ok')) - ], - )); - }, - onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Link öffnen', - content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}', - confirmButton: 'Öffnen', - onConfirm: () => launchUrl(Uri.parse(e.uri), mode: LaunchMode.externalApplication), - ), - ); - }, - ), - ); -} diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart new file mode 100644 index 0000000..a0ebeed --- /dev/null +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart @@ -0,0 +1,46 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../repository/marianum_dates_repository.dart'; +import 'marianum_dates_event.dart'; +import 'marianum_dates_state.dart'; + +class MarianumDatesBloc + extends + LoadableHydratedBloc< + MarianumDatesEvent, + MarianumDatesState, + MarianumDatesRepository + > { + MarianumDatesBloc() { + on((event, emit) { + add( + Emit((state) => state.copyWith(showPastEvents: event.shouldBeVisible)), + ); + }); + } + + bool showPastEvents() => innerState?.showPastEvents ?? false; + + List? getEvents() => + innerState?.events + .where((e) => showPastEvents() || e.end.isAfter(DateTime.now())) + .toList() ?? + []; + + @override + MarianumDatesState fromNothing() => + const MarianumDatesState(showPastEvents: false, events: []); + @override + MarianumDatesState fromStorage(Map json) => + MarianumDatesState.fromJson(json); + @override + Future gatherData() async { + final events = await repo.getEvents(); + add(DataGathered((state) => state.copyWith(events: events))); + } + + @override + MarianumDatesRepository repository() => MarianumDatesRepository(); + @override + Map? toStorage(state) => state.toJson(); +} diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart new file mode 100644 index 0000000..b62b9f9 --- /dev/null +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_event.dart @@ -0,0 +1,10 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import 'marianum_dates_state.dart'; + +sealed class MarianumDatesEvent + extends LoadableHydratedBlocEvent {} + +class SetPastEventsVisible extends MarianumDatesEvent { + final bool shouldBeVisible; + SetPastEventsVisible(this.shouldBeVisible); +} diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart new file mode 100644 index 0000000..a45611e --- /dev/null +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart @@ -0,0 +1,31 @@ +import 'package:flutter/foundation.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'marianum_dates_state.freezed.dart'; +part 'marianum_dates_state.g.dart'; + +@freezed +abstract class MarianumDatesState with _$MarianumDatesState { + const factory MarianumDatesState({ + required bool showPastEvents, + required List events, + }) = _MarianumDatesState; + + factory MarianumDatesState.fromJson(Map json) => + _$MarianumDatesStateFromJson(json); +} + +@freezed +abstract class MarianumDate with _$MarianumDate { + const factory MarianumDate({ + required String uid, + required String title, + required String? description, + required DateTime start, + required DateTime end, + required bool isAllDay, + }) = _MarianumDate; + + factory MarianumDate.fromJson(Map json) => + _$MarianumDateFromJson(json); +} diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.freezed.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.freezed.dart new file mode 100644 index 0000000..7ef98c8 --- /dev/null +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.freezed.dart @@ -0,0 +1,588 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'marianum_dates_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$MarianumDatesState implements DiagnosticableTreeMixin { + + bool get showPastEvents; List get events; +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MarianumDatesStateCopyWith get copyWith => _$MarianumDatesStateCopyWithImpl(this as MarianumDatesState, _$identity); + + /// Serializes this MarianumDatesState to a JSON map. + Map toJson(); + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDatesState')) + ..add(DiagnosticsProperty('showPastEvents', showPastEvents))..add(DiagnosticsProperty('events', events)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MarianumDatesState&&(identical(other.showPastEvents, showPastEvents) || other.showPastEvents == showPastEvents)&&const DeepCollectionEquality().equals(other.events, events)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,showPastEvents,const DeepCollectionEquality().hash(events)); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDatesState(showPastEvents: $showPastEvents, events: $events)'; +} + + +} + +/// @nodoc +abstract mixin class $MarianumDatesStateCopyWith<$Res> { + factory $MarianumDatesStateCopyWith(MarianumDatesState value, $Res Function(MarianumDatesState) _then) = _$MarianumDatesStateCopyWithImpl; +@useResult +$Res call({ + bool showPastEvents, List events +}); + + + + +} +/// @nodoc +class _$MarianumDatesStateCopyWithImpl<$Res> + implements $MarianumDatesStateCopyWith<$Res> { + _$MarianumDatesStateCopyWithImpl(this._self, this._then); + + final MarianumDatesState _self; + final $Res Function(MarianumDatesState) _then; + +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? showPastEvents = null,Object? events = null,}) { + return _then(_self.copyWith( +showPastEvents: null == showPastEvents ? _self.showPastEvents : showPastEvents // ignore: cast_nullable_to_non_nullable +as bool,events: null == events ? _self.events : events // ignore: cast_nullable_to_non_nullable +as List, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MarianumDatesState]. +extension MarianumDatesStatePatterns on MarianumDatesState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MarianumDatesState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MarianumDatesState value) $default,){ +final _that = this; +switch (_that) { +case _MarianumDatesState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MarianumDatesState value)? $default,){ +final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( bool showPastEvents, List events)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that.showPastEvents,_that.events);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( bool showPastEvents, List events) $default,) {final _that = this; +switch (_that) { +case _MarianumDatesState(): +return $default(_that.showPastEvents,_that.events);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( bool showPastEvents, List events)? $default,) {final _that = this; +switch (_that) { +case _MarianumDatesState() when $default != null: +return $default(_that.showPastEvents,_that.events);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _MarianumDatesState with DiagnosticableTreeMixin implements MarianumDatesState { + const _MarianumDatesState({required this.showPastEvents, required final List events}): _events = events; + factory _MarianumDatesState.fromJson(Map json) => _$MarianumDatesStateFromJson(json); + +@override final bool showPastEvents; + final List _events; +@override List get events { + if (_events is EqualUnmodifiableListView) return _events; + // ignore: implicit_dynamic_type + return EqualUnmodifiableListView(_events); +} + + +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MarianumDatesStateCopyWith<_MarianumDatesState> get copyWith => __$MarianumDatesStateCopyWithImpl<_MarianumDatesState>(this, _$identity); + +@override +Map toJson() { + return _$MarianumDatesStateToJson(this, ); +} +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDatesState')) + ..add(DiagnosticsProperty('showPastEvents', showPastEvents))..add(DiagnosticsProperty('events', events)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MarianumDatesState&&(identical(other.showPastEvents, showPastEvents) || other.showPastEvents == showPastEvents)&&const DeepCollectionEquality().equals(other._events, _events)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,showPastEvents,const DeepCollectionEquality().hash(_events)); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDatesState(showPastEvents: $showPastEvents, events: $events)'; +} + + +} + +/// @nodoc +abstract mixin class _$MarianumDatesStateCopyWith<$Res> implements $MarianumDatesStateCopyWith<$Res> { + factory _$MarianumDatesStateCopyWith(_MarianumDatesState value, $Res Function(_MarianumDatesState) _then) = __$MarianumDatesStateCopyWithImpl; +@override @useResult +$Res call({ + bool showPastEvents, List events +}); + + + + +} +/// @nodoc +class __$MarianumDatesStateCopyWithImpl<$Res> + implements _$MarianumDatesStateCopyWith<$Res> { + __$MarianumDatesStateCopyWithImpl(this._self, this._then); + + final _MarianumDatesState _self; + final $Res Function(_MarianumDatesState) _then; + +/// Create a copy of MarianumDatesState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? showPastEvents = null,Object? events = null,}) { + return _then(_MarianumDatesState( +showPastEvents: null == showPastEvents ? _self.showPastEvents : showPastEvents // ignore: cast_nullable_to_non_nullable +as bool,events: null == events ? _self._events : events // ignore: cast_nullable_to_non_nullable +as List, + )); +} + + +} + + +/// @nodoc +mixin _$MarianumDate implements DiagnosticableTreeMixin { + + String get uid; String get title; String? get description; DateTime get start; DateTime get end; bool get isAllDay; +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$MarianumDateCopyWith get copyWith => _$MarianumDateCopyWithImpl(this as MarianumDate, _$identity); + + /// Serializes this MarianumDate to a JSON map. + Map toJson(); + +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDate')) + ..add(DiagnosticsProperty('uid', uid))..add(DiagnosticsProperty('title', title))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('start', start))..add(DiagnosticsProperty('end', end))..add(DiagnosticsProperty('isAllDay', isAllDay)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is MarianumDate&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.isAllDay, isAllDay) || other.isAllDay == isAllDay)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,title,description,start,end,isAllDay); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDate(uid: $uid, title: $title, description: $description, start: $start, end: $end, isAllDay: $isAllDay)'; +} + + +} + +/// @nodoc +abstract mixin class $MarianumDateCopyWith<$Res> { + factory $MarianumDateCopyWith(MarianumDate value, $Res Function(MarianumDate) _then) = _$MarianumDateCopyWithImpl; +@useResult +$Res call({ + String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay +}); + + + + +} +/// @nodoc +class _$MarianumDateCopyWithImpl<$Res> + implements $MarianumDateCopyWith<$Res> { + _$MarianumDateCopyWithImpl(this._self, this._then); + + final MarianumDate _self; + final $Res Function(MarianumDate) _then; + +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? uid = null,Object? title = null,Object? description = freezed,Object? start = null,Object? end = null,Object? isAllDay = null,}) { + return _then(_self.copyWith( +uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable +as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable +as DateTime,isAllDay: null == isAllDay ? _self.isAllDay : isAllDay // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + +} + + +/// Adds pattern-matching-related methods to [MarianumDate]. +extension MarianumDatePatterns on MarianumDate { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _MarianumDate value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _MarianumDate value) $default,){ +final _that = this; +switch (_that) { +case _MarianumDate(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _MarianumDate value)? $default,){ +final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that.uid,_that.title,_that.description,_that.start,_that.end,_that.isAllDay);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay) $default,) {final _that = this; +switch (_that) { +case _MarianumDate(): +return $default(_that.uid,_that.title,_that.description,_that.start,_that.end,_that.isAllDay);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay)? $default,) {final _that = this; +switch (_that) { +case _MarianumDate() when $default != null: +return $default(_that.uid,_that.title,_that.description,_that.start,_that.end,_that.isAllDay);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _MarianumDate with DiagnosticableTreeMixin implements MarianumDate { + const _MarianumDate({required this.uid, required this.title, required this.description, required this.start, required this.end, required this.isAllDay}); + factory _MarianumDate.fromJson(Map json) => _$MarianumDateFromJson(json); + +@override final String uid; +@override final String title; +@override final String? description; +@override final DateTime start; +@override final DateTime end; +@override final bool isAllDay; + +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$MarianumDateCopyWith<_MarianumDate> get copyWith => __$MarianumDateCopyWithImpl<_MarianumDate>(this, _$identity); + +@override +Map toJson() { + return _$MarianumDateToJson(this, ); +} +@override +void debugFillProperties(DiagnosticPropertiesBuilder properties) { + properties + ..add(DiagnosticsProperty('type', 'MarianumDate')) + ..add(DiagnosticsProperty('uid', uid))..add(DiagnosticsProperty('title', title))..add(DiagnosticsProperty('description', description))..add(DiagnosticsProperty('start', start))..add(DiagnosticsProperty('end', end))..add(DiagnosticsProperty('isAllDay', isAllDay)); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _MarianumDate&&(identical(other.uid, uid) || other.uid == uid)&&(identical(other.title, title) || other.title == title)&&(identical(other.description, description) || other.description == description)&&(identical(other.start, start) || other.start == start)&&(identical(other.end, end) || other.end == end)&&(identical(other.isAllDay, isAllDay) || other.isAllDay == isAllDay)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,uid,title,description,start,end,isAllDay); + +@override +String toString({ DiagnosticLevel minLevel = DiagnosticLevel.info }) { + return 'MarianumDate(uid: $uid, title: $title, description: $description, start: $start, end: $end, isAllDay: $isAllDay)'; +} + + +} + +/// @nodoc +abstract mixin class _$MarianumDateCopyWith<$Res> implements $MarianumDateCopyWith<$Res> { + factory _$MarianumDateCopyWith(_MarianumDate value, $Res Function(_MarianumDate) _then) = __$MarianumDateCopyWithImpl; +@override @useResult +$Res call({ + String uid, String title, String? description, DateTime start, DateTime end, bool isAllDay +}); + + + + +} +/// @nodoc +class __$MarianumDateCopyWithImpl<$Res> + implements _$MarianumDateCopyWith<$Res> { + __$MarianumDateCopyWithImpl(this._self, this._then); + + final _MarianumDate _self; + final $Res Function(_MarianumDate) _then; + +/// Create a copy of MarianumDate +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? uid = null,Object? title = null,Object? description = freezed,Object? start = null,Object? end = null,Object? isAllDay = null,}) { + return _then(_MarianumDate( +uid: null == uid ? _self.uid : uid // ignore: cast_nullable_to_non_nullable +as String,title: null == title ? _self.title : title // ignore: cast_nullable_to_non_nullable +as String,description: freezed == description ? _self.description : description // ignore: cast_nullable_to_non_nullable +as String?,start: null == start ? _self.start : start // ignore: cast_nullable_to_non_nullable +as DateTime,end: null == end ? _self.end : end // ignore: cast_nullable_to_non_nullable +as DateTime,isAllDay: null == isAllDay ? _self.isAllDay : isAllDay // ignore: cast_nullable_to_non_nullable +as bool, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.g.dart b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.g.dart new file mode 100644 index 0000000..e923443 --- /dev/null +++ b/lib/state/app/modules/marianum_dates/bloc/marianum_dates_state.g.dart @@ -0,0 +1,41 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'marianum_dates_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_MarianumDatesState _$MarianumDatesStateFromJson(Map json) => + _MarianumDatesState( + showPastEvents: json['showPastEvents'] as bool, + events: (json['events'] as List) + .map((e) => MarianumDate.fromJson(e as Map)) + .toList(), + ); + +Map _$MarianumDatesStateToJson(_MarianumDatesState instance) => + { + 'showPastEvents': instance.showPastEvents, + 'events': instance.events, + }; + +_MarianumDate _$MarianumDateFromJson(Map json) => + _MarianumDate( + uid: json['uid'] as String, + title: json['title'] as String, + description: json['description'] as String?, + start: DateTime.parse(json['start'] as String), + end: DateTime.parse(json['end'] as String), + isAllDay: json['isAllDay'] as bool, + ); + +Map _$MarianumDateToJson(_MarianumDate instance) => + { + 'uid': instance.uid, + 'title': instance.title, + 'description': instance.description, + 'start': instance.start.toIso8601String(), + 'end': instance.end.toIso8601String(), + 'isAllDay': instance.isAllDay, + }; diff --git a/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart new file mode 100644 index 0000000..63bcbbb --- /dev/null +++ b/lib/state/app/modules/marianum_dates/data_provider/marianum_dates_get_events.dart @@ -0,0 +1,58 @@ +import 'package:dio/dio.dart'; +import 'package:enough_icalendar/enough_icalendar.dart'; + +import '../bloc/marianum_dates_state.dart'; + +class MarianumDatesGetEvents { + static const String url = + 'https://public-cal.marianumlan.de/cal_public/ad4c5da8-7466-9c72-89cb-8b8d9a5cf26c'; + + final Dio _dio = Dio( + BaseOptions( + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + ), + ); + + Future> run() async { + final response = await _dio.get(url); + final body = response.data; + if (body == null || body.isEmpty) return []; + + final root = VComponent.parse(body); + final calendar = root is VCalendar ? root : null; + final source = calendar?.children ?? root.children; + + final events = source + .whereType() + .map(_toMarianumDate) + .whereType() + .toList(); + events.sort((a, b) => a.start.compareTo(b.start)); + return events; + } + + static MarianumDate? _toMarianumDate(VEvent e) { + final start = e.start; + if (start == null) return null; + final end = e.end ?? start; + final isAllDay = _isAllDay(start, end); + return MarianumDate( + uid: e.uid, + title: e.summary ?? '', + description: e.description, + start: start, + end: end, + isAllDay: isAllDay, + ); + } + + static bool _isAllDay(DateTime start, DateTime end) { + final startMidnight = + start.hour == 0 && start.minute == 0 && start.second == 0; + final endMidnight = end.hour == 0 && end.minute == 0 && end.second == 0; + return startMidnight && + endMidnight && + end.difference(start).inHours % 24 == 0; + } +} 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 new file mode 100644 index 0000000..ead14c9 --- /dev/null +++ b/lib/state/app/modules/marianum_dates/repository/marianum_dates_repository.dart @@ -0,0 +1,7 @@ +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(); +} diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart similarity index 55% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart index 97c3b95..decc69f 100644 --- a/lib/state/app/modules/marianumMessage/bloc/marianum_message_bloc.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_bloc.dart @@ -1,10 +1,16 @@ -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc.dart'; -import '../../../infrastructure/utilityWidgets/loadableHydratedBloc/loadable_hydrated_bloc_event.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/marianum_message_repository.dart'; import 'marianum_message_event.dart'; import 'marianum_message_state.dart'; -class MarianumMessageBloc extends LoadableHydratedBloc { +class MarianumMessageBloc + extends + LoadableHydratedBloc< + MarianumMessageEvent, + MarianumMessageState, + MarianumMessageRepository + > { @override Future gatherData() async { var messages = await repo.getMessages(); @@ -15,10 +21,13 @@ class MarianumMessageBloc extends LoadableHydratedBloc MarianumMessageRepository(); @override - MarianumMessageState fromNothing() => const MarianumMessageState(messageList: MarianumMessageList(base: '', messages: [])); + MarianumMessageState fromNothing() => const MarianumMessageState( + messageList: MarianumMessageList(base: '', messages: []), + ); @override - MarianumMessageState fromStorage(Map json) => MarianumMessageState.fromJson(json); + MarianumMessageState fromStorage(Map json) => + MarianumMessageState.fromJson(json); @override Map? toStorage(MarianumMessageState state) => state.toJson(); } diff --git a/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart new file mode 100644 index 0000000..6ad6e1c --- /dev/null +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_event.dart @@ -0,0 +1,7 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import 'marianum_message_state.dart'; + +sealed class MarianumMessageEvent + extends LoadableHydratedBlocEvent {} + +class MessageEvent extends MarianumMessageEvent {} diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart similarity index 82% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart index a119bb6..9421618 100644 --- a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.dart +++ b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.dart @@ -3,14 +3,14 @@ import 'package:freezed_annotation/freezed_annotation.dart'; part 'marianum_message_state.freezed.dart'; part 'marianum_message_state.g.dart'; - @freezed abstract class MarianumMessageState with _$MarianumMessageState { const factory MarianumMessageState({ required MarianumMessageList messageList, }) = _MarianumMessageState; - factory MarianumMessageState.fromJson(Map json) => _$MarianumMessageStateFromJson(json); + factory MarianumMessageState.fromJson(Map json) => + _$MarianumMessageStateFromJson(json); } @freezed @@ -20,7 +20,8 @@ abstract class MarianumMessageList with _$MarianumMessageList { required List messages, }) = _MarianumMessageList; - factory MarianumMessageList.fromJson(Map json) => _$MarianumMessageListFromJson(json); + factory MarianumMessageList.fromJson(Map json) => + _$MarianumMessageListFromJson(json); } @freezed @@ -31,11 +32,8 @@ abstract class MarianumMessage with _$MarianumMessage { required String url, }) = _MarianumMessage; - factory MarianumMessage.fromJson(Map json) => _$MarianumMessageFromJson(json); + factory MarianumMessage.fromJson(Map json) => + _$MarianumMessageFromJson(json); } - -enum GradeAveragesGradingSystem { - highSchool, - middleSchool, -} +enum GradeAveragesGradingSystem { highSchool, middleSchool } diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.freezed.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.freezed.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.freezed.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.freezed.dart diff --git a/lib/state/app/modules/marianumMessage/bloc/marianum_message_state.g.dart b/lib/state/app/modules/marianum_message/bloc/marianum_message_state.g.dart similarity index 100% rename from lib/state/app/modules/marianumMessage/bloc/marianum_message_state.g.dart rename to lib/state/app/modules/marianum_message/bloc/marianum_message_state.g.dart diff --git a/lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart similarity index 65% rename from lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart rename to lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart index f8c4b24..a8eb24a 100644 --- a/lib/state/app/modules/marianumMessage/dataProvider/marianum_message_get_messages.dart +++ b/lib/state/app/modules/marianum_message/data_provider/marianum_message_get_messages.dart @@ -1,12 +1,13 @@ import 'package:dio/dio.dart'; -import '../../../infrastructure/dataLoader/data_loader.dart'; import '../../../basis/dataloader/mhsl_data_loader.dart'; +import '../../../infrastructure/data_loader/data_loader.dart'; import '../bloc/marianum_message_state.dart'; class MarianumMessageGetMessages extends MhslDataLoader { @override Future> fetch() async => dio.get('/message/messages.json'); @override - MarianumMessageList assemble(DataLoaderResult data) => MarianumMessageList.fromJson(data.json); + MarianumMessageList assemble(DataLoaderResult data) => + MarianumMessageList.fromJson(data.asMap()); } diff --git a/lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart similarity index 55% rename from lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart rename to lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart index 3148b92..3d26cb4 100644 --- a/lib/state/app/modules/marianumMessage/repository/marianum_message_repository.dart +++ b/lib/state/app/modules/marianum_message/repository/marianum_message_repository.dart @@ -1,7 +1,8 @@ import '../../../infrastructure/repository/repository.dart'; import '../bloc/marianum_message_state.dart'; -import '../dataProvider/marianum_message_get_messages.dart'; +import '../data_provider/marianum_message_get_messages.dart'; class MarianumMessageRepository extends Repository { - Future getMessages() => MarianumMessageGetMessages().run(); + Future getMessages() => + MarianumMessageGetMessages().run(); } diff --git a/lib/state/app/modules/settings/bloc/settings_cubit.dart b/lib/state/app/modules/settings/bloc/settings_cubit.dart new file mode 100644 index 0000000..a785e22 --- /dev/null +++ b/lib/state/app/modules/settings/bloc/settings_cubit.dart @@ -0,0 +1,105 @@ +import 'dart:async'; +import 'dart:developer'; + +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../../../../storage/settings.dart'; +import '../../../../../utils/debouncer.dart'; +import '../../../../../view/pages/settings/data/default_settings.dart'; +import '../../app_modules.dart'; + +class SettingsCubit extends HydratedCubit { + static const _debounceTag = 'settings_persist'; + bool _emitScheduled = false; + + SettingsCubit() : super(DefaultSettings.get()); + + Settings val({bool write = false}) { + if (write) { + // Defer the emit until the synchronous mutation on the returned object + // has finished. Without this scheduleMicrotask the cubit emits a copy + // captured *before* the assignment runs, so listeners (and HydratedBloc + // persistence) see the old value on the first emit. + if (!_emitScheduled) { + _emitScheduled = true; + scheduleMicrotask(() { + _emitScheduled = false; + _emitFreshInstance(); + }); + } + Debouncer.debounce( + _debounceTag, + const Duration(milliseconds: 500), + _emitFreshInstance, + ); + } + return state; + } + + void _emitFreshInstance() { + try { + emit(Settings.fromJson(state.toJson())); + } catch (e) { + log('Failed to refresh settings state: $e'); + } + } + + Future reset() async { + emit(DefaultSettings.get()); + } + + @override + Settings fromJson(Map json) { + try { + return _appendNewModules(Settings.fromJson(json)); + } catch (_) { + try { + return _appendNewModules( + Settings.fromJson( + _mergeSettings(json, DefaultSettings.get().toJson()), + ), + ); + } catch (_) { + return DefaultSettings.get(); + } + } + } + + // Modules added in newer app versions won't appear in a previously persisted + // moduleOrder. Append any enum value that is neither ordered nor hidden so it + // becomes visible in the "Mehr" menu without forcing a full settings reset. + Settings _appendNewModules(Settings s) { + final order = s.modulesSettings.moduleOrder; + final hidden = s.modulesSettings.hiddenModules; + final missing = Modules.values.where( + (m) => !order.contains(m) && !hidden.contains(m), + ); + if (missing.isEmpty) return s; + s.modulesSettings.moduleOrder = [...order, ...missing]; + return s; + } + + @override + Map? toJson(Settings state) => state.toJson(); + + Map _mergeSettings( + Map oldMap, + Map newMap, + ) { + final merged = Map.from(newMap); + oldMap.forEach((key, value) { + if (merged.containsKey(key)) { + if (value is Map && + merged[key] is Map) { + merged[key] = _mergeSettings( + value, + merged[key] as Map, + ); + } else { + merged[key] = value; + } + } + }); + return merged; + } +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_bloc.dart b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart new file mode 100644 index 0000000..ea0f985 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_bloc.dart @@ -0,0 +1,232 @@ +import 'package:intl/intl.dart'; + +import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import '../repository/timetable_repository.dart'; +import 'timetable_event.dart'; +import 'timetable_state.dart'; + +class TimetableBloc + extends + LoadableHydratedBloc< + TimetableEvent, + TimetableState, + TimetableRepository + > { + static const Duration _weekSpan = Duration(days: 7); + static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd'); + + DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); + + /// Set by [retry] to force the next [gatherData] to bypass cache freshness + /// checks and actually hit the network. Cleared at the top of [gatherData]. + bool _forceRenew = false; + + @override + void retry() { + _forceRenew = true; + super.retry(); + } + + @override + TimetableRepository repository() => TimetableRepository(); + + @override + TimetableState fromNothing() { + final reference = DateTime.now().add(const Duration(days: 2)); + return TimetableState( + startDate: _startOfWeek(reference), + endDate: _endOfWeek(reference), + ); + } + + @override + TimetableState fromStorage(Map json) => + TimetableState.fromJson(json); + + @override + Map? toStorage(TimetableState state) => state.toJson(); + + @override + Future gatherData() async { + final initial = innerState ?? fromNothing(); + final renew = _forceRenew; + _forceRenew = false; + + Object? firstError; + void recordError(Object e) { + firstError ??= e; + } + + await Future.wait([ + _loadCurrentWeek( + initial.startDate, + initial.endDate, + onError: recordError, + renew: renew, + ), + _loadStaticReferenceData(onError: recordError, renew: renew), + _loadCustomEvents(onError: recordError, renew: renew), + ]); + + if (firstError != null) throw firstError!; + + add(DataGathered((s) => s)); + _prefetchAdjacentWeeks(initial.startDate, initial.endDate); + } + + void changeWeek(DateTime startDate, DateTime endDate) { + final current = innerState ?? fromNothing(); + if (current.startDate == startDate && current.endDate == endDate) return; + add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate))); + _loadCurrentWeek(startDate, endDate); + _prefetchAdjacentWeeks(startDate, endDate); + } + + void resetWeek() { + final reference = DateTime.now().add(const Duration(days: 2)); + changeWeek(_startOfWeek(reference), _endOfWeek(reference)); + } + + void refresh() => fetch(); + + Future addCustomEvent(CustomTimetableEvent event) async { + await repo.data.addCustomEvent(event); + await _refreshCustomEvents(); + } + + Future updateCustomEvent(String id, CustomTimetableEvent event) async { + await repo.data.updateCustomEvent(id, event); + await _refreshCustomEvents(); + } + + Future removeCustomEvent(String id) async { + await repo.data.removeCustomEvent(id); + await _refreshCustomEvents(); + } + + Future _loadCurrentWeek( + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + bool renew = false, + }) async { + final requestStart = DateTime.now(); + _lastWeekRequestStart = requestStart; + try { + final week = await repo.data.getWeek( + startDate, + endDate, + onError: onError, + renew: renew, + ); + if (_lastWeekRequestStart.isAfter(requestStart)) return; + _writeWeekToCache(startDate, week); + } catch (e) { + onError?.call(e); + } + } + + Future _loadStaticReferenceData({ + void Function(Object)? onError, + bool renew = false, + }) async { + try { + final (rooms, subjects, schoolHolidays) = await ( + repo.data.getRooms(onError: onError, renew: renew), + repo.data.getSubjects(onError: onError, renew: renew), + repo.data.getSchoolHolidays(onError: onError, renew: renew), + ).wait; + + add( + Emit( + (s) => s.copyWith( + rooms: rooms, + subjects: subjects, + schoolHolidays: schoolHolidays, + dataVersion: s.dataVersion + 1, + ), + ), + ); + } catch (e) { + onError?.call(e); + } + + try { + final timegrid = await repo.data.getTimegrid(renew: renew); + add( + Emit( + (s) => s.copyWith(timegrid: timegrid, dataVersion: s.dataVersion + 1), + ), + ); + } catch (_) { + // Timegrid load failure falls back to a hardcoded schedule in the UI layer. + } + } + + Future _loadCustomEvents({ + void Function(Object)? onError, + bool renew = false, + }) async { + try { + final events = await repo.data.getCustomEvents( + renew: renew, + onError: onError, + ); + add( + Emit( + (s) => + s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), + ), + ); + } catch (e) { + onError?.call(e); + } + } + + Future _refreshCustomEvents() async { + final events = await repo.data.getCustomEvents(renew: true); + add( + DataGathered( + (s) => s.copyWith(customEvents: events, dataVersion: s.dataVersion + 1), + ), + ); + } + + void _prefetchAdjacentWeeks(DateTime start, DateTime end) { + _prefetchWeek(start.subtract(_weekSpan), end.subtract(_weekSpan)); + _prefetchWeek(start.add(_weekSpan), end.add(_weekSpan)); + } + + void _prefetchWeek(DateTime start, DateTime end) { + repo.data + .getWeek(start, end) + .then((week) => _writeWeekToCache(start, week)) + .catchError((_) {}); + } + + void _writeWeekToCache(DateTime weekStart, GetTimetableResponse week) { + final key = _weekKeyFormat.format(weekStart); + add( + Emit((s) { + final updated = Map.of(s.weekCache); + updated[key] = week; + return s.copyWith(weekCache: updated, dataVersion: s.dataVersion + 1); + }), + ); + } + + static DateTime _startOfWeek(DateTime reference) { + final monday = reference.subtract(Duration(days: reference.weekday - 1)); + return DateTime(monday.year, monday.month, monday.day); + } + + static DateTime _endOfWeek(DateTime reference) { + final friday = reference.add( + Duration(days: DateTime.daysPerWeek - reference.weekday - 2), + ); + return DateTime(friday.year, friday.month, friday.day); + } +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_event.dart b/lib/state/app/modules/timetable/bloc/timetable_event.dart new file mode 100644 index 0000000..871f2bc --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_event.dart @@ -0,0 +1,4 @@ +import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; +import 'timetable_state.dart'; + +sealed class TimetableEvent extends LoadableHydratedBlocEvent {} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.dart b/lib/state/app/modules/timetable/bloc/timetable_state.dart new file mode 100644 index 0000000..d99d0ee --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.dart @@ -0,0 +1,41 @@ +import 'package:freezed_annotation/freezed_annotation.dart'; + +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; + +part 'timetable_state.freezed.dart'; +part 'timetable_state.g.dart'; + +@freezed +abstract class TimetableState with _$TimetableState { + const TimetableState._(); + + const factory TimetableState({ + @Default({}) + Map weekCache, + GetRoomsResponse? rooms, + GetSubjectsResponse? subjects, + GetHolidaysResponse? schoolHolidays, + GetTimegridUnitsResponse? timegrid, + GetCustomTimetableEventResponse? customEvents, + required DateTime startDate, + required DateTime endDate, + @Default(0) int dataVersion, + }) = _TimetableState; + + factory TimetableState.fromJson(Map json) => + _$TimetableStateFromJson(json); + + Iterable getAllKnownLessons() => + weekCache.values.expand((response) => response.result); + + bool get hasReferenceData => + rooms != null && + subjects != null && + schoolHolidays != null && + customEvents != null; +} diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart new file mode 100644 index 0000000..4af71be --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.freezed.dart @@ -0,0 +1,307 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND +// coverage:ignore-file +// ignore_for_file: type=lint +// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark + +part of 'timetable_state.dart'; + +// ************************************************************************** +// FreezedGenerator +// ************************************************************************** + +// dart format off +T _$identity(T value) => value; + +/// @nodoc +mixin _$TimetableState { + + Map get weekCache; GetRoomsResponse? get rooms; GetSubjectsResponse? get subjects; GetHolidaysResponse? get schoolHolidays; GetTimegridUnitsResponse? get timegrid; GetCustomTimetableEventResponse? get customEvents; DateTime get startDate; DateTime get endDate; int get dataVersion; +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +$TimetableStateCopyWith get copyWith => _$TimetableStateCopyWithImpl(this as TimetableState, _$identity); + + /// Serializes this TimetableState to a JSON map. + Map toJson(); + + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is TimetableState&&const DeepCollectionEquality().equals(other.weekCache, weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); + +@override +String toString() { + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; +} + + +} + +/// @nodoc +abstract mixin class $TimetableStateCopyWith<$Res> { + factory $TimetableStateCopyWith(TimetableState value, $Res Function(TimetableState) _then) = _$TimetableStateCopyWithImpl; +@useResult +$Res call({ + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion +}); + + + + +} +/// @nodoc +class _$TimetableStateCopyWithImpl<$Res> + implements $TimetableStateCopyWith<$Res> { + _$TimetableStateCopyWithImpl(this._self, this._then); + + final TimetableState _self; + final $Res Function(TimetableState) _then; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@pragma('vm:prefer-inline') @override $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { + return _then(_self.copyWith( +weekCache: null == weekCache ? _self.weekCache : weekCache // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable +as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable +as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable +as int, + )); +} + +} + + +/// Adds pattern-matching-related methods to [TimetableState]. +extension TimetableStatePatterns on TimetableState { +/// A variant of `map` that fallback to returning `orElse`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeMap(TResult Function( _TimetableState value)? $default,{required TResult orElse(),}){ +final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// Callbacks receives the raw object, upcasted. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case final Subclass2 value: +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult map(TResult Function( _TimetableState value) $default,){ +final _that = this; +switch (_that) { +case _TimetableState(): +return $default(_that);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `map` that fallback to returning `null`. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case final Subclass value: +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? mapOrNull(TResult? Function( _TimetableState value)? $default,){ +final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that);case _: + return null; + +} +} +/// A variant of `when` that fallback to an `orElse` callback. +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return orElse(); +/// } +/// ``` + +@optionalTypeArgs TResult maybeWhen(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,{required TResult orElse(),}) {final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + return orElse(); + +} +} +/// A `switch`-like method, using callbacks. +/// +/// As opposed to `map`, this offers destructuring. +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case Subclass2(:final field2): +/// return ...; +/// } +/// ``` + +@optionalTypeArgs TResult when(TResult Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion) $default,) {final _that = this; +switch (_that) { +case _TimetableState(): +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + throw StateError('Unexpected subclass'); + +} +} +/// A variant of `when` that fallback to returning `null` +/// +/// It is equivalent to doing: +/// ```dart +/// switch (sealedClass) { +/// case Subclass(:final field): +/// return ...; +/// case _: +/// return null; +/// } +/// ``` + +@optionalTypeArgs TResult? whenOrNull(TResult? Function( Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion)? $default,) {final _that = this; +switch (_that) { +case _TimetableState() when $default != null: +return $default(_that.weekCache,_that.rooms,_that.subjects,_that.schoolHolidays,_that.timegrid,_that.customEvents,_that.startDate,_that.endDate,_that.dataVersion);case _: + return null; + +} +} + +} + +/// @nodoc +@JsonSerializable() + +class _TimetableState extends TimetableState { + const _TimetableState({final Map weekCache = const {}, this.rooms, this.subjects, this.schoolHolidays, this.timegrid, this.customEvents, required this.startDate, required this.endDate, this.dataVersion = 0}): _weekCache = weekCache,super._(); + factory _TimetableState.fromJson(Map json) => _$TimetableStateFromJson(json); + + final Map _weekCache; +@override@JsonKey() Map get weekCache { + if (_weekCache is EqualUnmodifiableMapView) return _weekCache; + // ignore: implicit_dynamic_type + return EqualUnmodifiableMapView(_weekCache); +} + +@override final GetRoomsResponse? rooms; +@override final GetSubjectsResponse? subjects; +@override final GetHolidaysResponse? schoolHolidays; +@override final GetTimegridUnitsResponse? timegrid; +@override final GetCustomTimetableEventResponse? customEvents; +@override final DateTime startDate; +@override final DateTime endDate; +@override@JsonKey() final int dataVersion; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@override @JsonKey(includeFromJson: false, includeToJson: false) +@pragma('vm:prefer-inline') +_$TimetableStateCopyWith<_TimetableState> get copyWith => __$TimetableStateCopyWithImpl<_TimetableState>(this, _$identity); + +@override +Map toJson() { + return _$TimetableStateToJson(this, ); +} + +@override +bool operator ==(Object other) { + return identical(this, other) || (other.runtimeType == runtimeType&&other is _TimetableState&&const DeepCollectionEquality().equals(other._weekCache, _weekCache)&&(identical(other.rooms, rooms) || other.rooms == rooms)&&(identical(other.subjects, subjects) || other.subjects == subjects)&&(identical(other.schoolHolidays, schoolHolidays) || other.schoolHolidays == schoolHolidays)&&(identical(other.timegrid, timegrid) || other.timegrid == timegrid)&&(identical(other.customEvents, customEvents) || other.customEvents == customEvents)&&(identical(other.startDate, startDate) || other.startDate == startDate)&&(identical(other.endDate, endDate) || other.endDate == endDate)&&(identical(other.dataVersion, dataVersion) || other.dataVersion == dataVersion)); +} + +@JsonKey(includeFromJson: false, includeToJson: false) +@override +int get hashCode => Object.hash(runtimeType,const DeepCollectionEquality().hash(_weekCache),rooms,subjects,schoolHolidays,timegrid,customEvents,startDate,endDate,dataVersion); + +@override +String toString() { + return 'TimetableState(weekCache: $weekCache, rooms: $rooms, subjects: $subjects, schoolHolidays: $schoolHolidays, timegrid: $timegrid, customEvents: $customEvents, startDate: $startDate, endDate: $endDate, dataVersion: $dataVersion)'; +} + + +} + +/// @nodoc +abstract mixin class _$TimetableStateCopyWith<$Res> implements $TimetableStateCopyWith<$Res> { + factory _$TimetableStateCopyWith(_TimetableState value, $Res Function(_TimetableState) _then) = __$TimetableStateCopyWithImpl; +@override @useResult +$Res call({ + Map weekCache, GetRoomsResponse? rooms, GetSubjectsResponse? subjects, GetHolidaysResponse? schoolHolidays, GetTimegridUnitsResponse? timegrid, GetCustomTimetableEventResponse? customEvents, DateTime startDate, DateTime endDate, int dataVersion +}); + + + + +} +/// @nodoc +class __$TimetableStateCopyWithImpl<$Res> + implements _$TimetableStateCopyWith<$Res> { + __$TimetableStateCopyWithImpl(this._self, this._then); + + final _TimetableState _self; + final $Res Function(_TimetableState) _then; + +/// Create a copy of TimetableState +/// with the given fields replaced by the non-null parameter values. +@override @pragma('vm:prefer-inline') $Res call({Object? weekCache = null,Object? rooms = freezed,Object? subjects = freezed,Object? schoolHolidays = freezed,Object? timegrid = freezed,Object? customEvents = freezed,Object? startDate = null,Object? endDate = null,Object? dataVersion = null,}) { + return _then(_TimetableState( +weekCache: null == weekCache ? _self._weekCache : weekCache // ignore: cast_nullable_to_non_nullable +as Map,rooms: freezed == rooms ? _self.rooms : rooms // ignore: cast_nullable_to_non_nullable +as GetRoomsResponse?,subjects: freezed == subjects ? _self.subjects : subjects // ignore: cast_nullable_to_non_nullable +as GetSubjectsResponse?,schoolHolidays: freezed == schoolHolidays ? _self.schoolHolidays : schoolHolidays // ignore: cast_nullable_to_non_nullable +as GetHolidaysResponse?,timegrid: freezed == timegrid ? _self.timegrid : timegrid // ignore: cast_nullable_to_non_nullable +as GetTimegridUnitsResponse?,customEvents: freezed == customEvents ? _self.customEvents : customEvents // ignore: cast_nullable_to_non_nullable +as GetCustomTimetableEventResponse?,startDate: null == startDate ? _self.startDate : startDate // ignore: cast_nullable_to_non_nullable +as DateTime,endDate: null == endDate ? _self.endDate : endDate // ignore: cast_nullable_to_non_nullable +as DateTime,dataVersion: null == dataVersion ? _self.dataVersion : dataVersion // ignore: cast_nullable_to_non_nullable +as int, + )); +} + + +} + +// dart format on diff --git a/lib/state/app/modules/timetable/bloc/timetable_state.g.dart b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart new file mode 100644 index 0000000..367b428 --- /dev/null +++ b/lib/state/app/modules/timetable/bloc/timetable_state.g.dart @@ -0,0 +1,58 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'timetable_state.dart'; + +// ************************************************************************** +// JsonSerializableGenerator +// ************************************************************************** + +_TimetableState _$TimetableStateFromJson(Map json) => + _TimetableState( + weekCache: + (json['weekCache'] as Map?)?.map( + (k, e) => MapEntry( + k, + GetTimetableResponse.fromJson(e as Map), + ), + ) ?? + const {}, + rooms: json['rooms'] == null + ? null + : GetRoomsResponse.fromJson(json['rooms'] as Map), + subjects: json['subjects'] == null + ? null + : GetSubjectsResponse.fromJson( + json['subjects'] as Map, + ), + schoolHolidays: json['schoolHolidays'] == null + ? null + : GetHolidaysResponse.fromJson( + json['schoolHolidays'] as Map, + ), + timegrid: json['timegrid'] == null + ? null + : GetTimegridUnitsResponse.fromJson( + json['timegrid'] as Map, + ), + customEvents: json['customEvents'] == null + ? null + : GetCustomTimetableEventResponse.fromJson( + json['customEvents'] as Map, + ), + startDate: DateTime.parse(json['startDate'] as String), + endDate: DateTime.parse(json['endDate'] as String), + dataVersion: (json['dataVersion'] as num?)?.toInt() ?? 0, + ); + +Map _$TimetableStateToJson(_TimetableState instance) => + { + 'weekCache': instance.weekCache, + 'rooms': instance.rooms, + 'subjects': instance.subjects, + 'schoolHolidays': instance.schoolHolidays, + 'timegrid': instance.timegrid, + 'customEvents': instance.customEvents, + 'startDate': instance.startDate.toIso8601String(), + 'endDate': instance.endDate.toIso8601String(), + 'dataVersion': instance.dataVersion, + }; 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 new file mode 100644 index 0000000..2b029a2 --- /dev/null +++ b/lib/state/app/modules/timetable/data_provider/timetable_data_provider.dart @@ -0,0 +1,109 @@ +import 'package:intl/intl.dart'; + +import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/add/add_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_cache.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; +import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart'; +import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event.dart'; +import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.dart'; +import '../../../../../api/request_cache.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_cache.dart'; +import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_cache.dart'; +import '../../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_cache.dart'; +import '../../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_cache.dart'; +import '../../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_cache.dart'; +import '../../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../../../../../model/account_data.dart'; + +class TimetableDataProvider { + static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); + + Future getWeek( + DateTime startDate, + DateTime endDate, { + void Function(Object)? onError, + bool renew = false, + }) => resolveFromCache( + (onUpdate, onError) => GetTimetableCache( + startdate: int.parse(_dateFormat.format(startDate)), + enddate: int.parse(_dateFormat.format(endDate)), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getWeek', + ); + + Future getRooms({ + void Function(Object)? onError, + bool renew = false, + }) => resolveFromCache( + (onUpdate, onError) => + GetRoomsCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getRooms', + ); + + Future getSubjects({ + void Function(Object)? onError, + bool renew = false, + }) => resolveFromCache( + (onUpdate, onError) => + GetSubjectsCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getSubjects', + ); + + Future getSchoolHolidays({ + void Function(Object)? onError, + bool renew = false, + }) => resolveFromCache( + (onUpdate, onError) => + GetHolidaysCache(renew: renew, onUpdate: onUpdate, onError: onError), + onError: onError, + operationName: 'getSchoolHolidays', + ); + + Future getTimegrid({bool renew = false}) => + resolveFromCache( + (onUpdate, _) => + GetTimegridUnitsCache(renew: renew, onUpdate: onUpdate), + operationName: 'getTimegrid', + ); + + Future getCustomEvents({ + bool renew = false, + void Function(Object)? onError, + }) => resolveFromCache( + (onUpdate, onError) => GetCustomTimetableEventCache( + GetCustomTimetableEventParams(AccountData().getUserSecret()), + renew: renew, + onUpdate: onUpdate, + onError: onError, + ), + onError: onError, + operationName: 'getCustomEvents', + ); + + Future addCustomEvent(CustomTimetableEvent event) => + AddCustomTimetableEvent( + AddCustomTimetableEventParams(AccountData().getUserSecret(), event), + ).run(); + + Future updateCustomEvent(String id, CustomTimetableEvent event) => + UpdateCustomTimetableEvent( + UpdateCustomTimetableEventParams(id, event), + ).run(); + + Future removeCustomEvent(String id) => + RemoveCustomTimetableEvent(RemoveCustomTimetableEventParams(id)).run(); +} diff --git a/lib/state/app/modules/timetable/repository/timetable_repository.dart b/lib/state/app/modules/timetable/repository/timetable_repository.dart new file mode 100644 index 0000000..af88a9e --- /dev/null +++ b/lib/state/app/modules/timetable/repository/timetable_repository.dart @@ -0,0 +1,12 @@ +import '../../../infrastructure/repository/repository.dart'; +import '../bloc/timetable_state.dart'; +import '../data_provider/timetable_data_provider.dart'; + +class TimetableRepository extends Repository { + final TimetableDataProvider _provider; + + TimetableRepository([TimetableDataProvider? provider]) + : _provider = provider ?? TimetableDataProvider(); + + TimetableDataProvider get data => _provider; +} diff --git a/lib/storage/base/settingsProvider.dart b/lib/storage/base/settingsProvider.dart deleted file mode 100644 index b1fb43f..0000000 --- a/lib/storage/base/settingsProvider.dart +++ /dev/null @@ -1,80 +0,0 @@ -import 'dart:convert'; -import 'dart:developer'; -import 'package:easy_debounce/easy_debounce.dart'; -import 'package:flutter/material.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../view/settings/defaultSettings.dart'; -import 'settings.dart'; - -class SettingsProvider extends ChangeNotifier { - static const String _fieldName = 'settings'; - - late SharedPreferences _storage; - late Settings _settings = DefaultSettings.get(); - - Settings val({bool write = false}) { - if(write) { - notifyListeners(); - EasyDebounce.debounce( - _fieldName, - const Duration(milliseconds: 500), - update - ); - } - return _settings; - } - - SettingsProvider() { - _readFromStorage(); - } - - Future reset() async { - _storage = await SharedPreferences.getInstance(); - _storage.remove(_fieldName); - _settings = DefaultSettings.get(); - await update(); - - notifyListeners(); - } - - Future _readFromStorage() async { - _storage = await SharedPreferences.getInstance(); - - try { - _settings = Settings.fromJson(jsonDecode(_storage.getString(_fieldName)!)); - } catch(exception) { - try { - log('Settings were changed, trying to recover from old Settings: ${exception.toString()}'); - _settings = Settings.fromJson(_mergeSettings(jsonDecode(_storage.getString(_fieldName)!), DefaultSettings.get().toJson())); - log('Settings recovered successfully: ${_settings.toJson().toString()}'); - } catch(exception) { - log('Settings are defective and not recoverable, using defaults: ${exception.toString()}'); - _settings = DefaultSettings.get(); - log('Settings were reset to defaults!'); - } - } - - notifyListeners(); - } - - Future update() async { - await _storage.setString(_fieldName, jsonEncode(_settings.toJson())); - } - - Map _mergeSettings(Map oldMap, Map newMap) { - var mergedMap = Map.from(newMap); - - oldMap.forEach((key, value) { - if (mergedMap.containsKey(key)) { - if (value is Map && mergedMap[key] is Map) { - mergedMap[key] = _mergeSettings(value, mergedMap[key]); - } else { - mergedMap[key] = value; - } - } - }); - - return mergedMap; - } -} diff --git a/lib/storage/devTools/devToolsSettings.dart b/lib/storage/dev_tools_settings.dart similarity index 58% rename from lib/storage/devTools/devToolsSettings.dart rename to lib/storage/dev_tools_settings.dart index 4a882ed..d89ffe2 100644 --- a/lib/storage/devTools/devToolsSettings.dart +++ b/lib/storage/dev_tools_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'devToolsSettings.g.dart'; +part 'dev_tools_settings.g.dart'; @JsonSerializable() class DevToolsSettings { @@ -8,8 +8,13 @@ class DevToolsSettings { bool checkerboardOffscreenLayers; bool checkerboardRasterCacheImages; - DevToolsSettings({required this.showPerformanceOverlay, required this.checkerboardOffscreenLayers, required this.checkerboardRasterCacheImages}); + DevToolsSettings({ + required this.showPerformanceOverlay, + required this.checkerboardOffscreenLayers, + required this.checkerboardRasterCacheImages, + }); - factory DevToolsSettings.fromJson(Map json) => _$DevToolsSettingsFromJson(json); + factory DevToolsSettings.fromJson(Map json) => + _$DevToolsSettingsFromJson(json); Map toJson() => _$DevToolsSettingsToJson(this); } diff --git a/lib/storage/devTools/devToolsSettings.g.dart b/lib/storage/dev_tools_settings.g.dart similarity index 96% rename from lib/storage/devTools/devToolsSettings.g.dart rename to lib/storage/dev_tools_settings.g.dart index 3f480fe..a1cc6de 100644 --- a/lib/storage/devTools/devToolsSettings.g.dart +++ b/lib/storage/dev_tools_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'devToolsSettings.dart'; +part of 'dev_tools_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/file/fileSettings.dart b/lib/storage/file/fileSettings.dart deleted file mode 100644 index 9dec6ca..0000000 --- a/lib/storage/file/fileSettings.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../../view/pages/files/files.dart'; - -part 'fileSettings.g.dart'; - -@JsonSerializable() -class FileSettings { - bool sortFoldersToTop; - - bool ascending; - SortOption sortBy; - - FileSettings({required this.sortFoldersToTop, required this.ascending, required this.sortBy}); - - factory FileSettings.fromJson(Map json) => _$FileSettingsFromJson(json); - Map toJson() => _$FileSettingsToJson(this); -} diff --git a/lib/storage/file_settings.dart b/lib/storage/file_settings.dart new file mode 100644 index 0000000..c7dad77 --- /dev/null +++ b/lib/storage/file_settings.dart @@ -0,0 +1,23 @@ +import 'package:json_annotation/json_annotation.dart'; + +import '../view/pages/files/data/sort_options.dart'; + +part 'file_settings.g.dart'; + +@JsonSerializable() +class FileSettings { + bool sortFoldersToTop; + + bool ascending; + SortOption sortBy; + + FileSettings({ + required this.sortFoldersToTop, + required this.ascending, + required this.sortBy, + }); + + factory FileSettings.fromJson(Map json) => + _$FileSettingsFromJson(json); + Map toJson() => _$FileSettingsToJson(this); +} diff --git a/lib/storage/file/fileSettings.g.dart b/lib/storage/file_settings.g.dart similarity index 96% rename from lib/storage/file/fileSettings.g.dart rename to lib/storage/file_settings.g.dart index 9855925..38d4aa5 100644 --- a/lib/storage/file/fileSettings.g.dart +++ b/lib/storage/file_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'fileSettings.dart'; +part of 'file_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/fileView/fileViewSettings.dart b/lib/storage/file_view_settings.dart similarity index 81% rename from lib/storage/fileView/fileViewSettings.dart rename to lib/storage/file_view_settings.dart index 1f0f1b3..365f680 100644 --- a/lib/storage/fileView/fileViewSettings.dart +++ b/lib/storage/file_view_settings.dart @@ -1,6 +1,6 @@ import 'package:json_annotation/json_annotation.dart'; -part 'fileViewSettings.g.dart'; +part 'file_view_settings.g.dart'; @JsonSerializable() class FileViewSettings { @@ -8,6 +8,7 @@ class FileViewSettings { FileViewSettings({required this.alwaysOpenExternally}); - factory FileViewSettings.fromJson(Map json) => _$FileViewSettingsFromJson(json); + factory FileViewSettings.fromJson(Map json) => + _$FileViewSettingsFromJson(json); Map toJson() => _$FileViewSettingsToJson(this); } diff --git a/lib/storage/fileView/fileViewSettings.g.dart b/lib/storage/file_view_settings.g.dart similarity index 94% rename from lib/storage/fileView/fileViewSettings.g.dart rename to lib/storage/file_view_settings.g.dart index f34915a..44d2e30 100644 --- a/lib/storage/fileView/fileViewSettings.g.dart +++ b/lib/storage/file_view_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'fileViewSettings.dart'; +part of 'file_view_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/holidays/holidaysSettings.dart b/lib/storage/holidays_settings.dart similarity index 62% rename from lib/storage/holidays/holidaysSettings.dart rename to lib/storage/holidays_settings.dart index 6a25292..6f4709b 100644 --- a/lib/storage/holidays/holidaysSettings.dart +++ b/lib/storage/holidays_settings.dart @@ -1,14 +1,18 @@ import 'package:json_annotation/json_annotation.dart'; -part 'holidaysSettings.g.dart'; +part 'holidays_settings.g.dart'; @JsonSerializable() class HolidaysSettings { bool dismissedDisclaimer; bool showPastEvents; - HolidaysSettings({required this.dismissedDisclaimer, required this.showPastEvents}); + HolidaysSettings({ + required this.dismissedDisclaimer, + required this.showPastEvents, + }); - factory HolidaysSettings.fromJson(Map json) => _$HolidaysSettingsFromJson(json); + factory HolidaysSettings.fromJson(Map json) => + _$HolidaysSettingsFromJson(json); Map toJson() => _$HolidaysSettingsToJson(this); } diff --git a/lib/storage/holidays/holidaysSettings.g.dart b/lib/storage/holidays_settings.g.dart similarity index 95% rename from lib/storage/holidays/holidaysSettings.g.dart rename to lib/storage/holidays_settings.g.dart index f229202..976acb6 100644 --- a/lib/storage/holidays/holidaysSettings.g.dart +++ b/lib/storage/holidays_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'holidaysSettings.dart'; +part of 'holidays_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/general/modulesSettings.dart b/lib/storage/modules_settings.dart similarity index 64% rename from lib/storage/general/modulesSettings.dart rename to lib/storage/modules_settings.dart index 387982f..0706b9c 100644 --- a/lib/storage/general/modulesSettings.dart +++ b/lib/storage/modules_settings.dart @@ -2,18 +2,23 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import '../../state/app/modules/app_modules.dart'; -part 'modulesSettings.g.dart'; +part 'modules_settings.g.dart'; @JsonSerializable() class ModulesSettings { List moduleOrder; List hiddenModules; + bool autoFillBottomBar; + int fixedBottomBarSlots; ModulesSettings({ required this.moduleOrder, - required this.hiddenModules + required this.hiddenModules, + this.autoFillBottomBar = true, + this.fixedBottomBarSlots = 3, }); - factory ModulesSettings.fromJson(Map json) => _$ModulesSettingsFromJson(json); + factory ModulesSettings.fromJson(Map json) => + _$ModulesSettingsFromJson(json); Map toJson() => _$ModulesSettingsToJson(this); } diff --git a/lib/storage/general/modulesSettings.g.dart b/lib/storage/modules_settings.g.dart similarity index 78% rename from lib/storage/general/modulesSettings.g.dart rename to lib/storage/modules_settings.g.dart index c5a7262..e97774b 100644 --- a/lib/storage/general/modulesSettings.g.dart +++ b/lib/storage/modules_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'modulesSettings.dart'; +part of 'modules_settings.dart'; // ************************************************************************** // JsonSerializableGenerator @@ -14,6 +14,8 @@ ModulesSettings _$ModulesSettingsFromJson(Map json) => hiddenModules: (json['hiddenModules'] as List) .map((e) => $enumDecode(_$ModulesEnumMap, e)) .toList(), + autoFillBottomBar: json['autoFillBottomBar'] as bool? ?? true, + fixedBottomBarSlots: (json['fixedBottomBarSlots'] as num?)?.toInt() ?? 3, ); Map _$ModulesSettingsToJson( @@ -23,6 +25,8 @@ Map _$ModulesSettingsToJson( 'hiddenModules': instance.hiddenModules .map((e) => _$ModulesEnumMap[e]!) .toList(), + 'autoFillBottomBar': instance.autoFillBottomBar, + 'fixedBottomBarSlots': instance.fixedBottomBarSlots, }; const _$ModulesEnumMap = { @@ -33,4 +37,5 @@ const _$ModulesEnumMap = { Modules.roomPlan: 'roomPlan', Modules.gradeAveragesCalculator: 'gradeAveragesCalculator', Modules.holidays: 'holidays', + Modules.marianumDates: 'marianumDates', }; diff --git a/lib/storage/notification/notificationSettings.dart b/lib/storage/notification_settings.dart similarity index 61% rename from lib/storage/notification/notificationSettings.dart rename to lib/storage/notification_settings.dart index ce02847..664cd2f 100644 --- a/lib/storage/notification/notificationSettings.dart +++ b/lib/storage/notification_settings.dart @@ -1,14 +1,18 @@ import 'package:json_annotation/json_annotation.dart'; -part 'notificationSettings.g.dart'; +part 'notification_settings.g.dart'; @JsonSerializable() class NotificationSettings { bool askUsageDismissed; bool enabled; - NotificationSettings({required this.askUsageDismissed, required this.enabled}); + NotificationSettings({ + required this.askUsageDismissed, + required this.enabled, + }); - factory NotificationSettings.fromJson(Map json) => _$NotificationSettingsFromJson(json); + factory NotificationSettings.fromJson(Map json) => + _$NotificationSettingsFromJson(json); Map toJson() => _$NotificationSettingsToJson(this); } diff --git a/lib/storage/notification/notificationSettings.g.dart b/lib/storage/notification_settings.g.dart similarity index 94% rename from lib/storage/notification/notificationSettings.g.dart rename to lib/storage/notification_settings.g.dart index 0229204..ac37ecb 100644 --- a/lib/storage/notification/notificationSettings.g.dart +++ b/lib/storage/notification_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'notificationSettings.dart'; +part of 'notification_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/base/settings.dart b/lib/storage/settings.dart similarity index 62% rename from lib/storage/base/settings.dart rename to lib/storage/settings.dart index 2914e1a..e7fc579 100644 --- a/lib/storage/base/settings.dart +++ b/lib/storage/settings.dart @@ -1,23 +1,20 @@ import 'package:flutter/material.dart'; import 'package:json_annotation/json_annotation.dart'; -import '../devTools/devToolsSettings.dart'; -import '../file/fileSettings.dart'; -import '../fileView/fileViewSettings.dart'; -import '../general/modulesSettings.dart'; -import '../holidays/holidaysSettings.dart'; -import '../notification/notificationSettings.dart'; -import '../talk/talkSettings.dart'; -import '../timetable/timetableSettings.dart'; +import 'dev_tools_settings.dart'; +import 'file_settings.dart'; +import 'file_view_settings.dart'; +import 'holidays_settings.dart'; +import 'modules_settings.dart'; +import 'notification_settings.dart'; +import 'talk_settings.dart'; +import 'timetable_settings.dart'; part 'settings.g.dart'; @JsonSerializable(explicitToJson: true) class Settings { - @JsonKey( - toJson: _themeToJson, - fromJson: _themeFromJson, - ) + @JsonKey(toJson: _themeToJson, fromJson: _themeFromJson) ThemeMode appTheme; bool devToolsEnabled; @@ -44,8 +41,10 @@ class Settings { }); static String _themeToJson(ThemeMode m) => m.name; - static ThemeMode _themeFromJson(String m) => ThemeMode.values.firstWhere((element) => element.name == m); + static ThemeMode _themeFromJson(String m) => + ThemeMode.values.firstWhere((element) => element.name == m); - factory Settings.fromJson(Map json) => _$SettingsFromJson(json); + factory Settings.fromJson(Map json) => + _$SettingsFromJson(json); Map toJson() => _$SettingsToJson(this); } diff --git a/lib/storage/base/settings.g.dart b/lib/storage/settings.g.dart similarity index 100% rename from lib/storage/base/settings.g.dart rename to lib/storage/settings.g.dart diff --git a/lib/storage/talk/talkSettings.dart b/lib/storage/talk/talkSettings.dart deleted file mode 100644 index 7c3123a..0000000 --- a/lib/storage/talk/talkSettings.dart +++ /dev/null @@ -1,16 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -part 'talkSettings.g.dart'; - -@JsonSerializable() -class TalkSettings { - bool sortFavoritesToTop; - bool sortUnreadToTop; - Map drafts; - Map draftReplies; - - TalkSettings({required this.sortFavoritesToTop, required this.sortUnreadToTop, required this.drafts, required this.draftReplies}); - - factory TalkSettings.fromJson(Map json) => _$TalkSettingsFromJson(json); - Map toJson() => _$TalkSettingsToJson(this); -} diff --git a/lib/storage/talk_settings.dart b/lib/storage/talk_settings.dart new file mode 100644 index 0000000..77838bc --- /dev/null +++ b/lib/storage/talk_settings.dart @@ -0,0 +1,22 @@ +import 'package:json_annotation/json_annotation.dart'; + +part 'talk_settings.g.dart'; + +@JsonSerializable() +class TalkSettings { + bool sortFavoritesToTop; + bool sortUnreadToTop; + Map drafts; + Map draftReplies; + + TalkSettings({ + required this.sortFavoritesToTop, + required this.sortUnreadToTop, + required this.drafts, + required this.draftReplies, + }); + + factory TalkSettings.fromJson(Map json) => + _$TalkSettingsFromJson(json); + Map toJson() => _$TalkSettingsToJson(this); +} diff --git a/lib/storage/talk/talkSettings.g.dart b/lib/storage/talk_settings.g.dart similarity index 96% rename from lib/storage/talk/talkSettings.g.dart rename to lib/storage/talk_settings.g.dart index 568988e..0b3e55b 100644 --- a/lib/storage/talk/talkSettings.g.dart +++ b/lib/storage/talk_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'talkSettings.dart'; +part of 'talk_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/storage/timetable/timetableSettings.dart b/lib/storage/timetable_settings.dart similarity index 67% rename from lib/storage/timetable/timetableSettings.dart rename to lib/storage/timetable_settings.dart index 26c9d73..feaa007 100644 --- a/lib/storage/timetable/timetableSettings.dart +++ b/lib/storage/timetable_settings.dart @@ -1,8 +1,8 @@ import 'package:json_annotation/json_annotation.dart'; -import '../../view/pages/timetable/timetableNameMode.dart'; +import '../../../view/pages/timetable/data/timetable_name_mode.dart'; -part 'timetableSettings.g.dart'; +part 'timetable_settings.g.dart'; @JsonSerializable() class TimetableSettings { @@ -11,9 +11,10 @@ class TimetableSettings { TimetableSettings({ required this.connectDoubleLessons, - required this.timetableNameMode + required this.timetableNameMode, }); - factory TimetableSettings.fromJson(Map json) => _$TimetableSettingsFromJson(json); + factory TimetableSettings.fromJson(Map json) => + _$TimetableSettingsFromJson(json); Map toJson() => _$TimetableSettingsToJson(this); } diff --git a/lib/storage/timetable/timetableSettings.g.dart b/lib/storage/timetable_settings.g.dart similarity index 96% rename from lib/storage/timetable/timetableSettings.g.dart rename to lib/storage/timetable_settings.g.dart index 2ea7c43..87c60d7 100644 --- a/lib/storage/timetable/timetableSettings.g.dart +++ b/lib/storage/timetable_settings.g.dart @@ -1,6 +1,6 @@ // GENERATED CODE - DO NOT MODIFY BY HAND -part of 'timetableSettings.dart'; +part of 'timetable_settings.dart'; // ************************************************************************** // JsonSerializableGenerator diff --git a/lib/theming/appTheme.dart b/lib/theming/appTheme.dart deleted file mode 100644 index c122cc2..0000000 --- a/lib/theming/appTheme.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../widget/dropdownDisplay.dart'; - -class AppTheme { - static DropdownDisplay getDisplayOptions(ThemeMode theme) { - switch(theme) { - case ThemeMode.system: - return DropdownDisplay(icon: Icons.auto_fix_high_outlined, displayName: 'Systemvorgabe'); - - case ThemeMode.light: - return DropdownDisplay(icon: Icons.wb_sunny_outlined, displayName: 'Hell'); - - case ThemeMode.dark: - return DropdownDisplay(icon: Icons.dark_mode_outlined, displayName: 'Dunkel'); - - } - } - - static bool isDarkMode(BuildContext context) => Theme.of(context).brightness == Brightness.dark; -} diff --git a/lib/theming/app_theme.dart b/lib/theming/app_theme.dart new file mode 100644 index 0000000..4acb96f --- /dev/null +++ b/lib/theming/app_theme.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +import '../widget/dropdown_display.dart'; + +class AppSpacing { + static const double xs = 4; + static const double sm = 8; + static const double md = 16; + static const double lg = 24; + static const double xl = 40; +} + +TextStyle inputErrorStyle(BuildContext context) => + TextStyle(color: Theme.of(context).colorScheme.error); + +class AppTheme { + static DropdownDisplay getDisplayOptions(ThemeMode theme) { + switch (theme) { + case ThemeMode.system: + return DropdownDisplay( + icon: Icons.auto_fix_high_outlined, + displayName: 'Systemvorgabe', + ); + + case ThemeMode.light: + return DropdownDisplay( + icon: Icons.wb_sunny_outlined, + displayName: 'Hell', + ); + + case ThemeMode.dark: + return DropdownDisplay( + icon: Icons.dark_mode_outlined, + displayName: 'Dunkel', + ); + } + } + + static bool isDarkMode(BuildContext context) => + Theme.of(context).brightness == Brightness.dark; +} diff --git a/lib/theming/darkAppTheme.dart b/lib/theming/dark_app_theme.dart similarity index 100% rename from lib/theming/darkAppTheme.dart rename to lib/theming/dark_app_theme.dart diff --git a/lib/theming/lightAppTheme.dart b/lib/theming/light_app_theme.dart similarity index 88% rename from lib/theming/lightAppTheme.dart rename to lib/theming/light_app_theme.dart index 97e9130..5589bae 100644 --- a/lib/theming/lightAppTheme.dart +++ b/lib/theming/light_app_theme.dart @@ -7,7 +7,7 @@ class LightAppTheme { brightness: Brightness.light, colorScheme: ColorScheme.fromSeed(seedColor: marianumRed), floatingActionButtonTheme: const FloatingActionButtonThemeData( - foregroundColor: Colors.white - ) + foregroundColor: Colors.white, + ), ); } diff --git a/lib/utils/FileSaver.dart b/lib/utils/FileSaver.dart deleted file mode 100644 index 1a1a88c..0000000 --- a/lib/utils/FileSaver.dart +++ /dev/null @@ -1,23 +0,0 @@ -import 'dart:io'; - -import 'package:permission_handler/permission_handler.dart'; - -// only tested on android! -class FileSaver { - static Future getExternalDocumentPath() async { - var permission = await Permission.storage.status; - if(!permission.isGranted) { - await Permission.storage.request(); - } - var directory = Directory('/storage/emulated/0/Download'); - final externalPath = directory.path; - await Directory(externalPath).create(recursive: true); - return externalPath; - } - - static Future writeBytes(List bytes, String name) async { - final path = await getExternalDocumentPath(); - var file = File('$path/$name'); - return file.writeAsBytes(bytes); - } -} diff --git a/lib/utils/cache_invalidation_bus.dart b/lib/utils/cache_invalidation_bus.dart new file mode 100644 index 0000000..9f4aa4e --- /dev/null +++ b/lib/utils/cache_invalidation_bus.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +/// App-wide pub/sub channel for cache invalidations. Producers (e.g. webdav +/// move/delete handlers) call [notifyListFiles] after they have dropped the +/// cached listing for a folder so that any [_FilesView] currently sitting on +/// that folder — possibly in the background, beneath a child route — can +/// refresh itself instead of showing the stale snapshot it loaded earlier. +class CacheInvalidationBus { + CacheInvalidationBus._(); + + static final StreamController _listFiles = + StreamController.broadcast(); + + /// Emits the invalidated `pathString` (in `FilesBloc` format: relative, + /// no leading or trailing slash; root is '/'). + static Stream get listFilesStream => _listFiles.stream; + + static void notifyListFiles(String pathString) { + _listFiles.add(pathString); + } +} diff --git a/lib/utils/clipboard_helper.dart b/lib/utils/clipboard_helper.dart new file mode 100644 index 0000000..54c56f1 --- /dev/null +++ b/lib/utils/clipboard_helper.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +/// Copies [text] to the system clipboard and shows a SnackBar. +Future copyToClipboard( + BuildContext context, + String text, { + String successMessage = 'In Zwischenablage kopiert', +}) async { + await Clipboard.setData(ClipboardData(text: text)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(successMessage), + duration: const Duration(seconds: 2), + ), + ); +} diff --git a/lib/utils/debouncer.dart b/lib/utils/debouncer.dart new file mode 100644 index 0000000..ce3fb5e --- /dev/null +++ b/lib/utils/debouncer.dart @@ -0,0 +1,34 @@ +import 'dart:async'; + +/// Static map-based debouncer/throttler. Replaces the `easy_debounce` package +/// with a minimal in-house implementation: each unique [tag] tracks its own +/// pending Timer and gating flag. +class Debouncer { + Debouncer._(); + + static final Map _debounceTimers = {}; + static final Map _throttleTimers = {}; + + /// Coalesces calls under [tag]: the [action] runs once [delay] has elapsed + /// without further calls for the same tag. + static void debounce(String tag, Duration delay, void Function() action) { + _debounceTimers[tag]?.cancel(); + _debounceTimers[tag] = Timer(delay, () { + _debounceTimers.remove(tag); + action(); + }); + } + + /// Runs [action] immediately and ignores subsequent calls under the same + /// [tag] until [duration] has elapsed. + static void throttle(String tag, Duration duration, void Function() action) { + if (_throttleTimers.containsKey(tag)) return; + _throttleTimers[tag] = Timer(duration, () => _throttleTimers.remove(tag)); + action(); + } + + static void cancel(String tag) { + _debounceTimers.remove(tag)?.cancel(); + _throttleTimers.remove(tag)?.cancel(); + } +} diff --git a/lib/utils/download_manager.dart b/lib/utils/download_manager.dart new file mode 100644 index 0000000..8e68ab4 --- /dev/null +++ b/lib/utils/download_manager.dart @@ -0,0 +1,151 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +import '../api/marianumcloud/webdav/webdav_api.dart'; +import '../model/account_data.dart'; +import 'file_downloader.dart'; + +/// Snapshot of a single download's lifecycle. UI widgets rebuild whenever the +/// owning [DownloadJob.status] notifier emits a new instance. +sealed class DownloadStatus { + const DownloadStatus(); +} + +class DownloadInProgress extends DownloadStatus { + const DownloadInProgress(this.percent); + final double percent; +} + +class DownloadDone extends DownloadStatus { + const DownloadDone(this.localPath); + final String localPath; +} + +class DownloadCancelled extends DownloadStatus { + const DownloadCancelled(); +} + +class DownloadFailed extends DownloadStatus { + const DownloadFailed(this.message); + final String message; +} + +/// Tracks a single in-flight or finished download. Survives widget dispose so +/// that re-entering the screen reattaches to the same job. +class DownloadJob { + DownloadJob({ + required this.remotePath, + required this.name, + required this.localPath, + required FileDownloader downloader, + }) : _downloader = downloader; + + final String remotePath; + final String name; + final String localPath; + final FileDownloader _downloader; + + final ValueNotifier status = ValueNotifier( + const DownloadInProgress(0), + ); + bool _disposed = false; + + bool get isFinished => + status.value is DownloadDone || + status.value is DownloadFailed || + status.value is DownloadCancelled; + + void cancel() { + if (isFinished) return; + _downloader.cancel(); + status.value = const DownloadCancelled(); + } + + void _dispose() { + if (_disposed) return; + _disposed = true; + status.dispose(); + } +} + +/// Central in-memory registry for downloads. Keyed by remote path so a file +/// triggered from multiple screens (Files + Chat) reuses one job. +/// +/// Not persistent across app restarts — restarting abandons in-flight +/// downloads and partial files are cleaned on next start attempt. +class DownloadManager { + DownloadManager._(); + static final DownloadManager instance = DownloadManager._(); + + final Map _jobs = {}; + + /// Active or recently finished job for [remotePath], or null if none. + DownloadJob? jobFor(String remotePath) => _jobs[remotePath]; + + /// Returns the existing job if a download is in progress for [remotePath], + /// otherwise starts a new one. Caller listens on [DownloadJob.status]. + Future start({ + required String remotePath, + required String name, + }) async { + final existing = _jobs[remotePath]; + if (existing != null && !existing.isFinished) return existing; + if (existing != null) { + _jobs.remove(remotePath); + scheduleMicrotask(existing._dispose); + } + + final tempDir = await getTemporaryDirectory(); + final encodedPath = Uri.encodeComponent(remotePath).replaceAll('%2F', '/'); + final localPath = '${tempDir.path}${Platform.pathSeparator}$name'; + + final downloader = FileDownloader(); + final job = DownloadJob( + remotePath: remotePath, + name: name, + localPath: localPath, + downloader: downloader, + ); + _jobs[remotePath] = job; + + downloader.run( + client: Dio(BaseOptions(headers: AccountData().authHeaders())), + url: '${WebdavApi.buildWebdavUrl()}$encodedPath', + savePath: localPath, + onProgress: (percent) { + if (job.isFinished) return; + job.status.value = DownloadInProgress(percent); + }, + onDone: () { + if (job.isFinished) return; + job.status.value = DownloadDone(localPath); + }, + onError: (error) { + if (job.isFinished) return; + try { + File(localPath).deleteSync(); + } on FileSystemException { + // partial file may not exist — ignore + } + job.status.value = DownloadFailed(error.toString()); + }, + ); + + return job; + } + + /// Removes a finished job from the registry. Safe to call from a status + /// listener: actual disposal of the underlying notifier is deferred to the + /// next microtask so the in-flight `notifyListeners` cycle can finish before + /// the notifier is destroyed. Active (unfinished) jobs are left untouched. + void clear(String remotePath) { + final job = _jobs[remotePath]; + if (job == null || !job.isFinished) return; + _jobs.remove(remotePath); + scheduleMicrotask(job._dispose); + } +} diff --git a/lib/utils/file_clipboard.dart b/lib/utils/file_clipboard.dart new file mode 100644 index 0000000..a8abda3 --- /dev/null +++ b/lib/utils/file_clipboard.dart @@ -0,0 +1,44 @@ +import 'package:flutter/foundation.dart'; + +import '../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +enum FileClipboardOperation { cut, copy } + +/// In-memory clipboard for file operations within the app. Mirrors the +/// cut/copy/paste pattern of native file managers (iOS Files, Android Files, +/// Finder). Contents are not persisted across app restarts. +/// +/// Listen via [ChangeNotifier] (e.g. `ListenableBuilder`) to render a paste +/// banner when [isEmpty] is false. +class FileClipboard extends ChangeNotifier { + FileClipboard._(); + static final FileClipboard instance = FileClipboard._(); + + FileClipboardOperation? _operation; + List _files = const []; + + FileClipboardOperation? get operation => _operation; + List get files => List.unmodifiable(_files); + bool get isEmpty => _files.isEmpty; + + void cut(List files) { + if (files.isEmpty) return; + _operation = FileClipboardOperation.cut; + _files = List.of(files); + notifyListeners(); + } + + void copy(List files) { + if (files.isEmpty) return; + _operation = FileClipboardOperation.copy; + _files = List.of(files); + notifyListeners(); + } + + void clear() { + if (_operation == null && _files.isEmpty) return; + _operation = null; + _files = const []; + notifyListeners(); + } +} diff --git a/lib/utils/file_downloader.dart b/lib/utils/file_downloader.dart new file mode 100644 index 0000000..34c1088 --- /dev/null +++ b/lib/utils/file_downloader.dart @@ -0,0 +1,51 @@ +import 'package:dio/dio.dart'; + +/// Lightweight cancel handle around a single `Dio.download` call. The download +/// itself is started by [run]; the handle returns synchronously so callers can +/// install it into shared state before the first progress event can fire. +class FileDownloader { + FileDownloader(); + + final CancelToken _cancelToken = CancelToken(); + bool _cancelled = false; + + bool get isCancelled => _cancelled; + + void cancel() { + if (_cancelled) return; + _cancelled = true; + _cancelToken.cancel('user cancelled'); + } + + /// Kicks off the download. Returns immediately; the download progresses in + /// the background and events are delivered via callbacks. Callbacks are not + /// invoked once [cancel] has been called. + void run({ + required Dio client, + required String url, + required String savePath, + required void Function(double percent) onProgress, + required void Function() onDone, + required void Function(Object error) onError, + }) { + client + .download( + url, + savePath, + cancelToken: _cancelToken, + onReceiveProgress: (received, total) { + if (_cancelled || total <= 0) return; + onProgress((received / total) * 100); + }, + ) + .then((_) { + if (_cancelled) return; + onDone(); + }) + .catchError((Object error) { + if (_cancelled) return; + onError(error); + }) + .ignore(); + } +} diff --git a/lib/utils/UrlOpener.dart b/lib/utils/url_opener.dart similarity index 83% rename from lib/utils/UrlOpener.dart rename to lib/utils/url_opener.dart index 450ed94..b88f8ab 100644 --- a/lib/utils/UrlOpener.dart +++ b/lib/utils/url_opener.dart @@ -3,7 +3,7 @@ import 'package:url_launcher/url_launcher_string.dart'; class UrlOpener { static Future onOpen(LinkableElement link) async { - if(await canLaunchUrlString(link.url)) { + if (await canLaunchUrlString(link.url)) { await launchUrlString(link.url); } } diff --git a/lib/view/login/login.dart b/lib/view/login/login.dart index 6f7d0a5..879cf0f 100644 --- a/lib/view/login/login.dart +++ b/lib/view/login/login.dart @@ -1,14 +1,12 @@ - -import 'dart:developer'; - import 'package:flutter/material.dart'; -import 'package:flutter_login/flutter_login.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../../api/marianumcloud/talk/room/getRoom.dart'; -import '../../api/marianumcloud/talk/room/getRoomParams.dart'; -import '../../model/accountData.dart'; -import '../../model/accountModel.dart'; +import '../../state/app/modules/account/bloc/account_bloc.dart'; +import '../../state/app/modules/account/bloc/account_state.dart'; +import '../../theming/light_app_theme.dart'; +import 'login_controller.dart'; +import 'widgets/login_branding.dart'; +import 'widgets/login_card.dart'; class Login extends StatefulWidget { const Login({super.key}); @@ -18,88 +16,59 @@ class Login extends StatefulWidget { } class _LoginState extends State { - bool displayDisclaimerText = true; + static const _marianumRed = LightAppTheme.marianumRed; - String? _checkInput(value)=> (value ?? '').length == 0 ? 'Eingabe erforderlich' : null; - - Future _login(LoginData data) async { - await AccountData().removeData(); - - try { - await AccountData().setData(data.name.toLowerCase(), data.password); - await GetRoom( - GetRoomParams( - includeStatus: false, - ), - ).run().then((value) async { - await AccountData().setData(data.name.toLowerCase(), data.password); - setState(() { - displayDisclaimerText = false; - }); - }); - } catch(e) { - await AccountData().removeData(); - log(e.toString()); - return 'Benutzername oder Password falsch! (${e.toString()})'; - } - - await Future.delayed(const Duration(seconds: 1)); - return null; - } - - Future _resetPassword(String name) => Future.delayed(Duration.zero).then((_) => 'Diese Funktion steht nicht zur Verfügung!'); + final LoginController _controller = LoginController(); @override - Widget build(BuildContext context) => FlutterLogin( - logo: Image.asset('assets/logo/icon.png').image, + void didChangeDependencies() { + super.didChangeDependencies(); + precacheImage(const AssetImage('assets/logo/icon.png'), context); + } - userValidator: _checkInput, - passwordValidator: _checkInput, - onSubmitAnimationCompleted: () => Provider.of(context, listen: false).setState(AccountModelState.loggedIn), + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } - onLogin: _login, - onSignup: null, + void _onLoginSuccess() { + context.read().setStatus(AccountStatus.loggedIn); + } - onRecoverPassword: _resetPassword, - hideForgotPasswordButton: true, - - theme: LoginTheme( - primaryColor: Theme.of(context).primaryColor, - accentColor: Colors.white, - errorColor: Theme.of(context).primaryColor, - footerBottomPadding: 10, - textFieldStyle: const TextStyle( - fontWeight: FontWeight.w500 - ), - cardTheme: const CardTheme( - elevation: 10, - ), - ), - - messages: LoginMessages( - loginButton: 'Anmelden', - userHint: 'Nutzername', - passwordHint: 'Passwort', - ), - - disableCustomPageTransformer: true, - - headerWidget: Padding( - padding: const EdgeInsets.only(bottom: 30), - child: Center( - child: Visibility( - visible: displayDisclaimerText, - child: const Text( - 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\nKeinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!', - textAlign: TextAlign.center, + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: _marianumRed, + body: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) => SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + minHeight: constraints.maxHeight, + maxWidth: 420, + ), + child: IntrinsicHeight( + child: Column( + children: [ + const LoginHeader(), + const SizedBox(height: 28), + LoginCard( + controller: _controller, + onSuccess: _onLoginSuccess, + ), + const SizedBox(height: 18), + const LoginDisclaimer(), + const Spacer(), + const LoginFooter(), + ], + ), + ), ), ), ), ), - - footer: 'Marianum Fulda - Die persönliche Schule', - title: 'Marianum Fulda', - - userType: LoginUserType.name, - ); + ), + ); } diff --git a/lib/view/login/login_controller.dart b/lib/view/login/login_controller.dart new file mode 100644 index 0000000..2d7126f --- /dev/null +++ b/lib/view/login/login_controller.dart @@ -0,0 +1,55 @@ +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; + +import '../../api/errors/auth_exception.dart'; +import '../../api/errors/error_mapper.dart'; +import '../../api/marianumcloud/talk/room/get_room.dart'; +import '../../api/marianumcloud/talk/room/get_room_params.dart'; +import '../../model/account_data.dart'; + +/// Owns the login flow's transient state (loading, last error) so it can be +/// driven from a thin Stateful view and unit-tested without a widget tree. +class LoginController extends ChangeNotifier { + bool _loading = false; + String? _errorMessage; + String? _errorDetails; + + bool get loading => _loading; + String? get errorMessage => _errorMessage; + String? get errorDetails => _errorDetails; + + /// Returns `true` when the credential probe succeeded. The view should + /// then transition the AccountBloc to `loggedIn`. + Future submit(String username, String password) async { + if (_loading) return false; + _loading = true; + _errorMessage = null; + _errorDetails = null; + notifyListeners(); + + final user = username.trim().toLowerCase(); + try { + await AccountData().removeData(); + await AccountData().setData(user, password); + await GetRoom(GetRoomParams(includeStatus: false)).run(); + _loading = false; + notifyListeners(); + return true; + } catch (e) { + log(e.toString()); + await AccountData().removeData(); + // 401 from the probe means the credentials were wrong; everything else + // (no network, server down, TLS errors, …) gets the generic mapped + // message so the user knows it isn't their typo. + final isWrongCredentials = e is AuthException && e.statusCode == 401; + _errorMessage = isWrongCredentials + ? 'Benutzername oder Passwort falsch.' + : errorToUserMessage(e); + _errorDetails = errorToTechnicalDetails(e); + _loading = false; + notifyListeners(); + return false; + } + } +} diff --git a/lib/view/login/widgets/login_branding.dart b/lib/view/login/widgets/login_branding.dart new file mode 100644 index 0000000..04649c5 --- /dev/null +++ b/lib/view/login/widgets/login_branding.dart @@ -0,0 +1,75 @@ +import 'package:flutter/material.dart'; + +class LoginHeader extends StatelessWidget { + const LoginHeader({super.key}); + + @override + Widget build(BuildContext context) => Column( + children: [ + const SizedBox(height: 40), + Image.asset( + 'assets/logo/icon.png', + height: 110, + fit: BoxFit.contain, + gaplessPlayback: true, + ), + const SizedBox(height: 20), + const Text( + 'Marianum Fulda', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.w600, + letterSpacing: 0.3, + ), + ), + const SizedBox(height: 6), + Text( + 'Stundenplan, Talk & Dateien an einem Ort.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.85), + fontSize: 14, + height: 1.3, + ), + ), + ], + ); +} + +class LoginDisclaimer extends StatelessWidget { + const LoginDisclaimer({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + 'Inoffizieller Nextcloud & Webuntis Client. Wird nicht vom Marianum betrieben. Keine Gewähr für Vollständigkeit, Richtigkeit und Aktualität.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.75), + fontSize: 11, + height: 1.4, + ), + ), + ); +} + +class LoginFooter extends StatelessWidget { + const LoginFooter({super.key}); + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.only(top: 16, bottom: 8), + child: Text( + 'Marianum Fulda. Die persönliche Schule.', + textAlign: TextAlign.center, + style: TextStyle( + color: Colors.white.withValues(alpha: 0.7), + fontSize: 12, + fontStyle: FontStyle.italic, + ), + ), + ); +} diff --git a/lib/view/login/widgets/login_card.dart b/lib/view/login/widgets/login_card.dart new file mode 100644 index 0000000..2ca2990 --- /dev/null +++ b/lib/view/login/widgets/login_card.dart @@ -0,0 +1,177 @@ +import 'package:flutter/material.dart'; + +import '../login_controller.dart'; +import 'login_error_banner.dart'; + +/// White Card hosting the login form (heading, two text fields, error +/// banner, submit button). Submitting calls [controller.submit] and signals +/// success via [onSuccess]. +class LoginCard extends StatefulWidget { + final LoginController controller; + final VoidCallback onSuccess; + + const LoginCard({ + required this.controller, + required this.onSuccess, + super.key, + }); + + @override + State createState() => _LoginCardState(); +} + +class _LoginCardState extends State { + final _formKey = GlobalKey(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + final _passwordFocus = FocusNode(); + + @override + void initState() { + super.initState(); + widget.controller.addListener(_onControllerChange); + } + + @override + void dispose() { + widget.controller.removeListener(_onControllerChange); + _usernameController.dispose(); + _passwordController.dispose(); + _passwordFocus.dispose(); + super.dispose(); + } + + void _onControllerChange() { + if (mounted) setState(() {}); + } + + String? _required(String? value) => + (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; + + Future _submit() async { + if (widget.controller.loading) return; + if (!(_formKey.currentState?.validate() ?? false)) return; + final ok = await widget.controller.submit( + _usernameController.text, + _passwordController.text, + ); + if (ok && mounted) widget.onSuccess(); + } + + InputDecoration _decoration(ThemeData theme, String label, IconData icon) => + InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHighest.withValues( + alpha: 0.4, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: theme.colorScheme.primary, width: 1.5), + ), + ); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final loading = widget.controller.loading; + return Card( + elevation: 8, + shadowColor: Colors.black.withValues(alpha: 0.35), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + color: theme.colorScheme.surface, + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 24, 24, 20), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + 'Anmelden', + style: theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 6), + Text( + 'Melde dich mit deinen Marianum-Zugangsdaten an.', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 20), + TextFormField( + controller: _usernameController, + enabled: !loading, + validator: _required, + autocorrect: false, + textInputAction: TextInputAction.next, + onFieldSubmitted: (_) => _passwordFocus.requestFocus(), + decoration: _decoration( + theme, + 'Nutzername', + Icons.person_outline, + ), + ), + const SizedBox(height: 12), + TextFormField( + controller: _passwordController, + focusNode: _passwordFocus, + enabled: !loading, + validator: _required, + obscureText: true, + obscuringCharacter: '•', + autocorrect: false, + enableSuggestions: false, + keyboardType: TextInputType.visiblePassword, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _submit(), + decoration: _decoration(theme, 'Passwort', Icons.lock_outline), + ), + LoginErrorBanner( + message: widget.controller.errorMessage, + details: widget.controller.errorDetails, + ), + const SizedBox(height: 20), + SizedBox( + height: 50, + child: FilledButton( + onPressed: loading ? null : _submit, + style: FilledButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + textStyle: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + child: loading + ? const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: Colors.white, + ), + ) + : const Text('Anmelden'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/view/login/widgets/login_error_banner.dart b/lib/view/login/widgets/login_error_banner.dart new file mode 100644 index 0000000..87d2ff4 --- /dev/null +++ b/lib/view/login/widgets/login_error_banner.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; + +import '../../../widget/info_dialog.dart'; + +/// Tappable error banner shown beneath the login form. Animates in/out via +/// AnimatedSize. When [details] is non-null, tapping opens an InfoDialog +/// with the technical error text. +class LoginErrorBanner extends StatelessWidget { + final String? message; + final String? details; + + const LoginErrorBanner({ + required this.message, + required this.details, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AnimatedSize( + duration: const Duration(milliseconds: 180), + curve: Curves.easeOut, + child: message == null + ? const SizedBox(height: 0, width: double.infinity) + : Padding( + padding: const EdgeInsets.only(top: 14), + child: Material( + color: theme.colorScheme.errorContainer.withValues(alpha: 0.6), + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: details != null + ? () => InfoDialog.show( + context, + details!, + copyable: true, + title: 'Fehlerdetails', + ) + : null, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 10, + ), + child: Row( + children: [ + Icon( + Icons.error_outline, + size: 20, + color: theme.colorScheme.onErrorContainer, + ), + const SizedBox(width: 10), + Expanded( + child: Text( + message!, + style: TextStyle( + color: theme.colorScheme.onErrorContainer, + fontSize: 13, + height: 1.3, + ), + ), + ), + if (details != null) ...[ + const SizedBox(width: 8), + Icon( + Icons.chevron_right, + size: 20, + color: theme.colorScheme.onErrorContainer + .withValues(alpha: 0.7), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/files/data/sort_options.dart b/lib/view/pages/files/data/sort_options.dart new file mode 100644 index 0000000..ab78fdf --- /dev/null +++ b/lib/view/pages/files/data/sort_options.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; + +enum SortOption { name, date, size } + +class BetterSortOption { + final String displayName; + final int Function(CacheableFile, CacheableFile) compare; + final IconData icon; + + BetterSortOption({ + required this.displayName, + required this.icon, + required this.compare, + }); +} + +class SortOptions { + static final Map options = { + SortOption.name: BetterSortOption( + displayName: 'Name', + icon: Icons.sort_by_alpha_outlined, + compare: (a, b) => a.name.compareTo(b.name), + ), + SortOption.date: BetterSortOption( + displayName: 'Datum', + icon: Icons.history_outlined, + compare: (a, b) => a.modifiedAt!.compareTo(b.modifiedAt!), + ), + SortOption.size: BetterSortOption( + displayName: 'Größe', + icon: Icons.sd_card_outlined, + compare: (a, b) { + if (a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; + if (a.size == null) return 0; + if (b.size == null) return 1; + return a.size!.compareTo(b.size!); + }, + ), + }; + + static BetterSortOption getOption(SortOption option) => options[option]!; +} diff --git a/lib/view/pages/files/fileElement.dart b/lib/view/pages/files/fileElement.dart deleted file mode 100644 index cd8b772..0000000 --- a/lib/view/pages/files/fileElement.dart +++ /dev/null @@ -1,189 +0,0 @@ -import 'dart:io'; - -import 'package:filesize/filesize.dart'; -import 'package:flowder/flowder.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:open_filex/open_filex.dart'; -import '../../../widget/infoDialog.dart'; -import 'package:nextcloud/nextcloud.dart'; -import 'package:path_provider/path_provider.dart'; - -import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../model/endpointData.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/fileViewer.dart'; -import '../../../widget/unimplementedDialog.dart'; -import 'files.dart'; - -class FileElement extends StatefulWidget { - final CacheableFile file; - final List path; - final void Function() refetch; - const FileElement(this.file, this.path, this.refetch, {super.key}); - - static Future download(BuildContext context, String remotePath, String name, Function(double) onProgress, Function(OpenResult) onDone) async { - var paths = await getTemporaryDirectory(); - - var encodedPath = Uri.encodeComponent(remotePath); - encodedPath = encodedPath.replaceAll('%2F', '/'); - - var local = paths.path + Platform.pathSeparator + name; - - var options = DownloaderUtils( - progressCallback: (current, total) { - final progress = (current / total) * 100; - onProgress(progress); - }, - file: File(local), - progress: ProgressImplementation(), - deleteOnCancel: true, - onDone: () { - //Future result = OpenFile.open(local); // TODO legacy - refactor: remove onDone parameter - Navigator.of(context).push(MaterialPageRoute(builder: (context) => FileViewer(path: local))); - onDone(OpenResult(message: 'File viewer opened', type: ResultType.done)); - // result.then((value) => { - // onDone(value) - // }); - }, - ); - - return await Flowder.download( - '${await WebdavApi.webdavConnectString}$encodedPath', - options, - ); - } - - @override - State createState() => _FileElementState(); -} - -class _FileElementState extends State { - double percent = 0; - Future? downloadCore; - - Widget getSubtitle() { - if(widget.file.currentlyDownloading) { - return Row( - children: [ - Container( - margin: const EdgeInsets.only(right: 10), - child: const Text('Download:'), - ), - Expanded( - child: LinearProgressIndicator(value: percent/100), - ), - Container( - margin: const EdgeInsets.only(left: 10), - child: Text('${percent.round()}%'), - ), - ], - ); - } - return widget.file.isDirectory - ? Text('geändert ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}') - : Text('${filesize(widget.file.size)}, ${Jiffy.parseFromDateTime(widget.file.modifiedAt ?? DateTime.now()).fromNow()}'); - } - - @override - Widget build(BuildContext context) => ListTile( - leading: CenteredLeading( - Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined) - ), - title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), - subtitle: getSubtitle(), - trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), - onTap: () { - if(widget.file.isDirectory) { - Navigator.of(context).push(MaterialPageRoute( - builder: (context) => Files(path: widget.path.toList()..add(widget.file.name)), - )); - } else { - if(EndpointData().getEndpointMode() == EndpointMode.stage) { - InfoDialog.show(context, 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!'); - return; - } - if(widget.file.currentlyDownloading) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Download abbrechen?', - content: 'Möchtest du den Download abbrechen?', - cancelButton: 'Nein', - confirmButton: 'Ja, Abbrechen', - onConfirm: () { - downloadCore?.then((value) { - if(!value.isCancelled) value.cancel(); - }); - setState(() { - widget.file.currentlyDownloading = false; - percent = 0; - downloadCore = null; - }); - }, - ), - ); - - return; - } - - setState(() { - widget.file.currentlyDownloading = true; - }); - - downloadCore = FileElement.download(context, widget.file.path, widget.file.name, (progress) { - setState(() => percent = progress); - }, (result) { - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Download'), - content: Text(result.message), - )); - } - - setState(() { - widget.file.currentlyDownloading = false; - percent = 0; - }); - }); - - } - }, - onLongPress: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Löschen'), - onTap: () { - Navigator.of(context).pop(); - showDialog(context: context, builder: (context) => ConfirmDialog( - title: 'Element löschen?', - content: 'Das Element wird unwiederruflich gelöscht.', - onConfirm: () { - WebdavApi.webdav - .then((value) => value.delete(PathUri.parse(widget.file.path))) - .then((value) => widget.refetch()); - } - )); - }, - ), - Visibility( - visible: !kReleaseMode, - child: ListTile( - leading: const Icon(Icons.share_outlined), - title: const Text('Teilen'), - onTap: () { - Navigator.of(context).pop(); - UnimplementedDialog.show(context); - }, - ), - ), - ], - )); - }, - ); -} diff --git a/lib/view/pages/files/fileUploadDialog.dart b/lib/view/pages/files/fileUploadDialog.dart deleted file mode 100644 index e69de29..0000000 diff --git a/lib/view/pages/files/files.dart b/lib/view/pages/files/files.dart index 86ab4f4..b860b7e 100644 --- a/lib/view/pages/files/files.dart +++ b/lib/view/pages/files/files.dart @@ -1,166 +1,116 @@ - -import 'dart:io'; +import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:loader_overlay/loader_overlay.dart'; -import 'package:nextcloud/nextcloud.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/cacheableFile.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesCache.dart'; -import '../../../api/marianumcloud/webdav/queries/listFiles/listFilesResponse.dart'; -import '../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../model/files/filesProps.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import '../../../widget/filePick.dart'; -import 'fileElement.dart'; -import 'filesUploadDialog.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/files/bloc/files_bloc.dart'; +import '../../../state/app/modules/files/bloc/files_state.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../utils/cache_invalidation_bus.dart'; +import '../../../widget/placeholder_view.dart'; +import 'data/sort_options.dart'; +import 'files_upload_dialog.dart'; +import 'widgets/add_file_menu.dart'; +import 'widgets/clipboard_banner.dart'; +import 'widgets/file_element.dart'; +import 'widgets/files_sort_actions.dart'; -class Files extends StatefulWidget { +class Files extends StatelessWidget { final List path; + Files({List? path, super.key}) : path = path ?? []; @override - State createState() => _FilesState(); + Widget build(BuildContext context) => + BlocModule>( + create: (_) => FilesBloc(initialPath: path), + child: (context, _, _) => _FilesView(path: path), + ); } -class BetterSortOption { - String displayName; - int Function(CacheableFile, CacheableFile) compare; - IconData icon; +class _FilesView extends StatefulWidget { + final List path; + const _FilesView({required this.path}); - BetterSortOption({required this.displayName, required this.icon, required this.compare}); + @override + State<_FilesView> createState() => _FilesViewState(); } -enum SortOption { - name, - date, - size -} +class _FilesViewState extends State<_FilesView> { + late final SettingsCubit settings; + late SortOption currentSort; + late bool currentSortDirection; + late final StreamSubscription _invalidationSub; -class SortOptions { - static Map options = { - SortOption.name: BetterSortOption( - displayName: 'Name', - icon: Icons.sort_by_alpha_outlined, - compare: (CacheableFile a, CacheableFile b) => a.name.compareTo(b.name) - ), - SortOption.date: BetterSortOption( - displayName: 'Datum', - icon: Icons.history_outlined, - compare: (CacheableFile a, CacheableFile b) => a.modifiedAt!.compareTo(b.modifiedAt!) - ), - SortOption.size: BetterSortOption( - displayName: 'Größe', - icon: Icons.sd_card_outlined, - compare: (CacheableFile a, CacheableFile b) { - if(a.isDirectory || b.isDirectory) return a.isDirectory ? 1 : 0; - if(a.size == null) return 0; - if(b.size == null) return 1; - return a.size!.compareTo(b.size!); - } - ) - }; + // Cache key in FilesBloc's pathString format: '/' for root, otherwise + // segments joined without leading/trailing slash. + String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/'); - static BetterSortOption getOption(SortOption option) => options[option]!; -} - -class _FilesState extends State { - FilesProps props = FilesProps(); - ListFilesResponse? data; - - late SettingsProvider settings = Provider.of(context, listen: false); - - SortOption currentSort = SortOption.name; - bool currentSortDirection = true; + // Relative folder path matching the WebDAV format used by `CacheableFile.path` + // (no leading slash; trailing slash for non-root). Empty string means root. + String get _currentFolderPath => + widget.path.isEmpty ? '' : '${widget.path.join('/')}/'; @override void initState() { super.initState(); + settings = context.read(); currentSort = settings.val().fileSettings.sortBy; currentSortDirection = settings.val().fileSettings.ascending; - _query(); - } - - void _query() { - ListFilesCache( - path: widget.path.isEmpty ? '/' : widget.path.join('/'), - onUpdate: (ListFilesResponse d) { - d.files.removeWhere((element) => element.name.isEmpty || element.name == widget.path.lastOrNull()); - setState(() { - data = d; - }); - } + _invalidationSub = CacheInvalidationBus.listFilesStream.listen( + _onInvalidation, ); } - Future mediaUpload(List? paths) async { - if(paths == null) return; + void _onInvalidation(String invalidatedPath) { + if (!mounted) return; + if (invalidatedPath != _myPathString) return; + context.read().refresh(); + } - pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog(filePaths: paths, remotePath: widget.path.join('/'), onUploadFinished: (uploadedFilePaths) => _query()), + @override + void dispose() { + _invalidationSub.cancel(); + super.dispose(); + } + + Future _mediaUpload(List? paths) async { + if (paths == null) return; + final bloc = context.read(); + unawaited( + pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: paths, + remotePath: widget.path.join('/'), + onUploadFinished: (_) => bloc.refresh(), + ), + ), ); - - return; } @override Widget build(BuildContext context) { - var files = data?.sortBy( - sortOption: currentSort, - foldersToTop: Provider.of(context).val().fileSettings.sortFoldersToTop, - reversed: currentSortDirection - ) ?? List.empty(); - + final bloc = context.read(); return Scaffold( appBar: AppBar( title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), actions: [ - // IconButton( - // icon: const Icon(Icons.search), - // onPressed: () => { - // // TODO implement search - // }, - // ), - PopupMenuButton( - icon: Icon(currentSortDirection ? Icons.text_rotate_up : Icons.text_rotation_down), - itemBuilder: (context) => [true, false].map((e) => PopupMenuItem( - value: e, - enabled: e != currentSortDirection, - child: Row( - children: [ - Icon(e ? Icons.text_rotate_up : Icons.text_rotation_down, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(e ? 'Aufsteigend' : 'Absteigend') - ], - ) - )).toList(), - onSelected: (e) { + FilesSortActions( + currentSort: currentSort, + ascending: currentSortDirection, + onDirectionChanged: (e) { setState(() { currentSortDirection = e; settings.val(write: true).fileSettings.ascending = e; }); }, - ), - PopupMenuButton( - icon: const Icon(Icons.sort), - itemBuilder: (context) => SortOptions.options.keys.map((key) => PopupMenuItem( - value: key, - enabled: key != currentSort, - child: Row( - children: [ - Icon(SortOptions.getOption(key).icon, color: Theme.of(context).colorScheme.onSurface), - const SizedBox(width: 15), - Text(SortOptions.getOption(key).displayName), - ], - ) - )).toList(), - onSelected: (e) { + onSortChanged: (e) { setState(() { currentSort = e; settings.val(write: true).fileSettings.sortBy = e; @@ -172,81 +122,47 @@ class _FilesState extends State { floatingActionButton: FloatingActionButton( heroTag: 'uploadFile', backgroundColor: Theme.of(context).primaryColor, - onPressed: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.create_new_folder_outlined), - title: const Text('Ordner erstellen'), - onTap: () { - Navigator.of(context).pop(); - showDialog(context: context, builder: (context) { - var inputController = TextEditingController(); - return AlertDialog( - title: const Text('Neuer Ordner'), - content: TextField( - controller: inputController, - decoration: const InputDecoration( - labelText: 'Name', - ), - ), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Abbrechen')), - TextButton(onPressed: () { - WebdavApi.webdav.then((webdav) { - webdav.mkcol(PathUri.parse("${widget.path.join("/")}/${inputController.text}")).then((value) => _query()); - }); - Navigator.of(context).pop(); - }, child: const Text('Ordner erstellen')), - ], - ); - }); - }, - ), - ListTile( - leading: const Icon(Icons.upload_file), - title: const Text('Aus Dateien hochladen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(context).pop(); - }, - ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.add_a_photo_outlined), - title: const Text('Aus Gallerie hochladen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if(value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(context).pop(); - }, - ), - ), - ], - )); - }, + onPressed: () => + showAddFileSheet(context, bloc: bloc, onPickedFiles: _mediaUpload), child: const Icon(Icons.add), ), - body: data == null ? const LoadingSpinner() : data!.files.isEmpty ? const PlaceholderView(icon: Icons.folder_off_rounded, text: 'Der Ordner ist leer') : LoaderOverlay( - child: RefreshIndicator( - onRefresh: () { - _query(); - return Future.delayed(const Duration(seconds: 3)); - }, - child: ListView.builder( - padding: EdgeInsets.zero, - itemCount: files.length, - itemBuilder: (context, index) { - var file = files.toList()[index]; - return FileElement(file, widget.path, _query); - }, + body: Column( + children: [ + ClipboardBanner( + currentFolder: _currentFolderPath, + onPasteDone: bloc.refresh, ), - ) - ) + Expanded( + child: LoadableStateConsumer( + isReady: (state) => state.listing != null, + child: (state, _) { + final listing = state.listing!; + if (listing.files.isEmpty) { + return const PlaceholderView( + icon: Icons.folder_off_rounded, + text: 'Der Ordner ist leer', + ); + } + final files = listing.sortBy( + sortOption: currentSort, + foldersToTop: context + .watch() + .val() + .fileSettings + .sortFoldersToTop, + reversed: currentSortDirection, + ); + return ListView.builder( + padding: EdgeInsets.zero, + itemCount: files.length, + itemBuilder: (context, index) => + FileElement(files[index], widget.path, bloc.refresh), + ); + }, + ), + ), + ], + ), ); } } diff --git a/lib/view/pages/files/filesUploadDialog.dart b/lib/view/pages/files/filesUploadDialog.dart deleted file mode 100644 index 72803c2..0000000 --- a/lib/view/pages/files/filesUploadDialog.dart +++ /dev/null @@ -1,311 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import 'package:loader_overlay/loader_overlay.dart'; -import 'package:nextcloud/nextcloud.dart'; -import 'package:uuid/uuid.dart'; - -import '../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/focusBehaviour.dart'; - -class FilesUploadDialog extends StatefulWidget { - final List filePaths; - final String remotePath; - final void Function(List uploadedFilePaths) onUploadFinished; - final bool uniqueNames; - - const FilesUploadDialog({super.key, required this.filePaths, required this.remotePath, required this.onUploadFinished, this.uniqueNames = false}); - - @override - State createState() => _FilesUploadDialogState(); -} - -class UploadableFile { - TextEditingController fileNameController = TextEditingController(); - String filePath; - String fileName; - double? _uploadProgress; - bool isConflicting = false; - - UploadableFile(this.filePath, this.fileName); -} - - -class _FilesUploadDialogState extends State { - late List _uploadableFiles; - bool _isUploading = false; - double _overallProgressValue = 0.0; - String _infoText = ''; - - @override - void initState() { - super.initState(); - - _uploadableFiles = widget.filePaths.map((filePath) { - var fileName = filePath.split(Platform.pathSeparator).last; - return UploadableFile(filePath, fileName); - }).toList(); - } - - void showHttpErrorCode(int httpErrorCode){ - showDialog( - context: context, - builder: (BuildContext context) => AlertDialog( - title: const Text('Ein Fehler ist aufgetreten'), - contentPadding: const EdgeInsets.all(10), - content: Text('Error code: $httpErrorCode'), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Schließen', textAlign: TextAlign.center), - ), - ], - ) - ); - } - - Future uploadFiles({bool override = false}) async { - setState(() { - _isUploading = true; - _infoText = 'Vorbereiten'; - for (var file in _uploadableFiles) { - file.isConflicting = false; - } - }); - - var webdavClient = await WebdavApi.webdav; - - if (!override) { - var result = (await webdavClient.propfind(PathUri.parse(widget.remotePath))).responses; - var conflictingFiles = _uploadableFiles.where((file) { - var fileName = file.fileName; - return result.any((element) => Uri.decodeComponent(element.href!).endsWith('/$fileName')); - }).toList(); - - if(conflictingFiles.isNotEmpty) { - bool replaceFiles = await showDialog( - context: context, - barrierDismissible: false, - builder: (context) => AlertDialog( - contentPadding: const EdgeInsets.all(10), - title: const Text('Konflikt', textAlign: TextAlign.center), - content: conflictingFiles.length == 1 ? - Text( - 'Eine Datei mit dem Namen "${conflictingFiles.map((e) => e.fileName).first}" existiert bereits.', - textAlign: TextAlign.left, - ) : - SingleChildScrollView( - child: Text( - '${conflictingFiles.length} Dateien mit folgenden Namen existieren bereits: \n${conflictingFiles.map((e) => '\n - ${e.fileName}').join('')}', - textAlign: TextAlign.left, - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context, false); - }, - child: const Text('Bearbeiten', textAlign: TextAlign.center), - ), - TextButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Bestätigen?', - content: 'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?', - onConfirm: () { - Navigator.pop(context, true); - }, - confirmButton: 'Ja', - cancelButton: 'Nein', - ), - ); - - }, - child: const Text('Überschreiben', textAlign: TextAlign.center), - ), - ], - ) - ); - - if(!replaceFiles) { - setState(() { - _isUploading = false; - _overallProgressValue = 0.0; - _infoText = ''; - for (var element in conflictingFiles) { - element.isConflicting = true; - } - }); - return; - } - } - } - - var uploadetFilePaths = []; - for (var file in _uploadableFiles) { - var fileName = file.fileName; - var filePath = file.filePath; - - if(widget.uniqueNames) fileName = '${fileName.split('.').first}-${const Uuid().v4()}.${fileName.split('.').last}'; - - var fullRemotePath = '${widget.remotePath}/$fileName'; - - setState(() { - _infoText = '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; - }); - - var uploadTask = await webdavClient.putFile( - File(filePath), - FileStat.statSync(filePath), - PathUri.parse(fullRemotePath), - onProgress: (progress) { - setState(() { - file._uploadProgress = progress; - _overallProgressValue = ((progress + _uploadableFiles.indexOf(file)) / _uploadableFiles.length).toDouble(); - }); - }, - ); - - if(uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { - setState(() { - _isUploading = false; - _overallProgressValue = 0.0; - _infoText = ''; - }); - Navigator.of(context).pop(); - showHttpErrorCode(uploadTask.statusCode); - } else { - uploadetFilePaths.add(fullRemotePath); - } - } - - setState(() { - _isUploading = false; - _overallProgressValue = 0.0; - _infoText = ''; - }); - Navigator.of(context).pop(); - widget.onUploadFinished(uploadetFilePaths); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Dateien hochladen'), - automaticallyImplyLeading: false, - ), - body: LoaderOverlay( - overlayWholeScreen: true, - child: Column( - children: [ - Expanded( - child: ListView.builder( - itemCount: _uploadableFiles.length, - itemBuilder: (context, index) { - final currentFile = _uploadableFiles[index]; - currentFile.fileNameController.text = currentFile.fileName; - return ListTile( - title: TextField( - readOnly: _isUploading, - controller: currentFile.fileNameController, - decoration: InputDecoration( - border: const UnderlineInputBorder(), - label: Text('Datei ${index+1}'), - errorText: currentFile.isConflicting ? 'existiert bereits' : null, - errorStyle: const TextStyle(color: Colors.red), - ), - onChanged: (input) { - currentFile.fileName = input; - }, - onTapOutside: (PointerDownEvent event) { - FocusBehaviour.textFieldTapOutside(context); - if(currentFile.isConflicting){ - setState(() { - currentFile.isConflicting = false; - }); - } - }, - onEditingComplete: () { - if(currentFile.isConflicting){ - setState(() { - currentFile.isConflicting = false; - }); - } - }, - ), - subtitle: _isUploading && (currentFile._uploadProgress ?? 0) < 1 ? LinearProgressIndicator( - value: currentFile._uploadProgress, - borderRadius: const BorderRadius.all(Radius.circular(2)), - ) : null, - trailing: Container( - width: 24, - height: 24, - padding: EdgeInsets.zero, - child: IconButton( - tooltip: 'Datei entfernen', - padding: EdgeInsets.zero, - onPressed: () { - if(!_isUploading) { - if(_uploadableFiles.length-1 <= 0) Navigator.of(context).pop(); - setState(() { - _uploadableFiles.removeAt(index); - }); - } - }, - icon: const Icon(Icons.delete_outlined), - ), - ), - ); - }, - ), - ), - Padding( - padding: const EdgeInsets.only(left: 15, right: 15, bottom: 15, top: 5), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Visibility( - visible: !_isUploading, - child: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Abbrechen'), - ), - ), - const Expanded(child: SizedBox.shrink()), - Visibility( - visible: _isUploading, - replacement: TextButton( - onPressed: () => uploadFiles(override: widget.uniqueNames), - child: const Text('Hochladen'), - ), - child: Visibility( - visible: _infoText.length < 5, - replacement: Row( - children: [ - Text(_infoText), - const SizedBox(width: 15), - CircularProgressIndicator(value: _overallProgressValue), - ], - ), - child: Stack( - alignment: Alignment.center, - children: [ - CircularProgressIndicator(value: _overallProgressValue), - Center(child: Text(_infoText)), - ], - ), - ), - - - ), - ], - ), - ), - ], - ), - ), - ); -} diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart new file mode 100644 index 0000000..273557c --- /dev/null +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -0,0 +1,367 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:loader_overlay/loader_overlay.dart'; +import 'package:nextcloud/nextcloud.dart'; + +import '../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../widget/confirm_dialog.dart'; +import '../../../widget/focus_behaviour.dart'; +import '../../../widget/info_dialog.dart'; + +class FilesUploadDialog extends StatefulWidget { + final List filePaths; + final String remotePath; + final void Function(List uploadedFilePaths) onUploadFinished; + final bool uniqueNames; + + const FilesUploadDialog({ + super.key, + required this.filePaths, + required this.remotePath, + required this.onUploadFinished, + this.uniqueNames = false, + }); + + @override + State createState() => _FilesUploadDialogState(); +} + +class UploadableFile { + TextEditingController fileNameController = TextEditingController(); + String filePath; + String fileName; + double? _uploadProgress; + bool isConflicting = false; + + UploadableFile(this.filePath, this.fileName); +} + +class _FilesUploadDialogState extends State { + late List _uploadableFiles; + bool _isUploading = false; + double _overallProgressValue = 0.0; + String _infoText = ''; + + @override + void initState() { + super.initState(); + + _uploadableFiles = widget.filePaths.map((filePath) { + var fileName = filePath.split(Platform.pathSeparator).last; + return UploadableFile(filePath, fileName); + }).toList(); + } + + void showHttpErrorCode(int httpErrorCode) { + InfoDialog.show( + context, + 'Error code: $httpErrorCode', + title: 'Ein Fehler ist aufgetreten', + copyable: true, + ); + } + + void _showUploadError(String message) { + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + }); + InfoDialog.show( + context, + message, + title: 'Upload fehlgeschlagen', + copyable: true, + ); + } + + Future uploadFiles({bool override = false}) async { + setState(() { + _isUploading = true; + _infoText = 'Vorbereiten'; + for (var file in _uploadableFiles) { + file.isConflicting = false; + } + }); + + final webdavClient = await WebdavApi.webdav; + + if (!override) { + List result; + try { + result = (await webdavClient.propfind( + PathUri.parse(widget.remotePath), + )).responses; + } catch (e) { + if (!mounted) return; + _showUploadError('Verbindung fehlgeschlagen: $e'); + return; + } + final conflictingFiles = _uploadableFiles.where((file) { + final fileName = file.fileName; + return result.any( + (element) => Uri.decodeComponent( + (element as WebDavResponse).href!, + ).endsWith('/$fileName'), + ); + }).toList(); + + if (conflictingFiles.isNotEmpty) { + if (!mounted) return; + final replaceFiles = await showDialog( + context: context, + barrierDismissible: false, + builder: (context) => AlertDialog( + contentPadding: const EdgeInsets.all(10), + title: const Text('Konflikt', textAlign: TextAlign.center), + content: conflictingFiles.length == 1 + ? Text( + 'Eine Datei mit dem Namen "${conflictingFiles.map((e) => e.fileName).first}" existiert bereits.', + textAlign: TextAlign.left, + ) + : SingleChildScrollView( + child: Text( + '${conflictingFiles.length} Dateien mit folgenden Namen existieren bereits: \n${conflictingFiles.map((e) => '\n - ${e.fileName}').join('')}', + textAlign: TextAlign.left, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Bearbeiten', textAlign: TextAlign.center), + ), + TextButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Bestätigen?', + content: + 'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?', + onConfirm: () { + Navigator.pop(context, true); + }, + confirmButton: 'Ja', + cancelButton: 'Nein', + ), + ); + }, + child: const Text('Überschreiben', textAlign: TextAlign.center), + ), + ], + ), + ); + + if (replaceFiles != true) { + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + for (var element in conflictingFiles) { + element.isConflicting = true; + } + }); + return; + } + } + } + + var uploadetFilePaths = []; + for (var file in _uploadableFiles) { + var fileName = file.fileName; + var filePath = file.filePath; + + if (widget.uniqueNames) { + final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36); + fileName = + '${fileName.split('.').first}-$unique.${fileName.split('.').last}'; + } + + var fullRemotePath = '${widget.remotePath}/$fileName'; + + setState(() { + _infoText = + '${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; + }); + + final HttpClientResponse uploadTask; + try { + uploadTask = await webdavClient.putFile( + File(filePath), + FileStat.statSync(filePath), + PathUri.parse(fullRemotePath), + onProgress: (progress) { + setState(() { + file._uploadProgress = progress; + _overallProgressValue = + ((progress + _uploadableFiles.indexOf(file)) / + _uploadableFiles.length) + .toDouble(); + }); + }, + ); + } catch (e) { + if (!mounted) return; + _showUploadError('Upload fehlgeschlagen für "$fileName": $e'); + return; + } + + if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + }); + if (!mounted) return; + Navigator.of(context).pop(); + showHttpErrorCode(uploadTask.statusCode); + } else { + uploadetFilePaths.add(fullRemotePath); + } + } + + setState(() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + }); + if (!mounted) return; + Navigator.of(context).pop(); + widget.onUploadFinished(uploadetFilePaths); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Dateien hochladen'), + automaticallyImplyLeading: false, + ), + body: LoaderOverlay( + overlayWholeScreen: true, + child: Column( + children: [ + Expanded( + child: ListView.builder( + itemCount: _uploadableFiles.length, + itemBuilder: (context, index) { + final currentFile = _uploadableFiles[index]; + currentFile.fileNameController.text = currentFile.fileName; + return ListTile( + title: TextField( + readOnly: _isUploading, + controller: currentFile.fileNameController, + decoration: InputDecoration( + border: const UnderlineInputBorder(), + label: Text('Datei ${index + 1}'), + errorText: currentFile.isConflicting + ? 'existiert bereits' + : null, + errorStyle: TextStyle( + color: Theme.of(context).colorScheme.error, + ), + ), + onChanged: (input) { + currentFile.fileName = input; + }, + onTapOutside: (PointerDownEvent event) { + FocusBehaviour.textFieldTapOutside(context); + if (currentFile.isConflicting) { + setState(() { + currentFile.isConflicting = false; + }); + } + }, + onEditingComplete: () { + if (currentFile.isConflicting) { + setState(() { + currentFile.isConflicting = false; + }); + } + }, + ), + subtitle: + _isUploading && (currentFile._uploadProgress ?? 0) < 1 + ? LinearProgressIndicator( + value: currentFile._uploadProgress, + borderRadius: const BorderRadius.all( + Radius.circular(2), + ), + ) + : null, + trailing: Container( + width: 24, + height: 24, + padding: EdgeInsets.zero, + child: IconButton( + tooltip: 'Datei entfernen', + padding: EdgeInsets.zero, + onPressed: () { + if (!_isUploading) { + if (_uploadableFiles.length - 1 <= 0) { + Navigator.of(context).pop(); + } + setState(() { + _uploadableFiles.removeAt(index); + }); + } + }, + icon: const Icon(Icons.delete_outlined), + ), + ), + ); + }, + ), + ), + Padding( + padding: const EdgeInsets.only( + left: 15, + right: 15, + bottom: 15, + top: 5, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Visibility( + visible: !_isUploading, + child: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Abbrechen'), + ), + ), + const Expanded(child: SizedBox.shrink()), + Visibility( + visible: _isUploading, + replacement: TextButton( + onPressed: () => uploadFiles(override: widget.uniqueNames), + child: const Text('Hochladen'), + ), + child: Visibility( + visible: _infoText.length < 5, + replacement: Row( + children: [ + Text(_infoText), + const SizedBox(width: 15), + CircularProgressIndicator(value: _overallProgressValue), + ], + ), + child: Stack( + alignment: Alignment.center, + children: [ + CircularProgressIndicator(value: _overallProgressValue), + Center(child: Text(_infoText)), + ], + ), + ), + ), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/view/pages/files/widgets/add_file_menu.dart b/lib/view/pages/files/widgets/add_file_menu.dart new file mode 100644 index 0000000..508fe7d --- /dev/null +++ b/lib/view/pages/files/widgets/add_file_menu.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +import '../../../../state/app/modules/files/bloc/files_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/file_pick.dart'; + +/// Opens the "Element hinzufügen" sheet (create folder, upload, take photo, …). +/// [onPickedFiles] receives selected/captured file paths (gallery, file picker +/// or camera) and is responsible for kicking off the upload flow. +void showAddFileSheet( + BuildContext context, { + required FilesBloc bloc, + required Future Function(List? paths) onPickedFiles, +}) { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const Icon(Icons.create_new_folder_outlined), + title: const Text('Ordner erstellen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _showCreateFolderDialog(context, bloc); + }, + ), + ListTile( + leading: const Icon(Icons.upload_file), + title: const Text('Aus Dateien hochladen'), + onTap: () { + FilePick.documentPick().then(onPickedFiles); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.add_a_photo_outlined), + title: const Text('Aus Galerie hochladen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) onPickedFiles(value.map((e) => e.path).toList()); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) onPickedFiles([image.path]); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ], + ); +} + +void _showCreateFolderDialog(BuildContext context, FilesBloc bloc) { + final inputController = TextEditingController(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Neuer Ordner'), + content: TextField( + controller: inputController, + decoration: const InputDecoration(labelText: 'Name'), + autofocus: true, + ), + actions: [ + AsyncDialogAction( + confirmLabel: 'Ordner erstellen', + onConfirm: () async { + if (inputController.text.trim().isEmpty) { + throw Exception('Bitte einen Namen eingeben.'); + } + await bloc.createFolder(inputController.text.trim()); + }, + ), + ], + ), + ); +} diff --git a/lib/view/pages/files/widgets/clipboard_banner.dart b/lib/view/pages/files/widgets/clipboard_banner.dart new file mode 100644 index 0000000..8b1a078 --- /dev/null +++ b/lib/view/pages/files/widgets/clipboard_banner.dart @@ -0,0 +1,173 @@ +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../utils/file_clipboard.dart'; +import '../../../../widget/info_dialog.dart'; + +/// Banner that appears at the top of a Files folder while there is something +/// in the file clipboard. Shows the cut/copy state and offers a "Hier +/// einfügen" button. +class ClipboardBanner extends StatefulWidget { + final String currentFolder; + final VoidCallback onPasteDone; + + const ClipboardBanner({ + required this.currentFolder, + required this.onPasteDone, + super.key, + }); + + @override + State createState() => _ClipboardBannerState(); +} + +class _ClipboardBannerState extends State { + bool _busy = false; + + // All paths here are relative to the WebDAV root (matching `CacheableFile.path`). + // Root is the empty string ''. Folders end with '/'. + String _normalised(String path) { + final stripped = path.replaceAll(RegExp(r'^/+|/+$'), ''); + return stripped.isEmpty ? '' : '$stripped/'; + } + + String _joinPath(String folder, String name, {required bool isDirectory}) => + isDirectory ? '$folder$name/' : '$folder$name'; + + // Disabled when: + // - clipboard is empty + // - we'd be pasting a folder into itself or one of its descendants + // - every entry already lives in the current folder (paste would be a no-op) + bool get _canPaste { + final cb = FileClipboard.instance; + if (cb.isEmpty) return false; + final dst = _normalised(widget.currentFolder); + var atLeastOneActionable = false; + for (final f in cb.files) { + if (f.isDirectory) { + final src = _normalised(f.path); + if (dst == src || dst.startsWith(src)) return false; + } + final destination = _joinPath( + widget.currentFolder, + f.name, + isDirectory: f.isDirectory, + ); + if (destination != f.path) atLeastOneActionable = true; + } + return atLeastOneActionable; + } + + // Cache key format used by ListFilesCache (matches FilesBloc's pathString: + // relative, no leading or trailing slash; root is '/'). + String _parentCacheKey(String relativePath) { + final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), ''); + if (!stripped.contains('/')) return '/'; + final parts = stripped.split('/')..removeLast(); + return parts.isEmpty ? '/' : parts.join('/'); + } + + Future _paste() async { + final cb = FileClipboard.instance; + if (_busy || !_canPaste) return; + setState(() => _busy = true); + final operation = cb.operation; + final errors = []; + final invalidatedSourceFolders = {}; + try { + final webdav = await WebdavApi.webdav; + for (final file in cb.files) { + final destination = _joinPath( + widget.currentFolder, + file.name, + isDirectory: file.isDirectory, + ); + if (destination == file.path) continue; + try { + if (operation == FileClipboardOperation.cut) { + await webdav.move( + PathUri.parse(file.path), + PathUri.parse(destination), + ); + invalidatedSourceFolders.add(_parentCacheKey(file.path)); + } else { + await webdav.copy( + PathUri.parse(file.path), + PathUri.parse(destination), + ); + } + } on Object catch (e) { + errors.add('${file.name}: $e'); + } + } + // After cut, the source folders no longer contain the moved files. Drop + // their cached listings so the next visit fetches fresh data instead of + // briefly showing the moved file as still present. + for (final folder in invalidatedSourceFolders) { + await ListFilesCache.invalidate(folder); + } + if (operation == FileClipboardOperation.cut) cb.clear(); + widget.onPasteDone(); + } finally { + if (mounted) setState(() => _busy = false); + } + if (errors.isNotEmpty && mounted) { + InfoDialog.show( + context, + errors.join('\n\n'), + copyable: true, + title: 'Einfügen teilweise fehlgeschlagen', + ); + } + } + + @override + Widget build(BuildContext context) => ListenableBuilder( + listenable: FileClipboard.instance, + builder: (context, _) { + final cb = FileClipboard.instance; + if (cb.isEmpty) return const SizedBox.shrink(); + final cut = cb.operation == FileClipboardOperation.cut; + final count = cb.files.length; + final label = count == 1 ? '"${cb.files.first.name}"' : '$count Elemente'; + return Material( + color: Theme.of(context).colorScheme.secondaryContainer, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + children: [ + Icon( + cut ? Icons.drive_file_move_outline : Icons.copy_outlined, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + cut ? '$label verschieben' : '$label kopieren', + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: _busy || !_canPaste ? null : _paste, + child: _busy + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Hier einfügen'), + ), + IconButton( + tooltip: 'Verwerfen', + icon: const Icon(Icons.close, size: 20), + onPressed: _busy ? null : cb.clear, + ), + ], + ), + ), + ); + }, + ); +} diff --git a/lib/view/pages/files/widgets/file_details_sheet.dart b/lib/view/pages/files/widgets/file_details_sheet.dart new file mode 100644 index 0000000..e2dee4b --- /dev/null +++ b/lib/view/pages/files/widgets/file_details_sheet.dart @@ -0,0 +1,78 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../utils/clipboard_helper.dart'; +import '../../../../widget/details_bottom_sheet.dart'; + +/// Shows a modal bottom sheet with technical metadata about a single file or +/// folder: full path, MIME type, size, timestamps, ETag. +void showFileDetailsSheet(BuildContext context, CacheableFile file) { + showDetailsBottomSheet( + context, + header: ListTile( + leading: Icon( + file.isDirectory ? Icons.folder : Icons.description_outlined, + size: 32, + ), + title: Text( + file.name, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(file.isDirectory ? 'Ordner' : (file.mimeType ?? '–')), + ), + children: (_) => [ + _DetailRow(label: 'Pfad', value: file.path, copyable: true), + if (!file.isDirectory) + _DetailRow(label: 'Größe', value: filesize(file.size)), + if (file.modifiedAt != null) + _DetailRow( + label: 'Geändert', + value: + '${file.modifiedAt!.formatDateTime()} (${file.modifiedAt!.formatRelative()})', + ), + if (file.createdAt != null) + _DetailRow(label: 'Erstellt', value: file.createdAt!.formatDateTime()), + if (file.eTag != null) + _DetailRow(label: 'ETag', value: file.eTag!, copyable: true), + ], + ); +} + +class _DetailRow extends StatelessWidget { + const _DetailRow({ + required this.label, + required this.value, + this.copyable = false, + }); + final String label; + final String value; + final bool copyable; + + @override + Widget build(BuildContext context) => Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 6), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + width: 90, + child: Text( + label, + style: TextStyle( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + Expanded(child: SelectableText(value)), + if (copyable) + IconButton( + tooltip: 'Kopieren', + icon: const Icon(Icons.copy, size: 18), + onPressed: () => copyToClipboard(context, value), + ), + ], + ), + ); +} diff --git a/lib/view/pages/files/widgets/file_element.dart b/lib/view/pages/files/widgets/file_element.dart new file mode 100644 index 0000000..0e64864 --- /dev/null +++ b/lib/view/pages/files/widgets/file_element.dart @@ -0,0 +1,325 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:nextcloud/nextcloud.dart'; + +import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import '../../../../api/marianumcloud/webdav/webdav_api.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../model/endpoint_data.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../utils/download_manager.dart'; +import '../../../../utils/file_clipboard.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/info_dialog.dart'; +import 'file_details_sheet.dart'; + +class FileElement extends StatefulWidget { + final CacheableFile file; + final List path; + final void Function() refetch; + const FileElement(this.file, this.path, this.refetch, {super.key}); + + @override + State createState() => _FileElementState(); +} + +class _FileElementState extends State { + DownloadJob? _job; + + @override + void initState() { + super.initState(); + _attachJob(DownloadManager.instance.jobFor(widget.file.path)); + } + + @override + void didUpdateWidget(covariant FileElement oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.file.path != widget.file.path) { + _detachJob(); + _attachJob(DownloadManager.instance.jobFor(widget.file.path)); + } + } + + @override + void dispose() { + _detachJob(); + super.dispose(); + } + + void _attachJob(DownloadJob? job) { + _job = job; + if (job == null) return; + job.status.addListener(_onStatusChange); + if (job.isFinished) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); + } + } + + void _detachJob() { + _job?.status.removeListener(_onStatusChange); + _job = null; + } + + void _onStatusChange() { + if (!mounted) return; + final job = _job; + if (job == null) return; + final status = job.status.value; + if (status is DownloadDone) { + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + AppRoutes.openFileViewer(context, status.localPath); + setState(() {}); + } else if (status is DownloadFailed) { + final message = status.message; + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + setState(() {}); + InfoDialog.show(context, message, title: 'Download', copyable: true); + } else if (status is DownloadCancelled) { + DownloadManager.instance.clear(widget.file.path); + _detachJob(); + setState(() {}); + } else { + setState(() {}); + } + } + + Future _startDownload() async { + final job = await DownloadManager.instance.start( + remotePath: widget.file.path, + name: widget.file.name, + ); + if (!mounted) return; + if (_job == job) return; + _detachJob(); + _attachJob(job); + setState(() {}); + } + + void _confirmCancel() { + showDialog( + context: context, + builder: (dialogContext) => ConfirmDialog( + title: 'Download abbrechen?', + content: 'Möchtest du den Download abbrechen?', + cancelButton: 'Nein', + confirmButton: 'Ja, Abbrechen', + onConfirm: () => _job?.cancel(), + ), + ); + } + + Widget _subtitle() { + final status = _job?.status.value; + if (status is DownloadInProgress) { + return Row( + children: [ + Container( + margin: const EdgeInsets.only(right: 10), + child: const Text('Download:'), + ), + Expanded(child: LinearProgressIndicator(value: status.percent / 100)), + Container( + margin: const EdgeInsets.only(left: 10), + child: Text('${status.percent.round()}%'), + ), + ], + ); + } + final modified = widget.file.modifiedAt ?? DateTime.now(); + return widget.file.isDirectory + ? Text('geändert ${modified.formatRelative()}') + : Text('${filesize(widget.file.size)}, ${modified.formatRelative()}'); + } + + void _onTap() { + if (widget.file.isDirectory) { + AppRoutes.openFolder( + context, + widget.path.toList()..add(widget.file.name), + ); + return; + } + if (EndpointData().getEndpointMode() == EndpointMode.stage) { + InfoDialog.show( + context, + 'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!', + ); + return; + } + final status = _job?.status.value; + if (status is DownloadInProgress) { + _confirmCancel(); + return; + } + _startDownload(); + } + + // All paths here are relative to the WebDAV root (matching CacheableFile.path). + // Root parent is the empty string ''. Folders end with '/'. + String _parentPathOf(String path) { + final stripped = path.replaceAll(RegExp(r'^/+|/+$'), ''); + if (!stripped.contains('/')) return ''; + final parts = stripped.split('/')..removeLast(); + return parts.isEmpty ? '' : '${parts.join('/')}/'; + } + + String _joinPath(String folder, String name, {required bool isDirectory}) => + isDirectory ? '$folder$name/' : '$folder$name'; + + Future _rename() async { + final controller = TextEditingController(text: widget.file.name); + try { + final newName = await showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: const Text('Umbenennen'), + content: TextField( + controller: controller, + decoration: const InputDecoration(labelText: 'Neuer Name'), + autofocus: true, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Abbrechen'), + ), + TextButton( + onPressed: () => + Navigator.of(dialogCtx).pop(controller.text.trim()), + child: const Text('Umbenennen'), + ), + ], + ), + ); + if (newName == null || newName.isEmpty || newName == widget.file.name) { + return; + } + + final parent = _parentPathOf(widget.file.path); + final destination = _joinPath( + parent, + newName, + isDirectory: widget.file.isDirectory, + ); + await _runWebdavOp(() async { + final webdav = await WebdavApi.webdav; + await webdav.move( + PathUri.parse(widget.file.path), + PathUri.parse(destination), + ); + }, errorTitle: 'Umbenennen fehlgeschlagen'); + } finally { + controller.dispose(); + } + } + + void _putOnClipboard({required bool copy}) { + if (copy) { + FileClipboard.instance.copy([widget.file]); + } else { + FileClipboard.instance.cut([widget.file]); + } + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + '"${widget.file.name}" zum ${copy ? "Kopieren" : "Verschieben"} bereitgelegt', + ), + duration: const Duration(seconds: 2), + ), + ); + } + + Future _delete() async { + await showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Element löschen?', + content: 'Das Element wird unwiederruflich gelöscht.', + confirmButton: 'Löschen', + onConfirmAsync: () async { + final webdav = await WebdavApi.webdav; + await webdav.delete(PathUri.parse(widget.file.path)); + widget.refetch(); + }, + ), + ); + } + + Future _runWebdavOp( + Future Function() action, { + required String errorTitle, + }) async { + try { + await action(); + widget.refetch(); + } on Object catch (e) { + if (!mounted) return; + InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true); + } + } + + void _showActionSheet() { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.info_outline)), + title: const Text('Info'), + onTap: () { + Navigator.of(sheetCtx).pop(); + showFileDetailsSheet(context, widget.file); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_rename_outline)), + title: const Text('Umbenennen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _rename(); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.drive_file_move_outline)), + title: const Text('Verschieben'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: false); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.copy_outlined)), + title: const Text('Kopieren'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _putOnClipboard(copy: true); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.delete_outline)), + title: const Text('Löschen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + _delete(); + }, + ), + ], + ); + } + + @override + Widget build(BuildContext context) => ListTile( + leading: CenteredLeading( + Icon(widget.file.isDirectory ? Icons.folder : Icons.description_outlined), + ), + title: Text(widget.file.name, maxLines: 2, overflow: TextOverflow.ellipsis), + subtitle: _subtitle(), + trailing: Icon(widget.file.isDirectory ? Icons.arrow_right : null), + onTap: _onTap, + onLongPress: _showActionSheet, + ); +} diff --git a/lib/view/pages/files/widgets/files_sort_actions.dart b/lib/view/pages/files/widgets/files_sort_actions.dart new file mode 100644 index 0000000..77a3175 --- /dev/null +++ b/lib/view/pages/files/widgets/files_sort_actions.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import '../data/sort_options.dart'; + +class FilesSortActions extends StatelessWidget { + final SortOption currentSort; + final bool ascending; + final ValueChanged onDirectionChanged; + final ValueChanged onSortChanged; + + const FilesSortActions({ + required this.currentSort, + required this.ascending, + required this.onDirectionChanged, + required this.onSortChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + PopupMenuButton( + icon: Icon( + ascending ? Icons.text_rotate_up : Icons.text_rotation_down, + ), + itemBuilder: (context) => [true, false] + .map( + (e) => PopupMenuItem( + value: e, + enabled: e != ascending, + child: Row( + children: [ + Icon( + e ? Icons.text_rotate_up : Icons.text_rotation_down, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(e ? 'Aufsteigend' : 'Absteigend'), + ], + ), + ), + ) + .toList(), + onSelected: onDirectionChanged, + ), + PopupMenuButton( + icon: const Icon(Icons.sort), + itemBuilder: (context) => SortOptions.options.keys + .map( + (key) => PopupMenuItem( + value: key, + enabled: key != currentSort, + child: Row( + children: [ + Icon( + SortOptions.getOption(key).icon, + color: theme.colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(SortOptions.getOption(key).displayName), + ], + ), + ), + ) + .toList(), + onSelected: onSortChanged, + ), + ], + ); + } +} diff --git a/lib/state/app/modules/gradeAverages/view/grade_averages_list_view.dart b/lib/view/pages/grade_averages/grade_averages_list_view.dart similarity index 75% rename from lib/state/app/modules/gradeAverages/view/grade_averages_list_view.dart rename to lib/view/pages/grade_averages/grade_averages_list_view.dart index 3366a9c..d87625e 100644 --- a/lib/state/app/modules/gradeAverages/view/grade_averages_list_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_list_view.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; -import '../bloc/grade_averages_bloc.dart'; -import '../bloc/grade_averages_event.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_event.dart'; class GradeAveragesListView extends StatelessWidget { const GradeAveragesListView({super.key}); @@ -12,7 +12,7 @@ class GradeAveragesListView extends StatelessWidget { var bloc = context.watch(); String getGradeDisplay(int grade) { - if(bloc.isMiddleSchool()) { + if (bloc.isMiddleSchool()) { return 'Note $grade'; } else { return "$grade Punkt${grade > 1 ? "e" : ""}"; @@ -25,7 +25,9 @@ class GradeAveragesListView extends StatelessWidget { var grade = bloc.getGradeFromIndex(index); return Material( child: ListTile( - tileColor: grade.isEven ? Colors.transparent : Colors.transparent.withAlpha(50), + tileColor: grade.isEven + ? Colors.transparent + : Colors.transparent.withAlpha(50), title: Center( child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -39,7 +41,13 @@ class GradeAveragesListView extends StatelessWidget { icon: const Icon(Icons.remove), color: Theme.of(context).colorScheme.onSurface, ), - Text('${bloc.countOfGrade(grade)}', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.bold)), + Text( + '${bloc.countOfGrade(grade)}', + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.bold, + ), + ), IconButton( onPressed: () { bloc.add(IncrementGrade(grade)); diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart new file mode 100644 index 0000000..c7c5558 --- /dev/null +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -0,0 +1,125 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../state/app/modules/grade_averages/bloc/grade_averages_bloc.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_event.dart'; +import '../../../state/app/modules/grade_averages/bloc/grade_averages_state.dart'; +import '../../../widget/confirm_dialog.dart'; +import 'grade_averages_list_view.dart'; + +class GradeAveragesView extends StatelessWidget { + const GradeAveragesView({super.key}); + + @override + Widget build(BuildContext context) => BlocProvider( + create: (context) => GradeAveragesBloc(), + child: BlocBuilder( + builder: (context, state) { + var bloc = context.watch(); + + return Scaffold( + appBar: AppBar( + title: const Text('Notendurschnittsrechner'), + actions: [ + Visibility( + visible: bloc.state.grades.isNotEmpty, + child: IconButton( + onPressed: () { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Zurücksetzen?', + content: 'Alle Einträge werden entfernt.', + confirmButton: 'Zurücksetzen', + onConfirm: () { + bloc.add(ResetAll()); + }, + ), + ); + }, + icon: const Icon(Icons.delete_forever), + ), + ), + PopupMenuButton( + initialValue: bloc.isMiddleSchool(), + icon: const Icon(Icons.more_horiz), + itemBuilder: (context) => [true, false] + .map( + (isMiddleSchool) => PopupMenuItem( + value: isMiddleSchool, + child: Row( + children: [ + Icon( + isMiddleSchool + ? Icons.calculate_outlined + : Icons.school_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(isMiddleSchool ? 'Realschule' : 'Oberstufe'), + ], + ), + ), + ) + .toList(), + onSelected: (isMiddleSchool) { + if (bloc.state.grades.isNotEmpty) { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Notensystem wechseln', + content: + 'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.', + confirmButton: 'Fortfahren', + onConfirm: () => + bloc.add(GradingSystemChanged(isMiddleSchool)), + ), + ); + } else { + bloc.add(GradingSystemChanged(isMiddleSchool)); + } + }, + ), + ], + ), + + body: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const SizedBox(height: 30), + Row( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + 'Ø', + style: TextStyle(fontSize: 30, fontWeight: FontWeight.bold), + ), + SizedBox(width: 5), + Text( + bloc.average().toStringAsFixed(2), + style: const TextStyle( + fontSize: 30, + fontWeight: FontWeight.bold, + ), + ), + ], + ), + const SizedBox(height: 10), + const Divider(), + const SizedBox(height: 10), + Text( + bloc.isMiddleSchool() + ? 'Wähle die Anzahl deiner jeweiligen Noten aus' + : 'Wähle die Anzahl deiner jeweiligen Punkte aus', + ), + const SizedBox(height: 10), + const Expanded(child: GradeAveragesListView()), + ], + ), + ); + }, + ), + ); +} diff --git a/lib/view/pages/holidays/holidays_view.dart b/lib/view/pages/holidays/holidays_view.dart new file mode 100644 index 0000000..2018d2d --- /dev/null +++ b/lib/view/pages/holidays/holidays_view.dart @@ -0,0 +1,155 @@ +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; + +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/holidays/bloc/holidays_bloc.dart'; +import '../../../state/app/modules/holidays/bloc/holidays_event.dart'; +import '../../../state/app/modules/holidays/bloc/holidays_state.dart'; +import '../../../widget/animated_time.dart'; +import '../../../widget/centered_leading.dart'; +import '../../../widget/debug/debug_tile.dart'; +import '../../../widget/details_bottom_sheet.dart'; +import '../../../widget/info_dialog.dart'; +import '../../../widget/list_view_util.dart'; +import '../../../widget/string_extensions.dart'; + +class HolidaysView extends StatelessWidget { + const HolidaysView({super.key}); + + @override + Widget build( + BuildContext context, + ) => BlocModule>( + create: (context) => HolidaysBloc(), + autoRebuild: true, + child: (context, bloc, state) { + void showDisclaimer() => InfoDialog.show( + context, + 'Sämtliche Datumsangaben sind ohne Gewähr.\n' + 'Ich übernehme weder Verantwortung für die Richtigkeit der Daten noch hafte ich für wirtschaftliche Schäden die aus der Verwendung dieser Daten entstehen können.\n\n' + 'Die Daten stammen von https://ferien-api.de/', + title: 'Richtigkeit und Bereitstellung der Daten', + ); + + return Scaffold( + appBar: AppBar( + title: const Text('Schulferien in Hessen'), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: showDisclaimer, + ), + PopupMenuButton( + initialValue: bloc.showPastHolidays(), + icon: const Icon(Icons.history), + itemBuilder: (context) => [true, false] + .map( + (e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastHolidays(), + child: Row( + children: [ + Icon( + e + ? Icons.history_outlined + : Icons.history_toggle_off_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'), + ], + ), + ), + ) + .toList(), + onSelected: (e) => bloc.add(SetPastHolidaysVisible(e)), + ), + ], + ), + body: LoadableStateConsumer( + onLoad: (state) { + if (state.showDisclaimer) showDisclaimer(); + bloc.add(DisclaimerDismissed()); + }, + child: (state, loading) => ListViewUtil.fromList( + bloc.getHolidays(), + (holiday) { + var holidayType = holiday.name.split(' ').first.capitalize(); + String formatDate(String date) => + Jiffy.parse(date).format(pattern: 'dd.MM.yyyy'); + String getYear(String date, {String format = 'yyyy'}) => + Jiffy.parse(date).format(pattern: format); + + String getHolidayYear(String startDate, String endDate) => + getYear(startDate) == getYear(endDate) + ? getYear(startDate) + : '${getYear(startDate)}/${getYear(endDate, format: 'yy')}'; + + return ListTile( + leading: const CenteredLeading(Icon(Icons.calendar_month)), + title: Text( + '$holidayType ${getHolidayYear(holiday.start, holiday.end)}', + ), + subtitle: Text( + '${formatDate(holiday.start)} - ${formatDate(holiday.end)}', + ), + onTap: () => showDetailsBottomSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + '$holidayType ${holiday.year} in Hessen', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading( + Icon(Icons.signpost_outlined), + ), + title: Text(holiday.name.capitalize()), + subtitle: Text(holiday.slug.capitalize()), + ), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text('vom ${formatDate(holiday.start)}'), + ), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text('bis zum ${formatDate(holiday.end)}'), + ), + if (DateTime.parse( + holiday.start, + ).difference(DateTime.now()).isNegative) + ListTile( + leading: const CenteredLeading( + Icon(Icons.content_paste_search_outlined), + ), + title: Text(Jiffy.parse(holiday.start).fromNow()), + ) + else + ListTile( + leading: const CenteredLeading( + Icon(Icons.timer_outlined), + ), + title: AnimatedTime( + callback: () => DateTime.parse( + holiday.start, + ).difference(DateTime.now()), + ), + subtitle: Text(Jiffy.parse(holiday.start).fromNow()), + ), + DebugTile(sheetCtx).jsonData(holiday.toJson()), + ], + ), + trailing: const Icon(Icons.arrow_right), + ); + }, + ), + ), + ); + }, + ); +} diff --git a/lib/view/pages/marianum_dates/data/event_formatter.dart b/lib/view/pages/marianum_dates/data/event_formatter.dart new file mode 100644 index 0000000..c5f31d9 --- /dev/null +++ b/lib/view/pages/marianum_dates/data/event_formatter.dart @@ -0,0 +1,38 @@ +import '../../../../extensions/date_time.dart'; +import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; + +/// Pure formatting helpers for `MarianumDate` events. Held outside the view +/// so the view can stay focused on layout and these helpers remain +/// unit-testable. +class EventFormatter { + /// Compact trailing label shown in the list row: "HH:mm–HH:mm" for same-day, + /// "dd.MM. HH:mm–dd.MM. HH:mm" otherwise, or "Ganztägig" for all-day events. + static String trailingLabel(MarianumDate event) { + if (event.isAllDay) return 'Ganztägig'; + if (event.start.isSameDay(event.end)) { + if (event.start == event.end) return event.start.formatHm(); + return '${event.start.formatHm()}–${event.end.formatHm()}'; + } + return '${event.start.formatDateShortHm()}–${event.end.formatDateShortHm()}'; + } + + /// Verbose date+time line shown in the details sheet. Drops the trailing + /// time when the event is all-day, and de-duplicates same-day endpoints. + static String longRange(MarianumDate event) { + if (event.isAllDay) { + final inclusiveEnd = event.end.isAfter(event.start) + ? event.end.subtract(const Duration(days: 1)) + : event.end; + return event.start.isSameDay(inclusiveEnd) + ? '${event.start.formatDate()} · Ganztägig' + : '${event.start.formatDate()} – ${inclusiveEnd.formatDate()} · Ganztägig'; + } + if (event.start.isSameDay(event.end)) { + if (event.start == event.end) { + return '${event.start.formatDate()} · ${event.start.formatHm()}'; + } + return '${event.start.formatDate()} · ${event.start.formatHm()} – ${event.end.formatHm()}'; + } + return '${event.start.formatDateTime()} – ${event.end.formatDateTime()}'; + } +} diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart new file mode 100644 index 0000000..585457a --- /dev/null +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -0,0 +1,124 @@ +import 'package:flutter/material.dart'; + +import '../../../extensions/date_time.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_bloc.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_event.dart'; +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../../widget/placeholder_view.dart'; +import 'search_marianum_dates.dart'; +import 'widgets/event_list_tile.dart'; +import 'widgets/month_section_header.dart'; + +class MarianumDatesView extends StatelessWidget { + const MarianumDatesView({super.key}); + + /// Groups events by `yyyy-MM` (chronological). Uses the event's start date. + static List<_MonthGroup> _groupByMonth(List events) { + final byMonth = >{}; + for (final e in events) { + final key = + '${e.start.year.toString().padLeft(4, '0')}-${e.start.month.toString().padLeft(2, '0')}'; + byMonth.putIfAbsent(key, () => []).add(e); + } + final keys = byMonth.keys.toList()..sort(); + return keys.map((key) { + final first = byMonth[key]!.first.start; + final label = first.formatMonthYear().toUpperCase(); + return _MonthGroup(key: key, label: label, events: byMonth[key]!); + }).toList(); + } + + @override + Widget build(BuildContext context) => + BlocModule>( + create: (context) => MarianumDatesBloc(), + autoRebuild: true, + child: (context, bloc, state) => Scaffold( + appBar: AppBar( + title: const Text('Marianum Termine'), + actions: [ + PopupMenuButton( + initialValue: bloc.showPastEvents(), + icon: const Icon(Icons.history), + itemBuilder: (context) => [true, false] + .map( + (e) => PopupMenuItem( + value: e, + enabled: e != bloc.showPastEvents(), + child: Row( + children: [ + Icon( + e + ? Icons.history_outlined + : Icons.history_toggle_off_outlined, + color: Theme.of(context).colorScheme.onSurface, + ), + const SizedBox(width: 15), + Text( + e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen', + ), + ], + ), + ), + ) + .toList(), + onSelected: (e) => bloc.add(SetPastEventsVisible(e)), + ), + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final events = bloc.getEvents() ?? const []; + showSearch( + context: context, + delegate: SearchMarianumDates(events), + ); + }, + ), + ], + ), + body: LoadableStateConsumer( + child: (state, loading) { + final events = bloc.getEvents() ?? const []; + final groups = _groupByMonth(events); + + if (groups.isEmpty) { + return const PlaceholderView( + icon: Icons.event_busy_outlined, + text: 'Keine Termine', + ); + } + + return CustomScrollView( + slivers: [ + for (final group in groups) + SliverMainAxisGroup( + slivers: [ + SliverPersistentHeader( + pinned: true, + delegate: MonthHeaderDelegate(label: group.label), + ), + SliverList.builder( + itemCount: group.events.length, + itemBuilder: (_, i) => + MarianumDateRow(event: group.events[i]), + ), + ], + ), + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ); + }, + ), + ), + ); +} + +class _MonthGroup { + final String key; + final String label; + final List events; + _MonthGroup({required this.key, required this.label, required this.events}); +} diff --git a/lib/view/pages/marianum_dates/search_marianum_dates.dart b/lib/view/pages/marianum_dates/search_marianum_dates.dart new file mode 100644 index 0000000..293bbf9 --- /dev/null +++ b/lib/view/pages/marianum_dates/search_marianum_dates.dart @@ -0,0 +1,51 @@ +import 'package:flutter/material.dart'; + +import '../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../../widget/placeholder_view.dart'; +import 'widgets/event_list_tile.dart'; + +class SearchMarianumDates extends SearchDelegate { + final List events; + + SearchMarianumDates(this.events); + + List _matches() { + if (query.trim().isEmpty) return events; + final q = query.trim().toLowerCase(); + return events.where((e) { + final title = e.title.toLowerCase(); + final desc = e.description?.toLowerCase() ?? ''; + return title.contains(q) || desc.contains(q); + }).toList(); + } + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; + + @override + Widget? buildLeading(BuildContext context) => IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => close(context, null), + ); + + @override + Widget buildResults(BuildContext context) { + final matches = _matches(); + if (matches.isEmpty) { + return const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Treffer', + ); + } + return ListView.builder( + itemCount: matches.length, + itemBuilder: (_, i) => MarianumDateRow(event: matches[i]), + ); + } + + @override + Widget buildSuggestions(BuildContext context) => buildResults(context); +} diff --git a/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart new file mode 100644 index 0000000..11e0564 --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/event_details_sheet.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; + +import '../../../../extensions/date_time.dart'; +import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../../../widget/animated_time.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../data/event_formatter.dart'; + +void showEventDetailsSheet(BuildContext context, MarianumDate event) { + final isUpcoming = !event.start.difference(DateTime.now()).isNegative; + showDetailsBottomSheet( + context, + header: ListTile( + leading: const Icon(Icons.event_outlined, size: 32), + title: Text( + event.title.isEmpty ? '(ohne Titel)' : event.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + children: (sheetContext) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: Text(EventFormatter.longRange(event)), + ), + if (event.description != null && event.description!.trim().isNotEmpty) + ListTile( + leading: const CenteredLeading(Icon(Icons.notes_outlined)), + title: Text(event.description!.trim()), + ), + if (isUpcoming) + ListTile( + leading: const CenteredLeading(Icon(Icons.timer_outlined)), + title: AnimatedTime( + callback: () => event.start.difference(DateTime.now()), + ), + subtitle: Text(event.start.formatRelative()), + ) + else + ListTile( + leading: const CenteredLeading( + Icon(Icons.content_paste_search_outlined), + ), + title: Text(event.start.formatRelative()), + ), + DebugTile(sheetContext).jsonData(event.toJson()), + ], + ); +} diff --git a/lib/view/pages/marianum_dates/widgets/event_list_tile.dart b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart new file mode 100644 index 0000000..8fb09f9 --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/event_list_tile.dart @@ -0,0 +1,146 @@ +import 'package:flutter/material.dart'; + +import '../../../../state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import '../../timetable/custom_events/custom_event_edit_dialog.dart'; +import '../data/event_formatter.dart'; +import 'event_details_sheet.dart'; + +class MarianumDateRow extends StatelessWidget { + final MarianumDate event; + const MarianumDateRow({required this.event, super.key}); + + String _dayLabel() => event.start.day.toString().padLeft(2, '0'); + + String _monthYearLabel() => + '${event.start.month.toString().padLeft(2, '0')}.${event.start.year}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return InkWell( + onTap: () => showEventDetailsSheet(context, event), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 4, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + SizedBox( + width: 44, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _dayLabel(), + textAlign: TextAlign.center, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + color: theme.colorScheme.onSurface, + height: 1.1, + ), + ), + Text( + _monthYearLabel(), + textAlign: TextAlign.center, + maxLines: 1, + softWrap: false, + overflow: TextOverflow.visible, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + height: 1.1, + ), + ), + ], + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + event.title.isEmpty ? '(ohne Titel)' : event.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface, + ), + ), + if (event.description != null && + event.description!.trim().isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + event.description!.trim(), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ], + ], + ), + ), + const SizedBox(width: 8), + Text( + EventFormatter.trailingLabel(event), + style: theme.textTheme.labelMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: 4), + IconButton( + icon: _CalendarPlusIcon( + color: theme.colorScheme.onSurfaceVariant, + ), + tooltip: 'In Stundenplan übernehmen', + onPressed: () => showDialog( + context: context, + builder: (_) => CustomEventEditDialog( + initialTitle: event.title, + initialDescription: event.description, + initialStart: event.start, + initialEnd: event.end, + ), + barrierDismissible: false, + ), + ), + ], + ), + ), + ); + } +} + +/// Composite icon: calendar with a small plus badge in the bottom-right. +/// Material's bundled icon set has no `calendar_add_on`, so we layer +/// `Icons.event_outlined` and `Icons.add` to get the same affordance. +class _CalendarPlusIcon extends StatelessWidget { + final Color color; + const _CalendarPlusIcon({required this.color}); + + @override + Widget build(BuildContext context) => SizedBox( + width: 22, + height: 22, + child: Stack( + clipBehavior: Clip.none, + children: [ + Icon(Icons.event_outlined, size: 22, color: color), + Positioned( + right: -2, + bottom: -2, + child: Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + shape: BoxShape.circle, + ), + padding: const EdgeInsets.all(1), + child: Icon(Icons.add_circle, size: 12, color: color), + ), + ), + ], + ), + ); +} diff --git a/lib/view/pages/marianum_dates/widgets/month_section_header.dart b/lib/view/pages/marianum_dates/widgets/month_section_header.dart new file mode 100644 index 0000000..e274711 --- /dev/null +++ b/lib/view/pages/marianum_dates/widgets/month_section_header.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +class MonthHeaderDelegate extends SliverPersistentHeaderDelegate { + final String label; + MonthHeaderDelegate({required this.label}); + + static const double _height = 38; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + final theme = Theme.of(context); + return Container( + height: _height, + color: theme.colorScheme.surfaceContainer, + padding: const EdgeInsets.symmetric(horizontal: 16), + alignment: Alignment.centerLeft, + child: Text( + label, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.w700, + letterSpacing: 1.2, + ), + ), + ); + } + + @override + double get maxExtent => _height; + + @override + double get minExtent => _height; + + @override + bool shouldRebuild(covariant MonthHeaderDelegate oldDelegate) => + oldDelegate.label != label; +} diff --git a/lib/view/pages/marianum_message/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart new file mode 100644 index 0000000..f49b2a4 --- /dev/null +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +import '../../../routing/app_routes.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart'; +import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; + +class MarianumMessageListView extends StatelessWidget { + const MarianumMessageListView({super.key}); + + @override + Widget build( + BuildContext context, + ) => BlocModule>( + create: (context) => MarianumMessageBloc(), + child: (context, bloc, state) => Scaffold( + appBar: AppBar(title: const Text('Marianum Message')), + body: LoadableStateConsumer( + child: (state, loading) => ListView.builder( + itemCount: state.messageList.messages.length, + itemBuilder: (context, index) { + var message = state.messageList.messages.toList()[index]; + return ListTile( + leading: const Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [Icon(Icons.newspaper)], + ), + title: Text(message.name, overflow: TextOverflow.ellipsis), + subtitle: Text('vom ${message.date}'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + AppRoutes.openMarianumMessage( + context, + state.messageList.base, + message, + ); + }, + ); + }, + ), + ), + ), + ); +} diff --git a/lib/view/pages/marianum_message/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart new file mode 100644 index 0000000..1ce7b40 --- /dev/null +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -0,0 +1,49 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import '../../../widget/confirm_dialog.dart'; +import '../../../widget/info_dialog.dart'; + +class MessageView extends StatefulWidget { + final String basePath; + final MarianumMessage message; + const MessageView({super.key, required this.basePath, required this.message}); + + @override + State createState() => _MessageViewState(); +} + +class _MessageViewState extends State { + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(widget.message.name)), + body: SfPdfViewer.network( + widget.basePath + widget.message.url, + enableHyperlinkNavigation: true, + onDocumentLoadFailed: (PdfDocumentLoadFailedDetails e) { + Navigator.of(context).pop(); + InfoDialog.show( + context, + "Dokument '${widget.message.name}' konnte nicht geladen werden:\n${e.description}", + title: 'Fehler beim öffnen', + ); + }, + onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Link öffnen', + content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}', + confirmButton: 'Öffnen', + onConfirm: () => launchUrl( + Uri.parse(e.uri), + mode: LaunchMode.externalApplication, + ), + ), + ); + }, + ), + ); +} diff --git a/lib/view/pages/more/feedback/feedbackDialog.dart b/lib/view/pages/more/feedback/feedbackDialog.dart deleted file mode 100644 index 80d7daf..0000000 --- a/lib/view/pages/more/feedback/feedbackDialog.dart +++ /dev/null @@ -1,179 +0,0 @@ - -import 'dart:convert'; -import 'dart:typed_data'; - -import 'package:flutter/material.dart'; -import 'package:image_picker/image_picker.dart'; -import 'package:loader_overlay/loader_overlay.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; -import 'package:badges/badges.dart' as badges; - -import '../../../../api/mhsl/server/feedback/addFeedback.dart'; -import '../../../../api/mhsl/server/feedback/addFeedbackParams.dart'; -import '../../../../model/accountData.dart'; -import '../../../../storage/base/settingsProvider.dart'; -import '../../../../widget/filePick.dart'; -import '../../../../widget/focusBehaviour.dart'; -import '../../../../widget/infoDialog.dart'; - -class FeedbackDialog extends StatefulWidget { - const FeedbackDialog({super.key}); - - @override - State createState() => _FeedbackDialogState(); -} - -class _FeedbackDialogState extends State { - final ImagePicker picker = ImagePicker(); - - final TextEditingController _feedbackInput = TextEditingController(); - Uint8List? _image; - String? _error; - bool _textFieldEmpty = false; - - @override - void initState() { - super.initState(); - _feedbackInput.addListener(() { - setState(() { - _textFieldEmpty = _feedbackInput.text.isEmpty; - _error = null; - }); - }); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Feedback'), - ), - body: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.max, - children: [ - const SizedBox(height: 5), - const Text('Feedback, Anregungen, Ideen, Fehler und Verbesserungen', textAlign: TextAlign.center), - const SizedBox(height: 15), - const Text('Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.', textAlign: TextAlign.center, style: TextStyle(fontSize: 11)), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.all(10), - child: TextField( - onChanged: (value) { - if(value.trim().toLowerCase() == 'ranzig') { - _feedbackInput.text = 'selber'; - } - }, - controller: _feedbackInput, - autofocus: true, - decoration: InputDecoration( - border: const OutlineInputBorder(), - label: const Text('Feedback und Verbesserungen'), - errorText: _textFieldEmpty ? 'Bitte gib eine Beschreibung an!' : null, - ), - minLines: 4, - maxLines: 7, - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const SizedBox(height: 10), - if(_image != null) Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - badges.Badge( - badgeContent: const Icon(Icons.close_outlined, size: 17), - badgeStyle: const badges.BadgeStyle( - padding: EdgeInsets.all(2), - ), - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(5)), - border: Border.all( - width: 3, - color: Theme.of(context).primaryColor, - ), - ), - height: 150, - child: Image( - image: Image.memory(_image!).image, - fit: BoxFit.contain, - ), - ), - onTap: () async { - setState(() { - _image = null; - }); - }, - ), - ], - ), - Padding( - padding: const EdgeInsets.all(5), - child: Visibility( - visible: _error != null, - child: Visibility( - visible: Provider.of(context, listen: false).val().devToolsEnabled, - replacement: const Text('Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', textAlign: TextAlign.center, style: TextStyle(color: Colors.red)), - child: Text('Senden fehlgeschlagen: \n $_error', textAlign: TextAlign.center, style: const TextStyle(color: Colors.red)), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(right: 20, left: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.end, - children: [ - Visibility( - visible: _image == null, - child: IconButton( - onPressed: () async { - context.loaderOverlay.show(); - var imageData = await (await FilePick.galleryPick())?.readAsBytes(); - if(context.mounted) context.loaderOverlay.hide(); - setState(() { - _image = imageData; - }); - }, - icon: const Icon(Icons.attach_file_outlined), - ), - ), - const Expanded(child: SizedBox.shrink()), - TextButton( - onPressed: () async { - if(_feedbackInput.text.isEmpty){ - setState(() { - _textFieldEmpty = true; - }); - return; - } - context.loaderOverlay.show(); - AddFeedback( - AddFeedbackParams( - user: AccountData().getUserSecret(), - feedback: _feedbackInput.text, - screenshot: _image != null ? base64Encode(_image!) : null, - appVersion: int.parse((await PackageInfo.fromPlatform()).buildNumber), - ) - ).run().then((value) { - Navigator.of(context).pop(); - InfoDialog.show(context, 'Danke für dein Feedback!'); - context.loaderOverlay.hide(); - }).catchError((error, trace) { - setState(() { - _error = error.toString(); - }); - context.loaderOverlay.hide(); - }); - }, - child: const Text('Senden'), - ) - ] - ) - ) - - ], - ), - ), - ); -} diff --git a/lib/view/pages/more/feedback/feedback_dialog.dart b/lib/view/pages/more/feedback/feedback_dialog.dart new file mode 100644 index 0000000..3fcd528 --- /dev/null +++ b/lib/view/pages/more/feedback/feedback_dialog.dart @@ -0,0 +1,211 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:badges/badges.dart' as badges; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:loader_overlay/loader_overlay.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../api/mhsl/server/feedback/add_feedback.dart'; +import '../../../../api/mhsl/server/feedback/add_feedback_params.dart'; +import '../../../../model/account_data.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/file_pick.dart'; +import '../../../../widget/focus_behaviour.dart'; +import '../../../../widget/info_dialog.dart'; + +class FeedbackDialog extends StatefulWidget { + const FeedbackDialog({super.key}); + + @override + State createState() => _FeedbackDialogState(); +} + +class _FeedbackDialogState extends State { + final ImagePicker picker = ImagePicker(); + + final TextEditingController _feedbackInput = TextEditingController(); + Uint8List? _image; + String? _error; + bool _textFieldEmpty = false; + + @override + void initState() { + super.initState(); + _feedbackInput.addListener(() { + setState(() { + _textFieldEmpty = _feedbackInput.text.isEmpty; + _error = null; + }); + }); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Feedback')), + body: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.max, + children: [ + const SizedBox(height: 5), + const Text( + 'Feedback, Anregungen, Ideen, Fehler und Verbesserungen', + textAlign: TextAlign.center, + ), + const SizedBox(height: 15), + const Text( + 'Bitte gib keine geheimen Daten wie z.B. Passwörter weiter.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 11), + ), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.all(10), + child: TextField( + onChanged: (value) { + if (value.trim().toLowerCase() == 'ranzig') { + _feedbackInput.text = 'selber'; + } + }, + controller: _feedbackInput, + autofocus: true, + decoration: InputDecoration( + border: const OutlineInputBorder(), + label: const Text('Feedback und Verbesserungen'), + errorText: _textFieldEmpty + ? 'Bitte gib eine Beschreibung an!' + : null, + ), + minLines: 4, + maxLines: 7, + onTapOutside: (PointerDownEvent event) => + FocusBehaviour.textFieldTapOutside(context), + ), + ), + const SizedBox(height: 10), + if (_image != null) + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + badges.Badge( + badgeContent: const Icon(Icons.close_outlined, size: 17), + badgeStyle: const badges.BadgeStyle( + padding: EdgeInsets.all(2), + ), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(5)), + border: Border.all( + width: 3, + color: Theme.of(context).primaryColor, + ), + ), + height: 150, + child: Image( + image: Image.memory(_image!).image, + fit: BoxFit.contain, + ), + ), + onTap: () async { + setState(() { + _image = null; + }); + }, + ), + ], + ), + Padding( + padding: const EdgeInsets.all(5), + child: Visibility( + visible: _error != null, + child: Visibility( + visible: context.read().val().devToolsEnabled, + replacement: const Text( + 'Senden fehlgeschlagen, bitte überprüfe die Internetverbindung.', + textAlign: TextAlign.center, + style: TextStyle(color: Colors.red), + ), + child: Text( + 'Senden fehlgeschlagen: \n $_error', + textAlign: TextAlign.center, + style: const TextStyle(color: Colors.red), + ), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(right: 20, left: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + Visibility( + visible: _image == null, + child: IconButton( + onPressed: () async { + context.loaderOverlay.show(); + final picked = await FilePick.multipleGalleryPick(); + final imageData = await picked?.first.readAsBytes(); + if (context.mounted) context.loaderOverlay.hide(); + setState(() { + _image = imageData; + }); + }, + icon: const Icon(Icons.attach_file_outlined), + ), + ), + const Expanded(child: SizedBox.shrink()), + TextButton( + onPressed: () async { + if (_feedbackInput.text.isEmpty) { + setState(() { + _textFieldEmpty = true; + }); + return; + } + context.loaderOverlay.show(); + unawaited( + AddFeedback( + AddFeedbackParams( + user: AccountData().getUserSecret(), + feedback: _feedbackInput.text, + screenshot: _image != null + ? base64Encode(_image!) + : null, + appVersion: int.parse( + (await PackageInfo.fromPlatform()).buildNumber, + ), + ), + ) + .run() + .then((value) { + if (!context.mounted) return; + Navigator.of(context).pop(); + InfoDialog.show( + context, + 'Danke für dein Feedback!', + ); + context.loaderOverlay.hide(); + }) + .catchError((Object error, StackTrace trace) { + if (!mounted) return; + setState(() { + _error = error.toString(); + }); + if (!context.mounted) return; + context.loaderOverlay.hide(); + }), + ); + }, + child: const Text('Senden'), + ), + ], + ), + ), + ], + ), + ), + ); +} diff --git a/lib/view/pages/more/roomplan/roomplan.dart b/lib/view/pages/more/roomplan/roomplan.dart index edabc5e..63f3d3e 100644 --- a/lib/view/pages/more/roomplan/roomplan.dart +++ b/lib/view/pages/more/roomplan/roomplan.dart @@ -6,14 +6,14 @@ class Roomplan extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Raumplan'), + appBar: AppBar(title: const Text('Raumplan')), + body: PhotoView( + imageProvider: Image.asset('assets/img/raumplan.png').image, + minScale: 0.5, + maxScale: 2.0, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, ), - body: PhotoView( - imageProvider: Image.asset('assets/img/raumplan.jpg').image, - minScale: 0.5, - maxScale: 2.0, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.background), - ), - ); + ), + ); } diff --git a/lib/view/pages/more/share/appSharePlatformView.dart b/lib/view/pages/more/share/app_share_platform_view.dart similarity index 78% rename from lib/view/pages/more/share/appSharePlatformView.dart rename to lib/view/pages/more/share/app_share_platform_view.dart index b25d7c1..fe3ed7f 100644 --- a/lib/view/pages/more/share/appSharePlatformView.dart +++ b/lib/view/pages/more/share/app_share_platform_view.dart @@ -8,13 +8,16 @@ class AppSharePlatformView extends StatelessWidget { @override Widget build(BuildContext context) { - var foregroundColor = Theme.of(context).colorScheme.onBackground; + var foregroundColor = Theme.of(context).colorScheme.onSurface; return Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center, children: [ - Text(title, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold)), + Text( + title, + style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold), + ), const SizedBox(height: 30), Container( padding: const EdgeInsets.all(10), @@ -26,8 +29,8 @@ class AppSharePlatformView extends StatelessWidget { version: QrVersions.auto, size: 200, dataModuleStyle: QrDataModuleStyle( - color: foregroundColor, - dataModuleShape: QrDataModuleShape.square + color: foregroundColor, + dataModuleShape: QrDataModuleShape.square, ), eyeStyle: QrEyeStyle( color: foregroundColor, diff --git a/lib/view/pages/more/share/qrShareView.dart b/lib/view/pages/more/share/qrShareView.dart deleted file mode 100644 index 8aba4c8..0000000 --- a/lib/view/pages/more/share/qrShareView.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:screen_brightness/screen_brightness.dart'; - -import 'appSharePlatformView.dart'; - -class QrShareView extends StatefulWidget { - const QrShareView({super.key}); - - @override - State createState() => _QrShareViewState(); -} - -class _QrShareViewState extends State { - @override - void initState() { - ScreenBrightness.instance.setApplicationScreenBrightness(1.0); - super.initState(); - } - - @override - void dispose() { - ScreenBrightness.instance.resetApplicationScreenBrightness(); - super.dispose(); - } - - @override - Widget build(BuildContext context) => DefaultTabController( - length: 2, - child: Scaffold( - appBar: AppBar( - title: const Text('Teile die App'), - bottom: const TabBar( - tabs: [ - Tab(icon: Icon(Icons.android_outlined), text: 'Android'), - Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'), - ], - ), - ), - body: const TabBarView( - children: [ - AppSharePlatformView('Für Android', 'https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client'), - AppSharePlatformView('Für iOS & iPad', 'https://apps.apple.com/us/app/marianum-fulda/id6458789560'), - ], - ), - ), - ); -} diff --git a/lib/view/pages/more/share/qr_share_view.dart b/lib/view/pages/more/share/qr_share_view.dart new file mode 100644 index 0000000..d3482ca --- /dev/null +++ b/lib/view/pages/more/share/qr_share_view.dart @@ -0,0 +1,53 @@ +import 'package:flutter/material.dart'; +import 'package:screen_brightness/screen_brightness.dart'; + +import 'app_share_platform_view.dart'; + +class QrShareView extends StatefulWidget { + const QrShareView({super.key}); + + @override + State createState() => _QrShareViewState(); +} + +class _QrShareViewState extends State { + @override + void initState() { + ScreenBrightness.instance.setApplicationScreenBrightness(1.0); + super.initState(); + } + + @override + void dispose() { + ScreenBrightness.instance.resetApplicationScreenBrightness(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => DefaultTabController( + length: 2, + child: Scaffold( + appBar: AppBar( + title: const Text('Teile die App'), + bottom: const TabBar( + tabs: [ + Tab(icon: Icon(Icons.android_outlined), text: 'Android'), + Tab(icon: Icon(Icons.apple_outlined), text: 'iOS & iPadOS'), + ], + ), + ), + body: const TabBarView( + children: [ + AppSharePlatformView( + 'Für Android', + 'https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client', + ), + AppSharePlatformView( + 'Für iOS & iPad', + 'https://apps.apple.com/us/app/marianum-fulda/id6458789560', + ), + ], + ), + ), + ); +} diff --git a/lib/view/pages/more/share/selectShareTypeDialog.dart b/lib/view/pages/more/share/selectShareTypeDialog.dart deleted file mode 100644 index 0b768bc..0000000 --- a/lib/view/pages/more/share/selectShareTypeDialog.dart +++ /dev/null @@ -1,38 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:share_plus/share_plus.dart'; - -import '../../../../widget/sharePositionOrigin.dart'; -import 'qrShareView.dart'; - -class SelectShareTypeDialog extends StatelessWidget { - const SelectShareTypeDialog({super.key}); - - @override - Widget build(BuildContext context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.qr_code_2_outlined), - title: const Text('Per QR-Code'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => const QrShareView())); - }, - ), - ListTile( - leading: const Icon(Icons.link_outlined), - title: const Text('Per Link teilen'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - Share.share( - sharePositionOrigin: SharePositionOrigin.get(context), - subject: 'App Teilen', - 'Hol dir die für das Marianum maßgeschneiderte App:' - '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' - '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' - '\n\nViel Spaß!' - ); - }, - ) - ], - ); -} diff --git a/lib/view/pages/more/share/select_share_type_dialog.dart b/lib/view/pages/more/share/select_share_type_dialog.dart new file mode 100644 index 0000000..61dc782 --- /dev/null +++ b/lib/view/pages/more/share/select_share_type_dialog.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../../widget/share_position_origin.dart'; + +enum ShareTargetType { qr } + +/// Bottom sheet that lets the user pick how they want to share the app. +/// Resolves with [ShareTargetType.qr] for the QR option, or `null` when the +/// sheet is dismissed (link sharing fires immediately and resolves null). +Future showSelectShareTypeSheet(BuildContext context) { + return showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (sheetCtx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.qr_code_2_outlined), + title: const Text('Per QR-Code'), + trailing: const Icon(Icons.arrow_right), + onTap: () => Navigator.of(sheetCtx).pop(ShareTargetType.qr), + ), + ListTile( + leading: const Icon(Icons.link_outlined), + title: const Text('Per Link teilen'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + Navigator.of(sheetCtx).pop(); + SharePlus.instance.share( + ShareParams( + sharePositionOrigin: SharePositionOrigin.get(sheetCtx), + subject: 'App Teilen', + text: + 'Hol dir die für das Marianum maßgeschneiderte App:' + '\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client ' + '\niOS: https://apps.apple.com/us/app/marianum-fulda/id6458789560 ' + '\n\nViel Spaß!', + ), + ); + }, + ), + ], + ), + ), + ); +} diff --git a/lib/view/pages/overhang.dart b/lib/view/pages/overhang.dart index 7b3db61..889eeca 100644 --- a/lib/view/pages/overhang.dart +++ b/lib/view/pages/overhang.dart @@ -1,20 +1,14 @@ - import 'dart:io'; import 'package:flutter/material.dart'; import 'package:in_app_review/in_app_review.dart'; -import 'package:provider/provider.dart'; -import '../../extensions/renderNotNull.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; +import '../../extensions/render_not_null.dart'; +import '../../routing/app_routes.dart'; import '../../state/app/modules/app_modules.dart'; -import '../../storage/base/settingsProvider.dart'; -import '../../widget/centeredLeading.dart'; -import '../../widget/infoDialog.dart'; -import '../settings/defaultSettings.dart'; -import '../settings/settings.dart'; -import 'more/feedback/feedbackDialog.dart'; -import 'more/share/selectShareTypeDialog.dart'; +import '../../widget/centered_leading.dart'; +import '../../widget/info_dialog.dart'; +import 'more/share/select_share_type_dialog.dart'; class Overhang extends StatefulWidget { const Overhang({super.key}); @@ -24,103 +18,86 @@ class Overhang extends StatefulWidget { } class _OverhangState extends State { - bool editMode = false; - @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => Scaffold( - appBar: AppBar( - title: const Text('Mehr'), - actions: [ - if(editMode) IconButton( - onPressed: settings.val().modulesSettings.toJson().toString() != DefaultSettings.get().modulesSettings.toJson().toString() - ? () => settings.val(write: true).modulesSettings = DefaultSettings.get().modulesSettings - : null, - icon: Icon(Icons.undo_outlined) - ), - IconButton(onPressed: () => setState(() => editMode = !editMode), icon: Icon(Icons.edit_note_outlined), color: editMode ? Theme.of(context).primaryColor : null), - IconButton(onPressed: editMode ? null : () => pushScreen(context, screen: const Settings(), withNavBar: false), icon: const Icon(Icons.settings)), - ], - ), - body: editMode ? _sorting() : _overhang(), - )); - - Widget _sorting() => Consumer(builder: (context, settings, child) { - void changeVisibility(Modules module) { - var hidden = settings.val(write: true).modulesSettings.hiddenModules; - hidden.contains(module) ? hidden.remove(module) : (hidden.length < 3 ? hidden.add(module) : null); - } - - return ReorderableListView( - header: const Center( - heightFactor: 2, - child: Text('Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', textAlign: TextAlign.center) - ), - children: AppModule.modules(context, showFiltered: true) - .map((key, value) => MapEntry(key, value.toListTile( - context, - key: Key(key.name), - isReorder: true, - onVisibleChange: () => changeVisibility(key), - isVisible: !settings.val().modulesSettings.hiddenModules.contains(key) - ))) - .values - .toList(), - onReorder: (oldIndex, newIndex) { - if (newIndex > oldIndex) newIndex -= 1; - - var order = settings.val().modulesSettings.moduleOrder.toList(); - final movedModule = order.removeAt(oldIndex); - order.insert(newIndex, movedModule); - settings.val(write: true).modulesSettings.moduleOrder = order; - } - ); - }); - - Widget _overhang() => ListView( - children: [ - ...AppModule.getOverhangModules(context).map((e) => e.toListTile(context)), - - const Divider(), - - ListTile( - leading: const Icon(Icons.share_outlined), - title: const Text('Teile die App'), - subtitle: const Text('Mit Freunden und deiner Klasse teilen'), - trailing: const Icon(Icons.arrow_right), - onTap: () => showDialog(context: context, builder: (context) => const SelectShareTypeDialog()) - ), - FutureBuilder( - future: InAppReview.instance.isAvailable(), - builder: (context, snapshot) { - if(!snapshot.hasData) return const SizedBox.shrink(); - - String? getPlatformStoreName() { - if(Platform.isAndroid) return 'Play store'; - if(Platform.isIOS) return 'App store'; - return null; - } - - return ListTile( - leading: const CenteredLeading(Icon(Icons.star_rate_outlined)), - title: const Text('App bewerten'), - subtitle: getPlatformStoreName().wrapNullable((data) => Text('Im $data')), - trailing: const Icon(Icons.arrow_right), - onTap: () { - InAppReview.instance.openStoreListing(appStoreId: '6458789560').then( - (value) => InfoDialog.show(context, 'Vielen Dank!'), - onError: (error) => InfoDialog.show(context, error.toString()) - ); - }, - ); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.feedback_outlined)), - title: const Text('Du hast eine Idee?'), - subtitle: const Text('Fehler und Verbessungsvorschläge'), - trailing: const Icon(Icons.arrow_right), - onTap: () => pushScreen(context, withNavBar: false, screen: const FeedbackDialog()), + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Mehr'), + actions: [ + IconButton( + onPressed: () => AppRoutes.openSettings(context), + icon: const Icon(Icons.settings), ), ], - ); + ), + body: _overhang(), + ); + + Widget _overhang() => ListView( + children: [ + ...AppModule.getOverhangModules( + context, + ).map((e) => e.toListTile(context)), + + const Divider(), + + ListTile( + leading: const Icon(Icons.share_outlined), + title: const Text('Teile die App'), + subtitle: const Text('Mit Freunden und deiner Klasse teilen'), + trailing: const Icon(Icons.arrow_right), + onTap: () async { + final result = await showSelectShareTypeSheet(context); + if (!mounted || result != ShareTargetType.qr) return; + if (context.mounted) AppRoutes.openQrShare(context); + }, + ), + FutureBuilder( + future: InAppReview.instance.isAvailable(), + builder: (context, snapshot) { + if (!snapshot.hasData) return const SizedBox.shrink(); + + String? getPlatformStoreName() { + if (Platform.isAndroid) return 'Play store'; + if (Platform.isIOS) return 'App store'; + return null; + } + + return ListTile( + leading: const CenteredLeading(Icon(Icons.star_rate_outlined)), + title: const Text('App bewerten'), + subtitle: getPlatformStoreName().wrapNullable( + (data) => Text('Im $data'), + ), + trailing: const Icon(Icons.arrow_right), + onTap: () { + InAppReview.instance + .openStoreListing(appStoreId: '6458789560') + .then( + (value) { + if (!context.mounted) return; + InfoDialog.show(context, 'Vielen Dank!'); + }, + onError: (error) { + if (!context.mounted) return; + InfoDialog.show( + context, + error.toString(), + copyable: true, + title: 'Fehler', + ); + }, + ); + }, + ); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.feedback_outlined)), + title: const Text('Du hast eine Idee?'), + subtitle: const Text('Fehler und Verbessungsvorschläge'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openFeedback(context), + ), + ], + ); } diff --git a/lib/view/pages/settings/data/default_settings.dart b/lib/view/pages/settings/data/default_settings.dart new file mode 100644 index 0000000..84d888b --- /dev/null +++ b/lib/view/pages/settings/data/default_settings.dart @@ -0,0 +1,67 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import '../../../../state/app/modules/app_modules.dart'; +import '../../../../storage/dev_tools_settings.dart'; +import '../../../../storage/file_settings.dart'; +import '../../../../storage/file_view_settings.dart'; +import '../../../../storage/holidays_settings.dart'; +import '../../../../storage/modules_settings.dart'; +import '../../../../storage/notification_settings.dart'; +import '../../../../storage/settings.dart'; +import '../../../../storage/talk_settings.dart'; +import '../../../../storage/timetable_settings.dart'; +import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; +import '../../files/data/sort_options.dart'; + +class DefaultSettings { + static Settings get() => Settings( + appTheme: ThemeMode.system, + devToolsEnabled: false, + modulesSettings: ModulesSettings( + moduleOrder: [ + Modules.timetable, + Modules.talk, + Modules.files, + Modules.marianumMessage, + Modules.roomPlan, + Modules.gradeAveragesCalculator, + Modules.holidays, + Modules.marianumDates, + ], + hiddenModules: [], + autoFillBottomBar: true, + fixedBottomBarSlots: 3, + ), + timetableSettings: TimetableSettings( + connectDoubleLessons: true, + timetableNameMode: TimetableNameMode.name, + ), + talkSettings: TalkSettings( + sortFavoritesToTop: true, + sortUnreadToTop: false, + drafts: {}, + draftReplies: {}, + ), + fileSettings: FileSettings( + sortFoldersToTop: true, + ascending: true, + sortBy: SortOption.name, + ), + holidaysSettings: HolidaysSettings( + dismissedDisclaimer: false, + showPastEvents: false, + ), + fileViewSettings: FileViewSettings(alwaysOpenExternally: Platform.isIOS), + notificationSettings: NotificationSettings( + askUsageDismissed: false, + enabled: false, + ), + devToolsSettings: DevToolsSettings( + checkerboardOffscreenLayers: false, + checkerboardRasterCacheImages: false, + showPerformanceOverlay: false, + ), + ); +} diff --git a/lib/view/pages/settings/modules_settings_page.dart b/lib/view/pages/settings/modules_settings_page.dart new file mode 100644 index 0000000..4a84b86 --- /dev/null +++ b/lib/view/pages/settings/modules_settings_page.dart @@ -0,0 +1,157 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../state/app/modules/app_modules.dart'; +import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../storage/settings.dart' as model; +import 'data/default_settings.dart'; + +/// Reorderable list with bottom-bar slot configuration on top. +/// +/// Used both inline in the "Mehr" edit mode and as the body of +/// [ModulesSettingsPage] from the main settings. +class ModuleSortBody extends StatelessWidget { + const ModuleSortBody({super.key}); + + @override + Widget build( + BuildContext context, + ) => BlocBuilder( + builder: (context, _) { + final settings = context.read(); + final modulesSettings = settings.val().modulesSettings; + + void changeVisibility(Modules module) { + var hidden = settings.val(write: true).modulesSettings.hiddenModules; + if (hidden.contains(module)) { + hidden.remove(module); + } else if (hidden.length < 3) { + hidden.add(module); + } + } + + return ReorderableListView( + header: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const Padding( + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 16), + child: Text( + 'Halte und ziehe einen Eintrag, um ihn zu verschieben.\nEs können 3 Bereiche ausgeblendet werden.', + textAlign: TextAlign.center, + ), + ), + SwitchListTile( + title: const Text('Modulleiste automatisch füllen'), + subtitle: const Text( + 'Auf größeren Bildschirmen werden mehr Module direkt angezeigt', + ), + value: modulesSettings.autoFillBottomBar, + onChanged: (value) => + settings.val(write: true).modulesSettings.autoFillBottomBar = + value, + ), + if (!modulesSettings.autoFillBottomBar) + ListTile( + title: const Text('Anzahl Slots in der Modulleiste'), + subtitle: Text( + '${modulesSettings.fixedBottomBarSlots} Module (zzgl. „Mehr")', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.remove_circle_outline), + onPressed: + modulesSettings.fixedBottomBarSlots > + AppModule.minBottomBarSlots + ? () => + settings + .val(write: true) + .modulesSettings + .fixedBottomBarSlots -= + 1 + : null, + ), + Text('${modulesSettings.fixedBottomBarSlots}'), + IconButton( + icon: const Icon(Icons.add_circle_outline), + onPressed: + modulesSettings.fixedBottomBarSlots < + AppModule.maxBottomBarSlots + ? () => + settings + .val(write: true) + .modulesSettings + .fixedBottomBarSlots += + 1 + : null, + ), + ], + ), + ), + const Divider(), + ], + ), + children: AppModule.modules(context, showFiltered: true) + .map( + (key, value) => MapEntry( + key, + value.toListTile( + context, + key: Key(key.name), + isReorder: true, + onVisibleChange: () => changeVisibility(key), + isVisible: !settings + .val() + .modulesSettings + .hiddenModules + .contains(key), + ), + ), + ) + .values + .toList(), + onReorder: (oldIndex, newIndex) { + if (newIndex > oldIndex) newIndex -= 1; + + var order = settings.val().modulesSettings.moduleOrder.toList(); + final movedModule = order.removeAt(oldIndex); + order.insert(newIndex, movedModule); + settings.val(write: true).modulesSettings.moduleOrder = order; + }, + ); + }, + ); +} + +class ModulesSettingsPage extends StatelessWidget { + const ModulesSettingsPage({super.key}); + + @override + Widget build(BuildContext context) => + BlocBuilder( + builder: (context, _) { + final settings = context.read(); + final isModified = + settings.val().modulesSettings.toJson().toString() != + DefaultSettings.get().modulesSettings.toJson().toString(); + return Scaffold( + appBar: AppBar( + title: const Text('Module'), + actions: [ + IconButton( + tooltip: 'Auf Standard zurücksetzen', + onPressed: isModified + ? () => settings.val(write: true).modulesSettings = + DefaultSettings.get().modulesSettings + : null, + icon: const Icon(Icons.undo_outlined), + ), + ], + ), + body: const ModuleSortBody(), + ); + }, + ); +} diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart new file mode 100644 index 0000000..eeda08b --- /dev/null +++ b/lib/view/pages/settings/sections/about_section.dart @@ -0,0 +1,150 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../data/default_settings.dart'; +import '../widgets/privacy_info.dart'; +import 'dev_tools_section.dart'; + +class AboutSection extends StatelessWidget { + const AboutSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return Column( + children: [ + ListTile( + leading: const Icon(Icons.live_help_outlined), + title: const Text('Informationen und Lizenzen'), + onTap: () => _showAppInfo(context), + trailing: const Icon(Icons.arrow_right), + ), + ListTile( + leading: const Icon(Icons.policy_outlined), + title: const Text('Impressum & Datenschutz'), + onTap: () => _showPrivacyDialog(context), + trailing: const Icon(Icons.arrow_right), + ), + const Divider(), + ListTile( + leading: const CenteredLeading(Icon(Icons.code)), + title: const Text('Quellcode MarianumMobile/Client'), + subtitle: const Text('GNU GPL v3'), + onTap: () => ConfirmDialog.openBrowser( + context, + 'https://mhsl.eu/gitea/MarianumMobile/Client', + ), + ), + ListTile( + leading: const Icon(Icons.developer_mode_outlined), + title: const Text('Entwicklermodus'), + trailing: Checkbox( + value: settings.val().devToolsEnabled, + onChanged: (state) => + _toggleDeveloperMode(context, settings, state), + ), + ), + Visibility( + visible: settings.val().devToolsEnabled, + child: DevToolsSection(settings: settings), + ), + ], + ); + } + + Future _showAppInfo(BuildContext context) async { + final appInfo = await PackageInfo.fromPlatform(); + if (!context.mounted) return; + showAboutDialog( + context: context, + applicationIcon: const Icon(Icons.apps), + applicationName: 'MarianumMobile', + applicationVersion: + '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', + applicationLegalese: + 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' + 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' + "${kReleaseMode ? "Production" : "Development"} build\n" + 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', + ); + } + + void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.school_outlined)), + title: const Text('Infos zum Marianum Fulda'), + subtitle: const Text('Für Talk-Chats und Dateien'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Marianum', + imprintUrl: 'https://www.marianum-fulda.de/impressum', + privacyUrl: 'https://www.marianum-fulda.de/datenschutz', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.date_range_outlined)), + title: const Text('Infos zu Web-/ Untis'), + subtitle: const Text('Für den Stundenplan'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'Untis', + imprintUrl: 'https://www.untis.at/impressum', + privacyUrl: 'https://www.untis.at/datenschutz-wu-apps', + ).showPopup(sheetCtx), + ), + ListTile( + leading: const CenteredLeading( + Icon(Icons.send_time_extension_outlined), + ), + title: const Text('Infos zu mhsl'), + subtitle: const Text('Für Countdowns, Marianum Message und mehr'), + trailing: const Icon(Icons.arrow_right), + onTap: () => PrivacyInfo( + providerText: 'mhsl', + imprintUrl: 'https://mhsl.eu/id.html', + privacyUrl: 'https://mhsl.eu/datenschutz.html', + ).showPopup(sheetCtx), + ), + ], + ); + + void _toggleDeveloperMode( + BuildContext context, + SettingsCubit settings, + bool? state, + ) { + void apply() { + final enabled = state ?? false; + settings.val(write: true).devToolsEnabled = enabled; + if (!enabled) { + settings.val(write: true).devToolsSettings = + DefaultSettings.get().devToolsSettings; + } + } + + if (!state!) { + apply(); + return; + } + + ConfirmDialog( + title: 'Entwicklermodus', + content: + 'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\n' + 'Die Verwendung der Tools kann darüber hinaus bei falscher Verwendung zu Fehlern führen.\n\n' + 'Aktivieren auf eigene Verantwortung.', + confirmButton: 'Ja, ich verstehe das Risiko', + cancelButton: 'Nein, zurück zur App', + onConfirm: apply, + ).asDialog(context); + } +} diff --git a/lib/view/pages/settings/sections/account_section.dart b/lib/view/pages/settings/sections/account_section.dart new file mode 100644 index 0000000..f7b37ea --- /dev/null +++ b/lib/view/pages/settings/sections/account_section.dart @@ -0,0 +1,40 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../model/account_data.dart'; +import '../../../../state/app/modules/account/bloc/account_bloc.dart'; +import '../../../../state/app/modules/account/bloc/account_state.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; + +class AccountSection extends StatelessWidget { + const AccountSection({super.key}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const CenteredLeading(Icon(Icons.logout_outlined)), + title: const Text('Konto abmelden'), + subtitle: Text('Angemeldet als ${AccountData().getUsername()}'), + onTap: () => _showLogoutDialog(context), + ); + + Future _showLogoutDialog(BuildContext context) async { + // Sequential logout flow: dialog wipes secure storage, dialog closes + // (single Navigator.pop), then we flip the AccountBloc state. The bloc + // listener in main.dart pops the Settings route and runs the in-memory + // wipe. Triggering setStatus from inside removeData (the previous + // approach) raced AsyncDialogAction's pop(true) against popUntil(isFirst) + // and could leave the navigator in an inconsistent state. + final confirmed = await showDialog( + context: context, + builder: (dialogContext) => ConfirmDialog( + title: 'Abmelden?', + content: 'Möchtest du dich wirklich abmelden?', + confirmButton: 'Abmelden', + onConfirmAsync: AccountData().removeData, + ), + ); + if (confirmed != true || !context.mounted) return; + context.read().setStatus(AccountStatus.loggedOut); + } +} diff --git a/lib/view/pages/settings/sections/appearance_section.dart b/lib/view/pages/settings/sections/appearance_section.dart new file mode 100644 index 0000000..851c25f --- /dev/null +++ b/lib/view/pages/settings/sections/appearance_section.dart @@ -0,0 +1,38 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../theming/app_theme.dart'; + +class AppearanceSection extends StatelessWidget { + const AppearanceSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return ListTile( + leading: const Icon(Icons.dark_mode_outlined), + title: const Text('Farbgebung'), + trailing: DropdownButton( + value: settings.val().appTheme, + icon: const Icon(Icons.arrow_drop_down), + items: ThemeMode.values + .map( + (e) => DropdownMenuItem( + value: e, + enabled: e != settings.val().appTheme, + child: Row( + children: [ + Icon(AppTheme.getDisplayOptions(e).icon), + const SizedBox(width: 10), + Text(AppTheme.getDisplayOptions(e).displayName), + ], + ), + ), + ) + .toList(), + onChanged: (e) => settings.val(write: true).appTheme = e!, + ), + ); + } +} diff --git a/lib/view/pages/settings/sections/dev_tools_section.dart b/lib/view/pages/settings/sections/dev_tools_section.dart new file mode 100644 index 0000000..f8978ab --- /dev/null +++ b/lib/view/pages/settings/sections/dev_tools_section.dart @@ -0,0 +1,172 @@ +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hydrated_bloc/hydrated_bloc.dart'; + +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../storage/settings.dart' as model; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/debug/cache_view.dart'; +import '../../../../widget/debug/json_viewer.dart'; +import '../../../../widget/details_bottom_sheet.dart'; + +class DevToolsSection extends StatefulWidget { + final SettingsCubit settings; + const DevToolsSection({required this.settings, super.key}); + + @override + State createState() => _DevToolsSectionState(); +} + +class _DevToolsSectionState extends State { + @override + Widget build(BuildContext context) => Column( + children: [ + ListTile( + leading: const CenteredLeading(Icon(Icons.speed_outlined)), + title: const Text('Performance overlays'), + trailing: const Icon(Icons.arrow_right), + onTap: () { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + BlocBuilder( + bloc: widget.settings, + builder: (_, _) { + final dev = widget.settings.val().devToolsSettings; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.auto_graph_outlined), + title: const Text('Performance graph'), + trailing: Checkbox( + value: dev.showPerformanceOverlay, + onChanged: (e) => + widget.settings + .val(write: true) + .devToolsSettings + .showPerformanceOverlay = + e!, + ), + ), + ListTile( + leading: const Icon( + Icons.screen_search_desktop_outlined, + ), + title: const Text('Indicate offscreen layers'), + trailing: Checkbox( + value: dev.checkerboardOffscreenLayers, + onChanged: (e) => + widget.settings + .val(write: true) + .devToolsSettings + .checkerboardOffscreenLayers = + e!, + ), + ), + ListTile( + leading: const Icon(Icons.imagesearch_roller_outlined), + title: const Text('Indicate raster cache images'), + trailing: Checkbox( + value: dev.checkerboardRasterCacheImages, + onChanged: (e) => + widget.settings + .val(write: true) + .devToolsSettings + .checkerboardRasterCacheImages = + e!, + ), + ), + ], + ); + }, + ), + ], + ); + }, + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.image_outlined)), + title: const Text('Thumb-storage'), + subtitle: Text( + 'etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen', + ), + onLongPress: () { + ConfirmDialog( + title: 'Thumbs cache löschen', + content: 'Alle zwischengespeicherten Bilder werden gelöscht.', + confirmButton: 'Unwiederruflich löschen', + onConfirm: () => PaintingBinding.instance.imageCache.clear(), + ).asDialog(context); + }, + ), + ListTile( + leading: const CenteredLeading( + Icon(Icons.settings_applications_outlined), + ), + title: const Text('Settings-storage JSON dump'), + subtitle: Text( + 'etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen', + ), + onTap: () { + JsonViewer.asDialog(context, widget.settings.val().toJson()); + }, + onLongPress: () { + ConfirmDialog( + title: 'Einstellungen löschen', + content: + 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', + confirmButton: 'Unwiederruflich Löschen', + onConfirm: () { + context.read().reset(); + }, + ).asDialog(context); + }, + trailing: const Icon(Icons.arrow_right), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.data_object)), + title: const Text('Cache-storage JSON dump'), + subtitle: FutureBuilder( + future: const CacheView().totalSize(), + builder: (context, snapshot) => Text( + "etwa ${snapshot.hasError + ? "?" + : snapshot.hasData + ? filesize(snapshot.data) + : "..."}\nLange tippen um zu löschen", + ), + ), + onTap: () => AppRoutes.openCacheView(context), + onLongPress: () { + ConfirmDialog( + title: 'App-Cache löschen', + content: + 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', + confirmButton: 'Unwiederruflich löschen', + onConfirm: () => + const CacheView().clear().then((value) => setState(() {})), + ).asDialog(context); + }, + trailing: const Icon(Icons.arrow_right), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.data_object)), + title: const Text('BLOC-storage state cache'), + subtitle: const Text('Lange tippen um zu löschen'), + onLongPress: () { + ConfirmDialog( + title: 'BLOC-Cache löschen', + content: + 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', + confirmButton: 'Unwiederruflich löschen', + onConfirm: () => HydratedBloc.storage.clear(), + ).asDialog(context); + }, + ), + ], + ); +} diff --git a/lib/view/pages/settings/sections/files_section.dart b/lib/view/pages/settings/sections/files_section.dart new file mode 100644 index 0000000..1c4a7d1 --- /dev/null +++ b/lib/view/pages/settings/sections/files_section.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; + +class FilesSection extends StatelessWidget { + const FilesSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + return Column( + children: [ + ListTile( + leading: const Icon(Icons.drive_folder_upload_outlined), + title: const Text('Ordner in Dateien nach oben sortieren'), + trailing: Checkbox( + value: settings.val().fileSettings.sortFoldersToTop, + onChanged: (e) => + settings.val(write: true).fileSettings.sortFoldersToTop = e!, + ), + ), + ListTile( + leading: const Icon(Icons.open_in_new_outlined), + title: const Text('Dateien immer mit Systemdialog öffnen'), + trailing: Checkbox( + value: settings.val().fileViewSettings.alwaysOpenExternally, + onChanged: (e) => + settings + .val(write: true) + .fileViewSettings + .alwaysOpenExternally = + e!, + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/settings/sections/modules_section.dart b/lib/view/pages/settings/sections/modules_section.dart new file mode 100644 index 0000000..94eb221 --- /dev/null +++ b/lib/view/pages/settings/sections/modules_section.dart @@ -0,0 +1,16 @@ +import 'package:flutter/material.dart'; + +import '../../../../routing/app_routes.dart'; + +class ModulesSection extends StatelessWidget { + const ModulesSection({super.key}); + + @override + Widget build(BuildContext context) => ListTile( + leading: const Icon(Icons.apps_outlined), + title: const Text('Module'), + subtitle: const Text('Reihenfolge, Sichtbarkeit und Modulleiste anpassen'), + trailing: const Icon(Icons.arrow_right), + onTap: () => AppRoutes.openModulesSettings(context), + ); +} diff --git a/lib/view/pages/settings/sections/talk_section.dart b/lib/view/pages/settings/sections/talk_section.dart new file mode 100644 index 0000000..575f4cc --- /dev/null +++ b/lib/view/pages/settings/sections/talk_section.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../notification/notify_updater.dart'; +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/info_dialog.dart'; + +class TalkSection extends StatelessWidget { + const TalkSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final talkSettings = settings.val().talkSettings; + final notificationSettings = settings.val().notificationSettings; + return Column( + children: [ + ListTile( + leading: const Icon(Icons.star_border), + title: const Text('Favoriten im Talk nach oben sortieren'), + trailing: Checkbox( + value: talkSettings.sortFavoritesToTop, + onChanged: (e) => + settings.val(write: true).talkSettings.sortFavoritesToTop = e!, + ), + ), + ListTile( + leading: const Icon(Icons.mark_email_unread_outlined), + title: const Text('Ungelesene Chats nach oben sortieren'), + trailing: Checkbox( + value: talkSettings.sortUnreadToTop, + onChanged: (e) => + settings.val(write: true).talkSettings.sortUnreadToTop = e!, + ), + ), + ListTile( + leading: const CenteredLeading( + Icon(Icons.notifications_active_outlined), + ), + title: const Text('Push-Benachrichtigungen aktivieren'), + subtitle: const Text('Lange tippen für mehr Informationen'), + trailing: Checkbox( + value: notificationSettings.enabled, + onChanged: (e) { + if (e!) { + NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); + } else { + settings.val(write: true).notificationSettings.enabled = e; + } + }, + ), + onLongPress: () => _showInfoDialog(context), + ), + ], + ); + } + + void _showInfoDialog(BuildContext context) => InfoDialog.show( + context, + "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" + 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' + 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' + 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' + 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!', + title: 'Info über Push', + ); +} diff --git a/lib/view/pages/settings/sections/timetable_section.dart b/lib/view/pages/settings/sections/timetable_section.dart new file mode 100644 index 0000000..044cca0 --- /dev/null +++ b/lib/view/pages/settings/sections/timetable_section.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../../../../view/pages/timetable/data/timetable_name_mode.dart'; + +class TimetableSection extends StatelessWidget { + const TimetableSection({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + final timetableSettings = settings.val().timetableSettings; + return Column( + children: [ + ListTile( + leading: const Icon(Icons.abc_outlined), + title: const Text('Fachbezeichnung'), + trailing: DropdownButton( + value: timetableSettings.timetableNameMode, + icon: const Icon(Icons.arrow_drop_down), + items: TimetableNameMode.values + .map( + (e) => DropdownMenuItem( + value: e, + enabled: e != timetableSettings.timetableNameMode, + child: Row( + children: [ + Icon(TimetableNameModes.getDisplayOptions(e).icon), + const SizedBox(width: 10), + Text( + TimetableNameModes.getDisplayOptions(e).displayName, + ), + ], + ), + ), + ) + .toList(), + onChanged: (value) => + settings.val(write: true).timetableSettings.timetableNameMode = + value!, + ), + ), + ListTile( + leading: const Icon(Icons.calendar_view_day_outlined), + title: const Text('Doppelstunden zusammenhängend anzeigen'), + trailing: Checkbox( + value: timetableSettings.connectDoubleLessons, + onChanged: (e) => + settings + .val(write: true) + .timetableSettings + .connectDoubleLessons = + e!, + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/settings/settings.dart b/lib/view/pages/settings/settings.dart new file mode 100644 index 0000000..6c6b970 --- /dev/null +++ b/lib/view/pages/settings/settings.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +import 'sections/about_section.dart'; +import 'sections/account_section.dart'; +import 'sections/appearance_section.dart'; +import 'sections/files_section.dart'; +import 'sections/modules_section.dart'; +import 'sections/talk_section.dart'; +import 'sections/timetable_section.dart'; + +class Settings extends StatelessWidget { + const Settings({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Einstellungen')), + body: ListView( + children: const [ + AccountSection(), + Divider(), + AppearanceSection(), + Divider(), + ModulesSection(), + Divider(), + TimetableSection(), + Divider(), + TalkSection(), + Divider(), + FilesSection(), + Divider(), + AboutSection(), + ], + ), + ); +} diff --git a/lib/view/pages/settings/widgets/privacy_info.dart b/lib/view/pages/settings/widgets/privacy_info.dart new file mode 100644 index 0000000..8e15c67 --- /dev/null +++ b/lib/view/pages/settings/widgets/privacy_info.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/details_bottom_sheet.dart'; + +class PrivacyInfo { + String providerText; + String privacyUrl; + String imprintUrl; + + PrivacyInfo({ + required this.providerText, + required this.imprintUrl, + required this.privacyUrl, + }); + + void showPopup(BuildContext context) { + showDetailsBottomSheet( + context, + header: Padding( + padding: const EdgeInsets.fromLTRB(16, 4, 16, 12), + child: Text( + 'Betreiberinformation | $providerText', + style: Theme.of(context).textTheme.titleLarge, + ), + ), + children: (sheetCtx) => [ + ListTile( + leading: const CenteredLeading(Icon(Icons.person_pin_outlined)), + title: const Text('Impressum'), + subtitle: Text(imprintUrl), + onTap: () => ConfirmDialog.openBrowser(sheetCtx, imprintUrl), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)), + title: const Text('Datenschutzerklärung'), + subtitle: Text(privacyUrl), + onTap: () => ConfirmDialog.openBrowser(sheetCtx, privacyUrl), + ), + ], + ); + } +} diff --git a/lib/view/pages/talk/chatDetails/participants/participantsListView.dart b/lib/view/pages/talk/chatDetails/participants/participantsListView.dart deleted file mode 100644 index dfb6b82..0000000 --- a/lib/view/pages/talk/chatDetails/participants/participantsListView.dart +++ /dev/null @@ -1,47 +0,0 @@ -import 'package:collection/collection.dart'; -import 'package:flutter/material.dart'; - -import '../../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; -import '../../../../../widget/userAvatar.dart'; - -class ParticipantsListView extends StatelessWidget { - final GetParticipantsResponse participantsResponse; - const ParticipantsListView(this.participantsResponse, {super.key}); - - @override - Widget build(BuildContext context) { - lastname(participant) => participant.displayName.toString().split(' ').last; - - final participants = participantsResponse.data - .sorted((a, b) { - final typeComparison = a.participantType.index.compareTo(b.participantType.index); - if (typeComparison != 0) return typeComparison; - return lastname(a).compareTo(lastname(b)); - }); - var groupedParticipants = participants.groupListsBy((participant) => participant.participantType); - - return Scaffold( - appBar: AppBar( - title: const Text('Mitglieder'), - ), - body: ListView( - children: [ - ...groupedParticipants.entries.map((entry) => Column( - children: [ - ListTile( - title: Text(entry.key.prettyName), - titleTextStyle: Theme.of(context).textTheme.titleMedium - ), - ...entry.value.map((participant) => ListTile( - leading: UserAvatar(id: participant.actorId), - title: Text(participant.displayName), - subtitle: participant.statusMessage != null ? Text(participant.statusMessage!) : null, - )), - Divider(), - ], - )) - ], - ) - ); - } -} diff --git a/lib/view/pages/talk/chatList.dart b/lib/view/pages/talk/chatList.dart deleted file mode 100644 index 905a11d..0000000 --- a/lib/view/pages/talk/chatList.dart +++ /dev/null @@ -1,149 +0,0 @@ - -import 'dart:async'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_split_view/flutter_split_view.dart'; -import 'package:provider/provider.dart'; - -import '../../../api/marianumcloud/talk/createRoom/createRoom.dart'; -import '../../../api/marianumcloud/talk/createRoom/createRoomParams.dart'; -import '../../../model/chatList/chatListProps.dart'; -import '../../../notification/notifyUpdater.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/loadingSpinner.dart'; -import 'components/chatTile.dart'; -import 'components/splitViewPlaceholder.dart'; -import 'joinChat.dart'; -import 'searchChat.dart'; - -class ChatList extends StatefulWidget { - const ChatList({super.key}); - - @override - State createState() => _ChatListState(); -} - -class _ChatListState extends State { - late SettingsProvider settings; - - @override - void initState() { - super.initState(); - settings = Provider.of(context, listen: false); - - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - _query(); - - if(!settings.val().notificationSettings.enabled && !settings.val().notificationSettings.askUsageDismissed) { - settings.val(write: true).notificationSettings.askUsageDismissed = true; - - ConfirmDialog( - icon: Icons.notifications_active_outlined, - title: 'Benachrichtigungen aktivieren', - content: 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', - confirmButton: 'Weiter', - onConfirm: () { - FirebaseMessaging.instance.requestPermission( - provisional: false - ).then((value) { - switch (value.authorizationStatus) { - case AuthorizationStatus.authorized: - NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); - break; - case AuthorizationStatus.denied: - showDialog(context: context, builder: (context) => const AlertDialog( - content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'), - )); - break; - default: - break; - } - }); - }, - ).asDialog(context); - } - }); - } - - void _query({bool renew = false}) { - Provider.of(context, listen: false).run(renew: renew); - } - - @override - Widget build(BuildContext context) { - ChatListProps? latestData; - - return SplitView.material( - placeholder: const SplitViewPlaceholder(), - breakpoint: 1000, - child: Scaffold( - appBar: AppBar( - title: const Text('Talk'), - actions: [ - IconButton( - icon: const Icon(Icons.search), - onPressed: () async { - if(latestData == null) return; - showSearch(context: context, delegate: SearchChat(latestData!.getRoomsResponse.data.toList())); - }, - ) - ], - ), - floatingActionButton: FloatingActionButton( - heroTag: 'createChat', - backgroundColor: Theme.of(context).primaryColor, - onPressed: () async { - showSearch(context: context, delegate: JoinChat()).then((username) { - if(username == null) return; - - ConfirmDialog( - title: 'Chat starten', - content: "Möchtest du einen Chat mit Nutzer '$username' starten?", - confirmButton: 'Chat starten', - onConfirm: () { - CreateRoom(CreateRoomParams( - roomType: 1, - invite: username, - )).run().then((value) { - _query(renew: true); - }); - }, - ).asDialog(context); - }); - }, - child: const Icon(Icons.add_comment_outlined), - ), - body: Consumer( - builder: (context, data, child) { - - if(data.primaryLoading()) return const LoadingSpinner(); - latestData = data; - var chats = []; - for (var chatRoom in data.getRoomsResponse.sortBy( - lastActivity: true, - favoritesToTop: Provider.of(context).val().talkSettings.sortFavoritesToTop, - unreadToTop: Provider.of(context).val().talkSettings.sortUnreadToTop, - ) - ) { - var hasDraft = settings.val().talkSettings.drafts.containsKey(chatRoom.token); - chats.add(ChatTile(data: chatRoom, query: _query, hasDraft: hasDraft)); - } - - return RefreshIndicator( - color: Theme.of(context).primaryColor, - onRefresh: () { - _query(renew: true); - return Future.delayed(const Duration(seconds: 3)); - }, - child: ListView( - padding: EdgeInsets.zero, - children: chats - ), - ); - }, - ), - ), - ); - } -} diff --git a/lib/view/pages/talk/chatView.dart b/lib/view/pages/talk/chatView.dart deleted file mode 100644 index 4d6ca48..0000000 --- a/lib/view/pages/talk/chatView.dart +++ /dev/null @@ -1,144 +0,0 @@ - -import 'package:flutter/material.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; - -import '../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../theming/appTheme.dart'; -import '../../../model/chatList/chatProps.dart'; -import '../../../widget/clickableAppBar.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/userAvatar.dart'; -import 'chatDetails/chatInfo.dart'; -import 'components/chatBubble.dart'; -import 'components/chatTextfield.dart'; -import 'talkNavigator.dart'; - -class ChatView extends StatefulWidget { - final GetRoomResponseObject room; - final String selfId; - final UserAvatar avatar; - - const ChatView({super.key, required this.room, required this.selfId, required this.avatar}); - - @override - State createState() => _ChatViewState(); -} - -class _ChatViewState extends State { - - final ScrollController _listController = ScrollController(); - - @override - void initState() { - super.initState(); - } - - void _query({bool renew = false}) { - Provider.of(context, listen: false).setQueryToken(widget.room.token); - } - - @override - Widget build(BuildContext context) => Consumer( - builder: (context, data, child) { - var messages = List.empty(growable: true); - - if(!data.primaryLoading()) { - - var lastDate = DateTime.now(); - data.getChatResponse.sortByTimestamp().forEach((element) { - var elementDate = DateTime.fromMillisecondsSinceEpoch(element.timestamp * 1000); - - if(element.systemMessage.contains('reaction')) return; - if(element.systemMessage.contains('poll_voted')) return; - var commonRead = int.parse(data.getChatResponse.headers?['x-chat-last-common-read'] ?? '0'); - - if(!elementDate.isSameDay(lastDate)) { - lastDate = elementDate; - messages.add(ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), - chatData: widget.room, - refetch: _query, - )); - } - messages.add( - ChatBubble( - context: context, - isSender: element.actorId == widget.selfId && element.messageType == GetRoomResponseObjectMessageType.comment, - bubbleData: element, - chatData: widget.room, - refetch: _query, - isRead: element.id <= commonRead, - selfId: widget.selfId, - ) - ); - }); - - if(data.getChatResponse.data.length >= 200) { - messages.insert(0, ChatBubble( - context: context, - isSender: false, - bubbleData: GetChatResponseObject.getTextDummy( - 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' - 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de' - ), - chatData: widget.room, - refetch: _query, - )); - } - } - - return Scaffold( - backgroundColor: const Color(0xffefeae2), - appBar: ClickableAppBar( - onTap: () { - TalkNavigator.pushSplitView(context, ChatInfo(widget.room)); - }, - appBar: AppBar( - title: Row( - children: [ - widget.avatar, - const SizedBox(width: 10), - Expanded( - child: Text(widget.room.displayName, overflow: TextOverflow.ellipsis, maxLines: 1), - ) - ], - ), - ), - ), - body: Container( - decoration: BoxDecoration( - image: DecorationImage( - image: const AssetImage('assets/background/chat.png'), - scale: 1.5, - opacity: 1, - repeat: ImageRepeat.repeat, - invertColors: AppTheme.isDarkMode(context), - ) - ), - child: data.primaryLoading() ? const LoadingSpinner() : Column( - children: [ - Expanded( - child: ListView( - reverse: true, - controller: _listController, - children: messages.reversed.toList(), - ), - ), - Container( - color: Theme.of(context).colorScheme.surface, - child: TalkNavigator.isSecondaryVisible(context) - ? ChatTextfield(widget.room.token, selfId: widget.selfId) - : SafeArea(child: ChatTextfield(widget.room.token, selfId: widget.selfId) - ), - ) - ], - ), - ), - ); - }, - ); -} diff --git a/lib/view/pages/talk/chat_list.dart b/lib/view/pages/talk/chat_list.dart new file mode 100644 index 0000000..8939266 --- /dev/null +++ b/lib/view/pages/talk/chat_list.dart @@ -0,0 +1,205 @@ +import 'package:firebase_messaging/firebase_messaging.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_split_view/flutter_split_view.dart'; + +import '../../../notification/notify_updater.dart'; +import '../../../routing/app_routes.dart'; +import '../../../state/app/infrastructure/loadable_state/loadable_state.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; +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/info_dialog.dart'; +import '../../../widget/placeholder_view.dart'; +import 'join_chat.dart'; +import 'search_chat.dart'; +import 'widgets/chat_tile.dart'; +import 'widgets/split_view_placeholder.dart'; + +class ChatList extends StatelessWidget { + const ChatList({super.key}); + + @override + Widget build(BuildContext context) => + BlocModule>( + create: (_) => ChatListBloc(), + child: (context, bloc, _) => const _ChatListView(), + ); +} + +class _ChatListView extends StatefulWidget { + const _ChatListView(); + + @override + State<_ChatListView> createState() => _ChatListViewState(); +} + +class _ChatListViewState extends State<_ChatListView> { + late final SettingsCubit _settings; + + @override + void initState() { + super.initState(); + _settings = context.read(); + + AppRoutes.pendingChatToken.addListener(_maybeOpenPendingChat); + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _maybeAskForNotificationPermission(); + _maybeOpenPendingChat(); + }); + } + + @override + void dispose() { + AppRoutes.pendingChatToken.removeListener(_maybeOpenPendingChat); + super.dispose(); + } + + void _maybeOpenPendingChat() { + if (!mounted) return; + final resolved = AppRoutes.resolvePendingChat(context); + if (resolved == null) return; + AppRoutes.pendingChatToken.value = null; + + // Replace any chat already pushed on top of the chat list so a freshly + // tapped notification doesn't stack indefinitely on previous chats. + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.popUntil((route) => route.isFirst); + } + + AppRoutes.openChatView( + context, + room: resolved.room, + selfId: resolved.selfId, + avatar: resolved.avatar, + overrideToSingleSubScreen: true, + ); + } + + void _maybeAskForNotificationPermission() { + final notificationSettings = _settings.val().notificationSettings; + if (notificationSettings.enabled || + notificationSettings.askUsageDismissed) { + return; + } + + _settings.val(write: true).notificationSettings.askUsageDismissed = true; + ConfirmDialog( + icon: Icons.notifications_active_outlined, + title: 'Benachrichtigungen aktivieren', + content: + 'Auf wunsch kannst du Push-Benachrichtigungen aktivieren. Deine Einstellungen kannst du jederzeit ändern.', + confirmButton: 'Weiter', + onConfirm: () { + FirebaseMessaging.instance.requestPermission(provisional: false).then(( + value, + ) { + if (!mounted) return; + switch (value.authorizationStatus) { + case AuthorizationStatus.authorized: + NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context); + break; + case AuthorizationStatus.denied: + InfoDialog.show( + context, + 'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.', + ); + break; + default: + break; + } + }); + }, + ).asDialog(context); + } + + @override + Widget build(BuildContext context) { + final bloc = context.read(); + return SplitView.material( + placeholder: const SplitViewPlaceholder(), + breakpoint: 1000, + child: BlocListener>( + listener: (_, _) => _maybeOpenPendingChat(), + child: Scaffold( + appBar: AppBar( + title: const Text('Talk'), + actions: [ + IconButton( + icon: const Icon(Icons.search), + onPressed: () { + final rooms = bloc.state.data?.rooms; + if (rooms == null) return; + showSearch( + context: context, + delegate: SearchChat(rooms.data.toList()), + ); + }, + ), + ], + ), + floatingActionButton: FloatingActionButton( + heroTag: 'createChat', + backgroundColor: Theme.of(context).primaryColor, + onPressed: () { + showSearch(context: context, delegate: JoinChat()).then(( + username, + ) { + if (username == null || !context.mounted) return; + ConfirmDialog( + title: 'Chat starten', + content: + "Möchtest du einen Chat mit Nutzer '$username' starten?", + confirmButton: 'Chat starten', + onConfirmAsync: () => bloc.createDirectChat(username), + ).asDialog(context); + }); + }, + child: const Icon(Icons.add_comment_outlined), + ), + body: LoadableStateConsumer( + child: (state, _) { + final rooms = state.rooms; + if (rooms == null) return const SizedBox.shrink(); + + final talkSettings = context + .watch() + .val() + .talkSettings; + final sorted = rooms.sortBy( + lastActivity: true, + favoritesToTop: talkSettings.sortFavoritesToTop, + unreadToTop: talkSettings.sortUnreadToTop, + ); + + if (sorted.isEmpty) { + return const PlaceholderView( + icon: Icons.chat_bubble_outline, + text: 'Noch keine Chats — starte einen über das +-Symbol.', + ); + } + + return ListView( + padding: EdgeInsets.zero, + children: sorted.map((room) { + final hasDraft = _settings + .val() + .talkSettings + .drafts + .containsKey(room.token); + return ChatTile(data: room, hasDraft: hasDraft); + }).toList(), + ); + }, + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart new file mode 100644 index 0000000..2c81c48 --- /dev/null +++ b/lib/view/pages/talk/chat_view.dart @@ -0,0 +1,162 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../extensions/date_time.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../state/app/modules/chat/bloc/chat_state.dart'; +import '../../../theming/app_theme.dart'; +import '../../../widget/clickable_app_bar.dart'; +import '../../../widget/user_avatar.dart'; +import 'details/chat_info.dart'; +import 'talk_navigator.dart'; +import 'widgets/chat_bubble.dart'; +import 'widgets/chat_textfield.dart'; + +class ChatView extends StatefulWidget { + final GetRoomResponseObject room; + final String selfId; + final UserAvatar avatar; + + const ChatView({ + super.key, + required this.room, + required this.selfId, + required this.avatar, + }); + + @override + State createState() => _ChatViewState(); +} + +class _ChatViewState extends State { + final ScrollController _listController = ScrollController(); + + void _refresh() { + context.read().setToken(widget.room.token); + } + + List _buildMessages(GetChatResponse response) { + final messages = []; + var lastDate = DateTime.now(); + for (final element in response.sortByTimestamp()) { + final elementDate = DateTime.fromMillisecondsSinceEpoch( + element.timestamp * 1000, + ); + + if (element.systemMessage.contains('reaction')) continue; + if (element.systemMessage.contains('poll_voted')) continue; + final commonRead = int.parse( + response.headers?['x-chat-last-common-read'] ?? '0', + ); + + if (!elementDate.isSameDay(lastDate)) { + lastDate = elementDate; + messages.add( + ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getDateDummy(element.timestamp), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + ), + ); + } + + messages.add( + ChatBubble( + context: context, + isSender: + element.actorId == widget.selfId && + element.messageType == GetRoomResponseObjectMessageType.comment, + bubbleData: element, + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + isRead: element.id <= commonRead, + selfId: widget.selfId, + ), + ); + } + + if (response.data.length >= 200) { + messages.insert( + 0, + ChatBubble( + context: context, + isSender: false, + bubbleData: GetChatResponseObject.getTextDummy( + 'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. ' + 'Um ältere Nachrichten abzurufen verwende die Webversion unter https://cloud.marianum-fulda.de', + ), + chatData: widget.room, + refetch: ({bool renew = false}) => _refresh(), + ), + ); + } + + return messages; + } + + @override + Widget build(BuildContext context) => Scaffold( + backgroundColor: const Color(0xffefeae2), + appBar: ClickableAppBar( + onTap: () => TalkNavigator.pushSplitView(context, ChatInfo(widget.room)), + appBar: AppBar( + title: Row( + children: [ + widget.avatar, + const SizedBox(width: 10), + Expanded( + child: Text( + widget.room.displayName, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ), + ], + ), + ), + ), + body: DecoratedBox( + decoration: BoxDecoration( + image: DecorationImage( + image: const AssetImage('assets/background/chat.png'), + scale: 1.5, + opacity: 1, + repeat: ImageRepeat.repeat, + invertColors: AppTheme.isDarkMode(context), + ), + ), + child: Column( + children: [ + Expanded( + child: LoadableStateConsumer( + isReady: (state) => + state.chatResponse != null && + state.currentToken == widget.room.token, + child: (state, _) => ListView( + reverse: true, + controller: _listController, + children: _buildMessages(state.chatResponse!).reversed.toList(), + ), + ), + ), + ColoredBox( + color: Theme.of(context).colorScheme.surface, + child: TalkNavigator.isSecondaryVisible(context) + ? ChatTextfield(widget.room.token, selfId: widget.selfId) + : SafeArea( + child: ChatTextfield( + widget.room.token, + selfId: widget.selfId, + ), + ), + ), + ], + ), + ), + ); +} diff --git a/lib/view/pages/talk/components/chatBubble.dart b/lib/view/pages/talk/components/chatBubble.dart deleted file mode 100644 index 141c47e..0000000 --- a/lib/view/pages/talk/components/chatBubble.dart +++ /dev/null @@ -1,502 +0,0 @@ -import 'package:bubble/bubble.dart'; -import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; -import 'package:flowder/flowder.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:open_filex/open_filex.dart'; -import '../../../../api/marianumcloud/talk/getPoll/getPollState.dart'; -import '../../../../extensions/text.dart'; -import 'package:provider/provider.dart'; - -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/deleteMessage/deleteMessage.dart'; -import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessage.dart'; -import '../../../../api/marianumcloud/talk/deleteReactMessage/deleteReactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessage.dart'; -import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../model/chatList/chatProps.dart'; -import '../../../../widget/debug/debugTile.dart'; -import '../../../../widget/loadingSpinner.dart'; -import '../../files/fileElement.dart'; -import 'answerReference.dart'; -import 'chatBubbleStyles.dart'; -import 'chatMessage.dart'; -import '../messageReactions.dart'; -import 'pollOptionsList.dart'; - -class ChatBubble extends StatefulWidget { - final BuildContext context; - final bool isSender; - final GetChatResponseObject bubbleData; - final GetRoomResponseObject chatData; - final bool isRead; - final String? selfId; - - final double spacing = 3; - final double timeIconSize = 11; - final Color timeIconColor = Colors.grey; - - final void Function({bool renew}) refetch; - - const ChatBubble({ - required this.context, - required this.isSender, - required this.bubbleData, - required this.chatData, - required this.refetch, - this.isRead = false, - this.selfId, - super.key}); - - @override - State createState() => _ChatBubbleState(); -} - -class _ChatBubbleState extends State with SingleTickerProviderStateMixin { - late ChatMessage message; - double downloadProgress = 0; - Future? downloadCore; - - late Offset _position = const Offset(0, 0); - late Offset _dragStartPosition = Offset.zero; - - BubbleStyle getStyle() { - var styles = ChatBubbleStyles(context); - if(widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) { - if(widget.isSender) { - return styles.getSelfStyle(false); - } else { - return styles.getRemoteStyle(false); - } - } else { - return styles.getSystemStyle(); - } - } - - void showOptionsDialog() { - showDialog(context: context, builder: (context) { - var commonReactions = ['👍', '👎', '😆', '❤️', '👀']; - var canReact = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment; - return SimpleDialog( - children: [ - Visibility( - visible: canReact, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Wrap( - alignment: WrapAlignment.center, - children: [ - ...commonReactions.map((e) => TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40) - ), - onPressed: () { - Navigator.of(context).pop(); - ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(e), - ).run().then((value) => widget.refetch(renew: true)); - }, - child: Text(e), - ), - ), - IconButton( - onPressed: () { - showDialog(context: context, builder: (context) => AlertDialog( - contentPadding: const EdgeInsets.all(15), - titlePadding: const EdgeInsets.only(left: 6, top: 15), - title: Row( - mainAxisAlignment: MainAxisAlignment.start, - children: [ - IconButton( - onPressed: () { - Navigator.of(context).pop(); - }, - icon: const Icon(Icons.arrow_back), - ), - const SizedBox(width: 10), - const Text('Reagieren'), - ], - ), - content: SizedBox( - width: 256, - height: 270, - child: Column( - children: [ - emojis.EmojiPicker( - config: emojis.Config( - height: 256, - // swapCategoryAndBottomBar: true, // TODO this property is no longer supported, need to find an replacement - emojiViewConfig: emojis.EmojiViewConfig( - backgroundColor: Theme.of(context).canvasColor, - recentsLimit: 67, - emojiSizeMax: 25, - noRecents: const Text('Keine zuletzt verwendeten Emojis'), - columns: 7, - ), - bottomActionBarConfig: const emojis.BottomActionBarConfig( - enabled: false, - ), - categoryViewConfig: emojis.CategoryViewConfig( - backgroundColor: Theme.of(context).hoverColor, - iconColorSelected: Theme.of(context).primaryColor, - indicatorColor: Theme.of(context).primaryColor, - ), - searchViewConfig: emojis.SearchViewConfig( - backgroundColor: Theme.of(context).dividerColor, - // buttonColor: Theme.of(context).dividerColor, // TODO property no longer supported - hintText: 'Suchen', - buttonIconColor: Colors.white, - ), - ), - onEmojiSelected: (emojis.Category? category, emojis.Emoji emoji) { - Navigator.of(context).pop(); - Navigator.of(context).pop(); - ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(emoji.emoji), - ).run().then((value) => widget.refetch(renew: true)); - }, - ), - ], - ), - ), - )); - }, - style: IconButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - minimumSize: const Size(40, 40), - ), - icon: const Icon(Icons.add_circle_outline_outlined), - ), - ], - ), - const Divider(), - ], - ), - ), - Visibility( - visible: widget.bubbleData.isReplyable, - child: ListTile( - leading: const Icon(Icons.reply_outlined), - title: const Text('Antworten'), - onTap: () => { - Provider.of(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token), - Navigator.of(context).pop(), - }, - ), - ), - Visibility( - visible: canReact, - child: ListTile( - leading: const Icon(Icons.emoji_emotions_outlined), - title: const Text('Reaktionen'), - onTap: () { - Navigator.of(context).push(MaterialPageRoute(builder: (context) => MessageReactions( - token: widget.chatData.token, - messageId: widget.bubbleData.id, - ))); - }, - ), - ), - Visibility( - visible: widget.bubbleData.message != '{file}', - child: ListTile( - leading: const Icon(Icons.copy), - title: const Text('Nachricht kopieren'), - onTap: () => { - Clipboard.setData(ClipboardData(text: widget.bubbleData.message)), - Navigator.of(context).pop(), - }, - ), - ), - Visibility( - visible: !kReleaseMode && !widget.isSender && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne, - child: ListTile( - leading: const Icon(Icons.sms_outlined), - title: Text("Private Nachricht an '${widget.bubbleData.actorDisplayName}'"), - onTap: () => { - Navigator.of(context).pop() - }, - ), - ), - Visibility( - visible: widget.isSender && DateTime.fromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).add(const Duration(hours: 6)).isAfter(DateTime.now()), - child: ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Nachricht löschen'), - onTap: () { - DeleteMessage(widget.chatData.token, widget.bubbleData.id).run().then((value) { - Provider.of(context, listen: false).run(); - Navigator.of(context).pop(); - }); - }, - ), - ), - DebugTile(context).jsonData(widget.bubbleData.toJson()), - ], - ); - }); - } - - - @override - Widget build(BuildContext context) { - message = ChatMessage(originalMessage: widget.bubbleData.message, originalData: widget.bubbleData.messageParameters); - var showActorDisplayName = widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment && widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; - var showBubbleTime = widget.bubbleData.messageType != GetRoomResponseObjectMessageType.system - && widget.bubbleData.messageType != GetRoomResponseObjectMessageType.deletedComment; - - var parent = widget.bubbleData.parent; - var actorText = Text( - widget.bubbleData.actorDisplayName, - textAlign: TextAlign.start, - overflow: TextOverflow.ellipsis, - style: TextStyle(color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), - ); - - var timeText = Text( - Jiffy.parseFromMillisecondsSinceEpoch(widget.bubbleData.timestamp * 1000).format(pattern: 'HH:mm'), - textAlign: TextAlign.end, - style: TextStyle(color: widget.timeIconColor, fontSize: widget.timeIconSize), - ); - - return Column( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - textDirection: TextDirection.ltr, - crossAxisAlignment: CrossAxisAlignment.end, - - children: [ - GestureDetector( - onHorizontalDragStart: (details) { - _dragStartPosition = _position; - }, - onHorizontalDragUpdate: (details) { - if(!widget.bubbleData.isReplyable) return; - var dx = details.delta.dx - _dragStartPosition.dx; - setState(() { - _position = (_position.dx + dx).abs() > 60 ? Offset(_position.dx, 0) : Offset(_position.dx + dx, 0); - }); - }, - onHorizontalDragEnd: (DragEndDetails details) { - var isAction = _position.dx.abs() > 50; - setState(() { - _position = const Offset(0, 0); - }); - if(widget.bubbleData.isReplyable && isAction) { - Provider.of(context, listen: false).setReferenceMessageId(widget.bubbleData.id, context, widget.chatData.token); - } - }, - onLongPress: showOptionsDialog, - onDoubleTap: showOptionsDialog, - onTap: () { - if(message.originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { - var pollId = int.parse(message.originalData!['object']!.id); - var pollState = GetPollState(token: widget.bubbleData.token, pollId: pollId).run(); - showDialog(context: context, builder: (context) => AlertDialog( - title: Text(message.originalData!['object']!.name, overflow: TextOverflow.ellipsis), - content: FutureBuilder( - future: pollState, - builder: (context, snapshot) { - if(snapshot.connectionState == ConnectionState.waiting) return const Column(mainAxisSize: MainAxisSize.min, children: [LoadingSpinner()]); - - var pollData = snapshot.data!.data; - return SingleChildScrollView( - child: PollOptionsList( - pollData: pollData, - chatToken: widget.chatData.token, - ), - ); - } - ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Zurück') - ), - ], - )); - } - - if(message.file == null) return; - - if(downloadProgress > 0) { - showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Download abbrechen?'), - content: const Text('Möchtest du den Download abbrechen?'), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: const Text('Nein')), - TextButton(onPressed: () { - downloadCore?.then((value) { - if(!value.isCancelled) value.cancel(); - Navigator.of(context).pop(); - }); - setState(() { - downloadProgress = 0; - downloadCore = null; - }); - }, child: const Text('Ja, Abbrechen')) - ], - )); - - return; - } - - setState(() { - downloadProgress = 1; - }); - downloadCore = FileElement.download(context, message.file!.path!, message.file!.name, (progress) { - if(progress > 1) { - setState(() { - downloadProgress = progress; - }); - } - }, (result) { - setState(() { - downloadProgress = 0; - }); - - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(result.message), - )); - } - }); - }, - child: Transform.translate( - offset: _position, - child: Bubble( - style: getStyle(), - child: Column( - children: [ - Container( - constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.9, - minWidth: showActorDisplayName - ? actorText.size.width - : timeText.size.width + (widget.isSender ? widget.spacing + widget.timeIconSize : 0) + 3, - ), - child: Stack( - children: [ - Visibility( - visible: showActorDisplayName, - child: Positioned( - top: 0, - left: 0, - child: actorText - ), - ), - Padding( - padding: EdgeInsets.only(bottom: showBubbleTime ? 18 : 0, top: showActorDisplayName ? 18 : 0), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if(parent != null && widget.bubbleData.messageType == GetRoomResponseObjectMessageType.comment) ...[ - AnswerReference( - context: context, - referenceMessage: parent, - selfId: widget.selfId, - ), - const SizedBox(height: 5), - ], - message.getWidget(), - ], - ), - ), - Visibility( - visible: showBubbleTime, - child: Positioned( - bottom: 0, - right: 0, - child: Row( - children: [ - timeText, - if(widget.isSender) ...[ - SizedBox(width: widget.spacing), - Icon( - widget.isRead ? Icons.done_all_outlined: Icons.done_outlined, - size: widget.timeIconSize, - color: widget.timeIconColor - ) - ] - ], - ) - ), - ), - Visibility( - visible: downloadProgress > 0, - child: Positioned( - bottom: 0, - right: 0, - left: 0, - child: LinearProgressIndicator(value: downloadProgress == 1 ? null : downloadProgress/100), - ), - ), - ], - ), - ), - ], - ), - ), - ), - ), - Visibility( - visible: widget.bubbleData.reactions != null, - child: Transform.translate( - offset: const Offset(0, -10), - child: Container( - width: MediaQuery.of(context).size.width, - margin: const EdgeInsets.only(left: 15, right: 15), - child: Wrap( - alignment: widget.isSender ? WrapAlignment.end : WrapAlignment.start, - crossAxisAlignment: WrapCrossAlignment.start, - children: widget.bubbleData.reactions?.entries.map((e) { - var hasSelfReacted = widget.bubbleData.reactionsSelf?.contains(e.key) ?? false; - return Container( - margin: const EdgeInsets.only(right: 2.5, left: 2.5), - child: ActionChip( - label: Text('${e.key} ${e.value}'), - visualDensity: const VisualDensity(vertical: VisualDensity.minimumDensity, horizontal: VisualDensity.minimumDensity), - padding: EdgeInsets.zero, - backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null, - onPressed: () { - if(hasSelfReacted) { - // Delete existing reaction - DeleteReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: DeleteReactMessageParams(e.key), - ).run().then((value) => widget.refetch(renew: true)); - - } else { - // Add reaction - ReactMessage( - chatToken: widget.chatData.token, - messageId: widget.bubbleData.id, - params: ReactMessageParams(e.key) - ).run().then((value) => widget.refetch(renew: true)); - } - }, - ), - ); - }).toList() ?? [], - ), - ), - ), - ), - ], - ); - } -} diff --git a/lib/view/pages/talk/components/chatMessage.dart b/lib/view/pages/talk/components/chatMessage.dart deleted file mode 100644 index 468805f..0000000 --- a/lib/view/pages/talk/components/chatMessage.dart +++ /dev/null @@ -1,76 +0,0 @@ - -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_linkify/flutter_linkify.dart'; - -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import '../../../../model/accountData.dart'; -import '../../../../model/endpointData.dart'; -import '../../../../utils/UrlOpener.dart'; - -class ChatMessage { - String originalMessage; - Map? originalData; - - RichObjectString? file; - String content = ''; - - bool get containsFile => file != null; - - ChatMessage({required this.originalMessage, this.originalData}) { - if(originalData?.containsKey('file') ?? false) { - file = originalData?['file']; - } - content = RichObjectStringProcessor.parseToString(originalMessage, originalData); - } - - Widget getWidget() { - - var contentWidget = Linkify( - text: content, - onOpen: UrlOpener.onOpen, - ); - - if(originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { - return ListTile( - leading: const Icon(Icons.poll_outlined), - title: Text(originalData!['object']!.name), - contentPadding: const EdgeInsets.only(left: 10), - ); - } - - if(file == null) return contentWidget; - - return Padding( - padding: const EdgeInsets.only(top: 5), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CachedNetworkImage( - errorWidget: (context, url, error) => Row( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - const Icon(Icons.file_open_outlined, size: 35), - const SizedBox(width: 10), - Flexible(child: Text(file!.name, maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle(fontWeight: FontWeight.bold))), - const SizedBox(width: 10), - ], - ), - alignment: Alignment.center, - placeholder: (context, url) => const Padding(padding: EdgeInsets.all(15), child: SizedBox(width: 50, child: LinearProgressIndicator())), - fadeInDuration: Duration.zero, - fadeOutDuration: Duration.zero, - errorListener: (value) {}, - imageUrl: 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', - ), - if(originalMessage != '{file}') ...[ - SizedBox(height: 5), - contentWidget - ] - ], - ) - ); - } -} diff --git a/lib/view/pages/talk/components/chatTextfield.dart b/lib/view/pages/talk/components/chatTextfield.dart deleted file mode 100644 index 2da2f9a..0000000 --- a/lib/view/pages/talk/components/chatTextfield.dart +++ /dev/null @@ -1,233 +0,0 @@ - -import 'dart:io'; -import 'package:flutter/material.dart'; -import 'package:nextcloud/nextcloud.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; - -import '../../../../api/marianumcloud/files-sharing/fileSharingApi.dart'; -import '../../../../api/marianumcloud/files-sharing/fileSharingApiParams.dart'; -import '../../../../api/marianumcloud/talk/sendMessage/sendMessage.dart'; -import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart'; -import '../../../../api/marianumcloud/webdav/webdavApi.dart'; -import '../../../../model/chatList/chatProps.dart'; -import '../../../../storage/base/settingsProvider.dart'; -import '../../../../widget/filePick.dart'; -import '../../../../widget/focusBehaviour.dart'; -import '../../files/filesUploadDialog.dart'; -import 'answerReference.dart'; - -class ChatTextfield extends StatefulWidget { - final String sendToToken; - final String? selfId; - const ChatTextfield(this.sendToToken, {this.selfId, super.key}); - - @override - State createState() => _ChatTextfieldState(); -} - -class _ChatTextfieldState extends State { - late SettingsProvider settings; - final TextEditingController _textBoxController = TextEditingController(); - bool isLoading = false; - - void _query() { - Provider.of(context, listen: false).run(); - } - - void share(String shareFolder, List filePaths) { - for (var element in filePaths) { - var fileName = element.split(Platform.pathSeparator).last; - FileSharingApi().share(FileSharingApiParams( - shareType: 10, - shareWith: widget.sendToToken, - path: '$shareFolder/$fileName', - )).then((value) => _query()); - } - } - - Future mediaUpload(List? paths) async { - if (paths == null) return; - - var shareFolder = 'MarianumMobile'; - WebdavApi.webdav.then((webdav) { - webdav.mkcol(PathUri.parse('/$shareFolder')); - }); - - pushScreen( - context, - withNavBar: false, - screen: FilesUploadDialog( - filePaths: paths, - remotePath: shareFolder, - onUploadFinished: (uploadedFilePaths) { - share(shareFolder, uploadedFilePaths); - }, - uniqueNames: true, - ), - ); - } - - void setDraft(String text) { - if(text.isNotEmpty) { - settings.val(write: true).talkSettings.drafts[widget.sendToToken] = text; - } else { - settings.val(write: true).talkSettings.drafts.removeWhere((key, value) => key == widget.sendToToken); - } - } - - @override - void initState() { - super.initState(); - settings = Provider.of(context, listen: false); - Provider.of(context, listen: false).unsafeInternalSetReferenceMessageId = - settings.val().talkSettings.draftReplies[widget.sendToToken]; - } - - @override - Widget build(BuildContext context) { - _textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; - - return Stack( - children: [ - Align( - alignment: Alignment.bottomLeft, - child: Container( - padding: const EdgeInsets.only(left: 10, bottom: 3, top: 3, right: 10), - width: double.infinity, - child: Column( - children: [ - Consumer( - builder: (context, data, child) { - if(data.getReferenceMessageId != null) { - var referenceMessage = data.getChatResponse.sortByTimestamp().where((element) => element.id == data.getReferenceMessageId).last; - return Row( - children: [ - Expanded( - child: AnswerReference( - context: context, - referenceMessage: referenceMessage, - selfId: widget.selfId, - ), - ), - IconButton( - onPressed: () => data.setReferenceMessageId(null, context, widget.sendToToken), - icon: const Icon(Icons.close_outlined), - padding: const EdgeInsets.only(left: 0), - ), - ], - ); - } else { - return const SizedBox.shrink(); - } - }, - ), - Row( - children: [ - GestureDetector( - onTap: (){ - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.file_open), - title: const Text('Aus Dateien auswählen'), - onTap: () { - FilePick.documentPick().then(mediaUpload); - Navigator.of(context).pop(); - }, - ), - Visibility( - visible: !Platform.isIOS, - child: ListTile( - leading: const Icon(Icons.image), - title: const Text('Aus Gallerie auswählen'), - onTap: () { - FilePick.multipleGalleryPick().then((value) { - if(value != null) mediaUpload(value.map((e) => e.path).toList()); - }); - Navigator.of(context).pop(); - }, - ), - ), - ], - )); - }, - child: Material( - elevation: 5, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(30), - ), - child: Container( - height: 30, - width: 30, - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - child: const Icon(Icons.attach_file_outlined, color: Colors.white, size: 20, ), - ), - ) - ), - const SizedBox(width: 15), - Expanded( - child: TextField( - autocorrect: true, - textCapitalization: TextCapitalization.sentences, - controller: _textBoxController, - maxLines: 7, - minLines: 1, - decoration: const InputDecoration( - hintText: 'Nachricht schreiben...', - border: InputBorder.none, - ), - onChanged: (String text) { - if(text.trim().toLowerCase() == 'marbot marbot marbot') { - var newText = 'Roboter sind cool und so, aber Marbots sind besser!'; - _textBoxController.text = newText; - text = newText; - } - setDraft(text); - }, - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const SizedBox(width: 15), - FloatingActionButton( - mini: true, - onPressed: () { - if(_textBoxController.text.isEmpty) return; - if(isLoading) return; - - setState(() { - isLoading = true; - }); - SendMessage(widget.sendToToken, SendMessageParams( - _textBoxController.text, - replyTo: Provider.of(context, listen: false).getReferenceMessageId.toString() - )).run().then((value) { - _query(); - setState(() { - isLoading = false; - }); - _textBoxController.text = ''; - setDraft(''); - Provider.of(context, listen: false).setReferenceMessageId(null, context, widget.sendToToken); - }); - }, - backgroundColor: Theme.of(context).primaryColor, - elevation: 5, - child: isLoading - ? Container(padding: const EdgeInsets.all(10), child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2)) - : const Icon(Icons.send, color: Colors.white, size: 18), - ), - ], - ), - ], - ), - - ), - ), - ], - ); - } -} diff --git a/lib/view/pages/talk/components/chatTile.dart b/lib/view/pages/talk/components/chatTile.dart deleted file mode 100644 index 7c313dc..0000000 --- a/lib/view/pages/talk/components/chatTile.dart +++ /dev/null @@ -1,185 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import '../../../../api/marianumcloud/talk/leaveRoom/leaveRoom.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../api/marianumcloud/talk/setFavorite/setFavorite.dart'; -import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarker.dart'; -import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dart'; -import '../../../../model/chatList/chatProps.dart'; -import '../../../../widget/confirmDialog.dart'; -import '../../../../widget/debug/debugTile.dart'; -import '../../../../widget/userAvatar.dart'; -import '../chatView.dart'; -import '../talkNavigator.dart'; - -class ChatTile extends StatefulWidget { - final GetRoomResponseObject data; - final void Function({bool renew}) query; - final bool disableContextActions; - final bool hasDraft; - - const ChatTile({super.key, required this.data, required this.query, this.disableContextActions = false, this.hasDraft = false}); - - @override - State createState() => _ChatTileState(); -} - -class _ChatTileState extends State { - late String selfUsername; - - @override - void initState() { - super.initState(); - SharedPreferences.getInstance().then((value) => { - selfUsername = value.getString('username')! - }); - } - - void setCurrentAsRead() { - SetReadMarker( - widget.data.token, - true, - setReadMarkerParams: SetReadMarkerParams( - lastReadMessage: widget.data.lastMessage.id - ) - ).run().then((value) => widget.query(renew: true)); - } - - @override - Widget build(BuildContext context) => Consumer(builder: (context, chatData, child) { - var isGroup = widget.data.type != GetRoomResponseObjectConversationType.oneToOne; - var circleAvatar = UserAvatar(id: isGroup ? widget.data.token : widget.data.name, isGroup: isGroup); - return ListTile( - style: ListTileStyle.list, - tileColor: chatData.currentToken() == widget.data.token && TalkNavigator.isSecondaryVisible(context) - ? Theme.of(context).primaryColor.withAlpha(100) - : null, - leading: Stack( - children: [ - circleAvatar, - Visibility( - visible: widget.data.isFavorite, - child: Positioned( - right: 0, - bottom: 0, - child: Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor.withAlpha(200), - borderRadius: BorderRadius.circular(90.0), - ), - child: const Icon(Icons.star, color: Colors.amberAccent, size: 15), - ), - ), - ) - ], - ), - title: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text(widget.data.displayName, overflow: TextOverflow.ellipsis), - ), - if(widget.hasDraft) ...[ - const SizedBox(width: 5), - const Icon(Icons.edit_outlined, size: 15), - ], - ], - ), - subtitle: Text("${Jiffy.parseFromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).fromNow()}: ${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}", overflow: TextOverflow.ellipsis), - trailing: widget.data.unreadMessages <= 0 - ? null - : Container( - padding: const EdgeInsets.all(1), - decoration: BoxDecoration( - color: Theme.of(context).primaryColor, - borderRadius: BorderRadius.circular(30), - ), - constraints: const BoxConstraints( - minWidth: 20, - minHeight: 20, - ), - child: Text( - '${widget.data.unreadMessages}', - style: const TextStyle( - color: Colors.white, - fontSize: 15, - ), - textAlign: TextAlign.center, - ), - ), - onTap: () async { - setCurrentAsRead(); - var view = ChatView(room: widget.data, selfId: selfUsername, avatar: circleAvatar); - TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true); - Provider.of(context, listen: false).setQueryToken(widget.data.token); - }, - onLongPress: () { - if(widget.disableContextActions) return; - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - Visibility( - visible: widget.data.unreadMessages > 0, - replacement: ListTile( - leading: const Icon(Icons.mark_chat_unread_outlined), - title: const Text('Als ungelesen markieren'), - onTap: () { - SetReadMarker(widget.data.token, false).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - child: ListTile( - leading: const Icon(Icons.mark_chat_read_outlined), - title: const Text('Als gelesen markieren'), - onTap: () { - setCurrentAsRead(); - Navigator.of(context).pop(); - }, - ), - ), - Visibility( - visible: widget.data.isFavorite, - replacement: ListTile( - leading: const Icon(Icons.star_outline), - title: const Text('Zu Favoriten hinzufügen'), - onTap: () { - SetFavorite(widget.data.token, true).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - child: ListTile( - leading: const Icon(Icons.stars_outlined), - title: const Text('Von Favoriten entfernen'), - onTap: () { - SetFavorite(widget.data.token, false).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ), - ), - ListTile( - leading: const Icon(Icons.delete_outline), - title: const Text('Konversation verlassen'), - onTap: () { - ConfirmDialog( - title: 'Chat verlassen', - content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.', - confirmButton: 'Löschen', - onConfirm: () { - LeaveRoom(widget.data.token).run().then((value) => widget.query(renew: true)); - Navigator.of(context).pop(); - }, - ).asDialog(context); - }, - ), - DebugTile(context).jsonData(widget.data.toJson()), - ], - )); - }, - ); - }); -} diff --git a/lib/view/pages/talk/components/splitViewPlaceholder.dart b/lib/view/pages/talk/components/splitViewPlaceholder.dart deleted file mode 100644 index d5c4030..0000000 --- a/lib/view/pages/talk/components/splitViewPlaceholder.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../../theming/appTheme.dart'; - -class SplitViewPlaceholder extends StatelessWidget { - const SplitViewPlaceholder({super.key}); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar(), - body: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - MediaQuery( - data: MediaQuery.of(context).copyWith( - invertColors: !AppTheme.isDarkMode(context), - ), - child: Image.asset('assets/logo/icon.png', height: 200), - ), - const SizedBox(height: 30), - const Text('Marianum Fulda\nTalk', textAlign: TextAlign.center, style: TextStyle(fontSize: 30)), - ], - ), - ) - ); -} diff --git a/lib/view/pages/talk/components/chatBubbleStyles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart similarity index 56% rename from lib/view/pages/talk/components/chatBubbleStyles.dart rename to lib/view/pages/talk/data/chat_bubble_styles.dart index 5cf527a..c5c5f21 100644 --- a/lib/view/pages/talk/components/chatBubbleStyles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -1,18 +1,25 @@ -import 'package:bubble/bubble.dart'; import 'package:flutter/material.dart'; -import '../../../../theming/appTheme.dart'; +import '../../../../theming/app_theme.dart'; +import '../widgets/bubble.dart'; extension ColorExtensions on Color { Color invert() { - final r = 255 - red; - final g = 255 - green; - final b = 255 - blue; - - return Color.fromARGB((opacity * 255).round(), r, g, b); + final invertedR = 1.0 - r; + final invertedG = 1.0 - g; + final invertedB = 1.0 - b; + return Color.from( + alpha: a, + red: invertedR, + green: invertedG, + blue: invertedB, + ); } - Color withWhite(int whiteValue) => Color.fromARGB(alpha, whiteValue, whiteValue, whiteValue); + Color withWhite(int whiteValue) { + final value = whiteValue / 255.0; + return Color.from(alpha: a, red: value, green: value, blue: value); + } } class ChatBubbleStyles { @@ -21,19 +28,21 @@ class ChatBubbleStyles { ChatBubbleStyles(this.context); BubbleStyle getSystemStyle() => BubbleStyle( - color: AppTheme.isDarkMode(context) ? const Color(0xff182229) : Colors.white, - borderWidth: 1, + color: AppTheme.isDarkMode(context) + ? const Color(0xff182229) + : Colors.white, elevation: 2, margin: const BubbleEdges.only(bottom: 20, top: 10), alignment: Alignment.center, ); BubbleStyle getRemoteStyle(bool seamless) { - var color = AppTheme.isDarkMode(context) ? const Color(0xff202c33) : Colors.white; + var color = AppTheme.isDarkMode(context) + ? const Color(0xff202c33) + : Colors.white; return BubbleStyle( nip: BubbleNip.leftTop, color: seamless ? Colors.transparent : color, - borderWidth: seamless ? 0 : 1, elevation: seamless ? 0 : 1, margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50), alignment: Alignment.topLeft, @@ -41,11 +50,12 @@ class ChatBubbleStyles { } BubbleStyle getSelfStyle(bool seamless) { - var color = AppTheme.isDarkMode(context) ? const Color(0xff005c4b) : const Color(0xffd3d3d3); + var color = AppTheme.isDarkMode(context) + ? const Color(0xff005c4b) + : const Color(0xffd3d3d3); return BubbleStyle( nip: BubbleNip.rightBottom, color: seamless ? Colors.transparent : color, - borderWidth: seamless ? 0 : 1, elevation: seamless ? 0 : 1, margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50), alignment: Alignment.topRight, diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart new file mode 100644 index 0000000..30ec493 --- /dev/null +++ b/lib/view/pages/talk/data/chat_message.dart @@ -0,0 +1,86 @@ +import 'package:cached_network_image/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_linkify/flutter_linkify.dart'; + +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; +import '../../../../model/account_data.dart'; +import '../../../../model/endpoint_data.dart'; +import '../../../../utils/url_opener.dart'; + +class ChatMessage { + String originalMessage; + Map? originalData; + + RichObjectString? file; + String content = ''; + + bool get containsFile => file != null; + + ChatMessage({required this.originalMessage, this.originalData}) { + if (originalData?.containsKey('file') ?? false) { + file = originalData?['file']; + } + content = RichObjectStringProcessor.parseToString( + originalMessage, + originalData, + ); + } + + Widget getWidget() { + var contentWidget = Linkify(text: content, onOpen: UrlOpener.onOpen); + + if (originalData?['object']?.type == RichObjectStringObjectType.talkPoll) { + return ListTile( + leading: const Icon(Icons.poll_outlined), + title: Text(originalData!['object']!.name), + contentPadding: const EdgeInsets.only(left: 10), + ); + } + + if (file == null) return contentWidget; + + return Padding( + padding: const EdgeInsets.only(top: 5), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CachedNetworkImage( + errorWidget: (context, url, error) => Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + const Icon(Icons.file_open_outlined, size: 35), + const SizedBox(width: 10), + Flexible( + child: Text( + file!.name, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + ), + const SizedBox(width: 10), + ], + ), + alignment: Alignment.center, + placeholder: (context, url) => const Padding( + padding: EdgeInsets.all(15), + child: SizedBox(width: 50, child: LinearProgressIndicator()), + ), + fadeInDuration: Duration.zero, + fadeOutDuration: Duration.zero, + errorListener: (value) {}, + httpHeaders: AccountData().authHeaders(), + imageUrl: + 'https://${EndpointData().nextcloud().full()}/index.php/core/preview?fileId=${file!.id}&x=130&y=-1&a=1', + ), + if (originalMessage != '{file}') ...[ + SizedBox(height: 5), + contentWidget, + ], + ], + ), + ); + } +} diff --git a/lib/view/pages/talk/chatDetails/chatInfo.dart b/lib/view/pages/talk/details/chat_info.dart similarity index 51% rename from lib/view/pages/talk/chatDetails/chatInfo.dart rename to lib/view/pages/talk/details/chat_info.dart index 81ca7a3..fad96f5 100644 --- a/lib/view/pages/talk/chatDetails/chatInfo.dart +++ b/lib/view/pages/talk/details/chat_info.dart @@ -1,13 +1,13 @@ import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsCache.dart'; -import '../../../../api/marianumcloud/talk/getParticipants/getParticipantsResponse.dart'; -import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import '../../../../widget/largeProfilePictureView.dart'; -import '../../../../widget/loadingSpinner.dart'; -import '../../../../widget/userAvatar.dart'; -import '../talkNavigator.dart'; -import 'participants/participantsListView.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_cache.dart'; +import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../widget/large_profile_picture_view.dart'; +import '../../../../widget/loading_spinner.dart'; +import '../../../../widget/user_avatar.dart'; +import '../talk_navigator.dart'; +import 'participants_list_view.dart'; class ChatInfo extends StatefulWidget { final GetRoomResponseObject room; @@ -28,18 +28,17 @@ class _ChatInfoState extends State { setState(() { participants = data; }); - } + }, ); super.initState(); } @override Widget build(BuildContext context) { - var isGroup = widget.room.type != GetRoomResponseObjectConversationType.oneToOne; + var isGroup = + widget.room.type != GetRoomResponseObjectConversationType.oneToOne; return Scaffold( - appBar: AppBar( - title: Text(widget.room.displayName), - ), + appBar: AppBar(title: Text(widget.room.displayName)), body: Center( child: Column( crossAxisAlignment: CrossAxisAlignment.center, @@ -52,23 +51,34 @@ class _ChatInfoState extends State { size: 80, ), onTap: () { - if(isGroup) return; - TalkNavigator.pushSplitView(context, LargeProfilePictureView(widget.room.name)); + if (isGroup) return; + TalkNavigator.pushSplitView( + context, + LargeProfilePictureView(widget.room.name), + ); }, ), const SizedBox(height: 30), - Text(widget.room.displayName, textAlign: TextAlign.center, style: const TextStyle(fontSize: 30)), - if(!isGroup) Text(widget.room.name), + Text( + widget.room.displayName, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 30), + ), + if (!isGroup) Text(widget.room.name), const SizedBox(height: 10), - if(isGroup) Text(widget.room.description, textAlign: TextAlign.center), + if (isGroup) + Text(widget.room.description, textAlign: TextAlign.center), const SizedBox(height: 30), - if(participants == null) const LoadingSpinner(), - if(participants != null) ...[ + if (participants == null) const LoadingSpinner(), + if (participants != null) ...[ ListTile( leading: const Icon(Icons.supervised_user_circle), title: Text('${participants!.data.length} Mitglieder'), trailing: const Icon(Icons.arrow_right), - onTap: () => TalkNavigator.pushSplitView(context, ParticipantsListView(participants!)), + onTap: () => TalkNavigator.pushSplitView( + context, + ParticipantsListView(participants!), + ), ), ], ], diff --git a/lib/view/pages/talk/details/message_reactions.dart b/lib/view/pages/talk/details/message_reactions.dart new file mode 100644 index 0000000..6a2a807 --- /dev/null +++ b/lib/view/pages/talk/details/message_reactions.dart @@ -0,0 +1,95 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart'; +import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart'; +import '../../../../model/account_data.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/loading_spinner.dart'; +import '../../../../widget/placeholder_view.dart'; +import '../../../../widget/unimplemented_dialog.dart'; +import '../../../../widget/user_avatar.dart'; + +class MessageReactions extends StatefulWidget { + final String token; + final int messageId; + const MessageReactions({ + super.key, + required this.token, + required this.messageId, + }); + + @override + State createState() => _MessageReactionsState(); +} + +class _MessageReactionsState extends State { + late Future data; + + @override + void initState() { + super.initState(); + data = GetReactions( + chatToken: widget.token, + messageId: widget.messageId, + ).run(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Reaktionen')), + body: FutureBuilder( + future: data, + builder: (context, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const LoadingSpinner(); + } + if (snapshot.data!.data.isEmpty) { + return const PlaceholderView( + icon: Icons.search_off_outlined, + text: 'Keine Reaktionen gefunden!', + ); + } + return ListView( + children: [ + ...snapshot.data!.data.entries.map( + (entry) => ExpansionTile( + textColor: Theme.of(context).colorScheme.onSurface, + collapsedTextColor: Theme.of(context).colorScheme.onSurface, + iconColor: Theme.of(context).colorScheme.onSurface, + collapsedIconColor: Theme.of(context).colorScheme.onSurface, + + subtitle: const Text('Tippe für mehr'), + leading: CenteredLeading(Text(entry.key)), + title: Text('${entry.value.length} mal reagiert'), + children: entry.value.map((e) { + var isSelf = AccountData().getUsername() == e.actorId; + return ListTile( + leading: UserAvatar(id: e.actorId, isGroup: false), + title: Text(e.actorDisplayName), + subtitle: isSelf + ? const Text('Du') + : e.actorType == + GetReactionsResponseObjectActorType.guests + ? const Text('Gast') + : null, + trailing: isSelf + ? null + : Visibility( + visible: kReleaseMode, + child: IconButton( + onPressed: () => + UnimplementedDialog.show(context), + icon: const Icon(Icons.textsms_outlined), + ), + ), + ); + }).toList(), + ), + ), + ], + ); + }, + ), + ); +} diff --git a/lib/view/pages/talk/details/participants_list_view.dart b/lib/view/pages/talk/details/participants_list_view.dart new file mode 100644 index 0000000..b3c4850 --- /dev/null +++ b/lib/view/pages/talk/details/participants_list_view.dart @@ -0,0 +1,55 @@ +import 'package:collection/collection.dart'; +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/get_participants/get_participants_response.dart'; +import '../../../../widget/user_avatar.dart'; + +class ParticipantsListView extends StatelessWidget { + final GetParticipantsResponse participantsResponse; + const ParticipantsListView(this.participantsResponse, {super.key}); + + @override + Widget build(BuildContext context) { + String lastname(participant) => + participant.displayName.toString().split(' ').last; + + final participants = participantsResponse.data.sorted((a, b) { + final typeComparison = a.participantType.index.compareTo( + b.participantType.index, + ); + if (typeComparison != 0) return typeComparison; + return lastname(a).compareTo(lastname(b)); + }); + var groupedParticipants = participants.groupListsBy( + (participant) => participant.participantType, + ); + + return Scaffold( + appBar: AppBar(title: const Text('Mitglieder')), + body: ListView( + children: [ + ...groupedParticipants.entries.map( + (entry) => Column( + children: [ + ListTile( + title: Text(entry.key.prettyName), + titleTextStyle: Theme.of(context).textTheme.titleMedium, + ), + ...entry.value.map( + (participant) => ListTile( + leading: UserAvatar(id: participant.actorId), + title: Text(participant.displayName), + subtitle: participant.statusMessage != null + ? Text(participant.statusMessage!) + : null, + ), + ), + Divider(), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/talk/joinChat.dart b/lib/view/pages/talk/join_chat.dart similarity index 57% rename from lib/view/pages/talk/joinChat.dart rename to lib/view/pages/talk/join_chat.dart index e51815a..51188f2 100644 --- a/lib/view/pages/talk/joinChat.dart +++ b/lib/view/pages/talk/join_chat.dart @@ -1,48 +1,43 @@ - import 'package:async/async.dart'; import 'package:flutter/material.dart'; -import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart'; -import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart'; -import '../../../model/endpointData.dart'; -import '../../../widget/placeholderView.dart'; +import '../../../api/errors/error_mapper.dart'; +import '../../../api/marianumcloud/autocomplete/autocomplete_api.dart'; +import '../../../api/marianumcloud/autocomplete/autocomplete_response.dart'; +import '../../../model/endpoint_data.dart'; +import '../../../widget/app_progress_indicator.dart'; +import '../../../widget/placeholder_view.dart'; class JoinChat extends SearchDelegate { CancelableOperation? future; @override List? buildActions(BuildContext context) => [ - if(future != null && query.isNotEmpty) FutureBuilder( + if (future != null && query.isNotEmpty) + FutureBuilder( future: future!.value, builder: (context, snapshot) { - if(snapshot.connectionState != ConnectionState.done) { - return Container( - padding: const EdgeInsets.all(10), - child: const Center( - child: SizedBox( - height: 20, - width: 20, - child: CircularProgressIndicator( - strokeWidth: 3, - ), - ), - ), + if (snapshot.connectionState != ConnectionState.done) { + return const Padding( + padding: EdgeInsets.all(10), + child: Center(child: AppProgressIndicator.medium()), ); } return const SizedBox.shrink(); }, ), - if(query.isNotEmpty) IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), - ]; + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; @override Widget? buildLeading(BuildContext context) => null; @override Widget buildResults(BuildContext context) { - if(future != null) future!.cancel(); + if (future != null) future!.cancel(); - if(query.isEmpty) { + if (query.isEmpty) { return const PlaceholderView( text: 'Suche nach benutzern', icon: Icons.person_search_outlined, @@ -53,13 +48,15 @@ class JoinChat extends SearchDelegate { return FutureBuilder( future: future!.value, builder: (context, snapshot) { - if(snapshot.hasData) { + if (snapshot.hasData) { return ListView.builder( itemCount: snapshot.data!.data.length, itemBuilder: (context, index) { var object = snapshot.data!.data[index]; var circleAvatar = CircleAvatar( - foregroundImage: Image.network('https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128').image, + foregroundImage: Image.network( + 'https://${EndpointData().nextcloud().full()}/avatar/${object.id}/128', + ).image, backgroundColor: Theme.of(context).primaryColor, foregroundColor: Colors.white, child: const Icon(Icons.person), @@ -73,18 +70,20 @@ class JoinChat extends SearchDelegate { close(context, object.id); }, ); - } + }, + ); + } else if (snapshot.hasError) { + return PlaceholderView( + icon: Icons.search_off, + text: errorToUserMessage(snapshot.error), ); - } else if(snapshot.hasError) { - return const PlaceholderView(icon: Icons.search_off, text: 'Ein fehler ist aufgetreten. Bist du mit dem Internet verbunden?'); } - return const Center(child: CircularProgressIndicator()); + return const Center(child: AppProgressIndicator.large()); }, ); } @override Widget buildSuggestions(BuildContext context) => buildResults(context); - } diff --git a/lib/view/pages/talk/messageReactions.dart b/lib/view/pages/talk/messageReactions.dart deleted file mode 100644 index b3aeaf2..0000000 --- a/lib/view/pages/talk/messageReactions.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; - -import '../../../api/marianumcloud/talk/getReactions/getReactions.dart'; -import '../../../api/marianumcloud/talk/getReactions/getReactionsResponse.dart'; -import '../../../model/accountData.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import '../../../widget/unimplementedDialog.dart'; -import '../../../widget/userAvatar.dart'; - -class MessageReactions extends StatefulWidget { - final String token; - final int messageId; - const MessageReactions({super.key, required this.token, required this.messageId}); - - @override - State createState() => _MessageReactionsState(); -} - -class _MessageReactionsState extends State { - late Future data; - - @override - void initState() { - super.initState(); - data = GetReactions(chatToken: widget.token, messageId: widget.messageId).run(); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Reaktionen'), - ), - body: FutureBuilder( - future: data, - builder: (context, snapshot) { - if(snapshot.connectionState == ConnectionState.waiting) return const LoadingSpinner(); - if(snapshot.data!.data.isEmpty) return const PlaceholderView(icon: Icons.search_off_outlined, text: 'Keine Reaktionen gefunden!'); - return ListView( - children: [ - ...snapshot.data!.data.entries.map((entry) => ExpansionTile( - textColor: Theme.of(context).colorScheme.onSurface, - collapsedTextColor: Theme.of(context).colorScheme.onSurface, - iconColor: Theme.of(context).colorScheme.onSurface, - collapsedIconColor: Theme.of(context).colorScheme.onSurface, - - subtitle: const Text('Tippe für mehr'), - leading: CenteredLeading(Text(entry.key)), - title: Text('${entry.value.length} mal reagiert'), - children: entry.value.map((e) { - var isSelf = AccountData().getUsername() == e.actorId; - return ListTile( - leading: UserAvatar(id: e.actorId, isGroup: false), - title: Text(e.actorDisplayName), - subtitle: isSelf - ? const Text('Du') - : e.actorType == GetReactionsResponseObjectActorType.guests ? const Text('Gast') : null, - trailing: isSelf - ? null - : Visibility( - visible: kReleaseMode, - child: IconButton( - onPressed: () => UnimplementedDialog.show(context), - icon: const Icon(Icons.textsms_outlined), - ), - ), - ); - }).toList(), - )) - ], - ); - }, - ), - ); -} diff --git a/lib/view/pages/talk/searchChat.dart b/lib/view/pages/talk/searchChat.dart deleted file mode 100644 index 3f39738..0000000 --- a/lib/view/pages/talk/searchChat.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../api/marianumcloud/talk/room/getRoomResponse.dart'; -import 'components/chatTile.dart'; - -class SearchChat extends SearchDelegate { - List chats; - - SearchChat(this.chats); - - @override - List? buildActions(BuildContext context) => [ - if(query.isNotEmpty) IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), - ]; - - @override - Widget? buildLeading(BuildContext context) => null; - - @override - Widget buildResults(BuildContext context) { - var items = chats.where( - (e) => e.displayName.toString().toLowerCase().contains(query.toLowerCase()) || e.name.toString().toLowerCase().contains(query.toLowerCase()) - ).toList()..sort((a, b) => b.lastActivity.compareTo(a.lastActivity)); - return ListView.builder( - itemCount: items.length, - itemBuilder: (context, index) { - var item = items.elementAt(index); - return ChatTile(data: item, disableContextActions: true, query: ({bool renew = true}) {}); - }, - ); - } - - @override - Widget buildSuggestions(BuildContext context) => buildResults(context); -} diff --git a/lib/view/pages/talk/search_chat.dart b/lib/view/pages/talk/search_chat.dart new file mode 100644 index 0000000..9f36bf7 --- /dev/null +++ b/lib/view/pages/talk/search_chat.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../api/marianumcloud/talk/room/get_room_response.dart'; +import 'widgets/chat_tile.dart'; + +class SearchChat extends SearchDelegate { + List chats; + + SearchChat(this.chats); + + @override + List? buildActions(BuildContext context) => [ + if (query.isNotEmpty) + IconButton(onPressed: () => query = '', icon: const Icon(Icons.delete)), + ]; + + @override + Widget? buildLeading(BuildContext context) => null; + + @override + Widget buildResults(BuildContext context) { + var items = + chats + .where( + (e) => + e.displayName.toString().toLowerCase().contains( + query.toLowerCase(), + ) || + e.name.toString().toLowerCase().contains(query.toLowerCase()), + ) + .toList() + ..sort((a, b) => b.lastActivity.compareTo(a.lastActivity)); + return ListView.builder( + itemCount: items.length, + itemBuilder: (context, index) { + var item = items.elementAt(index); + return ChatTile(data: item, disableContextActions: true); + }, + ); + } + + @override + Widget buildSuggestions(BuildContext context) => buildResults(context); +} diff --git a/lib/view/pages/talk/talkNavigator.dart b/lib/view/pages/talk/talkNavigator.dart deleted file mode 100644 index 18c33ab..0000000 --- a/lib/view/pages/talk/talkNavigator.dart +++ /dev/null @@ -1,18 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:flutter_split_view/flutter_split_view.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; - -class TalkNavigator { - static bool hasSplitViewState(BuildContext context) => context.findAncestorStateOfType() != null; - static bool isSecondaryVisible(BuildContext context) => hasSplitViewState(context) && SplitView.of(context).isSecondaryVisible; - - static void pushSplitView(BuildContext context, Widget view, {bool overrideToSingleSubScreen = false}) { - if(isSecondaryVisible(context)) { - var splitView = SplitView.of(context); - overrideToSingleSubScreen ? splitView.setSecondary(view) : splitView.push(view); - } else { - pushScreen(context, screen: view, withNavBar: false); - } - } -} diff --git a/lib/view/pages/talk/talk_navigator.dart b/lib/view/pages/talk/talk_navigator.dart new file mode 100644 index 0000000..a6a7e00 --- /dev/null +++ b/lib/view/pages/talk/talk_navigator.dart @@ -0,0 +1,25 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_split_view/flutter_split_view.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; + +class TalkNavigator { + static bool hasSplitViewState(BuildContext context) => + context.findAncestorStateOfType() != null; + static bool isSecondaryVisible(BuildContext context) => + hasSplitViewState(context) && SplitView.of(context).isSecondaryVisible; + + static void pushSplitView( + BuildContext context, + Widget view, { + bool overrideToSingleSubScreen = false, + }) { + if (isSecondaryVisible(context)) { + var splitView = SplitView.of(context); + overrideToSingleSubScreen + ? splitView.setSecondary(view) + : splitView.push(view); + } else { + pushScreen(context, screen: view, withNavBar: false); + } + } +} diff --git a/lib/view/pages/talk/components/answerReference.dart b/lib/view/pages/talk/widgets/answer_reference.dart similarity index 63% rename from lib/view/pages/talk/components/answerReference.dart rename to lib/view/pages/talk/widgets/answer_reference.dart index ce02745..6508ae5 100644 --- a/lib/view/pages/talk/components/answerReference.dart +++ b/lib/view/pages/talk/widgets/answer_reference.dart @@ -1,14 +1,19 @@ import 'package:flutter/material.dart'; -import '../../../../api/marianumcloud/talk/chat/getChatResponse.dart'; -import '../../../../api/marianumcloud/talk/chat/richObjectStringProcessor.dart'; -import 'chatBubbleStyles.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; +import '../data/chat_bubble_styles.dart'; class AnswerReference extends StatelessWidget { final BuildContext context; final GetChatResponseObject referenceMessage; final String? selfId; - const AnswerReference({required this.context, required this.referenceMessage, required this.selfId, super.key}); + const AnswerReference({ + required this.context, + required this.referenceMessage, + required this.selfId, + super.key, + }); @override Widget build(BuildContext context) { @@ -16,15 +21,25 @@ class AnswerReference extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: referenceMessage.actorId == selfId - ? style.getSelfStyle(false).color!.withGreen(200).withOpacity(0.2) - : style.getRemoteStyle(false).color!.withWhite(200).withOpacity(0.2), + ? style + .getSelfStyle(false) + .color! + .withGreen(200) + .withValues(alpha: 0.2) + : style + .getRemoteStyle(false) + .color! + .withWhite(200) + .withValues(alpha: 0.2), borderRadius: const BorderRadius.all(Radius.circular(5)), - border: Border(left: BorderSide( + border: Border( + left: BorderSide( color: referenceMessage.actorId == selfId ? style.getSelfStyle(false).color!.withGreen(200) : style.getRemoteStyle(false).color!.withWhite(200), - width: 5 - )), + width: 5, + ), + ), ), child: Padding( padding: const EdgeInsets.all(5).add(const EdgeInsets.only(left: 5)), @@ -43,7 +58,10 @@ class AnswerReference extends StatelessWidget { ), ), Text( - RichObjectStringProcessor.parseToString(referenceMessage.message, referenceMessage.messageParameters), + RichObjectStringProcessor.parseToString( + referenceMessage.message, + referenceMessage.messageParameters, + ), maxLines: 2, style: TextStyle( overflow: TextOverflow.ellipsis, diff --git a/lib/view/pages/talk/widgets/bubble.dart b/lib/view/pages/talk/widgets/bubble.dart new file mode 100644 index 0000000..30d1fac --- /dev/null +++ b/lib/view/pages/talk/widgets/bubble.dart @@ -0,0 +1,110 @@ +import 'package:flutter/material.dart'; + +enum BubbleNip { leftTop, rightBottom, none } + +class BubbleEdges { + const BubbleEdges.only({ + this.top = 0, + this.bottom = 0, + this.left = 0, + this.right = 0, + }); + const BubbleEdges.all(double value) + : top = value, + bottom = value, + left = value, + right = value; + + final double top; + final double bottom; + final double left; + final double right; + + EdgeInsets toEdgeInsets() => EdgeInsets.fromLTRB(left, top, right, bottom); +} + +class BubbleStyle { + const BubbleStyle({ + this.color, + this.borderWidth = 0, + this.elevation = 0, + this.margin = const BubbleEdges.only(), + this.padding = const BubbleEdges.all(8), + this.alignment = Alignment.centerLeft, + this.nip = BubbleNip.none, + this.borderRadius = 12, + }); + + final Color? color; + final double borderWidth; + final double elevation; + final BubbleEdges margin; + final BubbleEdges padding; + final Alignment alignment; + final BubbleNip nip; + final double borderRadius; +} + +/// The "nip" is faked by flattening one corner so the bubble anchors to +/// the speaker side. +class Bubble extends StatelessWidget { + const Bubble({required this.child, required this.style, super.key}); + + final Widget child; + final BubbleStyle style; + + BorderRadius _radius() { + final r = Radius.circular(style.borderRadius); + final flat = Radius.zero; + switch (style.nip) { + case BubbleNip.leftTop: + return BorderRadius.only( + topLeft: flat, + topRight: r, + bottomLeft: r, + bottomRight: r, + ); + case BubbleNip.rightBottom: + return BorderRadius.only( + topLeft: r, + topRight: r, + bottomLeft: r, + bottomRight: flat, + ); + case BubbleNip.none: + return BorderRadius.all(r); + } + } + + @override + Widget build(BuildContext context) { + final radius = _radius(); + return Align( + alignment: style.alignment, + child: Container( + margin: style.margin.toEdgeInsets(), + decoration: BoxDecoration( + color: style.color, + borderRadius: radius, + border: style.borderWidth > 0 + ? Border.all( + color: Theme.of(context).dividerColor, + width: style.borderWidth, + ) + : null, + boxShadow: style.elevation > 0 + ? [ + BoxShadow( + color: Colors.black26, + blurRadius: style.elevation * 2, + offset: Offset(0, style.elevation), + ), + ] + : null, + ), + padding: style.padding.toEdgeInsets(), + child: child, + ), + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_bubble.dart b/lib/view/pages/talk/widgets/chat_bubble.dart new file mode 100644 index 0000000..7e50a9e --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_bubble.dart @@ -0,0 +1,374 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../extensions/text.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../utils/download_manager.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/info_dialog.dart'; +import '../data/chat_bubble_styles.dart'; +import '../data/chat_message.dart'; +import 'answer_reference.dart'; +import 'bubble.dart'; +import 'chat_bubble_poll.dart'; +import 'chat_bubble_reactions.dart'; +import 'chat_message_options_dialog.dart'; + +class ChatBubble extends StatefulWidget { + final BuildContext context; + final bool isSender; + final GetChatResponseObject bubbleData; + final GetRoomResponseObject chatData; + final bool isRead; + final String? selfId; + + final double spacing = 3; + final double timeIconSize = 11; + final Color timeIconColor = Colors.grey; + + final void Function({bool renew}) refetch; + + const ChatBubble({ + required this.context, + required this.isSender, + required this.bubbleData, + required this.chatData, + required this.refetch, + this.isRead = false, + this.selfId, + super.key, + }); + + @override + State createState() => _ChatBubbleState(); +} + +class _ChatBubbleState extends State + with SingleTickerProviderStateMixin { + late ChatMessage message; + DownloadJob? _job; + + Offset _position = Offset.zero; + Offset _dragStartPosition = Offset.zero; + + @override + void initState() { + super.initState(); + final filePath = widget.bubbleData.messageParameters?['file']?.path; + if (filePath != null) _attachJob(DownloadManager.instance.jobFor(filePath)); + } + + @override + void dispose() { + _detachJob(); + super.dispose(); + } + + void _attachJob(DownloadJob? job) { + _job = job; + if (job == null) return; + job.status.addListener(_onStatusChange); + if (job.isFinished) { + WidgetsBinding.instance.addPostFrameCallback((_) => _onStatusChange()); + } + } + + void _detachJob() { + _job?.status.removeListener(_onStatusChange); + _job = null; + } + + void _onStatusChange() { + if (!mounted) return; + final job = _job; + if (job == null) return; + final status = job.status.value; + if (status is DownloadDone) { + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + AppRoutes.openFileViewer(context, status.localPath); + setState(() {}); + } else if (status is DownloadFailed) { + final message = status.message; + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + setState(() {}); + InfoDialog.show(context, message, title: 'Download fehlgeschlagen'); + } else if (status is DownloadCancelled) { + DownloadManager.instance.clear(job.remotePath); + _detachJob(); + setState(() {}); + } else { + setState(() {}); + } + } + + Future _startFileDownload() async { + final file = message.file; + final filePath = file?.path; + if (file == null || filePath == null) return; + final job = await DownloadManager.instance.start( + remotePath: filePath, + name: file.name, + ); + if (!mounted) return; + if (_job == job) return; + _detachJob(); + _attachJob(job); + setState(() {}); + } + + void _confirmCancel() { + ConfirmDialog( + title: 'Download abbrechen?', + content: 'Möchtest du den Download abbrechen?', + confirmButton: 'Ja, Abbrechen', + cancelButton: 'Nein', + onConfirm: () => _job?.cancel(), + ).asDialog(context); + } + + BubbleStyle _getStyle() { + final styles = ChatBubbleStyles(context); + if (widget.bubbleData.messageType != + GetRoomResponseObjectMessageType.comment) { + return styles.getSystemStyle(); + } + return widget.isSender + ? styles.getSelfStyle(false) + : styles.getRemoteStyle(false); + } + + void _showOptionsDialog() => showChatMessageOptionsDialog( + context, + chatData: widget.chatData, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + onRefetch: widget.refetch, + ); + + void _onTap() { + final obj = message.originalData?['object']; + if (obj?.type == RichObjectStringObjectType.talkPoll) { + showChatBubblePollDialog( + context, + chatToken: widget.chatData.token, + messageToken: widget.bubbleData.token, + pollId: int.parse(obj!.id), + pollName: obj.name, + ); + return; + } + if (message.file == null) return; + if (_job?.status.value is DownloadInProgress) { + _confirmCancel(); + } else { + _startFileDownload(); + } + } + + @override + Widget build(BuildContext context) { + message = ChatMessage( + originalMessage: widget.bubbleData.message, + originalData: widget.bubbleData.messageParameters, + ); + final showActorDisplayName = + widget.bubbleData.messageType == + GetRoomResponseObjectMessageType.comment && + widget.chatData.type != GetRoomResponseObjectConversationType.oneToOne; + final showBubbleTime = + widget.bubbleData.messageType != + GetRoomResponseObjectMessageType.system && + widget.bubbleData.messageType != + GetRoomResponseObjectMessageType.deletedComment; + + final parent = widget.bubbleData.parent; + final actorText = Text( + widget.bubbleData.actorDisplayName, + textAlign: TextAlign.start, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Theme.of(context).primaryColor, + fontWeight: FontWeight.bold, + ), + ); + + final timeText = Text( + DateTime.fromMillisecondsSinceEpoch( + widget.bubbleData.timestamp * 1000, + ).formatHm(), + textAlign: TextAlign.end, + style: TextStyle( + color: widget.timeIconColor, + fontSize: widget.timeIconSize, + ), + ); + + return Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + textDirection: TextDirection.ltr, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + GestureDetector( + onHorizontalDragStart: (_) => _dragStartPosition = _position, + onHorizontalDragUpdate: (details) { + if (!widget.bubbleData.isReplyable) return; + final dx = details.delta.dx - _dragStartPosition.dx; + setState(() { + _position = (_position.dx + dx).abs() > 60 + ? Offset(_position.dx, 0) + : Offset(_position.dx + dx, 0); + }); + }, + onHorizontalDragEnd: (_) { + final isAction = _position.dx.abs() > 50; + setState(() => _position = Offset.zero); + if (widget.bubbleData.isReplyable && isAction) { + context.read().setReferenceMessageId( + widget.bubbleData.id, + ); + } + }, + onLongPress: _showOptionsDialog, + onDoubleTap: _showOptionsDialog, + onTap: _onTap, + child: Transform.translate( + offset: _position, + child: Bubble( + style: _getStyle(), + child: _BubbleContent( + actorText: actorText, + timeText: timeText, + messageWidget: message.getWidget(), + parent: parent, + bubbleData: widget.bubbleData, + isSender: widget.isSender, + isRead: widget.isRead, + selfId: widget.selfId, + spacing: widget.spacing, + timeIconSize: widget.timeIconSize, + timeIconColor: widget.timeIconColor, + showActorDisplayName: showActorDisplayName, + showBubbleTime: showBubbleTime, + downloadJob: _job, + ), + ), + ), + ), + ChatBubbleReactions( + bubbleData: widget.bubbleData, + chatData: widget.chatData, + isSender: widget.isSender, + onChanged: widget.refetch, + ), + ], + ); + } +} + +class _BubbleContent extends StatelessWidget { + final Text actorText; + final Text timeText; + final Widget messageWidget; + final GetChatResponseObject? parent; + final GetChatResponseObject bubbleData; + final bool isSender; + final bool isRead; + final String? selfId; + final double spacing; + final double timeIconSize; + final Color timeIconColor; + final bool showActorDisplayName; + final bool showBubbleTime; + final DownloadJob? downloadJob; + + const _BubbleContent({ + required this.actorText, + required this.timeText, + required this.messageWidget, + required this.parent, + required this.bubbleData, + required this.isSender, + required this.isRead, + required this.selfId, + required this.spacing, + required this.timeIconSize, + required this.timeIconColor, + required this.showActorDisplayName, + required this.showBubbleTime, + required this.downloadJob, + }); + + @override + Widget build(BuildContext context) => Container( + constraints: BoxConstraints( + maxWidth: MediaQuery.of(context).size.width * 0.9, + minWidth: showActorDisplayName + ? actorText.size.width + : timeText.size.width + (isSender ? spacing + timeIconSize : 0) + 3, + ), + child: Stack( + children: [ + if (showActorDisplayName) Positioned(top: 0, left: 0, child: actorText), + Padding( + padding: EdgeInsets.only( + bottom: showBubbleTime ? 18 : 0, + top: showActorDisplayName ? 18 : 0, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (parent != null && + bubbleData.messageType == + GetRoomResponseObjectMessageType.comment) ...[ + AnswerReference( + context: context, + referenceMessage: parent!, + selfId: selfId, + ), + const SizedBox(height: 5), + ], + messageWidget, + ], + ), + ), + if (showBubbleTime) + Positioned( + bottom: 0, + right: 0, + child: Row( + children: [ + timeText, + if (isSender) ...[ + SizedBox(width: spacing), + Icon( + isRead ? Icons.done_all_outlined : Icons.done_outlined, + size: timeIconSize, + color: timeIconColor, + ), + ], + ], + ), + ), + if (downloadJob?.status.value is DownloadInProgress) + Positioned( + bottom: 0, + right: 0, + left: 0, + child: LinearProgressIndicator( + value: () { + final s = downloadJob!.status.value as DownloadInProgress; + return s.percent <= 0 ? null : s.percent / 100; + }(), + ), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/widgets/chat_bubble_poll.dart b/lib/view/pages/talk/widgets/chat_bubble_poll.dart new file mode 100644 index 0000000..2f5b3a6 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_bubble_poll.dart @@ -0,0 +1,44 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state.dart'; +import '../../../../widget/loading_spinner.dart'; +import 'poll_options_list.dart'; + +/// Opens the poll dialog that lets a user vote on a Talk poll attached to +/// a message. Loads the poll state lazily and renders the option list. +void showChatBubblePollDialog( + BuildContext context, { + required String chatToken, + required String messageToken, + required int pollId, + required String pollName, +}) { + final pollState = GetPollState(token: messageToken, pollId: pollId).run(); + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + title: Text(pollName, overflow: TextOverflow.ellipsis), + content: FutureBuilder( + future: pollState, + builder: (_, snapshot) { + if (snapshot.connectionState == ConnectionState.waiting) { + return const Column( + mainAxisSize: MainAxisSize.min, + children: [LoadingSpinner()], + ); + } + final pollData = snapshot.data!.data; + return SingleChildScrollView( + child: PollOptionsList(pollData: pollData, chatToken: chatToken), + ); + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Zurück'), + ), + ], + ), + ); +} diff --git a/lib/view/pages/talk/widgets/chat_bubble_reactions.dart b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart new file mode 100644 index 0000000..02e80ac --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_bubble_reactions.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message.dart'; +import '../../../../api/marianumcloud/talk/delete_react_message/delete_react_message_params.dart'; +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'; + +/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles +/// the user's own reaction via the Talk API and notifies via [onChanged]. +class ChatBubbleReactions extends StatelessWidget { + final GetChatResponseObject bubbleData; + final GetRoomResponseObject chatData; + final bool isSender; + final void Function({bool renew}) onChanged; + + const ChatBubbleReactions({ + required this.bubbleData, + required this.chatData, + required this.isSender, + required this.onChanged, + super.key, + }); + + @override + Widget build(BuildContext context) { + final reactions = bubbleData.reactions; + if (reactions == null) return const SizedBox.shrink(); + return Transform.translate( + offset: const Offset(0, -10), + child: Container( + width: MediaQuery.of(context).size.width, + margin: const EdgeInsets.only(left: 15, right: 15), + child: Wrap( + alignment: isSender ? WrapAlignment.end : WrapAlignment.start, + crossAxisAlignment: WrapCrossAlignment.start, + children: reactions.entries.map((e) { + final hasSelfReacted = + bubbleData.reactionsSelf?.contains(e.key) ?? false; + return Container( + margin: const EdgeInsets.only(right: 2.5, left: 2.5), + child: ActionChip( + label: Text('${e.key} ${e.value}'), + visualDensity: const VisualDensity( + vertical: VisualDensity.minimumDensity, + horizontal: VisualDensity.minimumDensity, + ), + padding: EdgeInsets.zero, + backgroundColor: hasSelfReacted + ? Theme.of(context).primaryColor + : null, + onPressed: () { + runWithErrorDialog(context, () async { + if (hasSelfReacted) { + await DeleteReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: DeleteReactMessageParams(e.key), + ).run(); + } else { + await ReactMessage( + chatToken: chatData.token, + messageId: bubbleData.id, + params: ReactMessageParams(e.key), + ).run(); + } + onChanged(renew: true); + }); + }, + ), + ); + }).toList(), + ), + ), + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_message_options_dialog.dart b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart new file mode 100644 index 0000000..227490b --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_message_options_dialog.dart @@ -0,0 +1,251 @@ +import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; +import '../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; +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 '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../utils/clipboard_helper.dart'; +import '../../../../widget/app_progress_indicator.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; + +const _commonReactions = ['👍', '👎', '😆', '❤️', '👀']; + +/// Long-press / double-tap options dialog for a single chat message bubble. +/// The hosting [ChatBubble] keeps responsibility for rendering the bubble; +/// this file owns the modal interactions (react, reply, copy, delete, ...). +void showChatMessageOptionsDialog( + BuildContext context, { + required GetRoomResponseObject chatData, + required GetChatResponseObject bubbleData, + required bool isSender, + required void Function({bool renew}) onRefetch, +}) { + final parentContext = context; + final canReact = + bubbleData.messageType == GetRoomResponseObjectMessageType.comment; + final canDelete = + isSender && + DateTime.fromMillisecondsSinceEpoch( + bubbleData.timestamp * 1000, + ).add(const Duration(hours: 6)).isAfter(DateTime.now()); + + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + if (canReact) + _ReactionsRow( + chatToken: chatData.token, + messageId: bubbleData.id, + onRefetch: onRefetch, + sheetContext: sheetCtx, + ), + if (bubbleData.isReplyable) + ListTile( + leading: const Icon(Icons.reply_outlined), + title: const Text('Antworten'), + onTap: () { + sheetCtx.read().setReferenceMessageId(bubbleData.id); + Navigator.of(sheetCtx).pop(); + }, + ), + if (canReact) + ListTile( + leading: const Icon(Icons.emoji_emotions_outlined), + title: const Text('Reaktionen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + if (!parentContext.mounted) return; + AppRoutes.openMessageReactions( + parentContext, + chatData.token, + bubbleData.id, + ); + }, + ), + if (bubbleData.message != '{file}') + ListTile( + leading: const Icon(Icons.copy), + title: const Text('Nachricht kopieren'), + onTap: () { + copyToClipboard(parentContext, bubbleData.message); + Navigator.of(sheetCtx).pop(); + }, + ), + if (!kReleaseMode && + !isSender && + chatData.type != GetRoomResponseObjectConversationType.oneToOne) + ListTile( + leading: const Icon(Icons.sms_outlined), + title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"), + onTap: () => Navigator.of(sheetCtx).pop(), + ), + if (canDelete) + AsyncListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Nachricht löschen'), + onPressed: () async { + await DeleteMessage(chatData.token, bubbleData.id).run(); + if (sheetCtx.mounted) sheetCtx.read().refresh(); + }, + ), + DebugTile(sheetCtx).jsonData(bubbleData.toJson()), + ], + ); +} + +class _ReactionsRow extends StatefulWidget { + final String chatToken; + final int messageId; + final void Function({bool renew}) onRefetch; + final BuildContext sheetContext; + + const _ReactionsRow({ + required this.chatToken, + required this.messageId, + required this.onRefetch, + required this.sheetContext, + }); + + @override + State<_ReactionsRow> createState() => _ReactionsRowState(); +} + +class _ReactionsRowState extends State<_ReactionsRow> { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _react(String emoji) async { + final ok = await _controller.run(() async { + await ReactMessage( + chatToken: widget.chatToken, + messageId: widget.messageId, + params: ReactMessageParams(emoji), + ).run(); + }); + if (!mounted) return; + if (ok) { + widget.onRefetch(renew: true); + if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop(); + } + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Wrap( + alignment: WrapAlignment.center, + children: [ + ..._commonReactions.map( + (emoji) => TextButton( + style: TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + onPressed: busy ? null : () => _react(emoji), + child: Text(emoji), + ), + ), + IconButton( + onPressed: busy ? null : () => _showEmojiPicker(context), + style: IconButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: const Size(40, 40), + ), + icon: busy + ? const AppProgressIndicator.small() + : const Icon(Icons.add_circle_outline_outlined), + ), + ], + ), + if (err != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + const Divider(), + ], + ); + }, + ); + + void _showEmojiPicker(BuildContext rowContext) { + showDialog( + context: rowContext, + builder: (pickerCtx) => AlertDialog( + contentPadding: const EdgeInsets.all(15), + titlePadding: const EdgeInsets.only(left: 6, top: 15), + title: Row( + children: [ + IconButton( + onPressed: () => Navigator.of(pickerCtx).pop(), + icon: const Icon(Icons.arrow_back), + ), + const SizedBox(width: 10), + const Text('Reagieren'), + ], + ), + content: SizedBox( + width: 256, + height: 270, + child: emojis.EmojiPicker( + config: emojis.Config( + height: 256, + emojiViewConfig: emojis.EmojiViewConfig( + backgroundColor: Theme.of(pickerCtx).canvasColor, + recentsLimit: 67, + emojiSizeMax: 25, + noRecents: const Text('Keine zuletzt verwendeten Emojis'), + columns: 7, + ), + bottomActionBarConfig: const emojis.BottomActionBarConfig( + enabled: false, + ), + categoryViewConfig: emojis.CategoryViewConfig( + backgroundColor: Theme.of(pickerCtx).hoverColor, + iconColorSelected: Theme.of(pickerCtx).primaryColor, + indicatorColor: Theme.of(pickerCtx).primaryColor, + ), + searchViewConfig: emojis.SearchViewConfig( + backgroundColor: Theme.of(pickerCtx).dividerColor, + hintText: 'Suchen', + buttonIconColor: Colors.white, + ), + ), + onEmojiSelected: (_, emoji) { + Navigator.of(pickerCtx).pop(); + _react(emoji.emoji); + }, + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_textfield.dart b/lib/view/pages/talk/widgets/chat_textfield.dart new file mode 100644 index 0000000..f4067b8 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_textfield.dart @@ -0,0 +1,317 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:nextcloud/nextcloud.dart'; +import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; + +import '../../../../api/marianumcloud/files_sharing/file_sharing_api.dart'; +import '../../../../api/marianumcloud/files_sharing/file_sharing_api_params.dart'; +import '../../../../api/marianumcloud/talk/send_message/send_message.dart'; +import '../../../../api/marianumcloud/talk/send_message/send_message_params.dart'; +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/details_bottom_sheet.dart'; +import '../../../../widget/file_pick.dart'; +import '../../../../widget/focus_behaviour.dart'; +import '../../files/files_upload_dialog.dart'; +import 'answer_reference.dart'; + +class ChatTextfield extends StatefulWidget { + final String sendToToken; + final String? selfId; + + const ChatTextfield(this.sendToToken, {this.selfId, super.key}); + + @override + State createState() => _ChatTextfieldState(); +} + +class _ChatTextfieldState extends State { + late SettingsCubit settings; + final TextEditingController _textBoxController = TextEditingController(); + final AsyncActionController _sendController = AsyncActionController(); + String? _sendError; + + void share(String shareFolder, List filePaths) { + for (final element in filePaths) { + final fileName = element.split(Platform.pathSeparator).last; + FileSharingApi() + .share( + FileSharingApiParams( + shareType: 10, + shareWith: widget.sendToToken, + path: '$shareFolder/$fileName', + ), + ) + .then((_) { + if (mounted) context.read().refresh(); + }); + } + } + + Future mediaUpload(List? paths) async { + if (paths == null) return; + + const shareFolder = 'MarianumMobile'; + unawaited( + WebdavApi.webdav.then( + (webdav) => webdav.mkcol(PathUri.parse('/$shareFolder')), + ), + ); + + if (!mounted) return; + unawaited( + pushScreen( + context, + withNavBar: false, + screen: FilesUploadDialog( + filePaths: paths, + remotePath: shareFolder, + onUploadFinished: (uploaded) => share(shareFolder, uploaded), + uniqueNames: true, + ), + ), + ); + } + + void _setDraft(String text) { + final talkSettings = settings.val(write: true).talkSettings; + if (text.isNotEmpty) { + talkSettings.drafts[widget.sendToToken] = text; + } else { + talkSettings.drafts.removeWhere((key, _) => key == widget.sendToToken); + } + } + + void _setDraftReply(int? messageId) { + final talkSettings = settings.val(write: true).talkSettings; + if (messageId != null) { + talkSettings.draftReplies[widget.sendToToken] = messageId; + } else { + talkSettings.draftReplies.removeWhere( + (key, _) => key == widget.sendToToken, + ); + } + } + + @override + void initState() { + super.initState(); + settings = context.read(); + final draftReply = settings + .val() + .talkSettings + .draftReplies[widget.sendToToken]; + if (draftReply != null) { + context.read().setReferenceMessageId(draftReply); + } + } + + @override + void dispose() { + _sendController.dispose(); + super.dispose(); + } + + Future _sendMessage(ChatBloc chatBloc) async { + if (_textBoxController.text.isEmpty) return; + final text = _textBoxController.text; + final replyTo = chatBloc.state.data?.referenceMessageId?.toString(); + setState(() => _sendError = null); + await SendMessage( + widget.sendToToken, + SendMessageParams(text, replyTo: replyTo), + ).run(); + if (!mounted) return; + chatBloc.refresh(); + _textBoxController.text = ''; + _setDraft(''); + chatBloc.setReferenceMessageId(null); + _setDraftReply(null); + } + + @override + Widget build(BuildContext context) { + _textBoxController.text = + settings.val().talkSettings.drafts[widget.sendToToken] ?? ''; + final chatBloc = context.watch(); + final chatState = chatBloc.state.data; + + Widget replyBanner = const SizedBox.shrink(); + if (chatState != null && + chatState.referenceMessageId != null && + chatState.chatResponse != null) { + try { + final referenceMessage = chatState.chatResponse! + .sortByTimestamp() + .firstWhere((e) => e.id == chatState.referenceMessageId); + replyBanner = Row( + children: [ + Expanded( + child: AnswerReference( + context: context, + referenceMessage: referenceMessage, + selfId: widget.selfId, + ), + ), + IconButton( + onPressed: () { + chatBloc.setReferenceMessageId(null); + _setDraftReply(null); + }, + icon: const Icon(Icons.close_outlined), + padding: const EdgeInsets.only(left: 0), + ), + ], + ); + } catch (_) { + /* reference no longer in current chat data */ + } + } + + return Stack( + children: [ + Align( + alignment: Alignment.bottomLeft, + child: Container( + padding: const EdgeInsets.only( + left: 10, + bottom: 3, + top: 3, + right: 10, + ), + width: double.infinity, + child: Column( + children: [ + replyBanner, + if (_sendError != null) + Padding( + padding: const EdgeInsets.only(bottom: 4), + child: Text( + _sendError!, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 12, + ), + ), + ), + Row( + children: [ + GestureDetector( + onTap: () { + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + ListTile( + leading: const Icon(Icons.file_open), + title: const Text('Aus Dateien auswählen'), + onTap: () { + FilePick.documentPick().then(mediaUpload); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.image), + title: const Text('Aus Galerie auswählen'), + onTap: () { + FilePick.multipleGalleryPick().then((value) { + if (value != null) { + mediaUpload( + value.map((e) => e.path).toList(), + ); + } + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ListTile( + leading: const Icon(Icons.camera_alt_outlined), + title: const Text('Foto aufnehmen'), + onTap: () { + FilePick.cameraPick().then((image) { + if (image != null) mediaUpload([image.path]); + }); + Navigator.of(sheetCtx).pop(); + }, + ), + ], + ); + }, + child: Material( + elevation: 5, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(30), + ), + child: Container( + height: 30, + width: 30, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + child: const Icon( + Icons.attach_file_outlined, + color: Colors.white, + size: 20, + ), + ), + ), + ), + const SizedBox(width: 15), + Expanded( + child: TextField( + autocorrect: true, + textCapitalization: TextCapitalization.sentences, + controller: _textBoxController, + maxLines: 7, + minLines: 1, + decoration: const InputDecoration( + hintText: 'Nachricht schreiben...', + border: InputBorder.none, + ), + onChanged: (text) { + if (text.trim().toLowerCase() == + 'marbot marbot marbot') { + const newText = + 'Roboter sind cool und so, aber Marbots sind besser!'; + _textBoxController.text = newText; + text = newText; + } + _setDraft(text); + }, + onTapOutside: (_) => + FocusBehaviour.textFieldTapOutside(context), + ), + ), + const SizedBox(width: 15), + ValueListenableBuilder( + valueListenable: _textBoxController, + builder: (context, value, _) => AsyncFab( + mini: true, + heroTag: 'chatSend_${widget.sendToToken}', + icon: Icons.send, + backgroundColor: Theme.of(context).primaryColor, + foregroundColor: Colors.white, + controller: _sendController, + onPressed: value.text.trim().isEmpty + ? null + : () => _sendMessage(chatBloc), + onError: (message) => + setState(() => _sendError = message), + onSuccess: () => setState(() => _sendError = null), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/talk/widgets/chat_tile.dart b/lib/view/pages/talk/widgets/chat_tile.dart new file mode 100644 index 0000000..ddb0127 --- /dev/null +++ b/lib/view/pages/talk/widgets/chat_tile.dart @@ -0,0 +1,221 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../../api/marianumcloud/talk/actions/talk_actions.dart'; +import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart'; +import '../../../../api/marianumcloud/talk/room/get_room_response.dart'; +import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; +import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../model/account_data.dart'; +import '../../../../state/app/modules/chat/bloc/chat_bloc.dart'; +import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart'; +import '../../../../widget/async_action_button.dart'; +import '../../../../widget/confirm_dialog.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/user_avatar.dart'; +import '../chat_view.dart'; +import '../talk_navigator.dart'; + +class ChatTile extends StatefulWidget { + final GetRoomResponseObject data; + final bool disableContextActions; + final bool hasDraft; + + const ChatTile({ + super.key, + required this.data, + this.disableContextActions = false, + this.hasDraft = false, + }); + + @override + State createState() => _ChatTileState(); +} + +class _ChatTileState extends State { + String? selfUsername; + + @override + void initState() { + super.initState(); + AccountData().waitForPopulation().then((_) { + if (!mounted) return; + setState( + () => selfUsername = AccountData().isPopulated() + ? AccountData().getUsername() + : null, + ); + }); + } + + void _refreshList() => context.read().refresh(); + + Future _setCurrentAsRead() async { + await SetReadMarker( + widget.data.token, + true, + setReadMarkerParams: SetReadMarkerParams( + lastReadMessage: widget.data.lastMessage.id, + ), + ).run(); + if (!mounted) return; + _refreshList(); + } + + @override + Widget build(BuildContext context) { + final chatBloc = context.watch(); + final isGroup = + widget.data.type != GetRoomResponseObjectConversationType.oneToOne; + final circleAvatar = UserAvatar( + id: isGroup ? widget.data.token : widget.data.name, + isGroup: isGroup, + ); + + return ListTile( + style: ListTileStyle.list, + tileColor: + chatBloc.state.data?.currentToken == widget.data.token && + TalkNavigator.isSecondaryVisible(context) + ? Theme.of(context).primaryColor.withAlpha(100) + : null, + leading: Stack( + children: [ + circleAvatar, + Visibility( + visible: widget.data.isFavorite, + child: Positioned( + right: 0, + bottom: 0, + child: Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withAlpha(200), + borderRadius: BorderRadius.circular(90.0), + ), + child: const Icon( + Icons.star, + color: Colors.amberAccent, + size: 15, + ), + ), + ), + ), + ], + ), + title: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + widget.data.displayName, + overflow: TextOverflow.ellipsis, + ), + ), + if (widget.hasDraft) ...[ + const SizedBox(width: 5), + const Icon(Icons.edit_outlined, size: 15), + ], + ], + ), + subtitle: Text( + '${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: ' + '${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}', + overflow: TextOverflow.ellipsis, + ), + trailing: widget.data.unreadMessages <= 0 + ? null + : Container( + padding: const EdgeInsets.all(1), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(30), + ), + constraints: const BoxConstraints(minWidth: 20, minHeight: 20), + child: Text( + '${widget.data.unreadMessages}', + style: const TextStyle(color: Colors.white, fontSize: 15), + textAlign: TextAlign.center, + ), + ), + onTap: () { + if (selfUsername == null) return; + unawaited(_setCurrentAsRead()); + final view = ChatView( + room: widget.data, + selfId: selfUsername!, + avatar: circleAvatar, + ); + TalkNavigator.pushSplitView( + context, + view, + overrideToSingleSubScreen: true, + ); + context.read().setToken(widget.data.token); + }, + onLongPress: () { + if (widget.disableContextActions) return; + showDetailsBottomSheet( + context, + children: (sheetCtx) => [ + if (widget.data.unreadMessages > 0) + AsyncListTile( + leading: const Icon(Icons.mark_chat_read_outlined), + title: const Text('Als gelesen markieren'), + onPressed: _setCurrentAsRead, + ) + else + AsyncListTile( + leading: const Icon(Icons.mark_chat_unread_outlined), + title: const Text('Als ungelesen markieren'), + onPressed: () async { + await SetReadMarker(widget.data.token, false).run(); + if (mounted) _refreshList(); + }, + ), + if (widget.data.isFavorite) + AsyncListTile( + leading: const Icon(Icons.stars_outlined), + title: const Text('Von Favoriten entfernen'), + onPressed: () async { + await SetFavorite(widget.data.token, false).run(); + if (mounted) _refreshList(); + }, + ) + else + AsyncListTile( + leading: const Icon(Icons.star_outline), + title: const Text('Zu Favoriten hinzufügen'), + onPressed: () async { + await SetFavorite(widget.data.token, true).run(); + if (mounted) _refreshList(); + }, + ), + ListTile( + leading: const Icon(Icons.delete_outline), + title: const Text('Konversation verlassen'), + onTap: () { + Navigator.of(sheetCtx).pop(); + ConfirmDialog( + title: 'Chat verlassen', + content: + 'Du benötigst ggf. eine Einladung um erneut beizutreten.', + confirmButton: 'Verlassen', + onConfirmAsync: () async { + await LeaveRoom(widget.data.token).run(); + if (mounted) _refreshList(); + }, + ).asDialog(context); + }, + ), + DebugTile(sheetCtx).jsonData(widget.data.toJson()), + ], + ); + }, + ); + } +} diff --git a/lib/view/pages/talk/components/pollOptionsList.dart b/lib/view/pages/talk/widgets/poll_options_list.dart similarity index 50% rename from lib/view/pages/talk/components/pollOptionsList.dart rename to lib/view/pages/talk/widgets/poll_options_list.dart index 30b3ab3..637b153 100644 --- a/lib/view/pages/talk/components/pollOptionsList.dart +++ b/lib/view/pages/talk/widgets/poll_options_list.dart @@ -1,14 +1,17 @@ - import 'package:flutter/material.dart'; import 'package:flutter_linkify/flutter_linkify.dart'; -import '../../../../api/marianumcloud/talk/getPoll/getPollStateResponse.dart'; -import '../../../../utils/UrlOpener.dart'; +import '../../../../api/marianumcloud/talk/get_poll/get_poll_state_response.dart'; +import '../../../../utils/url_opener.dart'; class PollOptionsList extends StatefulWidget { final GetPollStateResponseObject pollData; final String chatToken; - const PollOptionsList({super.key, required this.pollData, required this.chatToken}); + const PollOptionsList({ + super.key, + required this.pollData, + required this.chatToken, + }); @override State createState() => _PollOptionsListState(); @@ -23,45 +26,48 @@ class _PollOptionsListState extends State { var votedSelf = widget.pollData.votedSelf.contains(optionId); var portionsVisible = widget.pollData.votes is Map; var votes = portionsVisible - ? (widget.pollData.votes['option-$optionId'] as num?) ?? 0 - : 0; + ? (widget.pollData.votes['option-$optionId'] as num?) ?? 0 + : 0; var numVoters = widget.pollData.numVoters ?? 0; - double portion = numVoters == 0 ? 0 : (votes / numVoters); + final portion = numVoters == 0 ? 0.0 : (votes / numVoters); return ListTile( - // enabled: false, isThreeLine: portionsVisible, dense: true, - title: Text( - option, - style: Theme.of(context).textTheme.bodyLarge, - ), + title: Text(option, style: Theme.of(context).textTheme.bodyLarge), leading: Icon( votedSelf ? Icons.check_circle_outlined : Icons.circle_outlined, color: votedSelf ? Theme.of(context).colorScheme.primary.withValues(alpha: 0.6) - : Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), + : Theme.of( + context, + ).colorScheme.onSurfaceVariant.withValues(alpha: 0.6), ), - subtitle: portionsVisible ? Row( - children: [ - Expanded( - child: LinearProgressIndicator(value: portion.clamp(0.0, 1.0)), - ), - Container( - margin: const EdgeInsets.only(left: 10), - child: Text('${(portion * 100).round()}%'), - ), - ], - ) : null, + subtitle: portionsVisible + ? Row( + children: [ + Expanded( + child: LinearProgressIndicator( + value: portion.clamp(0.0, 1.0), + ), + ), + Container( + margin: const EdgeInsets.only(left: 10), + child: Text('${(portion * 100).round()}%'), + ), + ], + ) + : null, ); }), ListTile( title: Linkify( - text: 'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}', + text: + 'Wenn du abstimmen möchtest, verwende die Webversion unter https://cloud.marianum-fulda.de/call/${widget.chatToken}', onOpen: UrlOpener.onOpen, style: Theme.of(context).textTheme.bodySmall, ), - ) + ), ], ); } diff --git a/lib/view/pages/talk/widgets/split_view_placeholder.dart b/lib/view/pages/talk/widgets/split_view_placeholder.dart new file mode 100644 index 0000000..d5b12e5 --- /dev/null +++ b/lib/view/pages/talk/widgets/split_view_placeholder.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import '../../../../theming/app_theme.dart'; + +class SplitViewPlaceholder extends StatelessWidget { + const SplitViewPlaceholder({super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(invertColors: !AppTheme.isDarkMode(context)), + child: Image.asset('assets/logo/icon.png', height: 200), + ), + const SizedBox(height: 30), + const Text( + 'Marianum Fulda\nTalk', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 30), + ), + ], + ), + ), + ); +} diff --git a/lib/view/pages/timetable/appointmenetComponent.dart b/lib/view/pages/timetable/appointmenetComponent.dart deleted file mode 100644 index 0f2d3e4..0000000 --- a/lib/view/pages/timetable/appointmenetComponent.dart +++ /dev/null @@ -1,92 +0,0 @@ - -import 'package:flutter/material.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -import 'CrossPainter.dart'; - -class AppointmentComponent extends StatefulWidget { - final CalendarAppointmentDetails details; - final bool crossedOut; - const AppointmentComponent({super.key, required this.details, this.crossedOut = false}); - - @override - State createState() => _AppointmentComponentState(); -} - -class _AppointmentComponentState extends State { - @override - Widget build(BuildContext context) { - final Appointment meeting = widget.details.appointments.first; - final appointmentHeight = widget.details.bounds.height; - - return Stack( - children: [ - Column( - children: [ - Container( - padding: const EdgeInsets.all(3), - height: appointmentHeight, - alignment: Alignment.topLeft, - decoration: BoxDecoration( - shape: BoxShape.rectangle, - borderRadius: const BorderRadius.all(Radius.circular(5)), - color: meeting.color.withAlpha(meeting.endTime.isBefore(DateTime.now()) ? 100 : 255), - ), - child: SingleChildScrollView( - child: Column( - mainAxisAlignment: MainAxisAlignment.start, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - meeting.subject, - style: const TextStyle( - color: Colors.white, - fontSize: 15, - fontWeight: FontWeight.w500, - ), - maxLines: 1, - softWrap: false, - ), - ), - FittedBox( - fit: BoxFit.fitWidth, - child: Text( - (meeting.location == null || meeting.location!.isEmpty ? ' ' : meeting.location!), - maxLines: 3, - overflow: TextOverflow.ellipsis, - softWrap: true, - style: const TextStyle( - color: Colors.white, - fontSize: 10, - ), - ), - ) - ], - ), - ), - ), - ], - ), - Visibility( - visible: widget.crossedOut, - child: Positioned.fill( - child: Container( - decoration: BoxDecoration( - border: Border.all( - width: 2, - color: Colors.red.withAlpha(200), - ), - borderRadius: const BorderRadius.all(Radius.circular(5)), - ), - child: CustomPaint( - painter: CrossPainter(), - ), - ) - ), - ), - ], - ); - } -} diff --git a/lib/view/pages/timetable/appointmentDetails.dart b/lib/view/pages/timetable/appointmentDetails.dart deleted file mode 100644 index 9adb671..0000000 --- a/lib/view/pages/timetable/appointmentDetails.dart +++ /dev/null @@ -1,226 +0,0 @@ - -import 'dart:async'; - -import 'package:bottom_sheet/bottom_sheet.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:persistent_bottom_nav_bar_v2/persistent_bottom_nav_bar_v2.dart'; -import 'package:provider/provider.dart'; -import 'package:rrule/rrule.dart'; -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/remove/removeCustomTimetableEventParams.dart'; -import '../../../api/webuntis/queries/getRooms/getRoomsResponse.dart'; -import '../../../api/webuntis/queries/getSubjects/getSubjectsResponse.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/confirmDialog.dart'; -import '../../../widget/debug/debugTile.dart'; -import '../../../widget/unimplementedDialog.dart'; -import '../more/roomplan/roomplan.dart'; -import 'arbitraryAppointment.dart'; -import 'customTimetableEventEditDialog.dart'; - -class AppointmentDetails { - static String _getEventPrefix(String? code) { - if(code == 'cancelled') return 'Entfällt: '; - if(code == 'irregular') return 'Änderung: '; - return code ?? ''; - } - - static void show(BuildContext context, TimetableProps webuntisData, Appointment appointment) { - (appointment.id! as ArbitraryAppointment).handlers( - (webuntis) => _webuntis(context, webuntisData, appointment, webuntis), - (customData) => _custom(context, webuntisData, customData) - ); - } - - static void _bottomSheet( - BuildContext context, - Widget Function(BuildContext context) header, - SliverChildListDelegate Function(BuildContext context) body - ) { - showStickyFlexibleBottomSheet( - minHeight: 0, - initHeight: 0.4, - maxHeight: 0.7, - anchors: [0, 0.4, 0.7], - isSafeArea: true, - maxHeaderHeight: 100, - - context: context, - headerBuilder: (context, bottomSheetOffset) => header(context), - bodyBuilder: (context, bottomSheetOffset) => body(context) - ); - } - - static void _webuntis(BuildContext context, TimetableProps webuntisData, Appointment appointment, GetTimetableResponseObject timetableData) { - GetSubjectsResponseObject subject; - GetRoomsResponseObject room; - - try { - subject = webuntisData.getSubjectsResponse.result.firstWhere((subject) => subject.id == timetableData.su[0].id); - } catch(e) { - subject = GetSubjectsResponseObject(0, '?', 'Unbekannt', '?', true); - } - - try { - room = webuntisData.getRoomsResponse.result.firstWhere((room) => room.id == timetableData.ro[0].id); - } catch(e) { - room = GetRoomsResponseObject(0, '?', 'Unbekannt', true, '?'); - } - - _bottomSheet( - context, - (context) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text('${_getEventPrefix(timetableData.code)}${subject.alternateName}', textAlign: TextAlign.center, style: const TextStyle(fontSize: 25), overflow: TextOverflow.ellipsis), - Text(subject.longName), - Text("${Jiffy.parseFromDateTime(appointment.startTime).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endTime).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)), - ], - ), - ), - - (context) => SliverChildListDelegate( - [ - const Divider(), - ListTile( - leading: const Icon(Icons.notifications_active), - title: Text("Status: ${timetableData.code != null ? "Geändert" : "Regulär"}"), - ), - ListTile( - leading: const Icon(Icons.room), - title: Text('Raum: ${room.name} (${room.longName})'), - trailing: IconButton( - icon: const Icon(Icons.house_outlined), - onPressed: () { - pushScreen(context, withNavBar: false, screen: const Roomplan()); - }, - ), - ), - ListTile( - leading: const Icon(Icons.person), - title: timetableData.te.isNotEmpty - ? Text("Lehrkraft: ${timetableData.te[0].name} ${timetableData.te[0].longname.isNotEmpty ? "(${timetableData.te[0].longname})" : ""}") - : const Text('?'), - trailing: Visibility( - visible: !kReleaseMode, - child: IconButton( - icon: const Icon(Icons.textsms_outlined), - onPressed: () { - UnimplementedDialog.show(context); - }, - ), - ), - ), - ListTile( - leading: const Icon(Icons.abc), - title: Text('Typ: ${timetableData.activityType}'), - ), - ListTile( - leading: const Icon(Icons.people), - title: Text("Klasse(n): ${timetableData.kl.map((e) => e.name).join(", ")}"), - ), - DebugTile(context).jsonData(timetableData.toJson()), - ], - ) - ); - } - - static Completer deleteCustomEvent(BuildContext context, CustomTimetableEvent appointment) { - var future = Completer(); - ConfirmDialog( - title: 'Termin löschen', - content: "Der ${appointment.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.", - confirmButton: 'Löschen', - onConfirm: () { - RemoveCustomTimetableEvent( - RemoveCustomTimetableEventParams( - appointment.id - ) - ).run().then((value) { - Provider.of(context, listen: false).run(renew: true); - future.complete(); - }); - }, - ).asDialog(context); - return future; - } - - static void _custom(BuildContext context, TimetableProps webuntisData, CustomTimetableEvent appointment) { - _bottomSheet( - context, - (context) => Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text(appointment.title, style: const TextStyle(fontSize: 25, overflow: TextOverflow.ellipsis)), - Text("${Jiffy.parseFromDateTime(appointment.startDate).format(pattern: "HH:mm")} - ${Jiffy.parseFromDateTime(appointment.endDate).format(pattern: "HH:mm")}", style: const TextStyle(fontSize: 15)), - ], - ), - ), - (context) => SliverChildListDelegate( - [ - const Divider(), - Center( - child: Wrap( - children: [ - TextButton.icon( - onPressed: () { - Navigator.of(context).pop(); - showDialog( - context: context, - builder: (context) => CustomTimetableEventEditDialog(existingEvent: appointment), - ); - }, - label: const Text('Bearbeiten'), - icon: const Icon(Icons.edit_outlined), - ), - TextButton.icon( - onPressed: () { - deleteCustomEvent(context, appointment).future.then((value) => Navigator.of(context).pop()); - }, - label: const Text('Löschen'), - icon: const Icon(Icons.delete_outline), - ), - ], - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.info_outline), - title: Text(appointment.description.isEmpty ? 'Keine Beschreibung' : appointment.description), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.repeat_outlined)), - title: Text("Serie: ${appointment.rrule.isNotEmpty ? "Wiederholend" : "Einmailg"}"), - subtitle: FutureBuilder( - future: RruleL10nEn.create(), - builder: (context, snapshot) { - if(appointment.rrule.isEmpty) return const Text('Keine weiteren vorkomnisse'); - if(snapshot.data == null) return const Text('...'); - var rrule = RecurrenceRule.fromString(appointment.rrule); - if(!rrule.canFullyConvertToText) return const Text('Keine genauere Angabe möglich.'); - return Text(rrule.toText(l10n: snapshot.data!)); - }, - ) - ), - DebugTile(context).child( - ListTile( - leading: const CenteredLeading(Icon(Icons.rule)), - title: const Text('RRule'), - subtitle: Text(appointment.rrule.isEmpty ? 'Keine' : appointment.rrule), - ) - ), - DebugTile(context).jsonData(appointment.toJson()), - ] - ) - ); - } -} diff --git a/lib/view/pages/timetable/arbitraryAppointment.dart b/lib/view/pages/timetable/arbitraryAppointment.dart deleted file mode 100644 index 46c9b1b..0000000 --- a/lib/view/pages/timetable/arbitraryAppointment.dart +++ /dev/null @@ -1,19 +0,0 @@ - -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; - -class ArbitraryAppointment { - GetTimetableResponseObject? webuntis; - CustomTimetableEvent? custom; - - ArbitraryAppointment({this.webuntis, this.custom}); - - bool hasWebuntis() => webuntis != null; - - bool hasCustom() => custom != null; - - void handlers(void Function(GetTimetableResponseObject webuntisData) webuntis, void Function(CustomTimetableEvent customData) custom) { - if(hasWebuntis()) webuntis(this.webuntis!); - if(hasCustom()) custom(this.custom!); - } -} diff --git a/lib/view/pages/timetable/customTimetableEventEditDialog.dart b/lib/view/pages/timetable/customTimetableEventEditDialog.dart deleted file mode 100644 index ba21525..0000000 --- a/lib/view/pages/timetable/customTimetableEventEditDialog.dart +++ /dev/null @@ -1,233 +0,0 @@ - -import 'dart:developer'; - -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; -import 'package:rrule_generator/rrule_generator.dart'; -import 'package:time_range_picker/time_range_picker.dart'; - -import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/add/addCustomTimetableEventParams.dart'; -import '../../../api/mhsl/customTimetableEvent/customTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEvent.dart'; -import '../../../api/mhsl/customTimetableEvent/update/updateCustomTimetableEventParams.dart'; -import '../../../model/accountData.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/focusBehaviour.dart'; -import '../../../widget/infoDialog.dart'; -import 'customTimetableColors.dart'; - -class CustomTimetableEventEditDialog extends StatefulWidget { - final CustomTimetableEvent? existingEvent; - const CustomTimetableEventEditDialog({this.existingEvent, super.key}); - - @override - State createState() => _AddCustomTimetableEventDialogState(); -} - -class _AddCustomTimetableEventDialogState extends State { - late DateTime _date = widget.existingEvent?.startDate ?? DateTime.now(); - late TimeOfDay _startTime = widget.existingEvent?.startDate.toTimeOfDay() ?? const TimeOfDay(hour: 08, minute: 00); - late TimeOfDay _endTime = widget.existingEvent?.endDate.toTimeOfDay() ?? const TimeOfDay(hour: 09, minute: 30); - late final TextEditingController _eventName = TextEditingController(text: widget.existingEvent?.title); - late final TextEditingController _eventDescription = TextEditingController(text: widget.existingEvent?.description); - late String _recurringRule = widget.existingEvent?.rrule ?? ''; - late CustomTimetableColors _customTimetableColor = CustomTimetableColors.values.firstWhere( - (element) => element.name == widget.existingEvent?.color, - orElse: () => TimetableColors.defaultColor - ); - - late bool isEditingExisting = widget.existingEvent != null; - - bool validate() { - if(_eventName.text.isEmpty) return false; - return true; - } - - void fetchTimetable() { - Provider.of(context, listen: false).run(renew: true); - } - - @override - Widget build(BuildContext context) => AlertDialog( - insetPadding: const EdgeInsets.all(20), - contentPadding: const EdgeInsets.all(10), - title: const Text('Termin hinzufügen'), - content: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - title: TextField( - controller: _eventName, - autofocus: true, - decoration: const InputDecoration( - labelText: 'Terminname', - border: OutlineInputBorder() - ), - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - ListTile( - title: TextField( - controller: _eventDescription, - maxLines: 2, - minLines: 2, - decoration: const InputDecoration( - labelText: 'Beschreibung', - border: OutlineInputBorder() - ), - onTapOutside: (PointerDownEvent event) => FocusBehaviour.textFieldTapOutside(context), - ), - ), - const Divider(), - ListTile( - leading: const Icon(Icons.date_range_outlined), - title: Text(Jiffy.parseFromDateTime(_date).yMMMd), - subtitle: const Text('Datum'), - onTap: () async { - final pickedDate = await showDatePicker( - context: context, - initialDate: _date, - firstDate: DateTime.now().subtract(const Duration(days: 30)), - lastDate: DateTime.now().add(const Duration(days: 30)), - ); - if (pickedDate != null && pickedDate != _date) { - setState(() { - _date = pickedDate; - }); - } - }, - ), - ListTile( - leading: const Icon(Icons.access_time_outlined), - title: Text('${_startTime.format(context).toString()} - ${_endTime.format(context).toString()}'), - subtitle: const Text('Zeitraum'), - onTap: () async { - TimeRange timeRange = await showTimeRangePicker( - context: context, - start: _startTime, - end: _endTime, - disabledTime: TimeRange(startTime: const TimeOfDay(hour: 16, minute: 30), endTime: const TimeOfDay(hour: 08, minute: 00)), - disabledColor: Colors.grey, - paintingStyle: PaintingStyle.fill, - interval: const Duration(minutes: 5), - fromText: 'Beginnend', - toText: 'Endend', - strokeColor: Theme.of(context).colorScheme.secondary, - minDuration: const Duration(minutes: 15), - selectedColor: Theme.of(context).primaryColor, - ticks: 24, - ); - - setState(() { - _startTime = timeRange.startTime; - _endTime = timeRange.endTime; - }); - }, - ), - const Divider(), - ListTile( - leading: const Icon(Icons.color_lens_outlined), - title: const Text('Farbgebung'), - trailing: DropdownButton( - value: _customTimetableColor, - icon: const Icon(Icons.arrow_drop_down), - items: CustomTimetableColors.values.map((e) => DropdownMenuItem( - value: e, - enabled: e != _customTimetableColor, - child: Row( - children: [ - Icon(Icons.circle, color: TimetableColors.getDisplayOptions(e).color), - const SizedBox(width: 10), - Text(TimetableColors.getDisplayOptions(e).displayName), - ], - ), - )).toList(), - onChanged: (e) { - setState(() { - _customTimetableColor = e!; - }); - }, - ), - ), - const Divider(), - RRuleGenerator( - config: RRuleGeneratorConfig( - headerEnabled: true, - weekdayBackgroundColor: Theme.of(context).colorScheme.secondary, - weekdaySelectedBackgroundColor: Theme.of(context).primaryColor, - weekdayColor: Colors.black, - ), - initialRRule: _recurringRule, - textDelegate: const GermanRRuleTextDelegate(), - onChange: (String newValue) { - log('Rule: $newValue'); - setState(() { - _recurringRule = newValue; - }); - }, - ) - ], - ), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(); - }, - child: const Text('Abbrechen'), - ), - TextButton( - onPressed: () { - if(!validate()) return; - - var editedEvent = CustomTimetableEvent( - id: '', - title: _eventName.text, - description: _eventDescription.text, - startDate: _date.withTime(_startTime), - endDate: _date.withTime(_endTime), - color: _customTimetableColor.name, - rrule: _recurringRule, - createdAt: DateTime.now(), - updatedAt: DateTime.now(), - ); - - if(!isEditingExisting) { - AddCustomTimetableEvent( - AddCustomTimetableEventParams( - AccountData().getUserSecret(), - editedEvent - ) - ).run().then((value) { - Navigator.of(context).pop(); - fetchTimetable(); - }) - .catchError((error, stack) { - InfoDialog.show(context, error.toString()); - }); - } else { - UpdateCustomTimetableEvent( - UpdateCustomTimetableEventParams( - widget.existingEvent?.id ?? '', - editedEvent - ) - ).run().then((value) { - Navigator.of(context).pop(); - fetchTimetable(); - }) - .catchError((error, stack) { - InfoDialog.show(context, error.toString()); - }); - } - - - }, - child: Text(isEditingExisting ? 'Speichern' : 'Erstellen'), - ), - ], - ); -} diff --git a/lib/view/pages/timetable/customTimetableColors.dart b/lib/view/pages/timetable/custom_events/custom_event_colors.dart similarity index 53% rename from lib/view/pages/timetable/customTimetableColors.dart rename to lib/view/pages/timetable/custom_events/custom_event_colors.dart index 1b65838..3a74984 100644 --- a/lib/view/pages/timetable/customTimetableColors.dart +++ b/lib/view/pages/timetable/custom_events/custom_event_colors.dart @@ -1,35 +1,38 @@ import 'package:flutter/material.dart'; -import '../../../theming/darkAppTheme.dart'; +import '../../../../theming/dark_app_theme.dart'; -enum CustomTimetableColors { - orange, - red, - green, - blue -} +enum CustomTimetableColors { orange, red, green, blue } class TimetableColors { - static const CustomTimetableColors defaultColor = CustomTimetableColors.orange; + static const CustomTimetableColors defaultColor = + CustomTimetableColors.orange; static ColorModeDisplay getDisplayOptions(CustomTimetableColors color) { - switch(color) { + switch (color) { case CustomTimetableColors.green: return ColorModeDisplay(color: Colors.green, displayName: 'Grün'); - case CustomTimetableColors.blue: return ColorModeDisplay(color: Colors.blue, displayName: 'Blau'); - case CustomTimetableColors.orange: - return ColorModeDisplay(color: Colors.orange.shade800, displayName: 'Orange'); - + return ColorModeDisplay( + color: Colors.orange.shade800, + displayName: 'Orange', + ); case CustomTimetableColors.red: - return ColorModeDisplay(color: DarkAppTheme.marianumRed, displayName: 'Rot'); - + return ColorModeDisplay( + color: DarkAppTheme.marianumRed, + displayName: 'Rot', + ); } } - static Color getColorFromString(String color) => getDisplayOptions(CustomTimetableColors.values.firstWhere((element) => element.name == color, orElse: () => TimetableColors.defaultColor)).color; + static Color getColorFromString(String color) => getDisplayOptions( + CustomTimetableColors.values.firstWhere( + (e) => e.name == color, + orElse: () => defaultColor, + ), + ).color; } class ColorModeDisplay { 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 new file mode 100644 index 0000000..8dd64f2 --- /dev/null +++ b/lib/view/pages/timetable/custom_events/custom_event_edit_dialog.dart @@ -0,0 +1,308 @@ +import 'dart:developer'; + +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:rrule_generator/rrule_generator.dart'; +import 'package:time_range_picker/time_range_picker.dart'; + +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/focus_behaviour.dart'; +import 'custom_event_colors.dart'; + +class CustomEventEditDialog extends StatefulWidget { + final CustomTimetableEvent? existingEvent; + final DateTime? initialStart; + final DateTime? initialEnd; + final String? initialTitle; + final String? initialDescription; + + const CustomEventEditDialog({ + this.existingEvent, + this.initialStart, + this.initialEnd, + this.initialTitle, + this.initialDescription, + super.key, + }); + + @override + State createState() => _CustomEventEditDialogState(); +} + +class _CustomEventEditDialogState extends State { + // Selectable window for non-all-day events. Times outside this range are + // clamped in. For events outside school hours, use the all-day toggle. + static const TimeOfDay _windowStart = TimeOfDay(hour: 8, minute: 0); + static const TimeOfDay _windowEnd = TimeOfDay(hour: 16, minute: 30); + static const TimeOfDay _defaultStart = _windowStart; + static const TimeOfDay _defaultEnd = TimeOfDay(hour: 9, minute: 30); + static const int _minDurationMinutes = 15; + + late DateTime _date = + widget.existingEvent?.startDate ?? widget.initialStart ?? DateTime.now(); + late TimeOfDay _startTime; + late TimeOfDay _endTime; + late bool _isAllDay; + late final TextEditingController _name = TextEditingController( + text: widget.existingEvent?.title ?? widget.initialTitle, + ); + late final TextEditingController _description = TextEditingController( + text: widget.existingEvent?.description ?? widget.initialDescription, + ); + late String _rrule = widget.existingEvent?.rrule ?? ''; + late CustomTimetableColors _color = CustomTimetableColors.values.firstWhere( + (e) => e.name == widget.existingEvent?.color, + orElse: () => TimetableColors.defaultColor, + ); + + bool get _isEditing => widget.existingEvent != null; + + @override + void initState() { + super.initState(); + if (_isEditing) { + final s = widget.existingEvent!.startDate; + final e = widget.existingEvent!.endDate; + _isAllDay = isAllDayConvention(s, e); + if (_isAllDay) { + _startTime = _defaultStart; + _endTime = _defaultEnd; + } else { + final clamped = _clampToVisibleWindow(s.toTimeOfDay(), e.toTimeOfDay()); + _startTime = clamped.$1; + _endTime = clamped.$2; + } + return; + } + _isAllDay = false; + final rawStart = widget.initialStart?.toTimeOfDay() ?? _defaultStart; + final rawEnd = widget.initialEnd?.toTimeOfDay() ?? _defaultEnd; + final clamped = _clampToVisibleWindow(rawStart, rawEnd); + _startTime = clamped.$1; + _endTime = clamped.$2; + } + + static (TimeOfDay, TimeOfDay) _clampToVisibleWindow( + TimeOfDay rawStart, + TimeOfDay rawEnd, + ) { + int toMin(TimeOfDay t) => t.hour * 60 + t.minute; + TimeOfDay fromMin(int m) => TimeOfDay(hour: m ~/ 60, minute: m % 60); + + final windowStart = toMin(_windowStart); + final windowEnd = toMin(_windowEnd); + var start = toMin( + rawStart, + ).clamp(windowStart, windowEnd - _minDurationMinutes); + var end = toMin(rawEnd); + if (end < start + _minDurationMinutes) end = start + _minDurationMinutes; + if (end > windowEnd) { + end = windowEnd; + start = end - _minDurationMinutes; + } + return (fromMin(start), fromMin(end)); + } + + /// All-day convention shared with [TimetableAppointmentFactory]: a custom + /// event is treated as all-day when its start and end both land on midnight + /// of the same day. We piggyback on this so we don't need a backend schema + /// change. + static bool isAllDayConvention(DateTime start, DateTime end) => + start.year == end.year && + start.month == end.month && + start.day == end.day && + start.hour == 0 && + start.minute == 0 && + start.second == 0 && + end.hour == 0 && + end.minute == 0 && + end.second == 0; + + Future _save() async { + if (_name.text.trim().isEmpty) { + throw Exception('Bitte einen Terminnamen eingeben.'); + } + + // All-day convention: store start and end as midnight of the chosen day. + // The factory recognises this on read. + final midnight = DateTime(_date.year, _date.month, _date.day); + final startDate = _isAllDay ? midnight : _date.withTime(_startTime); + final endDate = _isAllDay ? midnight : _date.withTime(_endTime); + + final edited = CustomTimetableEvent( + id: widget.existingEvent?.id ?? '', + title: _name.text, + description: _description.text, + startDate: startDate, + endDate: endDate, + color: _color.name, + rrule: _rrule, + createdAt: DateTime.now(), + updatedAt: DateTime.now(), + ); + + final bloc = context.read(); + if (_isEditing) { + await bloc.updateCustomEvent(widget.existingEvent!.id, edited); + } else { + await bloc.addCustomEvent(edited); + } + } + + Future _pickDate() async { + final now = DateTime.now(); + final defaultFirst = now.subtract(const Duration(days: 30)); + final defaultLast = now.add(const Duration(days: 365)); + final picked = await showDatePicker( + context: context, + initialDate: _date, + firstDate: _date.isBefore(defaultFirst) ? _date : defaultFirst, + lastDate: _date.isAfter(defaultLast) ? _date : defaultLast, + ); + if (picked != null && picked != _date) setState(() => _date = picked); + } + + Future _pickTimeRange() async { + final range = await showTimeRangePicker( + context: context, + start: _startTime, + end: _endTime, + disabledTime: TimeRange(startTime: _windowEnd, endTime: _windowStart), + disabledColor: Colors.grey, + paintingStyle: PaintingStyle.fill, + interval: const Duration(minutes: 5), + fromText: 'Beginnend', + toText: 'Endend', + strokeColor: Theme.of(context).colorScheme.secondary, + minDuration: Duration(minutes: _minDurationMinutes), + selectedColor: Theme.of(context).primaryColor, + ticks: 24, + ); + if (range is! TimeRange) return; + setState(() { + _startTime = range.startTime; + _endTime = range.endTime; + }); + } + + @override + Widget build(BuildContext context) => AlertDialog( + insetPadding: const EdgeInsets.all(20), + contentPadding: const EdgeInsets.all(10), + title: Text(_isEditing ? 'Termin bearbeiten' : 'Termin hinzufügen'), + content: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + title: TextField( + controller: _name, + autofocus: true, + decoration: const InputDecoration( + labelText: 'Terminname', + border: OutlineInputBorder(), + ), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + ListTile( + title: TextField( + controller: _description, + maxLines: 2, + minLines: 2, + decoration: const InputDecoration( + labelText: 'Beschreibung', + border: OutlineInputBorder(), + ), + onTapOutside: (_) => FocusBehaviour.textFieldTapOutside(context), + ), + ), + const Divider(), + ListTile( + leading: const Icon(Icons.date_range_outlined), + title: Text(Jiffy.parseFromDateTime(_date).yMMMd), + subtitle: const Text('Datum'), + onTap: _pickDate, + ), + SwitchListTile( + secondary: const Icon(Icons.today_outlined), + title: const Text('Ganztägig'), + value: _isAllDay, + onChanged: (v) => setState(() => _isAllDay = v), + ), + if (!_isAllDay) + ListTile( + leading: const Icon(Icons.access_time_outlined), + title: Text( + '${_startTime.format(context)} - ${_endTime.format(context)}', + ), + subtitle: const Text('Zeitraum'), + onTap: _pickTimeRange, + ), + const Divider(), + ListTile( + leading: const Icon(Icons.color_lens_outlined), + title: const Text('Farbgebung'), + trailing: DropdownButton( + value: _color, + icon: const Icon(Icons.arrow_drop_down), + items: CustomTimetableColors.values + .map( + (e) => DropdownMenuItem( + value: e, + enabled: e != _color, + child: Row( + children: [ + Icon( + Icons.circle, + color: TimetableColors.getDisplayOptions(e).color, + ), + const SizedBox(width: 10), + Text( + TimetableColors.getDisplayOptions(e).displayName, + ), + ], + ), + ), + ) + .toList(), + onChanged: (e) => setState(() => _color = e!), + ), + ), + const Divider(), + RRuleGenerator( + config: RRuleGeneratorConfig( + selectDayStyle: RRuleSelectDayStyle( + dayStyle: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.secondary, + ), + dayTextStyle: const TextStyle(color: Colors.black), + selectedDayStyle: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).primaryColor, + ), + ), + ), + initialRRule: _rrule, + locale: RRuleLocale.de_DE, + onChange: (newValue) { + log('Rule: $newValue'); + setState(() => _rrule = newValue); + }, + ), + ], + ), + ), + actions: [ + AsyncDialogAction( + confirmLabel: _isEditing ? 'Speichern' : 'Erstellen', + onConfirm: _save, + ), + ], + ); +} diff --git a/lib/view/pages/timetable/custom_events/custom_events_view.dart b/lib/view/pages/timetable/custom_events/custom_events_view.dart new file mode 100644 index 0000000..24263f7 --- /dev/null +++ b/lib/view/pages/timetable/custom_events/custom_events_view.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../../../../extensions/date_time.dart'; +import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/placeholder_view.dart'; +import '../details/delete_custom_event.dart'; +import 'custom_event_edit_dialog.dart'; + +class CustomEventsView extends StatelessWidget { + const CustomEventsView({super.key}); + + void _openCreateDialog(BuildContext context) { + showDialog( + context: context, + builder: (_) => const CustomEventEditDialog(), + barrierDismissible: false, + ); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar( + title: const Text('Eigene Termine'), + actions: [ + IconButton( + icon: const Icon(Icons.add), + onPressed: () => _openCreateDialog(context), + ), + ], + ), + body: LoadableStateConsumer( + child: (state, _) { + final events = state.customEvents?.events ?? const []; + + if (events.isEmpty) { + return PlaceholderView( + icon: Icons.calendar_today_outlined, + text: 'Keine Einträge vorhanden', + button: TextButton( + onPressed: () => _openCreateDialog(context), + child: const Text('Termin erstellen'), + ), + ); + } + + return ListView( + children: events + .map( + (e) => ListTile( + title: Text(e.title), + subtitle: Text( + '${e.rrule.isNotEmpty ? "wiederholend, " : ""}' + 'beginnend ${e.startDate.formatRelative()}', + ), + leading: CenteredLeading( + Icon( + e.rrule.isEmpty + ? Icons.event_outlined + : Icons.event_repeat_outlined, + ), + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: const Icon(Icons.edit_outlined), + onPressed: () => showDialog( + context: context, + builder: (_) => + CustomEventEditDialog(existingEvent: e), + ), + ), + IconButton( + icon: const Icon(Icons.delete_outline), + onPressed: () => + showDeleteCustomEventDialog(context, e), + ), + ], + ), + ), + ) + .toList(), + ); + }, + ), + ); +} diff --git a/lib/view/pages/timetable/data/arbitrary_appointment.dart b/lib/view/pages/timetable/data/arbitrary_appointment.dart new file mode 100644 index 0000000..1f8dd82 --- /dev/null +++ b/lib/view/pages/timetable/data/arbitrary_appointment.dart @@ -0,0 +1,24 @@ +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; + +sealed class ArbitraryAppointment { + const ArbitraryAppointment(); + + T when({ + required T Function(GetTimetableResponseObject lesson) webuntis, + required T Function(CustomTimetableEvent event) custom, + }) => switch (this) { + WebuntisAppointment(:final lesson) => webuntis(lesson), + CustomAppointment(:final event) => custom(event), + }; +} + +class WebuntisAppointment extends ArbitraryAppointment { + final GetTimetableResponseObject lesson; + const WebuntisAppointment(this.lesson); +} + +class CustomAppointment extends ArbitraryAppointment { + final CustomTimetableEvent event; + const CustomAppointment(this.event); +} diff --git a/lib/view/pages/timetable/data/calendar_layout.dart b/lib/view/pages/timetable/data/calendar_layout.dart new file mode 100644 index 0000000..aafe4dd --- /dev/null +++ b/lib/view/pages/timetable/data/calendar_layout.dart @@ -0,0 +1,24 @@ +const double kCalendarStartHour = 7.5; +const double kCalendarEndHour = 17.25; +const Duration kCalendarTimeInterval = Duration(minutes: 30); +const double kCalendarViewHeaderHeight = 60; + +/// Below this, the grid scrolls vertically rather than compressing further. +const double kCalendarMinPxPerHour = 56; + +/// The grid scrolls vertically once lessons would otherwise be smaller. +const double kLessonBlockMinHeight = 50; + +/// Fixed (independent of actual break duration); breaks render as a compact +/// indicator. +const double kBreakBlockHeight = 28; + +const int kOutsideChipsMaxVisible = 2; +const double kOutsideChipHeight = 22; +const double kOutsideChipSpacing = 3; +const double kOutsideStripVerticalPadding = 3; + +const double kAppointmentTitleFontSize = 15; +const double kAppointmentTitleMinFontSize = 11; +const double kAppointmentBodyFontSize = 10; +const double kAppointmentBodyLineHeight = 1.15; diff --git a/lib/view/pages/timetable/data/calendar_logic.dart b/lib/view/pages/timetable/data/calendar_logic.dart new file mode 100644 index 0000000..aeead8b --- /dev/null +++ b/lib/view/pages/timetable/data/calendar_logic.dart @@ -0,0 +1,382 @@ +import 'package:rrule/rrule.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../extensions/date_time.dart'; +import 'arbitrary_appointment.dart'; +import 'calendar_layout.dart'; +import 'lesson_period_schedule.dart'; + +/// Either explicitly marked as all-day, or so long it's effectively a full +/// day from the user's perspective. We compare in minutes (not hours) because +/// `Duration.inHours` truncates: a 9h 30min event would otherwise count as 9. +bool isAllDayLike(Appointment a) => + a.isAllDay || a.endTime.difference(a.startTime).inMinutes >= 8 * 60; + +/// True when the appointment doesn't fit into the school-hours grid: +/// all-day, fully before the grid start, fully after the grid end, engulfing +/// the grid entirely, or lasting 10+ hours (treated as a de-facto all-day +/// event the source system happens to represent with explicit times). +bool isOutsideSchoolHours(Appointment a) { + if (isAllDayLike(a)) return true; + final schoolStart = (kCalendarStartHour * 60).round(); + final schoolEnd = (kCalendarEndHour * 60).round(); + final startMin = a.startTime.hour * 60 + a.startTime.minute; + final endMin = a.endTime.hour * 60 + a.endTime.minute; + if (endMin <= schoolStart) return true; + if (startMin >= schoolEnd) return true; + if (startMin <= schoolStart && endMin >= schoolEnd) return true; + return false; +} + +int dayIndex(DateTime t, DateTime weekStart) => + DateTime(t.year, t.month, t.day).difference(weekStart).inDays; + +class BoundRegion { + final TimeRegion region; + final DateTime start; + final DateTime end; + + BoundRegion({required this.region, required this.start, required this.end}); +} + +List expandRegionsForDay(List regions, DateTime day) { + final result = []; + final dayStart = DateTime(day.year, day.month, day.day); + for (final region in regions) { + final isRecurringDaily = + region.recurrenceRule != null && + region.recurrenceRule!.toUpperCase().contains('FREQ=DAILY'); + if (isRecurringDaily) { + final start = dayStart.add( + Duration( + hours: region.startTime.hour, + minutes: region.startTime.minute, + ), + ); + final end = dayStart.add( + Duration(hours: region.endTime.hour, minutes: region.endTime.minute), + ); + result.add(BoundRegion(region: region, start: start, end: end)); + } else if (region.startTime.isSameDay(day)) { + result.add( + BoundRegion( + region: region, + start: region.startTime, + end: region.endTime, + ), + ); + } + } + return result; +} + +/// Expands the given list of appointments across the visible 5-day work week +/// (resolving RRULE recurrences) and splits each day's events into two +/// buckets: those that fit within the school-hours grid (`inside`) and those +/// that don't (`outside` — all-day events and events that start before +/// [kCalendarStartHour] or end after [kCalendarEndHour]). The outside bucket +/// is rendered as chips above the grid. +({List> inside, List> outside}) +partitionAppointmentsForWeek( + List appointments, + DateTime weekStart, +) { + final inside = List>.generate(5, (_) => []); + final outside = List>.generate(5, (_) => []); + final weekEnd = weekStart.add(const Duration(days: 5)); + final weekStartUtc = weekStart.toUtc(); + final weekEndUtc = weekEnd.toUtc(); + + void place(int idx, Appointment a) { + if (isOutsideSchoolHours(a)) { + outside[idx].add(a); + } else { + inside[idx].add(a); + } + } + + for (final a in appointments) { + final rule = a.recurrenceRule; + if (rule == null || rule.isEmpty) { + final idx = dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); + continue; + } + try { + final parsed = RecurrenceRule.fromString(rule); + final anchorUtc = a.startTime.toUtc(); + final duration = a.endTime.difference(a.startTime); + for (final occUtc in parsed.getInstances(start: anchorUtc)) { + if (!occUtc.isBefore(weekEndUtc)) break; + if (occUtc.isBefore(weekStartUtc)) continue; + final occLocal = occUtc.toLocal(); + final idx = DateTime( + occLocal.year, + occLocal.month, + occLocal.day, + ).difference(weekStart).inDays; + if (idx < 0 || idx >= 5) continue; + final newStart = DateTime( + occLocal.year, + occLocal.month, + occLocal.day, + a.startTime.hour, + a.startTime.minute, + ); + place( + idx, + Appointment( + id: a.id, + startTime: newStart, + endTime: newStart.add(duration), + subject: a.subject, + color: a.color, + location: a.location, + notes: a.notes, + isAllDay: a.isAllDay, + ), + ); + } + } catch (_) { + final idx = dayIndex(a.startTime, weekStart); + if (idx >= 0 && idx < 5) place(idx, a); + } + } + return (inside: inside, outside: outside); +} + +/// Maps lesson periods to vertical screen positions. Every non-break period +/// gets the same fixed height (`lessonHeight`), every break gets `breakHeight`. +/// Short transition gaps (Wechselzeiten) between periods are not represented +/// at all — periods are rendered back-to-back, so a 5-minute gap simply +/// disappears visually. +class PeriodLayout { + final List periods; + final double lessonHeight; + final double breakHeight; + + const PeriodLayout({ + required this.periods, + required this.lessonHeight, + required this.breakHeight, + }); + + double _h(LessonPeriod p) => p.isBreak ? breakHeight : lessonHeight; + + double get totalHeight => periods.fold(0, (sum, p) => sum + _h(p)); + + double topOf(LessonPeriod period) { + var y = 0.0; + for (final p in periods) { + if (identical(p, period)) return y; + y += _h(p); + } + return y; + } + + double heightOf(LessonPeriod period) => _h(period); + + /// Vertical offset for a given time of day. Times inside a period are mapped + /// proportionally; times that fall into a transition gap are clipped to the + /// end of the preceding period. Times before the first / after the last + /// period clip to 0 / [totalHeight]. + double yOfDateTime(DateTime t) { + final tMin = t.hour * 60 + t.minute + t.second / 60.0; + var y = 0.0; + for (final p in periods) { + final pStart = p.start.hour * 60 + p.start.minute; + final pEnd = p.end.hour * 60 + p.end.minute; + final h = _h(p); + if (tMin < pStart) return y; + if (tMin <= pEnd) { + final span = pEnd - pStart; + final ratio = span > 0 ? (tMin - pStart) / span : 0.0; + return y + ratio * h; + } + y += h; + } + return y; + } + + /// Period at a given y-offset. If y falls into a break, returns the next + /// non-break period. Returns null when y is past the last period. + LessonPeriod? periodAtY(double y) { + var cursor = 0.0; + for (var i = 0; i < periods.length; i++) { + final p = periods[i]; + final h = _h(p); + if (y >= cursor && y < cursor + h) { + if (p.isBreak) { + for (var j = i + 1; j < periods.length; j++) { + if (!periods[j].isBreak) return periods[j]; + } + return null; + } + return p; + } + cursor += h; + } + return null; + } +} + +/// One cell rendered in the day column — either a regular appointment or an +/// overflow placeholder representing several hidden appointments. +sealed class LaidOutCell { + int get lane; + int get laneCount; + DateTime get startTime; + DateTime get endTime; +} + +class LaidOutAppointment extends LaidOutCell { + final Appointment appointment; + @override + final int lane; + @override + final int laneCount; + LaidOutAppointment(this.appointment, this.lane, this.laneCount); + + @override + DateTime get startTime => appointment.startTime; + @override + DateTime get endTime => appointment.endTime; +} + +class LaidOutOverflow extends LaidOutCell { + final List appointments; + @override + final int lane; + @override + final int laneCount; + @override + final DateTime startTime; + @override + final DateTime endTime; + LaidOutOverflow( + this.appointments, + this.lane, + this.laneCount, + this.startTime, + this.endTime, + ); +} + +/// Horizontal ordering rank for parallel appointments. Lower = further left. +/// User-owned custom events sit on the leftmost lane, cancelled lessons after +/// them, every other lesson last. Only used as a tiebreaker — the greedy lane +/// assignment still has to honor actual time-overlap constraints, so events +/// that start later can't jump left of events that started earlier and are +/// still occupying that lane. +int _appointmentPriority(Appointment a) { + final id = a.id; + if (id is CustomAppointment) return 0; + if (id is WebuntisAppointment && id.lesson.code == 'cancelled') return 1; + return 2; +} + +/// Assigns each appointment a lane index using a greedy sweep, then collapses +/// clusters that exceed [maxLanes] into `(maxLanes - 1)` visible appointments +/// + one trailing overflow cell. +/// +/// Greedy sweep: +/// 1. Sort by `startTime` ascending, then [_appointmentPriority] (custom → +/// cancelled → other) so parallel events land in the requested left-to- +/// right order, then `endTime` descending as a final tiebreaker. +/// 2. Walk the list, placing each appointment in the lowest-index lane that +/// is free at its `startTime`. When no lane is free, open a new one. +/// 3. A cluster ends as soon as every active lane's end is at or before the +/// next appointment's start. +List assignLanes( + List appts, { + required int maxLanes, +}) { + assert(maxLanes >= 2, 'maxLanes must reserve at least one slot for overflow'); + if (appts.isEmpty) return const []; + + final sorted = [...appts] + ..sort((a, b) { + final c = a.startTime.compareTo(b.startTime); + if (c != 0) return c; + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return b.endTime.compareTo(a.endTime); + }); + + // Phase 1: greedy lane assignment, grouped by cluster. + final clusters = >[]; + var current = <({Appointment apt, int lane})>[]; + var laneEnds = []; + + for (final apt in sorted) { + final allFree = + laneEnds.isNotEmpty && + laneEnds.every((end) => !end.isAfter(apt.startTime)); + if (allFree) { + clusters.add(current); + current = <({Appointment apt, int lane})>[]; + laneEnds = []; + } + + var laneIdx = -1; + for (var i = 0; i < laneEnds.length; i++) { + if (!laneEnds[i].isAfter(apt.startTime)) { + laneIdx = i; + break; + } + } + if (laneIdx == -1) { + laneIdx = laneEnds.length; + laneEnds.add(apt.endTime); + } else { + laneEnds[laneIdx] = apt.endTime; + } + + current.add((apt: apt, lane: laneIdx)); + } + if (current.isNotEmpty) clusters.add(current); + + // Phase 2: emit cells per cluster, collapsing if too wide. + final result = []; + for (final cluster in clusters) { + final laneCount = cluster.fold( + 0, + (m, e) => e.lane + 1 > m ? e.lane + 1 : m, + ); + + if (laneCount <= maxLanes) { + for (final entry in cluster) { + result.add(LaidOutAppointment(entry.apt, entry.lane, laneCount)); + } + } else { + // Too many parallel appointments: keep the highest-priority + // (maxLanes - 1) and collapse the rest into a single overflow cell in + // the trailing lane. Sorting by priority first means custom and + // cancelled lessons stay visible when the cluster has to be trimmed, + // matching the requested left-to-right order in the visible lanes. + final visibleCount = maxLanes - 1; + final byPriority = [...cluster.map((e) => e.apt)] + ..sort((a, b) { + final p = _appointmentPriority(a).compareTo(_appointmentPriority(b)); + if (p != 0) return p; + return a.startTime.compareTo(b.startTime); + }); + + for (var i = 0; i < visibleCount; i++) { + result.add(LaidOutAppointment(byPriority[i], i, maxLanes)); + } + + final overflow = byPriority.sublist(visibleCount); + var earliest = overflow.first.startTime; + var latest = overflow.first.endTime; + for (final a in overflow.skip(1)) { + if (a.startTime.isBefore(earliest)) earliest = a.startTime; + if (a.endTime.isAfter(latest)) latest = a.endTime; + } + result.add( + LaidOutOverflow(overflow, maxLanes - 1, maxLanes, earliest, latest), + ); + } + } + return result; +} diff --git a/lib/view/pages/timetable/data/lesson_color.dart b/lib/view/pages/timetable/data/lesson_color.dart new file mode 100644 index 0000000..03cda8f --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_color.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +import 'lesson_status.dart'; + +class LessonColor { + static const Color regular = Color.fromARGB(255, 153, 51, 51); + static const Color ongoing = Color.fromARGB(255, 200, 51, 51); + static const Color cancelled = Color(0xff000000); + static const Color irregular = Color(0xff8F19B3); + static const Color teacherChanged = Color(0xFF29639B); + static const Color event = Color(0xff2E7D32); + static const Color parseFallback = Color(0xff404040); + + static Color forStatus(LessonStatus status) { + switch (status) { + case LessonStatus.cancelled: + return cancelled; + case LessonStatus.event: + return event; + case LessonStatus.irregular: + return irregular; + case LessonStatus.teacherChanged: + return teacherChanged; + case LessonStatus.past: + case LessonStatus.regular: + return regular; + case LessonStatus.ongoing: + return ongoing; + } + } +} diff --git a/lib/view/pages/timetable/data/lesson_period_schedule.dart b/lib/view/pages/timetable/data/lesson_period_schedule.dart new file mode 100644 index 0000000..dfd9b5a --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_period_schedule.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; + +import '../../../../api/webuntis/queries/get_timegrid_units/get_timegrid_units_response.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; + +class LessonPeriod { + final String name; + final TimeOfDay start; + final TimeOfDay end; + final bool isBreak; + + const LessonPeriod({ + required this.name, + required this.start, + required this.end, + this.isBreak = false, + }); + + Duration get duration => Duration( + minutes: (end.hour * 60 + end.minute) - (start.hour * 60 + start.minute), + ); + + int get _startMinutes => start.hour * 60 + start.minute; +} + +class LessonPeriodSchedule { + final List periods; + + const LessonPeriodSchedule(this.periods); + + static LessonPeriodSchedule? fromApi(GetTimegridUnitsResponse response) { + final canonical = response.result.firstWhere( + (d) => d.day == 1, + orElse: () => response.result.isNotEmpty + ? response.result.first + : GetTimegridUnitsResponseDay(0, []), + ); + if (canonical.timeUnits.isEmpty) return null; + + final periods = + canonical.timeUnits + .map( + (u) => LessonPeriod( + name: u.name, + start: _fromHHMM(u.startTime), + end: _fromHHMM(u.endTime), + ), + ) + .toList() + ..sort((a, b) => a._startMinutes.compareTo(b._startMinutes)); + + return LessonPeriodSchedule(periods); + } + + static LessonPeriodSchedule fallback() => const LessonPeriodSchedule([ + LessonPeriod( + name: '0', + start: TimeOfDay(hour: 7, minute: 10), + end: TimeOfDay(hour: 7, minute: 53), + ), + LessonPeriod( + name: '1', + start: TimeOfDay(hour: 7, minute: 55), + end: TimeOfDay(hour: 8, minute: 40), + ), + LessonPeriod( + name: '2', + start: TimeOfDay(hour: 8, minute: 40), + end: TimeOfDay(hour: 9, minute: 25), + ), + LessonPeriod( + name: '3', + start: TimeOfDay(hour: 9, minute: 30), + end: TimeOfDay(hour: 10, minute: 15), + ), + LessonPeriod( + name: '4', + start: TimeOfDay(hour: 10, minute: 35), + end: TimeOfDay(hour: 11, minute: 20), + ), + LessonPeriod( + name: '5', + start: TimeOfDay(hour: 11, minute: 25), + end: TimeOfDay(hour: 12, minute: 10), + ), + LessonPeriod( + name: '6', + start: TimeOfDay(hour: 12, minute: 15), + end: TimeOfDay(hour: 13, minute: 0), + ), + LessonPeriod( + name: '7', + start: TimeOfDay(hour: 13, minute: 5), + end: TimeOfDay(hour: 13, minute: 50), + ), + LessonPeriod( + name: '8', + start: TimeOfDay(hour: 14, minute: 5), + end: TimeOfDay(hour: 14, minute: 50), + ), + LessonPeriod( + name: '9', + start: TimeOfDay(hour: 14, minute: 50), + end: TimeOfDay(hour: 15, minute: 35), + ), + LessonPeriod( + name: '10', + start: TimeOfDay(hour: 15, minute: 40), + end: TimeOfDay(hour: 16, minute: 25), + ), + LessonPeriod( + name: '11', + start: TimeOfDay(hour: 16, minute: 25), + end: TimeOfDay(hour: 17, minute: 10), + ), + ]); + + static LessonPeriodSchedule fromState(TimetableState state) { + final fromApi = state.timegrid != null + ? LessonPeriodSchedule.fromApi(state.timegrid!) + : null; + return (fromApi ?? fallback()).withSyntheticBreaks(); + } + + LessonPeriodSchedule withSyntheticBreaks() { + final result = []; + for (var i = 0; i < periods.length; i++) { + final current = periods[i]; + result.add(current); + if (i + 1 >= periods.length) continue; + final next = periods[i + 1]; + final gapMinutes = + next._startMinutes - (current.end.hour * 60 + current.end.minute); + if (gapMinutes >= 10) { + result.add( + LessonPeriod( + name: 'Pause', + start: current.end, + end: next.start, + isBreak: true, + ), + ); + } + } + return LessonPeriodSchedule(result); + } + + static TimeOfDay _fromHHMM(int hhmm) => + TimeOfDay(hour: hhmm ~/ 100, minute: hhmm % 100); +} diff --git a/lib/view/pages/timetable/data/lesson_status.dart b/lib/view/pages/timetable/data/lesson_status.dart new file mode 100644 index 0000000..6f63937 --- /dev/null +++ b/lib/view/pages/timetable/data/lesson_status.dart @@ -0,0 +1,36 @@ +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; + +enum LessonStatus { + cancelled, + event, + irregular, + teacherChanged, + past, + ongoing, + regular, +} + +class LessonStatusClassifier { + static LessonStatus classify( + GetTimetableResponseObject lesson, + DateTime startTime, + DateTime endTime, + DateTime now, { + bool isEvent = false, + }) { + if (lesson.code == 'cancelled') return LessonStatus.cancelled; + if (isEvent) return LessonStatus.event; + if (lesson.code == 'irregular' || + (lesson.te.isNotEmpty && lesson.te.first.id == 0)) { + return LessonStatus.irregular; + } + if (lesson.te.any((t) => t.orgname != null)) { + return LessonStatus.teacherChanged; + } + if (endTime.isBefore(now)) return LessonStatus.past; + if (startTime.isBefore(now) && endTime.isAfter(now)) { + return LessonStatus.ongoing; + } + return LessonStatus.regular; + } +} diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart new file mode 100644 index 0000000..cbce1a1 --- /dev/null +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -0,0 +1,235 @@ +import 'package:collection/collection.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import '../../../../api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../../../../storage/timetable_settings.dart'; +import '../custom_events/custom_event_colors.dart'; +import 'arbitrary_appointment.dart'; +import 'lesson_color.dart'; +import 'lesson_status.dart'; +import 'timetable_name_mode.dart'; +import 'webuntis_time.dart'; + +class TimetableAppointmentFactory { + final List lessons; + final List customEvents; + final GetRoomsResponse rooms; + final GetSubjectsResponse subjects; + final TimetableSettings settings; + final DateTime now; + + TimetableAppointmentFactory({ + required this.lessons, + required this.customEvents, + required this.rooms, + required this.subjects, + required this.settings, + required this.now, + }); + + List build() { + final source = settings.connectDoubleLessons + ? _mergeAdjacentLessons(lessons) + : lessons; + return [ + ...source.map(_lessonToAppointment), + ...customEvents.map(_customEventToAppointment), + ]; + } + + Appointment _lessonToAppointment(GetTimetableResponseObject lesson) { + try { + final startTime = WebuntisTime.parse(lesson.date, lesson.startTime); + final endTime = WebuntisTime.parse(lesson.date, lesson.endTime); + final subject = subjects.result.firstWhereOrNull( + (s) => s.id == lesson.su.firstOrNull?.id, + ); + final status = LessonStatusClassifier.classify( + lesson, + startTime, + endTime, + now, + isEvent: subject == null, + ); + + return Appointment( + id: WebuntisAppointment(lesson), + startTime: startTime, + endTime: endTime, + subject: _subjectName(lesson, subject), + location: _locationLabel(lesson), + notes: lesson.activityType, + color: LessonColor.forStatus(status), + ); + } catch (_) { + return Appointment( + id: WebuntisAppointment(lesson), + startTime: WebuntisTime.parse(lesson.date, lesson.startTime), + endTime: WebuntisTime.parse(lesson.date, lesson.endTime), + subject: 'Änderung', + notes: lesson.info, + location: 'Unbekannt', + color: LessonColor.parseFallback, + startTimeZone: '', + endTimeZone: '', + ); + } + } + + Appointment _customEventToAppointment(CustomTimetableEvent event) { + final allDay = isCustomEventAllDay(event); + return Appointment( + id: CustomAppointment(event), + startTime: event.startDate, + endTime: allDay + ? DateTime( + event.startDate.year, + event.startDate.month, + event.startDate.day, + 23, + 59, + ) + : event.endDate, + isAllDay: allDay, + // Preserve user-entered newlines in descriptions; the tile soft-wraps to + // fill the available height. For lessons we still collapse whitespace + // so room/teacher stay on one line each. + location: event.description.trim().isEmpty + ? null + : event.description.trim(), + subject: _collapseWhitespace(event.title) ?? event.title, + recurrenceRule: event.rrule, + color: TimetableColors.getColorFromString( + event.color ?? TimetableColors.defaultColor.name, + ), + startTimeZone: '', + endTimeZone: '', + ); + } + + /// All-day convention: a `CustomTimetableEvent` is treated as all-day when + /// its `startDate` and `endDate` both land on midnight of the same day. + /// Keeps the backend schema unchanged — the editor stores all-day events as + /// `start == end == midnight(date)`. + static bool isCustomEventAllDay(CustomTimetableEvent event) { + final s = event.startDate; + final e = event.endDate; + return s.year == e.year && + s.month == e.month && + s.day == e.day && + s.hour == 0 && + s.minute == 0 && + s.second == 0 && + e.hour == 0 && + e.minute == 0 && + e.second == 0; + } + + String _subjectName( + GetTimetableResponseObject lesson, + GetSubjectsResponseObject? subject, + ) { + if (subject == null) return 'Event'; + final name = switch (settings.timetableNameMode) { + TimetableNameMode.name => subject.name, + TimetableNameMode.longName => subject.longName, + TimetableNameMode.alternateName => subject.alternateName, + }; + return _collapseWhitespace(name) ?? 'Event'; + } + + String _locationLabel(GetTimetableResponseObject lesson) { + final roomName = + _collapseWhitespace( + rooms.result + .firstWhereOrNull((r) => r.id == lesson.ro.firstOrNull?.id) + ?.name, + ) ?? + 'Unbekannt'; + final teacherName = + _collapseWhitespace(lesson.te.firstOrNull?.longname) ?? 'Unbekannt'; + return '$roomName\n$teacherName'; + } + + /// Collapses any line-break or whitespace run to a single space and trims. + /// Returns null when input is null or fully whitespace. Webuntis sometimes + /// returns multi-line room names like "A30\n4" — this normalizes those so + /// the tile renders the room on a single line. + static String? _collapseWhitespace(String? s) { + if (s == null) return null; + final cleaned = s + .replaceAll('\r\n', ' ') + .replaceAll('\n', ' ') + .replaceAll('\r', ' ') + .replaceAll('\t', ' ') + .replaceAll(RegExp(r'\s+'), ' ') + .trim(); + return cleaned.isEmpty ? null : cleaned; + } + + // Pure: returns a new list of fresh objects, does not mutate input. + // (The previous version replaced `previous.endTime` in place, which + // mutated the original lesson object passed in via [input]. Across + // rebuilds those mutated lessons were observed again by the next merge + // pass — extending lessons further or, after the overlap-gap guard was + // added to [_canMerge], even causing the second half of a double lesson + // to be emitted alongside the already-merged block.) + static List _mergeAdjacentLessons( + List input, { + Duration maxGap = const Duration(minutes: 5), + }) { + if (input.isEmpty) return const []; + + final sorted = [...input] + ..sort( + (a, b) => WebuntisTime.parse( + a.date, + a.startTime, + ).compareTo(WebuntisTime.parse(b.date, b.startTime)), + ); + + final merged = []; + for (final current in sorted) { + if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) { + // `merged.last` is always a copy we created below, so mutating its + // endTime is safe and keeps the next iteration's gap check correct. + merged.last.endTime = current.endTime; + } else { + merged.add(_copyLesson(current)); + } + } + return merged; + } + + static GetTimetableResponseObject _copyLesson(GetTimetableResponseObject l) => + GetTimetableResponseObject.fromJson(l.toJson()); + + static bool _canMerge( + GetTimetableResponseObject a, + GetTimetableResponseObject b, + Duration maxGap, + ) { + final aSubject = a.su.firstOrNull?.id; + final bSubject = b.su.firstOrNull?.id; + if (aSubject == null || bSubject == null || aSubject != bSubject) { + return false; + } + if (a.ro.firstOrNull?.id != b.ro.firstOrNull?.id) return false; + if (a.te.firstOrNull?.id != b.te.firstOrNull?.id) return false; + if (a.code != b.code) return false; + + // Merge only sequential lessons (b starts at or after a ends, within the + // tolerance). Without the lower bound, identical-metadata lessons that + // overlap in time would silently collapse into one — and because the + // merge sets `previous.endTime = current.endTime`, an overlapping merge + // can even truncate the earlier lesson. + final gap = WebuntisTime.parse( + b.date, + b.startTime, + ).difference(WebuntisTime.parse(a.date, a.endTime)); + return !gap.isNegative && gap <= maxGap; + } +} diff --git a/lib/view/pages/timetable/data/timetable_name_mode.dart b/lib/view/pages/timetable/data/timetable_name_mode.dart new file mode 100644 index 0000000..39e3e24 --- /dev/null +++ b/lib/view/pages/timetable/data/timetable_name_mode.dart @@ -0,0 +1,27 @@ +import 'package:flutter/material.dart'; + +import '../../../../widget/dropdown_display.dart'; + +enum TimetableNameMode { name, longName, alternateName } + +class TimetableNameModes { + static DropdownDisplay getDisplayOptions(TimetableNameMode mode) { + switch (mode) { + case TimetableNameMode.name: + return DropdownDisplay( + icon: Icons.device_unknown_outlined, + displayName: 'Name', + ); + case TimetableNameMode.longName: + return DropdownDisplay( + icon: Icons.perm_device_info_outlined, + displayName: 'Langname', + ); + case TimetableNameMode.alternateName: + return DropdownDisplay( + icon: Icons.on_device_training_outlined, + displayName: 'Kurzform', + ); + } + } +} diff --git a/lib/view/pages/timetable/data/webuntis_time.dart b/lib/view/pages/timetable/data/webuntis_time.dart new file mode 100644 index 0000000..da9ff04 --- /dev/null +++ b/lib/view/pages/timetable/data/webuntis_time.dart @@ -0,0 +1,16 @@ +import 'package:intl/intl.dart'; + +class WebuntisTime { + static final DateFormat _dateFormat = DateFormat('yyyyMMdd'); + + static DateTime parse(int date, int time) { + final timeString = time.toString().padLeft(4, '0'); + return DateTime.parse( + '$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}', + ); + } + + static int formatDate(DateTime date) => int.parse(_dateFormat.format(date)); + + static String dateKey(DateTime date) => _dateFormat.format(date); +} diff --git a/lib/view/pages/timetable/details/appointment_details_dispatcher.dart b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart new file mode 100644 index 0000000..f1ce427 --- /dev/null +++ b/lib/view/pages/timetable/details/appointment_details_dispatcher.dart @@ -0,0 +1,24 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../data/arbitrary_appointment.dart'; +import 'custom_event_sheet.dart'; +import 'webuntis_lesson_sheet.dart'; + +class AppointmentDetailsDispatcher { + static void show( + BuildContext context, + TimetableBloc bloc, + Appointment appointment, + ) { + final id = appointment.id; + if (id is! ArbitraryAppointment) return; + + id.when( + webuntis: (lesson) => + WebuntisLessonSheet.show(context, bloc, appointment, lesson), + custom: (event) => CustomEventSheet.show(context, event), + ); + } +} diff --git a/lib/view/pages/timetable/details/custom_event_sheet.dart b/lib/view/pages/timetable/details/custom_event_sheet.dart new file mode 100644 index 0000000..dc7b6d5 --- /dev/null +++ b/lib/view/pages/timetable/details/custom_event_sheet.dart @@ -0,0 +1,100 @@ +import 'package:flutter/material.dart'; +import 'package:rrule/rrule.dart'; + +import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../widget/centered_leading.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../custom_events/custom_event_edit_dialog.dart'; +import 'delete_custom_event.dart'; + +class CustomEventSheet { + static void show(BuildContext context, CustomTimetableEvent event) { + final timeRange = event.startDate.timeRangeTo(event.endDate); + + showDetailsBottomSheet( + context, + header: ListTile( + leading: const Icon(Icons.event_outlined, size: 32), + title: Text( + event.title, + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text(timeRange), + ), + children: (sheetCtx) => [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Center( + child: Wrap( + children: [ + TextButton.icon( + onPressed: () { + Navigator.of(sheetCtx).pop(); + showDialog( + context: context, + builder: (_) => + CustomEventEditDialog(existingEvent: event), + ); + }, + label: const Text('Bearbeiten'), + icon: const Icon(Icons.edit_outlined), + ), + TextButton.icon( + onPressed: () { + showDeleteCustomEventDialog(context, event).future.then(( + _, + ) { + if (!sheetCtx.mounted) return; + Navigator.of(sheetCtx).pop(); + }); + }, + label: const Text('Löschen'), + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + ), + const Divider(height: 1), + ListTile( + leading: const Icon(Icons.info_outline), + title: Text( + event.description.isEmpty + ? 'Keine Beschreibung' + : event.description, + ), + ), + ListTile( + leading: const CenteredLeading(Icon(Icons.repeat_outlined)), + title: Text( + 'Serie: ${event.rrule.isNotEmpty ? "Wiederholend" : "Einmalig"}', + ), + subtitle: FutureBuilder( + future: RruleL10nEn.create(), + builder: (_, snapshot) { + if (event.rrule.isEmpty) { + return const Text('Keine weiteren Vorkommnisse'); + } + if (snapshot.data == null) return const Text('...'); + final rrule = RecurrenceRule.fromString(event.rrule); + if (!rrule.canFullyConvertToText) { + return const Text('Keine genauere Angabe möglich.'); + } + return Text(rrule.toText(l10n: snapshot.data!)); + }, + ), + ), + DebugTile(sheetCtx).child( + ListTile( + leading: const CenteredLeading(Icon(Icons.rule)), + title: const Text('RRule'), + subtitle: Text(event.rrule.isEmpty ? 'Keine' : event.rrule), + ), + ), + DebugTile(sheetCtx).jsonData(event.toJson()), + ], + ); + } +} diff --git a/lib/view/pages/timetable/details/delete_custom_event.dart b/lib/view/pages/timetable/details/delete_custom_event.dart new file mode 100644 index 0000000..33d186c --- /dev/null +++ b/lib/view/pages/timetable/details/delete_custom_event.dart @@ -0,0 +1,27 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +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'; + +Completer showDeleteCustomEventDialog( + BuildContext context, + CustomTimetableEvent event, +) { + final completer = Completer(); + final bloc = context.read(); + ConfirmDialog( + title: 'Termin löschen', + content: + 'Der ${event.rrule.isEmpty ? "Termin" : "Serientermin"} wird unwiederruflich gelöscht.', + confirmButton: 'Löschen', + onConfirmAsync: () async { + await bloc.removeCustomEvent(event.id); + completer.complete(); + }, + ).asDialog(context); + return completer; +} diff --git a/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart new file mode 100644 index 0000000..a5cd101 --- /dev/null +++ b/lib/view/pages/timetable/details/webuntis_lesson_sheet.dart @@ -0,0 +1,221 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import '../../../../api/webuntis/services/lesson_resolver.dart'; +import '../../../../extensions/date_time.dart'; +import '../../../../extensions/text.dart'; +import '../../../../routing/app_routes.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart'; +import '../../../../state/app/modules/timetable/bloc/timetable_state.dart'; +import '../../../../widget/debug/debug_tile.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../../../../widget/unimplemented_dialog.dart'; + +class WebuntisLessonSheet { + static void show( + BuildContext context, + TimetableBloc bloc, + Appointment appointment, + GetTimetableResponseObject lesson, + ) { + final state = bloc.state.data; + if (state == null) return; + + final headerSubject = LessonResolver.resolveSubject( + state, + lesson.su.firstOrNull?.id, + ); + final headerTitle = firstNonEmpty([ + headerSubject.alternateName, + headerSubject.name, + headerSubject.longName, + '?', + ]); + final headerLongName = + headerSubject.longName.isNotEmpty && + headerSubject.longName != headerTitle + ? headerSubject.longName + : ''; + + final timeRange = appointment.startTime.timeRangeTo(appointment.endTime); + + showDetailsBottomSheet( + context, + header: ListTile( + leading: Icon(LessonFormatter.iconForCode(lesson.code), size: 32), + title: Text( + '${LessonFormatter.codePrefix(lesson.code)}$headerTitle', + style: const TextStyle(fontWeight: FontWeight.bold), + ), + subtitle: Text( + headerLongName.isNotEmpty ? '$timeRange\n$headerLongName' : timeRange, + ), + isThreeLine: headerLongName.isNotEmpty, + ), + children: (_) => [ + ListTile( + leading: const Icon(Icons.notifications_active), + title: Text('Status: ${LessonFormatter.statusLabel(lesson.code)}'), + ), + if (lesson.su.length > 1) + _listTile( + icon: Icons.book_outlined, + label: 'Fächer', + entries: lesson.su.map((s) { + final resolved = LessonResolver.resolveSubject(state, s.id); + return LessonFormatter.formatLine( + firstNonEmpty([resolved.name, s.name, '?']), + longname: firstNonEmpty([resolved.longName, s.longname, '']), + ); + }).toList(), + ), + _roomTile(context, state, lesson), + _teacherTile(context, lesson), + if ((lesson.activityType ?? '').trim().isNotEmpty) + ListTile( + leading: const Icon(Icons.abc), + title: Text('Typ: ${lesson.activityType}'), + ), + if (lesson.kl.isNotEmpty) + _listTile( + icon: Icons.people, + label: lesson.kl.length == 1 ? 'Klasse' : 'Klassen', + entries: lesson.kl + .map( + (k) => LessonFormatter.formatLine( + k.name.isNotEmpty ? k.name : '?', + longname: k.longname, + ), + ) + .toList(), + ), + ..._optionalTextTiles(lesson), + DebugTile(context).jsonData(lesson.toJson()), + ], + ); + } + + static Widget _roomTile( + BuildContext context, + TimetableState state, + GetTimetableResponseObject lesson, + ) { + final trailing = IconButton( + icon: const Icon(Icons.house_outlined), + onPressed: () => AppRoutes.openRoomplan(context), + ); + + if (lesson.ro.isEmpty) { + return ListTile( + leading: const Icon(Icons.room), + title: const Text('Raum: ?'), + trailing: trailing, + ); + } + + final entries = lesson.ro.map((r) { + final resolved = LessonResolver.resolveRoom(state, r.id); + final name = firstNonEmpty([resolved.name, r.name, '?']); + final longname = firstNonEmpty([resolved.longName, r.longname, '']); + final building = resolved.building.trim(); + return LessonFormatter.formatLine( + name, + longname: longname, + extra: (building.isNotEmpty && building != '?') ? building : null, + ); + }).toList(); + + return _listTile( + icon: Icons.room, + label: lesson.ro.length == 1 ? 'Raum' : 'Räume', + entries: entries, + trailing: trailing, + ); + } + + static Widget _teacherTile( + BuildContext context, + GetTimetableResponseObject lesson, + ) { + final trailing = Visibility( + visible: !kReleaseMode, + child: IconButton( + icon: const Icon(Icons.textsms_outlined), + onPressed: () => UnimplementedDialog.show(context), + ), + ); + + if (lesson.te.isEmpty) { + return ListTile( + leading: const Icon(Icons.person), + title: const Text('Lehrkraft: ?'), + trailing: trailing, + ); + } + + final entries = lesson.te.map((t) { + final base = LessonFormatter.formatLine( + t.name.isNotEmpty ? t.name : '?', + longname: t.longname, + ); + final orgname = (t.orgname ?? '').trim(); + return orgname.isEmpty ? base : '$base · ehemals $orgname'; + }).toList(); + + return _listTile( + icon: Icons.person, + label: lesson.te.length == 1 ? 'Lehrkraft' : 'Lehrkräfte', + entries: entries, + trailing: trailing, + ); + } + + static Widget _listTile({ + required IconData icon, + required String label, + required List entries, + Widget? trailing, + }) { + if (entries.length == 1) { + return ListTile( + leading: Icon(icon), + title: Text('$label: ${entries.first}'), + trailing: trailing, + ); + } + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: entries.map(Text.new).toList(), + ), + trailing: trailing, + ); + } + + static List _optionalTextTiles(GetTimetableResponseObject lesson) { + return [ + _textTile(Icons.info_outline, 'Info', lesson.info), + _textTile(Icons.swap_horiz, 'Vertretungstext', lesson.substText), + _textTile(Icons.subject, 'Stundentext', lesson.lstext), + _textTile(Icons.category_outlined, 'Stundentyp', lesson.lstype), + _textTile(Icons.flag_outlined, 'Statusmerkmale', lesson.statflags), + _textTile(Icons.school_outlined, 'Lerngruppe', lesson.sg), + _textTile(Icons.bookmark_outline, 'Buchungshinweis', lesson.bkRemark), + _textTile(Icons.notes, 'Buchungstext', lesson.bkText), + ].whereType().toList(); + } + + static Widget? _textTile(IconData icon, String label, String? value) { + final text = (value ?? '').trim(); + if (text.isEmpty || text == '-') return null; + return ListTile( + leading: Icon(icon), + title: Text(label), + subtitle: Text(text), + ); + } +} diff --git a/lib/view/pages/timetable/timetable.dart b/lib/view/pages/timetable/timetable.dart index 208551c..6b7c8f3 100644 --- a/lib/view/pages/timetable/timetable.dart +++ b/lib/view/pages/timetable/timetable.dart @@ -1,26 +1,23 @@ -import 'dart:async'; - -import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; -import '../../../extensions/dateTime.dart'; -import 'package:provider/provider.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -import '../../../api/webuntis/queries/getHolidays/getHolidaysResponse.dart'; -import '../../../api/webuntis/queries/getTimetable/getTimetableResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../storage/base/settingsProvider.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import 'appointmenetComponent.dart'; -import 'appointmentDetails.dart'; -import 'arbitraryAppointment.dart'; -import 'customTimetableColors.dart'; -import 'customTimetableEventEditDialog.dart'; -import 'timeRegionComponent.dart'; -import 'timetableEvents.dart'; -import 'timetableNameMode.dart'; -import 'viewCustomTimetableEvents.dart'; +import '../../../extensions/date_time.dart'; +import '../../../routing/app_routes.dart'; +import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart'; +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 '../../../storage/timetable_settings.dart'; +import 'custom_events/custom_event_edit_dialog.dart'; +import 'data/arbitrary_appointment.dart'; +import 'data/lesson_period_schedule.dart'; +import 'data/timetable_appointment_factory.dart'; +import 'details/appointment_details_dispatcher.dart'; +import 'widgets/custom_workweek_calendar.dart'; +import 'widgets/special_regions_builder.dart'; + +enum _CalendarAction { addEvent, viewEvents } class Timetable extends StatefulWidget { const Timetable({super.key}); @@ -29,362 +26,153 @@ class Timetable extends StatefulWidget { State createState() => _TimetableState(); } -enum CalendarActions { addEvent, viewEvents } - class _TimetableState extends State { - CalendarController controller = CalendarController(); - late Timer updateTimings; - late final SettingsProvider settings; + final GlobalKey _calendarKey = + GlobalKey(); - @override - void initState() { - settings = Provider.of(context, listen: false); + List? _cachedAppointments; + int? _lastDataVersion; + TimetableSettings? _lastTimetableSettings; - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).run(); - }); + DateTime _initialDisplayDate() => DateTime.now().add(const Duration(days: 2)); - controller.displayDate = DateTime.now().add(const Duration(days: 2)); + void _jumpToToday() { + _calendarKey.currentState?.jumpToDate(_initialDisplayDate()); + } - updateTimings = Timer.periodic(const Duration(seconds: 30), (Timer t) => setState((){})); + void _onAction(_CalendarAction action) { + switch (action) { + case _CalendarAction.addEvent: + showDialog( + context: context, + builder: (_) => const CustomEventEditDialog(), + barrierDismissible: false, + ); + case _CalendarAction.viewEvents: + AppRoutes.openCustomEvents(context); + } + } - super.initState(); + List _appointments(TimetableState state) { + final timetableSettings = context + .watch() + .val() + .timetableSettings; + if (_cachedAppointments != null && + _lastDataVersion == state.dataVersion && + identical(_lastTimetableSettings, timetableSettings)) { + return _cachedAppointments!; + } + _lastDataVersion = state.dataVersion; + _lastTimetableSettings = timetableSettings; + + return _cachedAppointments = TimetableAppointmentFactory( + lessons: state.getAllKnownLessons().toList(), + customEvents: state.customEvents?.events ?? const [], + rooms: state.rooms!, + subjects: state.subjects!, + settings: timetableSettings, + now: DateTime.now(), + ).build(); + } + + bool _isCrossedOut(Appointment appointment) { + final id = appointment.id; + if (id is WebuntisAppointment) return id.lesson.code == 'cancelled'; + return false; + } + + bool _isOnInitialWeek(TimetableState state) { + final target = _initialDisplayDate(); + final targetMonday = target.subtract(Duration(days: target.weekday - 1)); + final mondayOnly = DateTime( + targetMonday.year, + targetMonday.month, + targetMonday.day, + ); + return state.startDate == mondayOnly; } @override - Widget build(BuildContext context) => Scaffold( + Widget build(BuildContext context) { + final bloc = context.read(); + final loadableState = context.watch().state; + final innerState = loadableState.data; + final atToday = innerState != null && _isOnInitialWeek(innerState); + return Scaffold( appBar: AppBar( title: const Text('Stunden & Vertretungsplan'), actions: [ IconButton( - icon: const Icon(Icons.home_outlined), - onPressed: () { - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - } + icon: const Icon(Icons.home_outlined), + onPressed: atToday ? null : _jumpToToday, ), - PopupMenuButton( + PopupMenuButton<_CalendarAction>( icon: const Icon(Icons.edit_calendar_outlined), - itemBuilder: (context) => CalendarActions.values.map( - (e) { - String title; - Icon icon; - switch(e) { - case CalendarActions.addEvent: - title = 'Kalendereintrag hinzufügen'; - icon = const Icon(Icons.add); - case CalendarActions.viewEvents: - title = 'Kalendereinträge anzeigen'; - icon = const Icon(Icons.perm_contact_calendar_outlined); - } - return PopupMenuItem( - value: e, - child: ListTile( - title: Text(title), - leading: icon, - ) - ); - } - ).toList(), - onSelected: (value) { - switch(value) { - case CalendarActions.addEvent: - showDialog( - context: context, - builder: (context) => const CustomTimetableEventEditDialog(), - barrierDismissible: false, - ); - case CalendarActions.viewEvents: - Navigator.of(context).push(MaterialPageRoute(builder: (context) => const ViewCustomTimetableEvents())); - } - }, - ) + onSelected: _onAction, + itemBuilder: (_) => const [ + PopupMenuItem( + value: _CalendarAction.addEvent, + child: ListTile( + title: Text('Kalendereintrag hinzufügen'), + leading: Icon(Icons.add), + ), + ), + PopupMenuItem( + value: _CalendarAction.viewEvents, + child: ListTile( + title: Text('Kalendereinträge anzeigen'), + leading: Icon(Icons.perm_contact_calendar_outlined), + ), + ), + ], + ), ], ), - body: Consumer( - builder: (context, value, child) { - - if(value.hasError) { - return PlaceholderView( - icon: Icons.calendar_month, - text: 'Webuntis error: ${value.error.toString()}', - button: TextButton( - child: const Text('Neu laden'), - onPressed: () { - controller.displayDate = DateTime.now().add(const Duration(days: 2)); - Provider.of(context, listen: false).resetWeek(); - }, - ), - ); - } - - if(value.primaryLoading()) return const LoadingSpinner(); - - var holidays = value.getHolidaysResponse; - - return RefreshIndicator( - child: SfCalendar( - timeZone: 'W. Europe Standard Time', - view: CalendarView.workWeek, - dataSource: _buildTableEvents(value), - - maxDate: DateTime.now().add(const Duration(days: 7)).nextWeekday(DateTime.saturday), - minDate: DateTime.now().subtract(const Duration (days: 14)).nextWeekday(DateTime.sunday), - - controller: controller, - - onViewChanged: (ViewChangedDetails details) { - WidgetsBinding.instance.addPostFrameCallback((timeStamp) { - Provider.of(context, listen: false).updateWeek(details.visibleDates.first, details.visibleDates.last); - }); - }, - - onTap: (calendarTapDetails) { - if(calendarTapDetails.appointments == null) return; - Appointment tapped = calendarTapDetails.appointments!.first; - AppointmentDetails.show(context, value, tapped); - }, - - firstDayOfWeek: DateTime.monday, - specialRegions: _buildSpecialTimeRegions(holidays), - timeSlotViewSettings: const TimeSlotViewSettings( - startHour: 07.5, - endHour: 16.5, - timeInterval: Duration(minutes: 30), - timeFormat: 'HH:mm', - dayFormat: 'EE', - timeIntervalHeight: 40, - ), - - timeRegionBuilder: (BuildContext context, TimeRegionDetails timeRegionDetails) => TimeRegionComponent(details: timeRegionDetails), - appointmentBuilder: (BuildContext context, CalendarAppointmentDetails details) => AppointmentComponent( - details: details, - crossedOut: _isCrossedOut(details) - ), - - headerHeight: 0, - selectionDecoration: const BoxDecoration(), - - allowAppointmentResize: false, - allowDragAndDrop: false, - allowViewNavigation: false, - ), - onRefresh: () async { - Provider.of(context, listen: false).run(renew: true); - return Future.delayed(const Duration(seconds: 3)); - } - ); - }, + body: LoadableStateConsumer( + child: (state, _) => _calendar(state, bloc), ), ); - - @override - void dispose() { - updateTimings.cancel(); - super.dispose(); } - List _buildSpecialTimeRegions(GetHolidaysResponse holidays) { - var lastMonday = DateTime.now().subtract(const Duration(days: 14)).nextWeekday(DateTime.monday); - var firstBreak = lastMonday.copyWith(hour: 10, minute: 15); - var secondBreak = lastMonday.copyWith(hour: 13, minute: 50); + Widget _calendar(TimetableState state, TimetableBloc bloc) { + if (!state.hasReferenceData) return const SizedBox.shrink(); - var holidayList = holidays.result.map((holiday) { - var startDay = _parseWebuntisTimestamp(holiday.startDate, 0); - var dayCount = _parseWebuntisTimestamp(holiday.endDate, 0) - .difference(startDay) - .inDays; - var days = List.generate(dayCount, (index) => startDay.add(Duration(days: index))); + final schedule = LessonPeriodSchedule.fromState(state); + final appointments = _appointments(state); + final regions = SpecialRegionsBuilder( + holidays: state.schoolHolidays!, + schedule: schedule, + colorScheme: Theme.of(context).colorScheme, + disabledColor: Theme.of(context).disabledColor, + ).build(); - return days.map((holidayDay) => TimeRegion( - startTime: holidayDay.copyWith(hour: 07, minute: 55), - endTime: holidayDay.copyWith(hour: 16, minute: 30), - text: 'holiday:${holiday.name}', - color: Theme - .of(context) - .disabledColor - .withAlpha(50), - iconData: Icons.holiday_village_outlined - )); - }).expand((e) => e); - - bool isInHoliday(DateTime time) => holidayList.any((element) => element.startTime.isSameDay(time)); - - return [ - ...holidayList, - - if(!isInHoliday(firstBreak)) - TimeRegion( - startTime: firstBreak, - endTime: firstBreak.add(const Duration(minutes: 20)), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', - text: 'centerIcon', - color: Theme.of(context).primaryColor.withAlpha(50), - iconData: Icons.restaurant - ), - - if(!isInHoliday(secondBreak)) - TimeRegion( - startTime: secondBreak, - endTime: secondBreak.add(const Duration(minutes: 15)), - recurrenceRule: 'FREQ=DAILY;INTERVAL=1', - text: 'centerIcon', - color: Theme.of(context).primaryColor.withAlpha(50), - iconData: Icons.restaurant - ), - ]; + return CustomWorkWeekCalendar( + key: _calendarKey, + schedule: schedule, + appointments: appointments, + timeRegions: regions, + initialDate: _initialDisplayDate(), + minDate: DateTime.now() + .subtract(const Duration(days: 14)) + .nextWeekday(DateTime.sunday), + maxDate: DateTime.now() + .add(const Duration(days: 7)) + .nextWeekday(DateTime.saturday), + onAppointmentTap: (apt) => + AppointmentDetailsDispatcher.show(context, bloc, apt), + onWeekChanged: (start, end) => bloc.changeWeek(start, end), + isCrossedOut: _isCrossedOut, + onCreateEvent: _onCreateEventAt, + ); } - List _removeDuplicates(TimetableProps data, Duration maxTimeBetweenDouble) { - - var timetableList = data.getTimetableResponse.result.toList(); - - if(timetableList.isEmpty) return timetableList; - - timetableList.sort((a, b) => _parseWebuntisTimestamp(a.date, a.startTime).compareTo(_parseWebuntisTimestamp(b.date, b.startTime))); - - var previousElement = timetableList.first; - for(var i = 1; i < timetableList.length; i++) { - var currentElement = timetableList.elementAt(i); - - bool isSameLesson() { - var currentSubjectId = currentElement.su.firstOrNull?.id; - var previousSubjectId = previousElement.su.firstOrNull?.id; - - if(currentSubjectId == null || previousSubjectId == null || currentSubjectId != previousSubjectId) return false; - - var currentRoomId = currentElement.ro.firstOrNull?.id; - var previousRoomId = previousElement.ro.firstOrNull?.id; - - if(currentRoomId != previousRoomId) return false; - - var currentTeacherId = currentElement.te.firstOrNull?.id; - var previousTeacherId = previousElement.te.firstOrNull?.id; - - if(currentTeacherId != previousTeacherId) return false; - - var currentStatusCode = currentElement.code; - var previousStatusCode = previousElement.code; - - if(currentStatusCode != previousStatusCode) return false; - - return true; - } - - bool isNotSeparated() => _parseWebuntisTimestamp(previousElement.date, previousElement.endTime).add(maxTimeBetweenDouble) - .isSameOrAfter(_parseWebuntisTimestamp(currentElement.date, currentElement.startTime)); - - if(isSameLesson() && isNotSeparated()) { - previousElement.endTime = currentElement.endTime; - timetableList.remove(currentElement); - i--; - } else { - previousElement = currentElement; - } - } - - return timetableList; - } - - TimetableEvents _buildTableEvents(TimetableProps data) { - - var timetableList = data.getTimetableResponse.result.toList(); - - if(settings.val().timetableSettings.connectDoubleLessons) { - timetableList = _removeDuplicates(data, const Duration(minutes: 5)); - } - - var appointments = timetableList.map((element) { - - var rooms = data.getRoomsResponse; - var subjects = data.getSubjectsResponse; - - try { - var startTime = _parseWebuntisTimestamp(element.date, element.startTime); - var endTime = _parseWebuntisTimestamp(element.date, element.endTime); - - var subject = subjects.result.firstWhereOrNull((subject) => subject.id == element.su.firstOrNull?.id); - var subjectName = 'Unbekannt'; - if(subject != null) { - subjectName = { - TimetableNameMode.name: subject.name, - TimetableNameMode.longName: subject.longName, - TimetableNameMode.alternateName: subject.alternateName, - }[settings.val().timetableSettings.timetableNameMode]!; - } - - return Appointment( - id: ArbitraryAppointment(webuntis: element), - startTime: startTime, - endTime: endTime, - subject: subjectName, - location: '' - '${rooms.result.firstWhereOrNull((room) => room.id == element.ro.firstOrNull?.id)?.name ?? 'Unbekannt'}' - '\n' - '${element.te.firstOrNull?.longname ?? 'Unbekannt'}', - notes: element.activityType, - color: _getEventColor(element, startTime, endTime), - ); - } catch(e) { - var endTime = _parseWebuntisTimestamp(element.date, element.endTime); - return Appointment( - id: ArbitraryAppointment(webuntis: element), - startTime: _parseWebuntisTimestamp(element.date, element.startTime), - endTime: endTime, - subject: 'Änderung', - notes: element.info, - location: 'Unbekannt', - color: const Color(0xff404040), - startTimeZone: '', - endTimeZone: '', - ); - } - }).toList(); - - appointments.addAll(data.getCustomTimetableEventResponse.events.map((customEvent) => Appointment( - id: ArbitraryAppointment(custom: customEvent), - startTime: customEvent.startDate, - endTime: customEvent.endDate, - location: customEvent.description, - subject: customEvent.title, - recurrenceRule: customEvent.rrule, - color: TimetableColors.getColorFromString(customEvent.color ?? TimetableColors.defaultColor.name), - startTimeZone: '', - endTimeZone: '', - ))); - - return TimetableEvents(appointments); - } - - DateTime _parseWebuntisTimestamp(int date, int time) { - var timeString = time.toString().padLeft(4, '0'); - return DateTime.parse('$date ${timeString.substring(0, 2)}:${timeString.substring(2, 4)}'); - } - - Color _getEventColor(GetTimetableResponseObject webuntisElement, DateTime startTime, DateTime endTime) { - // Cancelled - if(webuntisElement.code == 'cancelled') return const Color(0xff000000); - - // Any changes or no teacher at this element - if(webuntisElement.code == 'irregular' || webuntisElement.te.first.id == 0) return const Color(0xff8F19B3); - - // Teacher has changed - if(webuntisElement.te.any((element) => element.orgname != null)) return const Color(0xFF29639B); - - // Event was in the past - if(endTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor; - - // Event takes currently place - if(endTime.isAfter(DateTime.now()) && startTime.isBefore(DateTime.now())) return Theme.of(context).primaryColor.withRed(200); - - // Fallback - return Theme.of(context).primaryColor; - } - - bool _isCrossedOut(CalendarAppointmentDetails calendarEntry) { - var appointment = calendarEntry.appointments.first.id as ArbitraryAppointment; - if(appointment.hasWebuntis()) { - return appointment.webuntis!.code == 'cancelled'; - } - return false; + void _onCreateEventAt(DateTime start, DateTime end) { + showDialog( + context: context, + builder: (_) => + CustomEventEditDialog(initialStart: start, initialEnd: end), + barrierDismissible: false, + ); } } diff --git a/lib/view/pages/timetable/timetableEvents.dart b/lib/view/pages/timetable/timetableEvents.dart deleted file mode 100644 index 8450df7..0000000 --- a/lib/view/pages/timetable/timetableEvents.dart +++ /dev/null @@ -1,8 +0,0 @@ - -import 'package:syncfusion_flutter_calendar/calendar.dart'; - -class TimetableEvents extends CalendarDataSource { - TimetableEvents(List source) { - appointments = source; - } -} diff --git a/lib/view/pages/timetable/timetableNameMode.dart b/lib/view/pages/timetable/timetableNameMode.dart deleted file mode 100644 index 6e4f0dd..0000000 --- a/lib/view/pages/timetable/timetableNameMode.dart +++ /dev/null @@ -1,25 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../../widget/dropdownDisplay.dart'; - -enum TimetableNameMode { - name, - longName, - alternateName -} - -class TimetableNameModes { - static DropdownDisplay getDisplayOptions(TimetableNameMode theme) { - switch(theme) { - case TimetableNameMode.name: - return DropdownDisplay(icon: Icons.device_unknown_outlined, displayName: 'Name'); - - case TimetableNameMode.longName: - return DropdownDisplay(icon: Icons.perm_device_info_outlined, displayName: 'Langname'); - - case TimetableNameMode.alternateName: - return DropdownDisplay(icon: Icons.on_device_training_outlined, displayName: 'Kurzform'); - } - } -} - diff --git a/lib/view/pages/timetable/viewCustomTimetableEvents.dart b/lib/view/pages/timetable/viewCustomTimetableEvents.dart deleted file mode 100644 index f089184..0000000 --- a/lib/view/pages/timetable/viewCustomTimetableEvents.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:provider/provider.dart'; - -import '../../../api/mhsl/customTimetableEvent/get/getCustomTimetableEventResponse.dart'; -import '../../../model/timetable/timetableProps.dart'; -import '../../../widget/centeredLeading.dart'; -import '../../../widget/loadingSpinner.dart'; -import '../../../widget/placeholderView.dart'; -import 'appointmentDetails.dart'; -import 'customTimetableEventEditDialog.dart'; - -class ViewCustomTimetableEvents extends StatefulWidget { - const ViewCustomTimetableEvents({super.key}); - - @override - State createState() => _ViewCustomTimetableEventsState(); -} - -class _ViewCustomTimetableEventsState extends State { - late Future events; - - @override - void initState() { - super.initState(); - } - - _openCreateDialog() { - showDialog( - context: context, - builder: (context) => const CustomTimetableEventEditDialog(), - barrierDismissible: false, - ); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Eigene Termine'), - actions: [ - IconButton( - icon: const Icon(Icons.add), - onPressed: _openCreateDialog, - ) - ], - ), - body: Consumer(builder: (context, value, child) { - if(value.primaryLoading()) return const LoadingSpinner(); - - var listView = ListView( - children: value.getCustomTimetableEventResponse.events.map((e) => ListTile( - title: Text(e.title), - subtitle: Text("${e.rrule.isNotEmpty ? "wiederholdend, " : ""}beginnend ${Jiffy.parseFromDateTime(e.startDate).fromNow()}"), - leading: CenteredLeading(Icon(e.rrule.isEmpty ? Icons.event_outlined : Icons.event_repeat_outlined)), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - IconButton( - icon: const Icon(Icons.edit_outlined), - onPressed: () { - showDialog(context: context, builder: (context) => CustomTimetableEventEditDialog(existingEvent: e)); - }, - ), - IconButton( - icon: const Icon(Icons.delete_outline), - onPressed: () { - AppointmentDetails.deleteCustomEvent(context, e); - }, - ) - ], - ), - )).toList(), - ); - - var placeholder = PlaceholderView( - icon: Icons.calendar_today_outlined, - text: 'Keine Einträge vorhanden', - button: TextButton( - onPressed: _openCreateDialog, - child: const Text('Termin erstellen'), - ), - ); - - return RefreshIndicator( - onRefresh: () { - Provider.of(context, listen: false).run(renew: true); - return Future.delayed(const Duration(seconds: 3)); - }, - child: value.getCustomTimetableEventResponse.events.isEmpty - ? placeholder - : listView - ); - }), - ); -} diff --git a/lib/view/pages/timetable/widgets/appointment_tile.dart b/lib/view/pages/timetable/widgets/appointment_tile.dart new file mode 100644 index 0000000..61d08c0 --- /dev/null +++ b/lib/view/pages/timetable/widgets/appointment_tile.dart @@ -0,0 +1,212 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../data/arbitrary_appointment.dart'; +import '../data/calendar_layout.dart'; +import 'cross_painter.dart'; + +class AppointmentTile extends StatelessWidget { + static const _radius = BorderRadius.all(Radius.circular(7)); + + final Appointment appointment; + final bool crossedOut; + + const AppointmentTile({ + super.key, + required this.appointment, + this.crossedOut = false, + }); + + @override + Widget build(BuildContext context) { + final isPast = appointment.endTime.isBefore(DateTime.now()); + final color = appointment.color.withAlpha(isPast ? 160 : 255); + final isCustom = appointment.id is CustomAppointment; + final description = appointment.location ?? ''; + + return Padding( + padding: const EdgeInsets.all(1), + child: Stack( + children: [ + Positioned.fill( + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + alignment: Alignment.topLeft, + decoration: BoxDecoration( + shape: BoxShape.rectangle, + borderRadius: _radius, + color: color, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisSize: MainAxisSize.max, + children: [ + _AdaptiveTitle( + text: appointment.subject, + fontSize: kAppointmentTitleFontSize, + minFontSize: kAppointmentTitleMinFontSize, + fontWeight: FontWeight.w500, + ), + if (isCustom) ...[ + if (description.isNotEmpty) + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 1), + child: _WrappingBody( + text: description, + fontSize: kAppointmentBodyFontSize, + lineHeight: kAppointmentBodyLineHeight, + ), + ), + ), + ] else ...[ + for (final line + in description + .split('\n') + .where((p) => p.isNotEmpty) + .take(2)) + _ScaledLine( + text: line, + fontSize: kAppointmentBodyFontSize, + ), + ], + ], + ), + ), + ), + if (crossedOut) + Positioned.fill( + child: ClipRRect( + borderRadius: _radius, + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + width: 2, + color: Colors.red.withAlpha(200), + ), + borderRadius: _radius, + ), + child: CustomPaint(painter: CrossPainter()), + ), + ), + ), + ], + ), + ); + } +} + +/// Renders the appointment title. Scales down to fit the available width via +/// [FittedBox], but never below [minFontSize] — when even the minimum size +/// overflows, the text is rendered at [minFontSize] with an ellipsis. +class _AdaptiveTitle extends StatelessWidget { + final String text; + final double fontSize; + final double minFontSize; + final FontWeight? fontWeight; + + const _AdaptiveTitle({ + required this.text, + required this.fontSize, + required this.minFontSize, + this.fontWeight, + }); + + @override + Widget build(BuildContext context) { + final baseStyle = TextStyle( + color: Colors.white, + fontSize: fontSize, + fontWeight: fontWeight, + height: 1.1, + ); + final textScaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + // Probe at the minimum size: if even that overflows, we have to ellipsize. + final probe = TextPainter( + text: TextSpan( + text: text, + style: baseStyle.copyWith(fontSize: minFontSize), + ), + textDirection: TextDirection.ltr, + maxLines: 1, + textScaler: textScaler, + )..layout(); + if (probe.width > constraints.maxWidth) { + return Text( + text, + style: baseStyle.copyWith(fontSize: minFontSize), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + ); + } + return FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text(text, style: baseStyle, maxLines: 1, softWrap: false), + ); + }, + ); + } +} + +/// Body text for custom events. Wraps to fill the available height and clips +/// trailing content with an ellipsis if there is more than fits. +class _WrappingBody extends StatelessWidget { + final String text; + final double fontSize; + final double lineHeight; + + const _WrappingBody({ + required this.text, + required this.fontSize, + required this.lineHeight, + }); + + @override + Widget build(BuildContext context) { + final style = TextStyle( + color: Colors.white, + fontSize: fontSize, + height: lineHeight, + ); + final textScaler = MediaQuery.textScalerOf(context); + return LayoutBuilder( + builder: (context, constraints) { + final lineBox = textScaler.scale(fontSize) * lineHeight; + final maxLines = (constraints.maxHeight / lineBox).floor().clamp(1, 99); + return Text( + text, + style: style, + maxLines: maxLines, + overflow: TextOverflow.ellipsis, + softWrap: true, + ); + }, + ); + } +} + +/// One row of appointment text. The FittedBox scales **only this line** down +/// when the text is wider than the tile, so a long teacher name does not +/// shrink the room number above it. +class _ScaledLine extends StatelessWidget { + final String text; + final double fontSize; + + const _ScaledLine({required this.text, required this.fontSize}); + + @override + Widget build(BuildContext context) => FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Text( + text, + style: TextStyle(color: Colors.white, fontSize: fontSize, height: 1.1), + maxLines: 1, + softWrap: false, + ), + ); +} diff --git a/lib/view/pages/timetable/widgets/calendar/day_header.dart b/lib/view/pages/timetable/widgets/calendar/day_header.dart new file mode 100644 index 0000000..5f1ca7f --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/day_header.dart @@ -0,0 +1,87 @@ +part of '../custom_workweek_calendar.dart'; + +class _DayHeaderStrip extends StatelessWidget { + final DateTime weekStart; + final DateTime today; + final double rulerWidth; + + const _DayHeaderStrip({ + super.key, + required this.weekStart, + required this.today, + required this.rulerWidth, + }); + + @override + Widget build(BuildContext context) => Row( + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayHeaderCell( + date: weekStart.add(Duration(days: d)), + today: today, + ), + ), + ], + ); +} + +class _DayHeaderCell extends StatelessWidget { + final DateTime date; + final DateTime today; + + const _DayHeaderCell({required this.date, required this.today}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isToday = date.isSameDay(today); + final dayName = DateFormat( + 'EE', + Localizations.localeOf(context).toString(), + ).format(date).toUpperCase(); + + final accent = theme.colorScheme.primary; + final onAccent = theme.colorScheme.onPrimary; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + Text( + dayName, + style: theme.textTheme.labelSmall?.copyWith( + color: isToday ? accent : theme.colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w600, + fontSize: 12, + height: 1.1, + ), + ), + const SizedBox(height: 2), + AnimatedContainer( + duration: const Duration(milliseconds: 220), + curve: Curves.easeOutCubic, + width: 28, + height: 28, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isToday ? accent : Colors.transparent, + ), + alignment: Alignment.center, + child: Text( + '${date.day}', + style: theme.textTheme.titleSmall?.copyWith( + color: isToday ? onAccent : theme.colorScheme.onSurface, + fontWeight: isToday ? FontWeight.bold : FontWeight.normal, + height: 1.0, + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart new file mode 100644 index 0000000..360c780 --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -0,0 +1,270 @@ +part of '../custom_workweek_calendar.dart'; + +class _OutsideHoursStrip extends StatelessWidget { + final DateTime weekStart; + final List appointments; + final double rulerWidth; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideHoursStrip({ + super.key, + required this.weekStart, + required this.appointments, + required this.rulerWidth, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + @override + Widget build(BuildContext context) { + final outside = partitionAppointmentsForWeek( + appointments, + weekStart, + ).outside; + if (outside.every((day) => day.isEmpty)) return const SizedBox.shrink(); + + final theme = Theme.of(context); + final maxChipsPerDay = outside + .map( + (day) => day.length > kOutsideChipsMaxVisible + ? kOutsideChipsMaxVisible + : day.length, + ) + .fold(0, (m, c) => c > m ? c : m); + final stripHeight = + kOutsideStripVerticalPadding * 2 + + maxChipsPerDay * kOutsideChipHeight + + (maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0); + + return Container( + color: theme.colorScheme.surfaceContainerLowest, + padding: const EdgeInsets.symmetric( + vertical: kOutsideStripVerticalPadding, + ), + child: SizedBox( + height: stripHeight - kOutsideStripVerticalPadding * 2, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _OutsideDayColumn( + appointments: outside[d], + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + ), + ), + ], + ), + ), + ); + } +} + +class _OutsideDayColumn extends StatelessWidget { + final List appointments; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + + const _OutsideDayColumn({ + required this.appointments, + required this.onAppointmentTap, + required this.isCrossedOut, + }); + + void _showOverflow(BuildContext context, List hidden) { + showDetailsBottomSheet( + context, + children: (sheetCtx) { + final tiles = []; + for (var i = 0; i < hidden.length; i++) { + if (i > 0) tiles.add(const Divider(height: 1)); + final apt = hidden[i]; + tiles.add( + ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_subtitleFor(apt)), + onTap: () { + Navigator.of(sheetCtx).pop(); + onAppointmentTap(apt); + }, + ), + ); + } + return tiles; + }, + ); + } + + static String _subtitleFor(Appointment a) { + if (isAllDayLike(a)) return 'Ganztägig'; + return '${_hm(a.startTime)}–${_hm(a.endTime)}'; + } + + static String _hm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + if (appointments.isEmpty) return const SizedBox.shrink(); + final sorted = [...appointments] + ..sort((a, b) { + final aLike = isAllDayLike(a); + final bLike = isAllDayLike(b); + if (aLike && !bLike) return -1; + if (!aLike && bLike) return 1; + return a.startTime.compareTo(b.startTime); + }); + final visible = sorted.length <= kOutsideChipsMaxVisible + ? sorted + : sorted.take(kOutsideChipsMaxVisible - 1).toList(); + final overflow = sorted.length <= kOutsideChipsMaxVisible + ? const [] + : sorted.skip(kOutsideChipsMaxVisible - 1).toList(); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < visible.length; i++) ...[ + if (i > 0) const SizedBox(height: kOutsideChipSpacing), + SizedBox( + height: kOutsideChipHeight, + child: _OutsideChip( + appointment: visible[i], + onTap: () => onAppointmentTap(visible[i]), + ), + ), + ], + if (overflow.isNotEmpty) ...[ + const SizedBox(height: kOutsideChipSpacing), + SizedBox( + height: kOutsideChipHeight, + child: _OutsideOverflowChip( + count: overflow.length, + onTap: () => _showOverflow(context, overflow), + ), + ), + ], + ], + ), + ); + } +} + +class _OutsideChip extends StatelessWidget { + final Appointment appointment; + final VoidCallback onTap; + + const _OutsideChip({required this.appointment, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final allDay = isAllDayLike(appointment); + final timeLabel = allDay + ? null + : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + + // Past chips fade further, future/ongoing ones get a more saturated tint + // so the strip no longer reads as one uniform grey block. + final isPast = appointment.endTime.isBefore(DateTime.now()); + final backgroundAlpha = isPast ? 38 : 120; + final subjectColor = isPast + ? theme.colorScheme.onSurfaceVariant + : theme.colorScheme.onSurface; + final subjectWeight = isPast ? FontWeight.w400 : FontWeight.w600; + + return Material( + color: appointment.color.withAlpha(backgroundAlpha), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(7)), + ), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.max, + children: [ + Expanded( + child: Text( + appointment.subject, + maxLines: 1, + overflow: TextOverflow.ellipsis, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: subjectColor, + fontWeight: subjectWeight, + ), + ), + ), + if (timeLabel != null) ...[ + const SizedBox(width: 4), + Flexible( + child: Text( + timeLabel, + maxLines: 1, + overflow: TextOverflow.fade, + softWrap: false, + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + fontSize: 10, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } +} + +class _OutsideOverflowChip extends StatelessWidget { + final int count; + final VoidCallback onTap; + + const _OutsideOverflowChip({required this.count, required this.onTap}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: theme.colorScheme.secondaryContainer, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(6)), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: onTap, + child: Center( + child: Text( + '+$count weitere', + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.onSecondaryContainer, + fontWeight: FontWeight.w600, + ), + ), + ), + ), + ); + } +} diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart new file mode 100644 index 0000000..132298f --- /dev/null +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -0,0 +1,519 @@ +part of '../custom_workweek_calendar.dart'; + +class _WeekGrid extends StatelessWidget { + final DateTime weekStart; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + final DateTime today; + final ValueListenable nowNotifier; + final double rulerWidth; + final PeriodLayout layout; + + const _WeekGrid({ + required this.weekStart, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + required this.today, + required this.nowNotifier, + required this.rulerWidth, + required this.layout, + }); + + @override + Widget build(BuildContext context) { + final partitioned = partitionAppointmentsForWeek(appointments, weekStart); + + return Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + _PeriodRuler(schedule: schedule, layout: layout, width: rulerWidth), + for (var d = 0; d < 5; d++) + Expanded( + child: _DayColumn( + date: weekStart.add(Duration(days: d)), + schedule: schedule, + appointments: partitioned.inside[d], + timeRegions: timeRegions, + layout: layout, + today: today, + nowNotifier: nowNotifier, + onAppointmentTap: onAppointmentTap, + isCrossedOut: isCrossedOut, + onCreateEvent: onCreateEvent, + ), + ), + ], + ); + } +} + +class _PeriodRuler extends StatelessWidget { + final LessonPeriodSchedule schedule; + final PeriodLayout layout; + final double width; + + const _PeriodRuler({ + required this.schedule, + required this.layout, + required this.width, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return SizedBox( + width: width, + child: Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + height: layout.heightOf(period), + left: 0, + right: 0, + child: _PeriodLabel(period: period, theme: theme), + ), + ], + ), + ); + } +} + +class _PeriodLabel extends StatelessWidget { + final LessonPeriod period; + final ThemeData theme; + + const _PeriodLabel({required this.period, required this.theme}); + + @override + Widget build(BuildContext context) { + final dividerColor = theme.dividerColor.withAlpha(110); + final secondaryTextColor = theme.colorScheme.onSurfaceVariant; + + if (period.isBreak) { + return Container( + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: dividerColor, width: 0.5), + bottom: BorderSide(color: dividerColor, width: 0.5), + ), + ), + alignment: Alignment.center, + child: Icon( + Icons.coffee_outlined, + size: 12, + color: secondaryTextColor.withAlpha(180), + ), + ); + } + + final timeStyle = theme.textTheme.labelSmall?.copyWith( + color: secondaryTextColor.withAlpha(140), + height: 1.0, + fontSize: 9, + ); + const tightTextHeight = TextHeightBehavior( + applyHeightToFirstAscent: false, + applyHeightToLastDescent: false, + ); + + return LayoutBuilder( + builder: (context, constraints) { + final showTimes = constraints.maxHeight >= 38; + return DecoratedBox( + decoration: BoxDecoration( + border: Border(top: BorderSide(color: dividerColor, width: 0.5)), + ), + child: Stack( + clipBehavior: Clip.none, + alignment: Alignment.center, + children: [ + if (showTimes) + Positioned( + top: 3, + left: 0, + right: 0, + child: Text( + _format(period.start), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + Text( + period.name, + style: theme.textTheme.labelLarge?.copyWith( + color: theme.colorScheme.onSurface, + fontWeight: FontWeight.w500, + height: 1.0, + ), + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + if (showTimes) + Positioned( + bottom: 3, + left: 0, + right: 0, + child: Text( + _format(period.end), + style: timeStyle, + textAlign: TextAlign.center, + textHeightBehavior: tightTextHeight, + ), + ), + ], + ), + ); + }, + ); + } + + static String _format(TimeOfDay t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; +} + +class _DayColumn extends StatelessWidget { + final DateTime date; + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final PeriodLayout layout; + final DateTime today; + final ValueListenable nowNotifier; + final void Function(Appointment) onAppointmentTap; + final bool Function(Appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + + const _DayColumn({ + required this.date, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.layout, + required this.today, + required this.nowNotifier, + required this.onAppointmentTap, + required this.isCrossedOut, + required this.onCreateEvent, + }); + + bool _overlapsExistingAppointment( + DateTime start, + DateTime end, + List dayAppts, + ) { + for (final a in dayAppts) { + if (a.endTime.isAfter(start) && a.startTime.isBefore(end)) return true; + } + return false; + } + + void _handleLongPress( + LongPressStartDetails details, + List dayAppts, + ) { + if (onCreateEvent == null) return; + final period = layout.periodAtY(details.localPosition.dy); + if (period == null) return; + + final start = DateTime( + date.year, + date.month, + date.day, + period.start.hour, + period.start.minute, + ); + final end = DateTime( + date.year, + date.month, + date.day, + period.end.hour, + period.end.minute, + ); + if (_overlapsExistingAppointment(start, end, dayAppts)) return; + + HapticFeedback.mediumImpact(); + onCreateEvent!(start, end); + } + + void _showOverflowSheet( + BuildContext context, + List appointments, + ) { + final sorted = [...appointments] + ..sort((a, b) => a.startTime.compareTo(b.startTime)); + showDetailsBottomSheet( + context, + children: (sheetContext) { + final tiles = []; + for (var i = 0; i < sorted.length; i++) { + if (i > 0) tiles.add(const Divider(height: 1)); + final apt = sorted[i]; + tiles.add( + ListTile( + leading: Container( + width: 12, + height: 12, + decoration: BoxDecoration( + color: apt.color, + borderRadius: BorderRadius.circular(3), + ), + ), + title: Text( + apt.subject, + style: isCrossedOut(apt) + ? const TextStyle(decoration: TextDecoration.lineThrough) + : null, + ), + subtitle: Text(_overflowSubtitle(apt)), + onTap: () { + Navigator.of(sheetContext).pop(); + onAppointmentTap(apt); + }, + ), + ); + } + return tiles; + }, + ); + } + + static String _overflowSubtitle(Appointment apt) { + final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; + final loc = apt.location?.replaceAll('\n', ' · '); + return loc != null && loc.isNotEmpty ? '$time · $loc' : time; + } + + static String _formatHm(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + final dayAppointments = appointments; + final dayRegions = expandRegionsForDay(timeRegions, date); + final isToday = date.isSameDay(today); + + final isTablet = MediaQuery.of(context).size.shortestSide >= 600; + final laidOut = assignLanes(dayAppointments, maxLanes: isTablet ? 3 : 2); + + return GestureDetector( + behavior: HitTestBehavior.translucent, + onLongPressStart: (details) => _handleLongPress(details, dayAppointments), + child: DecoratedBox( + decoration: BoxDecoration( + color: isToday ? theme.colorScheme.primary.withAlpha(14) : null, + border: Border( + left: BorderSide( + color: theme.dividerColor.withAlpha(90), + width: 0.5, + ), + ), + ), + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + return Stack( + clipBehavior: Clip.none, + children: [ + for (final period in schedule.periods) + Positioned( + top: layout.topOf(period), + left: 0, + right: 0, + child: Container( + height: 0.5, + color: theme.dividerColor.withAlpha(60), + ), + ), + for (final region in dayRegions) + Positioned( + top: layout.yOfDateTime(region.start), + height: + (layout.yOfDateTime(region.end) - + layout.yOfDateTime(region.start)) + .clamp(0, double.infinity), + left: 0, + right: 0, + child: TimeRegionTile(region: region.region), + ), + for (final cell in laidOut) + Positioned( + top: layout.yOfDateTime(cell.startTime), + height: + (layout.yOfDateTime(cell.endTime) - + layout.yOfDateTime(cell.startTime)) + .clamp(0, double.infinity), + left: cell.lane * width / cell.laneCount, + width: width / cell.laneCount, + child: switch (cell) { + LaidOutAppointment(:final appointment) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onAppointmentTap(appointment), + child: AppointmentTile( + appointment: appointment, + crossedOut: isCrossedOut(appointment), + ), + ), + LaidOutOverflow(:final appointments) => GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => _showOverflowSheet(context, appointments), + child: _OverflowTile(count: appointments.length), + ), + }, + ), + if (isToday) + ValueListenableBuilder( + valueListenable: nowNotifier, + builder: (_, now, child) => _CurrentTimeMarker( + now: now, + layout: layout, + theme: theme, + ), + ), + ], + ); + }, + ), + ), + ); + } +} + +class _CurrentTimeMarker extends StatelessWidget { + final DateTime now; + final PeriodLayout layout; + final ThemeData theme; + + const _CurrentTimeMarker({ + required this.now, + required this.layout, + required this.theme, + }); + + @override + Widget build(BuildContext context) { + final periods = layout.periods; + if (periods.isEmpty) return const SizedBox.shrink(); + final tMin = now.hour * 60 + now.minute; + final firstStart = + periods.first.start.hour * 60 + periods.first.start.minute; + final lastEnd = periods.last.end.hour * 60 + periods.last.end.minute; + if (tMin < firstStart || tMin > lastEnd) return const SizedBox.shrink(); + + final y = layout.yOfDateTime(now); + + return AnimatedPositioned( + duration: const Duration(milliseconds: 400), + curve: Curves.easeInOut, + top: y - 1, + left: 0, + right: 0, + child: IgnorePointer( + child: Stack( + clipBehavior: Clip.none, + children: [ + Container(height: 2, color: theme.colorScheme.primary), + Positioned( + top: -3, + left: -4, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: theme.colorScheme.primary, + shape: BoxShape.circle, + ), + ), + ), + ], + ), + ), + ); + } +} + +class _OverflowTile extends StatelessWidget { + final int count; + const _OverflowTile({required this.count}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + const radius = BorderRadius.all(Radius.circular(7)); + + return Padding( + padding: const EdgeInsets.all(1), + child: Stack( + children: [ + // Stacked-cards effect: a darker layer peeks out below the front card. + Positioned( + top: 4, + left: 2, + right: 2, + bottom: 0, + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer.withAlpha(120), + ), + ), + ), + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 4, + child: Container( + decoration: BoxDecoration( + borderRadius: radius, + color: scheme.secondaryContainer, + ), + alignment: Alignment.center, + child: FittedBox( + fit: BoxFit.scaleDown, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.unfold_more_rounded, + size: 18, + color: scheme.onSecondaryContainer, + ), + Text( + '+$count', + style: theme.textTheme.titleSmall?.copyWith( + color: scheme.onSecondaryContainer, + fontWeight: FontWeight.w700, + height: 1.0, + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ); + } +} diff --git a/lib/view/pages/timetable/CrossPainter.dart b/lib/view/pages/timetable/widgets/cross_painter.dart similarity index 100% rename from lib/view/pages/timetable/CrossPainter.dart rename to lib/view/pages/timetable/widgets/cross_painter.dart diff --git a/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart new file mode 100644 index 0000000..b8f0a5c --- /dev/null +++ b/lib/view/pages/timetable/widgets/custom_workweek_calendar.dart @@ -0,0 +1,235 @@ +/// Custom 5-day work-week calendar (replaces Syncfusion's `WorkWeek` view). +/// +/// Implementation is split across `calendar/` for readability; everything +/// stays in this single library so private widgets and helpers (`_DayColumn`, +/// `_PeriodLayout`, `_isAllDayLike`, …) can remain library-private. +library; + +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:intl/intl.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../extensions/date_time.dart'; +import '../../../../widget/details_bottom_sheet.dart'; +import '../data/calendar_layout.dart'; +import '../data/calendar_logic.dart'; +import '../data/lesson_period_schedule.dart'; +import 'appointment_tile.dart'; +import 'time_region_tile.dart'; + +part 'calendar/day_header.dart'; +part 'calendar/outside_chips.dart'; +part 'calendar/week_grid.dart'; + +class CustomWorkWeekCalendar extends StatefulWidget { + final LessonPeriodSchedule schedule; + final List appointments; + final List timeRegions; + final DateTime initialDate; + final DateTime minDate; + final DateTime maxDate; + final void Function(Appointment appointment) onAppointmentTap; + final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged; + final bool Function(Appointment appointment) isCrossedOut; + final void Function(DateTime start, DateTime end)? onCreateEvent; + + const CustomWorkWeekCalendar({ + super.key, + required this.schedule, + required this.appointments, + required this.timeRegions, + required this.initialDate, + required this.minDate, + required this.maxDate, + required this.onAppointmentTap, + required this.onWeekChanged, + required this.isCrossedOut, + this.onCreateEvent, + }); + + @override + State createState() => CustomWorkWeekCalendarState(); +} + +class CustomWorkWeekCalendarState extends State { + static const double _rulerWidth = 36; + + late PageController _pageController; + late int _currentWeekIndex; + late DateTime _firstMonday; + late int _totalWeeks; + late Timer _ticker; + late ValueNotifier _nowNotifier; + DateTime _today = _dateOnly(DateTime.now()); + + @override + void initState() { + super.initState(); + _firstMonday = _mondayOf(widget.minDate); + final lastMonday = _mondayOf(widget.maxDate); + _totalWeeks = lastMonday.difference(_firstMonday).inDays ~/ 7 + 1; + _currentWeekIndex = + _mondayOf(widget.initialDate).difference(_firstMonday).inDays ~/ 7; + _pageController = PageController(initialPage: _currentWeekIndex); + _nowNotifier = ValueNotifier(DateTime.now()); + + _ticker = Timer.periodic(const Duration(seconds: 30), (_) { + if (!mounted) return; + final now = DateTime.now(); + _nowNotifier.value = now; + final newToday = _dateOnly(now); + if (newToday != _today) setState(() => _today = newToday); + }); + } + + @override + void dispose() { + _pageController.dispose(); + _ticker.cancel(); + _nowNotifier.dispose(); + super.dispose(); + } + + static DateTime _dateOnly(DateTime d) => DateTime(d.year, d.month, d.day); + + void jumpToDate(DateTime date) { + final target = _mondayOf(date).difference(_firstMonday).inDays ~/ 7; + if (target < 0 || target >= _totalWeeks) return; + _pageController.animateToPage( + target, + duration: const Duration(milliseconds: 380), + curve: Curves.easeInOutCubic, + ); + } + + static DateTime _mondayOf(DateTime d) { + final monday = d.subtract(Duration(days: d.weekday - 1)); + return DateTime(monday.year, monday.month, monday.day); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final visibleWeekStart = _firstMonday.add( + Duration(days: _currentWeekIndex * 7), + ); + + return Column( + children: [ + SizedBox( + height: kCalendarViewHeaderHeight, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 220), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) => FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, -0.08), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ), + child: _DayHeaderStrip( + key: ValueKey(visibleWeekStart), + weekStart: visibleWeekStart, + today: _today, + rulerWidth: _rulerWidth, + ), + ), + ), + ClipRect( + child: AnimatedSize( + duration: const Duration(milliseconds: 320), + curve: Curves.easeOutCubic, + alignment: Alignment.topCenter, + child: AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeInCubic, + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: _OutsideHoursStrip( + key: ValueKey(visibleWeekStart), + weekStart: visibleWeekStart, + appointments: widget.appointments, + rulerWidth: _rulerWidth, + onAppointmentTap: widget.onAppointmentTap, + isCrossedOut: widget.isCrossedOut, + ), + ), + ), + ), + Container(height: 0.5, color: theme.dividerColor.withAlpha(110)), + Expanded( + child: LayoutBuilder( + builder: (context, constraints) { + final periods = widget.schedule.periods; + final lessonCount = periods.where((p) => !p.isBreak).length; + final breakCount = periods.length - lessonCount; + final available = + constraints.maxHeight - breakCount * kBreakBlockHeight; + final fitLessonH = lessonCount > 0 + ? available / lessonCount + : kLessonBlockMinHeight; + final lessonH = fitLessonH < kLessonBlockMinHeight + ? kLessonBlockMinHeight + : fitLessonH; + final layout = PeriodLayout( + periods: periods, + lessonHeight: lessonH, + breakHeight: kBreakBlockHeight, + ); + final gridHeight = layout.totalHeight; + + return SingleChildScrollView( + physics: const AlwaysScrollableScrollPhysics(), + child: SizedBox( + height: gridHeight, + child: PageView.builder( + controller: _pageController, + itemCount: _totalWeeks, + onPageChanged: (index) { + setState(() => _currentWeekIndex = index); + final weekStart = _firstMonday.add( + Duration(days: index * 7), + ); + widget.onWeekChanged( + weekStart, + weekStart.add(const Duration(days: 4)), + ); + }, + itemBuilder: (_, weekIndex) { + final weekStart = _firstMonday.add( + Duration(days: weekIndex * 7), + ); + return _WeekGrid( + weekStart: weekStart, + schedule: widget.schedule, + appointments: widget.appointments, + timeRegions: widget.timeRegions, + onAppointmentTap: widget.onAppointmentTap, + isCrossedOut: widget.isCrossedOut, + onCreateEvent: widget.onCreateEvent, + today: _today, + nowNotifier: _nowNotifier, + rulerWidth: _rulerWidth, + layout: layout, + ); + }, + ), + ), + ); + }, + ), + ), + ], + ); + } +} diff --git a/lib/view/pages/timetable/widgets/special_regions_builder.dart b/lib/view/pages/timetable/widgets/special_regions_builder.dart new file mode 100644 index 0000000..02a06be --- /dev/null +++ b/lib/view/pages/timetable/widgets/special_regions_builder.dart @@ -0,0 +1,82 @@ +import 'package:flutter/material.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +import '../../../../api/webuntis/queries/get_holidays/get_holidays_response.dart'; +import '../../../../extensions/date_time.dart'; +import '../data/calendar_layout.dart'; +import '../data/lesson_period_schedule.dart'; +import '../data/webuntis_time.dart'; +import 'time_region_tile.dart'; + +class SpecialRegionsBuilder { + final GetHolidaysResponse holidays; + final LessonPeriodSchedule schedule; + final ColorScheme colorScheme; + final Color disabledColor; + + SpecialRegionsBuilder({ + required this.holidays, + required this.schedule, + required this.colorScheme, + required this.disabledColor, + }); + + List build() { + final lastMonday = DateTime.now() + .subtract(const Duration(days: 14)) + .nextWeekday(DateTime.monday); + + final holidayRegions = _buildHolidayRegions().toList(); + bool isInHoliday(DateTime time) => + holidayRegions.any((region) => region.startTime.isSameDay(time)); + + final breakRegions = schedule.periods + .where((p) => p.isBreak) + .map((p) { + final start = lastMonday.copyWith( + hour: p.start.hour, + minute: p.start.minute, + ); + return _breakRegion(start, p.duration); + }) + .where((region) => !isInHoliday(region.startTime)); + + return [...holidayRegions, ...breakRegions]; + } + + Iterable _buildHolidayRegions() => holidays.result.expand(( + holiday, + ) { + final startDay = WebuntisTime.parse(holiday.startDate, 0); + final dayCount = WebuntisTime.parse( + holiday.endDate, + 0, + ).difference(startDay).inDays; + final days = List.generate( + dayCount, + (i) => startDay.add(Duration(days: i)), + ); + final gridStartHour = kCalendarStartHour.floor(); + final gridStartMinute = ((kCalendarStartHour - gridStartHour) * 60).round(); + final gridEndHour = kCalendarEndHour.floor(); + final gridEndMinute = ((kCalendarEndHour - gridEndHour) * 60).round(); + return days.map( + (day) => TimeRegion( + startTime: day.copyWith(hour: gridStartHour, minute: gridStartMinute), + endTime: day.copyWith(hour: gridEndHour, minute: gridEndMinute), + text: '$kTimeRegionHolidayPrefix${holiday.name}', + color: disabledColor.withAlpha(50), + iconData: Icons.holiday_village_outlined, + ), + ); + }); + + TimeRegion _breakRegion(DateTime start, Duration duration) => TimeRegion( + startTime: start, + endTime: start.add(duration), + recurrenceRule: 'FREQ=DAILY;INTERVAL=1', + text: kTimeRegionCenterIcon, + color: colorScheme.primary.withAlpha(50), + iconData: Icons.restaurant, + ); +} diff --git a/lib/view/pages/timetable/timeRegionComponent.dart b/lib/view/pages/timetable/widgets/time_region_tile.dart similarity index 61% rename from lib/view/pages/timetable/timeRegionComponent.dart rename to lib/view/pages/timetable/widgets/time_region_tile.dart index 01d3d7e..292e170 100644 --- a/lib/view/pages/timetable/timeRegionComponent.dart +++ b/lib/view/pages/timetable/widgets/time_region_tile.dart @@ -1,31 +1,32 @@ import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_calendar/calendar.dart'; -class TimeRegionComponent extends StatefulWidget { - final TimeRegionDetails details; - const TimeRegionComponent({super.key, required this.details}); +const String kTimeRegionCenterIcon = 'centerIcon'; +const String kTimeRegionHolidayPrefix = 'holiday:'; - @override - State createState() => _TimeRegionComponentState(); -} +class TimeRegionTile extends StatelessWidget { + final TimeRegion region; + + const TimeRegionTile({super.key, required this.region}); -class _TimeRegionComponentState extends State { @override Widget build(BuildContext context) { - var text = widget.details.region.text!; - var color = widget.details.region.color; + final text = region.text ?? ''; + final color = region.color; - if (text == 'centerIcon') { + if (text == kTimeRegionCenterIcon) { return Container( color: color, alignment: Alignment.center, child: Icon( - widget.details.region.iconData, + region.iconData, size: 17, - color: Theme.of(context).primaryColor, + color: Theme.of(context).colorScheme.primary, ), ); - } else if(text.startsWith('holiday')) { + } + + if (text.startsWith(kTimeRegionHolidayPrefix)) { return Container( color: color, alignment: Alignment.center, @@ -38,7 +39,7 @@ class _TimeRegionComponentState extends State { RotatedBox( quarterTurns: 1, child: Text( - text.split(':').last, + text.substring(kTimeRegionHolidayPrefix.length), maxLines: 1, style: const TextStyle( fontWeight: FontWeight.bold, @@ -53,6 +54,6 @@ class _TimeRegionComponentState extends State { ); } - return const Placeholder(); + return const SizedBox.shrink(); } } diff --git a/lib/view/settings/defaultSettings.dart b/lib/view/settings/defaultSettings.dart deleted file mode 100644 index 7b99b25..0000000 --- a/lib/view/settings/defaultSettings.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; - -import '../../state/app/modules/app_modules.dart'; -import '../../storage/base/settings.dart'; -import '../../storage/devTools/devToolsSettings.dart'; -import '../../storage/file/fileSettings.dart'; -import '../../storage/fileView/fileViewSettings.dart'; -import '../../storage/general/modulesSettings.dart'; -import '../../storage/holidays/holidaysSettings.dart'; -import '../../storage/notification/notificationSettings.dart'; -import '../../storage/talk/talkSettings.dart'; -import '../../storage/timetable/timetableSettings.dart'; -import '../pages/files/files.dart'; -import '../pages/timetable/timetableNameMode.dart'; - -class DefaultSettings { - static Settings get() => Settings( - appTheme: ThemeMode.system, - devToolsEnabled: false, - modulesSettings: ModulesSettings( - moduleOrder: [ - Modules.timetable, - Modules.talk, - Modules.files, - Modules.marianumMessage, - Modules.roomPlan, - Modules.gradeAveragesCalculator, - Modules.holidays - ], - hiddenModules: [], - ), - timetableSettings: TimetableSettings( - connectDoubleLessons: true, - timetableNameMode: TimetableNameMode.name - ), - talkSettings: TalkSettings( - sortFavoritesToTop: true, - sortUnreadToTop: false, - drafts: {}, - draftReplies: {}, - ), - fileSettings: FileSettings( - sortFoldersToTop: true, - ascending: true, - sortBy: SortOption.name - ), - holidaysSettings: HolidaysSettings( - dismissedDisclaimer: false, - showPastEvents: false, - ), - fileViewSettings: FileViewSettings( - alwaysOpenExternally: Platform.isIOS, - ), - notificationSettings: NotificationSettings( - askUsageDismissed: false, - enabled: false, - ), - devToolsSettings: DevToolsSettings( - checkerboardOffscreenLayers: false, - checkerboardRasterCacheImages: false, - showPerformanceOverlay: false, - ), - ); -} diff --git a/lib/view/settings/devToolsSettings.dart b/lib/view/settings/devToolsSettings.dart deleted file mode 100644 index 4dc8877..0000000 --- a/lib/view/settings/devToolsSettings.dart +++ /dev/null @@ -1,130 +0,0 @@ - -import 'package:filesize/filesize.dart'; -import 'package:flutter/material.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import 'package:provider/provider.dart'; - -import '../../storage/base/settingsProvider.dart'; -import '../../widget/centeredLeading.dart'; -import '../../widget/confirmDialog.dart'; -import '../../widget/debug/cacheView.dart'; -import '../../widget/debug/jsonViewer.dart'; - -class DevToolsSettings extends StatefulWidget { - final SettingsProvider settings; - const DevToolsSettings({required this.settings, super.key}); - - @override - State createState() => _DevToolsSettingsState(); -} - -class _DevToolsSettingsState extends State { - @override - Widget build(BuildContext context) => Column( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.speed_outlined)), - title: const Text('Performance overlays'), - trailing: const Icon(Icons.arrow_right), - onTap: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const Icon(Icons.auto_graph_outlined), - title: const Text('Performance graph'), - trailing: Checkbox( - value: widget.settings.val().devToolsSettings.showPerformanceOverlay, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.showPerformanceOverlay = e!, - ), - ), - ListTile( - leading: const Icon(Icons.screen_search_desktop_outlined), - title: const Text('Indicate offscreen layers'), - trailing: Checkbox( - value: widget.settings.val().devToolsSettings.checkerboardOffscreenLayers, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardOffscreenLayers = e!, - ), - ), - ListTile( - leading: const Icon(Icons.imagesearch_roller_outlined), - title: const Text('Indicate raster cache images'), - trailing: Checkbox( - value: widget.settings.val().devToolsSettings.checkerboardRasterCacheImages, - onChanged: (e) => widget.settings.val(write: true).devToolsSettings.checkerboardRasterCacheImages = e!, - ), - ), - ], - )); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.image_outlined)), - title: const Text('Thumb-storage'), - subtitle: Text('etwa ${filesize(PaintingBinding.instance.imageCache.currentSizeBytes)}\nLange tippen um zu löschen'), - onLongPress: () { - ConfirmDialog( - title: 'Thumbs cache löschen', - content: 'Alle zwischengespeicherten Bilder werden gelöscht.', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => PaintingBinding.instance.imageCache.clear(), - ).asDialog(context); - }, - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.settings_applications_outlined)), - title: const Text('Settings-storage JSON dump'), - subtitle: Text('etwa ${filesize(widget.settings.val().toJson().toString().length * 8)}\nLange tippen um zu löschen'), - onTap: () { - JsonViewer.asDialog(context, widget.settings.val().toJson()); - }, - onLongPress: () { - ConfirmDialog( - title: 'Einstellungen löschen', - content: 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten sind nicht betroffen.', - confirmButton: 'Unwiederruflich Löschen', - onConfirm: () { - Provider.of(context, listen: false).reset(); - }, - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('Cache-storage JSON dump'), - subtitle: FutureBuilder( - future: const CacheView().totalSize(), - builder: (context, snapshot) => Text("etwa ${snapshot.hasError ? "?" : snapshot.hasData ? filesize(snapshot.data) : "..."}\nLange tippen um zu löschen"), - ), - onTap: () { - Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView())); - }, - onLongPress: () { - ConfirmDialog( - title: 'App-Cache löschen', - content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => const CacheView().clear().then((value) => setState((){})), - ).asDialog(context); - }, - trailing: const Icon(Icons.arrow_right), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.data_object)), - title: const Text('BLOC-storage state cache'), - subtitle: const Text('Lange tippen um zu löschen'), - onTap: () { - // Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView())); - }, - onLongPress: () { - ConfirmDialog( - title: 'BLOC-Cache löschen', - content: 'Alle cache Einträge werden gelöscht. Der Cache wird bei Nutzung der App automatisch erneut aufgebaut', - confirmButton: 'Unwiederruflich löschen', - onConfirm: () => HydratedBloc.storage.clear(), - ).asDialog(context); - }, - ), - ], - ); -} diff --git a/lib/view/settings/privacyInfo.dart b/lib/view/settings/privacyInfo.dart deleted file mode 100644 index ceb9ea5..0000000 --- a/lib/view/settings/privacyInfo.dart +++ /dev/null @@ -1,32 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../widget/centeredLeading.dart'; -import '../../widget/confirmDialog.dart'; - -class PrivacyInfo { - String providerText; - String privacyUrl; - String imprintUrl; - - PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl}); - - void showPopup(BuildContext context) { - showDialog(context: context, builder: (context) => SimpleDialog( - title: Text('Betreiberinformation | $providerText'), - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.person_pin_outlined)), - title: const Text('Impressum'), - subtitle: Text(imprintUrl), - onTap: () => ConfirmDialog.openBrowser(context, imprintUrl), - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)), - title: const Text('Datenschutzerklärung'), - subtitle: Text(privacyUrl), - onTap: () => ConfirmDialog.openBrowser(context, privacyUrl), - ), - ], - )); - } -} diff --git a/lib/view/settings/settings.dart b/lib/view/settings/settings.dart deleted file mode 100644 index 0d3f58d..0000000 --- a/lib/view/settings/settings.dart +++ /dev/null @@ -1,312 +0,0 @@ - -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; -import 'package:shared_preferences/shared_preferences.dart'; - -import '../../model/accountData.dart'; -import '../../model/timetable/timetableProps.dart'; -import '../../notification/notifyUpdater.dart'; -import '../../storage/base/settingsProvider.dart'; -import '../../theming/appTheme.dart'; -import '../../widget/centeredLeading.dart'; -import '../../widget/confirmDialog.dart'; -import '../../widget/debug/cacheView.dart'; -import '../pages/timetable/timetableNameMode.dart'; -import 'defaultSettings.dart'; -import 'devToolsSettings.dart'; -import 'privacyInfo.dart'; - -class Settings extends StatefulWidget { - const Settings({super.key}); - - @override - State createState() => _SettingsState(); -} - -class _SettingsState extends State { - - @override - void initState() { - super.initState(); - } - - bool developerMode = false; - - @override - Widget build(BuildContext context) => Consumer(builder: (context, settings, child) => Scaffold( - appBar: AppBar( - title: const Text('Einstellungen'), - ), - body: ListView( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.logout_outlined)), - title: const Text('Konto abmelden'), - subtitle: Text('Angemeldet als ${AccountData().getUsername()}'), - onTap: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Abmelden?', - content: 'Möchtest du dich wirklich abmelden?', - confirmButton: 'Abmelden', - onConfirm: () { - SharedPreferences.getInstance().then((value) => { - value.clear(), - }).then((value) async { - PaintingBinding.instance.imageCache.clear(); - Provider.of(context, listen: false).reset(); - const CacheView().clear(); - AccountData().removeData(context: context); - Navigator.popUntil(context, (route) => !Navigator.canPop(context)); - }); - }, - ), - ); - }, - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.dark_mode_outlined), - title: const Text('Farbgebung'), - trailing: DropdownButton( - value: settings.val().appTheme, - icon: const Icon(Icons.arrow_drop_down), - items: ThemeMode.values.map((e) => DropdownMenuItem( - value: e, - enabled: e != settings.val().appTheme, - child: Row( - children: [ - Icon(AppTheme.getDisplayOptions(e).icon), - const SizedBox(width: 10), - Text(AppTheme.getDisplayOptions(e).displayName), - ], - ), - )).toList(), - onChanged: (e) { - settings.val(write: true).appTheme = e!; - }, - ), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.abc_outlined), - title: const Text('Fachbezeichnung'), - trailing: DropdownButton( - value: settings.val().timetableSettings.timetableNameMode, - icon: Icon(Icons.arrow_drop_down), - items: TimetableNameMode.values.map((e) => DropdownMenuItem( - value: e, - enabled: e != settings.val().timetableSettings.timetableNameMode, - child: Row( - children: [ - Icon(TimetableNameModes.getDisplayOptions(e).icon), - const SizedBox(width: 10), - Text(TimetableNameModes.getDisplayOptions(e).displayName), - ], - ), - )).toList(), - onChanged: (value) { - settings.val(write: true).timetableSettings.timetableNameMode = value!; - Provider.of(context, listen: false).run(renew: false); - }, - ) - ), - ListTile( - leading: const Icon(Icons.calendar_view_day_outlined), - title: const Text('Doppelstunden zusammenhängend anzeigen'), - trailing: Checkbox( - value: settings.val().timetableSettings.connectDoubleLessons, - onChanged: (e) { - settings.val(write: true).timetableSettings.connectDoubleLessons = e!; - Provider.of(context, listen: false).run(renew: false); - }, - ), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.star_border), - title: const Text('Favoriten im Talk nach oben sortieren'), - trailing: Checkbox( - value: settings.val().talkSettings.sortFavoritesToTop, - onChanged: (e) { - settings.val(write: true).talkSettings.sortFavoritesToTop = e!; - }, - ), - ), - - ListTile( - leading: const Icon(Icons.mark_email_unread_outlined), - title: const Text('Ungelesene Chats nach oben sortieren'), - trailing: Checkbox( - value: settings.val().talkSettings.sortUnreadToTop, - onChanged: (e) { - settings.val(write: true).talkSettings.sortUnreadToTop = e!; - }, - ), - ), - - ListTile( - leading: const CenteredLeading(Icon(Icons.notifications_active_outlined)), - title: const Text('Push-Benachrichtigungen aktivieren'), - subtitle: const Text('Lange tippen für mehr Informationen'), - trailing: Checkbox( - value: settings.val().notificationSettings.enabled, - onChanged: (e) { - if(e!) { - NotifyUpdater.enableAfterDisclaimer(settings).asDialog(context); - } else { - settings.val(write: true).notificationSettings.enabled = e; - } - }, - ), - onLongPress: () => showDialog(context: context, builder: (context) => AlertDialog( - title: const Text('Info über Push'), - content: const SingleChildScrollView(child: Text('' - "Aufgrund technischer Limitationen müssen Push-Nachrichten über einen externen Server - hier 'mhsl.eu' (Author dieser App) - erfolgen.\n\n" - 'Wenn Push aktiviert wird, werden deine Zugangsdaten und ein Token verschlüsselt an den Betreiber gesendet und von ihm unverschlüsselt gespeichert.\n\n' - 'Der extene Server verwendet die Zugangsdaten um sich maschinell in Nextcloud Talk anzumelden und via Websockets auf neue Nachrichten zu warten.\n\n' - 'Wenn eine neue Nachricht eintrifft wird dein Telefon via FBC-Messaging (Google Firebase Push) vom externen Server benachrichtigt.\n\n' - 'Behalte im Hinterkopf, dass deine Zugangsdaten auf einem externen Server gespeichert werden und dies trotz bester Absichten ein Sicherheitsrisiko sein kann!' - )), - actions: [ - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Zurück')) - ], - )), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.drive_folder_upload_outlined), - title: const Text('Ordner in Dateien nach oben sortieren'), - trailing: Checkbox( - value: settings.val().fileSettings.sortFoldersToTop, - onChanged: (e) { - settings.val(write: true).fileSettings.sortFoldersToTop = e!; - }, - ), - ), - - ListTile( - leading: const Icon(Icons.open_in_new_outlined), - title: const Text('Dateien immer mit Systemdialog öffnen'), - trailing: Checkbox( - value: settings.val().fileViewSettings.alwaysOpenExternally, - onChanged: (e) { - settings.val(write: true).fileViewSettings.alwaysOpenExternally = e!; - }, - ), - ), - - const Divider(), - - ListTile( - leading: const Icon(Icons.live_help_outlined), - title: const Text('Informationen und Lizenzen'), - onTap: () { - PackageInfo.fromPlatform().then((appInfo) { - showAboutDialog( - context: context, - applicationIcon: const Icon(Icons.apps), - applicationName: 'MarianumMobile', - applicationVersion: '${appInfo.appName}\n\nPackage: ${appInfo.packageName}\nVersion: ${appInfo.version}\nBuild: ${appInfo.buildNumber}', - applicationLegalese: 'Dies ist ein Inoffizieller Nextcloud & Webuntis Client und wird nicht vom Marianum selbst betrieben.\n' - 'Keinerlei Gewähr für Vollständigkeit, Richtigkeit und Aktualität!\n\n' - "${kReleaseMode ? "Production" : "Development"} build\n" - 'Marianum Fulda 2023-${Jiffy.now().year}\nElias Müller', - ); - }); - }, - trailing: const Icon(Icons.arrow_right), - ), - - ListTile( - leading: const Icon(Icons.policy_outlined), - title: const Text('Impressum & Datenschutz'), - onTap: () { - showDialog(context: context, builder: (context) => SimpleDialog( - children: [ - ListTile( - leading: const CenteredLeading(Icon(Icons.school_outlined)), - title: const Text('Infos zum Marianum Fulda'), - subtitle: const Text('Für Talk-Chats und Dateien'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo(providerText: 'Marianum', imprintUrl: 'https://www.marianum-fulda.de/impressum', privacyUrl: 'https://www.marianum-fulda.de/datenschutz').showPopup(context) - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.date_range_outlined)), - title: const Text('Infos zu Web-/ Untis'), - subtitle: const Text('Für den Stundenplan'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo(providerText: 'Untis', imprintUrl: 'https://www.untis.at/impressum', privacyUrl: 'https://www.untis.at/datenschutz-wu-apps').showPopup(context) - ), - ListTile( - leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)), - title: const Text('Infos zu mhsl'), - subtitle: const Text('Für Countdowns, Marianum Message und mehr'), - trailing: const Icon(Icons.arrow_right), - onTap: () => PrivacyInfo(providerText: 'mhsl', imprintUrl: 'https://mhsl.eu/id.html', privacyUrl: 'https://mhsl.eu/datenschutz.html').showPopup(context), - ), - ], - )); - }, - trailing: const Icon(Icons.arrow_right), - ), - - const Divider(), - - ListTile( - leading: const CenteredLeading(Icon(Icons.code)), - title: const Text('Quellcode MarianumMobile/Client'), - subtitle: const Text('GNU GPL v3'), - onTap: () => ConfirmDialog.openBrowser(context, 'https://mhsl.eu/gitea/MarianumMobile/Client'), - ), - - ListTile( - leading: const Icon(Icons.developer_mode_outlined), - title: const Text('Entwicklermodus'), - trailing: Checkbox( - value: settings.val().devToolsEnabled, - onChanged: (state) { - changeView() { - var enabled = state ?? false; - settings.val(write: true).devToolsEnabled = enabled; - if(!enabled) settings.val(write: true).devToolsSettings = DefaultSettings.get().devToolsSettings; - } - - if(!state!) { - changeView(); - return; - } - - ConfirmDialog( - title: 'Entwicklermodus', - content: '' - 'Die Entwickleransicht bietet erweiterte Funktionen, die für den üblichen Gebrauch nicht benötigt werden.\n\nDie Verwendung der Tools kann darüber hinaus bei falscher Verwendung zu Fehlern führen.\n\n' - 'Aktivieren auf eigene Verantwortung.', - confirmButton: 'Ja, ich verstehe das Risiko', - cancelButton: 'Nein, zurück zur App', - onConfirm: changeView, - ).asDialog(context); - }, - ), - ), - - Visibility( - visible: settings.val().devToolsEnabled, - child: DevToolsSettings(settings: settings), - ), - ], - ), - )); -} diff --git a/lib/widget/about/about.dart b/lib/widget/about/about.dart index 17003f2..0d1d30e 100644 --- a/lib/widget/about/about.dart +++ b/lib/widget/about/about.dart @@ -1,4 +1,3 @@ - import 'package:flutter/material.dart'; class About extends StatelessWidget { @@ -6,13 +5,11 @@ class About extends StatelessWidget { @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Über diese App'), - ), - body: const Card( - elevation: 1, - borderOnForeground: true, - child: Text('Marianum Fulda'), - ), - ); + appBar: AppBar(title: const Text('Über diese App')), + body: const Card( + elevation: 1, + borderOnForeground: true, + child: Text('Marianum Fulda'), + ), + ); } diff --git a/lib/widget/animatedTime.dart b/lib/widget/animatedTime.dart deleted file mode 100644 index ea9991e..0000000 --- a/lib/widget/animatedTime.dart +++ /dev/null @@ -1,59 +0,0 @@ -import 'dart:async'; - -import 'package:animated_digit/animated_digit.dart'; -import 'package:flutter/material.dart'; - -class AnimatedTime extends StatefulWidget { - final Duration Function() callback; - const AnimatedTime({super.key, required this.callback}); - - @override - State createState() => _AnimatedTimeState(); -} - -class _AnimatedTimeState extends State { - Duration current = Duration.zero; - late Timer timer; - - @override - void initState() { - super.initState(); - timer = Timer.periodic(const Duration(seconds: 1), (Timer t) => update()); - current = widget.callback(); - } - - void update() { - setState(() { - current = widget.callback(); - }); - } - - @override - Widget build(BuildContext context) => Row( - children: [ - const Text('Noch '), - buildWidget(current.inDays), - const Text(' Tage, '), - buildWidget(current.inHours > 24 ? current.inHours - current.inDays * 24 : current.inHours), - const Text(':'), - buildWidget(current.inMinutes > 60 ? current.inMinutes - current.inHours * 60 : current.inMinutes), - const Text(':'), - buildWidget(current.inSeconds > 60 ? current.inSeconds - current.inMinutes * 60 : current.inSeconds), - ], - ); - - AnimatedDigitWidget buildWidget(int value) => AnimatedDigitWidget( - value: value, - duration: const Duration(milliseconds: 100), - textStyle: TextStyle( - fontSize: 15, - color: Theme.of(context).colorScheme.onSurface, - ), - ); - - @override - void dispose() { - timer.cancel(); - super.dispose(); - } -} diff --git a/lib/widget/animated_time.dart b/lib/widget/animated_time.dart new file mode 100644 index 0000000..1ba4ccd --- /dev/null +++ b/lib/widget/animated_time.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class AnimatedTime extends StatefulWidget { + final Duration Function() callback; + const AnimatedTime({super.key, required this.callback}); + + @override + State createState() => _AnimatedTimeState(); +} + +class _AnimatedTimeState extends State { + Duration current = Duration.zero; + late Timer timer; + + @override + void initState() { + super.initState(); + timer = Timer.periodic(const Duration(seconds: 1), (Timer t) => update()); + current = widget.callback(); + } + + void update() { + setState(() { + current = widget.callback(); + }); + } + + @override + Widget build(BuildContext context) => Row( + children: [ + const Text('Noch '), + buildWidget(current.inDays), + const Text(' Tage, '), + buildWidget( + current.inHours > 24 + ? current.inHours - current.inDays * 24 + : current.inHours, + ), + const Text(':'), + buildWidget( + current.inMinutes > 60 + ? current.inMinutes - current.inHours * 60 + : current.inMinutes, + ), + const Text(':'), + buildWidget( + current.inSeconds > 60 + ? current.inSeconds - current.inMinutes * 60 + : current.inSeconds, + ), + ], + ); + + Widget buildWidget(int value) => AnimatedSwitcher( + duration: const Duration(milliseconds: 100), + transitionBuilder: (child, animation) => + FadeTransition(opacity: animation, child: child), + child: Text( + '$value', + key: ValueKey(value), + style: TextStyle( + fontSize: 15, + color: Theme.of(context).colorScheme.onSurface, + ), + ), + ); + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } +} diff --git a/lib/widget/app_progress_indicator.dart b/lib/widget/app_progress_indicator.dart new file mode 100644 index 0000000..c383687 --- /dev/null +++ b/lib/widget/app_progress_indicator.dart @@ -0,0 +1,35 @@ +import 'package:flutter/material.dart'; + +class AppProgressIndicator extends StatelessWidget { + final double size; + final double strokeWidth; + final Color? color; + + const AppProgressIndicator._({ + required this.size, + required this.strokeWidth, + this.color, + }); + + const AppProgressIndicator.small({Color? color}) + : this._(size: 16, strokeWidth: 2, color: color); + + const AppProgressIndicator.medium({Color? color}) + : this._(size: 24, strokeWidth: 2.5, color: color); + + const AppProgressIndicator.large({Color? color}) + : this._(size: 40, strokeWidth: 3, color: color); + + @override + Widget build(BuildContext context) { + final resolved = color ?? Theme.of(context).colorScheme.primary; + return SizedBox( + width: size, + height: size, + child: CircularProgressIndicator( + strokeWidth: strokeWidth, + valueColor: AlwaysStoppedAnimation(resolved), + ), + ); + } +} diff --git a/lib/widget/async_action_button.dart b/lib/widget/async_action_button.dart new file mode 100644 index 0000000..96ef4eb --- /dev/null +++ b/lib/widget/async_action_button.dart @@ -0,0 +1,20 @@ +/// Family of async-aware buttons + helpers. Implementation is split across +/// `async_actions/` for readability; everything still lives in this single +/// library so private widgets like `_AsyncMixin` and `_InlineErrorWrapper` +/// can stay private and shared. +library; + +import 'package:flutter/material.dart'; + +import '../api/errors/error_mapper.dart'; +import 'app_progress_indicator.dart'; +import 'info_dialog.dart'; + +part 'async_actions/async_action_controller.dart'; +part 'async_actions/async_action_button.dart'; +part 'async_actions/async_dialog_action.dart'; +part 'async_actions/async_fab.dart'; +part 'async_actions/async_icon_button.dart'; +part 'async_actions/async_list_tile.dart'; +part 'async_actions/async_mixin.dart'; +part 'async_actions/async_text_button.dart'; diff --git a/lib/widget/async_actions/async_action_button.dart b/lib/widget/async_actions/async_action_button.dart new file mode 100644 index 0000000..51e4373 --- /dev/null +++ b/lib/widget/async_actions/async_action_button.dart @@ -0,0 +1,58 @@ +part of '../async_action_button.dart'; + +class AsyncActionButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final IconData? icon; + final ButtonStyle? style; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncActionButton({ + required this.onPressed, + required this.child, + this.icon, + this.style, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final spinner = AppProgressIndicator.small( + color: Theme.of(context).colorScheme.onPrimary, + ); + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [spinner, const SizedBox(width: 8), child], + ) + : (icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [Icon(icon), const SizedBox(width: 8), child], + ) + : child); + final button = ElevatedButton( + onPressed: handler, + style: style, + child: content, + ); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); + }, + ); +} diff --git a/lib/widget/async_actions/async_action_controller.dart b/lib/widget/async_actions/async_action_controller.dart new file mode 100644 index 0000000..2046c22 --- /dev/null +++ b/lib/widget/async_actions/async_action_controller.dart @@ -0,0 +1,67 @@ +part of '../async_action_button.dart'; + +typedef AsyncActionCallback = Future Function(); +typedef AsyncErrorBuilder = String Function(Object error); + +/// Wraps [action] with a try/catch that pops up an [InfoDialog] on failure +/// (using [errorBuilder] or the default error mapper). Returns `true` on +/// success, `false` on caught failure. +Future runWithErrorDialog( + BuildContext context, + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, +}) async { + try { + await action(); + return true; + } catch (e) { + if (!context.mounted) return false; + final message = errorBuilder != null + ? errorBuilder(e) + : errorToUserMessage(e); + final details = errorToTechnicalDetails(e); + final body = details != null && details != message + ? '$message\n\n$details' + : message; + InfoDialog.show(context, body, copyable: true, title: 'Fehler'); + return false; + } +} + +/// Reusable busy/error state for the async-button family. Multiple buttons +/// can share the same controller (e.g. a parent toolbar wanting to disable +/// while any one child is running). +class AsyncActionController extends ChangeNotifier { + bool _busy = false; + String? _error; + + bool get busy => _busy; + String? get error => _error; + + Future run( + AsyncActionCallback action, { + AsyncErrorBuilder? errorBuilder, + }) async { + if (_busy) return false; + _busy = true; + _error = null; + notifyListeners(); + try { + await action(); + _busy = false; + notifyListeners(); + return true; + } catch (e) { + _busy = false; + _error = errorBuilder != null ? errorBuilder(e) : errorToUserMessage(e); + notifyListeners(); + return false; + } + } + + void clearError() { + if (_error == null) return; + _error = null; + notifyListeners(); + } +} diff --git a/lib/widget/async_actions/async_dialog_action.dart b/lib/widget/async_actions/async_dialog_action.dart new file mode 100644 index 0000000..e96d59b --- /dev/null +++ b/lib/widget/async_actions/async_dialog_action.dart @@ -0,0 +1,95 @@ +part of '../async_action_button.dart'; + +class AsyncDialogAction extends StatefulWidget { + final String confirmLabel; + final AsyncActionCallback onConfirm; + final String? cancelLabel; + final AsyncErrorBuilder? errorBuilder; + final ButtonStyle? confirmStyle; + + const AsyncDialogAction({ + required this.confirmLabel, + required this.onConfirm, + this.cancelLabel = 'Abbrechen', + this.errorBuilder, + this.confirmStyle, + super.key, + }); + + @override + State createState() => _AsyncDialogActionState(); +} + +class _AsyncDialogActionState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final err = _controller.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (err != null) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Text( + err, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (widget.cancelLabel != null) + TextButton( + onPressed: _controller.busy + ? null + : () => Navigator.of(context).pop(), + child: Text(widget.cancelLabel!), + ), + TextButton( + style: widget.confirmStyle, + onPressed: _controller.busy + ? null + : () async { + final ok = await _controller.run( + widget.onConfirm, + errorBuilder: widget.errorBuilder, + ); + if (ok && context.mounted) { + Navigator.of(context).pop(true); + } + }, + child: _controller.busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + Text(widget.confirmLabel), + ], + ) + : Text(widget.confirmLabel), + ), + ], + ), + ], + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_fab.dart b/lib/widget/async_actions/async_fab.dart new file mode 100644 index 0000000..e041d83 --- /dev/null +++ b/lib/widget/async_actions/async_fab.dart @@ -0,0 +1,48 @@ +part of '../async_action_button.dart'; + +class AsyncFab extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? backgroundColor; + final Color? foregroundColor; + final Object? heroTag; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool mini; + + const AsyncFab({ + required this.onPressed, + required this.icon, + this.backgroundColor, + this.foregroundColor, + this.heroTag, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.mini = false, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final fg = foregroundColor ?? Theme.of(context).colorScheme.onPrimary; + return FloatingActionButton( + heroTag: heroTag, + backgroundColor: backgroundColor, + foregroundColor: fg, + mini: mini, + onPressed: handler, + child: busy ? AppProgressIndicator.small(color: fg) : Icon(icon), + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_icon_button.dart b/lib/widget/async_actions/async_icon_button.dart new file mode 100644 index 0000000..46b5cbf --- /dev/null +++ b/lib/widget/async_actions/async_icon_button.dart @@ -0,0 +1,46 @@ +part of '../async_action_button.dart'; + +class AsyncIconButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final IconData icon; + final Color? color; + final String? tooltip; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + + const AsyncIconButton({ + required this.onPressed, + required this.icon, + this.color, + this.tooltip, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + if (busy) { + return Padding( + padding: const EdgeInsets.all(12), + child: AppProgressIndicator.small(color: color), + ); + } + return IconButton( + icon: Icon(icon, color: color), + tooltip: tooltip, + onPressed: handler, + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_list_tile.dart b/lib/widget/async_actions/async_list_tile.dart new file mode 100644 index 0000000..5422679 --- /dev/null +++ b/lib/widget/async_actions/async_list_tile.dart @@ -0,0 +1,91 @@ +part of '../async_action_button.dart'; + +class AsyncListTile extends StatefulWidget { + final AsyncActionCallback onPressed; + final Widget? leading; + final Widget title; + final Widget? subtitle; + final bool closeOnSuccess; + final VoidCallback? onSuccess; + final AsyncErrorBuilder? errorBuilder; + final bool enabled; + + const AsyncListTile({ + required this.onPressed, + required this.title, + this.leading, + this.subtitle, + this.closeOnSuccess = true, + this.onSuccess, + this.errorBuilder, + this.enabled = true, + super.key, + }); + + @override + State createState() => _AsyncListTileState(); +} + +class _AsyncListTileState extends State { + final AsyncActionController _controller = AsyncActionController(); + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + Future _handleTap() async { + final ok = await _controller.run( + widget.onPressed, + errorBuilder: widget.errorBuilder, + ); + if (!mounted) return; + if (ok) { + widget.onSuccess?.call(); + if (widget.closeOnSuccess && Navigator.of(context).canPop()) { + Navigator.of(context).pop(); + } + } + } + + @override + Widget build(BuildContext context) => AnimatedBuilder( + animation: _controller, + builder: (context, _) { + final busy = _controller.busy; + final err = _controller.error; + final leading = busy + ? const SizedBox( + width: 24, + height: 24, + child: AppProgressIndicator.small(), + ) + : widget.leading; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + ListTile( + leading: leading, + title: widget.title, + subtitle: widget.subtitle, + enabled: widget.enabled && !busy, + onTap: busy ? null : _handleTap, + ), + if (err != null) + Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), + child: Text( + err, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), + ), + ), + ], + ); + }, + ); +} diff --git a/lib/widget/async_actions/async_mixin.dart b/lib/widget/async_actions/async_mixin.dart new file mode 100644 index 0000000..85d2dc3 --- /dev/null +++ b/lib/widget/async_actions/async_mixin.dart @@ -0,0 +1,120 @@ +part of '../async_action_button.dart'; + +class _AsyncMixin extends StatefulWidget { + final AsyncActionCallback? onPressed; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final Widget Function(BuildContext context, bool busy, VoidCallback? handler) + builder; + + const _AsyncMixin({ + required this.onPressed, + required this.builder, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + }); + + @override + State<_AsyncMixin> createState() => _AsyncMixinState(); +} + +class _AsyncMixinState extends State<_AsyncMixin> { + late final AsyncActionController _internal; + AsyncActionController get _controller => widget.controller ?? _internal; + + @override + void initState() { + super.initState(); + if (widget.controller == null) { + _internal = AsyncActionController(); + } + _controller.addListener(_onControllerChange); + } + + @override + void didUpdateWidget(covariant _AsyncMixin oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller != widget.controller) { + (oldWidget.controller ?? _internal).removeListener(_onControllerChange); + _controller.addListener(_onControllerChange); + } + } + + @override + void dispose() { + _controller.removeListener(_onControllerChange); + if (widget.controller == null) { + _internal.dispose(); + } + super.dispose(); + } + + void _onControllerChange() { + if (mounted) setState(() {}); + } + + Future _trigger() async { + final action = widget.onPressed; + if (action == null) return; + final success = await _controller.run( + action, + errorBuilder: widget.errorBuilder, + ); + if (!mounted) return; + if (success) { + widget.onSuccess?.call(); + } else if (widget.onError != null && _controller.error != null) { + widget.onError!(_controller.error!); + } + } + + @override + Widget build(BuildContext context) { + final handler = widget.onPressed == null ? null : _trigger; + return widget.builder( + context, + _controller.busy, + _controller.busy ? null : handler, + ); + } +} + +class _InlineErrorWrapper extends StatelessWidget { + final AsyncActionController? controller; + final Widget child; + const _InlineErrorWrapper({required this.controller, required this.child}); + + @override + Widget build(BuildContext context) { + final c = controller; + if (c == null) return child; + return AnimatedBuilder( + animation: c, + builder: (context, _) { + final err = c.error; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + child, + if (err != null) ...[ + const SizedBox(height: 8), + Text( + err, + textAlign: TextAlign.center, + style: TextStyle( + color: Theme.of(context).colorScheme.error, + fontSize: 13, + ), + ), + ], + ], + ); + }, + ); + } +} diff --git a/lib/widget/async_actions/async_text_button.dart b/lib/widget/async_actions/async_text_button.dart new file mode 100644 index 0000000..725ac79 --- /dev/null +++ b/lib/widget/async_actions/async_text_button.dart @@ -0,0 +1,48 @@ +part of '../async_action_button.dart'; + +class AsyncTextButton extends StatelessWidget { + final AsyncActionCallback? onPressed; + final Widget child; + final AsyncActionController? controller; + final AsyncErrorBuilder? errorBuilder; + final void Function(String message)? onError; + final VoidCallback? onSuccess; + final bool showInlineError; + + const AsyncTextButton({ + required this.onPressed, + required this.child, + this.controller, + this.errorBuilder, + this.onError, + this.onSuccess, + this.showInlineError = true, + super.key, + }); + + @override + Widget build(BuildContext context) => _AsyncMixin( + onPressed: onPressed, + controller: controller, + errorBuilder: errorBuilder, + onError: onError, + onSuccess: onSuccess, + builder: (context, busy, handler) { + final content = busy + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppProgressIndicator.small( + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(width: 8), + child, + ], + ) + : child; + final button = TextButton(onPressed: handler, child: content); + if (!showInlineError) return button; + return _InlineErrorWrapper(controller: controller, child: button); + }, + ); +} diff --git a/lib/widget/breaker/breaker.dart b/lib/widget/breaker/breaker.dart new file mode 100644 index 0000000..b969096 --- /dev/null +++ b/lib/widget/breaker/breaker.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../api/mhsl/breaker/get_breakers/get_breakers_response.dart'; +import '../../state/app/modules/breaker/bloc/breaker_bloc.dart'; +import '../../widget/placeholder_view.dart'; + +class Breaker extends StatelessWidget { + final BreakerArea breaker; + final Widget child; + + const Breaker({required this.breaker, required this.child, super.key}); + + @override + Widget build(BuildContext context) { + final bloc = context.watch(); + final blocked = bloc.isBlocked(breaker); + if (blocked != null) { + return PlaceholderView( + icon: Icons.app_blocking_outlined, + text: + 'Die App / Dieser Bereich ist zurzeit nicht verfügbar!\n\n' + '${blocked.isEmpty ? "Es wurde vom Server kein Grund übermittelt.\nAktualisiere die App und versuche es später erneut" : blocked}', + ); + } + return child; + } +} diff --git a/lib/widget/centeredLeading.dart b/lib/widget/centered_leading.dart similarity index 62% rename from lib/widget/centeredLeading.dart rename to lib/widget/centered_leading.dart index 2993e3f..be8bc15 100644 --- a/lib/widget/centeredLeading.dart +++ b/lib/widget/centered_leading.dart @@ -6,8 +6,8 @@ class CenteredLeading extends StatelessWidget { @override Widget build(BuildContext context) => Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.center, - children: [child], - ); + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + children: [child], + ); } diff --git a/lib/widget/clickableAppBar.dart b/lib/widget/clickable_app_bar.dart similarity index 78% rename from lib/widget/clickableAppBar.dart rename to lib/widget/clickable_app_bar.dart index a928113..c74a692 100644 --- a/lib/widget/clickableAppBar.dart +++ b/lib/widget/clickable_app_bar.dart @@ -6,7 +6,8 @@ class ClickableAppBar extends StatelessWidget implements PreferredSizeWidget { const ClickableAppBar({required this.onTap, required this.appBar, super.key}); @override - Widget build(BuildContext context) => GestureDetector(onTap: onTap, child: appBar); + Widget build(BuildContext context) => + GestureDetector(onTap: onTap, child: appBar); @override Size get preferredSize => appBar.preferredSize; diff --git a/lib/widget/confirmDialog.dart b/lib/widget/confirmDialog.dart deleted file mode 100644 index 8e2f01f..0000000 --- a/lib/widget/confirmDialog.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:url_launcher/url_launcher.dart'; - -class ConfirmDialog extends StatelessWidget { - final String title; - final String content; - final IconData? icon; - final String confirmButton; - final String cancelButton; - final void Function() onConfirm; - const ConfirmDialog({super.key, required this.title, this.content = '', this.icon, this.confirmButton = 'Ok', this.cancelButton = 'Abbrechen', required this.onConfirm}); - - void asDialog(BuildContext context) { - showDialog(context: context, builder: build); - } - - @override - Widget build(BuildContext context) => AlertDialog( - icon: icon != null ? Icon(icon) : null, - title: Text(title), - content: Text(content), - actions: [ - TextButton(onPressed: () { - Navigator.of(context).pop(); - }, child: Text(cancelButton)), - TextButton(onPressed: () { - Navigator.of(context).pop(); - onConfirm(); - }, child: Text(confirmButton)), - ], - ); - - static void openBrowser(BuildContext context, String url) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Link öffnen', - content: 'Möchtest du den folgenden Link öffnen?\n$url', - confirmButton: 'Öffnen', - onConfirm: () => launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), - ), - ); - } -} diff --git a/lib/widget/confirm_dialog.dart b/lib/widget/confirm_dialog.dart new file mode 100644 index 0000000..acdcf65 --- /dev/null +++ b/lib/widget/confirm_dialog.dart @@ -0,0 +1,76 @@ +import 'package:flutter/material.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import 'async_action_button.dart'; + +class ConfirmDialog extends StatelessWidget { + final String title; + final String content; + final IconData? icon; + final String confirmButton; + final String cancelButton; + final void Function()? onConfirm; + final AsyncActionCallback? onConfirmAsync; + final AsyncErrorBuilder? errorBuilder; + + const ConfirmDialog({ + super.key, + required this.title, + this.content = '', + this.icon, + this.confirmButton = 'Ok', + this.cancelButton = 'Abbrechen', + this.onConfirm, + this.onConfirmAsync, + this.errorBuilder, + }) : assert( + onConfirm != null || onConfirmAsync != null, + 'ConfirmDialog requires either onConfirm or onConfirmAsync', + ); + + void asDialog(BuildContext context) { + showDialog(context: context, builder: build); + } + + @override + Widget build(BuildContext context) => AlertDialog( + icon: icon != null ? Icon(icon) : null, + title: Text(title), + content: Text(content), + actions: onConfirmAsync != null + ? [ + AsyncDialogAction( + confirmLabel: confirmButton, + cancelLabel: cancelButton, + onConfirm: onConfirmAsync!, + errorBuilder: errorBuilder, + ), + ] + : [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(cancelButton), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(); + onConfirm!(); + }, + child: Text(confirmButton), + ), + ], + ); + + static void openBrowser(BuildContext context, String url) { + showDialog( + context: context, + builder: (context) => ConfirmDialog( + title: 'Link öffnen', + content: 'Möchtest du den folgenden Link öffnen?\n$url', + confirmButton: 'Öffnen', + onConfirm: () => + launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication), + ), + ); + } +} diff --git a/lib/widget/debug/cacheView.dart b/lib/widget/debug/cacheView.dart deleted file mode 100644 index ab66605..0000000 --- a/lib/widget/debug/cacheView.dart +++ /dev/null @@ -1,84 +0,0 @@ - -import 'dart:async'; -import 'dart:convert'; -import 'package:filesize/filesize.dart'; -import 'package:flutter/material.dart'; -import 'package:jiffy/jiffy.dart'; -import 'package:localstore/localstore.dart'; - -import '../../../widget/placeholderView.dart'; -import '../../api/requestCache.dart'; -import 'jsonViewer.dart'; - -class CacheView extends StatefulWidget { - const CacheView({super.key}); - - @override - State createState() => _CacheViewState(); - - Future clear() async { - await Localstore.instance.collection(RequestCache.collection).delete(); - } - - Future totalSize() async { - var data = await Localstore.instance.collection(RequestCache.collection).get(); - if(data!.length <= 1) return jsonEncode(data.values.first).length * 8; - return data.values.reduce((a, b) => jsonEncode(a).length + jsonEncode(b).length) * 8; - } -} - -class _CacheViewState extends State { - final Localstore storage = Localstore.instance; - late Future?> files; - - @override - void initState() { - files = Localstore.instance.collection(RequestCache.collection).get(); - super.initState(); - } - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Cache storage'), - ), - body: FutureBuilder( - future: files, - builder: (context, snapshot) { - if(snapshot.hasData) { - return ListView.builder( - itemCount: snapshot.data!.length, - itemBuilder: (context, index) { - Map element = snapshot.data![snapshot.data!.keys.elementAt(index)]; - var filename = snapshot.data!.keys.elementAt(index).split('/').last; - - return ListTile( - leading: const Icon(Icons.text_snippet_outlined), - title: Text(filename), - subtitle: Text("${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate']).fromNow()}"), - trailing: const Icon(Icons.arrow_right), - onTap: () => JsonViewer.asDialog(context, jsonDecode(element['json'])), - ); - }, - ); - } else if(snapshot.connectionState != ConnectionState.done) { - return const Center( - child: CircularProgressIndicator() - ); - } else { - return const Center( - child: PlaceholderView(icon: Icons.hourglass_empty, text: 'Keine Daten'), - ); - } - }, - ), - ); -} - -extension FutureExtension on Future { - bool isCompleted() { - final completer = Completer(); - then(completer.complete).catchError(completer.completeError); - return completer.isCompleted; - } -} diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart new file mode 100644 index 0000000..abfa8c9 --- /dev/null +++ b/lib/widget/debug/cache_view.dart @@ -0,0 +1,94 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:filesize/filesize.dart'; +import 'package:flutter/material.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:localstore/localstore.dart'; + +import '../../../widget/placeholder_view.dart'; +import '../../api/request_cache.dart'; +import 'json_viewer.dart'; + +class CacheView extends StatefulWidget { + const CacheView({super.key}); + + @override + State createState() => _CacheViewState(); + + Future clear() async { + await Localstore.instance.collection(RequestCache.collection).delete(); + } + + Future totalSize() async { + final data = await Localstore.instance + .collection(RequestCache.collection) + .get(); + if (data == null || data.isEmpty) return 0; + return data.values.fold( + 0, + (sum, value) => sum + jsonEncode(value).length, + ) * + 8; + } +} + +class _CacheViewState extends State { + final Localstore storage = Localstore.instance; + late Future?> files; + + @override + void initState() { + files = Localstore.instance.collection(RequestCache.collection).get(); + super.initState(); + } + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Cache storage')), + body: FutureBuilder( + future: files, + builder: (context, snapshot) { + if (snapshot.hasData) { + return ListView.builder( + itemCount: snapshot.data!.length, + itemBuilder: (context, index) { + final key = snapshot.data!.keys.elementAt(index); + final element = snapshot.data![key] as Map; + final filename = key.split('/').last; + + return ListTile( + leading: const Icon(Icons.text_snippet_outlined), + title: Text(filename), + subtitle: Text( + '${filesize(jsonEncode(element).length * 8)}, ${Jiffy.parseFromMillisecondsSinceEpoch(element['lastupdate'] as int).fromNow()}', + ), + trailing: const Icon(Icons.arrow_right), + onTap: () => JsonViewer.asDialog( + context, + jsonDecode(element['json'] as String) as Map, + ), + ); + }, + ); + } else if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } else { + return const Center( + child: PlaceholderView( + icon: Icons.hourglass_empty, + text: 'Keine Daten', + ), + ); + } + }, + ), + ); +} + +extension FutureExtension on Future { + bool isCompleted() { + final completer = Completer(); + then(completer.complete).catchError(completer.completeError); + return completer.isCompleted; + } +} diff --git a/lib/widget/debug/debugTile.dart b/lib/widget/debug/debugTile.dart deleted file mode 100644 index bc5a8c4..0000000 --- a/lib/widget/debug/debugTile.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -import '../../storage/base/settingsProvider.dart'; -import '../centeredLeading.dart'; -import 'jsonViewer.dart'; - -class DebugTile { - BuildContext context; - bool onlyInDebug; - DebugTile(this.context, {this.onlyInDebug = false}); - - bool devConditionFulfilled() => Provider.of(context, listen: false).val().devToolsEnabled && (onlyInDebug ? kDebugMode : true); - - Widget jsonData(Map data, {bool ignoreConfig = false}) => callback( - title: 'JSON daten anzeigen', - onTab: () => JsonViewer.asDialog(context, data) - ); - - Widget callback({String title = 'Debugaktion', required void Function() onTab}) => child( - ListTile( - leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), - title: Text(title), - subtitle: const Text('Entwicklermodus aktiviert'), - onTap: onTab, - ) - ); - - Widget child(Widget child) => Visibility( - visible: devConditionFulfilled(), - child: child, - ); - - void run(void Function() callback) { - if(!devConditionFulfilled()) return; - callback(); - } -} diff --git a/lib/widget/debug/debug_tile.dart b/lib/widget/debug/debug_tile.dart new file mode 100644 index 0000000..f167d1c --- /dev/null +++ b/lib/widget/debug/debug_tile.dart @@ -0,0 +1,43 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../state/app/modules/settings/bloc/settings_cubit.dart'; +import '../centered_leading.dart'; +import 'json_viewer.dart'; + +class DebugTile { + BuildContext context; + bool onlyInDebug; + DebugTile(this.context, {this.onlyInDebug = false}); + + bool devConditionFulfilled() => + context.read().val().devToolsEnabled && + (onlyInDebug ? kDebugMode : true); + + Widget jsonData(Map data, {bool ignoreConfig = false}) => + callback( + title: 'JSON daten anzeigen', + onTab: () => JsonViewer.asDialog(context, data), + ); + + Widget callback({ + String title = 'Debugaktion', + required void Function() onTab, + }) => child( + ListTile( + leading: const CenteredLeading(Icon(Icons.developer_mode_outlined)), + title: Text(title), + subtitle: const Text('Entwicklermodus aktiviert'), + onTap: onTab, + ), + ); + + Widget child(Widget child) => + Visibility(visible: devConditionFulfilled(), child: child); + + void run(void Function() callback) { + if (!devConditionFulfilled()) return; + callback(); + } +} diff --git a/lib/widget/debug/jsonViewer.dart b/lib/widget/debug/jsonViewer.dart deleted file mode 100644 index 465a98b..0000000 --- a/lib/widget/debug/jsonViewer.dart +++ /dev/null @@ -1,44 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; -import 'package:pretty_json/pretty_json.dart'; - -class JsonViewer extends StatelessWidget { - final String title; - final Map data; - - const JsonViewer({super.key, required this.title, required this.data}); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: Text(title), - ), - body: SingleChildScrollView( - scrollDirection: Axis.vertical, - child: Text(format(data)), - ), - ); - - static String format(Map jsonInput) => prettyJson(jsonInput, indent: 2); - - static void asDialog(BuildContext context, Map dataMap) { - showDialog(context: context, builder: (context) => AlertDialog( - scrollable: true, - title: const Row(children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')]), - content: Text(JsonViewer.format(dataMap)), - actions: [ - TextButton(onPressed: () { - Clipboard.setData(ClipboardData(text: JsonViewer.format(dataMap))).then((value) { - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Formatiertes JSON wurde erfolgreich in deiner Zwischenlage abgelegt.'))); - }); - }, child: const Text('Kopieren')), - TextButton(onPressed: () { - Clipboard.setData(ClipboardData(text: dataMap.toString())).then((value) { - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Unformatiertes JSON wurde erfolgreich in deiner Zwischenablage abgelegt.'))); - }); - }, child: const Text('Inline Kopieren')), - TextButton(onPressed: () => Navigator.of(context).pop(), child: const Text('Schließen')) - ], - )); - } -} diff --git a/lib/widget/debug/json_viewer.dart b/lib/widget/debug/json_viewer.dart new file mode 100644 index 0000000..57e13a0 --- /dev/null +++ b/lib/widget/debug/json_viewer.dart @@ -0,0 +1,61 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; + +import '../../utils/clipboard_helper.dart'; + +class JsonViewer extends StatelessWidget { + final String title; + final Map data; + + const JsonViewer({super.key, required this.title, required this.data}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: Text(title)), + body: SingleChildScrollView( + scrollDirection: Axis.vertical, + child: Text(format(data)), + ), + ); + + static final _encoder = const JsonEncoder.withIndent(' '); + + static String format(Map jsonInput) => + _encoder.convert(jsonInput); + + static void asDialog(BuildContext context, Map dataMap) { + showDialog( + context: context, + builder: (dialogCtx) => AlertDialog( + scrollable: true, + title: const Row( + children: [Icon(Icons.bug_report_outlined), Text('Rohdaten')], + ), + content: Text(JsonViewer.format(dataMap)), + actions: [ + TextButton( + onPressed: () => copyToClipboard( + dialogCtx, + JsonViewer.format(dataMap), + successMessage: 'Formatiertes JSON kopiert', + ), + child: const Text('Kopieren'), + ), + TextButton( + onPressed: () => copyToClipboard( + dialogCtx, + dataMap.toString(), + successMessage: 'Inline JSON kopiert', + ), + child: const Text('Inline Kopieren'), + ), + TextButton( + onPressed: () => Navigator.of(dialogCtx).pop(), + child: const Text('Schließen'), + ), + ], + ), + ); + } +} diff --git a/lib/widget/details_bottom_sheet.dart b/lib/widget/details_bottom_sheet.dart new file mode 100644 index 0000000..db6aff2 --- /dev/null +++ b/lib/widget/details_bottom_sheet.dart @@ -0,0 +1,31 @@ +import 'package:flutter/material.dart'; + +/// Shows a modal bottom sheet for a detail view (appointment, file, lesson, +/// custom event, etc.). All detail sheets in the app share this layout: drag +/// handle on top, default theme background, optional ListTile-style header +/// followed by a divider, scrollable body below. +void showDetailsBottomSheet( + BuildContext context, { + Widget? header, + required List Function(BuildContext sheetContext) children, +}) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + showDragHandle: true, + useSafeArea: true, + builder: (sheetContext) => SafeArea( + child: SingleChildScrollView( + padding: const EdgeInsets.only(bottom: 16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (header != null) ...[header, const Divider(height: 1)], + ...children(sheetContext), + ], + ), + ), + ), + ); +} diff --git a/lib/widget/dropdownDisplay.dart b/lib/widget/dropdown_display.dart similarity index 100% rename from lib/widget/dropdownDisplay.dart rename to lib/widget/dropdown_display.dart diff --git a/lib/widget/filePick.dart b/lib/widget/filePick.dart deleted file mode 100644 index b9d7dbb..0000000 --- a/lib/widget/filePick.dart +++ /dev/null @@ -1,29 +0,0 @@ - -import 'package:file_picker/file_picker.dart'; -import 'package:image_picker/image_picker.dart'; - -class FilePick { - static final _picker = ImagePicker(); - - static Future galleryPick() async { - final pickedImage = await _picker.pickImage(source: ImageSource.gallery); - if (pickedImage != null) { - return pickedImage; - } - return null; - } - - static Future?> multipleGalleryPick() async { - final pickedImages = await _picker.pickMultiImage(); - if(pickedImages.isNotEmpty) { - return pickedImages; - } - return null; - } - - static Future?> documentPick() async { - var result = await FilePicker.platform.pickFiles(allowMultiple: true); - var paths = result?.files.nonNulls.map((e) => e.path).toList(); - return paths?.nonNulls.toList(); - } -} diff --git a/lib/widget/fileViewer.dart b/lib/widget/fileViewer.dart deleted file mode 100644 index 54f6557..0000000 --- a/lib/widget/fileViewer.dart +++ /dev/null @@ -1,158 +0,0 @@ -import 'dart:io'; -import 'dart:math'; - -import 'package:flutter/material.dart'; -import 'package:open_filex/open_filex.dart'; -import 'package:photo_view/photo_view.dart'; -import 'package:provider/provider.dart'; -import 'package:share_plus/share_plus.dart'; -import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; - -import '../storage/base/settingsProvider.dart'; -import '../utils/FileSaver.dart'; -import 'infoDialog.dart'; -import 'placeholderView.dart'; -import 'sharePositionOrigin.dart'; - -class FileViewer extends StatefulWidget { - final String path; - final bool openExternal; - const FileViewer({super.key, required this.path, this.openExternal = false}); - - @override - State createState() => _FileViewerState(); -} - -enum FileViewingActions { - openExternal, - share, - save -} - -class _FileViewerState extends State { - PhotoViewController photoViewController = PhotoViewController(); - - late SettingsProvider settings = Provider.of(context, listen: false); - late bool openExternal; - - @override - void initState() { - openExternal = settings.val().fileViewSettings.alwaysOpenExternally || widget.openExternal; - super.initState(); - } - - @override - Widget build(BuildContext context) { - AppBar appbar({List actions = const []}) => AppBar( - title: Text(widget.path.split('/').last), - actions: [ - ...actions, - PopupMenuButton( - onSelected: (value) async { - switch(value) { - case FileViewingActions.openExternal: - Navigator.of(context).push( - MaterialPageRoute(builder: (context) => FileViewer(path: widget.path, openExternal: true)) - ); - break; - case FileViewingActions.share: - SharePlus.instance.share( - ShareParams( - files: [XFile(widget.path)], - sharePositionOrigin: SharePositionOrigin.get(context) - ) - ); - break; - case FileViewingActions.save: - await FileSaver.writeBytes(await File(widget.path).readAsBytes(), widget.path.split('/').last); - if(!context.mounted) return; - InfoDialog.show(context, 'Die Datei wurde im Downloads Ordner gespeichert.'); - break; - } - }, - itemBuilder: (context) => >[ - const PopupMenuItem( - value: FileViewingActions.openExternal, - child: ListTile( - leading: Icon(Icons.open_in_new), - title: Text('Extern öffnen'), - dense: true, - ), - ), - const PopupMenuItem( - value: FileViewingActions.share, - child: ListTile( - leading: Icon(Icons.share_outlined), - title: Text('Teilen'), - dense: true, - ), - ), - if(Platform.isAndroid) const PopupMenuItem( - value: FileViewingActions.save, - child: ListTile( - leading: Icon(Icons.save_alt_outlined), - title: Text('Speichern'), - dense: true, - ), - ), - ], - ), - ], - ); - - switch(openExternal ? '' : widget.path.split('.').last.toLowerCase()) { - case 'png': - case 'jpg': - case 'jpeg': - case 'webp': - case 'gif': - return Scaffold( - appBar: appbar( - actions: [ - IconButton(onPressed: () { - setState(() { - photoViewController.rotation += pi/2; - }); - }, icon: const Icon(Icons.rotate_right)), - ] - ), - backgroundColor: Colors.white, - body: PhotoView( - controller: photoViewController, - maxScale: 3.0, - minScale: 0.1, - imageProvider: Image.file(File(widget.path)).image, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - ) - ); - - - case 'pdf': - return Scaffold( - appBar: appbar(), - body: SfPdfViewer.file( - File(widget.path), - ), - ); - - default: - OpenFilex.open(widget.path).then((result) { - Navigator.of(context).pop(); - if(result.type != ResultType.done) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(result.message), - )); - } - }); - - return PlaceholderView( - text: 'Datei extern geöffnet', - icon: Icons.open_in_new, - button: TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Zurück'), - ), - ); - } - } -} diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart new file mode 100644 index 0000000..130880e --- /dev/null +++ b/lib/widget/file_pick.dart @@ -0,0 +1,20 @@ +import 'package:file_picker/file_picker.dart'; +import 'package:image_picker/image_picker.dart'; + +class FilePick { + static final _picker = ImagePicker(); + + static Future?> multipleGalleryPick() async { + final pickedImages = await _picker.pickMultiImage(); + return pickedImages.isNotEmpty ? pickedImages : null; + } + + static Future cameraPick() => + _picker.pickImage(source: ImageSource.camera); + + static Future?> documentPick() async { + final result = await FilePicker.pickFiles(allowMultiple: true); + final paths = result?.files.nonNulls.map((e) => e.path).toList(); + return paths?.nonNulls.toList(); + } +} diff --git a/lib/widget/file_viewer.dart b/lib/widget/file_viewer.dart new file mode 100644 index 0000000..6440bad --- /dev/null +++ b/lib/widget/file_viewer.dart @@ -0,0 +1,234 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:open_filex/open_filex.dart'; +import 'package:photo_view/photo_view.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; + +import '../routing/app_routes.dart'; +import '../state/app/modules/settings/bloc/settings_cubit.dart'; +import 'info_dialog.dart'; +import 'placeholder_view.dart'; +import 'share_position_origin.dart'; + +class FileViewer extends StatefulWidget { + final String path; + final bool openExternal; + const FileViewer({super.key, required this.path, this.openExternal = false}); + + @override + State createState() => _FileViewerState(); +} + +enum FileViewingActions { openExternal, share, save } + +/// Workaround for a Syncfusion PDF viewer race: SfPdfViewer's internal +/// LayoutBuilder calls `localToGlobal` during build, which asserts when an +/// ancestor RenderTransform (from the page-push animation) is still mid-layout. +/// We wait for the route's enter animation to complete before mounting it. +class _DeferredPdfViewer extends StatefulWidget { + const _DeferredPdfViewer({required this.path}); + final String path; + + @override + State<_DeferredPdfViewer> createState() => _DeferredPdfViewerState(); +} + +class _DeferredPdfViewerState extends State<_DeferredPdfViewer> { + bool _ready = false; + Animation? _routeAnimation; + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + if (_ready || _routeAnimation != null) return; + final animation = ModalRoute.of(context)?.animation; + if (animation == null || animation.isCompleted) { + _ready = true; + return; + } + _routeAnimation = animation..addStatusListener(_onAnimationStatus); + } + + void _onAnimationStatus(AnimationStatus status) { + if (status == AnimationStatus.completed && mounted) { + setState(() => _ready = true); + } + } + + @override + void dispose() { + _routeAnimation?.removeStatusListener(_onAnimationStatus); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (!_ready) { + return const Center(child: CircularProgressIndicator()); + } + return SfPdfViewer.file(File(widget.path)); + } +} + +class _FileViewerState extends State { + final PhotoViewController photoViewController = PhotoViewController(); + + late SettingsCubit settings = context.read(); + late bool openExternal; + + @override + void initState() { + openExternal = + settings.val().fileViewSettings.alwaysOpenExternally || + widget.openExternal; + super.initState(); + } + + @override + void dispose() { + photoViewController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + AppBar appbar({List actions = const []}) => AppBar( + title: Text(widget.path.split('/').last), + actions: [ + ...actions, + PopupMenuButton( + onSelected: (value) async { + switch (value) { + case FileViewingActions.openExternal: + AppRoutes.openFileViewer( + context, + widget.path, + openExternal: true, + ); + break; + case FileViewingActions.share: + unawaited( + SharePlus.instance.share( + ShareParams( + files: [XFile(widget.path)], + sharePositionOrigin: SharePositionOrigin.get(context), + ), + ), + ); + break; + case FileViewingActions.save: + try { + final bytes = await File(widget.path).readAsBytes(); + final saved = await FilePicker.saveFile( + fileName: widget.path.split('/').last, + bytes: bytes, + ); + if (!context.mounted) return; + if (saved != null) { + InfoDialog.show(context, 'Datei gespeichert.'); + } + } on Object catch (e) { + if (!context.mounted) return; + InfoDialog.show( + context, + 'Speichern fehlgeschlagen: $e', + copyable: true, + title: 'Fehler', + ); + } + break; + } + }, + itemBuilder: (context) => >[ + const PopupMenuItem( + value: FileViewingActions.openExternal, + child: ListTile( + leading: Icon(Icons.open_in_new), + title: Text('Extern öffnen'), + dense: true, + ), + ), + const PopupMenuItem( + value: FileViewingActions.share, + child: ListTile( + leading: Icon(Icons.share_outlined), + title: Text('Teilen'), + dense: true, + ), + ), + const PopupMenuItem( + value: FileViewingActions.save, + child: ListTile( + leading: Icon(Icons.save_alt_outlined), + title: Text('Speichern'), + dense: true, + ), + ), + ], + ), + ], + ); + + switch (openExternal ? '' : widget.path.split('.').last.toLowerCase()) { + case 'png': + case 'jpg': + case 'jpeg': + case 'webp': + case 'gif': + return Scaffold( + appBar: appbar( + actions: [ + IconButton( + onPressed: () { + setState(() { + photoViewController.rotation += pi / 2; + }); + }, + icon: const Icon(Icons.rotate_right), + ), + ], + ), + backgroundColor: Colors.white, + body: PhotoView( + controller: photoViewController, + maxScale: 3.0, + minScale: 0.1, + imageProvider: Image.file(File(widget.path)).image, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + ), + ); + + case 'pdf': + return Scaffold( + appBar: appbar(), + body: _DeferredPdfViewer(path: widget.path), + ); + + default: + OpenFilex.open(widget.path).then((result) { + if (!context.mounted) return; + Navigator.of(context).pop(); + if (result.type != ResultType.done) { + InfoDialog.show(context, result.message); + } + }); + + return PlaceholderView( + text: 'Datei extern geöffnet', + icon: Icons.open_in_new, + button: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Zurück'), + ), + ); + } + } +} diff --git a/lib/widget/focusBehaviour.dart b/lib/widget/focus_behaviour.dart similarity index 100% rename from lib/widget/focusBehaviour.dart rename to lib/widget/focus_behaviour.dart diff --git a/lib/widget/infoDialog.dart b/lib/widget/infoDialog.dart deleted file mode 100644 index e30a61e..0000000 --- a/lib/widget/infoDialog.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:flutter/material.dart'; - -class InfoDialog { - static show(BuildContext context, String info) { - showDialog(context: context, builder: (context) => AlertDialog( - content: Text(info), - contentPadding: const EdgeInsets.all(20), - )); - } -} diff --git a/lib/widget/info_dialog.dart b/lib/widget/info_dialog.dart new file mode 100644 index 0000000..22c4624 --- /dev/null +++ b/lib/widget/info_dialog.dart @@ -0,0 +1,43 @@ +import 'package:flutter/material.dart'; + +import '../utils/clipboard_helper.dart'; + +class InfoDialog { + /// Shows a single-text dialog. When [copyable] is true (default for error + /// details surfaces), the dialog body is selectable and a "Kopieren" action + /// places it on the clipboard with a SnackBar confirmation. + static void show( + BuildContext context, + String info, { + bool copyable = false, + String? title, + }) { + showDialog( + context: context, + builder: (dialogContext) { + final theme = Theme.of(dialogContext); + return AlertDialog( + title: title != null ? Text(title) : null, + content: SingleChildScrollView( + child: copyable + ? SelectableText(info, style: theme.textTheme.bodyMedium) + : Text(info, style: theme.textTheme.bodyMedium), + ), + contentPadding: const EdgeInsets.fromLTRB(20, 20, 20, 12), + actions: [ + if (copyable) + TextButton.icon( + onPressed: () => copyToClipboard(dialogContext, info), + icon: const Icon(Icons.copy_outlined, size: 18), + label: const Text('Kopieren'), + ), + TextButton( + onPressed: () => Navigator.of(dialogContext).pop(), + child: const Text('Schließen'), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widget/largeProfilePictureView.dart b/lib/widget/largeProfilePictureView.dart deleted file mode 100644 index 65a9a4f..0000000 --- a/lib/widget/largeProfilePictureView.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:photo_view/photo_view.dart'; - -import '../model/endpointData.dart'; - -class LargeProfilePictureView extends StatelessWidget { - final String username; - const LargeProfilePictureView(this.username, {super.key}); - - @override - Widget build(BuildContext context) => Scaffold( - appBar: AppBar( - title: const Text('Profilbild'), - ), - body: PhotoView( - imageProvider: Image.network('https://${EndpointData().nextcloud().full()}/avatar/$username/1024').image, - backgroundDecoration: BoxDecoration(color: Theme.of(context).colorScheme.surface), - ), - ); -} diff --git a/lib/widget/large_profile_picture_view.dart b/lib/widget/large_profile_picture_view.dart new file mode 100644 index 0000000..6751bdd --- /dev/null +++ b/lib/widget/large_profile_picture_view.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:photo_view/photo_view.dart'; + +import '../model/endpoint_data.dart'; + +class LargeProfilePictureView extends StatelessWidget { + final String username; + const LargeProfilePictureView(this.username, {super.key}); + + @override + Widget build(BuildContext context) => Scaffold( + appBar: AppBar(title: const Text('Profilbild')), + body: PhotoView( + imageProvider: Image.network( + 'https://${EndpointData().nextcloud().full()}/avatar/$username/1024', + ).image, + backgroundDecoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + ), + ), + ); +} diff --git a/lib/widget/list_view_util.dart b/lib/widget/list_view_util.dart index 468ae95..efa8ee8 100644 --- a/lib/widget/list_view_util.dart +++ b/lib/widget/list_view_util.dart @@ -1,9 +1,10 @@ - import 'package:flutter/material.dart'; class ListViewUtil { - static ListView fromList(List? items, Widget Function(T item) map) => ListView.builder( - itemCount: items?.length ?? 0, - itemBuilder: (context, index) => items != null ? map(items[index]) : null, - ); + static ListView fromList(List? items, Widget Function(T item) map) => + ListView.builder( + itemCount: items?.length ?? 0, + itemBuilder: (context, index) => + items != null ? map(items[index]) : null, + ); } diff --git a/lib/widget/loadingSpinner.dart b/lib/widget/loadingSpinner.dart deleted file mode 100644 index 4cf9ab0..0000000 --- a/lib/widget/loadingSpinner.dart +++ /dev/null @@ -1,55 +0,0 @@ - -import 'dart:async'; - -import 'package:flutter/material.dart'; - -class LoadingSpinner extends StatefulWidget { - const LoadingSpinner({super.key}); - - @override - State createState() => _LoadingSpinnerState(); -} - -class _LoadingSpinnerState extends State { - bool textVisible = false; - late Timer timer; - - @override - void initState() { - timer = Timer(const Duration(seconds: 30), () { - setState(() { - textVisible = true; - }); - }); - - super.initState(); - } - - @override - Widget build(BuildContext context) => Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Visibility( - visible: !textVisible, - replacement: const Icon(Icons.sentiment_dissatisfied_outlined), - child: const CircularProgressIndicator(), - ), - const SizedBox(height: 30), - Visibility( - visible: textVisible, - child: const Text( - textAlign: TextAlign.center, - 'Irgendetwas funktioniert nicht!\nBist du mit dem Internet verbunden?\n\nVersuche die App neuzustarten' - ), - ), - ], - ), - ); - - @override - void dispose() { - timer.cancel(); - super.dispose(); - } -} diff --git a/lib/widget/loading_spinner.dart b/lib/widget/loading_spinner.dart new file mode 100644 index 0000000..054214c --- /dev/null +++ b/lib/widget/loading_spinner.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class LoadingSpinner extends StatefulWidget { + const LoadingSpinner({super.key}); + + @override + State createState() => _LoadingSpinnerState(); +} + +class _LoadingSpinnerState extends State { + bool textVisible = false; + late Timer timer; + + @override + void initState() { + timer = Timer(const Duration(seconds: 30), () { + setState(() { + textVisible = true; + }); + }); + + super.initState(); + } + + @override + Widget build(BuildContext context) => Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Visibility( + visible: !textVisible, + replacement: const Icon(Icons.sentiment_dissatisfied_outlined), + child: const CircularProgressIndicator(), + ), + const SizedBox(height: 30), + Visibility( + visible: textVisible, + child: const Text( + textAlign: TextAlign.center, + 'Irgendetwas funktioniert nicht!\nBist du mit dem Internet verbunden?\n\nVersuche die App neuzustarten', + ), + ), + ], + ), + ); + + @override + void dispose() { + timer.cancel(); + super.dispose(); + } +} diff --git a/lib/widget/placeholderView.dart b/lib/widget/placeholder_view.dart similarity index 78% rename from lib/widget/placeholderView.dart rename to lib/widget/placeholder_view.dart index e890332..27e114a 100644 --- a/lib/widget/placeholderView.dart +++ b/lib/widget/placeholder_view.dart @@ -4,7 +4,12 @@ class PlaceholderView extends StatelessWidget { final IconData icon; final String text; final Widget? button; - const PlaceholderView({super.key, required this.icon, required this.text, this.button}); + const PlaceholderView({ + super.key, + required this.icon, + required this.text, + this.button, + }); @override Widget build(BuildContext context) => Scaffold( @@ -19,11 +24,11 @@ class PlaceholderView extends StatelessWidget { ), Text( text, - style: const TextStyle(fontSize: 20,), + style: const TextStyle(fontSize: 20), textAlign: TextAlign.center, ), const SizedBox(height: 30), - if(button != null) button!, + ?button, ], ), ), diff --git a/lib/widget/sharePositionOrigin.dart b/lib/widget/sharePositionOrigin.dart deleted file mode 100644 index f110beb..0000000 --- a/lib/widget/sharePositionOrigin.dart +++ /dev/null @@ -1,5 +0,0 @@ -import 'package:flutter/material.dart'; - -class SharePositionOrigin { - static Rect get(BuildContext context) => Rect.fromLTWH(0, 0, MediaQuery.of(context).size.width, MediaQuery.of(context).size.height / 2); -} diff --git a/lib/widget/share_position_origin.dart b/lib/widget/share_position_origin.dart new file mode 100644 index 0000000..2086f09 --- /dev/null +++ b/lib/widget/share_position_origin.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +class SharePositionOrigin { + static Rect get(BuildContext context) => Rect.fromLTWH( + 0, + 0, + MediaQuery.of(context).size.width, + MediaQuery.of(context).size.height / 2, + ); +} diff --git a/lib/widget/string_extensions.dart b/lib/widget/string_extensions.dart index 21c3901..d802e9a 100644 --- a/lib/widget/string_extensions.dart +++ b/lib/widget/string_extensions.dart @@ -1,3 +1,4 @@ extension StringExtensions on String { - String capitalize() => '${this[0].toUpperCase()}${substring(1).toLowerCase()}'; + String capitalize() => + '${this[0].toUpperCase()}${substring(1).toLowerCase()}'; } diff --git a/lib/widget/unimplementedDialog.dart b/lib/widget/unimplementedDialog.dart deleted file mode 100644 index c02472d..0000000 --- a/lib/widget/unimplementedDialog.dart +++ /dev/null @@ -1,7 +0,0 @@ -import 'package:flutter/material.dart'; - -class UnimplementedDialog { - static void show(BuildContext context) { - showDialog(context: context, builder: (context) => const AlertDialog(content: Text('Not implemented yet'))); - } -} diff --git a/lib/widget/unimplemented_dialog.dart b/lib/widget/unimplemented_dialog.dart new file mode 100644 index 0000000..dfae0eb --- /dev/null +++ b/lib/widget/unimplemented_dialog.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +class UnimplementedDialog { + static void show(BuildContext context) { + showDialog( + context: context, + builder: (context) => + const AlertDialog(content: Text('Not implemented yet')), + ); + } +} diff --git a/lib/widget/userAvatar.dart b/lib/widget/userAvatar.dart deleted file mode 100644 index a9a5d92..0000000 --- a/lib/widget/userAvatar.dart +++ /dev/null @@ -1,45 +0,0 @@ -import 'package:cached_network_image/cached_network_image.dart'; -import 'package:flutter/material.dart'; - -import '../model/accountData.dart'; -import '../model/endpointData.dart'; - -class UserAvatar extends StatelessWidget { - final String id; - final bool isGroup; - final int size; - const UserAvatar({required this.id, this.isGroup = false, this.size = 20, super.key}); - - @override - Widget build(BuildContext context) { - if(isGroup) { - return CircleAvatar( - foregroundImage: Image( - image: CachedNetworkImageProvider( - 'https://${AccountData().buildHttpAuthString()}@${EndpointData().nextcloud().full()}/ocs/v2.php/apps/spreed/api/v1/room/$id/avatar', - errorListener: (p0) {} - ) - ).image, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - onForegroundImageError: (o, t) {}, - radius: size.toDouble(), - child: Icon(Icons.group, size: size.toDouble()), - ); - } else { - return CircleAvatar( - foregroundImage: Image( - image: CachedNetworkImageProvider( - 'https://${EndpointData().nextcloud().full()}/avatar/$id/$size', - errorListener: (p0) {} - ), - ).image, - backgroundColor: Theme.of(context).primaryColor, - foregroundColor: Colors.white, - onForegroundImageError: (o, t) {}, - radius: size.toDouble(), - child: Icon(Icons.person, size: size.toDouble()), - ); - } - } -} diff --git a/lib/widget/user_avatar.dart b/lib/widget/user_avatar.dart new file mode 100644 index 0000000..7e8cf4d --- /dev/null +++ b/lib/widget/user_avatar.dart @@ -0,0 +1,145 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:http/http.dart' as http; + +import '../model/account_data.dart'; +import '../model/endpoint_data.dart'; + +class UserAvatar extends StatefulWidget { + final String id; + final bool isGroup; + final int size; + const UserAvatar({ + required this.id, + this.isGroup = false, + this.size = 20, + super.key, + }); + + @override + State createState() => _UserAvatarState(); +} + +class _AvatarPayload { + final Uint8List bytes; + final bool isSvg; + _AvatarPayload(this.bytes, this.isSvg); +} + +final Map> _avatarCache = {}; + +class _UserAvatarState extends State { + late Future<_AvatarPayload?> _payload; + + @override + void initState() { + super.initState(); + _payload = _load(); + } + + @override + void didUpdateWidget(UserAvatar oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.id != widget.id || + oldWidget.isGroup != widget.isGroup || + oldWidget.size != widget.size) { + _payload = _load(); + } + } + + String _url() { + final host = EndpointData().nextcloud().full(); + if (widget.isGroup) { + return 'https://$host/ocs/v2.php/apps/spreed/api/v1/room/${widget.id}/avatar'; + } + return 'https://$host/avatar/${widget.id}/${widget.size}'; + } + + Future<_AvatarPayload?> _load() { + final url = _url(); + return _avatarCache.putIfAbsent(url, () => _fetch(url)); + } + + Future<_AvatarPayload?> _fetch(String url) async { + try { + final response = await http.get( + Uri.parse(url), + headers: { + 'Authorization': AccountData().getBasicAuthHeader(), + 'Accept': 'image/png,image/jpeg,image/webp,image/svg+xml', + }, + ); + if (response.statusCode != 200 || response.bodyBytes.isEmpty) return null; + + final contentType = response.headers['content-type']?.toLowerCase() ?? ''; + final bytes = response.bodyBytes; + final isSvg = contentType.contains('svg') || _looksLikeSvg(bytes); + return _AvatarPayload(bytes, isSvg); + } catch (_) { + return null; + } + } + + static bool _looksLikeSvg(Uint8List bytes) { + final head = utf8 + .decode( + bytes.sublist(0, bytes.length < 256 ? bytes.length : 256), + allowMalformed: true, + ) + .trimLeft(); + return head.startsWith('( + future: _payload, + builder: (context, snapshot) { + final payload = snapshot.data; + + Widget content; + if (payload == null) { + content = Icon( + widget.isGroup ? Icons.group : Icons.person, + size: radius, + color: Colors.white, + ); + } else if (payload.isSvg) { + content = SvgPicture.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + ); + } else { + content = Image.memory( + payload.bytes, + width: radius * 2, + height: radius * 2, + fit: BoxFit.cover, + gaplessPlayback: true, + ); + } + + return CircleAvatar( + radius: radius, + backgroundColor: theme.primaryColor, + foregroundColor: Colors.white, + child: ClipOval( + child: SizedBox( + width: radius * 2, + height: radius * 2, + child: content, + ), + ), + ); + }, + ); + } +} diff --git a/pubspec.yaml b/pubspec.yaml index 9563857..e6cd8c4 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -7,6 +7,7 @@ version: 0.1.7+46 environment: sdk: ">=3.8.0 <4.0.0" +# nextcloud (custom Git fork) pins intl ^0.18.0; Flutter 3.41 needs ^0.20.2. dependency_overrides: intl: 0.20.2 @@ -17,37 +18,27 @@ dependencies: flutter_localizations: sdk: flutter - animated_digit: ^3.2.3 async: ^2.11.0 badges: ^3.1.2 - bloc: ^9.0.0 - bottom_sheet: ^4.0.4 - bubble: ^1.2.1 cached_network_image: ^3.4.1 collection: ^1.19.0 connectivity_plus: ^7.1.0 crypto: ^3.0.6 - cupertino_icons: ^1.0.8 device_info_plus: ^12.4.0 - dio: ^4.0.6 - easy_debounce: ^2.0.3 + dio: ^5.9.2 emoji_picker_flutter: ^4.3.0 - fast_rsa: ^3.7.1 - file_picker: ^10.3.2 + file_picker: ^11.0.2 filesize: ^2.0.1 firebase_core: ^4.1.0 - firebase_in_app_messaging: ^0.9.0+1 firebase_messaging: ^16.0.1 - flowder: - git: - url: https://github.com/Harsh223/flowder.git flutter_app_badge: ^2.0.2 flutter_bloc: ^9.0.0 + flutter_secure_storage: ^10.0.0 + intl: ^0.20.2 flutter_linkify: ^6.0.0 flutter_local_notifications: ^21.0.0 - flutter_login: ^6.0.0 - flutter_native_splash: ^2.4.4 flutter_split_view: ^0.1.2 + flutter_svg: ^2.0.10 freezed_annotation: ^3.1.0 http: ^1.3.0 hydrated_bloc: ^11.0.0 @@ -65,25 +56,27 @@ dependencies: open_filex: ^4.7.0 package_info_plus: ^9.0.1 path_provider: ^2.1.5 - permission_handler: ^12.0.1 persistent_bottom_nav_bar_v2: ^6.1.0 photo_view: ^0.15.0 - pretty_json: ^2.0.0 - provider: ^6.1.2 qr_flutter: ^4.1.0 rrule: ^0.2.17 rrule_generator: ^0.9.0 screen_brightness: ^2.1.7 share_plus: ^12.0.2 shared_preferences: ^2.3.5 - syncfusion_flutter_calendar: ^33.1.46 - syncfusion_flutter_pdfviewer: ^33.1.46 + syncfusion_flutter_calendar: ^33.2.5 + syncfusion_flutter_pdfviewer: ^33.2.5 time_range_picker: ^2.3.0 url_launcher: ^6.3.1 - uuid: ^4.5.1 + enough_icalendar: ^0.17.0 dev_dependencies: + flutter_test: + sdk: flutter + fake_async: ^1.3.1 + flutter_launcher_icons: ^0.14.3 + flutter_native_splash: ^2.4.4 build_runner: ^2.10.5 freezed: ^3.2.4 diff --git a/test/api/errors/error_mapper_test.dart b/test/api/errors/error_mapper_test.dart new file mode 100644 index 0000000..e80899b --- /dev/null +++ b/test/api/errors/error_mapper_test.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:marianum_mobile/api/api_error.dart'; +import 'package:marianum_mobile/api/errors/auth_exception.dart'; +import 'package:marianum_mobile/api/errors/error_mapper.dart'; +import 'package:marianum_mobile/api/errors/network_exception.dart'; +import 'package:marianum_mobile/api/errors/parse_exception.dart'; + +void main() { + group('errorToUserMessage', () { + test('null falls back to the default message', () { + expect(errorToUserMessage(null), contains('Etwas ist schiefgelaufen')); + }); + + test('AppException returns its own userMessage', () { + final exception = AuthException.unauthorized(); + expect(errorToUserMessage(exception), exception.userMessage); + }); + + test('SocketException maps to NetworkException message', () { + expect( + errorToUserMessage(const SocketException('boom')), + const NetworkException().userMessage, + ); + }); + + test( + 'TimeoutException maps to the timeout-specific NetworkException message', + () { + expect( + errorToUserMessage(TimeoutException('slow')), + NetworkException.timeout().userMessage, + ); + }, + ); + + test('http.ClientException maps to NetworkException message', () { + expect( + errorToUserMessage(http.ClientException('failed')), + const NetworkException().userMessage, + ); + }); + + test('HandshakeException maps to a TLS-specific message', () { + final message = errorToUserMessage(const HandshakeException('bad cert')); + expect(message, contains('sichere Verbindung')); + expect(message, contains('Geräte-Uhrzeit')); + }); + + test('FormatException maps to ParseException message', () { + expect( + errorToUserMessage(const FormatException('bad json')), + const ParseException().userMessage, + ); + }); + + test('ApiError surfaces only the first line of its message', () { + final err = ApiError('Boom\nGET https://example.com/foo'); + expect(errorToUserMessage(err), 'Boom'); + }); + + test('ApiError with empty message falls back to default', () { + final err = ApiError(''); + expect(errorToUserMessage(err), contains('Etwas ist schiefgelaufen')); + }); + + test('unknown error type falls back', () { + expect( + errorToUserMessage(StateError('weird')), + contains('Etwas ist schiefgelaufen'), + ); + }); + + test('custom fallback overrides the default', () { + expect(errorToUserMessage(null, fallback: 'meins'), 'meins'); + }); + + test('DioException connectionTimeout maps to timeout NetworkException', () { + final ex = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.connectionTimeout, + ); + expect(errorToUserMessage(ex), NetworkException.timeout().userMessage); + }); + + test('DioException connectionError maps to NetworkException', () { + final ex = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.connectionError, + ); + expect(errorToUserMessage(ex), const NetworkException().userMessage); + }); + + test('DioException badResponse maps to a server status message', () { + final ex = DioException( + requestOptions: RequestOptions(path: '/x'), + type: DioExceptionType.badResponse, + response: Response( + requestOptions: RequestOptions(path: '/x'), + statusCode: 503, + ), + ); + expect(errorToUserMessage(ex), contains('503')); + }); + }); + + group('errorToTechnicalDetails', () { + test('null returns null', () { + expect(errorToTechnicalDetails(null), isNull); + }); + + test('AppException uses its technicalDetails when set', () { + final ex = AuthException.unauthorized(technicalDetails: 'http 401, foo'); + expect(errorToTechnicalDetails(ex), 'http 401, foo'); + }); + + test('AppException without details falls back to toString()', () { + final ex = AuthException.unauthorized(); + expect(errorToTechnicalDetails(ex), ex.toString()); + }); + + test('arbitrary object stringifies', () { + expect(errorToTechnicalDetails(StateError('x')), contains('x')); + }); + }); + + group('errorAllowsRetry', () { + test('null allows retry by default', () { + expect(errorAllowsRetry(null), isTrue); + }); + + test('AuthException disallows retry (allowRetry=false)', () { + expect(errorAllowsRetry(AuthException.unauthorized()), isFalse); + }); + + test('NetworkException allows retry (allowRetry=true)', () { + expect(errorAllowsRetry(const NetworkException()), isTrue); + }); + + test('non-AppException allows retry by default', () { + expect(errorAllowsRetry(StateError('x')), isTrue); + }); + }); +} diff --git a/test/api/talk/rich_object_string_processor_test.dart b/test/api/talk/rich_object_string_processor_test.dart new file mode 100644 index 0000000..aca6a44 --- /dev/null +++ b/test/api/talk/rich_object_string_processor_test.dart @@ -0,0 +1,77 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/get_chat_response.dart'; +import 'package:marianum_mobile/api/marianumcloud/talk/chat/rich_object_string_processor.dart'; + +RichObjectString _r( + String name, { + RichObjectStringObjectType type = RichObjectStringObjectType.user, +}) => RichObjectString(type, 'id-$name', name, null, null); + +void main() { + group('RichObjectStringProcessor.parseToString', () { + test('null data returns the message unchanged', () { + expect( + RichObjectStringProcessor.parseToString('Hallo {actor}', null), + 'Hallo {actor}', + ); + }); + + test('substitutes a single placeholder by .name', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} hat eine Datei geteilt', + {'actor': _r('Elias')}, + ), + 'Elias hat eine Datei geteilt', + ); + }); + + test('substitutes multiple placeholders independently', () { + expect( + RichObjectStringProcessor.parseToString( + '{actor} hat {file} mit {target} geteilt', + { + 'actor': _r('Elias'), + 'file': _r('foo.pdf', type: RichObjectStringObjectType.file), + 'target': _r('Klasse 11a', type: RichObjectStringObjectType.group), + }, + ), + 'Elias hat foo.pdf mit Klasse 11a geteilt', + ); + }); + + test('replaces every occurrence of the same placeholder', () { + expect( + RichObjectStringProcessor.parseToString('{actor} {actor} {actor}', { + 'actor': _r('A'), + }), + 'A A A', + ); + }); + + test('placeholders with no matching key remain unchanged', () { + expect( + RichObjectStringProcessor.parseToString('{actor} sah {file}', { + 'actor': _r('Elias'), + }), + 'Elias sah {file}', + ); + }); + + test('empty data map returns the message unchanged', () { + expect( + RichObjectStringProcessor.parseToString('Hallo {actor}', const {}), + 'Hallo {actor}', + ); + }); + + test('messages without placeholders are returned verbatim', () { + expect( + RichObjectStringProcessor.parseToString('reine Textnachricht', { + 'actor': _r('A'), + }), + 'reine Textnachricht', + ); + }); + }); +} diff --git a/test/api/webuntis/lesson_resolver_test.dart b/test/api/webuntis/lesson_resolver_test.dart new file mode 100644 index 0000000..01a675b --- /dev/null +++ b/test/api/webuntis/lesson_resolver_test.dart @@ -0,0 +1,117 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_rooms/get_rooms_response.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_subjects/get_subjects_response.dart'; +import 'package:marianum_mobile/api/webuntis/services/lesson_resolver.dart'; +import 'package:marianum_mobile/state/app/modules/timetable/bloc/timetable_state.dart'; + +TimetableState _state({ + Set subjects = const {}, + Set rooms = const {}, +}) => TimetableState( + subjects: subjects.isEmpty ? null : GetSubjectsResponse(subjects), + rooms: rooms.isEmpty ? null : GetRoomsResponse(rooms), + startDate: DateTime(2026, 1, 1), + endDate: DateTime(2026, 12, 31), +); + +void main() { + group('LessonResolver.resolveSubject', () { + test('returns the matching subject when the id is found', () { + final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); + final state = _state(subjects: {math}); + + final result = LessonResolver.resolveSubject(state, 7); + expect(result.id, 7); + expect(result.name, 'M'); + expect(result.longName, 'Mathe'); + }); + + test('returns the placeholder fallback when id is null', () { + final state = _state(subjects: const {}); + final result = LessonResolver.resolveSubject(state, null); + expect(result.id, 0); + expect(result.name, '?'); + expect(result.longName, 'Unbekannt'); + }); + + test('returns the placeholder fallback when id is unknown', () { + final math = GetSubjectsResponseObject(7, 'M', 'Mathe', 'Ma', true); + final state = _state(subjects: {math}); + + final result = LessonResolver.resolveSubject(state, 999); + expect(result.id, 0); + expect(result.longName, 'Unbekannt'); + }); + }); + + group('LessonResolver.resolveRoom', () { + test('returns the matching room when the id is found', () { + final room = GetRoomsResponseObject( + 3, + 'A1', + 'Aula 1', + true, + 'Hauptgebäude', + ); + final state = _state(rooms: {room}); + + final result = LessonResolver.resolveRoom(state, 3); + expect(result.id, 3); + expect(result.name, 'A1'); + expect(result.building, 'Hauptgebäude'); + }); + + test('returns the placeholder fallback when id is unknown', () { + final state = _state(rooms: const {}); + final result = LessonResolver.resolveRoom(state, 42); + expect(result.id, 0); + expect(result.name, '?'); + }); + }); + + group('LessonFormatter', () { + test('iconForCode picks the right icon per status', () { + expect( + LessonFormatter.iconForCode('cancelled').codePoint, + isNot(LessonFormatter.iconForCode('irregular').codePoint), + ); + expect( + LessonFormatter.iconForCode(null).codePoint, + isNot(LessonFormatter.iconForCode('cancelled').codePoint), + ); + }); + + test('statusLabel maps known codes to German labels', () { + expect(LessonFormatter.statusLabel(null), 'Regulär'); + expect(LessonFormatter.statusLabel(''), 'Regulär'); + expect(LessonFormatter.statusLabel('cancelled'), 'Entfällt'); + expect(LessonFormatter.statusLabel('irregular'), 'Geändert'); + expect(LessonFormatter.statusLabel('something-else'), 'something-else'); + }); + + test('codePrefix prepends a label for known codes', () { + expect(LessonFormatter.codePrefix('cancelled'), 'Entfällt: '); + expect(LessonFormatter.codePrefix('irregular'), 'Änderung: '); + expect(LessonFormatter.codePrefix(null), ''); + }); + + test('formatLine renders name + (longname) + · extra in that order', () { + expect( + LessonFormatter.formatLine( + 'Mathe', + longname: 'Mathematik', + extra: 'Hauptgebäude', + ), + 'Mathe (Mathematik) · Hauptgebäude', + ); + }); + + test('formatLine omits longname when it equals name', () { + expect(LessonFormatter.formatLine('Mathe', longname: 'Mathe'), 'Mathe'); + }); + + test('formatLine substitutes ? when name is empty', () { + expect(LessonFormatter.formatLine(''), '?'); + }); + }); +} diff --git a/test/extensions/date_time_test.dart b/test/extensions/date_time_test.dart new file mode 100644 index 0000000..2f174a9 --- /dev/null +++ b/test/extensions/date_time_test.dart @@ -0,0 +1,61 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:marianum_mobile/extensions/date_time.dart'; + +void main() { + setUpAll(() async { + // Jiffy needs locale data once before any formatting calls. + await Jiffy.setLocale('de'); + }); + + group('IsSameDay', () { + test('isSameDay matches by year/month/day, ignoring time', () { + final a = DateTime(2026, 5, 8, 9, 30); + final b = DateTime(2026, 5, 8, 22, 0); + expect(a.isSameDay(b), isTrue); + }); + + test('isSameDay differs across midnight', () { + final a = DateTime(2026, 5, 8, 23, 59); + final b = DateTime(2026, 5, 9, 0, 0); + expect(a.isSameDay(b), isFalse); + }); + + test('isSameOrAfter is inclusive', () { + final a = DateTime(2026, 5, 8, 12); + final b = DateTime(2026, 5, 8, 12); + expect(a.isSameOrAfter(b), isTrue); + expect(a.add(const Duration(seconds: 1)).isSameOrAfter(b), isTrue); + expect(a.subtract(const Duration(seconds: 1)).isSameOrAfter(b), isFalse); + }); + }); + + group('DateTimeFormatting', () { + final dt = DateTime(2026, 5, 8, 9, 7); + + test('formatHm pads hours and minutes to two digits', () { + expect(dt.formatHm(), '09:07'); + }); + + test('formatDate uses dd.MM.yyyy', () { + expect(dt.formatDate(), '08.05.2026'); + }); + + test('formatDateTime combines date and time', () { + expect(dt.formatDateTime(), '08.05.2026 09:07'); + }); + + test('formatDateShort drops the year', () { + expect(dt.formatDateShort(), '08.05.'); + }); + + test('formatDateShortHm combines short date and time', () { + expect(dt.formatDateShortHm(), '08.05. 09:07'); + }); + + test('timeRangeTo joins start and end with a hyphen', () { + final end = dt.add(const Duration(minutes: 45)); + expect(dt.timeRangeTo(end), '09:07 - 09:52'); + }); + }); +} diff --git a/test/utils/debouncer_test.dart b/test/utils/debouncer_test.dart new file mode 100644 index 0000000..494c908 --- /dev/null +++ b/test/utils/debouncer_test.dart @@ -0,0 +1,187 @@ +import 'package:fake_async/fake_async.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/utils/debouncer.dart'; + +void main() { + // Each test is wrapped in fakeAsync so timers fire deterministically. + group('Debouncer.debounce', () { + test( + 'runs the action once after the delay elapses without further calls', + () { + fakeAsync((async) { + var calls = 0; + Debouncer.debounce( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + + async.elapse(const Duration(milliseconds: 99)); + expect(calls, 0); + + async.elapse(const Duration(milliseconds: 1)); + expect(calls, 1); + }); + }, + ); + + test('subsequent calls within the delay reset the timer (coalesce)', () { + fakeAsync((async) { + var calls = 0; + void schedule() => Debouncer.debounce( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + + schedule(); + async.elapse(const Duration(milliseconds: 80)); + schedule(); // resets + async.elapse(const Duration(milliseconds: 80)); + schedule(); // resets + async.elapse(const Duration(milliseconds: 80)); + expect(calls, 0, reason: 'each schedule() resets the timer'); + + async.elapse(const Duration(milliseconds: 100)); + expect(calls, 1); + }); + }); + + test('different tags are independent', () { + fakeAsync((async) { + var aCalls = 0; + var bCalls = 0; + Debouncer.debounce( + 'a', + const Duration(milliseconds: 100), + () => aCalls++, + ); + Debouncer.debounce( + 'b', + const Duration(milliseconds: 100), + () => bCalls++, + ); + + async.elapse(const Duration(milliseconds: 100)); + expect(aCalls, 1); + expect(bCalls, 1); + }); + }); + }); + + group('Debouncer.throttle', () { + test( + 'first call runs immediately, subsequent calls within window are dropped', + () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 1, + reason: 'throttle fires the first call synchronously', + ); + + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 1, + reason: 'subsequent calls within the gate are ignored', + ); + + async.elapse(const Duration(milliseconds: 100)); + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 2, + reason: 'after the window elapses, throttle fires again', + ); + }); + }, + ); + + test('different tags throttle independently', () { + fakeAsync((async) { + var aCalls = 0; + var bCalls = 0; + Debouncer.throttle( + 'a', + const Duration(milliseconds: 100), + () => aCalls++, + ); + Debouncer.throttle( + 'b', + const Duration(milliseconds: 100), + () => bCalls++, + ); + expect(aCalls, 1); + expect(bCalls, 1); + + async.elapse(const Duration(milliseconds: 100)); + }); + }); + }); + + group('Debouncer.cancel', () { + test('cancels a pending debounce so the action never runs', () { + fakeAsync((async) { + var calls = 0; + Debouncer.debounce( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + Debouncer.cancel('tag'); + + async.elapse(const Duration(milliseconds: 200)); + expect(calls, 0); + }); + }); + + test( + 'cancels an active throttle gate so the next call fires immediately', + () { + fakeAsync((async) { + var calls = 0; + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect(calls, 1); + + Debouncer.cancel('tag'); + Debouncer.throttle( + 'tag', + const Duration(milliseconds: 100), + () => calls++, + ); + expect( + calls, + 2, + reason: 'cancel removed the gate so the next throttle fires again', + ); + + async.elapse(const Duration(milliseconds: 100)); + }); + }, + ); + }); +} diff --git a/test/utils/file_clipboard_test.dart b/test/utils/file_clipboard_test.dart new file mode 100644 index 0000000..52ceaa6 --- /dev/null +++ b/test/utils/file_clipboard_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/utils/file_clipboard.dart'; + +CacheableFile _file(String name) => + CacheableFile(path: '/$name', isDirectory: false, name: name); + +void main() { + // FileClipboard is a singleton — clear between tests so state doesn't leak. + setUp(FileClipboard.instance.clear); + + group('FileClipboard.cut', () { + test('switches to cut state and notifies listeners', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.cut([_file('a.txt')]); + + expect(cb.operation, FileClipboardOperation.cut); + expect(cb.files.map((f) => f.name), ['a.txt']); + expect(cb.isEmpty, isFalse); + expect(notifyCount, 1); + }); + + test('empty input is a no-op', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.cut(const []); + + expect(cb.operation, isNull); + expect(cb.isEmpty, isTrue); + expect(notifyCount, 0, reason: 'no state change → no notifyListeners'); + }); + + test('files getter returns an unmodifiable view', () { + final cb = FileClipboard.instance; + cb.cut([_file('a.txt')]); + expect(() => cb.files.add(_file('b.txt')), throwsUnsupportedError); + }); + }); + + group('FileClipboard.copy', () { + test('switches to copy state and notifies listeners', () { + final cb = FileClipboard.instance; + cb.copy([_file('a.txt'), _file('b.txt')]); + + expect(cb.operation, FileClipboardOperation.copy); + expect(cb.files, hasLength(2)); + }); + + test('overwrites a previous cut state', () { + final cb = FileClipboard.instance; + cb.cut([_file('cut.txt')]); + cb.copy([_file('copy.txt')]); + + expect(cb.operation, FileClipboardOperation.copy); + expect(cb.files.single.name, 'copy.txt'); + }); + }); + + group('FileClipboard.clear', () { + test('resets state and notifies', () { + final cb = FileClipboard.instance; + cb.copy([_file('a.txt')]); + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.clear(); + + expect(cb.operation, isNull); + expect(cb.isEmpty, isTrue); + expect(notifyCount, 1); + }); + + test('clearing an already-empty clipboard is a no-op', () { + final cb = FileClipboard.instance; + var notifyCount = 0; + void listener() => notifyCount++; + cb.addListener(listener); + addTearDown(() => cb.removeListener(listener)); + + cb.clear(); + cb.clear(); + + expect(notifyCount, 0); + }); + }); +} diff --git a/test/view/files/sort_options_test.dart b/test/view/files/sort_options_test.dart new file mode 100644 index 0000000..0f80d52 --- /dev/null +++ b/test/view/files/sort_options_test.dart @@ -0,0 +1,130 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; +import 'package:marianum_mobile/api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; +import 'package:marianum_mobile/view/pages/files/data/sort_options.dart'; + +CacheableFile _file({ + required String name, + bool isDirectory = false, + int? size, + DateTime? modifiedAt, +}) => CacheableFile( + path: '/$name', + isDirectory: isDirectory, + name: name, + size: size, + modifiedAt: modifiedAt, +); + +void main() { + group('SortOptions.options', () { + test('name comparator is alphabetic', () { + final cmp = SortOptions.getOption(SortOption.name).compare; + expect(cmp(_file(name: 'a'), _file(name: 'b')), lessThan(0)); + expect(cmp(_file(name: 'b'), _file(name: 'a')), greaterThan(0)); + expect(cmp(_file(name: 'a'), _file(name: 'a')), 0); + }); + + test('date comparator is chronological by modifiedAt', () { + final cmp = SortOptions.getOption(SortOption.date).compare; + final older = _file(name: 'a', modifiedAt: DateTime(2026, 1, 1)); + final newer = _file(name: 'b', modifiedAt: DateTime(2026, 5, 1)); + expect(cmp(older, newer), lessThan(0)); + expect(cmp(newer, older), greaterThan(0)); + }); + + test('size comparator pushes directories to the end (positional 1 vs 0)', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + final dir = _file(name: 'd', isDirectory: true); + final file = _file(name: 'f', size: 100); + // (dir, file) → returns 1 (dir.isDirectory true) → file sorts before dir. + expect(cmp(dir, file), 1); + expect(cmp(file, dir), 0); + }); + + test('size comparator handles null sizes', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + final noSize = _file(name: 'a'); + final withSize = _file(name: 'b', size: 100); + // a.size == null → returns 0 + expect(cmp(noSize, withSize), 0); + // b.size == null → returns 1 + expect(cmp(withSize, noSize), 1); + }); + + test('size comparator orders by file size when both known', () { + final cmp = SortOptions.getOption(SortOption.size).compare; + expect( + cmp(_file(name: 'a', size: 100), _file(name: 'b', size: 200)), + lessThan(0), + ); + expect( + cmp(_file(name: 'a', size: 200), _file(name: 'b', size: 100)), + greaterThan(0), + ); + }); + + test('options map contains all enum values exactly once', () { + expect(SortOptions.options.keys.toSet(), SortOption.values.toSet()); + }); + }); + + group('ListFilesResponse.sortBy', () { + final folderA = _file(name: 'A', isDirectory: true); + final folderB = _file(name: 'B', isDirectory: true); + final fileA = _file( + name: 'aaa', + size: 100, + modifiedAt: DateTime(2026, 1, 1), + ); + final fileB = _file( + name: 'bbb', + size: 50, + modifiedAt: DateTime(2026, 5, 1), + ); + + // Note: sortBy uses a string-buffer sort + compareTo descending. The actual + // list ordering reflects what users see in the file list. + test('foldersToTop=true keeps folders before files regardless of name', () { + final response = ListFilesResponse({fileA, fileB, folderA, folderB}); + final sorted = response.sortBy(sortOption: SortOption.name); + final folderCount = sorted.takeWhile((f) => f.isDirectory).length; + expect(folderCount, 2, reason: 'both folders should sit at the top'); + }); + + test('foldersToTop=false intermixes folders and files', () { + final response = ListFilesResponse({fileA, fileB, folderA, folderB}); + final sorted = response.sortBy( + sortOption: SortOption.name, + foldersToTop: false, + ); + final folderPositions = []; + for (var i = 0; i < sorted.length; i++) { + if (sorted[i].isDirectory) folderPositions.add(i); + } + // Without foldersToTop, folders aren't guaranteed to be at the front: + // assert at least one folder is somewhere other than the very top of + // a folders-first ordering. + expect(folderPositions, isNot([0, 1])); + }); + + test('reversed flips the order within each section', () { + final response = ListFilesResponse({fileA, fileB}); + final asc = response.sortBy( + sortOption: SortOption.name, + foldersToTop: false, + ); + final desc = response.sortBy( + sortOption: SortOption.name, + foldersToTop: false, + reversed: true, + ); + expect(desc, asc.reversed.toList()); + }); + + test('empty input yields an empty list', () { + final response = ListFilesResponse({}); + expect(response.sortBy(), isEmpty); + }); + }); +} diff --git a/test/view/marianum_dates/event_formatter_test.dart b/test/view/marianum_dates/event_formatter_test.dart new file mode 100644 index 0000000..7d14a1b --- /dev/null +++ b/test/view/marianum_dates/event_formatter_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:jiffy/jiffy.dart'; +import 'package:marianum_mobile/state/app/modules/marianum_dates/bloc/marianum_dates_state.dart'; +import 'package:marianum_mobile/view/pages/marianum_dates/data/event_formatter.dart'; + +MarianumDate _event({ + required DateTime start, + required DateTime end, + bool isAllDay = false, +}) => MarianumDate( + uid: 't', + title: 't', + description: null, + start: start, + end: end, + isAllDay: isAllDay, +); + +void main() { + setUpAll(() async { + await Jiffy.setLocale('de'); + }); + + group('EventFormatter.trailingLabel', () { + test('all-day events show "Ganztägig"', () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 9), + isAllDay: true, + ); + expect(EventFormatter.trailingLabel(e), 'Ganztägig'); + }); + + test('zero-length same-day event shows a single time', () { + final at = DateTime(2026, 5, 8, 9, 30); + final e = _event(start: at, end: at); + expect(EventFormatter.trailingLabel(e), '09:30'); + }); + + test('same-day event shows time range', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 8, 10, 30), + ); + expect(EventFormatter.trailingLabel(e), '09:00–10:30'); + }); + + test('multi-day event shows date+time on both sides', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 9, 11), + ); + expect(EventFormatter.trailingLabel(e), '08.05. 09:00–09.05. 11:00'); + }); + }); + + group('EventFormatter.longRange', () { + test('all-day single-day collapses inclusive end to start date', () { + // ICS-style all-day: end is exclusive (next midnight). Display drops it. + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 9), + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); + }); + + test( + 'all-day multi-day shows inclusive end (one day before exclusive end)', + () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 11), // exclusive → display "until 10.05." + isAllDay: true, + ); + expect( + EventFormatter.longRange(e), + '08.05.2026 – 10.05.2026 · Ganztägig', + ); + }, + ); + + test( + 'all-day event whose end equals start (degenerate) renders as single day', + () { + final e = _event( + start: DateTime(2026, 5, 8), + end: DateTime(2026, 5, 8), + isAllDay: true, + ); + expect(EventFormatter.longRange(e), '08.05.2026 · Ganztägig'); + }, + ); + + test('zero-length same-day timed event shows single time', () { + final at = DateTime(2026, 5, 8, 9, 30); + final e = _event(start: at, end: at); + expect(EventFormatter.longRange(e), '08.05.2026 · 09:30'); + }); + + test('same-day timed event shows date · range', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 8, 10, 30), + ); + expect(EventFormatter.longRange(e), '08.05.2026 · 09:00 – 10:30'); + }); + + test('multi-day timed event shows full datetimes on both sides', () { + final e = _event( + start: DateTime(2026, 5, 8, 9), + end: DateTime(2026, 5, 9, 11), + ); + expect( + EventFormatter.longRange(e), + '08.05.2026 09:00 – 09.05.2026 11:00', + ); + }); + }); +} diff --git a/test/view/timetable/calendar_logic_test.dart b/test/view/timetable/calendar_logic_test.dart new file mode 100644 index 0000000..5e06910 --- /dev/null +++ b/test/view/timetable/calendar_logic_test.dart @@ -0,0 +1,422 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/api/mhsl/custom_timetable_event/custom_timetable_event.dart'; +import 'package:marianum_mobile/api/webuntis/queries/get_timetable/get_timetable_response.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/arbitrary_appointment.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/calendar_logic.dart'; +import 'package:marianum_mobile/view/pages/timetable/data/lesson_period_schedule.dart'; +import 'package:syncfusion_flutter_calendar/calendar.dart'; + +DateTime _at(int year, int month, int day, [int hour = 0, int minute = 0]) => + DateTime(year, month, day, hour, minute); + +Appointment _appt({ + required DateTime start, + required DateTime end, + String subject = 'Test', + bool isAllDay = false, + Object? id, + String? rrule, +}) => Appointment( + id: id, + startTime: start, + endTime: end, + subject: subject, + color: Colors.blue, + isAllDay: isAllDay, + recurrenceRule: rrule, +); + +GetTimetableResponseObject _lesson({String? code}) => + GetTimetableResponseObject( + id: 0, + date: 0, + startTime: 0, + endTime: 0, + kl: const [], + te: const [], + su: const [], + ro: const [], + code: code, + ); + +CustomTimetableEvent _customEvent() => CustomTimetableEvent( + id: 'x', + title: '', + description: '', + startDate: DateTime(2026), + endDate: DateTime(2026), + color: null, + rrule: '', + createdAt: DateTime(2026), + updatedAt: DateTime(2026), +); + +void main() { + group('isAllDayLike', () { + test('explicit isAllDay flag wins', () { + final a = _appt( + start: _at(2026, 5, 8, 9), + end: _at(2026, 5, 8, 10), + isAllDay: true, + ); + expect(isAllDayLike(a), isTrue); + }); + + test('events under 8 hours are not all-day-like', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 15, 59)); + expect(isAllDayLike(a), isFalse); + }); + + test('events of exactly 8 hours count as all-day-like', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 16)); + expect(isAllDayLike(a), isTrue); + }); + + test( + 'Duration.inHours truncation does not let a 9h 30min event escape', + () { + final a = _appt( + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 17, 30), + ); + expect( + isAllDayLike(a), + isTrue, + reason: 'inHours would say 9; we compare in minutes (570 ≥ 480)', + ); + }, + ); + }); + + group('isOutsideSchoolHours', () { + // School hours run 7:30 → 17:15 (kCalendarStartHour = 7.5, kCalendarEndHour = 17.25). + + test('lessons fully inside the grid are inside', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9)); + expect(isOutsideSchoolHours(a), isFalse); + }); + + test('all-day-like events are always outside', () { + final a = _appt( + start: _at(2026, 5, 8, 9), + end: _at(2026, 5, 8, 10), + isAllDay: true, + ); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events ending at or before grid start are outside', () { + final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 7, 30)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events starting at or after grid end are outside', () { + final a = _appt(start: _at(2026, 5, 8, 17, 15), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events that engulf the entire grid are outside', () { + final a = _appt(start: _at(2026, 5, 8, 6), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isTrue); + }); + + test('events that cross only the start boundary are inside', () { + final a = _appt(start: _at(2026, 5, 8, 7), end: _at(2026, 5, 8, 8)); + expect(isOutsideSchoolHours(a), isFalse); + }); + + test('events that cross only the end boundary are inside', () { + final a = _appt(start: _at(2026, 5, 8, 17), end: _at(2026, 5, 8, 18)); + expect(isOutsideSchoolHours(a), isFalse); + }); + }); + + group('partitionAppointmentsForWeek', () { + final monday = _at(2026, 5, 4); // a Monday + + test('single non-recurring lesson lands in the right day bucket', () { + final wednesday9 = _appt( + start: _at(2026, 5, 6, 9), + end: _at(2026, 5, 6, 10), + ); + final result = partitionAppointmentsForWeek([wednesday9], monday); + expect(result.inside[0], isEmpty); + expect(result.inside[1], isEmpty); + expect(result.inside[2], hasLength(1)); + expect(result.inside[3], isEmpty); + expect(result.outside.expand((e) => e), isEmpty); + }); + + test('all-day events go to the outside bucket on their day', () { + final tuesdayAllDay = _appt( + start: _at(2026, 5, 5), + end: _at(2026, 5, 6), + isAllDay: true, + ); + final result = partitionAppointmentsForWeek([tuesdayAllDay], monday); + expect(result.inside.expand((e) => e), isEmpty); + expect(result.outside[1], hasLength(1)); + }); + + test('events outside the visible week are dropped', () { + final lastWeek = _appt( + start: _at(2026, 4, 27, 9), + end: _at(2026, 4, 27, 10), + ); + final nextWeek = _appt( + start: _at(2026, 5, 11, 9), + end: _at(2026, 5, 11, 10), + ); + final result = partitionAppointmentsForWeek([lastWeek, nextWeek], monday); + expect(result.inside.expand((e) => e), isEmpty); + expect(result.outside.expand((e) => e), isEmpty); + }); + + test('weekend events (Sat/Sun) are dropped, only Mon–Fri counted', () { + final saturday = _appt( + start: _at(2026, 5, 9, 9), + end: _at(2026, 5, 9, 10), + ); + final result = partitionAppointmentsForWeek([saturday], monday); + expect(result.inside.expand((e) => e), isEmpty); + }); + + test('weekly RRULE expands to one occurrence per matching week', () { + // Anchor on the Monday before our visible week, repeating weekly. + // The visible week's Monday should produce one occurrence. + final anchor = _appt( + start: _at(2026, 4, 27, 9), + end: _at(2026, 4, 27, 10), + rrule: 'RRULE:FREQ=WEEKLY;BYDAY=MO', + ); + final result = partitionAppointmentsForWeek([anchor], monday); + expect( + result.inside[0], + hasLength(1), + reason: 'Monday of the visible week should get one expansion', + ); + expect(result.inside[0].first.startTime, _at(2026, 5, 4, 9)); + }); + + test('malformed RRULE falls back to placing the anchor', () { + final broken = _appt( + start: _at(2026, 5, 6, 9), + end: _at(2026, 5, 6, 10), + rrule: 'this is not a valid rrule', + ); + final result = partitionAppointmentsForWeek([broken], monday); + expect(result.inside[2], hasLength(1)); + }); + }); + + group('PeriodLayout', () { + final p1 = const LessonPeriod( + name: '1', + start: TimeOfDay(hour: 8, minute: 0), + end: TimeOfDay(hour: 9, minute: 0), + ); + final brk = const LessonPeriod( + name: 'Pause', + start: TimeOfDay(hour: 9, minute: 0), + end: TimeOfDay(hour: 9, minute: 15), + isBreak: true, + ); + final p2 = const LessonPeriod( + name: '2', + start: TimeOfDay(hour: 9, minute: 15), + end: TimeOfDay(hour: 10, minute: 15), + ); + + final layout = PeriodLayout( + periods: [p1, brk, p2], + lessonHeight: 60, // 60px per lesson + breakHeight: 20, + ); + + test('totalHeight sums lessons and breaks', () { + expect(layout.totalHeight, 60 + 20 + 60); + }); + + test('topOf returns cumulative height of preceding periods', () { + expect(layout.topOf(p1), 0); + expect(layout.topOf(brk), 60); + expect(layout.topOf(p2), 80); + }); + + test('heightOf returns the period-type-specific height', () { + expect(layout.heightOf(p1), 60); + expect(layout.heightOf(brk), 20); + }); + + test('yOfDateTime maps proportionally inside a period', () { + // 8:30 = halfway through the 1st lesson → y = 30 + expect(layout.yOfDateTime(_at(2026, 5, 8, 8, 30)), 30); + }); + + test('yOfDateTime clips to 0 before the first period', () { + expect(layout.yOfDateTime(_at(2026, 5, 8, 6)), 0); + }); + + test('yOfDateTime clips to totalHeight after the last period', () { + expect(layout.yOfDateTime(_at(2026, 5, 8, 18)), layout.totalHeight); + }); + + test('periodAtY returns the lesson under the cursor', () { + expect(layout.periodAtY(0), p1); + expect(layout.periodAtY(59), p1); + }); + + test('periodAtY skips a break to the next non-break lesson', () { + // y=70 falls in the break range; periodAtY should jump to p2. + expect(layout.periodAtY(70), p2); + }); + + test('periodAtY returns null past the last period', () { + expect(layout.periodAtY(layout.totalHeight + 10), isNull); + }); + }); + + group('assignLanes', () { + test('non-overlapping appointments stay on lane 0', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 9)); + final b = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b], maxLanes: 2); + expect(result, hasLength(2)); + for (final cell in result) { + expect(cell.lane, 0); + expect( + cell.laneCount, + 1, + reason: 'separate clusters → laneCount=1 each', + ); + } + }); + + test('two overlapping appointments split into 2 lanes', () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 10)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b], maxLanes: 2); + expect(result, hasLength(2)); + expect(result.map((c) => c.lane).toSet(), {0, 1}); + expect(result.every((c) => c.laneCount == 2), isTrue); + }); + + test( + 'three overlapping appointments with maxLanes=2 collapse the third into overflow', + () { + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 11)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 11)); + final c = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + final result = assignLanes([a, b, c], maxLanes: 2); + + final visible = result.whereType().toList(); + final overflow = result.whereType().toList(); + expect( + visible, + hasLength(1), + reason: 'maxLanes-1 = 1 visible appointment', + ); + expect(overflow, hasLength(1)); + expect(overflow.first.appointments, hasLength(2)); + expect(overflow.first.lane, 1); + expect(overflow.first.laneCount, 2); + }, + ); + + test('CustomAppointment beats a regular lesson on lane priority', () { + final custom = _appt( + id: CustomAppointment(_customEvent()), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), + ); + final regular = _appt( + id: WebuntisAppointment(_lesson()), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), + ); + + final result = assignLanes([ + regular, + custom, + ], maxLanes: 2).whereType().toList(); + // Same startTime → priority decides: custom (0) goes left of regular (2). + final customCell = result.firstWhere( + (c) => c.appointment.id is CustomAppointment, + ); + final regularCell = result.firstWhere( + (c) => c.appointment.id is WebuntisAppointment, + ); + expect(customCell.lane, lessThan(regularCell.lane)); + }); + + test('cancelled lesson lands left of a non-cancelled one on tie', () { + final cancelled = _appt( + id: WebuntisAppointment(_lesson(code: 'cancelled')), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), + ); + final regular = _appt( + id: WebuntisAppointment(_lesson()), + start: _at(2026, 5, 8, 8), + end: _at(2026, 5, 8, 10), + ); + + final result = assignLanes([ + regular, + cancelled, + ], maxLanes: 2).whereType().toList(); + String? codeOf(LaidOutAppointment c) { + final id = c.appointment.id; + return id is WebuntisAppointment ? id.lesson.code : null; + } + + final cancelledCell = result.firstWhere((c) => codeOf(c) == 'cancelled'); + final regularCell = result.firstWhere((c) => codeOf(c) == null); + expect(cancelledCell.lane, lessThan(regularCell.lane)); + }); + + test( + 'overflow time-range spans earliest start to latest end of collapsed appointments', + () { + // 4 overlapping appointments, maxLanes = 2 → 1 visible + overflow of 3. + final a = _appt(start: _at(2026, 5, 8, 8), end: _at(2026, 5, 8, 12)); + final b = _appt(start: _at(2026, 5, 8, 9), end: _at(2026, 5, 8, 10)); + final c = _appt( + start: _at(2026, 5, 8, 9, 30), + end: _at(2026, 5, 8, 14), + ); + final d = _appt(start: _at(2026, 5, 8, 10), end: _at(2026, 5, 8, 11)); + + final overflow = assignLanes([ + a, + b, + c, + d, + ], maxLanes: 2).whereType().single; + expect(overflow.appointments, hasLength(3)); + expect( + overflow.startTime, + _at(2026, 5, 8, 9), + reason: 'earliest non-visible start time', + ); + expect( + overflow.endTime, + _at(2026, 5, 8, 14), + reason: 'latest non-visible end time', + ); + }, + ); + + test('empty input returns an empty list', () { + expect(assignLanes(const [], maxLanes: 2), isEmpty); + }); + + test('asserts maxLanes >= 2', () { + expect( + () => assignLanes(const [], maxLanes: 1), + throwsA(isA()), + ); + }); + }); +} diff --git a/test/widget/async_action_controller_test.dart b/test/widget/async_action_controller_test.dart new file mode 100644 index 0000000..a91489b --- /dev/null +++ b/test/widget/async_action_controller_test.dart @@ -0,0 +1,92 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:marianum_mobile/widget/async_action_button.dart'; + +void main() { + group('AsyncActionController.run', () { + test('toggles busy true while running and false after success', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + var seenBusyInsideCallback = false; + final ok = await controller.run(() async { + seenBusyInsideCallback = controller.busy; + }); + + expect( + seenBusyInsideCallback, + isTrue, + reason: 'busy must be true while the callback is running', + ); + expect(ok, isTrue); + expect(controller.busy, isFalse); + expect(controller.error, isNull); + }); + + test( + 'captures mapped error message on failure and returns false', + () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + final ok = await controller.run( + () async => throw Exception('boom'), + errorBuilder: (e) => 'custom: $e', + ); + + expect(ok, isFalse); + expect(controller.busy, isFalse); + expect(controller.error, contains('custom:')); + expect(controller.error, contains('boom')); + }, + ); + + test('rejects re-entry while busy', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + final firstStarted = Completer(); + final firstCanFinish = Completer(); + final firstFuture = controller.run(() async { + firstStarted.complete(); + await firstCanFinish.future; + }); + + await firstStarted.future; + expect(controller.busy, isTrue); + + final reentrant = await controller.run(() async {}); + expect( + reentrant, + isFalse, + reason: + 'second run while busy must be rejected without invoking callback', + ); + + firstCanFinish.complete(); + expect(await firstFuture, isTrue); + expect(controller.busy, isFalse); + }); + + test('clearError resets error and notifies listeners', () async { + final controller = AsyncActionController(); + addTearDown(controller.dispose); + + var notifyCount = 0; + controller.addListener(() => notifyCount++); + + await controller.run(() async => throw Exception('x')); + expect(controller.error, isNotNull); + final beforeClear = notifyCount; + + controller.clearError(); + expect(controller.error, isNull); + expect(notifyCount, beforeClear + 1); + + // No-op when already cleared. + controller.clearError(); + expect(notifyCount, beforeClear + 1); + }); + }); +}