claude refactor #95
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../api_error.dart';
|
||||
@@ -9,10 +10,46 @@ 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;
|
||||
@@ -21,6 +58,11 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||
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;
|
||||
}
|
||||
@@ -31,7 +73,7 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
||||
return const NetworkException().userMessage;
|
||||
}
|
||||
if (error is HandshakeException) {
|
||||
return 'Sichere Verbindung konnte nicht hergestellt werden.';
|
||||
return _tlsErrorMessage;
|
||||
}
|
||||
if (error is FormatException) {
|
||||
return const ParseException().userMessage;
|
||||
@@ -48,12 +90,20 @@ String? errorToTechnicalDetails(Object? error) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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/endpoint_data.dart';
|
||||
|
||||
/// Shared helpers for Nextcloud OCS v2 endpoints.
|
||||
///
|
||||
/// 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.
|
||||
/// Shared headers and URI builder for Nextcloud OCS v2 endpoints. Used by
|
||||
/// TalkApi, AutocompleteApi, FileSharingApi.
|
||||
class 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() => {
|
||||
'Accept': 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
'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}) {
|
||||
final endpoint = EndpointData().nextcloud();
|
||||
return Uri.https(
|
||||
|
||||
@@ -120,38 +120,6 @@ enum GetRoomResponseObjectParticipantNotificationLevel {
|
||||
@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 {
|
||||
@JsonValue('deleted_users') deletedUsers,
|
||||
@JsonValue('users') user,
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'dart:convert';
|
||||
import 'package:localstore/localstore.dart';
|
||||
|
||||
import 'api_response.dart';
|
||||
import 'errors/parse_exception.dart';
|
||||
|
||||
abstract class RequestCache<T extends ApiResponse?> {
|
||||
static const int cacheNothing = 0;
|
||||
@@ -81,10 +82,8 @@ abstract class RequestCache<T extends ApiResponse?> {
|
||||
|
||||
}
|
||||
|
||||
/// Concrete [RequestCache] that delegates the two overrides to functions
|
||||
/// passed in the constructor. Used to collapse the dozens of one-class-per-
|
||||
/// endpoint cache files that all just forward to `<Endpoint>().run()` and
|
||||
/// `<Response>.fromJson(jsonDecode(...))`.
|
||||
/// Concrete [RequestCache] that takes the two overrides as constructor
|
||||
/// callbacks instead of requiring a subclass per endpoint.
|
||||
class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
|
||||
final Future<T> Function() _loader;
|
||||
final T Function(Map<String, dynamic> json) _fromJson;
|
||||
@@ -115,3 +114,30 @@ class SimpleCache<T extends ApiResponse?> extends RequestCache<T> {
|
||||
@override
|
||||
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/json_viewer.dart';
|
||||
import '../widget/info_dialog.dart';
|
||||
import 'notification_tasks.dart';
|
||||
|
||||
class NotificationController {
|
||||
@@ -22,16 +23,13 @@ class NotificationController {
|
||||
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)),
|
||||
],
|
||||
),
|
||||
));
|
||||
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',
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+13
-38
@@ -25,22 +25,15 @@ import '../widget/debug/cache_view.dart';
|
||||
import '../widget/file_viewer.dart';
|
||||
import '../widget/user_avatar.dart';
|
||||
|
||||
/// Single entry point for full-page navigations across the app.
|
||||
///
|
||||
/// 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.
|
||||
/// 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._();
|
||||
|
||||
/// Token of a chat that should be auto-opened in the Talk tab once
|
||||
/// the chat list view picks it up. Set by [openChatByToken] (e.g. from
|
||||
/// a tapped notification) and consumed by the `ChatList` widget.
|
||||
/// Set by [openChatByToken] (e.g. from a tapped notification) and consumed
|
||||
/// by `ChatList` once the matching room is loaded.
|
||||
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
|
||||
|
||||
// -- Files --------------------------------------------------------------
|
||||
|
||||
static void openFolder(BuildContext context, List<String> path) {
|
||||
pushScreen(context, withNavBar: false, screen: Files(path: path));
|
||||
}
|
||||
@@ -53,14 +46,10 @@ class AppRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Timetable ----------------------------------------------------------
|
||||
|
||||
static void openCustomEvents(BuildContext context) {
|
||||
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
|
||||
}
|
||||
|
||||
// -- Marianum Message ---------------------------------------------------
|
||||
|
||||
static void openMarianumMessage(BuildContext context, String basePath, MarianumMessage message) {
|
||||
pushScreen(
|
||||
context,
|
||||
@@ -69,8 +58,6 @@ class AppRoutes {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Sharing / Settings / Feedback / DevTools ---------------------------
|
||||
|
||||
static void openQrShare(BuildContext context) {
|
||||
pushScreen(context, withNavBar: false, screen: const QrShareView());
|
||||
}
|
||||
@@ -95,8 +82,6 @@ class AppRoutes {
|
||||
pushScreen(context, withNavBar: false, screen: const Roomplan());
|
||||
}
|
||||
|
||||
// -- Talk ---------------------------------------------------------------
|
||||
|
||||
static void openMessageReactions(BuildContext context, String token, int messageId) {
|
||||
pushScreen(
|
||||
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(
|
||||
BuildContext context, {
|
||||
required GetRoomResponseObject room,
|
||||
@@ -122,9 +105,8 @@ class AppRoutes {
|
||||
context.read<ChatBloc>().setToken(room.token);
|
||||
}
|
||||
|
||||
/// Schedules a chat to be opened in the Talk tab. Use this when only the
|
||||
/// token is known (e.g. from a tapped notification) — the actual push
|
||||
/// happens inside the `ChatList` widget once the room is available.
|
||||
/// 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);
|
||||
@@ -135,11 +117,9 @@ class AppRoutes {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves a pending chat token (set via [openChatByToken]) using the
|
||||
/// [ChatListBloc]'s current rooms and the active [AccountData] credentials.
|
||||
/// Returns `null` if the token cannot yet be matched (e.g. the room is
|
||||
/// still being loaded). Callers should keep listening to [pendingChatToken]
|
||||
/// and the bloc state and retry when either changes.
|
||||
/// 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;
|
||||
@@ -166,17 +146,14 @@ class AppRoutes {
|
||||
return null;
|
||||
}
|
||||
|
||||
// -- Module / Tab navigation -------------------------------------------
|
||||
|
||||
/// 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.
|
||||
/// 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 the given [module] if it is currently
|
||||
/// in the bottom bar. Returns `true` if the jump happened.
|
||||
/// 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)
|
||||
@@ -187,8 +164,6 @@ class AppRoutes {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Convenience wrapper for the Talk tab — preserved for the notification
|
||||
/// handler API which only knows about Talk.
|
||||
static void goToTalkTab(BuildContext context) {
|
||||
goToTab(context, Modules.talk);
|
||||
}
|
||||
|
||||
@@ -12,21 +12,15 @@ abstract class DataLoader<TResult> {
|
||||
}
|
||||
|
||||
Future<TResult> run() async {
|
||||
var fetcher = fetch();
|
||||
await Future.wait([
|
||||
fetcher,
|
||||
Future.delayed(const Duration(milliseconds: 500)) // TODO tune or remove
|
||||
]);
|
||||
|
||||
var response = await fetcher;
|
||||
final response = await fetch();
|
||||
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;
|
||||
} catch (e, stack) {
|
||||
log('DataLoader assemble failed', error: e, stackTrace: stack);
|
||||
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/room/get_room_cache.dart';
|
||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../../api/request_cache.dart';
|
||||
|
||||
class ChatListDataProvider {
|
||||
Future<GetRoomResponse> getRooms({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) async {
|
||||
GetRoomResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = GetRoomCache(
|
||||
}) =>
|
||||
resolveFromCache<GetRoomResponse>(
|
||||
(onUpdate, onError) => GetRoomCache(
|
||||
renew: renew,
|
||||
onUpdate: (data) => latest = data,
|
||||
onError: (e) {
|
||||
capturedError = e;
|
||||
onError?.call(e);
|
||||
},
|
||||
onUpdate: onUpdate,
|
||||
onError: onError,
|
||||
),
|
||||
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) =>
|
||||
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_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
|
||||
@@ -14,22 +15,17 @@ class FilesDataProvider {
|
||||
String path, {
|
||||
void Function(ListFilesResponse)? onCacheData,
|
||||
void Function(Object)? onError,
|
||||
}) async {
|
||||
ListFilesResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = ListFilesCache(
|
||||
}) =>
|
||||
resolveFromCache<ListFilesResponse>(
|
||||
(onUpdate, onError) => ListFilesCache(
|
||||
path: path,
|
||||
onUpdate: (data) => latest = data,
|
||||
onUpdate: onUpdate,
|
||||
onCacheData: onCacheData,
|
||||
onError: (e) {
|
||||
capturedError = e;
|
||||
onError?.call(e);
|
||||
},
|
||||
onError: onError,
|
||||
),
|
||||
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 {
|
||||
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/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';
|
||||
@@ -30,112 +31,84 @@ class TimetableDataProvider {
|
||||
DateTime endDate, {
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) async {
|
||||
GetTimetableResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = GetTimetableCache(
|
||||
}) =>
|
||||
resolveFromCache<GetTimetableResponse>(
|
||||
(onUpdate, onError) => GetTimetableCache(
|
||||
startdate: int.parse(_dateFormat.format(startDate)),
|
||||
enddate: int.parse(_dateFormat.format(endDate)),
|
||||
renew: renew,
|
||||
onUpdate: (data) => latest = data,
|
||||
onError: (e) {
|
||||
capturedError = e;
|
||||
onError?.call(e);
|
||||
},
|
||||
onUpdate: onUpdate,
|
||||
onError: onError,
|
||||
),
|
||||
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({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) async {
|
||||
GetRoomsResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = GetRoomsCache(
|
||||
}) =>
|
||||
resolveFromCache<GetRoomsResponse>(
|
||||
(onUpdate, onError) => GetRoomsCache(
|
||||
renew: renew,
|
||||
onUpdate: (data) => latest = data,
|
||||
onError: (e) {
|
||||
capturedError = e;
|
||||
onError?.call(e);
|
||||
},
|
||||
onUpdate: onUpdate,
|
||||
onError: onError,
|
||||
),
|
||||
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({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) async {
|
||||
GetSubjectsResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = GetSubjectsCache(
|
||||
}) =>
|
||||
resolveFromCache<GetSubjectsResponse>(
|
||||
(onUpdate, onError) => GetSubjectsCache(
|
||||
renew: renew,
|
||||
onUpdate: (data) => latest = data,
|
||||
onError: (e) {
|
||||
capturedError = e;
|
||||
onError?.call(e);
|
||||
},
|
||||
onUpdate: onUpdate,
|
||||
onError: onError,
|
||||
),
|
||||
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({
|
||||
void Function(Object)? onError,
|
||||
bool renew = false,
|
||||
}) async {
|
||||
GetHolidaysResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = GetHolidaysCache(
|
||||
}) =>
|
||||
resolveFromCache<GetHolidaysResponse>(
|
||||
(onUpdate, onError) => GetHolidaysCache(
|
||||
renew: renew,
|
||||
onUpdate: (data) => latest = data,
|
||||
onError: (e) {
|
||||
capturedError = e;
|
||||
onError?.call(e);
|
||||
},
|
||||
onUpdate: onUpdate,
|
||||
onError: onError,
|
||||
),
|
||||
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 {
|
||||
GetTimegridUnitsResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = GetTimegridUnitsCache(
|
||||
Future<GetTimegridUnitsResponse> getTimegrid({bool renew = false}) =>
|
||||
resolveFromCache<GetTimegridUnitsResponse>(
|
||||
(onUpdate, _) => GetTimegridUnitsCache(
|
||||
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({
|
||||
bool renew = false,
|
||||
void Function(Object)? onError,
|
||||
}) async {
|
||||
GetCustomTimetableEventResponse? latest;
|
||||
Object? capturedError;
|
||||
final cache = GetCustomTimetableEventCache(
|
||||
}) =>
|
||||
resolveFromCache<GetCustomTimetableEventResponse>(
|
||||
(onUpdate, onError) => GetCustomTimetableEventCache(
|
||||
GetCustomTimetableEventParams(AccountData().getUserSecret()),
|
||||
renew: renew,
|
||||
onUpdate: (data) => latest = data,
|
||||
onError: (e) {
|
||||
capturedError = e;
|
||||
onError?.call(e);
|
||||
},
|
||||
onUpdate: onUpdate,
|
||||
onError: onError,
|
||||
),
|
||||
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) =>
|
||||
AddCustomTimetableEvent(AddCustomTimetableEventParams(AccountData().getUserSecret(), event)).run();
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// Copies [text] to the system clipboard and shows a SnackBar confirmation.
|
||||
/// Safe to await: respects context lifecycle via the provided [context].
|
||||
/// Copies [text] to the system clipboard and shows a SnackBar.
|
||||
Future<void> copyToClipboard(
|
||||
BuildContext context,
|
||||
String text, {
|
||||
|
||||
@@ -7,6 +7,7 @@ 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<String> filePaths;
|
||||
@@ -48,19 +49,11 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
],
|
||||
)
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Error code: $httpErrorCode',
|
||||
title: 'Ein Fehler ist aufgetreten',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -70,20 +63,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
});
|
||||
showDialog(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
InfoDialog.show(context, message, title: 'Upload fehlgeschlagen', copyable: true);
|
||||
}
|
||||
|
||||
Future<void> uploadFiles({bool override = false}) async {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
|
||||
@@ -77,13 +78,7 @@ class _FileElementState extends State<FileElement> {
|
||||
DownloadManager.instance.clear(widget.file.path);
|
||||
_detachJob();
|
||||
setState(() {});
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Download'),
|
||||
content: Text(message),
|
||||
),
|
||||
);
|
||||
InfoDialog.show(context, message, title: 'Download', copyable: true);
|
||||
} else if (status is DownloadCancelled) {
|
||||
DownloadManager.instance.clear(widget.file.path);
|
||||
_detachJob();
|
||||
@@ -172,6 +167,7 @@ class _FileElementState extends State<FileElement> {
|
||||
|
||||
Future<void> _rename() async {
|
||||
final controller = TextEditingController(text: widget.file.name);
|
||||
try {
|
||||
final newName = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
@@ -198,6 +194,9 @@ class _FileElementState extends State<FileElement> {
|
||||
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}) {
|
||||
@@ -234,25 +233,14 @@ class _FileElementState extends State<FileElement> {
|
||||
widget.refetch();
|
||||
} on Object catch (e) {
|
||||
if (!mounted) return;
|
||||
await showDialog<void>(
|
||||
context: context,
|
||||
builder: (dialogCtx) => AlertDialog(
|
||||
title: Text(errorTitle),
|
||||
content: Text(e.toString()),
|
||||
actions: [TextButton(onPressed: () => Navigator.of(dialogCtx).pop(), child: const Text('OK'))],
|
||||
),
|
||||
);
|
||||
InfoDialog.show(context, e.toString(), title: errorTitle, copyable: true);
|
||||
}
|
||||
}
|
||||
|
||||
void _showActionSheet() {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (sheetCtx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.info_outline)),
|
||||
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';
|
||||
|
||||
/// 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 {
|
||||
final SortOption currentSort;
|
||||
final bool ascending;
|
||||
|
||||
@@ -10,6 +10,8 @@ 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';
|
||||
|
||||
@@ -21,18 +23,13 @@ class HolidaysView extends StatelessWidget {
|
||||
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(''
|
||||
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/'),
|
||||
actions: [
|
||||
TextButton(child: const Text('Okay'), onPressed: () => Navigator.of(context).pop()),
|
||||
],
|
||||
));
|
||||
}
|
||||
'Die Daten stammen von https://ferien-api.de/',
|
||||
title: 'Richtigkeit und Bereitstellung der Daten',
|
||||
);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
@@ -78,9 +75,16 @@ class HolidaysView extends StatelessWidget {
|
||||
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: [
|
||||
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()),
|
||||
@@ -94,21 +98,20 @@ class HolidaysView extends StatelessWidget {
|
||||
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(
|
||||
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()),
|
||||
),
|
||||
child: ListTile(
|
||||
)
|
||||
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(context).jsonData(holiday.toJson()),
|
||||
DebugTile(sheetCtx).jsonData(holiday.toJson()),
|
||||
],
|
||||
)),
|
||||
),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
);
|
||||
}),
|
||||
|
||||
@@ -5,26 +5,33 @@ import '../../../../widget/share_position_origin.dart';
|
||||
|
||||
enum ShareTargetType { qr }
|
||||
|
||||
class SelectShareTypeDialog extends StatelessWidget {
|
||||
const SelectShareTypeDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => SimpleDialog(
|
||||
/// 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<ShareTargetType?> showSelectShareTypeSheet(BuildContext context) {
|
||||
return showModalBottomSheet<ShareTargetType>(
|
||||
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(context).pop(ShareTargetType.qr),
|
||||
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(context).pop();
|
||||
Navigator.of(sheetCtx).pop();
|
||||
SharePlus.instance.share(ShareParams(
|
||||
sharePositionOrigin: SharePositionOrigin.get(context),
|
||||
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 '
|
||||
@@ -32,7 +39,9 @@ class SelectShareTypeDialog extends StatelessWidget {
|
||||
'\n\nViel Spaß!',
|
||||
));
|
||||
},
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,10 +42,7 @@ class _OverhangState extends State<Overhang> {
|
||||
subtitle: const Text('Mit Freunden und deiner Klasse teilen'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () async {
|
||||
final result = await showDialog<ShareTargetType>(
|
||||
context: context,
|
||||
builder: (_) => const SelectShareTypeDialog(),
|
||||
);
|
||||
final result = await showSelectShareTypeSheet(context);
|
||||
if (!mounted || result != ShareTargetType.qr) return;
|
||||
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 '../../../../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';
|
||||
@@ -69,10 +70,9 @@ class AboutSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
void _showPrivacyDialog(BuildContext context) => showDialog(
|
||||
context: context,
|
||||
builder: (context) => SimpleDialog(
|
||||
children: [
|
||||
void _showPrivacyDialog(BuildContext context) => showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.school_outlined)),
|
||||
title: const Text('Infos zum Marianum Fulda'),
|
||||
@@ -82,7 +82,7 @@ class AboutSection extends StatelessWidget {
|
||||
providerText: 'Marianum',
|
||||
imprintUrl: 'https://www.marianum-fulda.de/impressum',
|
||||
privacyUrl: 'https://www.marianum-fulda.de/datenschutz',
|
||||
).showPopup(context),
|
||||
).showPopup(sheetCtx),
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.date_range_outlined)),
|
||||
@@ -93,7 +93,7 @@ class AboutSection extends StatelessWidget {
|
||||
providerText: 'Untis',
|
||||
imprintUrl: 'https://www.untis.at/impressum',
|
||||
privacyUrl: 'https://www.untis.at/datenschutz-wu-apps',
|
||||
).showPopup(context),
|
||||
).showPopup(sheetCtx),
|
||||
),
|
||||
ListTile(
|
||||
leading: const CenteredLeading(Icon(Icons.send_time_extension_outlined)),
|
||||
@@ -104,10 +104,9 @@ class AboutSection extends StatelessWidget {
|
||||
providerText: 'mhsl',
|
||||
imprintUrl: 'https://mhsl.eu/id.html',
|
||||
privacyUrl: 'https://mhsl.eu/datenschutz.html',
|
||||
).showPopup(context),
|
||||
).showPopup(sheetCtx),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
void _toggleDeveloperMode(BuildContext context, SettingsCubit settings, bool? state) {
|
||||
|
||||
@@ -11,6 +11,7 @@ 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;
|
||||
@@ -29,13 +30,15 @@ class _DevToolsSectionState extends State<DevToolsSection> {
|
||||
title: const Text('Performance overlays'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
onTap: () {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => BlocBuilder<SettingsCubit, model.Settings>(
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
BlocBuilder<SettingsCubit, model.Settings>(
|
||||
bloc: widget.settings,
|
||||
builder: (_, _) {
|
||||
final dev = widget.settings.val().devToolsSettings;
|
||||
return SimpleDialog(
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
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)),
|
||||
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',
|
||||
|
||||
@@ -2,6 +2,7 @@ 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;
|
||||
@@ -11,22 +12,29 @@ class PrivacyInfo {
|
||||
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: [
|
||||
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(context, 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(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/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';
|
||||
@@ -98,11 +99,9 @@ class _ChatListViewState extends State<_ChatListView> {
|
||||
NotifyUpdater.enableAfterDisclaimer(_settings).asDialog(context);
|
||||
break;
|
||||
case AuthorizationStatus.denied:
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) => const AlertDialog(
|
||||
content: Text('Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.'),
|
||||
),
|
||||
InfoDialog.show(
|
||||
context,
|
||||
'Du kannst die Benachrichtigungen später jederzeit in den App-Einstellungen aktivieren.',
|
||||
);
|
||||
break;
|
||||
default:
|
||||
|
||||
@@ -40,9 +40,8 @@ class BubbleStyle {
|
||||
final double borderRadius;
|
||||
}
|
||||
|
||||
/// Lightweight chat bubble. Replaces the abandoned `bubble` package: renders a
|
||||
/// rounded container with optional shadow / border. The nip is conveyed by
|
||||
/// flattening one corner so the bubble visually anchors to the speaker side.
|
||||
/// 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});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
final Text actorText;
|
||||
final Text timeText;
|
||||
|
||||
@@ -14,13 +14,14 @@ 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 = <String>['👍', '👎', '😆', '❤️', '👀'];
|
||||
|
||||
/// 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, ...).
|
||||
Future<void> showChatMessageOptionsDialog(
|
||||
void showChatMessageOptionsDialog(
|
||||
BuildContext context, {
|
||||
required GetRoomResponseObject chatData,
|
||||
required GetChatResponseObject bubbleData,
|
||||
@@ -34,24 +35,23 @@ Future<void> showChatMessageOptionsDialog(
|
||||
.add(const Duration(hours: 6))
|
||||
.isAfter(DateTime.now());
|
||||
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (dialogCtx) => SimpleDialog(
|
||||
children: [
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
if (canReact)
|
||||
_ReactionsRow(
|
||||
chatToken: chatData.token,
|
||||
messageId: bubbleData.id,
|
||||
onRefetch: onRefetch,
|
||||
dialogContext: dialogCtx,
|
||||
sheetContext: sheetCtx,
|
||||
),
|
||||
if (bubbleData.isReplyable)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.reply_outlined),
|
||||
title: const Text('Antworten'),
|
||||
onTap: () {
|
||||
dialogCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
|
||||
Navigator.of(dialogCtx).pop();
|
||||
sheetCtx.read<ChatBloc>().setReferenceMessageId(bubbleData.id);
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
if (canReact)
|
||||
@@ -59,7 +59,7 @@ Future<void> showChatMessageOptionsDialog(
|
||||
leading: const Icon(Icons.emoji_emotions_outlined),
|
||||
title: const Text('Reaktionen'),
|
||||
onTap: () {
|
||||
Navigator.of(dialogCtx).pop();
|
||||
Navigator.of(sheetCtx).pop();
|
||||
if (!parentContext.mounted) return;
|
||||
AppRoutes.openMessageReactions(parentContext, chatData.token, bubbleData.id);
|
||||
},
|
||||
@@ -70,14 +70,14 @@ Future<void> showChatMessageOptionsDialog(
|
||||
title: const Text('Nachricht kopieren'),
|
||||
onTap: () {
|
||||
copyToClipboard(parentContext, bubbleData.message);
|
||||
Navigator.of(dialogCtx).pop();
|
||||
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(dialogCtx).pop(),
|
||||
onTap: () => Navigator.of(sheetCtx).pop(),
|
||||
),
|
||||
if (canDelete)
|
||||
AsyncListTile(
|
||||
@@ -85,12 +85,11 @@ Future<void> showChatMessageOptionsDialog(
|
||||
title: const Text('Nachricht löschen'),
|
||||
onPressed: () async {
|
||||
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 int messageId;
|
||||
final void Function({bool renew}) onRefetch;
|
||||
final BuildContext dialogContext;
|
||||
final BuildContext sheetContext;
|
||||
|
||||
const _ReactionsRow({
|
||||
required this.chatToken,
|
||||
required this.messageId,
|
||||
required this.onRefetch,
|
||||
required this.dialogContext,
|
||||
required this.sheetContext,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -131,7 +130,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
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/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';
|
||||
@@ -124,8 +125,9 @@ class _ChatTileState extends State<ChatTile> {
|
||||
},
|
||||
onLongPress: () {
|
||||
if (widget.disableContextActions) return;
|
||||
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
|
||||
children: [
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
if (widget.data.unreadMessages > 0)
|
||||
AsyncListTile(
|
||||
leading: const Icon(Icons.mark_chat_read_outlined),
|
||||
@@ -163,7 +165,7 @@ class _ChatTileState extends State<ChatTile> {
|
||||
leading: const Icon(Icons.delete_outline),
|
||||
title: const Text('Konversation verlassen'),
|
||||
onTap: () {
|
||||
Navigator.of(dialogCtx).pop();
|
||||
Navigator.of(sheetCtx).pop();
|
||||
ConfirmDialog(
|
||||
title: 'Chat verlassen',
|
||||
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
|
||||
@@ -175,9 +177,9 @@ class _ChatTileState extends State<ChatTile> {
|
||||
).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);
|
||||
|
||||
return ListTile(
|
||||
// enabled: false,
|
||||
isThreeLine: portionsVisible,
|
||||
dense: true,
|
||||
title: Text(
|
||||
|
||||
@@ -3,14 +3,22 @@ const double kCalendarEndHour = 17.25;
|
||||
const Duration kCalendarTimeInterval = Duration(minutes: 30);
|
||||
const double kCalendarViewHeaderHeight = 60;
|
||||
|
||||
/// Minimum pixels per hour. Below this, the grid scrolls vertically rather
|
||||
/// than compressing further.
|
||||
/// Below this, the grid scrolls vertically rather than compressing further.
|
||||
const double kCalendarMinPxPerHour = 56;
|
||||
|
||||
/// Minimum height of a lesson block in the period-based layout. The grid
|
||||
/// scrolls vertically once lessons would otherwise be smaller than this.
|
||||
/// The grid scrolls vertically once lessons would otherwise be smaller.
|
||||
const double kLessonBlockMinHeight = 50;
|
||||
|
||||
/// Fixed height of a break block in the period-based layout. Independent of
|
||||
/// the actual break duration; breaks are rendered as a compact indicator.
|
||||
/// 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;
|
||||
|
||||
@@ -2,14 +2,11 @@ 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));
|
||||
static const _titleFontSize = 15.0;
|
||||
static const _titleMinFontSize = 11.0;
|
||||
static const _bodyFontSize = 10.0;
|
||||
static const _bodyLineHeight = 1.15;
|
||||
|
||||
final Appointment appointment;
|
||||
final bool crossedOut;
|
||||
@@ -42,8 +39,8 @@ class AppointmentTile extends StatelessWidget {
|
||||
children: [
|
||||
_AdaptiveTitle(
|
||||
text: appointment.subject,
|
||||
fontSize: _titleFontSize,
|
||||
minFontSize: _titleMinFontSize,
|
||||
fontSize: kAppointmentTitleFontSize,
|
||||
minFontSize: kAppointmentTitleMinFontSize,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
if (isCustom) ...[
|
||||
@@ -53,8 +50,8 @@ class AppointmentTile extends StatelessWidget {
|
||||
padding: const EdgeInsets.only(top: 1),
|
||||
child: _WrappingBody(
|
||||
text: description,
|
||||
fontSize: _bodyFontSize,
|
||||
lineHeight: _bodyLineHeight,
|
||||
fontSize: kAppointmentBodyFontSize,
|
||||
lineHeight: kAppointmentBodyLineHeight,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -63,7 +60,7 @@ class AppointmentTile extends StatelessWidget {
|
||||
.split('\n')
|
||||
.where((p) => p.isNotEmpty)
|
||||
.take(2))
|
||||
_ScaledLine(text: line, fontSize: _bodyFontSize),
|
||||
_ScaledLine(text: line, fontSize: kAppointmentBodyFontSize),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
part of '../custom_workweek_calendar.dart';
|
||||
|
||||
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 List<Appointment> appointments;
|
||||
final double rulerWidth;
|
||||
@@ -28,17 +23,17 @@ class _OutsideHoursStrip extends StatelessWidget {
|
||||
|
||||
final theme = Theme.of(context);
|
||||
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);
|
||||
final stripHeight = _verticalPadding * 2 +
|
||||
maxChipsPerDay * _chipHeight +
|
||||
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * _chipSpacing : 0);
|
||||
final stripHeight = kOutsideStripVerticalPadding * 2 +
|
||||
maxChipsPerDay * kOutsideChipHeight +
|
||||
(maxChipsPerDay > 1 ? (maxChipsPerDay - 1) * kOutsideChipSpacing : 0);
|
||||
|
||||
return Container(
|
||||
color: theme.colorScheme.surfaceContainerLowest,
|
||||
padding: const EdgeInsets.symmetric(vertical: _verticalPadding),
|
||||
padding: const EdgeInsets.symmetric(vertical: kOutsideStripVerticalPadding),
|
||||
child: SizedBox(
|
||||
height: stripHeight - _verticalPadding * 2,
|
||||
height: stripHeight - kOutsideStripVerticalPadding * 2,
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -47,9 +42,6 @@ class _OutsideHoursStrip extends StatelessWidget {
|
||||
Expanded(
|
||||
child: _OutsideDayColumn(
|
||||
appointments: outside[d],
|
||||
maxVisible: _maxVisibleChips,
|
||||
chipHeight: _chipHeight,
|
||||
chipSpacing: _chipSpacing,
|
||||
onAppointmentTap: onAppointmentTap,
|
||||
isCrossedOut: isCrossedOut,
|
||||
),
|
||||
@@ -63,17 +55,11 @@ class _OutsideHoursStrip extends StatelessWidget {
|
||||
|
||||
class _OutsideDayColumn extends StatelessWidget {
|
||||
final List<Appointment> appointments;
|
||||
final int maxVisible;
|
||||
final double chipHeight;
|
||||
final double chipSpacing;
|
||||
final void Function(Appointment) onAppointmentTap;
|
||||
final bool Function(Appointment) isCrossedOut;
|
||||
|
||||
const _OutsideDayColumn({
|
||||
required this.appointments,
|
||||
required this.maxVisible,
|
||||
required this.chipHeight,
|
||||
required this.chipSpacing,
|
||||
required this.onAppointmentTap,
|
||||
required this.isCrossedOut,
|
||||
});
|
||||
@@ -132,11 +118,12 @@ class _OutsideDayColumn extends StatelessWidget {
|
||||
if (!aLike && bLike) return 1;
|
||||
return a.startTime.compareTo(b.startTime);
|
||||
});
|
||||
final visible = sorted.length <= maxVisible
|
||||
final visible = sorted.length <= kOutsideChipsMaxVisible
|
||||
? sorted
|
||||
: sorted.take(maxVisible - 1).toList();
|
||||
final overflow =
|
||||
sorted.length <= maxVisible ? const <Appointment>[] : sorted.skip(maxVisible - 1).toList();
|
||||
: sorted.take(kOutsideChipsMaxVisible - 1).toList();
|
||||
final overflow = sorted.length <= kOutsideChipsMaxVisible
|
||||
? const <Appointment>[]
|
||||
: sorted.skip(kOutsideChipsMaxVisible - 1).toList();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
@@ -145,9 +132,9 @@ class _OutsideDayColumn extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
for (var i = 0; i < visible.length; i++) ...[
|
||||
if (i > 0) SizedBox(height: chipSpacing),
|
||||
if (i > 0) const SizedBox(height: kOutsideChipSpacing),
|
||||
SizedBox(
|
||||
height: chipHeight,
|
||||
height: kOutsideChipHeight,
|
||||
child: _OutsideChip(
|
||||
appointment: visible[i],
|
||||
onTap: () => onAppointmentTap(visible[i]),
|
||||
@@ -155,9 +142,9 @@ class _OutsideDayColumn extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
if (overflow.isNotEmpty) ...[
|
||||
SizedBox(height: chipSpacing),
|
||||
const SizedBox(height: kOutsideChipSpacing),
|
||||
SizedBox(
|
||||
height: chipHeight,
|
||||
height: kOutsideChipHeight,
|
||||
child: _OutsideOverflowChip(
|
||||
count: overflow.length,
|
||||
onTap: () => _showOverflow(context, overflow),
|
||||
|
||||
@@ -429,8 +429,7 @@ class _OverflowTile extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(1),
|
||||
child: Stack(
|
||||
children: [
|
||||
// Card peeking out at the bottom — visual hint that more cards lie
|
||||
// underneath the visible one.
|
||||
// Stacked-cards effect: a darker layer peeks out below the front card.
|
||||
Positioned(
|
||||
top: 4,
|
||||
left: 2,
|
||||
@@ -443,7 +442,6 @@ class _OverflowTile extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
// Front card with the "+N" indicator.
|
||||
Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
|
||||
@@ -40,10 +40,9 @@ class AsyncTextButton extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
: child;
|
||||
return _InlineErrorWrapper(
|
||||
controller: controller,
|
||||
child: TextButton(onPressed: handler, child: content),
|
||||
);
|
||||
final button = TextButton(onPressed: handler, child: content);
|
||||
if (!showInlineError) return button;
|
||||
return _InlineErrorWrapper(controller: controller, child: button);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ class _DeferredPdfViewerState extends State<_DeferredPdfViewer> {
|
||||
}
|
||||
|
||||
class _FileViewerState extends State<FileViewer> {
|
||||
PhotoViewController photoViewController = PhotoViewController();
|
||||
final PhotoViewController photoViewController = PhotoViewController();
|
||||
|
||||
late SettingsCubit settings = context.read<SettingsCubit>();
|
||||
late bool openExternal;
|
||||
@@ -92,6 +92,12 @@ class _FileViewerState extends State<FileViewer> {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
photoViewController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
AppBar appbar({List<Widget> actions = const []}) => AppBar(
|
||||
@@ -196,9 +202,7 @@ class _FileViewerState extends State<FileViewer> {
|
||||
if (!context.mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
if (result.type != ResultType.done) {
|
||||
showDialog(context: context, builder: (context) => AlertDialog(
|
||||
content: Text(result.message),
|
||||
));
|
||||
InfoDialog.show(context, result.message);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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';
|
||||
@@ -36,8 +37,9 @@ void main() {
|
||||
});
|
||||
|
||||
test('HandshakeException maps to a TLS-specific message', () {
|
||||
expect(errorToUserMessage(const HandshakeException('bad cert')),
|
||||
'Sichere Verbindung konnte nicht hergestellt werden.');
|
||||
final message = errorToUserMessage(const HandshakeException('bad cert'));
|
||||
expect(message, contains('sichere Verbindung'));
|
||||
expect(message, contains('Geräte-Uhrzeit'));
|
||||
});
|
||||
|
||||
test('FormatException maps to ParseException message', () {
|
||||
@@ -63,6 +65,34 @@ void main() {
|
||||
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', () {
|
||||
|
||||
Reference in New Issue
Block a user