claude refactor #95

Merged
MineTec merged 24 commits from develop-refactor into develop 2026-05-08 19:49:43 +00:00
37 changed files with 595 additions and 753 deletions
Showing only changes of commit 9e139b5704 - Show all commits
+51 -1
View File
@@ -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;
}
-16
View File
@@ -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>))
)
);
}
}
-18
View File
@@ -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,
};
+2 -12
View File
@@ -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,
+30 -4
View File
@@ -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,
);
}
+8 -10
View File
@@ -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
View File
@@ -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 -2
View File
@@ -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 -27
View File
@@ -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 {
+10 -24
View File
@@ -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;
+24 -21
View File
@@ -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ß!',
));
},
)
),
],
),
),
);
}
+1 -4
View File
@@ -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),
),
],
));
);
}
}
+4 -5
View File
@@ -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:
+2 -3
View File
@@ -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();
}
}
+7 -5
View File
@@ -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);
},
);
}
+8 -4
View File
@@ -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);
}
});
+32 -2
View File
@@ -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', () {