refactored data providers with centralized cache resolution, unified UI using custom dialogs and bottom sheets, and enhanced network error handling for Dio and TLS errors
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
@@ -9,10 +10,46 @@ import '../webuntis/webuntis_error.dart';
|
|||||||
import 'app_exception.dart';
|
import 'app_exception.dart';
|
||||||
import 'network_exception.dart';
|
import 'network_exception.dart';
|
||||||
import 'parse_exception.dart';
|
import 'parse_exception.dart';
|
||||||
|
import 'server_exception.dart';
|
||||||
import 'talk_exception.dart';
|
import 'talk_exception.dart';
|
||||||
import 'webuntis_exception.dart';
|
import 'webuntis_exception.dart';
|
||||||
|
|
||||||
const String _defaultFallback = 'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
|
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}) {
|
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||||
if (error == null) return fallback;
|
if (error == null) return fallback;
|
||||||
@@ -21,6 +58,11 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
|||||||
if (error is TalkError) return TalkException(error).userMessage;
|
if (error is TalkError) return TalkException(error).userMessage;
|
||||||
if (error is WebuntisError) return WebuntisException(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) {
|
if (error is SocketException) {
|
||||||
return const NetworkException().userMessage;
|
return const NetworkException().userMessage;
|
||||||
}
|
}
|
||||||
@@ -31,7 +73,7 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
|||||||
return const NetworkException().userMessage;
|
return const NetworkException().userMessage;
|
||||||
}
|
}
|
||||||
if (error is HandshakeException) {
|
if (error is HandshakeException) {
|
||||||
return 'Sichere Verbindung konnte nicht hergestellt werden.';
|
return _tlsErrorMessage;
|
||||||
}
|
}
|
||||||
if (error is FormatException) {
|
if (error is FormatException) {
|
||||||
return const ParseException().userMessage;
|
return const ParseException().userMessage;
|
||||||
@@ -48,12 +90,20 @@ String? errorToTechnicalDetails(Object? error) {
|
|||||||
if (error is AppException) return error.technicalDetails ?? error.toString();
|
if (error is AppException) return error.technicalDetails ?? error.toString();
|
||||||
if (error is TalkError) return TalkException(error).technicalDetails;
|
if (error is TalkError) return TalkException(error).technicalDetails;
|
||||||
if (error is WebuntisError) return WebuntisException(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();
|
return error.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
bool errorAllowsRetry(Object? error) {
|
bool errorAllowsRetry(Object? error) {
|
||||||
if (error == null) return true;
|
if (error == null) return true;
|
||||||
if (error is AppException) return error.allowRetry;
|
if (error is AppException) return error.allowRetry;
|
||||||
|
if (error is DioException) {
|
||||||
|
final mapped = _dioToAppException(error);
|
||||||
|
if (mapped != null) return mapped.allowRetry;
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,16 +0,0 @@
|
|||||||
import 'dart:convert';
|
|
||||||
import 'package:http/http.dart' as http;
|
|
||||||
|
|
||||||
import 'get_holidays_response.dart';
|
|
||||||
|
|
||||||
class GetHolidays {
|
|
||||||
Future<GetHolidaysResponse> query() async {
|
|
||||||
var response = (await http.get(Uri.parse('https://ferien-api.de/api/v1/holidays/HE'))).body;
|
|
||||||
var data = jsonDecode(response) as List<dynamic>;
|
|
||||||
return GetHolidaysResponse(
|
|
||||||
List<GetHolidaysResponseObject>.from(
|
|
||||||
data.map<GetHolidaysResponseObject>((e) => GetHolidaysResponseObject.fromJson(e as Map<String, dynamic>))
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import '../request_cache.dart';
|
|
||||||
import 'get_holidays.dart';
|
|
||||||
import 'get_holidays_response.dart';
|
|
||||||
|
|
||||||
class GetHolidaysCache extends SimpleCache<GetHolidaysResponse> {
|
|
||||||
GetHolidaysCache({super.onUpdate, super.renew})
|
|
||||||
: super(
|
|
||||||
cacheTime: RequestCache.cacheDay,
|
|
||||||
loader: () => GetHolidays().query(),
|
|
||||||
fromJson: (json) => GetHolidaysResponse(
|
|
||||||
(json['data'] as List)
|
|
||||||
.map((i) => GetHolidaysResponseObject.fromJson(i as Map<String, dynamic>))
|
|
||||||
.toList(),
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
start('state-holidays');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
|
|
||||||
import 'package:json_annotation/json_annotation.dart';
|
|
||||||
|
|
||||||
import '../api_response.dart';
|
|
||||||
|
|
||||||
part 'get_holidays_response.g.dart';
|
|
||||||
|
|
||||||
@JsonSerializable(explicitToJson: true)
|
|
||||||
class GetHolidaysResponse extends ApiResponse {
|
|
||||||
List<GetHolidaysResponseObject> data;
|
|
||||||
|
|
||||||
GetHolidaysResponse(this.data);
|
|
||||||
|
|
||||||
factory GetHolidaysResponse.fromJson(Map<String, dynamic> json) => _$GetHolidaysResponseFromJson(json);
|
|
||||||
Map<String, dynamic> 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<String, dynamic> json) => _$GetHolidaysResponseObjectFromJson(json);
|
|
||||||
Map<String, dynamic> toJson() => _$GetHolidaysResponseObjectToJson(this);
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
|
||||||
|
|
||||||
part of 'get_holidays_response.dart';
|
|
||||||
|
|
||||||
// **************************************************************************
|
|
||||||
// JsonSerializableGenerator
|
|
||||||
// **************************************************************************
|
|
||||||
|
|
||||||
GetHolidaysResponse _$GetHolidaysResponseFromJson(Map<String, dynamic> json) =>
|
|
||||||
GetHolidaysResponse(
|
|
||||||
(json['data'] as List<dynamic>)
|
|
||||||
.map(
|
|
||||||
(e) =>
|
|
||||||
GetHolidaysResponseObject.fromJson(e as Map<String, dynamic>),
|
|
||||||
)
|
|
||||||
.toList(),
|
|
||||||
)
|
|
||||||
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
|
|
||||||
(k, e) => MapEntry(k, e as String),
|
|
||||||
);
|
|
||||||
|
|
||||||
Map<String, dynamic> _$GetHolidaysResponseToJson(
|
|
||||||
GetHolidaysResponse instance,
|
|
||||||
) => <String, dynamic>{
|
|
||||||
'headers': ?instance.headers,
|
|
||||||
'data': instance.data.map((e) => e.toJson()).toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
GetHolidaysResponseObject _$GetHolidaysResponseObjectFromJson(
|
|
||||||
Map<String, dynamic> 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<String, dynamic> _$GetHolidaysResponseObjectToJson(
|
|
||||||
GetHolidaysResponseObject instance,
|
|
||||||
) => <String, dynamic>{
|
|
||||||
'start': instance.start,
|
|
||||||
'end': instance.end,
|
|
||||||
'year': instance.year,
|
|
||||||
'stateCode': instance.stateCode,
|
|
||||||
'name': instance.name,
|
|
||||||
'slug': instance.slug,
|
|
||||||
};
|
|
||||||
@@ -1,27 +1,17 @@
|
|||||||
import '../../model/account_data.dart';
|
import '../../model/account_data.dart';
|
||||||
import '../../model/endpoint_data.dart';
|
import '../../model/endpoint_data.dart';
|
||||||
|
|
||||||
/// Shared helpers for Nextcloud OCS v2 endpoints.
|
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
||||||
///
|
/// TalkApi, AutocompleteApi, FileSharingApi.
|
||||||
/// Three call sites previously duplicated the same header dictionary and the
|
|
||||||
/// same URI scaffolding (TalkApi, AutocompleteApi, FileSharingApi). Anything
|
|
||||||
/// that talks to `https://<domain>/<base>/ocs/v2.php/...` should go through
|
|
||||||
/// these two helpers so additions like a new header or a different auth
|
|
||||||
/// scheme only need to change here.
|
|
||||||
class NextcloudOcs {
|
class NextcloudOcs {
|
||||||
NextcloudOcs._();
|
NextcloudOcs._();
|
||||||
|
|
||||||
/// The standard OCS request header set: JSON accept, OCS API marker,
|
|
||||||
/// HTTP Basic auth from the active [AccountData].
|
|
||||||
static Map<String, String> headers() => {
|
static Map<String, String> headers() => {
|
||||||
'Accept': 'application/json',
|
'Accept': 'application/json',
|
||||||
'OCS-APIRequest': 'true',
|
'OCS-APIRequest': 'true',
|
||||||
'Authorization': AccountData().getBasicAuthHeader(),
|
'Authorization': AccountData().getBasicAuthHeader(),
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Builds an OCS URI by appending [pathSuffix] under `/ocs/v2.php/` of
|
|
||||||
/// the configured Nextcloud endpoint. Query parameters are converted to
|
|
||||||
/// strings (Uri rejects non-string values).
|
|
||||||
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
|
||||||
final endpoint = EndpointData().nextcloud();
|
final endpoint = EndpointData().nextcloud();
|
||||||
return Uri.https(
|
return Uri.https(
|
||||||
|
|||||||
@@ -120,38 +120,6 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
|
|||||||
@JsonValue(3) neverNotify,
|
@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<String, dynamic> json) => _$GetRoomResponseObjectMessageFromJson(json);
|
|
||||||
// Map<String, dynamic> toJson() => _$GetRoomResponseObjectMessageToJson(this);
|
|
||||||
// }
|
|
||||||
|
|
||||||
enum GetRoomResponseObjectMessageActorType {
|
enum GetRoomResponseObjectMessageActorType {
|
||||||
@JsonValue('deleted_users') deletedUsers,
|
@JsonValue('deleted_users') deletedUsers,
|
||||||
@JsonValue('users') user,
|
@JsonValue('users') user,
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'dart:convert';
|
|||||||
import 'package:localstore/localstore.dart';
|
import 'package:localstore/localstore.dart';
|
||||||
|
|
||||||
import 'api_response.dart';
|
import 'api_response.dart';
|
||||||
|
import 'errors/parse_exception.dart';
|
||||||
|
|
||||||
abstract class RequestCache<T extends ApiResponse?> {
|
abstract class RequestCache<T extends ApiResponse?> {
|
||||||
static const int cacheNothing = 0;
|
static const int cacheNothing = 0;
|
||||||
@@ -81,10 +82,8 @@ abstract class RequestCache<T extends ApiResponse?> {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Concrete [RequestCache] that delegates the two overrides to functions
|
/// Concrete [RequestCache] that takes the two overrides as constructor
|
||||||
/// passed in the constructor. Used to collapse the dozens of one-class-per-
|
/// callbacks instead of requiring a subclass per endpoint.
|
||||||
/// endpoint cache files that all just forward to `<Endpoint>().run()` and
|
|
||||||
/// `<Response>.fromJson(jsonDecode(...))`.
|
|
||||||
class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
|
class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
|
||||||
final Future<T> Function() _loader;
|
final Future<T> Function() _loader;
|
||||||
final T Function(Map<String, dynamic> json) _fromJson;
|
final T Function(Map<String, dynamic> json) _fromJson;
|
||||||
@@ -115,3 +114,30 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
|
|||||||
@override
|
@override
|
||||||
T onLocalData(String json) => _fromJson(jsonDecode(json) as Map<String, dynamic>);
|
T onLocalData(String json) => _fromJson(jsonDecode(json) as Map<String, dynamic>);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 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<T> resolveFromCache<T extends ApiResponse?>(
|
||||||
|
RequestCache<T> 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../widget/debug/debug_tile.dart';
|
import '../widget/debug/debug_tile.dart';
|
||||||
import '../widget/debug/json_viewer.dart';
|
import '../widget/debug/json_viewer.dart';
|
||||||
|
import '../widget/info_dialog.dart';
|
||||||
import 'notification_tasks.dart';
|
import 'notification_tasks.dart';
|
||||||
|
|
||||||
class NotificationController {
|
class NotificationController {
|
||||||
@@ -22,16 +23,13 @@ class NotificationController {
|
|||||||
NotificationTasks.updateProviders(context);
|
NotificationTasks.updateProviders(context);
|
||||||
|
|
||||||
DebugTile(context).run(() {
|
DebugTile(context).run(() {
|
||||||
showDialog(context: context, builder: (context) => AlertDialog(
|
InfoDialog.show(
|
||||||
title: const Text('Notification report'),
|
context,
|
||||||
content: Column(
|
'Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.\n\n'
|
||||||
mainAxisSize: MainAxisSize.min,
|
'${JsonViewer.format(message.data)}',
|
||||||
children: [
|
copyable: true,
|
||||||
const Text('Dieser Bericht wird angezeigt, da du den Entwicklermodus aktiviert hast und die App über eine Benachrichtigung geöffnet wurde.'),
|
title: 'Notification report',
|
||||||
Text(JsonViewer.format(message.data)),
|
);
|
||||||
],
|
|
||||||
),
|
|
||||||
));
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+13
-38
@@ -25,22 +25,15 @@ import '../widget/debug/cache_view.dart';
|
|||||||
import '../widget/file_viewer.dart';
|
import '../widget/file_viewer.dart';
|
||||||
import '../widget/user_avatar.dart';
|
import '../widget/user_avatar.dart';
|
||||||
|
|
||||||
/// Single entry point for full-page navigations across the app.
|
/// Single entry point for full-page navigations. Dialogs and bottom sheets
|
||||||
///
|
/// stay at the call sites; only full-page pushes go through here.
|
||||||
/// Every full-page push in modules should go through one of these methods.
|
|
||||||
/// Dialogs (`showDialog`), bottom sheets (`showStickyFlexibleBottomSheet`,
|
|
||||||
/// `showDetailsBottomSheet`), and `Navigator.pop` for closing those
|
|
||||||
/// remain unchanged and live at the call sites.
|
|
||||||
class AppRoutes {
|
class AppRoutes {
|
||||||
AppRoutes._();
|
AppRoutes._();
|
||||||
|
|
||||||
/// Token of a chat that should be auto-opened in the Talk tab once
|
/// Set by [openChatByToken] (e.g. from a tapped notification) and consumed
|
||||||
/// the chat list view picks it up. Set by [openChatByToken] (e.g. from
|
/// by `ChatList` once the matching room is loaded.
|
||||||
/// a tapped notification) and consumed by the `ChatList` widget.
|
|
||||||
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
||||||
|
|
||||||
// -- Files --------------------------------------------------------------
|
|
||||||
|
|
||||||
static void openFolder(BuildContext context, List<String> path) {
|
static void openFolder(BuildContext context, List<String> path) {
|
||||||
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
||||||
}
|
}
|
||||||
@@ -53,14 +46,10 @@ class AppRoutes {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Timetable ----------------------------------------------------------
|
|
||||||
|
|
||||||
static void openCustomEvents(BuildContext context) {
|
static void openCustomEvents(BuildContext context) {
|
||||||
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Marianum Message ---------------------------------------------------
|
|
||||||
|
|
||||||
static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) {
|
static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) {
|
||||||
pushScreen(
|
pushScreen(
|
||||||
context,
|
context,
|
||||||
@@ -69,8 +58,6 @@ class AppRoutes {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Sharing / Settings / Feedback / DevTools ---------------------------
|
|
||||||
|
|
||||||
static void openQrShare(BuildContext context) {
|
static void openQrShare(BuildContext context) {
|
||||||
pushScreen(context, withNavBar: false, screen: const QrShareView());
|
pushScreen(context, withNavBar: false, screen: const QrShareView());
|
||||||
}
|
}
|
||||||
@@ -95,8 +82,6 @@ class AppRoutes {
|
|||||||
pushScreen(context, withNavBar: false, screen: const Roomplan());
|
pushScreen(context, withNavBar: false, screen: const Roomplan());
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Talk ---------------------------------------------------------------
|
|
||||||
|
|
||||||
static void openMessageReactions(BuildContext context, String token, int messageId) {
|
static void openMessageReactions(BuildContext context, String token, int messageId) {
|
||||||
pushScreen(
|
pushScreen(
|
||||||
context,
|
context,
|
||||||
@@ -105,8 +90,6 @@ class AppRoutes {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Opens a chat from a known [GetRoomResponseObject]. Delegates to
|
|
||||||
/// [TalkNavigator.pushSplitView] so tablet split-view behaviour stays intact.
|
|
||||||
static void openChatView(
|
static void openChatView(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required GetRoomResponseObject room,
|
required GetRoomResponseObject room,
|
||||||
@@ -122,9 +105,8 @@ class AppRoutes {
|
|||||||
context.read<ChatBloc>().setToken(room.token);
|
context.read<ChatBloc>().setToken(room.token);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Schedules a chat to be opened in the Talk tab. Use this when only the
|
/// Schedules a chat to be opened in the Talk tab once the room is loaded.
|
||||||
/// token is known (e.g. from a tapped notification) — the actual push
|
/// Use when only the token is known (e.g. from a tapped notification).
|
||||||
/// happens inside the `ChatList` widget once the room is available.
|
|
||||||
static void openChatByToken(BuildContext context, String token) {
|
static void openChatByToken(BuildContext context, String token) {
|
||||||
pendingChatToken.value = token;
|
pendingChatToken.value = token;
|
||||||
goToTalkTab(context);
|
goToTalkTab(context);
|
||||||
@@ -135,11 +117,9 @@ class AppRoutes {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolves a pending chat token (set via [openChatByToken]) using the
|
/// Resolves a pending chat token (set via [openChatByToken]). Returns null
|
||||||
/// [ChatListBloc]'s current rooms and the active [AccountData] credentials.
|
/// if the room or account is not ready yet — callers should retry when
|
||||||
/// Returns `null` if the token cannot yet be matched (e.g. the room is
|
/// [pendingChatToken] or the bloc state change.
|
||||||
/// still being loaded). Callers should keep listening to [pendingChatToken]
|
|
||||||
/// and the bloc state and retry when either changes.
|
|
||||||
static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
|
static ResolvedPendingChat? resolvePendingChat(BuildContext context) {
|
||||||
final token = pendingChatToken.value;
|
final token = pendingChatToken.value;
|
||||||
if (token == null) return null;
|
if (token == null) return null;
|
||||||
@@ -166,17 +146,14 @@ class AppRoutes {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Module / Tab navigation -------------------------------------------
|
/// Pushes a module from the "Mehr" tab list. Modules already in the bottom
|
||||||
|
/// bar are switched to via [goToTab] instead.
|
||||||
/// Opens an [AppModule]'s root view as a full screen push (used by the
|
|
||||||
/// "Mehr" tab list). Modules that live in the bottom bar are reached via
|
|
||||||
/// [goToTab] instead.
|
|
||||||
static void openModule(BuildContext context, AppModule module) {
|
static void openModule(BuildContext context, AppModule module) {
|
||||||
pushScreen(context, withNavBar: false, screen: module.create());
|
pushScreen(context, withNavBar: false, screen: module.create());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Switches the bottom navigation to the given [module] if it is currently
|
/// Switches the bottom navigation to [module]. Returns false when the
|
||||||
/// in the bottom bar. Returns `true` if the jump happened.
|
/// module is not currently in the bottom bar.
|
||||||
static bool goToTab(BuildContext context, Modules module) {
|
static bool goToTab(BuildContext context, Modules module) {
|
||||||
final index = AppModule.getBottomBarModules(context)
|
final index = AppModule.getBottomBarModules(context)
|
||||||
.map((e) => e.module)
|
.map((e) => e.module)
|
||||||
@@ -187,8 +164,6 @@ class AppRoutes {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convenience wrapper for the Talk tab — preserved for the notification
|
|
||||||
/// handler API which only knows about Talk.
|
|
||||||
static void goToTalkTab(BuildContext context) {
|
static void goToTalkTab(BuildContext context) {
|
||||||
goToTab(context, Modules.talk);
|
goToTab(context, Modules.talk);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,21 +12,15 @@ abstract class DataLoader<TResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<TResult> run() async {
|
Future<TResult> run() async {
|
||||||
var fetcher = fetch();
|
final response = await fetch();
|
||||||
await Future.wait([
|
|
||||||
fetcher,
|
|
||||||
Future.delayed(const Duration(milliseconds: 500)) // TODO tune or remove
|
|
||||||
]);
|
|
||||||
|
|
||||||
var response = await fetcher;
|
|
||||||
try {
|
try {
|
||||||
return assemble(DataLoaderResult(
|
return assemble(DataLoaderResult(
|
||||||
json: jsonDecode(response.data!),
|
json: jsonDecode(response.data!),
|
||||||
headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))),
|
headers: response.headers.map.map((key, value) => MapEntry(key, value.join(';'))),
|
||||||
));
|
));
|
||||||
} catch(trace, e) {
|
} catch (e, stack) {
|
||||||
log(trace.toString());
|
log('DataLoader assemble failed', error: e, stackTrace: stack);
|
||||||
throw e;
|
rethrow;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,26 +2,22 @@ import '../../../../../api/marianumcloud/talk/create_room/create_room.dart';
|
|||||||
import '../../../../../api/marianumcloud/talk/create_room/create_room_params.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_cache.dart';
|
||||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||||
|
import '../../../../../api/request_cache.dart';
|
||||||
|
|
||||||
class ChatListDataProvider {
|
class ChatListDataProvider {
|
||||||
Future<GetRoomResponse> getRooms({
|
Future<GetRoomResponse> getRooms({
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
bool renew = false,
|
bool renew = false,
|
||||||
}) async {
|
}) =>
|
||||||
GetRoomResponse? latest;
|
resolveFromCache<GetRoomResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, onError) => GetRoomCache(
|
||||||
final cache = GetRoomCache(
|
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
onError: (e) {
|
onError: onError,
|
||||||
capturedError = e;
|
),
|
||||||
onError?.call(e);
|
onError: onError,
|
||||||
},
|
operationName: 'getRooms',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from getRooms');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> createDirectRoom(String invite) =>
|
Future<void> createDirectRoom(String invite) =>
|
||||||
CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run();
|
CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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_cache.dart';
|
||||||
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||||
import '../../../../../api/marianumcloud/webdav/webdav_api.dart';
|
import '../../../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||||
|
import '../../../../../api/request_cache.dart';
|
||||||
|
|
||||||
class FilesDataProvider {
|
class FilesDataProvider {
|
||||||
/// Lists files at [path]. Cached payload is delivered via [onCacheData] as
|
/// Lists files at [path]. Cached payload is delivered via [onCacheData] as
|
||||||
@@ -14,22 +15,17 @@ class FilesDataProvider {
|
|||||||
String path, {
|
String path, {
|
||||||
void Function(ListFilesResponse)? onCacheData,
|
void Function(ListFilesResponse)? onCacheData,
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
}) async {
|
}) =>
|
||||||
ListFilesResponse? latest;
|
resolveFromCache<ListFilesResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, onError) => ListFilesCache(
|
||||||
final cache = ListFilesCache(
|
|
||||||
path: path,
|
path: path,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
onCacheData: onCacheData,
|
onCacheData: onCacheData,
|
||||||
onError: (e) {
|
onError: onError,
|
||||||
capturedError = e;
|
),
|
||||||
onError?.call(e);
|
onError: onError,
|
||||||
},
|
operationName: 'listFiles',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from listFiles');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> createFolder(String fullPath) async {
|
Future<void> createFolder(String fullPath) async {
|
||||||
final webdav = await WebdavApi.webdav;
|
final webdav = await WebdavApi.webdav;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_time
|
|||||||
import '../../../../../api/mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.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.dart';
|
||||||
import '../../../../../api/mhsl/custom_timetable_event/update/update_custom_timetable_event_params.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_cache.dart';
|
||||||
import '../../../../../api/webuntis/queries/get_holidays/get_holidays_response.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_cache.dart';
|
||||||
@@ -30,112 +31,84 @@ class TimetableDataProvider {
|
|||||||
DateTime endDate, {
|
DateTime endDate, {
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
bool renew = false,
|
bool renew = false,
|
||||||
}) async {
|
}) =>
|
||||||
GetTimetableResponse? latest;
|
resolveFromCache<GetTimetableResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, onError) => GetTimetableCache(
|
||||||
final cache = GetTimetableCache(
|
|
||||||
startdate: int.parse(_dateFormat.format(startDate)),
|
startdate: int.parse(_dateFormat.format(startDate)),
|
||||||
enddate: int.parse(_dateFormat.format(endDate)),
|
enddate: int.parse(_dateFormat.format(endDate)),
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
onError: (e) {
|
onError: onError,
|
||||||
capturedError = e;
|
),
|
||||||
onError?.call(e);
|
onError: onError,
|
||||||
},
|
operationName: 'getWeek',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from getWeek');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GetRoomsResponse> getRooms({
|
Future<GetRoomsResponse> getRooms({
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
bool renew = false,
|
bool renew = false,
|
||||||
}) async {
|
}) =>
|
||||||
GetRoomsResponse? latest;
|
resolveFromCache<GetRoomsResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, onError) => GetRoomsCache(
|
||||||
final cache = GetRoomsCache(
|
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
onError: (e) {
|
onError: onError,
|
||||||
capturedError = e;
|
),
|
||||||
onError?.call(e);
|
onError: onError,
|
||||||
},
|
operationName: 'getRooms',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from getRooms');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GetSubjectsResponse> getSubjects({
|
Future<GetSubjectsResponse> getSubjects({
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
bool renew = false,
|
bool renew = false,
|
||||||
}) async {
|
}) =>
|
||||||
GetSubjectsResponse? latest;
|
resolveFromCache<GetSubjectsResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, onError) => GetSubjectsCache(
|
||||||
final cache = GetSubjectsCache(
|
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
onError: (e) {
|
onError: onError,
|
||||||
capturedError = e;
|
),
|
||||||
onError?.call(e);
|
onError: onError,
|
||||||
},
|
operationName: 'getSubjects',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from getSubjects');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GetHolidaysResponse> getSchoolHolidays({
|
Future<GetHolidaysResponse> getSchoolHolidays({
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
bool renew = false,
|
bool renew = false,
|
||||||
}) async {
|
}) =>
|
||||||
GetHolidaysResponse? latest;
|
resolveFromCache<GetHolidaysResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, onError) => GetHolidaysCache(
|
||||||
final cache = GetHolidaysCache(
|
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
onError: (e) {
|
onError: onError,
|
||||||
capturedError = e;
|
),
|
||||||
onError?.call(e);
|
onError: onError,
|
||||||
},
|
operationName: 'getSchoolHolidays',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from getSchoolHolidays');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GetTimegridUnitsResponse> getTimegrid({bool renew = false}) async {
|
Future<GetTimegridUnitsResponse> getTimegrid({bool renew = false}) =>
|
||||||
GetTimegridUnitsResponse? latest;
|
resolveFromCache<GetTimegridUnitsResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, _) => GetTimegridUnitsCache(
|
||||||
final cache = GetTimegridUnitsCache(
|
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
|
),
|
||||||
|
operationName: 'getTimegrid',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from getTimegrid');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<GetCustomTimetableEventResponse> getCustomEvents({
|
Future<GetCustomTimetableEventResponse> getCustomEvents({
|
||||||
bool renew = false,
|
bool renew = false,
|
||||||
void Function(Object)? onError,
|
void Function(Object)? onError,
|
||||||
}) async {
|
}) =>
|
||||||
GetCustomTimetableEventResponse? latest;
|
resolveFromCache<GetCustomTimetableEventResponse>(
|
||||||
Object? capturedError;
|
(onUpdate, onError) => GetCustomTimetableEventCache(
|
||||||
final cache = GetCustomTimetableEventCache(
|
|
||||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||||
renew: renew,
|
renew: renew,
|
||||||
onUpdate: (data) => latest = data,
|
onUpdate: onUpdate,
|
||||||
onError: (e) {
|
onError: onError,
|
||||||
capturedError = e;
|
),
|
||||||
onError?.call(e);
|
onError: onError,
|
||||||
},
|
operationName: 'getCustomEvents',
|
||||||
);
|
);
|
||||||
await cache.ready;
|
|
||||||
if (latest != null) return latest!;
|
|
||||||
throw capturedError ?? Exception('No data and no error from getCustomEvents');
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addCustomEvent(CustomTimetableEvent event) =>
|
Future<void> addCustomEvent(CustomTimetableEvent event) =>
|
||||||
AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run();
|
AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run();
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
/// Copies [text] to the system clipboard and shows a SnackBar confirmation.
|
/// Copies [text] to the system clipboard and shows a SnackBar.
|
||||||
/// Safe to await: respects context lifecycle via the provided [context].
|
|
||||||
Future<void> copyToClipboard(
|
Future<void> copyToClipboard(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
String text, {
|
String text, {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:nextcloud/nextcloud.dart';
|
|||||||
import '../../../api/marianumcloud/webdav/webdav_api.dart';
|
import '../../../api/marianumcloud/webdav/webdav_api.dart';
|
||||||
import '../../../widget/confirm_dialog.dart';
|
import '../../../widget/confirm_dialog.dart';
|
||||||
import '../../../widget/focus_behaviour.dart';
|
import '../../../widget/focus_behaviour.dart';
|
||||||
|
import '../../../widget/info_dialog.dart';
|
||||||
|
|
||||||
class FilesUploadDialog extends StatefulWidget {
|
class FilesUploadDialog extends StatefulWidget {
|
||||||
final List<String> filePaths;
|
final List<String> filePaths;
|
||||||
@@ -48,19 +49,11 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void showHttpErrorCode(int httpErrorCode) {
|
void showHttpErrorCode(int httpErrorCode) {
|
||||||
showDialog(
|
InfoDialog.show(
|
||||||
context: context,
|
context,
|
||||||
builder: (BuildContext context) => AlertDialog(
|
'Error code: $httpErrorCode',
|
||||||
title: const Text('Ein Fehler ist aufgetreten'),
|
title: 'Ein Fehler ist aufgetreten',
|
||||||
contentPadding: const EdgeInsets.all(10),
|
copyable: true,
|
||||||
content: Text('Error code: $httpErrorCode'),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Schließen', textAlign: TextAlign.center),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,20 +63,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
|||||||
_overallProgressValue = 0.0;
|
_overallProgressValue = 0.0;
|
||||||
_infoText = '';
|
_infoText = '';
|
||||||
});
|
});
|
||||||
showDialog(
|
InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true);
|
||||||
context: context,
|
|
||||||
builder: (BuildContext context) => AlertDialog(
|
|
||||||
title: const Text('Upload fehlgeschlagen'),
|
|
||||||
contentPadding: const EdgeInsets.all(10),
|
|
||||||
content: Text(message),
|
|
||||||
actions: [
|
|
||||||
TextButton(
|
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
|
||||||
child: const Text('Schließen', textAlign: TextAlign.center),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> uploadFiles({bool override = false}) async {
|
Future<void> uploadFiles({bool override = false}) async {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../../../utils/download_manager.dart';
|
|||||||
import '../../../../utils/file_clipboard.dart';
|
import '../../../../utils/file_clipboard.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/info_dialog.dart';
|
import '../../../../widget/info_dialog.dart';
|
||||||
import 'file_details_sheet.dart';
|
import 'file_details_sheet.dart';
|
||||||
|
|
||||||
@@ -77,13 +78,7 @@ class _FileElementState extends State<FileElement> {
|
|||||||
DownloadManager.instance.clear(widget.file.path);
|
DownloadManager.instance.clear(widget.file.path);
|
||||||
_detachJob();
|
_detachJob();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
showDialog<void>(
|
InfoDialog.show(context, message, title: 'Download', copyable: true);
|
||||||
context: context,
|
|
||||||
builder: (context) => AlertDialog(
|
|
||||||
title: const Text('Download'),
|
|
||||||
content: Text(message),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (status is DownloadCancelled) {
|
} else if (status is DownloadCancelled) {
|
||||||
DownloadManager.instance.clear(widget.file.path);
|
DownloadManager.instance.clear(widget.file.path);
|
||||||
_detachJob();
|
_detachJob();
|
||||||
@@ -172,6 +167,7 @@ class _FileElementState extends State<FileElement> {
|
|||||||
|
|
||||||
Future<void> _rename() async {
|
Future<void> _rename() async {
|
||||||
final controller = TextEditingController(text: widget.file.name);
|
final controller = TextEditingController(text: widget.file.name);
|
||||||
|
try {
|
||||||
final newName = await showDialog<String>(
|
final newName = await showDialog<String>(
|
||||||
context: context,
|
context: context,
|
||||||
builder: (dialogCtx) => AlertDialog(
|
builder: (dialogCtx) => AlertDialog(
|
||||||
@@ -198,6 +194,9 @@ class _FileElementState extends State<FileElement> {
|
|||||||
final webdav = await WebdavApi.webdav;
|
final webdav = await WebdavApi.webdav;
|
||||||
await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination));
|
await webdav.move(PathUri.parse(widget.file.path), PathUri.parse(destination));
|
||||||
}, errorTitle: 'Umbenennen fehlgeschlagen');
|
}, errorTitle: 'Umbenennen fehlgeschlagen');
|
||||||
|
} finally {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _putOnClipboard({required bool copy}) {
|
void _putOnClipboard({required bool copy}) {
|
||||||
@@ -234,25 +233,14 @@ class _FileElementState extends State<FileElement> {
|
|||||||
widget.refetch();
|
widget.refetch();
|
||||||
} on Object catch (e) {
|
} on Object catch (e) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
await showDialog<void>(
|
InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true);
|
||||||
context: context,
|
|
||||||
builder: (dialogCtx) => AlertDialog(
|
|
||||||
title: Text(errorTitle),
|
|
||||||
content: Text(e.toString()),
|
|
||||||
actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showActionSheet() {
|
void _showActionSheet() {
|
||||||
showModalBottomSheet<void>(
|
showDetailsBottomSheet(
|
||||||
context: context,
|
context,
|
||||||
showDragHandle: true,
|
children: (sheetCtx) => [
|
||||||
builder: (sheetCtx) => SafeArea(
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
children: [
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.info_outline)),
|
leading: const CenteredLeading(Icon(Icons.info_outline)),
|
||||||
title: const Text('Info'),
|
title: const Text('Info'),
|
||||||
@@ -294,8 +282,6 @@ class _FileElementState extends State<FileElement> {
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,6 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../data/sort_options.dart';
|
import '../data/sort_options.dart';
|
||||||
|
|
||||||
/// AppBar action buttons for sort direction (asc/desc) and sort field
|
|
||||||
/// (name/date/size). Pure UI – owners pass current values + selection
|
|
||||||
/// callbacks.
|
|
||||||
class FilesSortActions extends StatelessWidget {
|
class FilesSortActions extends StatelessWidget {
|
||||||
final SortOption currentSort;
|
final SortOption currentSort;
|
||||||
final bool ascending;
|
final bool ascending;
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import '../../../state/app/modules/holidays/bloc/holidays_state.dart';
|
|||||||
import '../../../widget/animated_time.dart';
|
import '../../../widget/animated_time.dart';
|
||||||
import '../../../widget/centered_leading.dart';
|
import '../../../widget/centered_leading.dart';
|
||||||
import '../../../widget/debug/debug_tile.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/list_view_util.dart';
|
||||||
import '../../../widget/string_extensions.dart';
|
import '../../../widget/string_extensions.dart';
|
||||||
|
|
||||||
@@ -21,18 +23,13 @@ class HolidaysView extends StatelessWidget {
|
|||||||
create: (context) => HolidaysBloc(),
|
create: (context) => HolidaysBloc(),
|
||||||
autoRebuild: true,
|
autoRebuild: true,
|
||||||
child: (context, bloc, state) {
|
child: (context, bloc, state) {
|
||||||
void showDisclaimer() {
|
void showDisclaimer() => InfoDialog.show(
|
||||||
showDialog(context: context, builder: (context) => AlertDialog(
|
context,
|
||||||
title: const Text('Richtigkeit und Bereitstellung der Daten'),
|
|
||||||
content: const Text(''
|
|
||||||
'Sämtliche Datumsangaben sind ohne Gewähr.\n'
|
'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'
|
'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/'),
|
'Die Daten stammen von https://ferien-api.de/',
|
||||||
actions: [
|
title: 'Richtigkeit und Bereitstellung der Daten',
|
||||||
TextButton(child: const Text('Okay'), onPressed: () => Navigator.of(context).pop()),
|
);
|
||||||
],
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
@@ -78,9 +75,16 @@ class HolidaysView extends StatelessWidget {
|
|||||||
leading: const CenteredLeading(Icon(Icons.calendar_month)),
|
leading: const CenteredLeading(Icon(Icons.calendar_month)),
|
||||||
title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'),
|
title: Text('$holidayType ${getHolidayYear(holiday.start, holiday.end)}'),
|
||||||
subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'),
|
subtitle: Text('${formatDate(holiday.start)} - ${formatDate(holiday.end)}'),
|
||||||
onTap: () => showDialog(context: context, builder: (context) => SimpleDialog(
|
onTap: () => showDetailsBottomSheet(
|
||||||
title: Text('$holidayType ${holiday.year} in Hessen'),
|
context,
|
||||||
children: [
|
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(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.signpost_outlined)),
|
leading: const CenteredLeading(Icon(Icons.signpost_outlined)),
|
||||||
title: Text(holiday.name.capitalize()),
|
title: Text(holiday.name.capitalize()),
|
||||||
@@ -94,21 +98,20 @@ class HolidaysView extends StatelessWidget {
|
|||||||
leading: const Icon(Icons.date_range_outlined),
|
leading: const Icon(Icons.date_range_outlined),
|
||||||
title: Text('bis zum ${formatDate(holiday.end)}'),
|
title: Text('bis zum ${formatDate(holiday.end)}'),
|
||||||
),
|
),
|
||||||
Visibility(
|
if (DateTime.parse(holiday.start).difference(DateTime.now()).isNegative)
|
||||||
visible: !DateTime.parse(holiday.start).difference(DateTime.now()).isNegative,
|
ListTile(
|
||||||
replacement: ListTile(
|
|
||||||
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
|
leading: const CenteredLeading(Icon(Icons.content_paste_search_outlined)),
|
||||||
title: Text(Jiffy.parse(holiday.start).fromNow()),
|
title: Text(Jiffy.parse(holiday.start).fromNow()),
|
||||||
),
|
)
|
||||||
child: ListTile(
|
else
|
||||||
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
|
leading: const CenteredLeading(Icon(Icons.timer_outlined)),
|
||||||
title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())),
|
title: AnimatedTime(callback: () => DateTime.parse(holiday.start).difference(DateTime.now())),
|
||||||
subtitle: Text(Jiffy.parse(holiday.start).fromNow()),
|
subtitle: Text(Jiffy.parse(holiday.start).fromNow()),
|
||||||
),
|
),
|
||||||
),
|
DebugTile(sheetCtx).jsonData(holiday.toJson()),
|
||||||
DebugTile(context).jsonData(holiday.toJson()),
|
|
||||||
],
|
],
|
||||||
)),
|
),
|
||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -5,26 +5,33 @@ import '../../../../widget/share_position_origin.dart';
|
|||||||
|
|
||||||
enum ShareTargetType { qr }
|
enum ShareTargetType { qr }
|
||||||
|
|
||||||
class SelectShareTypeDialog extends StatelessWidget {
|
/// Bottom sheet that lets the user pick how they want to share the app.
|
||||||
const SelectShareTypeDialog({super.key});
|
/// Resolves with [ShareTargetType.qr] for the QR option, or `null` when the
|
||||||
|
/// sheet is dismissed (link sharing fires immediately and resolves null).
|
||||||
@override
|
Future<ShareTargetType?> showSelectShareTypeSheet(BuildContext context) {
|
||||||
Widget build(BuildContext context) => SimpleDialog(
|
return showModalBottomSheet<ShareTargetType>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
useSafeArea: true,
|
||||||
|
builder: (sheetCtx) => SafeArea(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.qr_code_2_outlined),
|
leading: const Icon(Icons.qr_code_2_outlined),
|
||||||
title: const Text('Per QR-Code'),
|
title: const Text('Per QR-Code'),
|
||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () => Navigator.of(context).pop(ShareTargetType.qr),
|
onTap: () => Navigator.of(sheetCtx).pop(ShareTargetType.qr),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.link_outlined),
|
leading: const Icon(Icons.link_outlined),
|
||||||
title: const Text('Per Link teilen'),
|
title: const Text('Per Link teilen'),
|
||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(context).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
SharePlus.instance.share(ShareParams(
|
SharePlus.instance.share(ShareParams(
|
||||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
sharePositionOrigin: SharePositionOrigin.get(sheetCtx),
|
||||||
subject: 'App Teilen',
|
subject: 'App Teilen',
|
||||||
text: 'Hol dir die für das Marianum maßgeschneiderte App:'
|
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 '
|
'\n\nAndroid: https://play.google.com/store/apps/details?id=eu.mhsl.marianum.mobile.client '
|
||||||
@@ -32,7 +39,9 @@ class SelectShareTypeDialog extends StatelessWidget {
|
|||||||
'\n\nViel Spaß!',
|
'\n\nViel Spaß!',
|
||||||
));
|
));
|
||||||
},
|
},
|
||||||
)
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,10 +42,7 @@ class _OverhangState extends State<Overhang> {
|
|||||||
subtitle: const Text('Mit Freunden und deiner Klasse teilen'),
|
subtitle: const Text('Mit Freunden und deiner Klasse teilen'),
|
||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
final result = await showDialog<ShareTargetType>(
|
final result = await showSelectShareTypeSheet(context);
|
||||||
context: context,
|
|
||||||
builder: (_) => const SelectShareTypeDialog(),
|
|
||||||
);
|
|
||||||
if (!mounted || result != ShareTargetType.qr) return;
|
if (!mounted || result != ShareTargetType.qr) return;
|
||||||
if (context.mounted) AppRoutes.openQrShare(context);
|
if (context.mounted) AppRoutes.openQrShare(context);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:package_info_plus/package_info_plus.dart';
|
|||||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../data/default_settings.dart';
|
import '../data/default_settings.dart';
|
||||||
import '../widgets/privacy_info.dart';
|
import '../widgets/privacy_info.dart';
|
||||||
import 'dev_tools_section.dart';
|
import 'dev_tools_section.dart';
|
||||||
@@ -69,10 +70,9 @@ class AboutSection extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showPrivacyDialog(BuildContext context) => showDialog(
|
void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (context) => SimpleDialog(
|
children: (sheetCtx) => [
|
||||||
children: [
|
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
||||||
title: const Text('Infos zum Marianum Fulda'),
|
title: const Text('Infos zum Marianum Fulda'),
|
||||||
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
|
|||||||
providerText: 'Marianum',
|
providerText: 'Marianum',
|
||||||
imprintUrl: 'https://www.marianum-fulda.de/impressum',
|
imprintUrl: 'https://www.marianum-fulda.de/impressum',
|
||||||
privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
|
privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
|
||||||
).showPopup(context),
|
).showPopup(sheetCtx),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
||||||
@@ -93,7 +93,7 @@ class AboutSection extends StatelessWidget {
|
|||||||
providerText: 'Untis',
|
providerText: 'Untis',
|
||||||
imprintUrl: 'https://www.untis.at/impressum',
|
imprintUrl: 'https://www.untis.at/impressum',
|
||||||
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
|
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
|
||||||
).showPopup(context),
|
).showPopup(sheetCtx),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)),
|
leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)),
|
||||||
@@ -104,10 +104,9 @@ class AboutSection extends StatelessWidget {
|
|||||||
providerText: 'mhsl',
|
providerText: 'mhsl',
|
||||||
imprintUrl: 'https://mhsl.eu/id.html',
|
imprintUrl: 'https://mhsl.eu/id.html',
|
||||||
privacyUrl: 'https://mhsl.eu/datenschutz.html',
|
privacyUrl: 'https://mhsl.eu/datenschutz.html',
|
||||||
).showPopup(context),
|
).showPopup(sheetCtx),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) {
|
void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import '../../../../widget/centered_leading.dart';
|
|||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/debug/cache_view.dart';
|
import '../../../../widget/debug/cache_view.dart';
|
||||||
import '../../../../widget/debug/json_viewer.dart';
|
import '../../../../widget/debug/json_viewer.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
|
||||||
class DevToolsSection extends StatefulWidget {
|
class DevToolsSection extends StatefulWidget {
|
||||||
final SettingsCubit settings;
|
final SettingsCubit settings;
|
||||||
@@ -29,13 +30,15 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
|||||||
title: const Text('Performance overlays'),
|
title: const Text('Performance overlays'),
|
||||||
trailing: const Icon(Icons.arrow_right),
|
trailing: const Icon(Icons.arrow_right),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
showDialog(
|
showDetailsBottomSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (dialogCtx) => BlocBuilder<SettingsCubit, model.Settings>(
|
children: (sheetCtx) => [
|
||||||
|
BlocBuilder<SettingsCubit, model.Settings>(
|
||||||
bloc: widget.settings,
|
bloc: widget.settings,
|
||||||
builder: (_, _) {
|
builder: (_, _) {
|
||||||
final dev = widget.settings.val().devToolsSettings;
|
final dev = widget.settings.val().devToolsSettings;
|
||||||
return SimpleDialog(
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.auto_graph_outlined),
|
leading: const Icon(Icons.auto_graph_outlined),
|
||||||
@@ -65,6 +68,7 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -122,9 +126,6 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
|||||||
leading: const CenteredLeading(Icon(Icons.data_object)),
|
leading: const CenteredLeading(Icon(Icons.data_object)),
|
||||||
title: const Text('BLOC-storage state cache'),
|
title: const Text('BLOC-storage state cache'),
|
||||||
subtitle: const Text('Lange tippen um zu löschen'),
|
subtitle: const Text('Lange tippen um zu löschen'),
|
||||||
onTap: () {
|
|
||||||
// Navigator.push(context, MaterialPageRoute(builder: (context) => const CacheView()));
|
|
||||||
},
|
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title: 'BLOC-Cache löschen',
|
title: 'BLOC-Cache löschen',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
|
|
||||||
import '../../../../widget/centered_leading.dart';
|
import '../../../../widget/centered_leading.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
|
||||||
class PrivacyInfo {
|
class PrivacyInfo {
|
||||||
String providerText;
|
String providerText;
|
||||||
@@ -11,22 +12,29 @@ class PrivacyInfo {
|
|||||||
PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl});
|
PrivacyInfo({required this.providerText, required this.imprintUrl, required this.privacyUrl});
|
||||||
|
|
||||||
void showPopup(BuildContext context) {
|
void showPopup(BuildContext context) {
|
||||||
showDialog(context: context, builder: (context) => SimpleDialog(
|
showDetailsBottomSheet(
|
||||||
title: Text('Betreiberinformation | $providerText'),
|
context,
|
||||||
children: [
|
header: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 4, 16, 12),
|
||||||
|
child: Text(
|
||||||
|
'Betreiberinformation | $providerText',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
children: (sheetCtx) => [
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.person_pin_outlined)),
|
leading: const CenteredLeading(Icon(Icons.person_pin_outlined)),
|
||||||
title: const Text('Impressum'),
|
title: const Text('Impressum'),
|
||||||
subtitle: Text(imprintUrl),
|
subtitle: Text(imprintUrl),
|
||||||
onTap: () => ConfirmDialog.openBrowser(context, imprintUrl),
|
onTap: () => ConfirmDialog.openBrowser(sheetCtx, imprintUrl),
|
||||||
),
|
),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)),
|
leading: const CenteredLeading(Icon(Icons.privacy_tip_outlined)),
|
||||||
title: const Text('Datenschutzerklärung'),
|
title: const Text('Datenschutzerklärung'),
|
||||||
subtitle: Text(privacyUrl),
|
subtitle: Text(privacyUrl),
|
||||||
onTap: () => ConfirmDialog.openBrowser(context, privacyUrl),
|
onTap: () => ConfirmDialog.openBrowser(sheetCtx, privacyUrl),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
));
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
|||||||
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
import '../../../state/app/modules/chat_list/bloc/chat_list_state.dart';
|
||||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||||
import '../../../widget/confirm_dialog.dart';
|
import '../../../widget/confirm_dialog.dart';
|
||||||
|
import '../../../widget/info_dialog.dart';
|
||||||
import '../../../widget/placeholder_view.dart';
|
import '../../../widget/placeholder_view.dart';
|
||||||
import 'join_chat.dart';
|
import 'join_chat.dart';
|
||||||
import 'search_chat.dart';
|
import 'search_chat.dart';
|
||||||
@@ -98,11 +99,9 @@ class _ChatListViewState extends State<_ChatListView> {
|
|||||||
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
|
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
|
||||||
break;
|
break;
|
||||||
case AuthorizationStatus.denied:
|
case AuthorizationStatus.denied:
|
||||||
showDialog(
|
InfoDialog.show(
|
||||||
context: context,
|
context,
|
||||||
builder: (_) => const AlertDialog(
|
'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.',
|
||||||
content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -40,9 +40,8 @@ class BubbleStyle {
|
|||||||
final double borderRadius;
|
final double borderRadius;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a
|
/// The "nip" is faked by flattening one corner so the bubble anchors to
|
||||||
/// rounded container with optional shadow / border. The nip is conveyed by
|
/// the speaker side.
|
||||||
/// flattening one corner so the bubble visually anchors to the speaker side.
|
|
||||||
class Bubble extends StatelessWidget {
|
class Bubble extends StatelessWidget {
|
||||||
const Bubble({required this.child, required this.style, super.key});
|
const Bubble({required this.child, required this.style, super.key});
|
||||||
|
|
||||||
|
|||||||
@@ -246,9 +246,6 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stack inside the bubble: actor name (top-left, optional), message body
|
|
||||||
/// (centre), timestamp + read marker (bottom-right, optional), and a
|
|
||||||
/// download progress bar overlaid at the bottom while a job is running.
|
|
||||||
class _BubbleContent extends StatelessWidget {
|
class _BubbleContent extends StatelessWidget {
|
||||||
final Text actorText;
|
final Text actorText;
|
||||||
final Text timeText;
|
final Text timeText;
|
||||||
|
|||||||
@@ -14,13 +14,14 @@ import '../../../../utils/clipboard_helper.dart';
|
|||||||
import '../../../../widget/app_progress_indicator.dart';
|
import '../../../../widget/app_progress_indicator.dart';
|
||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
|
|
||||||
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
|
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
|
||||||
|
|
||||||
/// Long-press / double-tap options dialog for a single chat message bubble.
|
/// Long-press / double-tap options dialog for a single chat message bubble.
|
||||||
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
|
/// The hosting [ChatBubble] keeps responsibility for rendering the bubble;
|
||||||
/// this file owns the modal interactions (react, reply, copy, delete, ...).
|
/// this file owns the modal interactions (react, reply, copy, delete, ...).
|
||||||
Future<void> showChatMessageOptionsDialog(
|
void showChatMessageOptionsDialog(
|
||||||
BuildContext context, {
|
BuildContext context, {
|
||||||
required GetRoomResponseObject chatData,
|
required GetRoomResponseObject chatData,
|
||||||
required GetChatResponseObject bubbleData,
|
required GetChatResponseObject bubbleData,
|
||||||
@@ -34,24 +35,23 @@ Future<void> showChatMessageOptionsDialog(
|
|||||||
.add(const Duration(hours: 6))
|
.add(const Duration(hours: 6))
|
||||||
.isAfter(DateTime.now());
|
.isAfter(DateTime.now());
|
||||||
|
|
||||||
return showDialog(
|
showDetailsBottomSheet(
|
||||||
context: context,
|
context,
|
||||||
builder: (dialogCtx) => SimpleDialog(
|
children: (sheetCtx) => [
|
||||||
children: [
|
|
||||||
if (canReact)
|
if (canReact)
|
||||||
_ReactionsRow(
|
_ReactionsRow(
|
||||||
chatToken: chatData.token,
|
chatToken: chatData.token,
|
||||||
messageId: bubbleData.id,
|
messageId: bubbleData.id,
|
||||||
onRefetch: onRefetch,
|
onRefetch: onRefetch,
|
||||||
dialogContext: dialogCtx,
|
sheetContext: sheetCtx,
|
||||||
),
|
),
|
||||||
if (bubbleData.isReplyable)
|
if (bubbleData.isReplyable)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.reply_outlined),
|
leading: const Icon(Icons.reply_outlined),
|
||||||
title: const Text('Antworten'),
|
title: const Text('Antworten'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
dialogCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
|
sheetCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (canReact)
|
if (canReact)
|
||||||
@@ -59,7 +59,7 @@ Future<void> showChatMessageOptionsDialog(
|
|||||||
leading: const Icon(Icons.emoji_emotions_outlined),
|
leading: const Icon(Icons.emoji_emotions_outlined),
|
||||||
title: const Text('Reaktionen'),
|
title: const Text('Reaktionen'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
if (!parentContext.mounted) return;
|
if (!parentContext.mounted) return;
|
||||||
AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id);
|
AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id);
|
||||||
},
|
},
|
||||||
@@ -70,14 +70,14 @@ Future<void> showChatMessageOptionsDialog(
|
|||||||
title: const Text('Nachricht kopieren'),
|
title: const Text('Nachricht kopieren'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
copyToClipboard(parentContext, bubbleData.message);
|
copyToClipboard(parentContext, bubbleData.message);
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne)
|
if (!kReleaseMode && !isSender && chatData.type != GetRoomResponseObjectConversationType.oneToOne)
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.sms_outlined),
|
leading: const Icon(Icons.sms_outlined),
|
||||||
title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"),
|
title: Text("Private Nachricht an '${bubbleData.actorDisplayName}'"),
|
||||||
onTap: () => Navigator.of(dialogCtx).pop(),
|
onTap: () => Navigator.of(sheetCtx).pop(),
|
||||||
),
|
),
|
||||||
if (canDelete)
|
if (canDelete)
|
||||||
AsyncListTile(
|
AsyncListTile(
|
||||||
@@ -85,12 +85,11 @@ Future<void> showChatMessageOptionsDialog(
|
|||||||
title: const Text('Nachricht löschen'),
|
title: const Text('Nachricht löschen'),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await DeleteMessage(chatData.token, bubbleData.id).run();
|
await DeleteMessage(chatData.token, bubbleData.id).run();
|
||||||
if (dialogCtx.mounted) dialogCtx.read<ChatBloc>().refresh();
|
if (sheetCtx.mounted) sheetCtx.read<ChatBloc>().refresh();
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DebugTile(dialogCtx).jsonData(bubbleData.toJson()),
|
DebugTile(sheetCtx).jsonData(bubbleData.toJson()),
|
||||||
],
|
],
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,13 +97,13 @@ class _ReactionsRow extends StatefulWidget {
|
|||||||
final String chatToken;
|
final String chatToken;
|
||||||
final int messageId;
|
final int messageId;
|
||||||
final void Function({bool renew}) onRefetch;
|
final void Function({bool renew}) onRefetch;
|
||||||
final BuildContext dialogContext;
|
final BuildContext sheetContext;
|
||||||
|
|
||||||
const _ReactionsRow({
|
const _ReactionsRow({
|
||||||
required this.chatToken,
|
required this.chatToken,
|
||||||
required this.messageId,
|
required this.messageId,
|
||||||
required this.onRefetch,
|
required this.onRefetch,
|
||||||
required this.dialogContext,
|
required this.sheetContext,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -131,7 +130,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (ok) {
|
if (ok) {
|
||||||
widget.onRefetch(renew: true);
|
widget.onRefetch(renew: true);
|
||||||
if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop();
|
if (widget.sheetContext.mounted) Navigator.of(widget.sheetContext).pop();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
|
|||||||
import '../../../../widget/async_action_button.dart';
|
import '../../../../widget/async_action_button.dart';
|
||||||
import '../../../../widget/confirm_dialog.dart';
|
import '../../../../widget/confirm_dialog.dart';
|
||||||
import '../../../../widget/debug/debug_tile.dart';
|
import '../../../../widget/debug/debug_tile.dart';
|
||||||
|
import '../../../../widget/details_bottom_sheet.dart';
|
||||||
import '../../../../widget/user_avatar.dart';
|
import '../../../../widget/user_avatar.dart';
|
||||||
import '../chat_view.dart';
|
import '../chat_view.dart';
|
||||||
import '../talk_navigator.dart';
|
import '../talk_navigator.dart';
|
||||||
@@ -124,8 +125,9 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
},
|
},
|
||||||
onLongPress: () {
|
onLongPress: () {
|
||||||
if (widget.disableContextActions) return;
|
if (widget.disableContextActions) return;
|
||||||
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
|
showDetailsBottomSheet(
|
||||||
children: [
|
context,
|
||||||
|
children: (sheetCtx) => [
|
||||||
if (widget.data.unreadMessages > 0)
|
if (widget.data.unreadMessages > 0)
|
||||||
AsyncListTile(
|
AsyncListTile(
|
||||||
leading: const Icon(Icons.mark_chat_read_outlined),
|
leading: const Icon(Icons.mark_chat_read_outlined),
|
||||||
@@ -163,7 +165,7 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
leading: const Icon(Icons.delete_outline),
|
leading: const Icon(Icons.delete_outline),
|
||||||
title: const Text('Konversation verlassen'),
|
title: const Text('Konversation verlassen'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.of(dialogCtx).pop();
|
Navigator.of(sheetCtx).pop();
|
||||||
ConfirmDialog(
|
ConfirmDialog(
|
||||||
title: 'Chat verlassen',
|
title: 'Chat verlassen',
|
||||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||||
@@ -175,9 +177,9 @@ class _ChatTileState extends State<ChatTile> {
|
|||||||
).asDialog(context);
|
).asDialog(context);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
DebugTile(dialogCtx).jsonData(widget.data.toJson()),
|
DebugTile(sheetCtx).jsonData(widget.data.toJson()),
|
||||||
],
|
],
|
||||||
));
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class _PollOptionsListState extends State<PollOptionsList> {
|
|||||||
final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
|
final portion = numVoters == 0 ? 0.0 : (votes / numVoters);
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
// enabled: false,
|
|
||||||
isThreeLine: portionsVisible,
|
isThreeLine: portionsVisible,
|
||||||
dense: true,
|
dense: true,
|
||||||
title: Text(
|
title: Text(
|
||||||
|
|||||||
@@ -3,14 +3,22 @@ const double kCalendarEndHour = 17.25;
|
|||||||
const Duration kCalendarTimeInterval = Duration(minutes: 30);
|
const Duration kCalendarTimeInterval = Duration(minutes: 30);
|
||||||
const double kCalendarViewHeaderHeight = 60;
|
const double kCalendarViewHeaderHeight = 60;
|
||||||
|
|
||||||
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather
|
/// Below this, the grid scrolls vertically rather than compressing further.
|
||||||
/// than compressing further.
|
|
||||||
const double kCalendarMinPxPerHour = 56;
|
const double kCalendarMinPxPerHour = 56;
|
||||||
|
|
||||||
/// Minimum height of a lesson block in the period-based layout. The grid
|
/// The grid scrolls vertically once lessons would otherwise be smaller.
|
||||||
/// scrolls vertically once lessons would otherwise be smaller than this.
|
|
||||||
const double kLessonBlockMinHeight = 50;
|
const double kLessonBlockMinHeight = 50;
|
||||||
|
|
||||||
/// Fixed height of a break block in the period-based layout. Independent of
|
/// Fixed (independent of actual break duration); breaks render as a compact
|
||||||
/// the actual break duration; breaks are rendered as a compact indicator.
|
/// indicator.
|
||||||
const double kBreakBlockHeight = 28;
|
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;
|
||||||
|
|||||||
@@ -2,14 +2,11 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||||
|
|
||||||
import '../data/arbitrary_appointment.dart';
|
import '../data/arbitrary_appointment.dart';
|
||||||
|
import '../data/calendar_layout.dart';
|
||||||
import 'cross_painter.dart';
|
import 'cross_painter.dart';
|
||||||
|
|
||||||
class AppointmentTile extends StatelessWidget {
|
class AppointmentTile extends StatelessWidget {
|
||||||
static const _radius = BorderRadius.all(Radius.circular(7));
|
static const _radius = BorderRadius.all(Radius.circular(7));
|
||||||
static const _titleFontSize = 15.0;
|
|
||||||
static const _titleMinFontSize = 11.0;
|
|
||||||
static const _bodyFontSize = 10.0;
|
|
||||||
static const _bodyLineHeight = 1.15;
|
|
||||||
|
|
||||||
final Appointment appointment;
|
final Appointment appointment;
|
||||||
final bool crossedOut;
|
final bool crossedOut;
|
||||||
@@ -42,8 +39,8 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_AdaptiveTitle(
|
_AdaptiveTitle(
|
||||||
text: appointment.subject,
|
text: appointment.subject,
|
||||||
fontSize: _titleFontSize,
|
fontSize: kAppointmentTitleFontSize,
|
||||||
minFontSize: _titleMinFontSize,
|
minFontSize: kAppointmentTitleMinFontSize,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
if (isCustom) ...[
|
if (isCustom) ...[
|
||||||
@@ -53,8 +50,8 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.only(top: 1),
|
padding: const EdgeInsets.only(top: 1),
|
||||||
child: _WrappingBody(
|
child: _WrappingBody(
|
||||||
text: description,
|
text: description,
|
||||||
fontSize: _bodyFontSize,
|
fontSize: kAppointmentBodyFontSize,
|
||||||
lineHeight: _bodyLineHeight,
|
lineHeight: kAppointmentBodyLineHeight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -63,7 +60,7 @@ class AppointmentTile extends StatelessWidget {
|
|||||||
.split('\n')
|
.split('\n')
|
||||||
.where((p) => p.isNotEmpty)
|
.where((p) => p.isNotEmpty)
|
||||||
.take(2))
|
.take(2))
|
||||||
_ScaledLine(text: line, fontSize: _bodyFontSize),
|
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
part of '../custom_workweek_calendar.dart';
|
part of '../custom_workweek_calendar.dart';
|
||||||
|
|
||||||
class _OutsideHoursStrip extends StatelessWidget {
|
class _OutsideHoursStrip extends StatelessWidget {
|
||||||
static const int _maxVisibleChips = 2;
|
|
||||||
static const double _chipHeight = 22;
|
|
||||||
static const double _chipSpacing = 3;
|
|
||||||
static const double _verticalPadding = 3;
|
|
||||||
|
|
||||||
final DateTime weekStart;
|
final DateTime weekStart;
|
||||||
final List<Appointment> appointments;
|
final List<Appointment> appointments;
|
||||||
final double rulerWidth;
|
final double rulerWidth;
|
||||||
@@ -28,17 +23,17 @@ class _OutsideHoursStrip extends StatelessWidget {
|
|||||||
|
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final maxChipsPerDay = outside
|
final maxChipsPerDay = outside
|
||||||
.map((day) => day.length > _maxVisibleChips ? _maxVisibleChips : day.length)
|
.map((day) => day.length > kOutsideChipsMaxVisible ? kOutsideChipsMaxVisible : day.length)
|
||||||
.fold<int>(0, (m, c) => c > m ? c : m);
|
.fold<int>(0, (m, c) => c > m ? c : m);
|
||||||
final stripHeight = _verticalPadding * 2 +
|
final stripHeight = kOutsideStripVerticalPadding * 2 +
|
||||||
maxChipsPerDay * _chipHeight +
|
maxChipsPerDay * kOutsideChipHeight +
|
||||||
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
|
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0);
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
color: theme.colorScheme.surfaceContainerLowest,
|
color: theme.colorScheme.surfaceContainerLowest,
|
||||||
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
|
padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: stripHeight - _verticalPadding * 2,
|
height: stripHeight - kOutsideStripVerticalPadding * 2,
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -47,9 +42,6 @@ class _OutsideHoursStrip extends StatelessWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: _OutsideDayColumn(
|
child: _OutsideDayColumn(
|
||||||
appointments: outside[d],
|
appointments: outside[d],
|
||||||
maxVisible: _maxVisibleChips,
|
|
||||||
chipHeight: _chipHeight,
|
|
||||||
chipSpacing: _chipSpacing,
|
|
||||||
onAppointmentTap: onAppointmentTap,
|
onAppointmentTap: onAppointmentTap,
|
||||||
isCrossedOut: isCrossedOut,
|
isCrossedOut: isCrossedOut,
|
||||||
),
|
),
|
||||||
@@ -63,17 +55,11 @@ class _OutsideHoursStrip extends StatelessWidget {
|
|||||||
|
|
||||||
class _OutsideDayColumn extends StatelessWidget {
|
class _OutsideDayColumn extends StatelessWidget {
|
||||||
final List<Appointment> appointments;
|
final List<Appointment> appointments;
|
||||||
final int maxVisible;
|
|
||||||
final double chipHeight;
|
|
||||||
final double chipSpacing;
|
|
||||||
final void Function(Appointment) onAppointmentTap;
|
final void Function(Appointment) onAppointmentTap;
|
||||||
final bool Function(Appointment) isCrossedOut;
|
final bool Function(Appointment) isCrossedOut;
|
||||||
|
|
||||||
const _OutsideDayColumn({
|
const _OutsideDayColumn({
|
||||||
required this.appointments,
|
required this.appointments,
|
||||||
required this.maxVisible,
|
|
||||||
required this.chipHeight,
|
|
||||||
required this.chipSpacing,
|
|
||||||
required this.onAppointmentTap,
|
required this.onAppointmentTap,
|
||||||
required this.isCrossedOut,
|
required this.isCrossedOut,
|
||||||
});
|
});
|
||||||
@@ -132,11 +118,12 @@ class _OutsideDayColumn extends StatelessWidget {
|
|||||||
if (!aLike && bLike) return 1;
|
if (!aLike && bLike) return 1;
|
||||||
return a.startTime.compareTo(b.startTime);
|
return a.startTime.compareTo(b.startTime);
|
||||||
});
|
});
|
||||||
final visible = sorted.length <= maxVisible
|
final visible = sorted.length <= kOutsideChipsMaxVisible
|
||||||
? sorted
|
? sorted
|
||||||
: sorted.take(maxVisible - 1).toList();
|
: sorted.take(kOutsideChipsMaxVisible - 1).toList();
|
||||||
final overflow =
|
final overflow = sorted.length <= kOutsideChipsMaxVisible
|
||||||
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
|
? const <Appointment>[]
|
||||||
|
: sorted.skip(kOutsideChipsMaxVisible - 1).toList();
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
@@ -145,9 +132,9 @@ class _OutsideDayColumn extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < visible.length; i++) ...[
|
for (var i = 0; i < visible.length; i++) ...[
|
||||||
if (i > 0) SizedBox(height: chipSpacing),
|
if (i > 0) const SizedBox(height: kOutsideChipSpacing),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: chipHeight,
|
height: kOutsideChipHeight,
|
||||||
child: _OutsideChip(
|
child: _OutsideChip(
|
||||||
appointment: visible[i],
|
appointment: visible[i],
|
||||||
onTap: () => onAppointmentTap(visible[i]),
|
onTap: () => onAppointmentTap(visible[i]),
|
||||||
@@ -155,9 +142,9 @@ class _OutsideDayColumn extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (overflow.isNotEmpty) ...[
|
if (overflow.isNotEmpty) ...[
|
||||||
SizedBox(height: chipSpacing),
|
const SizedBox(height: kOutsideChipSpacing),
|
||||||
SizedBox(
|
SizedBox(
|
||||||
height: chipHeight,
|
height: kOutsideChipHeight,
|
||||||
child: _OutsideOverflowChip(
|
child: _OutsideOverflowChip(
|
||||||
count: overflow.length,
|
count: overflow.length,
|
||||||
onTap: () => _showOverflow(context, overflow),
|
onTap: () => _showOverflow(context, overflow),
|
||||||
|
|||||||
@@ -429,8 +429,7 @@ class _OverflowTile extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(1),
|
padding: const EdgeInsets.all(1),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
// Card peeking out at the bottom — visual hint that more cards lie
|
// Stacked-cards effect: a darker layer peeks out below the front card.
|
||||||
// underneath the visible one.
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 4,
|
top: 4,
|
||||||
left: 2,
|
left: 2,
|
||||||
@@ -443,7 +442,6 @@ class _OverflowTile extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Front card with the "+N" indicator.
|
|
||||||
Positioned(
|
Positioned(
|
||||||
top: 0,
|
top: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@@ -40,10 +40,9 @@ class AsyncTextButton extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
)
|
)
|
||||||
: child;
|
: child;
|
||||||
return _InlineErrorWrapper(
|
final button = TextButton(onPressed: handler, child: content);
|
||||||
controller: controller,
|
if (!showInlineError) return button;
|
||||||
child: TextButton(onPressed: handler, child: content),
|
return _InlineErrorWrapper(controller: controller, child: button);
|
||||||
);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _FileViewerState extends State<FileViewer> {
|
class _FileViewerState extends State<FileViewer> {
|
||||||
PhotoViewController photoViewController = PhotoViewController();
|
final PhotoViewController photoViewController = PhotoViewController();
|
||||||
|
|
||||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||||
late bool openExternal;
|
late bool openExternal;
|
||||||
@@ -92,6 +92,12 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
photoViewController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
AppBar appbar({List<Widget> actions = const []}) => AppBar(
|
AppBar appbar({List<Widget> actions = const []}) => AppBar(
|
||||||
@@ -196,9 +202,7 @@ class _FileViewerState extends State<FileViewer> {
|
|||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop();
|
||||||
if (result.type != ResultType.done) {
|
if (result.type != ResultType.done) {
|
||||||
showDialog(context: context, builder: (context) => AlertDialog(
|
InfoDialog.show(context, result.message);
|
||||||
content: Text(result.message),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:marianum_mobile/api/api_error.dart';
|
import 'package:marianum_mobile/api/api_error.dart';
|
||||||
@@ -36,8 +37,9 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('HandshakeException maps to a TLS-specific message', () {
|
test('HandshakeException maps to a TLS-specific message', () {
|
||||||
expect(errorToUserMessage(const HandshakeException('bad cert')),
|
final message = errorToUserMessage(const HandshakeException('bad cert'));
|
||||||
'Sichere Verbindung konnte nicht hergestellt werden.');
|
expect(message, contains('sichere Verbindung'));
|
||||||
|
expect(message, contains('Geräte-Uhrzeit'));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('FormatException maps to ParseException message', () {
|
test('FormatException maps to ParseException message', () {
|
||||||
@@ -63,6 +65,34 @@ void main() {
|
|||||||
test('custom fallback overrides the default', () {
|
test('custom fallback overrides the default', () {
|
||||||
expect(errorToUserMessage(null, fallback: 'meins'), 'meins');
|
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', () {
|
group('errorToTechnicalDetails', () {
|
||||||
|
|||||||
Reference in New Issue
Block a user