Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e625216a90 | |||
| 8128cede21 | |||
| e5f7cf0176 | |||
| e349d667d4 | |||
| 7c1f5c06df | |||
| e8c6ac1c65 | |||
| 478f0ff20b | |||
| 8274dd46cd | |||
| d7536ea5d0 | |||
| 398b147c76 |
@@ -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 +0,0 @@
|
||||
class ApiRequest {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
-6
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+5
-10
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-6
@@ -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);
|
||||
});
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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,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');
|
||||
}
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+15
-10
@@ -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!');
|
||||
|
||||
@@ -248,6 +248,7 @@ class AppModule {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: isVisible ? 'Modul ausblenden' : 'Modul einblenden',
|
||||
onPressed: onVisibleChange,
|
||||
icon: Icon(
|
||||
isVisible
|
||||
|
||||
@@ -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)));
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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 = '';
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user