10 Commits

143 changed files with 2493 additions and 1677 deletions
+40
View File
@@ -0,0 +1,40 @@
---
#
# Hinweis: Die Steuerzeichen ("---") dürfen NICHT entfernt werden!
# Nach dem zweiten Steuerzeichen wird als Markdown interpretiert und es sind keine Kommentare mehr möglich!
# Kommentare sind nur innerhalb des Steuerblocks zugelassen, sowie die Variablen.
#
# Notfall-Nachricht der MarianumMobile-App
#
# Diese Datei wird bei jedem App-Start geladen.
# Solange 'active' nicht true ist, wird NICHTS angezeigt (Normalzustand).
#
# Steuerfelder:
# active: true schaltet die Anzeige ein (Default: false)
# dismissible: true = wegklickbar & gecachte Inhalte der App weiterhin normal sichtbar, false = Vollbild & nicht schließbar (Default: true)
# title: optionale Überschrift
#
# Im Notfall: 'active: false' auf 'active: true' setzen, unten Inhalt anpassen.
# Der Textinhalt wird in Markdown ausgewertet. Siehe https://markdownlivepreview.com/
# VORSICHT: Hashtags (#) sind in Markdown kein Kommentar sondern "Titel"!
# Beispielkonfiguration:
#
# ---
# # Ein Kommentar
# active: true
# dismissible: false
# title: Wichtiger Hinweis
# ---
# Hinweistext in Markdown
#
# ============================================================================
active: false
dismissible: true
title: Hinweis
---
# Serverstörung
Der Zugriff auf einige Funktionen ist derzeit großflächig **eingeschränkt**. Wir arbeiten an einer Lösung.
Bitte prüfe unter folgendem Link auf aktuelle Informationen der Schulleitung.
- Aktuelle Informationen: [www.marianum-fulda.de](https://www.marianum-fulda.de)
-1
View File
@@ -1 +0,0 @@
class ApiRequest {}
+90
View File
@@ -0,0 +1,90 @@
/// A backend-independent emergency notice loaded from a foreign server URL —
/// frontmatter (control fields) plus a free Markdown body, so it stays
/// hand-writable in an outage. See [parse] for the format.
class EmergencyNotice {
/// `false` renders full-screen and blocks back/barrier taps.
final bool dismissible;
final String? title;
final String body;
const EmergencyNotice({
required this.dismissible,
required this.title,
required this.body,
});
/// Parses the raw file, or returns `null` when there is nothing to show.
/// Never throws — malformed input yields `null` so a broken file can't break
/// the app.
///
/// ```
/// ---
/// active: true # required truthy, else null; # lines are comments
/// dismissible: true # default true
/// title: Störung # optional
/// ---
/// Free **markdown** body (everything after the closing ---).
/// ```
static EmergencyNotice? parse(String raw) {
final lines = raw
.replaceAll('\r\n', '\n')
.replaceAll('\r', '\n')
.split('\n');
var i = 0;
while (i < lines.length && lines[i].trim().isEmpty) {
i++;
}
if (i >= lines.length || lines[i].trim() != '---') return null;
final openIndex = i;
var closeIndex = -1;
for (var j = openIndex + 1; j < lines.length; j++) {
if (lines[j].trim() == '---') {
closeIndex = j;
break;
}
}
if (closeIndex == -1) return null;
final meta = <String, String>{};
for (var j = openIndex + 1; j < closeIndex; j++) {
final line = lines[j].trim();
if (line.isEmpty || line.startsWith('#')) continue;
final sep = line.indexOf(':');
if (sep <= 0) continue;
final key = line.substring(0, sep).trim().toLowerCase();
final value = line.substring(sep + 1).trim();
meta[key] = value;
}
if (_parseBool(meta['active']) != true) return null;
final body = lines.sublist(closeIndex + 1).join('\n').trim();
if (body.isEmpty) return null;
final title = meta['title'];
return EmergencyNotice(
dismissible: _parseBool(meta['dismissible']) ?? true,
title: (title == null || title.isEmpty) ? null : title,
body: body,
);
}
static bool? _parseBool(String? value) {
switch (value?.trim().toLowerCase()) {
case 'true':
case 'yes':
case '1':
case 'on':
return true;
case 'false':
case 'no':
case '0':
case 'off':
return false;
default:
return null;
}
}
}
@@ -0,0 +1,51 @@
import 'package:dio/dio.dart';
import 'emergency_notice.dart';
/// Loads the emergency notice from a foreign URL over a standalone [Dio] — no
/// MarianumConnect interceptors/base URL/auth, so it survives a backend outage.
/// Never throws: any failure yields `null` (nothing shown).
///
/// A [cacheTtl] in-memory throttle keeps rapid resumes from hammering the host;
/// it lives only for the process, so a cold start always fetches fresh.
class EmergencyNoticeClient {
EmergencyNoticeClient();
static const Duration cacheTtl = Duration(minutes: 1);
EmergencyNotice? _cached;
String? _cachedUrl;
DateTime? _cachedAt;
Future<EmergencyNotice?> fetch(String url) async {
final cachedAt = _cachedAt;
if (cachedAt != null &&
_cachedUrl == url &&
DateTime.now().difference(cachedAt) < cacheTtl) {
return _cached;
}
EmergencyNotice? result;
try {
final dio = Dio(
BaseOptions(
connectTimeout: const Duration(seconds: 5),
receiveTimeout: const Duration(seconds: 5),
sendTimeout: const Duration(seconds: 5),
responseType: ResponseType.plain,
),
);
final response = await dio.get<String>(url);
final raw = response.data;
result = (raw == null || raw.isEmpty) ? null : EmergencyNotice.parse(raw);
} catch (_) {
result = null;
}
// Cache failures too, so a down server isn't retried on every resume.
_cached = result;
_cachedUrl = url;
_cachedAt = DateTime.now();
return result;
}
}
+3 -3
View File
@@ -6,6 +6,7 @@ import 'package:http/http.dart' as http;
import 'package:nextcloud/nextcloud.dart';
import '../api_error.dart';
import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart';
import 'app_exception.dart';
import 'auth_exception.dart';
@@ -59,9 +60,8 @@ AppException? _dioToAppException(DioException error) {
/// status plus a trimmed body preview (same format as the Talk API errors).
AppException _dynamiteToAppException(DynamiteApiException error) {
final status = error.statusCode;
final body = error.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
final preview = previewBody(error.body);
final detail = preview.isEmpty ? 'HTTP $status' : 'HTTP $status body=$preview';
switch (status) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
+53
View File
@@ -0,0 +1,53 @@
import 'dart:async';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'errors/auth_exception.dart';
import 'errors/network_exception.dart';
import 'errors/not_found_exception.dart';
import 'errors/server_exception.dart';
/// Runs [send] and converts transport-level failures (socket/timeout/client
/// errors) into a [NetworkException] tagged with [label] (e.g. `Talk <uri>`).
/// Passes through whatever [send] produces, including `null` for the base-class
/// request hooks that may skip the call.
Future<http.Response?> sendGuarded(
String label,
Future<http.Response>? Function() send,
) async {
try {
return await send();
} on SocketException catch (e) {
throw NetworkException(technicalDetails: '$label: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: '$label: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: '$label: ${e.message}');
}
}
/// Collapses whitespace and caps an HTTP error body at 500 chars so it can be
/// embedded in an [AppException]'s technical details without dumping headers.
String previewBody(String body) {
final collapsed = body.replaceAll(RegExp(r'\s+'), ' ').trim();
return collapsed.length > 500 ? '${collapsed.substring(0, 500)}' : collapsed;
}
/// Builds a `<label> -> HTTP <status>[ body=<preview>]` technical detail line.
String httpErrorDetail(String label, String body, int status) {
final preview = previewBody(body);
return preview.isEmpty
? '$label -> HTTP $status'
: '$label -> HTTP $status body=$preview';
}
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying
/// [detail] as technical details: 401/403 map to auth errors, 404 to
/// not-found, everything else to a generic server error.
Never throwForStatus(int status, String detail) {
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
}
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@@ -38,9 +37,6 @@ class AutocompleteApi {
technicalDetails: 'core/autocomplete/get: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return AutocompleteResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
}
}
@@ -1,18 +1,13 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
/// Mix of two Nextcloud surfaces:
@@ -42,30 +37,17 @@ Future<http.Response> _send(
) async {
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await perform(uri, headers);
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
}
final response = (await sendGuarded(
'Cloud $uri',
() => perform(uri, headers),
))!;
final status = response.statusCode;
if (status >= 200 && status < 300) return response;
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Cloud $uri -> HTTP $status'
: 'Cloud $uri -> HTTP $status body=$preview';
final detail = httpErrorDetail('Cloud $uri', response.body, status);
log(detail);
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
throwForStatus(status, detail);
}
class SetUserAvatar {
+7
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../model/account_data.dart';
import '../../model/endpoint_data.dart';
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
class NextcloudOcs {
NextcloudOcs._();
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
/// every response nests its `meta`/`data` under).
static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
static Map<String, String> headers() => {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
@@ -28,9 +27,7 @@ class SearchFiles {
'Files search failed with ${response.statusCode}: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final ocs = decoded['ocs'] as Map<String, dynamic>;
final data = ocs['data'] as Map<String, dynamic>;
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return SearchFilesResponse.fromJson(data);
}
}
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
@override
GetChatResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
@@ -1,11 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../errors/network_exception.dart';
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
@@ -41,24 +37,17 @@ class LongPollChat {
);
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await http
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15));
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'LongPollChat $uri: $e');
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'LongPollChat $uri: ${e.message}');
}
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
@@ -12,12 +11,8 @@ class ClosePoll extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'create_room_params.dart';
import 'create_room_response.dart';
@@ -13,10 +12,8 @@ class CreateRoom extends TalkApi<CreateRoomResponse> {
CreateRoom(this.params) : super('v4/room', params);
@override
CreateRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
CreateRoomResponse assemble(String raw) =>
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_participants_response.dart';
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
GetParticipants(this.token) : super('v4/room/$token/participants', null);
@override
GetParticipantsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetParticipantsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_poll_state_response.dart';
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
@@ -1,9 +1,8 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_reactions_response.dart';
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
: super('v1/reaction/$chatToken/$messageId', null);
@override
GetReactionsResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetReactionsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart';
import 'get_room_params.dart';
import 'get_room_response.dart';
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override
GetRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
GetRoomResponse assemble(String raw) =>
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
+13 -40
View File
@@ -1,29 +1,20 @@
import 'dart:async';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../api_params.dart';
import '../../api_request.dart';
import '../../api_response.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../../http_errors.dart';
import '../nextcloud_ocs.dart';
enum TalkApiMethod { get, post, put, delete }
abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
abstract class TalkApi<T extends ApiResponse?> {
String path;
ApiParams? body;
Map<String, String>? headers;
Map<String, dynamic>? getParameters;
http.Response? response;
TalkApi(this.path, this.body, {this.headers, this.getParameters});
Future<http.Response>? request(
@@ -40,22 +31,15 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
);
final mergedHeaders = {...NextcloudOcs.headers(), ...?headers};
final http.Response data;
try {
final raw = await request(endpoint, body, mergedHeaders);
if (raw == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Talk $endpoint: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Talk $endpoint: ${e.message}');
final data = await sendGuarded(
'Talk $endpoint',
() => request(endpoint, body, mergedHeaders),
);
if (data == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom Talk-Server erhalten.',
technicalDetails: 'Talk request returned null',
);
}
final status = data.statusCode;
@@ -63,20 +47,9 @@ abstract class TalkApi<T extends ApiResponse?> extends ApiRequest {
// Talk's OCS errors carry the real reason in the body (expired session,
// removed participant, ...); include a trimmed preview so the dialog and
// logs surface the cause instead of just the bare status code.
final body = data.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Talk $endpoint -> HTTP $status'
: 'Talk $endpoint -> HTTP $status body=$preview';
final detail = httpErrorDetail('Talk $endpoint', data.body, status);
log(detail);
if (status == 401) {
throw AuthException.unauthorized(technicalDetails: detail);
}
if (status == 403) {
throw AuthException.forbidden(technicalDetails: detail);
}
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
throwForStatus(status, detail);
}
try {
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
import 'vote_poll_params.dart';
@@ -22,12 +23,8 @@ class VotePoll extends TalkApi<GetPollStateResponse> {
);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response>? request(
+1 -2
View File
@@ -2,10 +2,9 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../api_request.dart';
import '../../api_response.dart';
abstract class WebdavApi<T> extends ApiRequest {
abstract class WebdavApi<T> {
T genericParams;
WebdavApi(this.genericParams) {
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
class MarianumConnectApi {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 20);
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
static final Dio _instance = _build();
static Dio dio() => _instance;
/// A fresh dio with the standard JSON options but no interceptors — used by
/// the auth queries (login/verify) that must bypass the bearer/demo
/// interceptors to avoid a re-auth loop.
static Dio plainDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _plainReceiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
static Dio _build() {
final dio = Dio(
BaseOptions(
@@ -26,4 +26,36 @@ abstract class MarianumConnectQuery {
throw mapMarianumConnectError(e);
}
}
/// GETs [path] and parses the JSON object body with [fromJson].
Future<T> getObject<T>(
String path,
T Function(Map<String, dynamic> json) fromJson, {
Map<String, dynamic>? queryParameters,
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint(path),
queryParameters: queryParameters,
);
return fromJson(response.data!);
});
/// GETs [path] and maps each element of the JSON array body with [fromJson].
Future<List<T>> getList<T>(
String path,
T Function(Map<String, dynamic> json) fromJson, {
Map<String, dynamic>? queryParameters,
}) => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint(path),
queryParameters: queryParameters,
);
return response.data!
.map((e) => fromJson(e as Map<String, dynamic>))
.toList();
});
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}
@@ -1,6 +1,7 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
import 'auth_login_response.dart';
@@ -9,9 +10,6 @@ import 'auth_login_response.dart';
/// run through the shared dio instance — that one has the interceptor, which
/// would attempt to re-auth us into a loop if our credentials are wrong.
class AuthLogin extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
AuthLogin({
@@ -19,17 +17,7 @@ class AuthLogin extends MarianumConnectQuery {
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? _buildDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? MarianumConnectApi.plainDio());
Future<AuthLoginResponse> run({
required String username,
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart';
/// Probes that the stored bearer token still maps to the given credentials.
@@ -12,9 +13,6 @@ import '../../marianumconnect_query.dart';
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
/// and obscure a real 401 with a silent re-login.
class AuthVerify extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
AuthVerify({
@@ -22,17 +20,7 @@ class AuthVerify extends MarianumConnectQuery {
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
super(dio: dio ?? _buildDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? MarianumConnectApi.plainDio());
/// Throws [AuthException] on 401 (credentials no longer match the token's
/// user, token missing, or token rejected), other [AppException]s on
@@ -7,8 +7,6 @@ import 'get_breakers_response.dart';
class GetBreakers extends MarianumConnectQuery {
GetBreakers({super.dio});
Future<GetBreakersResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
return GetBreakersResponse.fromJson(response.data!);
});
Future<GetBreakersResponse> run() =>
getObject('breaker', GetBreakersResponse.fromJson);
}
@@ -7,10 +7,6 @@ import 'get_capabilities_response.dart';
class GetCapabilities extends MarianumConnectQuery {
GetCapabilities({super.dio});
Future<CapabilitiesResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('me/capabilities'),
);
return CapabilitiesResponse.fromJson(response.data!);
});
Future<CapabilitiesResponse> run() =>
getObject('me/capabilities', CapabilitiesResponse.fromJson);
}
@@ -4,10 +4,5 @@ import '../../models/mc_holiday.dart';
class GetHolidays extends MarianumConnectQuery {
GetHolidays({super.dio});
Future<List<McHoliday>> run() => guard(() async {
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
return response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
});
Future<List<McHoliday>> run() => getList('holidays', McHoliday.fromJson);
}
@@ -6,8 +6,6 @@ import 'get_ticker_response.dart';
class GetTicker extends MarianumConnectQuery {
GetTicker({super.dio});
Future<TickerResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker'));
return TickerResponse.fromJson(response.data!);
});
Future<TickerResponse> run() =>
getObject('ticker', TickerResponse.fromJson);
}
@@ -6,10 +6,6 @@ import 'get_ticker_nav_response.dart';
class GetTickerNav extends MarianumConnectQuery {
GetTickerNav({super.dio});
Future<TickerNavResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('ticker/pages'),
);
return TickerNavResponse.fromJson(response.data!);
});
Future<TickerNavResponse> run() =>
getObject('ticker/pages', TickerNavResponse.fromJson);
}
@@ -4,10 +4,8 @@ import '../../marianumconnect_query.dart';
class TimetableCustomEventsGet extends MarianumConnectQuery {
TimetableCustomEventsGet({super.dio});
Future<GetCustomTimetableEventResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/custom-events'),
);
return GetCustomTimetableEventResponse.fromJson(response.data!);
});
Future<GetCustomTimetableEventResponse> run() => getObject(
'timetable/custom-events',
GetCustomTimetableEventResponse.fromJson,
);
}
@@ -4,13 +4,11 @@ import 'timetable_get_classes_response.dart';
class TimetableGetClasses extends MarianumConnectQuery {
TimetableGetClasses({super.dio});
Future<TimetableGetClassesResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/classes'),
);
final list = response.data!
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetClassesResponse(result: list);
});
Future<TimetableGetClassesResponse> run() async =>
TimetableGetClassesResponse(
result: await getList(
'timetable/elements/classes',
McTimetableClass.fromJson,
),
);
}
@@ -13,14 +13,9 @@ class TimetableGetElementWeek extends MarianumConnectQuery {
required int id,
required DateTime from,
required DateTime until,
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/${type.pathSegment}/$id'),
queryParameters: {'from': _format(from), 'until': _format(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
});
String _format(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}) => getObject(
'timetable/${type.pathSegment}/$id',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
@@ -4,13 +4,8 @@ import 'timetable_get_holidays_response.dart';
class TimetableGetHolidays extends MarianumConnectQuery {
TimetableGetHolidays({super.dio});
Future<TimetableGetHolidaysResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/holidays'),
);
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
});
Future<TimetableGetHolidaysResponse> run() async =>
TimetableGetHolidaysResponse(
result: await getList('timetable/holidays', McHoliday.fromJson),
);
}
@@ -4,11 +4,7 @@ import 'timetable_get_rooms_response.dart';
class TimetableGetRooms extends MarianumConnectQuery {
TimetableGetRooms({super.dio});
Future<TimetableGetRoomsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
final list = response.data!
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetRoomsResponse(result: list);
});
Future<TimetableGetRoomsResponse> run() async => TimetableGetRoomsResponse(
result: await getList('timetable/rooms', McRoom.fromJson),
);
}
@@ -4,10 +4,6 @@ import 'timetable_get_schoolyear_response.dart';
class TimetableGetSchoolyear extends MarianumConnectQuery {
TimetableGetSchoolyear({super.dio});
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
});
Future<TimetableGetSchoolyearResponse> run() =>
getObject('timetable/schoolyear', TimetableGetSchoolyearResponse.fromJson);
}
@@ -4,13 +4,11 @@ import 'timetable_get_students_response.dart';
class TimetableGetStudents extends MarianumConnectQuery {
TimetableGetStudents({super.dio});
Future<TimetableGetStudentsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/students'),
);
final list = response.data!
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetStudentsResponse(result: list);
});
Future<TimetableGetStudentsResponse> run() async =>
TimetableGetStudentsResponse(
result: await getList(
'timetable/elements/students',
McTimetableStudent.fromJson,
),
);
}
@@ -4,13 +4,8 @@ import 'timetable_get_subjects_response.dart';
class TimetableGetSubjects extends MarianumConnectQuery {
TimetableGetSubjects({super.dio});
Future<TimetableGetSubjectsResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/subjects'),
);
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
});
Future<TimetableGetSubjectsResponse> run() async =>
TimetableGetSubjectsResponse(
result: await getList('timetable/subjects', McSubject.fromJson),
);
}
@@ -4,13 +4,11 @@ import 'timetable_get_teachers_response.dart';
class TimetableGetTeachers extends MarianumConnectQuery {
TimetableGetTeachers({super.dio});
Future<TimetableGetTeachersResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/elements/teachers'),
);
final list = response.data!
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTeachersResponse(result: list);
});
Future<TimetableGetTeachersResponse> run() async =>
TimetableGetTeachersResponse(
result: await getList(
'timetable/elements/teachers',
McTimetableTeacherElement.fromJson,
),
);
}
@@ -4,13 +4,8 @@ import 'timetable_get_timegrid_response.dart';
class TimetableGetTimegrid extends MarianumConnectQuery {
TimetableGetTimegrid({super.dio});
Future<TimetableGetTimegridResponse> run() => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('timetable/timegrid'),
);
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
});
Future<TimetableGetTimegridResponse> run() async =>
TimetableGetTimegridResponse(
result: await getList('timetable/timegrid', McTimegridUnit.fromJson),
);
}
@@ -7,14 +7,9 @@ class TimetableGetWeek extends MarianumConnectQuery {
Future<TimetableGetWeekResponse> run({
required DateTime from,
required DateTime until,
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/me'),
queryParameters: {'from': _format(from), 'until': _format(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
});
String _format(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
}) => getObject(
'timetable/me',
TimetableGetWeekResponse.fromJson,
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
);
}
@@ -7,14 +7,11 @@ import 'user_search_response.dart';
class UserSearch extends MarianumConnectQuery {
UserSearch({super.dio});
Future<UserSearchResponse> run(String query) => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('users/search'),
Future<UserSearchResponse> run(String query) async => UserSearchResponse(
result: await getList(
'users/search',
McUserSearchResult.fromJson,
queryParameters: {'q': query},
);
final list = response.data!
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
.toList();
return UserSearchResponse(result: list);
});
),
);
}
+8 -27
View File
@@ -1,21 +1,16 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:jiffy/jiffy.dart';
import '../api_request.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import '../http_errors.dart';
abstract class MhslApi<T> extends ApiRequest {
abstract class MhslApi<T> {
String subpath;
MhslApi(this.subpath);
http.Response? response;
Future<http.Response>? request(Uri uri);
T assemble(String raw);
@@ -24,22 +19,12 @@ abstract class MhslApi<T> extends ApiRequest {
'https://mhsl.eu/marianum/marianummobile/$subpath',
);
final http.Response data;
try {
final raw = await request(endpoint);
if (raw == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
technicalDetails: 'mhsl request returned null',
);
}
data = raw;
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'mhsl $subpath: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'mhsl $subpath: ${e.message}');
final data = await sendGuarded('mhsl $subpath', () => request(endpoint));
if (data == null) {
throw const NetworkException(
userMessage: 'Keine Antwort vom MHSL-Dienst erhalten.',
technicalDetails: 'mhsl request returned null',
);
}
if (data.statusCode > 299) {
@@ -55,8 +40,4 @@ abstract class MhslApi<T> extends ApiRequest {
throw ParseException(technicalDetails: 'mhsl $subpath assemble: $e');
}
}
static String dateTimeToJson(DateTime time) =>
Jiffy.parseFromDateTime(time).format(pattern: 'yyyy-MM-dd HH:mm:ss');
static DateTime dateTimeFromJson(String time) => DateTime.parse(time);
}
+1 -3
View File
@@ -256,9 +256,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (totalTabs != _knownTotalTabs) {
var targetIndex = currentIndex;
if (_userOnLastTab) {
targetIndex = totalTabs - 1;
} else if (currentIndex >= totalTabs) {
if (_userOnLastTab || currentIndex >= totalTabs) {
targetIndex = totalTabs - 1;
}
// Replace the controller atomically: a stale index past the new
+2 -6
View File
@@ -13,12 +13,8 @@ extension IsSameDay on DateTime {
TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute);
bool isSameDateTime(DateTime other) {
var isSameDay = this.isSameDay(other);
var isSameTimeOfDay = (toTimeOfDay() == other.toTimeOfDay());
return isSameDay && isSameTimeOfDay;
}
bool isSameDateTime(DateTime other) =>
isSameDay(other) && toTimeOfDay() == other.toTimeOfDay();
bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other);
}
+6 -3
View File
@@ -55,6 +55,7 @@ import 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart';
import 'widget/downloads/download_tray.dart';
import 'widget/emergency/emergency_notice_gate.dart';
import 'widget_data/widget_sync.dart';
Future<void> main() async {
@@ -360,9 +361,10 @@ class _MainState extends State<Main> {
// would otherwise cover it).
child: DownloadTrayHost(child: child ?? const SizedBox.shrink()),
),
home: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
home: EmergencyNoticeGate(
child: LoaderOverlay(
child: Breaker(
breaker: BreakerArea.global,
child: BlocConsumer<AccountBloc, AccountState>(
listenWhen: (previous, current) =>
previous.status != current.status,
@@ -468,6 +470,7 @@ class _MainState extends State<Main> {
);
}
},
),
),
),
),
+12 -14
View File
@@ -196,15 +196,10 @@ class AccountData {
/// Prefer this over embedding credentials in URLs — error logs and crash
/// reports often capture the URL but not headers.
String getBasicAuthHeader() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
_requirePopulated();
// Prefer the scoped app password once available; it survives real-password
// rotation and is what the push-v2 registration is bound to.
final secret = _appPassword ?? _password;
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
return _basicAuth(_appPassword ?? _password!);
}
/// Basic-auth header using the Talk app password — authenticates the
@@ -212,29 +207,32 @@ class AccountData {
/// talk password has not been minted yet; callers treat that as a failed
/// talk registration and retry on the next start.
String getTalkBasicAuthHeader() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
_requirePopulated();
if (!hasAppPasswordTalk()) {
throw StateError('Talk app password not available yet');
}
return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}';
return _basicAuth(_appPasswordTalk!);
}
/// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another).
String getRealPasswordBasicAuthHeader() {
_requirePopulated();
return _basicAuth(_password!);
}
void _requirePopulated() {
if (!isPopulated()) {
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}';
}
String _basicAuth(String secret) =>
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
/// header map ready to merge into HTTP request headers.
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
+1 -27
View File
@@ -1,18 +1,3 @@
import 'account_data.dart';
enum EndpointMode { live, stage }
class EndpointOptions {
Endpoint live;
Endpoint? staged;
EndpointOptions({required this.live, required this.staged});
Endpoint get(EndpointMode mode) {
if (staged == null || mode == EndpointMode.live) return live;
return staged!;
}
}
class Endpoint {
String domain;
String path;
@@ -29,16 +14,5 @@ class EndpointData {
EndpointData._construct();
EndpointMode getEndpointMode() {
late String existingName;
existingName = AccountData().getUsername();
return existingName.startsWith('google')
? EndpointMode.stage
: EndpointMode.live;
}
Endpoint nextcloud() => EndpointOptions(
live: Endpoint(domain: 'cloud.marianum-fulda.de'),
staged: Endpoint(domain: 'mhsl.eu', path: '/marianum/marianummobile/cloud'),
).get(getEndpointMode());
Endpoint nextcloud() => Endpoint(domain: 'cloud.marianum-fulda.de');
}
+3 -9
View File
@@ -371,13 +371,7 @@ class PushRenderer {
jsonEncode({'chatToken': ?chatToken, 'nid': nid});
/// Deterministic non-negative 31-bit id from a string, used when the push
/// carries no `nid`.
int _fallbackId(String? seed) {
if (seed == null || seed.isEmpty) return 0;
var hash = 0;
for (final unit in seed.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}
/// carries no `nid`. Shares the hash with [stableChatNotificationId] (an
/// empty/null seed hashes to 0).
int _fallbackId(String? seed) => stableChatNotificationId(seed ?? '');
}
+7
View File
@@ -87,6 +87,13 @@ class PushStatusReport {
(general.registeredProxyServer?.isNotEmpty ?? false) &&
!proxyEndpointMismatch(general) &&
general.lastRegistrationError == null;
/// True when no link in the chain is currently broken. Unknown links stay
/// permissive (mirroring [readyForTestNotification]) so a not-yet-loaded
/// capability or an undetermined OS permission does not flip the at-a-glance
/// health icon to red. Drives the compact status indicator in the settings.
bool get chainHealthy =>
buildPushStatusRows(this).every((row) => row.state != PushCheck.fail);
}
/// Collects the current push chain state. Settings/capability flags come from
+1 -1
View File
@@ -440,7 +440,7 @@ class AppRoutes {
static bool goToTab(BuildContext context, Modules module) {
final index = AppModule.getBottomBarModules(
context,
).map((e) => e.module).toList().indexOf(module);
).indexWhere((e) => e.module == module);
if (index == -1) return false;
Main.bottomNavigator.jumpToTab(index);
return true;
+4 -8
View File
@@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
class PendingShare {
final List<String> filePaths;
final String? text;
@@ -17,12 +19,6 @@ class PendingShare {
/// fires two `open(url)` requests per share (see ShareViewController), so
/// the same share can arrive twice on the media stream — receivedAt is
/// deliberately ignored here so such duplicates compare equal.
bool contentEquals(PendingShare other) {
if (text != other.text) return false;
if (filePaths.length != other.filePaths.length) return false;
for (var i = 0; i < filePaths.length; i++) {
if (filePaths[i] != other.filePaths[i]) return false;
}
return true;
}
bool contentEquals(PendingShare other) =>
text == other.text && listEquals(filePaths, other.filePaths);
}
@@ -1,45 +0,0 @@
import 'dart:convert';
import 'dart:developer';
import 'package:dio/dio.dart';
abstract class DataLoader<TResult> {
final Dio dio;
DataLoader(this.dio) {
dio.options.connectTimeout = const Duration(seconds: 10);
dio.options.sendTimeout = const Duration(seconds: 30);
dio.options.receiveTimeout = const Duration(seconds: 30);
}
Future<TResult> run() async {
final response = await fetch();
try {
return assemble(
DataLoaderResult(
json: jsonDecode(response.data!),
headers: response.headers.map.map(
(key, value) => MapEntry(key, value.join(';')),
),
),
);
} catch (e, stack) {
log('DataLoader assemble failed', error: e, stackTrace: stack);
rethrow;
}
}
Future<Response<String>> fetch();
TResult assemble(DataLoaderResult data);
}
class DataLoaderResult {
final dynamic json;
final Map<String, String> headers;
Map<String, dynamic> asMap() => json as Map<String, dynamic>;
List<dynamic> asList() => json as List<dynamic>;
List<Map<String, dynamic>> asListOfMaps() =>
asList().map((e) => e as Map<String, dynamic>).toList();
DataLoaderResult({required this.json, required this.headers});
}
@@ -19,12 +19,5 @@ abstract class LoadableState<TState> with _$LoadableState<TState> {
String? statusText,
}) = _LoadableState<TState>;
bool _hasError() => error != null;
bool _hasData() => data != null;
bool showPrimaryLoading() => isLoading && !_hasData();
bool showBackgroundLoading() => isLoading && _hasData();
bool showErrorBar() => _hasError() && _hasData();
bool showError() => _hasError() && !_hasData();
bool showContent() => _hasData();
bool showContent() => data != null;
}
@@ -107,16 +107,26 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
var bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context);
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 14, color: foreground),
const SizedBox(width: 10),
Text(
bloc.connectionText(lastUpdated: widget.lastUpdated),
style: TextStyle(fontSize: 12, color: foreground),
// liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird;
// Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite
// Label getragen, sonst liest der Screenreader ihn doppelt.
return Semantics(
liveRegion: true,
container: true,
label: bloc.connectionText(lastUpdated: widget.lastUpdated),
child: ExcludeSemantics(
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(bloc.connectionIcon(), size: 14, color: foreground),
const SizedBox(width: 10),
Text(
bloc.connectionText(lastUpdated: widget.lastUpdated),
style: TextStyle(fontSize: 12, color: foreground),
),
],
),
],
),
);
}
@@ -123,23 +123,28 @@ abstract class LoadableHydratedBloc<
fetch();
}
/// Maps [e] through the shared error mapper and emits it as an [Error] event.
/// Does not guard [isClosed] — callers decide whether a late error still
/// applies.
void addLoadingError(Object e) => add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
),
),
);
void fetch() {
log('Fetching data for ${TState.toString()}');
gatherData()
.catchError((e) {
.catchError((Object e) {
log('Error while fetching ${TState.toString()}: ${e.toString()}');
// The bloc may have been closed before this async error landed;
// adding to a closed bloc throws, so swallow that case.
if (isClosed) return;
add(
Error(
LoadingError(
message: errorToUserMessage(e),
technicalDetails: errorToTechnicalDetails(e),
allowRetry: errorAllowsRetry(e),
),
),
);
addLoadingError(e);
})
.then((value) {
log('Fetch for ${TState.toString()} completed!');
+1
View File
@@ -248,6 +248,7 @@ class AppModule {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
onPressed: onVisibleChange,
icon: Icon(
isVisible
+1 -13
View File
@@ -4,13 +4,11 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../chat_list/bloc/chat_list_bloc.dart';
@@ -181,17 +179,7 @@ class ChatBloc
if (!stillCurrent()) return;
if (capturedError != null) {
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
if (capturedError != null) addLoadingError(capturedError!);
}
void _startLongPoll(String token) {
@@ -3,10 +3,8 @@ import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/chat_list_repository.dart';
@@ -87,17 +85,7 @@ class ChatListBloc
} catch (e) {
capturedError = e;
}
if (capturedError != null) {
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
if (capturedError != null) addLoadingError(capturedError!);
}
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
@@ -2,9 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../infrastructure/loadable_state/loading_error.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/files_repository.dart';
@@ -112,16 +110,6 @@ class FilesBloc
);
add(DataGathered((s) => s.copyWith(listing: listing)));
}
if (capturedError != null) {
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
if (capturedError != null) addLoadingError(capturedError!);
}
}
@@ -1,5 +1,3 @@
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../repository/ticker_repository.dart';
@@ -10,9 +8,7 @@ class TickerBloc
extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> {
@override
Future<void> gatherData() async {
final results = await Future.wait([repo.getTicker(), repo.getNav()]);
final ticker = results[0] as TickerResponse;
final nav = results[1] as TickerNavResponse;
final (ticker, nav) = await (repo.getTicker(), repo.getNav()).wait;
add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav)));
}
+14
View File
@@ -17,12 +17,18 @@ class DevToolsSettings {
@JsonKey(defaultValue: '')
String marianumConnectCustomUrl;
/// Optional override for the backend-independent emergency-notice source.
/// Empty falls back to [emergencyNoticeDefaultUrl].
@JsonKey(defaultValue: '')
String emergencyNoticeUrl;
DevToolsSettings({
required this.showPerformanceOverlay,
required this.checkerboardOffscreenLayers,
required this.checkerboardRasterCacheImages,
this.marianumConnectEndpoint = MarianumConnectEndpoint.live,
this.marianumConnectCustomUrl = '',
this.emergencyNoticeUrl = '',
});
// Resolves the effective base URL, falling back to live when the custom URL
@@ -43,6 +49,14 @@ class DevToolsSettings {
static const String liveUrl = 'https://connect.marianum-fulda.de';
static const String betaUrl = 'https://connect-beta.marianum-fulda.de';
/// Compiled-in source for the backend-independent emergency notice. Must live
/// on infrastructure that is reachable even when MarianumConnect is down.
static const String emergencyNoticeDefaultUrl = 'https://www.marianum-fulda.de/~aushang/marMobile.override';
String? resolveEmergencyNoticeUrl() =>
sanitizeCustomUrl(emergencyNoticeUrl) ??
sanitizeCustomUrl(emergencyNoticeDefaultUrl);
/// `true` in builds where plaintext HTTP custom endpoints are still allowed
/// (debug, profile). Release builds keep this `false` and the picker
/// rejects `http://` entirely.
+2
View File
@@ -19,6 +19,7 @@ DevToolsSettings _$DevToolsSettingsFromJson(
) ??
MarianumConnectEndpoint.live,
marianumConnectCustomUrl: json['marianumConnectCustomUrl'] as String? ?? '',
emergencyNoticeUrl: json['emergencyNoticeUrl'] as String? ?? '',
);
Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
@@ -29,6 +30,7 @@ Map<String, dynamic> _$DevToolsSettingsToJson(DevToolsSettings instance) =>
'marianumConnectEndpoint':
_$MarianumConnectEndpointEnumMap[instance.marianumConnectEndpoint]!,
'marianumConnectCustomUrl': instance.marianumConnectCustomUrl,
'emergencyNoticeUrl': instance.emergencyNoticeUrl,
};
const _$MarianumConnectEndpointEnumMap = {
+25 -10
View File
@@ -36,7 +36,6 @@ class DownloadManager {
final Map<String, DownloadJob> _jobs = {}; // keyed by remotePath
final Map<String, DownloadJob> _byTaskId = {};
final Map<String, bd.DownloadTask> _taskById = {};
/// All jobs the user should currently see in the downloads tray/overview:
/// everything that is in progress or finished-but-not-yet-opened (failed
@@ -134,7 +133,6 @@ class DownloadManager {
)..taskId = task.taskId;
_jobs[remotePath] = job;
_byTaskId[task.taskId] = job;
_taskById[task.taskId] = task;
_refreshVisible();
final ok = await bd.FileDownloader().enqueue(task);
@@ -181,6 +179,29 @@ class DownloadManager {
final taskId = job.taskId;
if (taskId == null || !Platform.isAndroid) return;
final id = _androidNotificationId(taskId);
// background_downloader pushes the completion status to us *before* it posts
// the "Fertig" notification, and that post runs through a queue throttled to
// one notification per ~300ms. When the file opens immediately (lone
// foreground download), our first cancel therefore races ahead of the
// notification actually appearing and no-ops — leaving it stuck. Re-cancel
// across the throttle window so the notification is caught once it lands.
for (final delay in _dismissRetryDelays) {
if (delay == Duration.zero) {
_cancelNotification(id);
} else {
Future<void>.delayed(delay, () => _cancelNotification(id));
}
}
}
static const _dismissRetryDelays = [
Duration.zero,
Duration(milliseconds: 350),
Duration(milliseconds: 750),
Duration(milliseconds: 1500),
];
void _cancelNotification(int id) {
unawaited(
NotificationService().flutterLocalNotificationsPlugin
.cancel(id: id)
@@ -217,10 +238,7 @@ class DownloadManager {
job.status.value = const DownloadCancelled();
}
_jobs.remove(job.remotePath);
if (taskId != null) {
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
if (taskId != null) _byTaskId.remove(taskId);
scheduleMicrotask(job.dispose);
}
_refreshVisible();
@@ -314,10 +332,7 @@ class DownloadManager {
void _remove(DownloadJob job) {
_jobs.remove(job.remotePath);
final taskId = job.taskId;
if (taskId != null) {
_byTaskId.remove(taskId);
_taskById.remove(taskId);
}
if (taskId != null) _byTaskId.remove(taskId);
_refreshVisible();
scheduleMicrotask(job.dispose);
}
+8 -5
View File
@@ -7,11 +7,14 @@ class LoginHeader extends StatelessWidget {
Widget build(BuildContext context) => Column(
children: [
const SizedBox(height: 40),
Image.asset(
'assets/logo/icon.png',
height: 110,
fit: BoxFit.contain,
gaplessPlayback: true,
// Dekoratives Logo der Schulname steht direkt darunter als Text.
const ExcludeSemantics(
child: Image(
image: AssetImage('assets/logo/icon.png'),
height: 110,
fit: BoxFit.contain,
gaplessPlayback: true,
),
),
const SizedBox(height: 20),
const Text(
+53 -41
View File
@@ -18,6 +18,14 @@ class LoginErrorBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final showDetails = details != null
? () => InfoDialog.show(
context,
details!,
copyable: true,
title: 'Fehlerdetails',
)
: null;
return AnimatedSize(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOut,
@@ -25,52 +33,56 @@ class LoginErrorBanner extends StatelessWidget {
? const SizedBox(height: 0, width: double.infinity)
: Padding(
padding: const EdgeInsets.only(top: 14),
child: Material(
color: theme.colorScheme.errorContainer.withValues(alpha: 0.6),
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: details != null
? () => InfoDialog.show(
context,
details!,
copyable: true,
title: 'Fehlerdetails',
)
: null,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
child: Semantics(
button: showDetails != null,
label: showDetails != null
? '${message!}, Fehlerdetails anzeigen'
: message,
onTap: showDetails,
child: ExcludeSemantics(
child: Material(
color: theme.colorScheme.errorContainer.withValues(
alpha: 0.6,
),
child: Row(
children: [
Icon(
Icons.error_outline,
size: 20,
color: theme.colorScheme.onErrorContainer,
borderRadius: BorderRadius.circular(12),
child: InkWell(
onTap: showDetails,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
const SizedBox(width: 10),
Expanded(
child: Text(
message!,
style: TextStyle(
child: Row(
children: [
Icon(
Icons.error_outline,
size: 20,
color: theme.colorScheme.onErrorContainer,
fontSize: 13,
height: 1.3,
),
),
const SizedBox(width: 10),
Expanded(
child: Text(
message!,
style: TextStyle(
color: theme.colorScheme.onErrorContainer,
fontSize: 13,
height: 1.3,
),
),
),
if (details != null) ...[
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
size: 20,
color: theme.colorScheme.onErrorContainer
.withValues(alpha: 0.7),
),
],
],
),
if (details != null) ...[
const SizedBox(width: 8),
Icon(
Icons.chevron_right,
size: 20,
color: theme.colorScheme.onErrorContainer
.withValues(alpha: 0.7),
),
],
],
),
),
),
),
+10 -18
View File
@@ -62,12 +62,14 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
);
}
void _resetProgress() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
}
void _showUploadError(String message) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
InfoDialog.show(
context,
message,
@@ -157,9 +159,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
if (replaceFiles != true) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
_resetProgress();
for (var element in conflictingFiles) {
element.isConflicting = true;
}
@@ -222,11 +222,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
if (!mounted) return;
Navigator.of(context).pop();
showHttpErrorCode(uploadTask.statusCode);
@@ -235,11 +231,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
}
}
setState(() {
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
setState(_resetProgress);
if (!mounted) return;
Navigator.of(context).pop();
widget.onUploadFinished(uploadetFilePaths);
@@ -33,6 +33,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
@override
Widget? buildLeading(BuildContext context) => IconButton(
icon: const Icon(Icons.arrow_back),
tooltip: 'Zurück',
onPressed: () => close(context, null),
);
@@ -239,6 +239,7 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
),
trailing: IconButton(
onPressed: () => copyToClipboard(context, _share.url!),
tooltip: 'Link kopieren',
icon: const Icon(Icons.copy_outlined),
),
),
@@ -127,6 +127,7 @@ class _ShareePickerPageState extends State<ShareePickerPage> {
? null
: IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Leeren',
onPressed: () {
_searchController.clear();
_query = '';
+11 -23
View File
@@ -6,7 +6,6 @@ import 'package:nextcloud/nextcloud.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/endpoint_data.dart';
import '../../../../routing/app_routes.dart';
import '../../../../share_intent/remote_file_ref.dart';
import '../../../../state/app/modules/nextcloud_capabilities/bloc/nextcloud_capabilities_cubit.dart';
@@ -18,7 +17,6 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/demo_restricted.dart';
import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/downloads/download_trigger.dart';
import '../../../../widget/info_dialog.dart';
import '../../../../widget/prompt_dialog.dart';
import '../../talk/widgets/highlighted_linkify.dart';
import '../sharing/share_sheet.dart';
@@ -108,13 +106,6 @@ class _FileElementState extends State<FileElement>
return;
}
if (guardDemoAction(context)) return;
if (EndpointData().getEndpointMode() == EndpointMode.stage) {
InfoDialog.show(
context,
'Virtuelle Dateien im Staging Prozess können nicht heruntergeladen werden!',
);
return;
}
if (isDownloading) {
confirmCancelDownload();
return;
@@ -180,21 +171,18 @@ class _FileElementState extends State<FileElement>
);
}
Future<void> _delete() async {
void _delete() {
if (guardDemoAction(context)) return;
await showDialog<void>(
context: context,
builder: (context) => ConfirmDialog(
title: 'Element löschen?',
content: 'Das Element wird unwiederruflich gelöscht.',
confirmButton: 'Löschen',
onConfirmAsync: () async {
final webdav = await WebdavApi.webdav;
await webdav.delete(PathUri.parse(widget.file.path));
widget.refetch();
},
),
);
ConfirmDialog(
title: 'Element löschen?',
content: 'Das Element wird unwiederruflich gelöscht.',
confirmButton: 'Löschen',
onConfirmAsync: () async {
final webdav = await WebdavApi.webdav;
await webdav.delete(PathUri.parse(widget.file.path));
widget.refetch();
},
).asDialog(context);
}
void _showActionSheet() {
@@ -23,6 +23,8 @@ class FilesSortActions extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
PopupMenuButton<bool>(
tooltip:
'Sortierrichtung: ${ascending ? 'aufsteigend' : 'absteigend'}',
icon: Icon(
ascending ? Icons.text_rotate_up : Icons.text_rotation_down,
),
@@ -47,6 +49,8 @@ class FilesSortActions extends StatelessWidget {
onSelected: onDirectionChanged,
),
PopupMenuButton<SortOption>(
tooltip:
'Sortieren nach: ${SortOptions.getOption(currentSort).displayName}',
icon: const Icon(Icons.sort),
itemBuilder: (context) => SortOptions.options.keys
.map(
@@ -166,6 +166,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
? null
: IconButton(
icon: const Icon(Icons.clear),
tooltip: 'Leeren',
onPressed: () {
_searchController.clear();
setState(() => _query = '');
@@ -35,6 +35,7 @@ class GradeAveragesListView extends StatelessWidget {
Text(getGradeDisplay(grade)),
const SizedBox(width: 30),
IconButton(
tooltip: 'Note entfernen',
onPressed: () {
bloc.add(DecrementGrade(grade));
},
@@ -49,6 +50,7 @@ class GradeAveragesListView extends StatelessWidget {
),
),
IconButton(
tooltip: 'Note hinzufügen',
onPressed: () {
bloc.add(IncrementGrade(grade));
},
@@ -64,6 +66,7 @@ class GradeAveragesListView extends StatelessWidget {
maintainSize: true,
visible: bloc.canDecrementOrDelete(grade),
child: IconButton(
tooltip: 'Löschen',
icon: const Icon(Icons.delete),
onPressed: () {
bloc.add(ResetGrade(grade));
@@ -24,23 +24,19 @@ class GradeAveragesView extends StatelessWidget {
Visibility(
visible: bloc.state.grades.isNotEmpty,
child: IconButton(
onPressed: () {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen',
onConfirm: () {
bloc.add(ResetAll());
},
),
);
},
tooltip: 'Alle zurücksetzen',
onPressed: () => ConfirmDialog(
title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen',
onConfirm: () => bloc.add(ResetAll()),
).asDialog(context),
icon: const Icon(Icons.delete_forever),
),
),
PopupMenuButton<bool>(
tooltip:
'Notensystem: ${bloc.isMiddleSchool() ? 'Realschule' : 'Oberstufe'}',
initialValue: bloc.isMiddleSchool(),
icon: const Icon(Icons.more_horiz),
itemBuilder: (context) => [true, false]
@@ -64,17 +60,14 @@ class GradeAveragesView extends StatelessWidget {
.toList(),
onSelected: (isMiddleSchool) {
if (bloc.state.grades.isNotEmpty) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Notensystem wechseln',
content:
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
confirmButton: 'Fortfahren',
onConfirm: () =>
bloc.add(GradingSystemChanged(isMiddleSchool)),
),
);
ConfirmDialog(
title: 'Notensystem wechseln',
content:
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
confirmButton: 'Fortfahren',
onConfirm: () =>
bloc.add(GradingSystemChanged(isMiddleSchool)),
).asDialog(context);
} else {
bloc.add(GradingSystemChanged(isMiddleSchool));
}
+6 -8
View File
@@ -38,10 +38,13 @@ class HolidaysView extends StatelessWidget {
title: const Text('Schulferien'),
actions: [
IconButton(
tooltip: 'Informationen',
icon: const Icon(Icons.info_outline),
onPressed: showDisclaimer,
),
PopupMenuButton<bool>(
tooltip:
'Vergangene Ferien ${bloc.showPastHolidays() ? 'ausblenden' : 'anzeigen'}',
initialValue: bloc.showPastHolidays(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
@@ -81,9 +84,7 @@ class HolidaysView extends StatelessWidget {
text: 'Keine Schulferien verfügbar',
);
}
return ListViewUtil.fromList<McHoliday>(
holidays,
(holiday) {
return ListViewUtil.fromList<McHoliday>(holidays, (holiday) {
String holidayYear() {
final startYear = holiday.startDate.year;
final endYear = holiday.endDate.year;
@@ -93,9 +94,7 @@ class HolidaysView extends StatelessWidget {
return ListTile(
leading: const CenteredLeading(Icon(Icons.calendar_month)),
title: Text(
'${holiday.longName} ${holidayYear()}',
),
title: Text('${holiday.longName} ${holidayYear()}'),
subtitle: Text(
'${holiday.startDate.formatDate()} - ${holiday.endDate.formatDate()}',
),
@@ -147,8 +146,7 @@ class HolidaysView extends StatelessWidget {
),
trailing: const Icon(Icons.arrow_right),
);
},
);
});
},
),
);
@@ -27,98 +27,99 @@ class MarianumDatesView extends StatelessWidget {
return keys.map((key) {
final first = byMonth[key]!.first.start;
final label = first.formatMonthYear().toUpperCase();
return _MonthGroup(key: key, label: label, events: byMonth[key]!);
return _MonthGroup(label: label, events: byMonth[key]!);
}).toList();
}
@override
Widget build(BuildContext context) =>
BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
create: (context) => MarianumDatesBloc(),
autoRebuild: true,
child: (context, bloc, state) => Scaffold(
appBar: AppBar(
title: const Text('Marianum Termine'),
actions: [
PopupMenuButton<bool>(
initialValue: bloc.showPastEvents(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
.map(
(e) => PopupMenuItem<bool>(
value: e,
enabled: e != bloc.showPastEvents(),
child: Row(
children: [
Icon(
e
? Icons.history_outlined
: Icons.history_toggle_off_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(
e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen',
),
],
),
),
)
.toList(),
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
),
IconButton(
icon: const Icon(Icons.search),
onPressed: () {
final events = bloc.getEvents() ?? const <MarianumDate>[];
showSearch(
context: context,
delegate: SearchMarianumDates(events),
);
},
),
],
),
body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>(
child: (state, loading) {
final events = bloc.getEvents() ?? const <MarianumDate>[];
final groups = _groupByMonth(events);
if (groups.isEmpty) {
return const PlaceholderView(
icon: Icons.event_busy_outlined,
text: 'Keine Termine',
);
}
return CustomScrollView(
slivers: [
for (final group in groups)
SliverMainAxisGroup(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: MonthHeaderDelegate(label: group.label),
),
SliverList.builder(
itemCount: group.events.length,
itemBuilder: (_, i) =>
MarianumDateRow(event: group.events[i]),
Widget build(
BuildContext context,
) => BlocModule<MarianumDatesBloc, LoadableState<MarianumDatesState>>(
create: (context) => MarianumDatesBloc(),
autoRebuild: true,
child: (context, bloc, state) => Scaffold(
appBar: AppBar(
title: const Text('Marianum Termine'),
actions: [
PopupMenuButton<bool>(
tooltip:
'Vergangene Termine ${bloc.showPastEvents() ? 'ausblenden' : 'anzeigen'}',
initialValue: bloc.showPastEvents(),
icon: const Icon(Icons.history),
itemBuilder: (context) => [true, false]
.map(
(e) => PopupMenuItem<bool>(
value: e,
enabled: e != bloc.showPastEvents(),
child: Row(
children: [
Icon(
e
? Icons.history_outlined
: Icons.history_toggle_off_outlined,
color: Theme.of(context).colorScheme.onSurface,
),
const SizedBox(width: 15),
Text(e ? 'Alle anzeigen' : 'Nur zukünftige anzeigen'),
],
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
),
)
.toList(),
onSelected: (e) => bloc.add(SetPastEventsVisible(e)),
),
IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () {
final events = bloc.getEvents() ?? const <MarianumDate>[];
showSearch(
context: context,
delegate: SearchMarianumDates(events),
);
},
),
),
);
],
),
body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>(
child: (state, loading) {
final events = bloc.getEvents() ?? const <MarianumDate>[];
final groups = _groupByMonth(events);
if (groups.isEmpty) {
return const PlaceholderView(
icon: Icons.event_busy_outlined,
text: 'Keine Termine',
);
}
return CustomScrollView(
slivers: [
for (final group in groups)
SliverMainAxisGroup(
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: MonthHeaderDelegate(label: group.label),
),
SliverList.builder(
itemCount: group.events.length,
itemBuilder: (_, i) =>
MarianumDateRow(event: group.events[i]),
),
],
),
const SliverToBoxAdapter(child: SizedBox(height: 24)),
],
);
},
),
),
);
}
class _MonthGroup {
final String key;
final String label;
final List<MarianumDate> events;
_MonthGroup({required this.key, required this.label, required this.events});
_MonthGroup({required this.label, required this.events});
}
@@ -22,11 +22,16 @@ class SearchMarianumDates extends SearchDelegate<MarianumDate?> {
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import 'search_marianum_messages.dart';
class MarianumMessageListView extends StatelessWidget {
@@ -21,6 +22,7 @@ class MarianumMessageListView extends StatelessWidget {
title: const Text('Marianum Message'),
actions: [
IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () {
final list = bloc.state.data?.messageList;
@@ -40,12 +42,9 @@ class MarianumMessageListView extends StatelessWidget {
child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length,
itemBuilder: (context, index) {
var message = state.messageList.messages.toList()[index];
var message = state.messageList.messages[index];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
leading: const CenteredLeading(Icon(Icons.newspaper)),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart';
import '../../../widget/app_progress_indicator.dart';
@@ -39,20 +38,8 @@ class _MessageViewState extends State<MessageView> {
return SfPdfViewer.memory(
snapshot.data!,
enableHyperlinkNavigation: true,
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Link öffnen',
content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}',
confirmButton: 'Öffnen',
onConfirm: () => launchUrl(
Uri.parse(e.uri),
mode: LaunchMode.externalApplication,
),
),
);
},
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
ConfirmDialog.openBrowser(context, e.uri),
);
},
),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/placeholder_view.dart';
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
@@ -22,11 +23,16 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
];
@override
Widget? buildLeading(BuildContext context) => IconButton(
tooltip: 'Zurück',
icon: const Icon(Icons.arrow_back),
onPressed: () => close(context, null),
);
@@ -45,10 +51,7 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
itemBuilder: (_, i) {
final message = matches[i];
return ListTile(
leading: const Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
leading: const CenteredLeading(Icon(Icons.newspaper)),
title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right),
+12 -6
View File
@@ -1,18 +1,24 @@
import 'package:flutter/material.dart';
import 'package:photo_view/photo_view.dart';
import '../../../../widget/a11y/a11y_labels.dart';
class Roomplan extends StatelessWidget {
const Roomplan({super.key});
@override
Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('Raumplan')),
body: PhotoView(
imageProvider: Image.asset('assets/img/raumplan.png').image,
minScale: 0.5,
maxScale: 2.0,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
body: Semantics(
image: true,
label: A11yLabels.roomPlan,
child: PhotoView(
imageProvider: Image.asset('assets/img/raumplan.png').image,
minScale: 0.5,
maxScale: 2.0,
backgroundDecoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
),
),
),
);
+1
View File
@@ -23,6 +23,7 @@ class _OverhangState extends State<Overhang> {
title: const Text('Mehr'),
actions: [
IconButton(
tooltip: 'Einstellungen',
onPressed: () => AppRoutes.openSettings(context),
icon: const Icon(Icons.settings),
),
@@ -86,20 +86,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
trailing: DropdownButton<ChatBackgroundType>(
value: s.type,
icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundType.values
.map(
(e) => DropdownMenuItem<ChatBackgroundType>(
value: e,
child: Row(
children: [
Icon(_typeIcon(e)),
const SizedBox(width: 10),
Text(_typeLabel(e)),
],
),
),
)
.toList(),
items: _iconDropdownItems(
ChatBackgroundType.values,
_typeIcon,
_typeLabel,
),
onChanged: (e) => _onTypeChanged(context, settings, e!),
),
),
@@ -142,20 +133,11 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
trailing: DropdownButton<ChatBackgroundFit>(
value: s.fit,
icon: const Icon(Icons.arrow_drop_down),
items: ChatBackgroundFit.values
.map(
(e) => DropdownMenuItem<ChatBackgroundFit>(
value: e,
child: Row(
children: [
Icon(_fitIcon(e)),
const SizedBox(width: 10),
Text(_fitLabel(e)),
],
),
),
)
.toList(),
items: _iconDropdownItems(
ChatBackgroundFit.values,
_fitIcon,
_fitLabel,
),
onChanged: (e) {
Haptics.selection();
settings.val(write: true).chatBackgroundSettings.fit =
@@ -280,6 +262,27 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
cs.type = ChatBackgroundType.color;
}
/// Dropdown menu items rendered as an icon + label row, shared by the source
/// and fill-mode pickers.
static List<DropdownMenuItem<T>> _iconDropdownItems<T>(
List<T> values,
IconData Function(T) icon,
String Function(T) label,
) => values
.map(
(e) => DropdownMenuItem<T>(
value: e,
child: Row(
children: [
Icon(icon(e)),
const SizedBox(width: 10),
Text(label(e)),
],
),
),
)
.toList();
IconData _typeIcon(ChatBackgroundType type) => switch (type) {
ChatBackgroundType.pattern => Icons.texture_outlined,
ChatBackgroundType.image => Icons.image_outlined,
@@ -330,7 +333,6 @@ class _Preview extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bubble(
context,
alignment: Alignment.centerLeft,
color: remoteColor,
text: 'Wie gefällt dir der neue Hintergrund?',
@@ -338,7 +340,6 @@ class _Preview extends StatelessWidget {
),
const SizedBox(height: 8),
_bubble(
context,
alignment: Alignment.centerRight,
color: selfColor,
text: 'Sieht richtig gut aus! 🎉',
@@ -352,8 +353,7 @@ class _Preview extends StatelessWidget {
);
}
Widget _bubble(
BuildContext context, {
Widget _bubble({
required Alignment alignment,
required Color color,
required String text,
@@ -75,6 +75,7 @@ class DefaultSettings {
showPerformanceOverlay: false,
marianumConnectEndpoint: MarianumConnectEndpoint.live,
marianumConnectCustomUrl: '',
emergencyNoticeUrl: '',
),
hapticSettings: HapticSettings(level: HapticLevel.full),
);
@@ -66,6 +66,7 @@ class ModuleSortBody extends StatelessWidget {
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
tooltip: 'Slot entfernen',
icon: const Icon(Icons.remove_circle_outline),
onPressed:
modulesSettings.fixedBottomBarSlots >
@@ -80,6 +81,7 @@ class ModuleSortBody extends StatelessWidget {
),
Text('${modulesSettings.fixedBottomBarSlots}'),
IconButton(
tooltip: 'Slot hinzufügen',
icon: const Icon(Icons.add_circle_outline),
onPressed:
modulesSettings.fixedBottomBarSlots <
@@ -1,7 +1,6 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:jiffy/jiffy.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -71,7 +70,7 @@ class AboutSection extends StatelessWidget {
applicationLegalese:
'Alles für deinen Schulalltag am Marianum Fulda.\n\n'
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n"
'Marianum Fulda\n2019-2020 & 2022-${Jiffy.now().year}\nElias Müller',
'Marianum Fulda\n2019-2020 & 2022-${DateTime.now().year}\nElias Müller',
);
}
@@ -5,6 +5,7 @@ import 'package:hydrated_bloc/hydrated_bloc.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/dev_tools_settings.dart';
import '../../../../storage/settings.dart' as model;
import '../../../../widget/centered_leading.dart';
import '../../../../widget/confirm_dialog.dart';
@@ -88,6 +89,27 @@ class _DevToolsSectionState extends State<DevToolsSection> {
onTap: () =>
MarianumConnectEndpointPicker.show(context, widget.settings),
),
BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings,
builder: (_, _) {
final override = widget.settings
.val()
.devToolsSettings
.emergencyNoticeUrl
.trim();
return ListTile(
leading: const CenteredLeading(Icon(Icons.emergency_outlined)),
title: const Text('Notfall-Nachricht (Quelle)'),
subtitle: Text(
override.isEmpty
? 'Standardquelle (im Code hinterlegt)'
: override,
),
trailing: const Icon(Icons.arrow_right),
onTap: () => _EmergencyNoticeUrlEditor.show(context, widget.settings),
);
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.image_outlined)),
title: const Text('Thumb-storage'),
@@ -170,3 +192,94 @@ class _DevToolsSectionState extends State<DevToolsSection> {
],
);
}
/// Bottom-sheet editor for the backend-independent emergency-notice override
/// URL. Empty clears the override so the compiled-in default source is used.
class _EmergencyNoticeUrlEditor extends StatefulWidget {
final SettingsCubit settings;
const _EmergencyNoticeUrlEditor({required this.settings});
static void show(BuildContext context, SettingsCubit settings) {
showDetailsBottomSheet(
context,
header: const ListTile(title: Text('Notfall-Nachricht (Quelle)')),
children: (sheetCtx) => [_EmergencyNoticeUrlEditor(settings: settings)],
);
}
@override
State<_EmergencyNoticeUrlEditor> createState() =>
_EmergencyNoticeUrlEditorState();
}
class _EmergencyNoticeUrlEditorState extends State<_EmergencyNoticeUrlEditor> {
late final TextEditingController _controller;
String? _error;
@override
void initState() {
super.initState();
_controller = TextEditingController(
text: widget.settings.val().devToolsSettings.emergencyNoticeUrl,
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _save() {
final raw = _controller.text.trim();
// Empty is valid: it clears the override and falls back to the default.
if (raw.isNotEmpty &&
DevToolsSettings.sanitizeCustomUrl(raw) == null) {
setState(
() => _error = DevToolsSettings.allowsHttpCustomEndpoint
? 'Ungültige URL (http(s)://host[:port]/...)'
: 'Ungültige URL — nur HTTPS erlaubt',
);
return;
}
widget.settings.val(write: true).devToolsSettings.emergencyNoticeUrl = raw;
Navigator.of(context).pop();
}
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Padding(
padding: EdgeInsets.only(bottom: 12),
child: Text(
'Überschreibt die im Code hinterlegte Standardquelle. Leer '
'lassen, um die Standardquelle zu verwenden.',
style: TextStyle(fontSize: 12),
),
),
TextField(
controller: _controller,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'https://...',
errorText: _error,
),
onChanged: (_) {
if (_error != null) setState(() => _error = null);
},
),
Padding(
padding: const EdgeInsets.only(top: 16),
child: FilledButton(
onPressed: _save,
child: const Text('Übernehmen'),
),
),
],
),
);
}
@@ -4,7 +4,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../push/push_registration.dart';
import '../../../../push/push_status.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../widget/centered_leading.dart';
import '../widgets/push_status_sheet.dart';
@@ -17,7 +19,6 @@ class TalkSection extends StatelessWidget {
Widget build(BuildContext context) {
final settings = context.watch<SettingsCubit>();
final talkSettings = settings.val().talkSettings;
final notificationSettings = settings.val().notificationSettings;
return Column(
children: [
SettingsCheckboxTile(
@@ -41,47 +42,180 @@ class TalkSection extends StatelessWidget {
trailing: const Icon(Icons.arrow_right),
onTap: () => AppRoutes.openChatBackgroundSettings(context),
),
SettingsCheckboxTile(
icon: Icons.notifications_active_outlined,
title: 'Push-Benachrichtigungen',
subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
value: notificationSettings.enabled,
onChanged: (enabled) {
settings.val(write: true).notificationSettings.enabled = enabled;
// Turning off does NOT unregister: the device stays subscribed so
// silent sync pushes keep arriving; the message handler and iOS
// NSE suppress only the visible notification (via the mirrored
// flag). Enabling (re-)registers and ensures the OS permission.
if (enabled) {
final messenger = ScaffoldMessenger.of(context);
unawaited(() async {
// Only register when the OS permission isn't explicitly
// denied — otherwise NC + proxy would push into the void.
if (await PushRegistration.requestOsPermission()) {
await PushRegistration().register();
} else {
messenger.showSnackBar(
const SnackBar(
content: Text(
'Die Benachrichtigungsberechtigung wurde in den '
'Systemeinstellungen deaktiviert. Bitte aktiviere '
'sie dort, um Push-Benachrichtigungen zu erhalten.',
),
),
);
}
}());
}
},
),
ListTile(
leading: const CenteredLeading(Icon(Icons.monitor_heart_outlined)),
title: const Text('Push-Status'),
subtitle: const Text('Registrierung und Zustellung im Detail'),
trailing: const Icon(Icons.arrow_right),
onTap: () => showPushStatusSheet(context),
_PushSettings(
settings: settings,
capabilities: context.read<CapabilitiesCubit>(),
enabled: settings.val().notificationSettings.enabled,
devMode: settings.val().devToolsEnabled,
),
],
);
}
}
/// The push area: the enable switch carries an at-a-glance health icon (green
/// check / red X) right before the checkbox, and the detailed status checklist
/// is hidden — it only surfaces when the chain is broken or the developer mode
/// is on. Owns the [PushStatusReport] loading so the inline icon and the detail
/// entry share one source of truth.
class _PushSettings extends StatefulWidget {
final SettingsCubit settings;
final CapabilitiesCubit capabilities;
final bool enabled;
final bool devMode;
const _PushSettings({
required this.settings,
required this.capabilities,
required this.enabled,
required this.devMode,
});
@override
State<_PushSettings> createState() => _PushSettingsState();
}
class _PushSettingsState extends State<_PushSettings>
with WidgetsBindingObserver {
PushStatusReport? _report;
/// True while a (de)registration triggered by the switch is in flight. The
/// report collected in that window still reflects the pre-registration state,
/// so the status is shown as "loading" instead of briefly flashing red.
bool _busy = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
unawaited(_load());
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didUpdateWidget(covariant _PushSettings oldWidget) {
super.didUpdateWidget(oldWidget);
// Toggling the setting changes several links at once — re-collect.
if (oldWidget.enabled != widget.enabled) unawaited(_load());
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// The OS permission can change while the app is backgrounded.
if (state == AppLifecycleState.resumed) unawaited(_load());
}
Future<void> _load() async {
final caps = widget.capabilities.state;
final report = await collectPushStatus(
settingEnabled: widget.settings.val().notificationSettings.enabled,
capabilityPush: caps.pushNotifications,
capabilitiesLoaded: caps.loaded,
);
if (!mounted) return;
setState(() => _report = report);
}
void _onToggle(bool enabled) {
widget.settings.val(write: true).notificationSettings.enabled = enabled;
// Turning off does NOT unregister: the device stays subscribed so silent
// sync pushes keep arriving; the message handler and iOS NSE suppress only
// the visible notification (via the mirrored flag). Enabling (re-)registers
// and ensures the OS permission.
if (!enabled) return;
setState(() => _busy = true);
final messenger = ScaffoldMessenger.of(context);
unawaited(() async {
try {
// Only register when the OS permission isn't explicitly denied —
// otherwise NC + proxy would push into the void.
if (await PushRegistration.requestOsPermission()) {
await PushRegistration().register();
} else {
messenger.showSnackBar(
const SnackBar(
content: Text(
'Die Benachrichtigungsberechtigung wurde in den '
'Systemeinstellungen deaktiviert. Bitte aktiviere sie dort, um '
'Push-Benachrichtigungen zu erhalten.',
),
),
);
}
} finally {
if (mounted) await _load();
if (mounted) setState(() => _busy = false);
}
}());
}
@override
Widget build(BuildContext context) {
final report = _report;
final broken =
widget.enabled && !_busy && report != null && !report.chainHealthy;
return Column(
children: [
SettingsCheckboxTile(
icon: Icons.notifications_active_outlined,
title: 'Push-Benachrichtigungen',
subtitle: 'Benachrichtigungen bei neuen Talk-Nachrichten erhalten',
value: widget.enabled,
beforeCheckbox: _inlineStatusIcon(report),
onChanged: _onToggle,
),
// Detail entry only when there is a problem to fix or for developers.
if (broken || widget.devMode) _detailTile(error: broken),
],
);
}
/// Health icon shown before the checkbox — a spinner while a registration is
/// in flight, otherwise the green/red verdict once the report has loaded.
Widget? _inlineStatusIcon(PushStatusReport? report) {
if (_busy) {
return const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
);
}
if (!widget.enabled || report == null) return null;
final healthy = report.chainHealthy;
return Icon(
healthy ? Icons.check_circle : Icons.cancel,
color: healthy ? Colors.green : Theme.of(context).colorScheme.error,
semanticLabel: healthy ? 'Push in Ordnung' : 'Push funktioniert nicht',
);
}
/// The full status checklist entry — same list-tile footprint whether broken
/// or not; a problem is signalled only through the error-colored icon/text.
Widget _detailTile({required bool error}) {
final color = error ? Theme.of(context).colorScheme.error : null;
final textStyle = color == null ? null : TextStyle(color: color);
return ListTile(
leading: CenteredLeading(
Icon(Icons.monitor_heart_outlined, color: color),
),
title: Text('Push-Status', style: textStyle),
subtitle: Text(
error
? 'Ein Schritt in der Zustellkette ist unterbrochen'
: 'Registrierung und Zustellung im Detail',
style: textStyle,
),
trailing: Icon(Icons.arrow_right, color: color),
// The sheet can re-register; re-collect on close so the dot reflects it.
onTap: () async {
await showPushStatusSheet(context);
if (mounted) await _load();
},
);
}
}
@@ -20,11 +20,11 @@ import '../../../../widget/details_bottom_sheet.dart';
/// verbatim error), a manual re-register action and — once the chain is
/// operational — a test notification. Loads once on open; the refresh action
/// re-collects on demand (no polling).
void showPushStatusSheet(BuildContext context) {
Future<void> showPushStatusSheet(BuildContext context) {
// Captured here: the sheet outlives this build context's element tree.
final settings = context.read<SettingsCubit>();
final capabilities = context.read<CapabilitiesCubit>();
showDetailsBottomSheet(
return showDetailsBottomSheet(
context,
header: const ListTile(
leading: Icon(Icons.monitor_heart_outlined),
@@ -277,13 +277,22 @@ class _PushStatusBodyState extends State<_PushStatusBody>
Widget _stateIcon(PushCheck state, ThemeData theme) {
switch (state) {
case PushCheck.ok:
return const Icon(Icons.check_circle_outline, color: Colors.green);
return const Icon(
Icons.check_circle_outline,
color: Colors.green,
semanticLabel: 'In Ordnung',
);
case PushCheck.fail:
return Icon(Icons.cancel_outlined, color: theme.colorScheme.error);
return Icon(
Icons.cancel_outlined,
color: theme.colorScheme.error,
semanticLabel: 'Fehler',
);
case PushCheck.unknown:
return Icon(
Icons.remove_circle_outline,
color: theme.colorScheme.onSurfaceVariant,
semanticLabel: 'Unbekannt',
);
}
}
@@ -14,29 +14,39 @@ class SettingsCheckboxTile extends StatelessWidget {
final bool value;
final ValueChanged<bool> onChanged;
/// Optional widget rendered just before the checkbox (e.g. a status icon).
final Widget? beforeCheckbox;
const SettingsCheckboxTile({
required this.icon,
required this.title,
required this.value,
required this.onChanged,
this.subtitle,
this.beforeCheckbox,
super.key,
});
@override
Widget build(BuildContext context) {
final leadingIcon = Icon(icon);
final checkbox = Checkbox(
value: value,
onChanged: (e) {
Haptics.selection();
onChanged(e ?? false);
},
);
return ListTile(
leading: subtitle == null ? leadingIcon : CenteredLeading(leadingIcon),
title: Text(title),
subtitle: subtitle == null ? null : Text(subtitle!),
trailing: Checkbox(
value: value,
onChanged: (e) {
Haptics.selection();
onChanged(e ?? false);
},
),
trailing: beforeCheckbox == null
? checkbox
: Row(
mainAxisSize: MainAxisSize.min,
children: [beforeCheckbox!, checkbox],
),
);
}
}
@@ -76,6 +76,7 @@ class ShareChatPicker extends StatelessWidget {
actions: [
Builder(
builder: (ctx) => IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () {
final rooms = ctx.read<ChatListBloc>().state.data?.rooms;
@@ -166,13 +167,24 @@ Future<void> _afterExternalFilesUploaded(
GetRoomResponseObject room,
List<String> uploadedRemotePaths,
PendingShare share,
) async {
) => _runShareFlow(
context,
action: () =>
shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths),
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
);
/// Shared share-flow scaffolding: shows the blocking spinner, runs [action],
/// maps failures to an error dialog (popping the spinner first), and invokes
/// [onSuccess] on success while still mounted.
Future<void> _runShareFlow(
BuildContext context, {
required Future<void> Function() action,
required VoidCallback onSuccess,
}) async {
unawaited(_showBlockingSpinner(context));
try {
await shareFilesToChat(
token: room.token,
remoteFilePaths: uploadedRemotePaths,
);
await action();
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
@@ -186,7 +198,7 @@ Future<void> _afterExternalFilesUploaded(
return;
}
if (!context.mounted) return;
_setExternalDraftAndOpenChat(context, room, share);
onSuccess();
}
void _setExternalDraftAndOpenChat(
@@ -213,61 +225,30 @@ Future<void> _internalShareFlow(
BuildContext context,
GetRoomResponseObject room,
RemoteFileRef file,
) async {
unawaited(_showBlockingSpinner(context));
try {
await shareFilesToChat(
token: room.token,
remoteFilePaths: [file.path],
);
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
InfoDialog.show(
context,
errorToUserMessage(e),
title: 'Fehler',
copyable: true,
);
}
return;
}
if (!context.mounted) return;
_finishWithChat(context, room);
}
) => _runShareFlow(
context,
action: () =>
shareFilesToChat(token: room.token, remoteFilePaths: [file.path]),
onSuccess: () => _finishWithChat(context, room),
);
Future<void> _forwardMessageFlow(
BuildContext context,
GetRoomResponseObject room,
String? text,
RemoteFileRef? file,
) async {
unawaited(_showBlockingSpinner(context));
try {
) => _runShareFlow(
context,
action: () async {
if (file != null) {
await shareFilesToChat(
token: room.token,
remoteFilePaths: [file.path],
);
await shareFilesToChat(token: room.token, remoteFilePaths: [file.path]);
}
if (text != null && text.isNotEmpty) {
await SendMessage(room.token, SendMessageParams(text)).run();
}
} catch (e) {
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) {
InfoDialog.show(
context,
errorToUserMessage(e),
title: 'Fehler',
copyable: true,
);
}
return;
}
if (!context.mounted) return;
_finishWithChat(context, room);
}
},
onSuccess: () => _finishWithChat(context, room),
);
/// Modal progress overlay shown during share-API roundtrips. The dialog is
/// popped together with the picker by the subsequent popUntil(isFirst).
@@ -121,26 +121,15 @@ class ShareTargetPage extends StatelessWidget {
Widget _buildFilePreview(BuildContext context) {
if (share.filePaths.length == 1) {
final path = share.filePaths.first;
final name = path.split(Platform.pathSeparator).last;
return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: _isImagePath(path)
? Image.file(
File(path),
fit: BoxFit.contain,
// Decode at most ~1080px so 50-MP gallery photos don't
// balloon the decode buffer just to render at <320px high.
cacheWidth: 1080,
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
)
: _fileFallbackLarge(name),
// Decode at most ~1080px so 50-MP gallery photos don't balloon the
// decode buffer just to render at <320px high.
child: _filePreviewTile(
context,
share.filePaths.first,
fit: BoxFit.contain,
cacheWidth: 1080,
),
);
}
@@ -153,28 +142,38 @@ class ShareTargetPage extends StatelessWidget {
mainAxisSpacing: 10,
),
itemCount: share.filePaths.length,
itemBuilder: (context, i) {
final path = share.filePaths[i];
final name = path.split(Platform.pathSeparator).last;
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: _isImagePath(path)
? Image.file(
File(path),
fit: BoxFit.cover,
// Grid tiles are ~half-screen wide; 480px decode is
// sharp on 3x displays without blowing up memory when
// many files are shared at once.
cacheWidth: 480,
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
)
: _fileFallbackLarge(name),
);
},
// Grid tiles are ~half-screen wide; 480px decode is sharp on 3x displays
// without blowing up memory when many files are shared at once.
itemBuilder: (context, i) => _filePreviewTile(
context,
share.filePaths[i],
fit: BoxFit.cover,
cacheWidth: 480,
),
);
}
Widget _filePreviewTile(
BuildContext context,
String path, {
required BoxFit fit,
required int cacheWidth,
}) {
final name = path.split(Platform.pathSeparator).last;
return Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainer,
borderRadius: BorderRadius.circular(12),
),
clipBehavior: Clip.antiAlias,
child: _isImagePath(path)
? Image.file(
File(path),
fit: fit,
cacheWidth: cacheWidth,
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
)
: _fileFallbackLarge(name),
);
}
+1
View File
@@ -85,6 +85,7 @@ class _ChatListViewState extends State<_ChatListView> {
title: const Text('Talk'),
actions: [
IconButton(
tooltip: 'Suchen',
icon: const Icon(Icons.search),
onPressed: () {
final rooms = bloc.state.data?.rooms;
+7 -14
View File
@@ -110,10 +110,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
if (state.currentToken != widget.room.token) return;
final response = state.chatResponse;
if (response == null) return;
var maxId = 0;
for (final m in response.data) {
if (m.id > maxId) maxId = m.id;
}
final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
if (maxId == 0) return;
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
@@ -230,26 +227,24 @@ class _ChatViewState extends State<ChatView> with RouteAware {
? _searchQuery
: null;
final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0',
);
final messages = <Widget>[];
final chronologicalMatchIndex = <int, int>{};
var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) {
if (ChatSearchController.isHiddenSystemMessage(element)) continue;
final elementDate = DateTime.fromMillisecondsSinceEpoch(
element.timestamp * 1000,
);
if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) continue;
final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0',
);
if (!elementDate.isSameDay(lastDate)) {
lastDate = elementDate;
messages.add(
ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getDateDummy(element.timestamp),
chatData: widget.room,
@@ -269,7 +264,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.add(
ChatBubble(
context: context,
isSender:
element.actorId == widget.selfId &&
(element.messageType ==
@@ -291,7 +285,6 @@ class _ChatViewState extends State<ChatView> with RouteAware {
messages.insert(
0,
ChatBubble(
context: context,
isSender: false,
bubbleData: GetChatResponseObject.getTextDummy(
'Zurzeit können in dieser App nur die letzten 200 vergangenen Nachrichten angezeigt werden. '
@@ -4,18 +4,6 @@ import '../../../../theming/app_theme.dart';
import '../widgets/bubble.dart';
extension ColorExtensions on Color {
Color invert() {
final invertedR = 1.0 - r;
final invertedG = 1.0 - g;
final invertedB = 1.0 - b;
return Color.from(
alpha: a,
red: invertedR,
green: invertedG,
blue: invertedB,
);
}
Color withWhite(int whiteValue) {
final value = whiteValue / 255.0;
return Color.from(alpha: a, red: value, green: value, blue: value);
@@ -36,29 +24,23 @@ class ChatBubbleStyles {
alignment: Alignment.center,
);
BubbleStyle getRemoteStyle(bool seamless) {
var color = AppTheme.isDarkMode(context)
BubbleStyle getRemoteStyle() => BubbleStyle(
nip: BubbleNip.leftTop,
color: AppTheme.isDarkMode(context)
? const Color(0xff202c33)
: Colors.white;
return BubbleStyle(
nip: BubbleNip.leftTop,
color: seamless ? Colors.transparent : color,
elevation: seamless ? 0 : 1,
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
alignment: Alignment.topLeft,
);
}
: Colors.white,
elevation: 1,
margin: const BubbleEdges.only(bottom: 10, left: 10, right: 50),
alignment: Alignment.topLeft,
);
BubbleStyle getSelfStyle(bool seamless) {
var color = AppTheme.isDarkMode(context)
BubbleStyle getSelfStyle() => BubbleStyle(
nip: BubbleNip.rightBottom,
color: AppTheme.isDarkMode(context)
? const Color(0xff005c4b)
: const Color(0xffd3d3d3);
return BubbleStyle(
nip: BubbleNip.rightBottom,
color: seamless ? Colors.transparent : color,
elevation: seamless ? 0 : 1,
margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
alignment: Alignment.topRight,
);
}
: const Color(0xffd3d3d3),
elevation: 1,
margin: const BubbleEdges.only(bottom: 10, right: 10, left: 50),
alignment: Alignment.topRight,
);
}
@@ -16,8 +16,6 @@ class ChatMessage {
RichObjectString? file;
String content = '';
bool get containsFile => file != null;
ChatMessage({required this.originalMessage, this.originalData}) {
if (originalData?.containsKey('file') ?? false) {
file = originalData?['file'];
@@ -10,6 +10,14 @@ class ChatSearchMatch {
}
class ChatSearchController {
/// System messages that are folded into other bubbles (reactions, poll
/// votes, deletions) and therefore never rendered nor searched as their own
/// entry.
static bool isHiddenSystemMessage(GetChatResponseObject element) =>
element.systemMessage.contains('reaction') ||
element.systemMessage.contains('poll_voted') ||
element.systemMessage.contains('message_deleted');
static List<ChatSearchMatch> findMatches(
GetChatResponse response,
String query,
@@ -19,9 +27,7 @@ class ChatSearchController {
final matches = <ChatSearchMatch>[];
for (final element in response.sortByTimestamp()) {
if (element.systemMessage.contains('reaction')) continue;
if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) continue;
if (isHiddenSystemMessage(element)) continue;
final haystackText = RichObjectStringProcessor.parseToString(
element.message,
+5 -1
View File
@@ -27,7 +27,11 @@ class JoinChat extends SearchDelegate<String> {
},
),
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
];
@override
+5 -1
View File
@@ -25,7 +25,11 @@ class SearchChat extends SearchDelegate<GetRoomResponseObject?> {
@override
List<Widget>? buildActions(BuildContext context) => [
if (query.isNotEmpty)
IconButton(onPressed: () => query = '', icon: const Icon(Icons.clear)),
IconButton(
tooltip: 'Leeren',
onPressed: () => query = '',
icon: const Icon(Icons.clear),
),
];
@override

Some files were not shown because too many files have changed in this diff Show More