simplified code across the app and consolidated duplicated helpers

This commit is contained in:
2026-09-27 17:29:54 +02:00
parent 3e856d0b1c
commit ee1240f71a
155 changed files with 1772 additions and 2546 deletions
+2 -12
View File
@@ -10,9 +10,7 @@ import '../api_error.dart';
import '../http_errors.dart'; import '../http_errors.dart';
import '../marianumcloud/talk/talk_error.dart'; import '../marianumcloud/talk/talk_error.dart';
import 'app_exception.dart'; import 'app_exception.dart';
import 'auth_exception.dart';
import 'network_exception.dart'; import 'network_exception.dart';
import 'not_found_exception.dart';
import 'parse_exception.dart'; import 'parse_exception.dart';
import 'server_exception.dart'; import 'server_exception.dart';
import 'talk_exception.dart'; import 'talk_exception.dart';
@@ -65,23 +63,15 @@ AppException _dynamiteToAppException(DynamiteApiException error) {
final detail = preview.isEmpty final detail = preview.isEmpty
? 'HTTP $status' ? 'HTTP $status'
: 'HTTP $status body=$preview'; : 'HTTP $status body=$preview';
switch (status) { if (status == 429) {
case 401:
return AuthException.unauthorized(technicalDetails: detail);
case 403:
return AuthException.forbidden(technicalDetails: detail);
case 404:
return NotFoundException(technicalDetails: detail);
case 429:
return ServerException( return ServerException(
statusCode: status, statusCode: status,
userMessage: userMessage:
'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.', 'Zu viele Anfragen. Bitte warte einen Moment, bevor du es erneut versuchst.',
technicalDetails: detail, technicalDetails: detail,
); );
default:
return ServerException(statusCode: status, technicalDetails: detail);
} }
return exceptionForStatus(status, detail);
} }
String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) { String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
+20 -8
View File
@@ -3,6 +3,7 @@ import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'errors/app_exception.dart';
import 'errors/auth_exception.dart'; import 'errors/auth_exception.dart';
import 'errors/network_exception.dart'; import 'errors/network_exception.dart';
import 'errors/not_found_exception.dart'; import 'errors/not_found_exception.dart';
@@ -42,12 +43,23 @@ String httpErrorDetail(String label, String body, int status) {
: '$label -> HTTP $status body=$preview'; : '$label -> HTTP $status body=$preview';
} }
/// Throws the [AppException] matching a non-2xx HTTP [status], carrying /// The [AppException] matching a non-2xx HTTP [status], carrying [detail] as
/// [detail] as technical details: 401/403 map to auth errors, 404 to /// technical details: 401/403 map to auth errors, 404 to not-found, everything
/// not-found, everything else to a generic server error. /// else to a generic server error.
Never throwForStatus(int status, String detail) { AppException exceptionForStatus(int status, String detail) => switch (status) {
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail); 401 => AuthException.unauthorized(technicalDetails: detail),
if (status == 403) throw AuthException.forbidden(technicalDetails: detail); 403 => AuthException.forbidden(technicalDetails: detail),
if (status == 404) throw NotFoundException(technicalDetails: detail); 404 => NotFoundException(technicalDetails: detail),
throw ServerException(statusCode: status, technicalDetails: detail); _ => ServerException(statusCode: status, technicalDetails: detail),
};
/// Throws [exceptionForStatus].
Never throwForStatus(int status, String detail) =>
throw exceptionForStatus(status, detail);
/// Throws [exceptionForStatus] unless [response] is 2xx.
void ensureSuccess(String label, http.Response response) {
final status = response.statusCode;
if (status >= 200 && status < 300) return;
throwForStatus(status, httpErrorDetail(label, response.body, status));
} }
@@ -18,13 +18,7 @@ class DeleteAppPassword {
NextcloudOcs.uri('core/apppassword'), NextcloudOcs.uri('core/apppassword'),
// An explicit header may belong to an inactive account, so the active // An explicit header may belong to an inactive account, so the active
// session's headers must not be required then. // session's headers must not be required then.
headers: authorizationHeader == null headers: NextcloudOcs.headers(authorization: authorizationHeader),
? NextcloudOcs.headers()
: {
'Accept': 'application/json',
'OCS-APIRequest': 'true',
'Authorization': authorizationHeader,
},
); );
} }
} }
@@ -28,23 +28,17 @@ class GetAppPassword {
label, label,
() => _client.get( () => _client.get(
NextcloudOcs.uri('core/getapppassword'), NextcloudOcs.uri('core/getapppassword'),
headers: {
...NextcloudOcs.headers(),
// Deliberately NOT the shared Authorization value: that one prefers // Deliberately NOT the shared Authorization value: that one prefers
// the app password, but an app password cannot mint another one — // the app password, but an app password cannot mint another one —
// this endpoint requires the real password. // this endpoint requires the real password.
'Authorization': SessionManager() headers: NextcloudOcs.headers(
authorization: SessionManager()
.requireNextcloud() .requireNextcloud()
.realPasswordBasicAuthHeader, .realPasswordBasicAuthHeader,
}, ),
), ),
))!; ))!;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
final json = jsonDecode(utf8.decode(response.bodyBytes)); final json = jsonDecode(utf8.decode(response.bodyBytes));
final data = (json as Map)['ocs']?['data']; final data = (json as Map)['ocs']?['data'];
final appPassword = data is Map ? data['apppassword'] as String? : null; final appPassword = data is Map ? data['apppassword'] as String? : null;
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:typed_data'; import 'dart:typed_data';
@@ -96,9 +95,8 @@ class GetUserInfo {
uri, uri,
); );
try { try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data = final data =
(root['ocs'] as Map<String, dynamic>)['data'] as Map<String, dynamic>; NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
return CloudUserInfo( return CloudUserInfo(
userId: data['id'] as String, userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '', displayName: (data['displayname'] as String?) ?? '',
@@ -29,12 +29,7 @@ class LoginFlowApi {
headers: {'Accept': 'application/json', 'User-Agent': userAgent}, headers: {'Accept': 'application/json', 'User-Agent': userAgent},
), ),
))!; ))!;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowInit.fromJson( return LoginFlowInit.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
@@ -53,12 +48,7 @@ class LoginFlowApi {
), ),
))!; ))!;
if (response.statusCode == 404) return null; if (response.statusCode == 404) return null;
if (response.statusCode < 200 || response.statusCode >= 300) { ensureSuccess(label, response);
throwForStatus(
response.statusCode,
httpErrorDetail(label, response.body, response.statusCode),
);
}
return LoginFlowCredentials.fromJson( return LoginFlowCredentials.fromJson(
jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>, jsonDecode(utf8.decode(response.bodyBytes)) as Map<String, dynamic>,
); );
+5 -2
View File
@@ -13,10 +13,13 @@ class NextcloudOcs {
static Map<String, dynamic> decode(String raw) => static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>; (jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
static Map<String, String> headers() => { /// [authorization] overrides the active session's credentials, which are
/// then not required.
static Map<String, String> headers({String? authorization}) => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
'Authorization': SessionManager().requireNextcloud().basicAuthHeader, 'Authorization':
authorization ?? SessionManager().requireNextcloud().basicAuthHeader,
}; };
static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) { static Uri uri(String pathSuffix, {Map<String, dynamic>? queryParameters}) {
@@ -0,0 +1,46 @@
import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart';
import 'get_chat_response.dart';
/// GETs a chat page outside [TalkApi], which treats non-2xx as errors: here
/// HTTP 304 (nothing new / no older messages) is a normal outcome and yields
/// `null`.
Future<GetChatResponse?> fetchChatPage({
required String label,
required String chatToken,
required GetChatParams params,
Duration? timeout,
bool parseOffIsolate = false,
}) async {
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded('$label $uri', () {
final request = http.get(uri, headers: headers);
return timeout == null ? request : request.timeout(timeout);
}))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
final parsed = parseOffIsolate
? await compute(_parseChatResponse, response.body)
: _parseChatResponse(response.body);
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: '$label $uri: HTTP $status',
);
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -1,6 +1,3 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
@@ -16,11 +13,4 @@ class GetChat extends TalkApi<GetChatResponse> {
@override @override
GetChatResponse assemble(String raw) => GetChatResponse assemble(String raw) =>
GetChatResponse.fromJson(NextcloudOcs.decode(raw)); GetChatResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,17 +1,12 @@
import 'package:flutter/foundation.dart'; import 'fetch_chat_page.dart';
import 'package:http/http.dart' as http;
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`) /// Backwards-paging variant of GetChat (`lookIntoFuture=0` + `lastKnownMessageId`)
/// that fetches the page of messages *older* than a given id. Bypasses [TalkApi] /// that fetches the page of messages *older* than a given id. The server
/// because that layer treats non-2xx as errors, and the server answers HTTP 304 /// answers HTTP 304 when there are no older messages left — a normal "start of
/// when there are no older messages left — a normal "start of chat" outcome here. /// chat" outcome (see [fetchChatPage]). `setReadMarker=off` so paging into
/// `setReadMarker=off` so paging into history never moves the read cursor. /// history never moves the read cursor.
class GetChatHistory { class GetChatHistory {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -24,39 +19,18 @@ class GetChatHistory {
}); });
/// Returns the older page, or `null` on HTTP 304 (no older messages). /// Returns the older page, or `null` on HTTP 304 (no older messages).
Future<GetChatResponse?> run() async { Future<GetChatResponse?> run() => fetchChatPage(
final params = GetChatParams( label: 'GetChatHistory',
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.off, lookIntoFuture: GetChatParamsSwitch.off,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.off, setReadMarker: GetChatParamsSwitch.off,
limit: limit, limit: limit,
); ),
final uri = NextcloudOcs.uri(
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'GetChatHistory $uri',
() => http.get(uri, headers: headers),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
// A page holds up to 200 messages and lands while the user is scrolling; // A page holds up to 200 messages and lands while the user is scrolling;
// decoding it on the UI isolate stalls the fling. // decoding it on the UI isolate stalls the fling.
final parsed = await compute(_parseChatResponse, response.body); parseOffIsolate: true,
return parsed..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'GetChatHistory $uri: HTTP $status',
); );
} }
}
GetChatResponse _parseChatResponse(String body) =>
GetChatResponse.fromJson(NextcloudOcs.decode(body));
@@ -1,15 +1,10 @@
import 'package:http/http.dart' as http; import 'fetch_chat_page.dart';
import '../../../errors/server_exception.dart';
import '../../../http_errors.dart';
import '../../nextcloud_ocs.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
/// Long-poll variant of GetChat (`lookIntoFuture=1`). Bypasses [TalkApi] /// Long-poll variant of GetChat (`lookIntoFuture=1`); HTTP 304 is the normal
/// because that layer treats non-2xx as errors, and we need 304 to be a /// "no new messages" outcome (see [fetchChatPage]). `setReadMarker=on` lets
/// normal "no new messages" outcome. `setReadMarker=on` lets the server /// the server move the read cursor whenever the call returns messages.
/// move the read cursor whenever the call returns messages.
class LongPollChat { class LongPollChat {
final String chatToken; final String chatToken;
final int lastKnownMessageId; final int lastKnownMessageId;
@@ -22,37 +17,17 @@ class LongPollChat {
}); });
/// Returns the response, or `null` on HTTP 304 (server timeout, nothing new). /// Returns the response, or `null` on HTTP 304 (server timeout, nothing new).
Future<GetChatResponse?> run() async { Future<GetChatResponse?> run() => fetchChatPage(
final params = GetChatParams( label: 'LongPollChat',
chatToken: chatToken,
params: GetChatParams(
lookIntoFuture: GetChatParamsSwitch.on, lookIntoFuture: GetChatParamsSwitch.on,
timeout: timeoutSeconds, timeout: timeoutSeconds,
lastKnownMessageId: lastKnownMessageId, lastKnownMessageId: lastKnownMessageId,
includeLastKnown: GetChatParamsSwitch.off, includeLastKnown: GetChatParamsSwitch.off,
setReadMarker: GetChatParamsSwitch.on, setReadMarker: GetChatParamsSwitch.on,
limit: 100, limit: 100,
); ),
final uri = NextcloudOcs.uri( timeout: Duration(seconds: timeoutSeconds + 15),
'apps/spreed/api/v1/chat/$chatToken',
queryParameters: params.toJson(),
);
final headers = NextcloudOcs.headers();
final response = (await sendGuarded(
'LongPollChat $uri',
() => http
.get(uri, headers: headers)
.timeout(Duration(seconds: timeoutSeconds + 15)),
))!;
final status = response.statusCode;
if (status == 304) return null;
if (status >= 200 && status < 300) {
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
..headers = response.headers;
}
throw ServerException(
statusCode: status,
technicalDetails: 'LongPollChat $uri: HTTP $status',
); );
} }
}
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_participants_response.dart'; import 'get_participants_response.dart';
@@ -11,11 +9,4 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
@override @override
GetParticipantsResponse assemble(String raw) => GetParticipantsResponse assemble(String raw) =>
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw)); GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_poll_state_response.dart'; import 'get_poll_state_response.dart';
@@ -13,11 +11,4 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
@override @override
GetPollStateResponse assemble(String raw) => GetPollStateResponse assemble(String raw) =>
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,7 +1,3 @@
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../../../api_params.dart';
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_reactions_response.dart'; import 'get_reactions_response.dart';
@@ -15,11 +11,4 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
@override @override
GetReactionsResponse assemble(String raw) => GetReactionsResponse assemble(String raw) =>
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw)); GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
@override
Future<Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_shared_items_response.dart'; import 'get_shared_items_response.dart';
@@ -30,11 +28,4 @@ class GetSharedItems extends TalkApi<GetSharedItemsResponse> {
@override @override
GetSharedItemsResponse assemble(String raw) => GetSharedItemsResponse assemble(String raw) =>
GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,5 +1,3 @@
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_shared_items_overview_response.dart'; import 'get_shared_items_overview_response.dart';
@@ -18,11 +16,4 @@ class GetSharedItemsOverview extends TalkApi<GetSharedItemsOverviewResponse> {
@override @override
GetSharedItemsOverviewResponse assemble(String raw) => GetSharedItemsOverviewResponse assemble(String raw) =>
GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw)); GetSharedItemsOverviewResponse.fromOcs(NextcloudOcs.decode(raw));
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
@@ -1,5 +1,4 @@
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart'; import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
@@ -17,13 +16,6 @@ class GetRoom extends TalkApi<GetRoomResponse> {
@override @override
Future<GetRoomResponse> assembleAsync(String raw) => Future<GetRoomResponse> assembleAsync(String raw) =>
compute(_parseRooms, raw); compute(_parseRooms, raw);
@override
Future<http.Response> request(
Uri uri,
Object? body,
Map<String, String>? headers,
) => http.get(uri, headers: headers);
} }
GetRoomResponse _parseRooms(String raw) => GetRoomResponse _parseRooms(String raw) =>
+2 -1
View File
@@ -23,11 +23,12 @@ abstract class TalkApi<T extends ApiResponse?> {
TalkApi(this.path, this.body, {this.headers, this.getParameters}); TalkApi(this.path, this.body, {this.headers, this.getParameters});
/// Defaults to a plain GET; endpoints with a body or another verb override it.
Future<http.Response>? request( Future<http.Response>? request(
Uri uri, Uri uri,
ApiParams? body, ApiParams? body,
Map<String, String>? headers, Map<String, String>? headers,
); ) => http.get(uri, headers: headers);
T assemble(String raw); T assemble(String raw);
/// Override to parse large payloads off the UI isolate (e.g. via `compute` /// Override to parse large payloads off the UI isolate (e.g. via `compute`
@@ -81,8 +81,8 @@ class MarianumConnectTokenStorage {
} }
Future<void> clear() async { Future<void> clear() async {
await _storage.delete(key: _tokenKey); for (final key in fieldKeys) {
await _storage.delete(key: _tokenIdKey); await _storage.delete(key: key);
await _storage.delete(key: _expiresAtKey); }
} }
} }
@@ -0,0 +1,8 @@
import 'dart:io';
/// Platform wire value MarianumConnect expects (`android`/`ios`), null elsewhere.
String? clientPlatform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
@@ -2,8 +2,8 @@ import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../../errors/app_exception.dart';
import '../../../errors/ticker_content_unavailable_exception.dart'; import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'get_ticker_page_response.dart'; import 'get_ticker_page_response.dart';
@@ -14,26 +14,20 @@ import 'get_ticker_page_response.dart';
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped /// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser /// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a /// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error. The bespoke 404 handling is why this keeps its own try/catch /// generic error.
/// instead of the base [guard].
class GetTickerPage extends MarianumConnectQuery { class GetTickerPage extends MarianumConnectQuery {
final String slug; final String slug;
GetTickerPage(this.slug, {super.dio}); GetTickerPage(this.slug, {super.dio});
Future<TickerPageResponse> run() async { Future<TickerPageResponse> run() => getObject(
try { 'ticker/pages/${Uri.encodeComponent(slug)}',
final response = await dio.get<Map<String, dynamic>>( TickerPageResponse.fromJson,
endpoint('ticker/pages/${Uri.encodeComponent(slug)}'),
); );
return TickerPageResponse.fromJson(response.data!);
} on DioException catch (e) {
throw _mapError(e);
}
}
Object _mapError(DioException e) { @override
final response = e.response; AppException mapError(DioException error) {
final response = error.response;
if (response?.statusCode == 404) { if (response?.statusCode == 404) {
final body = _asMap(response?.data); final body = _asMap(response?.data);
if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') { if (body != null && body['error'] == 'CONTENT_UNAVAILABLE') {
@@ -44,7 +38,7 @@ class GetTickerPage extends MarianumConnectQuery {
); );
} }
} }
return mapMarianumConnectError(e); return super.mapError(error);
} }
Map<String, dynamic>? _asMap(dynamic data) { Map<String, dynamic>? _asMap(dynamic data) {
@@ -1,10 +1,10 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import 'report_client_error.dart'; import 'report_client_error.dart';
/// Fire-and-forget bridge from the app's global error handlers to the /// Fire-and-forget bridge from the app's global error handlers to the
@@ -61,7 +61,7 @@ class ClientErrorReporter {
message: message, message: message,
stacktrace: stacktrace, stacktrace: stacktrace,
context: context, context: context,
platform: _platform(), platform: clientPlatform(),
appVersion: _appVersion, appVersion: _appVersion,
); );
} catch (e) { } catch (e) {
@@ -73,10 +73,4 @@ class ClientErrorReporter {
final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#'); final normalized = message.toLowerCase().replaceAll(RegExp(r'\d+'), '#');
return '$errorType|$normalized|${context ?? ''}'; return '$errorType|$normalized|${context ?? ''}';
} }
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
} }
@@ -5,6 +5,7 @@ import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../client_platform.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth). /// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
@@ -27,7 +28,7 @@ class SubmitFeedback extends MarianumConnectQuery {
'message': message, 'message': message,
'screenshot': ?screenshotBase64, 'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null, 'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': _platform(), 'platform': clientPlatform(),
'appVersion': package.version, 'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber), 'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(), 'deviceModel': await _deviceModel(),
@@ -35,12 +36,6 @@ class SubmitFeedback extends MarianumConnectQuery {
); );
}); });
static String? _platform() {
if (Platform.isAndroid) return 'android';
if (Platform.isIOS) return 'ios';
return null;
}
static Future<String?> _deviceModel() async { static Future<String?> _deviceModel() async {
try { try {
final info = DeviceInfoPlugin(); final info = DeviceInfoPlugin();
+30 -35
View File
@@ -80,6 +80,25 @@ class _AppState extends State<App> with WidgetsBindingObserver {
} }
} }
/// Shares one debounce key, so a later emit supersedes a pending publish.
void _scheduleWidgetPublish(TimetableState data, Duration delay) {
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
final epoch = SessionManager().sessionEpoch;
Debouncer.debounce(
'widgetPublish',
delay,
() => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
}
void _reportTelemetry() { void _reportTelemetry() {
if (!mounted) return; if (!mounted) return;
TelemetryHeartbeat.report( TelemetryHeartbeat.report(
@@ -125,13 +144,18 @@ class _AppState extends State<App> with WidgetsBindingObserver {
final pending = await WidgetNavigation.consumePendingTimetableTap(); final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return; if (!pending || !mounted) return;
// `withNavBar: false` routes sit on the root navigator above the // `withNavBar: false` routes sit on the root navigator above the
// bottom-nav; pop them so jumpToTab is actually visible. Stop at // bottom-nav; pop them so jumpToTab is actually visible.
// popups so open dialogs/sheets stay alive. _popToShell();
AppRoutes.goToTab(context, Modules.timetable);
}
/// Pops full-page routes back to the tab shell, stopping at popups so open
/// dialogs/sheets stay alive.
void _popToShell() {
final navigator = Navigator.of(context); final navigator = Navigator.of(context);
if (navigator.canPop()) { if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute); navigator.popUntil((route) => route.isFirst || route is PopupRoute);
} }
AppRoutes.goToTab(context, Modules.timetable);
} }
void _handlePendingShare() { void _handlePendingShare() {
@@ -140,10 +164,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (share == null || ShareIntentListener.instance.isShown(share)) return; if (share == null || ShareIntentListener.instance.isShown(share)) return;
// A second share would otherwise leave the previous share-flow page // A second share would otherwise leave the previous share-flow page
// on top with stale (already-cleared) file paths. // on top with stale (already-cleared) file paths.
final navigator = Navigator.of(context); _popToShell();
if (navigator.canPop()) {
navigator.popUntil((route) => route.isFirst || route is PopupRoute);
}
ShareIntentListener.instance.markShown(share); ShareIntentListener.instance.markShown(share);
AppRoutes.openShareTarget(context, share); AppRoutes.openShareTarget(context, share);
} }
@@ -166,27 +187,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
timetable.refresh(); timetable.refresh();
// Mirror BLoC updates into the home-screen widget without waiting // Mirror BLoC updates into the home-screen widget without waiting
// for the periodic background refresh. // for the periodic background refresh.
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
_timetableWidgetSync?.cancel(); _timetableWidgetSync?.cancel();
// Debounced: a refresh or week swipe emits several times in a row, and // Debounced: a refresh or week swipe emits several times in a row, and
// only the settled state is worth mirroring. // only the settled state is worth mirroring.
_timetableWidgetSync = timetable.stream.listen((state) { _timetableWidgetSync = timetable.stream.listen((state) {
final data = state.data; final data = state.data;
if (data is TimetableState && !state.isLoading) { if (data is TimetableState && !state.isLoading) {
final epoch = SessionManager().sessionEpoch; _scheduleWidgetPublish(data, const Duration(seconds: 1));
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 1),
() => unawaited(
WidgetPublisher.publishFromBlocState(
data,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
} }
}); });
// Initial publish in case hydrated storage already has data. The widget // Initial publish in case hydrated storage already has data. The widget
@@ -194,19 +201,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
// frames are done (a fresh bloc emit in the meantime supersedes it). // frames are done (a fresh bloc emit in the meantime supersedes it).
final initialData = timetable.state.data; final initialData = timetable.state.data;
if (initialData is TimetableState) { if (initialData is TimetableState) {
final epoch = SessionManager().sessionEpoch; _scheduleWidgetPublish(initialData, const Duration(seconds: 3));
Debouncer.debounce(
'widgetPublish',
const Duration(seconds: 3),
() => unawaited(
WidgetPublisher.publishFromBlocState(
initialData,
settings: settingsCubit.val(),
isTeacher: capabilitiesCubit.isTeacher,
epoch: epoch,
),
),
);
} }
unawaited(_handlePendingWidgetNavigation()); unawaited(_handlePendingWidgetNavigation());
ShareIntentListener.pending.addListener(_handlePendingShare); ShareIntentListener.pending.addListener(_handlePendingShare);
+5 -27
View File
@@ -17,7 +17,6 @@ import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_time
import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart'; import '../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart'; import '../api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart';
import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart'; import '../api/mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../session/session.dart';
import '../session/session_manager.dart'; import '../session/session_manager.dart';
import '../widget_data/widget_data_mapper.dart'; import '../widget_data/widget_data_mapper.dart';
import '../widget_data/widget_publisher.dart'; import '../widget_data/widget_publisher.dart';
@@ -81,16 +80,12 @@ class WidgetBackgroundTask {
/// Throws on fetch failure so the worker path can signal a retry. /// Throws on fetch failure so the worker path can signal a retry.
static Future<void> runRefreshNow({bool force = false}) async { static Future<void> runRefreshNow({bool force = false}) async {
await WidgetSync.ensureInitialized(); await WidgetSync.ensureInitialized();
Session? session;
try {
// Bounded: a hanging keystore read must not stall the caller's budget // Bounded: a hanging keystore read must not stall the caller's budget
// (FCM handler ~25s on iOS) forever. // (FCM handler ~25s on iOS) forever.
session = await SessionManager().waitForLoad().timeout( final session = await SessionManager().waitForLoad().timeout(
const Duration(seconds: 10), const Duration(seconds: 10),
onTimeout: () => null,
); );
} on TimeoutException {
session = null;
}
if (session == null) { if (session == null) {
// Deliberately does NOT flip the widget to logged-out: a failed or slow // Deliberately does NOT flip the widget to logged-out: a failed or slow
// keychain read (locked iOS device during the 06:00 silent push) is // keychain read (locked iOS device during the 06:00 silent push) is
@@ -143,7 +138,6 @@ void _callbackDispatcher() {
} }
Future<void> _refresh() async { Future<void> _refresh() async {
await WidgetSync.ensureInitialized();
// The background isolate doesn't go through main.dart's BlocBuilder, so we // The background isolate doesn't go through main.dart's BlocBuilder, so we
// re-apply the endpoint the foreground last persisted. Without this the // re-apply the endpoint the foreground last persisted. Without this the
// dio singleton would fall back to its hardcoded live default even when // dio singleton would fall back to its hardcoded live default even when
@@ -191,26 +185,13 @@ Future<void> _refresh() async {
final timegrid = await timegridFuture; final timegrid = await timegridFuture;
final customEvents = await customEventsFuture; final customEvents = await customEventsFuture;
final lessons = timetable.entries;
final [connectDouble, isTeacher] = await Future.wait([ final [connectDouble, isTeacher] = await Future.wait([
WidgetSync.getConnectDoubleLessons(), WidgetSync.getConnectDoubleLessons(),
WidgetSync.getIsTeacher(), WidgetSync.getIsTeacher(),
]); ]);
final dayData = WidgetDataMapper.buildDayData( final (day: dayData, week: weekData) = WidgetDataMapper.buildSnapshot(
now: now, now: now,
lessons: lessons, lessons: timetable.entries,
subjects: subjects,
rooms: rooms,
holidays: holidays,
timegrid: timegrid,
customEvents: customEvents,
connectDoubleLessons: connectDouble,
showClassInsteadOfTeacher: isTeacher,
);
final weekData = WidgetDataMapper.buildWeekData(
now: now,
lessons: lessons,
subjects: subjects, subjects: subjects,
rooms: rooms, rooms: rooms,
holidays: holidays, holidays: holidays,
@@ -220,10 +201,7 @@ Future<void> _refresh() async {
showClassInsteadOfTeacher: isTeacher, showClassInsteadOfTeacher: isTeacher,
); );
await WidgetSync.writeDayData(dayData); await WidgetSync.publishSnapshot(day: dayData, week: weekData);
await WidgetSync.writeWeekData(weekData);
await WidgetSync.setLoggedIn(true);
await WidgetSync.triggerUpdate();
log( log(
'[widget-bg] refreshed: day=${dayData.lessons.length} ' '[widget-bg] refreshed: day=${dayData.lessons.length} '
'week=${weekData.lessons.length}', 'week=${weekData.lessons.length}',
+1 -3
View File
@@ -72,9 +72,7 @@ extension DateTimeFormatting on DateTime {
String formatDateRelativeShort({DateTime? now}) { String formatDateRelativeShort({DateTime? now}) {
final reference = now ?? DateTime.now(); final reference = now ?? DateTime.now();
final today = DateTime(reference.year, reference.month, reference.day); final diff = reference.dateOnly.difference(dateOnly).inDays;
final self = DateTime(year, month, day);
final diff = today.difference(self).inDays;
if (diff == 0) return 'Heute'; if (diff == 0) return 'Heute';
if (diff == 1) return 'Gestern'; if (diff == 1) return 'Gestern';
@@ -15,7 +15,7 @@ import '../widget/info_dialog.dart';
import 'notification_tasks.dart'; import 'notification_tasks.dart';
/// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are /// Bridges FCM lifecycle callbacks to the push pipeline. Background messages are
/// handled directly by [PushMessageHandler.onBackgroundMessage]; this class /// handled directly by `pushOnBackgroundMessage`; this class
/// covers the foreground and app-opened paths where a [BuildContext] is /// covers the foreground and app-opened paths where a [BuildContext] is
/// available. /// available.
class NotificationController { class NotificationController {
+1 -5
View File
@@ -27,17 +27,13 @@ class NotificationTasks {
} }
} }
/// Per-chat tag scheme — canonical definition lives beside the stacked
/// notification id in chat_thread_store.dart.
static String chatTag(String chatToken) => chatNotificationTag(chatToken);
/// Removes tray notifications belonging to [chatToken]. Eraser handles /// Removes tray notifications belonging to [chatToken]. Eraser handles
/// iOS (where the plugin's `getActiveNotifications` returns null ids /// iOS (where the plugin's `getActiveNotifications` returns null ids
/// for FCM posts and can't cancel them); the local-notifications sweep /// for FCM posts and can't cancel them); the local-notifications sweep
/// handles Android and acts as a fallback while Eraser's native side /// handles Android and acts as a fallback while Eraser's native side
/// isn't built in yet. /// isn't built in yet.
static Future<void> clearNotificationsForChat(String chatToken) async { static Future<void> clearNotificationsForChat(String chatToken) async {
final tag = chatTag(chatToken); final tag = chatNotificationTag(chatToken);
try { try {
await Eraser.clearAppNotificationsByTag(tag); await Eraser.clearAppNotificationsByTag(tag);
} on MissingPluginException { } on MissingPluginException {
+11 -18
View File
@@ -6,13 +6,20 @@ import 'package:localstore/localstore.dart';
/// notification, small enough to stay cheap in the background isolate. /// notification, small enough to stay cheap in the background isolate.
const int kChatThreadCap = 15; const int kChatThreadCap = 15;
/// Canonical per-chat notification tag (kept in sync with /// Canonical per-chat notification tag.
/// `NotificationTasks.chatTag`, which delegates here).
String chatNotificationTag(String chatToken) => 'talk_$chatToken'; String chatNotificationTag(String chatToken) => 'talk_$chatToken';
/// Deterministic non-negative 31-bit notification id per chat, so every /// Deterministic non-negative 31-bit notification id per chat, so every
/// message of the same conversation updates ONE stacked notification instead /// message of the same conversation updates ONE stacked notification instead
/// of adding a new card per push. /// of adding a new card per push.
final _fileSafeToken = RegExp(r'^[A-Za-z0-9_-]+$');
/// File-/doc-safe name for a chat token: normally URL-safe and used verbatim,
/// anything else is base64url-encoded so exotic ids can't escape a directory.
String fileSafeToken(String chatToken) => _fileSafeToken.hasMatch(chatToken)
? chatToken
: base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
int stableChatNotificationId(String chatToken) { int stableChatNotificationId(String chatToken) {
var hash = 0; var hash = 0;
for (final unit in chatToken.codeUnits) { for (final unit in chatToken.codeUnits) {
@@ -128,12 +135,8 @@ class ChatThreadStore {
ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance; ChatThreadStore({Localstore? db}) : _db = db ?? Localstore.instance;
/// Localstore doc ids become file names — tokens are normally URL-safe and /// Localstore doc ids become file names.
/// used verbatim, anything else is base64url-encoded. static String docIdForToken(String chatToken) => fileSafeToken(chatToken);
static String docIdForToken(String chatToken) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(chatToken)
? chatToken
: base64Url.encode(utf8.encode(chatToken)).replaceAll('=', '');
Future<List<ThreadMessage>> messages(String chatToken) async { Future<List<ThreadMessage>> messages(String chatToken) async {
final data = await _doc(chatToken).get(); final data = await _doc(chatToken).get();
@@ -145,16 +148,6 @@ class ChatThreadStore {
.toList(); .toList();
} }
/// Appends [message] and returns the updated (capped) history.
Future<List<ThreadMessage>> append(
String chatToken,
ThreadMessage message,
) async {
final updated = appendThreadMessage(await messages(chatToken), message);
await _write(chatToken, updated);
return updated;
}
/// Appends [message], but restarts the thread first when the chat's previous /// Appends [message], but restarts the thread first when the chat's previous
/// notification is no longer active ([isActive] == false — dismissed/read). /// notification is no longer active ([isActive] == false — dismissed/read).
/// See [threadAfterIncoming] for the tri-state semantics. /// See [threadAfterIncoming] for the tri-state semantics.
+4 -13
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'dart:ui'; import 'dart:ui';
@@ -13,6 +12,7 @@ import '../session/session_manager.dart';
import 'chat_thread_store.dart'; import 'chat_thread_store.dart';
import 'nid_store.dart'; import 'nid_store.dart';
import 'push_renderer.dart'; import 'push_renderer.dart';
import 'push_target.dart';
/// Notification action identifiers shared between the renderer (which attaches /// Notification action identifiers shared between the renderer (which attaches
/// the actions) and the response handlers (which dispatch them). /// the actions) and the response handlers (which dispatch them).
@@ -93,8 +93,7 @@ class PushActions {
// the mark-read action does. Best effort: a failure here must not // the mark-read action does. Best effort: a failure here must not
// fail the already-delivered reply. // fail the already-delivered reply.
await markRead(chatToken); await markRead(chatToken);
} } else {
if (!result.ok) {
// Never swallow the typed message: surface the failure (and the // Never swallow the typed message: surface the failure (and the
// undelivered text) as its own notification. The nid mapping stays // undelivered text) as its own notification. The nid mapping stays
// alive — the thread notification is still in the tray. // alive — the thread notification is still in the tray.
@@ -287,14 +286,6 @@ class PushActions {
return raw == null ? null : int.tryParse(raw); return raw == null ? null : int.tryParse(raw);
} }
static String? _payloadField(String? payload, String key) { static String? _payloadField(String? payload, String key) =>
if (payload == null || payload.isEmpty) return null; decodeNotificationPayload(payload)?[key]?.toString();
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final value = map[key];
return value?.toString();
} on Object {
return null;
}
}
} }
+2 -7
View File
@@ -9,6 +9,7 @@ import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
import '../api/marianumcloud/nextcloud_ocs.dart'; import '../api/marianumcloud/nextcloud_ocs.dart';
import 'chat_thread_store.dart';
/// Result of an avatar lookup at render time: [icon] when bytes are available /// Result of an avatar lookup at render time: [icon] when bytes are available
/// within the fetch budget, otherwise [late] carries the still-running fetch /// within the fetch budget, otherwise [late] carries the still-running fetch
@@ -102,13 +103,7 @@ class PushAvatarStore {
}, },
); );
/// File-safe cache name for a chat token. Tokens are normally URL-safe static String fileNameForToken(String token) => fileSafeToken(token);
/// already and used verbatim; anything else is base64url-encoded so exotic
/// ids can never escape the cache directory.
static String fileNameForToken(String token) =>
RegExp(r'^[A-Za-z0-9_-]+$').hasMatch(token)
? token
: base64Url.encode(utf8.encode(token)).replaceAll('=', '');
/// Whether a cache file written at [modified] is still fresh at [now]. /// Whether a cache file written at [modified] is still fresh at [now].
static bool isFresh(DateTime modified, DateTime now) => static bool isFresh(DateTime modified, DateTime now) =>
-2
View File
@@ -101,7 +101,6 @@ class PushMessageHandler {
case PushKind.connect: case PushKind.connect:
await _handleConnect( await _handleConnect(
message, message,
foreground: foreground,
notificationsEnabled: notificationsEnabled, notificationsEnabled: notificationsEnabled,
); );
break; break;
@@ -139,7 +138,6 @@ class PushMessageHandler {
Future<void> _handleConnect( Future<void> _handleConnect(
RemoteMessage message, { RemoteMessage message, {
required bool foreground,
required bool notificationsEnabled, required bool notificationsEnabled,
}) async { }) async {
// Connect pushes carry no silent side effects, so nothing to do when the // Connect pushes carry no silent side effects, so nothing to do when the
+1 -2
View File
@@ -258,10 +258,9 @@ class PushRegistration {
/// Flutter engine. Best-effort — a failure here must not abort registration. /// Flutter engine. Best-effort — a failure here must not abort registration.
Future<void> _persistNativeAuthContext() async { Future<void> _persistNativeAuthContext() async {
try { try {
final endpoint = EndpointData().nextcloud();
await _store.saveNativeAuthContext( await _store.saveNativeAuthContext(
username: SessionManager().requireNextcloud().username, username: SessionManager().requireNextcloud().username,
baseUrl: 'https://${endpoint.full()}', baseUrl: currentNcBaseUrl,
); );
} on Object catch (e) { } on Object catch (e) {
log('Push: could not persist native auth context: $e'); log('Push: could not persist native auth context: $e');
+26 -23
View File
@@ -203,16 +203,10 @@ class PushRenderer {
messages: styleMessages, messages: styleMessages,
); );
final androidDetails = AndroidNotificationDetails( final androidDetails = _talkAndroidDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag, tag: tag,
style: messagingStyle,
silent: !alert, silent: !alert,
styleInformation: messagingStyle,
actions: _talkActions, actions: _talkActions,
); );
@@ -247,17 +241,11 @@ class PushRenderer {
title: senderName, title: senderName,
body: messageText, body: messageText,
notificationDetails: NotificationDetails( notificationDetails: NotificationDetails(
android: AndroidNotificationDetails( android: _talkAndroidDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: AndroidNotificationCategory.message,
color: _accentColor,
tag: tag, tag: tag,
// Single sender ⇒ no conversationTitle (1:1 convention): the sender // Single sender ⇒ no conversationTitle (1:1 convention): the sender
// Person is the header, a title would repeat the name per line. // Person is the header, a title would repeat the name per line.
styleInformation: MessagingStyleInformation( style: MessagingStyleInformation(
const Person(key: 'self', name: 'Ich'), const Person(key: 'self', name: 'Ich'),
groupConversation: false, groupConversation: false,
messages: [ messages: [
@@ -271,6 +259,25 @@ class PushRenderer {
); );
} }
static AndroidNotificationDetails _talkAndroidDetails({
required String tag,
required StyleInformation style,
AndroidNotificationCategory? category = AndroidNotificationCategory.message,
bool silent = false,
List<AndroidNotificationAction>? actions,
}) => AndroidNotificationDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
category: category,
color: _accentColor,
tag: tag,
silent: silent,
styleInformation: style,
actions: actions,
);
// Both actions keep cancelNotification: true (the default): the plugin's // Both actions keep cancelNotification: true (the default): the plugin's
// Java receiver then removes the card NATIVELY the moment the action fires. // Java receiver then removes the card NATIVELY the moment the action fires.
// The reply action must not rely on our Dart-side cancel instead — MIUI/ // The reply action must not rely on our Dart-side cancel instead — MIUI/
@@ -306,14 +313,10 @@ class PushRenderer {
title: title, title: title,
body: body, body: body,
notificationDetails: NotificationDetails( notificationDetails: NotificationDetails(
android: AndroidNotificationDetails( android: _talkAndroidDetails(
talkChannelId,
talkChannelName,
importance: Importance.high,
priority: Priority.high,
color: _accentColor,
tag: 'talk_error_$chatToken', tag: 'talk_error_$chatToken',
styleInformation: BigTextStyleInformation(body), style: BigTextStyleInformation(body),
category: null,
), ),
iOS: const DarwinNotificationDetails(), iOS: const DarwinNotificationDetails(),
), ),
+1 -11
View File
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:developer'; import 'dart:developer';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -25,7 +24,7 @@ class PushTapRouter {
PushActions.handleBackgroundResponse(response); PushActions.handleBackgroundResponse(response);
return; return;
} }
final map = _payloadMap(response.payload); final map = decodeNotificationPayload(response.payload);
if (map == null) return; if (map == null) return;
final target = resolvePushTarget(map); final target = resolvePushTarget(map);
if (target != null) pendingTarget.value = target; if (target != null) pendingTarget.value = target;
@@ -50,13 +49,4 @@ class PushTapRouter {
log('Reading the notification launch details failed: $e'); log('Reading the notification launch details failed: $e');
} }
} }
static Map<String, dynamic>? _payloadMap(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
return jsonDecode(payload) as Map<String, dynamic>;
} on Object {
return null;
}
}
} }
+13
View File
@@ -1,3 +1,16 @@
import 'dart:convert';
/// Decodes the JSON payload of a locally rendered notification; null when
/// absent or malformed.
Map<String, dynamic>? decodeNotificationPayload(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
return jsonDecode(payload) as Map<String, dynamic>;
} on Object {
return null;
}
}
/// Where a tapped notification leads. /// Where a tapped notification leads.
sealed class PushTarget { sealed class PushTarget {
const PushTarget(); const PushTarget();
+14 -30
View File
@@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@@ -145,19 +146,17 @@ class AppRoutes {
/// the selected element (or null if dismissed). The timetable view renders /// the selected element (or null if dismissed). The timetable view renders
/// the chosen plan inline. Gated behind the `viewForeignTimetables` /// the chosen plan inline. Gated behind the `viewForeignTimetables`
/// capability at the call site. /// capability at the call site.
static Future<TimetableElementRef?> openElementPicker( static Future<TimetableElementRef?> openElementPicker(BuildContext context) =>
_pushForResult(context, const ElementPickerPage());
// pushScreen casts its internal MaterialPageRoute to `Route<T>`, which blows
// up for a concrete (record) T — so push untyped (T = dynamic, like every
// other caller) and cast the popped result ourselves.
static Future<T?> _pushForResult<T>(
BuildContext context, BuildContext context,
) async { Widget screen,
// pushScreen casts its internal MaterialPageRoute to `Route<T>`, which ) async =>
// blows up for a concrete (record) T — so push untyped (T = dynamic, like (await pushScreen(context, withNavBar: false, screen: screen)) as T?;
// every other caller) and cast the popped result ourselves.
final result = await pushScreen(
context,
withNavBar: false,
screen: const ElementPickerPage(),
);
return result as TimetableElementRef?;
}
/// Opens the user/group picker for sharing a file and resolves to the chosen /// Opens the user/group picker for sharing a file and resolves to the chosen
/// recipient (or null if dismissed). Which kinds are offered is gated by the /// recipient (or null if dismissed). Which kinds are offered is gated by the
@@ -166,19 +165,10 @@ class AppRoutes {
BuildContext context, { BuildContext context, {
required bool allowUsers, required bool allowUsers,
required bool allowGroups, required bool allowGroups,
}) async { }) => _pushForResult(
// Push untyped (T = dynamic) and cast the popped result ourselves —
// pushScreen can't carry a concrete non-Widget T (see openElementPicker).
final result = await pushScreen(
context, context,
withNavBar: false, ShareePickerPage(allowUsers: allowUsers, allowGroups: allowGroups),
screen: ShareePickerPage(
allowUsers: allowUsers,
allowGroups: allowGroups,
),
); );
return result as ShareeRef?;
}
static void openMarianumMessage( static void openMarianumMessage(
BuildContext context, BuildContext context,
@@ -452,13 +442,7 @@ class AppRoutes {
static GetRoomResponseObject? _findRoomByToken( static GetRoomResponseObject? _findRoomByToken(
GetRoomResponse? rooms, GetRoomResponse? rooms,
String token, String token,
) { ) => rooms?.data.firstWhereOrNull((room) => room.token == token);
if (rooms == null) return null;
for (final room in rooms.data) {
if (room.token == token) return room;
}
return null;
}
/// Pushes a module from the "Mehr" tab list. Modules already in the bottom /// Pushes a module from the "Mehr" tab list. Modules already in the bottom
/// bar are switched to via [goToTab] instead. /// bar are switched to via [goToTab] instead.
-1
View File
@@ -19,7 +19,6 @@ class PendingShare {
bool get hasFiles => filePaths.isNotEmpty; bool get hasFiles => filePaths.isNotEmpty;
bool get hasText => text != null && text!.isNotEmpty; bool get hasText => text != null && text!.isNotEmpty;
bool get isEmpty => !hasFiles && !hasText;
/// True when [other] carries the same payload. The iOS Share Extension /// True when [other] carries the same payload. The iOS Share Extension
/// fires two `open(url)` requests per share (see ShareViewController), so /// fires two `open(url)` requests per share (see ShareViewController), so
@@ -40,7 +40,7 @@ class LoadableStateConsumer<
super.key, super.key,
}); });
static Duration animationDuration = const Duration(milliseconds: 200); static const Duration animationDuration = Duration(milliseconds: 200);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -5,6 +5,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../../widget/info_dialog.dart'; import '../../../../../widget/info_dialog.dart';
import '../bloc/loadable_state_bloc.dart'; import '../bloc/loadable_state_bloc.dart';
import 'loadable_state_consumer.dart';
class LoadableStateErrorBar extends StatelessWidget { class LoadableStateErrorBar extends StatelessWidget {
final bool visible; final bool visible;
@@ -21,8 +22,6 @@ class LoadableStateErrorBar extends StatelessWidget {
super.key, super.key,
}); });
final Duration animationDuration = const Duration(milliseconds: 200);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.watch<LoadableStateBloc>(); final bloc = context.watch<LoadableStateBloc>();
@@ -31,9 +30,9 @@ class LoadableStateErrorBar extends StatelessWidget {
final shouldShow = visible || isOfflineWithCache; final shouldShow = visible || isOfflineWithCache;
return AnimatedSize( return AnimatedSize(
duration: animationDuration, duration: LoadableStateConsumer.animationDuration,
child: AnimatedSwitcher( child: AnimatedSwitcher(
duration: animationDuration, duration: LoadableStateConsumer.animationDuration,
transitionBuilder: (Widget child, Animation<double> animation) => transitionBuilder: (Widget child, Animation<double> animation) =>
SlideTransition( SlideTransition(
position: Tween<Offset>( position: Tween<Offset>(
@@ -46,16 +45,12 @@ class LoadableStateErrorBar extends StatelessWidget {
key: Key(shouldShow.hashCode.toString()), key: Key(shouldShow.hashCode.toString()),
visible: shouldShow, visible: shouldShow,
replacement: const SizedBox(width: double.infinity), replacement: const SizedBox(width: double.infinity),
child: Builder( child: InkWell(
builder: (context) {
var bloc = context.watch<LoadableStateBloc>();
return InkWell(
onTap: () { onTap: () {
if (!bloc.isConnected()) return; if (!bloc.isConnected()) return;
final body = [ final body = [
if (message != null && message!.isNotEmpty) message!, if (message != null && message!.isNotEmpty) message!,
if (technicalDetails != null && if (technicalDetails != null && technicalDetails!.isNotEmpty)
technicalDetails!.isNotEmpty)
technicalDetails!, technicalDetails!,
].join('\n\n'); ].join('\n\n');
if (body.isEmpty) return; if (body.isEmpty) return;
@@ -68,13 +63,9 @@ class LoadableStateErrorBar extends StatelessWidget {
}, },
child: Container( child: Container(
height: 20, height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(color: bloc.connectionColor(context)),
color: bloc.connectionColor(context),
),
child: LoadableStateErrorBarText(lastUpdated: lastUpdated), child: LoadableStateErrorBarText(lastUpdated: lastUpdated),
), ),
);
},
), ),
), ),
), ),
@@ -106,8 +97,9 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var bloc = context.watch<LoadableStateBloc>(); final bloc = context.watch<LoadableStateBloc>();
final foreground = bloc.connectionForegroundColor(context); final foreground = bloc.connectionForegroundColor(context);
final text = bloc.connectionText(lastUpdated: widget.lastUpdated);
// liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird; // liveRegion, damit das Auftauchen des Offline-/Fehlerbanners angesagt wird;
// Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite // Row-Semantik ausgeschlossen (Icon + Text) und Text über das explizite
@@ -115,17 +107,14 @@ class _LoadableStateErrorBarTextState extends State<LoadableStateErrorBarText> {
return Semantics( return Semantics(
liveRegion: true, liveRegion: true,
container: true, container: true,
label: bloc.connectionText(lastUpdated: widget.lastUpdated), label: text,
child: ExcludeSemantics( child: ExcludeSemantics(
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(bloc.connectionIcon(), size: 14, color: foreground), Icon(bloc.connectionIcon(), size: 14, color: foreground),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(text, style: TextStyle(fontSize: 12, color: foreground)),
bloc.connectionText(lastUpdated: widget.lastUpdated),
style: TextStyle(fontSize: 12, color: foreground),
),
], ],
), ),
), ),
@@ -253,7 +253,6 @@ abstract class LoadableHydratedBloc<
/// `==`, so use fields that compare cheaply, e.g. by identity). /// `==`, so use fields that compare cheaply, e.g. by identity).
Object? persistenceKey(TState data) => data; Object? persistenceKey(TState data) => data;
Future<void> gatherData(); Future<void> gatherData();
TRepository repository(); TRepository repository();
+11 -23
View File
@@ -177,9 +177,7 @@ class AppModule {
for (final missing in Modules.values) { for (final missing in Modules.values) {
if (!seen.add(missing)) continue; if (!seen.add(missing)) continue;
var insertAt = 0; var insertAt = 0;
for (final predecessor in Modules.values.takeWhile( for (final predecessor in Modules.values.takeWhile((m) => m != missing)) {
(m) => m != missing,
)) {
final pos = order.indexOf(predecessor); final pos = order.indexOf(predecessor);
if (pos >= insertAt) insertAt = pos + 1; if (pos >= insertAt) insertAt = pos + 1;
} }
@@ -231,33 +229,26 @@ class AppModule {
static const int minBottomBarSlots = 3; static const int minBottomBarSlots = 3;
static const int maxBottomBarSlots = 5; static const int maxBottomBarSlots = 5;
static int resolveBottomBarSlotCount(BuildContext context) { static int _slotCount(BuildContext context, int available) {
final settings = context.read<SettingsCubit>().val().modulesSettings; final settings = context.read<SettingsCubit>().val().modulesSettings;
final available = modules(context).length;
int desired;
if (settings.autoFillBottomBar) {
// 4 content tabs so the bar shows 5 entries including "Mehr" — enough // 4 content tabs so the bar shows 5 entries including "Mehr" — enough
// for the Ticker module without pushing "Dateien" into the overflow. // for the Ticker module without pushing "Dateien" into the overflow.
desired = 4; final desired = settings.autoFillBottomBar
} else { ? 4
desired = settings.fixedBottomBarSlots; : settings.fixedBottomBarSlots;
} return desired
.clamp(minBottomBarSlots, maxBottomBarSlots)
desired = desired.clamp(minBottomBarSlots, maxBottomBarSlots); .clamp(0, available);
return desired.clamp(0, available);
} }
static List<AppModule> getBottomBarModules(BuildContext context) { static List<AppModule> getBottomBarModules(BuildContext context) {
final all = modules(context).values.toList(); final all = modules(context).values.toList();
final slots = resolveBottomBarSlotCount(context); return all.take(_slotCount(context, all.length)).toList();
return all.take(slots).toList();
} }
static List<AppModule> getOverhangModules(BuildContext context) { static List<AppModule> getOverhangModules(BuildContext context) {
final all = modules(context).values.toList(); final all = modules(context).values.toList();
final slots = resolveBottomBarSlotCount(context); return all.skip(_slotCount(context, all.length)).toList();
return all.skip(slots).toList();
} }
Widget toListTile( Widget toListTile(
@@ -290,10 +281,7 @@ class AppModule {
: const Icon(Icons.arrow_right), : const Icon(Icons.arrow_right),
); );
PersistentTabConfig toBottomTab( PersistentTabConfig toBottomTab(BuildContext context) => PersistentTabConfig(
BuildContext context, {
Widget Function(IconData icon)? iconBuilder,
}) => PersistentTabConfig(
screen: Breaker(breaker: breakerArea, child: create()), screen: Breaker(breaker: breakerArea, child: create()),
item: ItemConfig( item: ItemConfig(
activeForegroundColor: Theme.of(context).primaryColor, activeForegroundColor: Theme.of(context).primaryColor,
+33 -30
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:developer'; import 'dart:developer';
import 'dart:math' as math;
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@@ -10,6 +9,7 @@ import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_response.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.dart';
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
import '../../../../../utils/exponential_backoff.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.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 '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../../chat_list/bloc/chat_list_bloc.dart'; import '../../chat_list/bloc/chat_list_bloc.dart';
@@ -26,7 +26,6 @@ class ChatBloc
// A loop parked in its request survives stop+start for the same token // A loop parked in its request survives stop+start for the same token
// (pause/resume); the generation retires it so only one loop polls. // (pause/resume); the generation retires it so only one loop polls.
int _pollGeneration = 0; int _pollGeneration = 0;
int _backoffMs = 0;
int _lastKnownMessageId = 0; int _lastKnownMessageId = 0;
bool _appResumed = true; bool _appResumed = true;
@@ -68,8 +67,7 @@ class ChatBloc
@override @override
Map<String, dynamic>? toStorage(ChatState state) { Map<String, dynamic>? toStorage(ChatState state) {
final response = state.chatResponse; final response = state.chatResponse;
if (response == null || if (response == null || response.data.length <= _kMaxPersistedMessages) {
response.data.length <= _kMaxPersistedMessages) {
return state.toJson(); return state.toJson();
} }
// Keep only the newest N; trimming drops older messages, so there is // Keep only the newest N; trimming drops older messages, so there is
@@ -129,9 +127,6 @@ class ChatBloc
add(Emit((s) => s.copyWith(referenceMessageId: messageId))); add(Emit((s) => s.copyWith(referenceMessageId: messageId)));
} }
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
/// The chat view may still be popping when the sign-out resets this bloc, /// The chat view may still be popping when the sign-out resets this bloc,
/// so leaveChat would find no token and the long-poll would keep running. /// so leaveChat would find no token and the long-poll would keep running.
@override @override
@@ -141,6 +136,9 @@ class ChatBloc
return super.reset(); return super.reset();
} }
/// No-op when the bloc has already moved on to a different token: when
/// popping a stacked chat (B over A), A's didPopNext runs setToken(A)
/// before B's dispose fires.
void leaveChat(String fromToken) { void leaveChat(String fromToken) {
if ((innerState?.currentToken ?? '') != fromToken) return; if ((innerState?.currentToken ?? '') != fromToken) return;
_chatViewActive = false; _chatViewActive = false;
@@ -272,7 +270,6 @@ class ChatBloc
if (_pollingToken == token) return; if (_pollingToken == token) return;
_stopLongPoll(); _stopLongPoll();
_pollingToken = token; _pollingToken = token;
_backoffMs = 0;
_lastKnownMessageId = _maxMessageId(innerState?.chatResponse); _lastKnownMessageId = _maxMessageId(innerState?.chatResponse);
unawaited(_pollLoop(token, ++_pollGeneration)); unawaited(_pollLoop(token, ++_pollGeneration));
} }
@@ -280,11 +277,11 @@ class ChatBloc
void _stopLongPoll() { void _stopLongPoll() {
_pollingToken = null; _pollingToken = null;
_pollGeneration++; _pollGeneration++;
_backoffMs = 0;
} }
Future<void> _pollLoop(String token, int generation) async { Future<void> _pollLoop(String token, int generation) async {
bool active() => generation == _pollGeneration && !isClosed; bool active() => generation == _pollGeneration && !isClosed;
var failures = 0;
while (active()) { while (active()) {
try { try {
final response = await LongPollChat( final response = await LongPollChat(
@@ -293,7 +290,7 @@ class ChatBloc
).run(); ).run();
if (!active()) return; if (!active()) return;
_backoffMs = 0; failures = 0;
if (response == null) continue; if (response == null) continue;
@@ -318,8 +315,13 @@ class ChatBloc
} on Object catch (e) { } on Object catch (e) {
if (!active()) return; if (!active()) return;
log('LongPoll error for $token: $e'); log('LongPoll error for $token: $e');
_backoffMs = _backoffMs == 0 ? 2000 : math.min(_backoffMs * 2, 30000); await Future<void>.delayed(
await Future.delayed(Duration(milliseconds: _backoffMs)); exponentialBackoff(
++failures,
base: const Duration(seconds: 2),
max: const Duration(seconds: 30),
),
);
} }
} }
} }
@@ -339,14 +341,7 @@ class ChatBloc
); );
return; return;
} }
final byId = <int, GetChatResponseObject>{}; final merged = _mergeById(current, incoming, incomingWins: true)
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in incoming.data) {
byId[m.id] = m;
}
final merged = GetChatResponse(byId.values.toSet())
..headers = incoming.headers; ..headers = incoming.headers;
add(DataGathered((s) => s.copyWith(chatResponse: merged))); add(DataGathered((s) => s.copyWith(chatResponse: merged)));
} }
@@ -357,14 +352,7 @@ class ChatBloc
void _applyOlderResponse(GetChatResponse older) { void _applyOlderResponse(GetChatResponse older) {
final current = innerState?.chatResponse; final current = innerState?.chatResponse;
if (current == null) return; if (current == null) return;
final byId = <int, GetChatResponseObject>{}; final merged = _mergeById(current, older, incomingWins: false)
for (final m in current.data) {
byId[m.id] = m;
}
for (final m in older.data) {
byId.putIfAbsent(m.id, () => m);
}
final merged = GetChatResponse(byId.values.toSet())
..headers = current.headers; ..headers = current.headers;
add( add(
DataGathered( DataGathered(
@@ -377,6 +365,22 @@ class ChatBloc
); );
} }
GetChatResponse _mergeById(
GetChatResponse current,
GetChatResponse incoming, {
required bool incomingWins,
}) {
final byId = {for (final m in current.data) m.id: m};
for (final m in incoming.data) {
if (incomingWins) {
byId[m.id] = m;
} else {
byId.putIfAbsent(m.id, () => m);
}
}
return GetChatResponse(byId.values.toSet());
}
int _maxMessageId(GetChatResponse? response) { int _maxMessageId(GetChatResponse? response) {
if (response == null) return 0; if (response == null) return 0;
var max = 0; var max = 0;
@@ -386,8 +390,7 @@ class ChatBloc
return max; return max;
} }
int _minMessageId(GetChatResponse? response) { int _minMessageId(GetChatResponse response) {
if (response == null) return 0;
var min = 0; var min = 0;
for (final m in response.data) { for (final m in response.data) {
if (m.id <= 0) continue; // skip dummies if (m.id <= 0) continue; // skip dummies
@@ -28,18 +28,16 @@ class FilesBloc
@override @override
Map<String, dynamic>? toStorage(FilesState state) => null; Map<String, dynamic>? toStorage(FilesState state) => null;
List<String> get _currentPath => innerState?.currentPath ?? initialPath;
@override @override
Future<void> gatherData() async { Future<void> gatherData() => _query(_currentPath);
final path = innerState?.currentPath ?? initialPath;
await _query(path);
}
Future<void> refresh() async { Future<void> refresh() async {
add(RefetchStarted<FilesState>()); add(RefetchStarted<FilesState>());
final path = innerState?.currentPath ?? initialPath;
// Explicit user action — bypass the cache TTL so the root listing also // Explicit user action — bypass the cache TTL so the root listing also
// refetches even though it is otherwise cached for a day. // refetches even though it is otherwise cached for a day.
await _query(path, renew: true); await _query(_currentPath, renew: true);
} }
/// Pull-to-refresh and error-screen retry route through here. Unlike the /// Pull-to-refresh and error-screen retry route through here. Unlike the
@@ -57,13 +55,15 @@ class FilesBloc
} }
Future<void> createFolder(String name) async { Future<void> createFolder(String name) async {
final path = innerState?.currentPath ?? initialPath; await repo.data.createFolder('${_currentPath.join('/')}/$name');
await repo.data.createFolder('${path.join('/')}/$name');
await refresh(); await refresh();
} }
Future<void> _query(List<String> path, {bool renew = false}) async { Future<void> _query(List<String> path, {bool renew = false}) async {
final pathString = path.isEmpty ? '/' : path.join('/'); // The listing contains the folder itself; only its children are shown.
void dropSelf(ListFilesResponse listing) => listing.files.removeWhere(
(file) => file.name.isEmpty || file.name == path.lastOrNull,
);
// Drop late results when [setPath] has navigated elsewhere or when the // Drop late results when [setPath] has navigated elsewhere or when the
// bloc has been disposed (e.g. share-flow picker closed mid-fetch). Both // bloc has been disposed (e.g. share-flow picker closed mid-fetch). Both
@@ -80,16 +80,14 @@ class FilesBloc
ListFilesResponse? listing; ListFilesResponse? listing;
try { try {
listing = await repo.data.listFiles( listing = await repo.data.listFiles(
pathString, path.isEmpty ? '/' : path.join('/'),
renew: renew, renew: renew,
onCacheData: (cached) { onCacheData: (cached) {
if (isStale()) return; if (isStale()) return;
// Cached payload arrives before the network call settles. Surface it // Cached payload arrives before the network call settles. Surface it
// immediately via Emit so the listing is visible while isLoading // immediately via Emit so the listing is visible while isLoading
// stays true and the top loading bar keeps spinning. // stays true and the top loading bar keeps spinning.
cached.files.removeWhere( dropSelf(cached);
(file) => file.name.isEmpty || file.name == path.lastOrNull,
);
add(Emit((s) => s.copyWith(listing: cached))); add(Emit((s) => s.copyWith(listing: cached)));
}, },
onRetry: (next, max) { onRetry: (next, max) {
@@ -105,9 +103,7 @@ class FilesBloc
if (isStale()) return; if (isStale()) return;
if (listing != null) { if (listing != null) {
listing.files.removeWhere( dropSelf(listing);
(file) => file.name.isEmpty || file.name == path.lastOrNull,
);
add(DataGathered((s) => s.copyWith(listing: listing))); add(DataGathered((s) => s.copyWith(listing: listing)));
} }
if (capturedError != null) addLoadingError(capturedError!); if (capturedError != null) addLoadingError(capturedError!);
@@ -3,10 +3,5 @@ import '../bloc/files_state.dart';
import '../data_provider/files_data_provider.dart'; import '../data_provider/files_data_provider.dart';
class FilesRepository extends Repository<FilesState> { class FilesRepository extends Repository<FilesState> {
final FilesDataProvider _provider; final FilesDataProvider data = FilesDataProvider();
FilesRepository([FilesDataProvider? provider])
: _provider = provider ?? FilesDataProvider();
FilesDataProvider get data => _provider;
} }
@@ -1,16 +1,14 @@
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/loadable_state/loadable_state.dart'; import '../../../infrastructure/loadable_state/loadable_state.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.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 '../../timetable/bloc/timetable_event.dart'; import '../../timetable/bloc/timetable_event.dart';
import '../../timetable/bloc/timetable_state.dart'; import '../../timetable/bloc/timetable_state.dart';
import '../../timetable/bloc/timetable_week_loader.dart';
import '../../timetable/data_provider/timetable_data_provider.dart';
import '../repository/foreign_timetable_repository.dart'; import '../repository/foreign_timetable_repository.dart';
/// Drives a foreign element's timetable. Mirrors `TimetableBloc`'s week-loading /// Drives a foreign element's timetable. Shares `TimetableBloc`'s week loading
/// and navigation but loads weeks from the element endpoint, carries no custom /// and navigation but loads weeks from the element endpoint, carries no custom
/// events, and does not persist (page-scoped, recreated per element). Reuses /// events, and does not persist (page-scoped, recreated per element). Reuses
/// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents` /// [TimetableState] verbatim so the render pipeline is unchanged; `customEvents`
@@ -21,34 +19,25 @@ class ForeignTimetableBloc
TimetableEvent, TimetableEvent,
TimetableState, TimetableState,
ForeignTimetableRepository ForeignTimetableRepository
> { >
with TimetableWeekLoader<ForeignTimetableRepository> {
final TimetableElementType type; final TimetableElementType type;
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's // Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
// `String get id` (the storage key), which a plain `int id` would illegally // `String get id` (the storage key), which a plain `int id` would illegally
// override. // override.
final int elementId; final int elementId;
final String title;
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); ForeignTimetableBloc({required this.type, required this.elementId});
ForeignTimetableBloc({
required this.type,
required this.elementId,
required this.title,
});
@override @override
ForeignTimetableRepository repository() => ForeignTimetableRepository(); ForeignTimetableRepository repository() => ForeignTimetableRepository();
@override @override
TimetableState fromNothing() { TimetableDataProvider get referenceData => repo.data.referenceData;
final reference = DateTime.now().addDays(2);
return TimetableState( @override
startDate: reference.mondayOfWeek, Future<TimetableGetWeekResponse> fetchWeek(DateTime start, DateTime end) =>
endDate: _endOfWeek(reference), repo.data.getElementWeek(type, elementId, start, end);
);
}
// Persistence disabled: page-scoped and element-specific, nothing worth // Persistence disabled: page-scoped and element-specific, nothing worth
// restoring. toJson returns null so nothing is written; fromJson starts fresh. // restoring. toJson returns null so nothing is written; fromJson starts fresh.
@@ -72,116 +61,5 @@ class ForeignTimetableBloc
Map<String, dynamic>? toStorage(TimetableState state) => null; Map<String, dynamic>? toStorage(TimetableState state) => null;
@override @override
Future<void> gatherData() async { Future<void> gatherData() => gatherWeek();
final initial = innerState ?? fromNothing();
Object? firstError;
void recordError(Object e) {
firstError ??= e;
}
await Future.wait([
_loadCurrentWeek(initial.startDate, initial.endDate, onError: recordError),
_loadStaticReferenceData(onError: recordError),
]);
if (firstError != null) throw firstError!;
add(DataGathered((s) => s));
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
}
void changeWeek(DateTime startDate, DateTime endDate) {
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
}
void refresh() => fetch();
Future<void> _loadCurrentWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getElementWeek(
type,
elementId,
startDate,
endDate,
onError: onError,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('getElementWeek error for $startDate–$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadStaticReferenceData({
void Function(Object)? onError,
}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
repo.data.getRooms(onError: onError),
repo.data.getSubjects(onError: onError),
repo.data.getSchoolHolidays(onError: onError),
repo.data.getCurrentSchoolyear(onError: onError),
).wait;
add(
Emit(
(s) => s.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid();
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI.
}
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
_prefetchWeek(start.subtractDays(7), end.subtractDays(7));
_prefetchWeek(start.addDays(7), end.addDays(7));
}
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getElementWeek(type, elementId, start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return DateTime(friday.year, friday.month, friday.day);
}
} }
@@ -1,64 +1,27 @@
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid_response.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../timetable/data_provider/timetable_data_provider.dart'; import '../../timetable/data_provider/timetable_data_provider.dart';
/// Data access for a foreign element's timetable. The week comes from the /// Data access for a foreign element's timetable. The week comes from the
/// element-specific endpoint; all reference data (rooms/subjects/holidays/ /// element-specific endpoint; all reference data (rooms/subjects/holidays/
/// school year/timegrid) is school-wide, so it delegates to the existing /// school year/timegrid) is school-wide and comes from [referenceData].
/// [TimetableDataProvider] (which caches it). Custom events are intentionally /// Custom events are intentionally absent — they are user-private.
/// absent — they are user-private.
class ForeignTimetableDataProvider { class ForeignTimetableDataProvider {
final TimetableDataProvider _base; final TimetableDataProvider referenceData;
ForeignTimetableDataProvider([TimetableDataProvider? base]) ForeignTimetableDataProvider([TimetableDataProvider? referenceData])
: _base = base ?? TimetableDataProvider(); : referenceData = referenceData ?? TimetableDataProvider();
Future<TimetableGetWeekResponse> getElementWeek( Future<TimetableGetWeekResponse> getElementWeek(
TimetableElementType type, TimetableElementType type,
int id, int id,
DateTime startDate, DateTime startDate,
DateTime endDate, { DateTime endDate,
void Function(Object)? onError, ) async => TimetableGetElementWeek().run(
}) async {
try {
return await TimetableGetElementWeek().run(
type: type, type: type,
id: id, id: id,
from: startDate, from: startDate,
until: endDate, until: endDate,
); );
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetRoomsResponse> getRooms({
void Function(Object)? onError,
bool renew = false,
}) => _base.getRooms(onError: onError, renew: renew);
Future<TimetableGetSubjectsResponse> getSubjects({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSubjects(onError: onError, renew: renew);
Future<TimetableGetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError,
bool renew = false,
}) => _base.getSchoolHolidays(onError: onError, renew: renew);
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
void Function(Object)? onError,
bool renew = false,
}) => _base.getCurrentSchoolyear(onError: onError, renew: renew);
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) =>
_base.getTimegrid(renew: renew);
} }
@@ -9,13 +9,13 @@ class HolidaysBloc
extends extends
LoadableHydratedBloc<HolidaysEvent, HolidaysState, HolidaysRepository> { LoadableHydratedBloc<HolidaysEvent, HolidaysState, HolidaysRepository> {
HolidaysBloc() { HolidaysBloc() {
on<SetPastHolidaysVisible>((event, emit) { on<SetPastHolidaysVisible>(
add( (event, emit) => add(
Emit( Emit(
(state) => state.copyWith(showPastHolidays: event.shouldBeVisible), (state) => state.copyWith(showPastHolidays: event.shouldBeVisible),
), ),
),
); );
});
on<DisclaimerDismissed>( on<DisclaimerDismissed>(
(event, emit) => (event, emit) =>
@@ -25,15 +25,13 @@ class HolidaysBloc
bool showPastHolidays() => innerState?.showPastHolidays ?? false; bool showPastHolidays() => innerState?.showPastHolidays ?? false;
bool showDisclaimerOnEntry() => innerState?.showDisclaimer ?? false; bool showDisclaimerOnEntry() => innerState?.showDisclaimer ?? false;
List<McHoliday>? getHolidays() => List<McHoliday> getHolidays() {
innerState?.holidays final now = DateTime.now();
.where( return innerState?.holidays
(element) => .where((h) => showPastHolidays() || h.endDate.isAfter(now))
showPastHolidays() ||
element.endDate.isAfter(DateTime.now()),
)
.toList() ?? .toList() ??
[]; [];
}
@override @override
HolidaysState fromNothing() => const HolidaysState( HolidaysState fromNothing() => const HolidaysState(
@@ -12,20 +12,22 @@ class MarianumDatesBloc
MarianumDatesRepository MarianumDatesRepository
> { > {
MarianumDatesBloc() { MarianumDatesBloc() {
on<SetPastEventsVisible>((event, emit) { on<SetPastEventsVisible>(
add( (event, emit) => add(
Emit((state) => state.copyWith(showPastEvents: event.shouldBeVisible)), Emit((state) => state.copyWith(showPastEvents: event.shouldBeVisible)),
),
); );
});
} }
bool showPastEvents() => innerState?.showPastEvents ?? false; bool showPastEvents() => innerState?.showPastEvents ?? false;
List<MarianumDate>? getEvents() => List<MarianumDate> getEvents() {
innerState?.events final now = DateTime.now();
.where((e) => showPastEvents() || e.end.isAfter(DateTime.now())) return innerState?.events
.where((e) => showPastEvents() || e.end.isAfter(now))
.toList() ?? .toList() ??
[]; [];
}
@override @override
MarianumDatesState fromNothing() => MarianumDatesState fromNothing() =>
@@ -3,5 +3,3 @@ import 'marianum_message_state.dart';
sealed class MarianumMessageEvent sealed class MarianumMessageEvent
extends LoadableHydratedBlocEvent<MarianumMessageState> {} extends LoadableHydratedBlocEvent<MarianumMessageState> {}
class MessageEvent extends MarianumMessageEvent {}
@@ -37,5 +37,3 @@ abstract class MarianumMessage with _$MarianumMessage {
factory MarianumMessage.fromJson(Map<String, dynamic> json) => factory MarianumMessage.fromJson(Map<String, dynamic> json) =>
_$MarianumMessageFromJson(json); _$MarianumMessageFromJson(json);
} }
enum GradeAveragesGradingSystem { highSchool, middleSchool }
@@ -12,7 +12,8 @@ import '../bloc/marianum_message_state.dart';
class MarianumMessageGetMessages { class MarianumMessageGetMessages {
final Dio _dio; final Dio _dio;
MarianumMessageGetMessages({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio(); MarianumMessageGetMessages({Dio? dio})
: _dio = dio ?? MarianumConnectApi.dio();
Future<MarianumMessageList> run() async { Future<MarianumMessageList> run() async {
try { try {
@@ -2,5 +2,3 @@ import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_
import 'ticker_state.dart'; import 'ticker_state.dart';
sealed class TickerEvent extends LoadableHydratedBlocEvent<TickerState> {} sealed class TickerEvent extends LoadableHydratedBlocEvent<TickerState> {}
class TickerLoadEvent extends TickerEvent {}
@@ -1,6 +1,5 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'ticker_page_state.dart'; import 'ticker_page_state.dart';
sealed class TickerPageEvent extends LoadableHydratedBlocEvent<TickerPageState> {} sealed class TickerPageEvent
extends LoadableHydratedBlocEvent<TickerPageState> {}
class TickerPageLoadEvent extends TickerPageEvent {}
@@ -1,14 +1,13 @@
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart'; import '../../../../../api/marianumconnect/queries/timetable_custom_events/custom_events_migration.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart'; import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'; import '../../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.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 '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import '../data_provider/timetable_data_provider.dart';
import '../repository/timetable_repository.dart'; import '../repository/timetable_repository.dart';
import 'timetable_event.dart'; import 'timetable_event.dart';
import 'timetable_state.dart'; import 'timetable_state.dart';
import 'timetable_week_loader.dart';
class TimetableBloc class TimetableBloc
extends extends
@@ -16,11 +15,10 @@ class TimetableBloc
TimetableEvent, TimetableEvent,
TimetableState, TimetableState,
TimetableRepository TimetableRepository
> { >
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0); with TimetableWeekLoader<TimetableRepository> {
/// Set by [retry] to force the next [gatherData] to bypass the custom-event
/// Set by [retry] to force the next [gatherData] to bypass cache freshness /// cache and actually hit the network. Cleared at the top of [gatherData].
/// checks and actually hit the network. Cleared at the top of [gatherData].
bool _forceRenew = false; bool _forceRenew = false;
@override @override
@@ -33,24 +31,21 @@ class TimetableBloc
TimetableRepository repository() => TimetableRepository(); TimetableRepository repository() => TimetableRepository();
@override @override
TimetableState fromNothing() { TimetableDataProvider get referenceData => repo.data;
final reference = DateTime.now().addDays(2);
return TimetableState( @override
startDate: reference.mondayOfWeek, Future<TimetableGetWeekResponse> fetchWeek(DateTime start, DateTime end) =>
endDate: _endOfWeek(reference), repo.data.getWeek(start, end);
);
}
/// Persisted state may carry a stale `startDate`/`endDate` from the user's /// Persisted state may carry a stale `startDate`/`endDate` from the user's
/// last view. Reset on every cold start so the calendar always mounts on /// last view. Reset on every cold start so the calendar always mounts on
/// the current week, not on whatever week the user closed the app on. /// the current week, not on whatever week the user closed the app on.
@override @override
TimetableState fromStorage(Map<String, dynamic> json) { TimetableState fromStorage(Map<String, dynamic> json) {
final stored = TimetableState.fromJson(json); final (start, end) = currentTimetableWeek();
final reference = DateTime.now().addDays(2); return TimetableState.fromJson(json).copyWith(
return stored.copyWith( startDate: start,
startDate: reference.mondayOfWeek, endDate: end,
endDate: _endOfWeek(reference),
accessibleStartDate: null, accessibleStartDate: null,
accessibleEndDate: null, accessibleEndDate: null,
); );
@@ -60,50 +55,14 @@ class TimetableBloc
Map<String, dynamic>? toStorage(TimetableState state) => state.toJson(); Map<String, dynamic>? toStorage(TimetableState state) => state.toJson();
@override @override
Future<void> gatherData() async { Future<void> gatherData() {
final initial = innerState ?? fromNothing();
final renew = _forceRenew; final renew = _forceRenew;
_forceRenew = false; _forceRenew = false;
return gatherWeek(
Object? firstError; also: (onError) => _loadCustomEvents(onError: onError, renew: renew),
void recordError(Object e) { );
firstError ??= e;
} }
await Future.wait([
_loadCurrentWeek(
initial.startDate,
initial.endDate,
onError: recordError,
renew: renew,
),
_loadStaticReferenceData(onError: recordError, renew: renew),
_loadCustomEvents(onError: recordError, renew: renew),
]);
if (firstError != null) throw firstError!;
add(DataGathered((s) => s));
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
}
void changeWeek(DateTime startDate, DateTime endDate) {
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
runInSession(() {
_loadCurrentWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
});
}
void resetWeek() {
final reference = DateTime.now().addDays(2);
changeWeek(reference.mondayOfWeek, _endOfWeek(reference));
}
void refresh() => fetch();
Future<void> addCustomEvent(CustomTimetableEvent event) async { Future<void> addCustomEvent(CustomTimetableEvent event) async {
await repo.data.addCustomEvent(event); await repo.data.addCustomEvent(event);
await _refreshCustomEvents(); await _refreshCustomEvents();
@@ -132,67 +91,10 @@ class TimetableBloc
} }
Future<void> _refreshSubjects() async { Future<void> _refreshSubjects() async {
final subjects = await repo.data.getSubjects(renew: true); final subjects = await repo.data.getSubjects();
add(DataGathered((s) => s.withReferenceData(subjects: subjects))); add(DataGathered((s) => s.withReferenceData(subjects: subjects)));
} }
Future<void> _loadCurrentWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
bool renew = false,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await repo.data.getWeek(
startDate,
endDate,
onError: onError,
renew: renew,
);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('getWeek error for $startDate–$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadStaticReferenceData({
void Function(Object)? onError,
bool renew = false,
}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
repo.data.getRooms(onError: onError, renew: renew),
repo.data.getSubjects(onError: onError, renew: renew),
repo.data.getSchoolHolidays(onError: onError, renew: renew),
repo.data.getCurrentSchoolyear(onError: onError, renew: renew),
).wait;
add(
Emit(
(s) => s.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await repo.data.getTimegrid(renew: renew);
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI layer.
}
}
Future<void> _loadCustomEvents({ Future<void> _loadCustomEvents({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, bool renew = false,
@@ -213,27 +115,4 @@ class TimetableBloc
final events = await repo.data.getCustomEvents(renew: true); final events = await repo.data.getCustomEvents(renew: true);
add(DataGathered((s) => s.withReferenceData(customEvents: events))); add(DataGathered((s) => s.withReferenceData(customEvents: events)));
} }
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
_prefetchWeek(start.subtractDays(7), end.subtractDays(7));
_prefetchWeek(start.addDays(7), end.addDays(7));
}
void _prefetchWeek(DateTime start, DateTime end) {
repo.data
.getWeek(start, end)
.then((week) => _writeWeekToCache(start, week))
.catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
static DateTime _endOfWeek(DateTime reference) {
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return DateTime(friday.year, friday.month, friday.day);
}
} }
@@ -98,9 +98,9 @@ abstract class TimetableState with _$TimetableState {
); );
} }
bool get hasReferenceData => /// Everything a foreign plan needs to render; it never has custom events.
rooms != null && bool get hasSchoolReferenceData =>
subjects != null && rooms != null && subjects != null && schoolHolidays != null;
schoolHolidays != null &&
customEvents != null; bool get hasReferenceData => hasSchoolReferenceData && customEvents != null;
} }
@@ -0,0 +1,138 @@
import 'dart:developer';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
import '../../../infrastructure/repository/repository.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 '../data_provider/timetable_data_provider.dart';
import 'timetable_event.dart';
import 'timetable_state.dart';
/// The date whose week the timetable opens on — two days ahead, so Saturday
/// and Sunday already show the next week.
DateTime timetableAnchorDate() => DateTime.now().addDays(2);
/// Monday 00:00 to Friday 00:00 of the week the timetable opens on.
(DateTime, DateTime) currentTimetableWeek() {
final reference = timetableAnchorDate();
final friday = reference.addDays(
DateTime.daysPerWeek - reference.weekday - 2,
);
return (reference.mondayOfWeek, friday.dateOnly);
}
/// Week navigation, week loading/prefetching and the school-wide reference
/// data, shared by the own and the foreign timetable.
mixin TimetableWeekLoader<R extends Repository<TimetableState>>
on LoadableHydratedBloc<TimetableEvent, TimetableState, R> {
DateTime _lastWeekRequestStart = DateTime.fromMillisecondsSinceEpoch(0);
Future<TimetableGetWeekResponse> fetchWeek(DateTime start, DateTime end);
TimetableDataProvider get referenceData;
@override
TimetableState fromNothing() {
final (start, end) = currentTimetableWeek();
return TimetableState(startDate: start, endDate: end);
}
/// Loads the viewed week and the reference data (plus [also]), fails with
/// the first error of any of them and prefetches the neighbouring weeks.
Future<void> gatherWeek({
Future<void> Function(void Function(Object) onError)? also,
}) async {
final initial = innerState ?? fromNothing();
Object? firstError;
void recordError(Object e) => firstError ??= e;
await Future.wait([
_loadWeek(initial.startDate, initial.endDate, onError: recordError),
_loadReferenceData(onError: recordError),
if (also != null) also(recordError),
]);
if (firstError != null) throw firstError!;
add(DataGathered((s) => s));
_prefetchAdjacentWeeks(initial.startDate, initial.endDate);
}
void changeWeek(DateTime startDate, DateTime endDate) {
final current = innerState ?? fromNothing();
if (current.startDate == startDate && current.endDate == endDate) return;
add(Emit((s) => s.copyWith(startDate: startDate, endDate: endDate)));
runInSession(() {
_loadWeek(startDate, endDate);
_prefetchAdjacentWeeks(startDate, endDate);
});
}
void refresh() => fetch();
Future<void> _loadWeek(
DateTime startDate,
DateTime endDate, {
void Function(Object)? onError,
}) async {
final requestStart = DateTime.now();
_lastWeekRequestStart = requestStart;
try {
final week = await fetchWeek(startDate, endDate);
if (_lastWeekRequestStart.isAfter(requestStart)) return;
_writeWeekToCache(startDate, week);
} catch (e) {
log('Week load error for $startDate–$endDate: $e');
onError?.call(e);
}
}
Future<void> _loadReferenceData({void Function(Object)? onError}) async {
try {
final (rooms, subjects, schoolHolidays, schoolyear) = await (
referenceData.getRooms(onError: onError),
referenceData.getSubjects(onError: onError),
referenceData.getSchoolHolidays(onError: onError),
referenceData.getCurrentSchoolyear(onError: onError),
).wait;
add(
Emit(
(s) => s.withReferenceData(
rooms: rooms,
subjects: subjects,
schoolHolidays: schoolHolidays,
schoolyear: schoolyear,
),
),
);
} catch (e) {
onError?.call(e);
}
try {
final timegrid = await referenceData.getTimegrid();
add(Emit((s) => s.withReferenceData(timegrid: timegrid)));
} catch (_) {
// Timegrid load failure falls back to a hardcoded schedule in the UI.
}
}
void _prefetchAdjacentWeeks(DateTime start, DateTime end) {
_prefetchWeek(start.subtractDays(7), end.subtractDays(7));
_prefetchWeek(start.addDays(7), end.addDays(7));
}
void _prefetchWeek(DateTime start, DateTime end) {
fetchWeek(
start,
end,
).then((week) => _writeWeekToCache(start, week)).catchError((_) {});
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
add(Emit((s) => s.withFetchedWeek(weekStart, week)));
}
}
@@ -23,72 +23,31 @@ import '../../../../../api/mhsl/custom_timetable_event/get/get_custom_timetable_
import '../../../../../api/request_cache.dart'; import '../../../../../api/request_cache.dart';
/// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is /// Pulls the timetable from the Marianum-Connect mobile API. Each endpoint is
/// its own HTTP call; this provider exposes the lazy futures so the bloc can /// its own HTTP call; this provider exposes the futures so the bloc can chain
/// chain them without seeing the dio layer. /// them without seeing the dio layer.
class TimetableDataProvider { class TimetableDataProvider {
Future<TimetableGetWeekResponse> getWeek( Future<TimetableGetWeekResponse> getWeek(
DateTime startDate, DateTime startDate,
DateTime endDate, { DateTime endDate,
void Function(Object)? onError, ) async => TimetableGetWeek().run(from: startDate, until: endDate);
bool renew = false,
}) async {
try {
return await TimetableGetWeek().run(from: startDate, until: endDate);
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetRoomsResponse> getRooms({ Future<TimetableGetRoomsResponse> getRooms({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, }) => _reportingErrors(TimetableGetRooms().run, onError);
}) async {
try {
return await TimetableGetRooms().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetSubjectsResponse> getSubjects({ Future<TimetableGetSubjectsResponse> getSubjects({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, }) => _reportingErrors(TimetableGetSubjects().run, onError);
}) async {
try {
return await TimetableGetSubjects().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetHolidaysResponse> getSchoolHolidays({ Future<TimetableGetHolidaysResponse> getSchoolHolidays({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, }) => _reportingErrors(TimetableGetHolidays().run, onError);
}) async {
try {
return await TimetableGetHolidays().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({ Future<TimetableGetSchoolyearResponse> getCurrentSchoolyear({
void Function(Object)? onError, void Function(Object)? onError,
bool renew = false, }) => _reportingErrors(TimetableGetSchoolyear().run, onError);
}) async {
try {
return await TimetableGetSchoolyear().run();
} catch (e) {
onError?.call(e);
rethrow;
}
}
Future<TimetableGetTimegridResponse> getTimegrid({bool renew = false}) => Future<TimetableGetTimegridResponse> getTimegrid() =>
TimetableGetTimegrid().run(); TimetableGetTimegrid().run();
Future<GetCustomTimetableEventResponse> getCustomEvents({ Future<GetCustomTimetableEventResponse> getCustomEvents({
@@ -131,4 +90,18 @@ class TimetableDataProvider {
if (DemoMode.active) return Future.value(); if (DemoMode.active) return Future.value();
return TimetableSubjectColorRemove().run(subjectShort); return TimetableSubjectColorRemove().run(subjectShort);
} }
// Reports the original error before rethrowing: callers await several of
// these together, and a parallel wait only surfaces a combined error.
static Future<T> _reportingErrors<T>(
Future<T> Function() request,
void Function(Object)? onError,
) async {
try {
return await request();
} catch (e) {
onError?.call(e);
rethrow;
}
}
} }
+4 -8
View File
@@ -88,14 +88,10 @@ class DevToolsSettings {
if (trimmed.isEmpty) return null; if (trimmed.isEmpty) return null;
final uri = Uri.tryParse(trimmed); final uri = Uri.tryParse(trimmed);
if (uri == null || !uri.hasScheme) return null; if (uri == null || !uri.hasScheme) return null;
if (uri.scheme == 'https') { final schemeAllowed =
// always fine uri.scheme == 'https' ||
} else if (uri.scheme == 'http' && allowsHttpCustomEndpoint) { (uri.scheme == 'http' && allowsHttpCustomEndpoint);
// dev only if (!schemeAllowed || uri.host.isEmpty) return null;
} else {
return null;
}
if (uri.host.isEmpty) return null;
return trimmed.endsWith('/') return trimmed.endsWith('/')
? trimmed.substring(0, trimmed.length - 1) ? trimmed.substring(0, trimmed.length - 1)
: trimmed; : trimmed;
+2 -2
View File
@@ -1,6 +1,6 @@
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../state/app/modules/app_modules.dart'; import '../state/app/modules/app_modules.dart';
part 'modules_settings.g.dart'; part 'modules_settings.g.dart';
@@ -34,8 +34,7 @@ class TimetableFavoritesSettings {
/// Adds or removes the element in place. Returns the new favorite state /// Adds or removes the element in place. Returns the new favorite state
/// (`true` = now starred). Callers persist via `SettingsCubit.val(write: true)`. /// (`true` = now starred). Callers persist via `SettingsCubit.val(write: true)`.
bool toggle(TimetableElementType type, int id, String label) { bool toggle(TimetableElementType type, int id, String label) {
final existing = favorites.where((f) => f.type == type && f.id == id); if (isFavorite(type, id)) {
if (existing.isNotEmpty) {
favorites.removeWhere((f) => f.type == type && f.id == id); favorites.removeWhere((f) => f.type == type && f.id == id);
return false; return false;
} }
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:json_annotation/json_annotation.dart'; import 'package:json_annotation/json_annotation.dart';
import '../../../view/pages/timetable/data/timetable_name_mode.dart'; import '../view/pages/timetable/data/timetable_name_mode.dart';
part 'timetable_settings.g.dart'; part 'timetable_settings.g.dart';
+8 -18
View File
@@ -10,31 +10,21 @@ class AppSpacing {
static const double xl = 40; static const double xl = 40;
} }
TextStyle inputErrorStyle(BuildContext context) =>
TextStyle(color: Theme.of(context).colorScheme.error);
class AppTheme { class AppTheme {
static DropdownDisplay getDisplayOptions(ThemeMode theme) { static DropdownDisplay getDisplayOptions(ThemeMode theme) => switch (theme) {
switch (theme) { ThemeMode.system => DropdownDisplay(
case ThemeMode.system:
return DropdownDisplay(
icon: Icons.auto_fix_high_outlined, icon: Icons.auto_fix_high_outlined,
displayName: 'Systemvorgabe', displayName: 'Systemvorgabe',
); ),
ThemeMode.light => DropdownDisplay(
case ThemeMode.light:
return DropdownDisplay(
icon: Icons.wb_sunny_outlined, icon: Icons.wb_sunny_outlined,
displayName: 'Hell', displayName: 'Hell',
); ),
ThemeMode.dark => DropdownDisplay(
case ThemeMode.dark:
return DropdownDisplay(
icon: Icons.dark_mode_outlined, icon: Icons.dark_mode_outlined,
displayName: 'Dunkel', displayName: 'Dunkel',
); ),
} };
}
static bool isDarkMode(BuildContext context) => static bool isDarkMode(BuildContext context) =>
Theme.of(context).brightness == Brightness.dark; Theme.of(context).brightness == Brightness.dark;
+2 -9
View File
@@ -35,11 +35,7 @@ class DownloadFailed extends DownloadStatus {
/// the same job. Kept engine-agnostic on purpose — the native /// the same job. Kept engine-agnostic on purpose — the native
/// `background_downloader` task lives entirely inside [DownloadManager]. /// `background_downloader` task lives entirely inside [DownloadManager].
class DownloadJob { class DownloadJob {
DownloadJob({ DownloadJob({required this.remotePath, required this.name, this.remoteFile});
required this.remotePath,
required this.name,
this.remoteFile,
});
final String remotePath; final String remotePath;
final String name; final String name;
@@ -59,10 +55,7 @@ class DownloadJob {
const DownloadInProgress(0), const DownloadInProgress(0),
); );
bool get isFinished => bool get isFinished => status.value is! DownloadInProgress;
status.value is DownloadDone ||
status.value is DownloadFailed ||
status.value is DownloadCancelled;
bool get isDone => status.value is DownloadDone; bool get isDone => status.value is DownloadDone;
bool get isFailed => status.value is DownloadFailed; bool get isFailed => status.value is DownloadFailed;
+6 -4
View File
@@ -237,9 +237,7 @@ class DownloadManager {
// instead of freezing at the last percent. // instead of freezing at the last percent.
job.status.value = const DownloadCancelled(); job.status.value = const DownloadCancelled();
} }
_jobs.remove(job.remotePath); _forget(job);
if (taskId != null) _byTaskId.remove(taskId);
scheduleMicrotask(job.dispose);
} }
_refreshVisible(); _refreshVisible();
} }
@@ -330,10 +328,14 @@ class DownloadManager {
// --- bookkeeping ----------------------------------------------------------- // --- bookkeeping -----------------------------------------------------------
void _remove(DownloadJob job) { void _remove(DownloadJob job) {
_forget(job);
_refreshVisible();
}
void _forget(DownloadJob job) {
_jobs.remove(job.remotePath); _jobs.remove(job.remotePath);
final taskId = job.taskId; final taskId = job.taskId;
if (taskId != null) _byTaskId.remove(taskId); if (taskId != null) _byTaskId.remove(taskId);
_refreshVisible();
scheduleMicrotask(job.dispose); scheduleMicrotask(job.dispose);
} }
+7 -8
View File
@@ -21,16 +21,15 @@ class FileClipboard extends ChangeNotifier {
List<CacheableFile> get files => List.unmodifiable(_files); List<CacheableFile> get files => List.unmodifiable(_files);
bool get isEmpty => _files.isEmpty; bool get isEmpty => _files.isEmpty;
void cut(List<CacheableFile> files) { void cut(List<CacheableFile> files) =>
if (files.isEmpty) return; _set(FileClipboardOperation.cut, files);
_operation = FileClipboardOperation.cut;
_files = List.of(files);
notifyListeners();
}
void copy(List<CacheableFile> files) { void copy(List<CacheableFile> files) =>
_set(FileClipboardOperation.copy, files);
void _set(FileClipboardOperation operation, List<CacheableFile> files) {
if (files.isEmpty) return; if (files.isEmpty) return;
_operation = FileClipboardOperation.copy; _operation = operation;
_files = List.of(files); _files = List.of(files);
notifyListeners(); notifyListeners();
} }
-9
View File
@@ -8,12 +8,3 @@ String randomHexId({int bytes = 16}) {
(_) => random.nextInt(256), (_) => random.nextInt(256),
).map((b) => b.toRadixString(16).padLeft(2, '0')).join(); ).map((b) => b.toRadixString(16).padLeft(2, '0')).join();
} }
/// Random RFC 4122 version-4 UUID, for ids a server expects in UUID form.
String randomUuidV4() {
final hex = randomHexId();
final variant = (int.parse(hex[16], radix: 16) & 0x3 | 0x8).toRadixString(16);
return '${hex.substring(0, 8)}-${hex.substring(8, 12)}-'
'4${hex.substring(13, 16)}-$variant${hex.substring(17, 20)}-'
'${hex.substring(20)}';
}
+4 -12
View File
@@ -32,25 +32,14 @@ class _LoginCardState extends State<LoginCard> {
final _passwordController = TextEditingController(); final _passwordController = TextEditingController();
final _passwordFocus = FocusNode(); final _passwordFocus = FocusNode();
@override
void initState() {
super.initState();
widget.controller.addListener(_onControllerChange);
}
@override @override
void dispose() { void dispose() {
widget.controller.removeListener(_onControllerChange);
_usernameController.dispose(); _usernameController.dispose();
_passwordController.dispose(); _passwordController.dispose();
_passwordFocus.dispose(); _passwordFocus.dispose();
super.dispose(); super.dispose();
} }
void _onControllerChange() {
if (mounted) setState(() {});
}
String? _required(String? value) => String? _required(String? value) =>
(value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null; (value ?? '').trim().isEmpty ? 'Eingabe erforderlich' : null;
@@ -81,7 +70,10 @@ class _LoginCardState extends State<LoginCard> {
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) =>
ListenableBuilder(listenable: widget.controller, builder: _buildForm);
Widget _buildForm(BuildContext context, Widget? _) {
final theme = Theme.of(context); final theme = Theme.of(context);
final loading = widget.controller.loading; final loading = widget.controller.loading;
return Form( return Form(
@@ -30,6 +30,12 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
final TextEditingController _lastName = TextEditingController(); final TextEditingController _lastName = TextEditingController();
final TextEditingController _phone = TextEditingController(); final TextEditingController _phone = TextEditingController();
final TextEditingController _note = TextEditingController(); final TextEditingController _note = TextEditingController();
late final List<TextEditingController> _textControllers = [
_firstName,
_lastName,
_phone,
_note,
];
final AsyncActionController _submitController = AsyncActionController(); final AsyncActionController _submitController = AsyncActionController();
late Future<void> _init; late Future<void> _init;
@@ -46,7 +52,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
final today = DateUtils.dateOnly(DateTime.now()); final today = DateUtils.dateOnly(DateTime.now());
_absentFrom = today; _absentFrom = today;
_absentUntil = today; _absentUntil = today;
for (final c in [_firstName, _lastName, _phone, _note]) { for (final c in _textControllers) {
c.addListener(_onFieldChanged); c.addListener(_onFieldChanged);
} }
_init = _load(); _init = _load();
@@ -54,7 +60,7 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
@override @override
void dispose() { void dispose() {
for (final c in [_firstName, _lastName, _phone, _note]) { for (final c in _textControllers) {
c.dispose(); c.dispose();
} }
_submitController.dispose(); _submitController.dispose();
@@ -290,11 +296,9 @@ class _AbsenceReportViewState extends State<AbsenceReportView> {
String? _requiredError(TextEditingController controller) => String? _requiredError(TextEditingController controller) =>
_submitted && controller.text.trim().isEmpty ? _required : null; _submitted && controller.text.trim().isEmpty ? _required : null;
InputDecoration _decoration(String label, {String? error, String? hint}) => InputDecoration _decoration(String label, {String? error}) => InputDecoration(
InputDecoration(
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
labelText: label, labelText: label,
hintText: hint,
errorText: error, errorText: error,
); );
} }
+25
View File
@@ -0,0 +1,25 @@
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import 'sort_options.dart';
typedef FileSort = ({SortOption by, bool ascending, bool foldersToTop});
/// Selector for `context.select`, so folder views rebuild only when the
/// file sort settings change.
FileSort fileSortOf(SettingsCubit cubit) {
final settings = cubit.state.fileSettings;
return (
by: settings.sortBy,
ascending: settings.ascending,
foldersToTop: settings.sortFoldersToTop,
);
}
extension FileSortListing on ListFilesResponse {
List<CacheableFile> sortedBy(FileSort sort) => sortBy(
sortOption: sort.by,
foldersToTop: sort.foldersToTop,
reversed: sort.ascending,
);
}
@@ -11,12 +11,7 @@ IconData iconForFile(CacheableFile file) {
final byMime = _iconForMime(mime); final byMime = _iconForMime(mime);
if (byMime != null) return byMime; if (byMime != null) return byMime;
} }
final ext = _extensionOf(file.name); return iconForFileName(file.name);
if (ext != null) {
final byExt = _extensionIcons[ext];
if (byExt != null) return byExt;
}
return Icons.insert_drive_file_outlined;
} }
/// Icon for a bare file name (no [CacheableFile] / MIME available), e.g. in the /// Icon for a bare file name (no [CacheableFile] / MIME available), e.g. in the
@@ -0,0 +1,26 @@
// Paths relative to the WebDAV root, as used by `CacheableFile.path`: no
// leading slash, folders end with '/', the root folder is ''.
final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
List<String> webdavSegmentsOf(String path) {
final stripped = path.replaceAll(_edgeSlashes, '');
return stripped.isEmpty ? [] : stripped.split('/');
}
List<String> webdavParentSegmentsOf(String path) {
final segments = webdavSegmentsOf(path);
if (segments.isNotEmpty) segments.removeLast();
return segments;
}
String webdavFolderPath(List<String> segments) =>
segments.isEmpty ? '' : '${segments.join('/')}/';
String webdavJoin(String folder, String name, {required bool isDirectory}) =>
isDirectory ? '$folder$name/' : '$folder$name';
/// Folder key used by `ListFilesCache` and `FilesBloc`: no leading or
/// trailing slash, root is '/'.
String listFilesCacheKey(List<String> segments) =>
segments.isEmpty ? '/' : segments.join('/');
+10 -46
View File
@@ -11,10 +11,10 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart';
import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/files/bloc/files_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../utils/cache_invalidation_bus.dart'; import '../../../utils/cache_invalidation_bus.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import 'data/sort_options.dart'; import 'data/file_sort.dart';
import 'data/webdav_path.dart';
import 'files_upload_dialog.dart'; import 'files_upload_dialog.dart';
import 'search/files_search_delegate.dart'; import 'search/files_search_delegate.dart';
import 'widgets/add_file_menu.dart'; import 'widgets/add_file_menu.dart';
@@ -44,9 +44,6 @@ class _FilesView extends StatefulWidget {
} }
class _FilesViewState extends State<_FilesView> { class _FilesViewState extends State<_FilesView> {
late final SettingsCubit settings;
late SortOption currentSort;
late bool currentSortDirection;
late final StreamSubscription<String> _invalidationSub; late final StreamSubscription<String> _invalidationSub;
// The list builder also runs for loading flips and parent rebuilds; only a // The list builder also runs for loading flips and parent rebuilds; only a
@@ -54,32 +51,16 @@ class _FilesViewState extends State<_FilesView> {
Object? _sortedKey; Object? _sortedKey;
List<CacheableFile> _sortedFiles = const []; List<CacheableFile> _sortedFiles = const [];
List<CacheableFile> _sorted(ListFilesResponse listing, bool foldersToTop) { List<CacheableFile> _sorted(ListFilesResponse listing, FileSort sort) {
final key = (listing, currentSort, currentSortDirection, foldersToTop); final key = (listing, sort);
if (key == _sortedKey) return _sortedFiles; if (key == _sortedKey) return _sortedFiles;
_sortedKey = key; _sortedKey = key;
return _sortedFiles = listing.sortBy( return _sortedFiles = listing.sortedBy(sort);
sortOption: currentSort,
foldersToTop: foldersToTop,
reversed: currentSortDirection,
);
} }
// Cache key in FilesBloc's pathString format: '/' for root, otherwise
// segments joined without leading/trailing slash.
String get _myPathString => widget.path.isEmpty ? '/' : widget.path.join('/');
// Relative folder path matching the WebDAV format used by `CacheableFile.path`
// (no leading slash; trailing slash for non-root). Empty string means root.
String get _currentFolderPath =>
widget.path.isEmpty ? '' : '${widget.path.join('/')}/';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
settings = context.read<SettingsCubit>();
currentSort = settings.val().fileSettings.sortBy;
currentSortDirection = settings.val().fileSettings.ascending;
_invalidationSub = CacheInvalidationBus.listFilesStream.listen( _invalidationSub = CacheInvalidationBus.listFilesStream.listen(
_onInvalidation, _onInvalidation,
); );
@@ -87,7 +68,7 @@ class _FilesViewState extends State<_FilesView> {
void _onInvalidation(String invalidatedPath) { void _onInvalidation(String invalidatedPath) {
if (!mounted) return; if (!mounted) return;
if (invalidatedPath != _myPathString) return; if (invalidatedPath != listFilesCacheKey(widget.path)) return;
context.read<FilesBloc>().refresh(); context.read<FilesBloc>().refresh();
} }
@@ -118,29 +99,12 @@ class _FilesViewState extends State<_FilesView> {
final bloc = context.read<FilesBloc>(); final bloc = context.read<FilesBloc>();
// Selected here, not in the LoadableStateConsumer child: that closure runs // Selected here, not in the LoadableStateConsumer child: that closure runs
// during the consumer's build, where this context may not subscribe. // during the consumer's build, where this context may not subscribe.
final foldersToTop = context.select( final sort = context.select(fileSortOf);
(SettingsCubit c) => c.state.fileSettings.sortFoldersToTop,
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'), title: Text(widget.path.isNotEmpty ? widget.path.last : 'Dateien'),
actions: [ actions: [
FilesSortActions( const FilesSortActions(),
currentSort: currentSort,
ascending: currentSortDirection,
onDirectionChanged: (e) {
setState(() {
currentSortDirection = e;
settings.val(write: true).fileSettings.ascending = e;
});
},
onSortChanged: (e) {
setState(() {
currentSort = e;
settings.val(write: true).fileSettings.sortBy = e;
});
},
),
IconButton( IconButton(
tooltip: 'Suchen', tooltip: 'Suchen',
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
@@ -162,7 +126,7 @@ class _FilesViewState extends State<_FilesView> {
body: Column( body: Column(
children: [ children: [
ClipboardBanner( ClipboardBanner(
currentFolder: _currentFolderPath, currentFolder: webdavFolderPath(widget.path),
onPasteDone: bloc.refresh, onPasteDone: bloc.refresh,
), ),
Expanded( Expanded(
@@ -176,7 +140,7 @@ class _FilesViewState extends State<_FilesView> {
text: 'Der Ordner ist leer', text: 'Der Ordner ist leer',
); );
} }
final files = _sorted(listing, foldersToTop); final files = _sorted(listing, sort);
return ListView.builder( return ListView.builder(
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
itemCount: files.length, itemCount: files.length,
+62 -66
View File
@@ -28,13 +28,15 @@ class FilesUploadDialog extends StatefulWidget {
} }
class UploadableFile { class UploadableFile {
TextEditingController fileNameController = TextEditingController(); final String filePath;
String filePath; final TextEditingController fileNameController;
String fileName;
double? _uploadProgress; double? _uploadProgress;
bool isConflicting = false; bool isConflicting = false;
UploadableFile(this.filePath, this.fileName); UploadableFile(this.filePath, String fileName)
: fileNameController = TextEditingController(text: fileName);
String get fileName => fileNameController.text;
} }
class _FilesUploadDialogState extends State<FilesUploadDialog> { class _FilesUploadDialogState extends State<FilesUploadDialog> {
@@ -47,10 +49,22 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
void initState() { void initState() {
super.initState(); super.initState();
_uploadableFiles = widget.filePaths.map((filePath) { _uploadableFiles = widget.filePaths
var fileName = filePath.split(Platform.pathSeparator).last; .map(
return UploadableFile(filePath, fileName); (filePath) => UploadableFile(
}).toList(); filePath,
filePath.split(Platform.pathSeparator).last,
),
)
.toList();
}
@override
void dispose() {
for (final file in _uploadableFiles) {
file.fileNameController.dispose();
}
super.dispose();
} }
void _resetProgress() { void _resetProgress() {
@@ -69,6 +83,10 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
); );
} }
void _clearConflict(UploadableFile file) {
if (file.isConflicting) setState(() => file.isConflicting = false);
}
Future<void> uploadFiles({bool override = false}) async { Future<void> uploadFiles({bool override = false}) async {
setState(() { setState(() {
_isUploading = true; _isUploading = true;
@@ -82,36 +100,39 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
if (!mounted) return; if (!mounted) return;
if (!override) { if (!override) {
List<dynamic> result; final List<String> existingPaths;
try { try {
result = (await webdavClient.propfind( final result = await webdavClient.propfind(
PathUri.parse(widget.remotePath), PathUri.parse(widget.remotePath),
)).responses; );
existingPaths = [
for (final response in result.responses)
Uri.decodeComponent(response.href!),
];
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
_showUploadError('Verbindung fehlgeschlagen: $e'); _showUploadError('Verbindung fehlgeschlagen: $e');
return; return;
} }
final conflictingFiles = _uploadableFiles.where((file) { final conflictingFiles = _uploadableFiles
final fileName = file.fileName; .where(
return result.any( (file) => existingPaths.any(
(element) => Uri.decodeComponent( (path) => path.endsWith('/${file.fileName}'),
(element as WebDavResponse).href!, ),
).endsWith('/$fileName'), )
); .toList();
}).toList();
if (conflictingFiles.isNotEmpty) { if (conflictingFiles.isNotEmpty) {
if (!mounted) return; if (!mounted) return;
final replaceFiles = await showDialog<bool>( final replaceFiles = await showDialog<bool>(
context: context, context: context,
barrierDismissible: false, barrierDismissible: false,
builder: (context) => AlertDialog( builder: (dialogContext) => AlertDialog(
contentPadding: const EdgeInsets.all(10), contentPadding: const EdgeInsets.all(10),
title: const Text('Konflikt', textAlign: TextAlign.center), title: const Text('Konflikt', textAlign: TextAlign.center),
content: conflictingFiles.length == 1 content: conflictingFiles.length == 1
? Text( ? Text(
'Eine Datei mit dem Namen "${conflictingFiles.map((e) => e.fileName).first}" existiert bereits.', 'Eine Datei mit dem Namen "${conflictingFiles.first.fileName}" existiert bereits.',
textAlign: TextAlign.left, textAlign: TextAlign.left,
) )
: SingleChildScrollView( : SingleChildScrollView(
@@ -122,27 +143,20 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
), ),
actions: [ actions: [
TextButton( TextButton(
onPressed: () { onPressed: () => Navigator.pop(dialogContext, false),
Navigator.pop(context, false);
},
child: const Text('Bearbeiten', textAlign: TextAlign.center), child: const Text('Bearbeiten', textAlign: TextAlign.center),
), ),
TextButton( TextButton(
onPressed: () { // ConfirmDialog closes itself first, so this pop closes the
showDialog( // conflict dialog.
context: context, onPressed: () => ConfirmDialog(
builder: (context) => ConfirmDialog(
title: 'Bestätigen?', title: 'Bestätigen?',
content: content:
'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?', 'Bist du sicher, dass du ${conflictingFiles.length} Dateien überschreiben möchtest?',
onConfirm: () { onConfirm: () => Navigator.pop(dialogContext, true),
Navigator.pop(context, true);
},
confirmButton: 'Ja', confirmButton: 'Ja',
cancelButton: 'Nein', cancelButton: 'Nein',
), ).asDialog(dialogContext),
);
},
child: const Text('Überschreiben', textAlign: TextAlign.center), child: const Text('Überschreiben', textAlign: TextAlign.center),
), ),
], ],
@@ -163,9 +177,9 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
} }
var uploadetFilePaths = <String>[]; var uploadetFilePaths = <String>[];
for (var file in _uploadableFiles) { for (final (fileIndex, file) in _uploadableFiles.indexed) {
var fileName = file.fileName; var fileName = file.fileName;
var filePath = file.filePath; final filePath = file.filePath;
if (widget.uniqueNames) { if (widget.uniqueNames) {
final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36); final unique = DateTime.now().microsecondsSinceEpoch.toRadixString(36);
@@ -177,7 +191,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
setState(() { setState(() {
_infoText = _infoText =
'${_uploadableFiles.indexOf(file) + 1}/${_uploadableFiles.length}'; '${fileIndex + 1}/${_uploadableFiles.length}';
}); });
// A vanished source file would otherwise surface as a cryptic // A vanished source file would otherwise surface as a cryptic
@@ -193,7 +207,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
} }
final HttpClientResponse uploadTask; final HttpClientResponse uploadTask;
final fileIndex = _uploadableFiles.indexOf(file);
var lastPercent = -1; var lastPercent = -1;
try { try {
uploadTask = await webdavClient.putFile( uploadTask = await webdavClient.putFile(
@@ -253,12 +266,6 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
itemCount: _uploadableFiles.length, itemCount: _uploadableFiles.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final currentFile = _uploadableFiles[index]; final currentFile = _uploadableFiles[index];
// Only sync when it differs: assigning text resets selection
// and notifies the field on every (progress) rebuild.
if (currentFile.fileNameController.text !=
currentFile.fileName) {
currentFile.fileNameController.text = currentFile.fileName;
}
return ListTile( return ListTile(
title: TextField( title: TextField(
readOnly: _isUploading, readOnly: _isUploading,
@@ -273,24 +280,11 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
), ),
), ),
onChanged: (input) {
currentFile.fileName = input;
},
onTapOutside: (PointerDownEvent event) { onTapOutside: (PointerDownEvent event) {
FocusBehaviour.textFieldTapOutside(context); FocusBehaviour.textFieldTapOutside(context);
if (currentFile.isConflicting) { _clearConflict(currentFile);
setState(() {
currentFile.isConflicting = false;
});
}
},
onEditingComplete: () {
if (currentFile.isConflicting) {
setState(() {
currentFile.isConflicting = false;
});
}
}, },
onEditingComplete: () => _clearConflict(currentFile),
), ),
subtitle: subtitle:
_isUploading && (currentFile._uploadProgress ?? 0) < 1 _isUploading && (currentFile._uploadProgress ?? 0) < 1
@@ -301,21 +295,23 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
), ),
) )
: null, : null,
trailing: Container( trailing: SizedBox(
width: 24, width: 24,
height: 24, height: 24,
padding: EdgeInsets.zero,
child: IconButton( child: IconButton(
tooltip: 'Datei entfernen', tooltip: 'Datei entfernen',
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: () { onPressed: () {
if (!_isUploading) { if (!_isUploading) {
if (_uploadableFiles.length - 1 <= 0) { if (_uploadableFiles.length == 1) {
Navigator.of(context).pop(); Navigator.of(context).pop();
} }
setState(() { final removed = _uploadableFiles[index];
_uploadableFiles.removeAt(index); setState(() => _uploadableFiles.removeAt(index));
}); // Its TextField is still mounted until the rebuild.
WidgetsBinding.instance.addPostFrameCallback(
(_) => removed.fileNameController.dispose(),
);
} }
}, },
icon: const Icon(Icons.delete_outlined), icon: const Icon(Icons.delete_outlined),
@@ -342,7 +338,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
child: const Text('Abbrechen'), child: const Text('Abbrechen'),
), ),
), ),
const Expanded(child: SizedBox.shrink()), const Spacer(),
Visibility( Visibility(
visible: _isUploading, visible: _isUploading,
replacement: TextButton( replacement: TextButton(
@@ -3,6 +3,7 @@ import 'package:flutter/foundation.dart';
import '../../../../api/marianumcloud/search/search_files.dart'; import '../../../../api/marianumcloud/search/search_files.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../utils/debouncer.dart'; import '../../../../utils/debouncer.dart';
import '../data/webdav_path.dart';
import 'local_cache_search.dart'; import 'local_cache_search.dart';
/// Holds the live state of a Files-search session: current query, the latest /// Holds the live state of a Files-search session: current query, the latest
@@ -28,11 +29,10 @@ class FilesSearchController extends ChangeNotifier {
bool _disposed = false; bool _disposed = false;
Future<List<CacheableFile>>? _localIndex; Future<List<CacheableFile>>? _localIndex;
Future<List<CacheableFile>> _searchLocal(List<String> pathScope) async => Future<List<CacheableFile>> _searchLocal() async => searchLocalCacheIndex(
searchLocalCacheIndex(
await (_localIndex ??= loadLocalCacheIndex()), await (_localIndex ??= loadLocalCacheIndex()),
_query, _query,
pathScope: pathScope, pathScope: _pathScope,
); );
/// Guards against the race where the search delegate is closed (and the /// Guards against the race where the search delegate is closed (and the
@@ -45,10 +45,7 @@ class FilesSearchController extends ChangeNotifier {
} }
String get query => _query; String get query => _query;
List<String> get pathScope => List.unmodifiable(_pathScope);
bool get isScoped => _pathScope.isNotEmpty; bool get isScoped => _pathScope.isNotEmpty;
List<CacheableFile> get cacheResults => _cacheResults;
List<CacheableFile> get serverResults => _serverResults;
bool get serverLoading => _serverLoading; bool get serverLoading => _serverLoading;
Object? get serverError => _serverError; Object? get serverError => _serverError;
@@ -81,18 +78,7 @@ class FilesSearchController extends ChangeNotifier {
_safeNotify(); _safeNotify();
return; return;
} }
// Show loading immediately — even before the (typically fast) cache await _searchLocalThenServer(epoch);
// scan resolves — so the indicator is visible the moment the user
// starts typing rather than after the first await hop.
_serverLoading = true;
_serverError = null;
_safeNotify();
final cacheHits = await _searchLocal(_pathScope);
if (epoch != _serverEpoch) return;
_cacheResults = cacheHits;
_safeNotify();
_scheduleServerCall();
} }
/// Drops the path filter and re-runs the current search globally. Used by /// Drops the path filter and re-runs the current search globally. Used by
@@ -105,11 +91,18 @@ class FilesSearchController extends ChangeNotifier {
_safeNotify(); _safeNotify();
return; return;
} }
await _searchLocalThenServer(epoch);
}
Future<void> _searchLocalThenServer(int epoch) async {
// Show loading immediately — even before the (typically fast) cache
// scan resolves — so the indicator is visible the moment the user
// starts typing rather than after the first await hop.
_serverLoading = true; _serverLoading = true;
_serverError = null; _serverError = null;
_safeNotify(); _safeNotify();
final cacheHits = await _searchLocal(const []); final cacheHits = await _searchLocal();
if (epoch != _serverEpoch) return; if (epoch != _serverEpoch) return;
_cacheResults = cacheHits; _cacheResults = cacheHits;
_safeNotify(); _safeNotify();
@@ -135,7 +128,7 @@ class FilesSearchController extends ChangeNotifier {
Future<void> _runServerCall() async { Future<void> _runServerCall() async {
final epoch = _serverEpoch; final epoch = _serverEpoch;
final term = _query; final term = _query;
final scopePrefix = _pathScope.isEmpty ? '' : '${_pathScope.join('/')}/'; final scopePrefix = webdavFolderPath(_pathScope);
try { try {
final response = await _api.run(term: term); final response = await _api.run(term: term);
if (epoch != _serverEpoch) return; if (epoch != _serverEpoch) return;
@@ -38,13 +38,7 @@ class FilesSearchDelegate extends SearchDelegate<void> {
); );
@override @override
Widget buildResults(BuildContext context) { Widget buildResults(BuildContext context) => buildSuggestions(context);
_controller.setQuery(query);
return FilesSearchResults(
controller: _controller,
onResultTap: () => close(context, null),
);
}
@override @override
Widget buildSuggestions(BuildContext context) { Widget buildSuggestions(BuildContext context) {
@@ -11,6 +11,7 @@ import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_
import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart'; import '../../../../state/app/infrastructure/loadable_state/view/loadable_state_primary_loading.dart';
import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../../widget/placeholder_view.dart'; import '../../../../widget/placeholder_view.dart';
import '../data/webdav_path.dart';
import '../widgets/file_element.dart'; import '../widgets/file_element.dart';
import 'files_search_controller.dart'; import 'files_search_controller.dart';
@@ -80,8 +81,8 @@ class FilesSearchResults extends StatelessWidget {
visible: showErrorScreen, visible: showErrorScreen,
message: errorMessage, message: errorMessage,
), ),
if (showEmpty) _emptyState(context), if (showEmpty) _emptyState(),
if (hasContent) _resultList(context, combined), if (hasContent) _resultList(combined),
], ],
), ),
), ),
@@ -89,7 +90,7 @@ class FilesSearchResults extends StatelessWidget {
); );
} }
Widget _emptyState(BuildContext context) => PlaceholderView( Widget _emptyState() => PlaceholderView(
icon: Icons.search_off_outlined, icon: Icons.search_off_outlined,
text: 'Keine Treffer gefunden.', text: 'Keine Treffer gefunden.',
button: controller.isScoped button: controller.isScoped
@@ -101,7 +102,7 @@ class FilesSearchResults extends StatelessWidget {
: null, : null,
); );
Widget _resultList(BuildContext context, List<CacheableFile> combined) { Widget _resultList(List<CacheableFile> combined) {
final groups = _groupByParent(combined); final groups = _groupByParent(combined);
final orderedKeys = groups.keys.toList()..sort(); final orderedKeys = groups.keys.toList()..sort();
// Flat (folder header | file) rows built lazily: results can run into // Flat (folder header | file) rows built lazily: results can run into
@@ -117,7 +118,7 @@ class FilesSearchResults extends StatelessWidget {
itemCount: rows.length, itemCount: rows.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final (folder, file) = rows[index]; final (folder, file) = rows[index];
final segments = _segmentsOf(folder); final segments = webdavSegmentsOf(folder);
if (file == null) { if (file == null) {
return _FolderHeader( return _FolderHeader(
key: ValueKey('folder:$folder'), key: ValueKey('folder:$folder'),
@@ -139,29 +140,14 @@ class FilesSearchResults extends StatelessWidget {
); );
} }
static final RegExp _edgeSlashes = RegExp(r'^/+|/+$');
Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) { Map<String, List<CacheableFile>> _groupByParent(List<CacheableFile> files) {
final map = <String, List<CacheableFile>>{}; final map = <String, List<CacheableFile>>{};
for (final file in files) { for (final file in files) {
map.putIfAbsent(_parentOf(file), () => []).add(file); final parent = '/${webdavParentSegmentsOf(file.path).join('/')}';
map.putIfAbsent(parent, () => []).add(file);
} }
return map; return map;
} }
String _parentOf(CacheableFile file) {
final stripped = file.path.replaceAll(_edgeSlashes, '');
final segments = stripped.split('/');
if (segments.length <= 1) return '/';
segments.removeLast();
return '/${segments.join('/')}';
}
List<String> _segmentsOf(String folder) {
final stripped = folder.replaceAll(_edgeSlashes, '');
if (stripped.isEmpty) return const [];
return stripped.split('/');
}
} }
class _FolderHeader extends StatelessWidget { class _FolderHeader extends StatelessWidget {
@@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart';
import '../../../../api/cache_store.dart'; import '../../../../api/cache_store.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/cacheable_file.dart';
import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
import '../data/webdav_path.dart';
/// Document key prefix used by `ListFilesCache._documentId`. /// Document key prefix used by `ListFilesCache._documentId`.
const String _folderCachePrefix = 'wd-folder-'; const String _folderCachePrefix = 'wd-folder-';
@@ -42,18 +43,16 @@ List<CacheableFile> buildLocalCacheIndex(List<String> payloads) {
/// Files in [index] whose name contains [query] (case-insensitive). /// Files in [index] whose name contains [query] (case-insensitive).
/// ///
/// [pathScope] restricts results to entries whose WebDAV path starts with /// [pathScope] restricts results to entries whose WebDAV path starts with
/// the given folder. Pass an empty list (or null) to search globally. /// the given folder. Pass an empty list to search globally.
List<CacheableFile> searchLocalCacheIndex( List<CacheableFile> searchLocalCacheIndex(
List<CacheableFile> index, List<CacheableFile> index,
String query, { String query, {
List<String>? pathScope, List<String> pathScope = const [],
}) { }) {
final trimmed = query.trim(); final trimmed = query.trim();
if (trimmed.isEmpty) return const []; if (trimmed.isEmpty) return const [];
final needle = trimmed.toLowerCase(); final needle = trimmed.toLowerCase();
final scopePrefix = pathScope == null || pathScope.isEmpty final scopePrefix = webdavFolderPath(pathScope);
? ''
: '${pathScope.join('/')}/';
return [ return [
for (final file in index) for (final file in index)
if (file.lowerName.contains(needle) && if (file.lowerName.contains(needle) &&
@@ -21,6 +21,18 @@ import 'share_password_sheet.dart';
String _two(int n) => n.toString().padLeft(2, '0'); String _two(int n) => n.toString().padLeft(2, '0');
String _isoDate(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}'; String _isoDate(DateTime d) => '${d.year}-${_two(d.month)}-${_two(d.day)}';
/// Token of the Talk room a share points to; null for other share kinds.
String? talkRoomTokenOf(Share share) {
final token = share.shareWith;
return share.isRoom && token != null && token.isNotEmpty ? token : null;
}
/// Opens the Talk room and closes the sheet it was opened from.
void openShareInTalk(BuildContext context, String token) {
AppRoutes.openChatByToken(context, token);
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
}
IconData shareIcon(Share share) { IconData shareIcon(Share share) {
if (share.isPublicLink) return Icons.link; if (share.isPublicLink) return Icons.link;
if (share.isGroup) return Icons.groups_outlined; if (share.isGroup) return Icons.groups_outlined;
@@ -85,6 +97,9 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
} }
} }
Future<void> _update(ShareUpdateParams params) =>
_mutate(() => FileSharingApi().update(_share.id, params));
List<SharePreset> _availablePresets() => [ List<SharePreset> _availablePresets() => [
SharePreset.readOnly, SharePreset.readOnly,
SharePreset.edit, SharePreset.edit,
@@ -93,9 +108,7 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
Future<void> _setPreset(SharePreset preset) async { Future<void> _setPreset(SharePreset preset) async {
final caps = context.read<NextcloudCapabilitiesCubit>(); final caps = context.read<NextcloudCapabilitiesCubit>();
await _mutate( await _update(
() => FileSharingApi().update(
_share.id,
ShareUpdateParams( ShareUpdateParams(
permissions: permissionsFor( permissions: permissionsFor(
preset, preset,
@@ -103,7 +116,6 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
isFolder: _share.isFolder, isFolder: _share.isFolder,
), ),
), ),
),
); );
} }
@@ -121,20 +133,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
lastDate: lastDate, lastDate: lastDate,
); );
if (picked == null) return; if (picked == null) return;
await _mutate( await _update(ShareUpdateParams(expireDate: _isoDate(picked)));
() => FileSharingApi().update(
_share.id,
ShareUpdateParams(expireDate: _isoDate(picked)),
),
);
} }
Future<void> _clearExpiry() => _mutate( Future<void> _clearExpiry() =>
() => FileSharingApi().update( _update(const ShareUpdateParams(expireDate: ''));
_share.id,
const ShareUpdateParams(expireDate: ''),
),
);
Future<void> _changePassword() async { Future<void> _changePassword() async {
final caps = context.read<NextcloudCapabilitiesCubit>(); final caps = context.read<NextcloudCapabilitiesCubit>();
@@ -144,27 +147,11 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
policyHint: caps.passwordPolicyHint, policyHint: caps.passwordPolicyHint,
); );
if (value == null || value.isEmpty || !mounted) return; if (value == null || value.isEmpty || !mounted) return;
await _mutate( await _update(ShareUpdateParams(password: value));
() => FileSharingApi().update(
_share.id,
ShareUpdateParams(password: value),
),
);
} }
Future<void> _clearPassword() => _mutate( Future<void> _clearPassword() =>
() => FileSharingApi().update( _update(const ShareUpdateParams(password: ''));
_share.id,
const ShareUpdateParams(password: ''),
),
);
void _openInTalk() {
final token = _share.shareWith;
if (token == null || token.isEmpty) return;
AppRoutes.openChatByToken(context, token);
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
}
Future<void> _confirmDelete() async { Future<void> _confirmDelete() async {
// Use showDialog directly (not asDialog) so we get the AsyncDialogAction's // Use showDialog directly (not asDialog) so we get the AsyncDialogAction's
@@ -217,12 +204,12 @@ class _ShareOptionsBodyState extends State<_ShareOptionsBody> {
), ),
), ),
if (_share.isRoom && (_share.shareWith?.isNotEmpty ?? false)) ...[ if (talkRoomTokenOf(_share) case final token?) ...[
const Divider(height: 24), const Divider(height: 24),
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)), leading: const CenteredLeading(Icon(Icons.chat_bubble_outline)),
title: const Text('Im Talk-Chat öffnen'), title: const Text('Im Talk-Chat öffnen'),
onTap: _busy ? null : _openInTalk, onTap: _busy ? null : () => openShareInTalk(context, token),
), ),
], ],
+5 -18
View File
@@ -47,19 +47,13 @@ class _ShareSheetBody extends StatefulWidget {
class _ShareSheetBodyState extends State<_ShareSheetBody> { class _ShareSheetBodyState extends State<_ShareSheetBody> {
late final String _ocsPath = ocsPathOf(widget.file); late final String _ocsPath = ocsPathOf(widget.file);
Future<List<Share>>? _future; late Future<List<Share>> _future = FileSharingApi().listForPath(_ocsPath);
bool _busy = false; bool _busy = false;
/// Last resolved share list — used by the create-link gate without depending /// Last resolved share list — used by the create-link gate without depending
/// on the FutureBuilder's snapshot. /// on the FutureBuilder's snapshot.
List<Share>? _lastShares; List<Share>? _lastShares;
@override
void initState() {
super.initState();
_future = FileSharingApi().listForPath(_ocsPath);
}
void _reload() { void _reload() {
// Block body: an arrow would return the Future, which setState rejects. // Block body: an arrow would return the Future, which setState rejects.
setState(() { setState(() {
@@ -144,26 +138,19 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
leading: const CenteredLeading(Icon(Icons.person_add_outlined)), leading: const CenteredLeading(Icon(Icons.person_add_outlined)),
title: const Text('Person oder Gruppe hinzufügen'), title: const Text('Person oder Gruppe hinzufügen'),
enabled: !_busy, enabled: !_busy,
onTap: _busy ? null : _addSharee, onTap: _addSharee,
), ),
if (caps.canCreatePublicLinks && _canAddLink(caps)) if (caps.canCreatePublicLinks && _canAddLink(caps))
ListTile( ListTile(
leading: const CenteredLeading(Icon(Icons.add_link)), leading: const CenteredLeading(Icon(Icons.add_link)),
title: const Text('Öffentlichen Link erstellen'), title: const Text('Öffentlichen Link erstellen'),
enabled: !_busy, enabled: !_busy,
onTap: _busy ? null : _createPublicLink, onTap: _createPublicLink,
), ),
], ],
); );
} }
void _openInTalk(Share share) {
final token = share.shareWith;
if (token == null || token.isEmpty) return;
AppRoutes.openChatByToken(context, token);
if (Navigator.of(context).canPop()) Navigator.of(context).pop();
}
/// Whether another public link may be created — hidden once one exists on a /// Whether another public link may be created — hidden once one exists on a
/// server that disallows multiple links. /// server that disallows multiple links.
bool _canAddLink(NextcloudCapabilitiesCubit caps) { bool _canAddLink(NextcloudCapabilitiesCubit caps) {
@@ -271,11 +258,11 @@ class _ShareSheetBodyState extends State<_ShareSheetBody> {
tooltip: 'Link kopieren', tooltip: 'Link kopieren',
onPressed: () => copyToClipboard(context, share.url!), onPressed: () => copyToClipboard(context, share.url!),
), ),
if (share.isRoom && (share.shareWith?.isNotEmpty ?? false)) if (talkRoomTokenOf(share) case final token?)
IconButton( IconButton(
icon: const Icon(Icons.chat_bubble_outline), icon: const Icon(Icons.chat_bubble_outline),
tooltip: 'Im Talk-Chat öffnen', tooltip: 'Im Talk-Chat öffnen',
onPressed: () => _openInTalk(share), onPressed: () => openShareInTalk(context, token),
), ),
IconButton( IconButton(
icon: const Icon(Icons.settings_outlined), icon: const Icon(Icons.settings_outlined),
@@ -5,6 +5,7 @@ import '../../../../api/marianumcloud/webdav/queries/list_files/list_files_cache
import '../../../../api/marianumcloud/webdav/webdav_api.dart'; import '../../../../api/marianumcloud/webdav/webdav_api.dart';
import '../../../../utils/file_clipboard.dart'; import '../../../../utils/file_clipboard.dart';
import '../../../../widget/info_dialog.dart'; import '../../../../widget/info_dialog.dart';
import '../data/webdav_path.dart';
/// Banner that appears at the top of a Files folder while there is something /// Banner that appears at the top of a Files folder while there is something
/// in the file clipboard. Shows the cut/copy state and offers a "Hier /// in the file clipboard. Shows the cut/copy state and offers a "Hier
@@ -26,16 +27,6 @@ class ClipboardBanner extends StatefulWidget {
class _ClipboardBannerState extends State<ClipboardBanner> { class _ClipboardBannerState extends State<ClipboardBanner> {
bool _busy = false; bool _busy = false;
// All paths here are relative to the WebDAV root (matching `CacheableFile.path`).
// Root is the empty string ''. Folders end with '/'.
String _normalised(String path) {
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
return stripped.isEmpty ? '' : '$stripped/';
}
String _joinPath(String folder, String name, {required bool isDirectory}) =>
isDirectory ? '$folder$name/' : '$folder$name';
// Disabled when: // Disabled when:
// - clipboard is empty // - clipboard is empty
// - we'd be pasting a folder into itself or one of its descendants // - we'd be pasting a folder into itself or one of its descendants
@@ -43,14 +34,14 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
bool get _canPaste { bool get _canPaste {
final cb = FileClipboard.instance; final cb = FileClipboard.instance;
if (cb.isEmpty) return false; if (cb.isEmpty) return false;
final dst = _normalised(widget.currentFolder); final dst = webdavFolderPath(webdavSegmentsOf(widget.currentFolder));
var atLeastOneActionable = false; var atLeastOneActionable = false;
for (final f in cb.files) { for (final f in cb.files) {
if (f.isDirectory) { if (f.isDirectory) {
final src = _normalised(f.path); final src = webdavFolderPath(webdavSegmentsOf(f.path));
if (dst == src || dst.startsWith(src)) return false; if (dst == src || dst.startsWith(src)) return false;
} }
final destination = _joinPath( final destination = webdavJoin(
widget.currentFolder, widget.currentFolder,
f.name, f.name,
isDirectory: f.isDirectory, isDirectory: f.isDirectory,
@@ -60,15 +51,6 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
return atLeastOneActionable; return atLeastOneActionable;
} }
// Cache key format used by ListFilesCache (matches FilesBloc's pathString:
// relative, no leading or trailing slash; root is '/').
String _parentCacheKey(String relativePath) {
final stripped = relativePath.replaceAll(RegExp(r'^/+|/+$'), '');
if (!stripped.contains('/')) return '/';
final parts = stripped.split('/')..removeLast();
return parts.isEmpty ? '/' : parts.join('/');
}
Future<void> _paste() async { Future<void> _paste() async {
final cb = FileClipboard.instance; final cb = FileClipboard.instance;
if (_busy || !_canPaste) return; if (_busy || !_canPaste) return;
@@ -79,7 +61,7 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
try { try {
final webdav = await WebdavApi.webdav; final webdav = await WebdavApi.webdav;
for (final file in cb.files) { for (final file in cb.files) {
final destination = _joinPath( final destination = webdavJoin(
widget.currentFolder, widget.currentFolder,
file.name, file.name,
isDirectory: file.isDirectory, isDirectory: file.isDirectory,
@@ -91,7 +73,9 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
PathUri.parse(file.path), PathUri.parse(file.path),
PathUri.parse(destination), PathUri.parse(destination),
); );
invalidatedSourceFolders.add(_parentCacheKey(file.path)); invalidatedSourceFolders.add(
listFilesCacheKey(webdavParentSegmentsOf(file.path)),
);
} else { } else {
await webdav.copy( await webdav.copy(
PathUri.parse(file.path), PathUri.parse(file.path),
@@ -105,9 +89,9 @@ class _ClipboardBannerState extends State<ClipboardBanner> {
// After cut, the source folders no longer contain the moved files. Drop // After cut, the source folders no longer contain the moved files. Drop
// their cached listings so the next visit fetches fresh data instead of // their cached listings so the next visit fetches fresh data instead of
// briefly showing the moved file as still present. // briefly showing the moved file as still present.
for (final folder in invalidatedSourceFolders) { await Future.wait(
await ListFilesCache.invalidate(folder); invalidatedSourceFolders.map(ListFilesCache.invalidate),
} );
if (operation == FileClipboardOperation.cut) cb.clear(); if (operation == FileClipboardOperation.cut) cb.clear();
widget.onPasteDone(); widget.onPasteDone();
} finally { } finally {
+5 -17
View File
@@ -19,6 +19,7 @@ import '../../../../widget/details_bottom_sheet.dart';
import '../../../../widget/downloads/download_trigger.dart'; import '../../../../widget/downloads/download_trigger.dart';
import '../../../../widget/prompt_dialog.dart'; import '../../../../widget/prompt_dialog.dart';
import '../../talk/widgets/highlighted_linkify.dart'; import '../../talk/widgets/highlighted_linkify.dart';
import '../data/webdav_path.dart';
import '../sharing/share_sheet.dart'; import '../sharing/share_sheet.dart';
import 'file_details_sheet.dart'; import 'file_details_sheet.dart';
import 'file_leading.dart'; import 'file_leading.dart';
@@ -26,7 +27,7 @@ import 'file_leading.dart';
class FileElement extends StatefulWidget { class FileElement extends StatefulWidget {
final CacheableFile file; final CacheableFile file;
final List<String> path; final List<String> path;
final void Function() refetch; final VoidCallback refetch;
/// When non-null, occurrences of this string in the file name are visually /// When non-null, occurrences of this string in the file name are visually
/// highlighted in the tile title. Used by the Files search delegate. /// highlighted in the tile title. Used by the Files search delegate.
@@ -101,7 +102,7 @@ class _FileElementState extends State<FileElement>
if (widget.file.isDirectory) { if (widget.file.isDirectory) {
AppRoutes.openFolder( AppRoutes.openFolder(
context, context,
widget.path.toList()..add(widget.file.name), [...widget.path, widget.file.name],
); );
return; return;
} }
@@ -116,18 +117,6 @@ class _FileElementState extends State<FileElement>
); );
} }
// All paths here are relative to the WebDAV root (matching CacheableFile.path).
// Root parent is the empty string ''. Folders end with '/'.
String _parentPathOf(String path) {
final stripped = path.replaceAll(RegExp(r'^/+|/+$'), '');
if (!stripped.contains('/')) return '';
final parts = stripped.split('/')..removeLast();
return parts.isEmpty ? '' : '${parts.join('/')}/';
}
String _joinPath(String folder, String name, {required bool isDirectory}) =>
isDirectory ? '$folder$name/' : '$folder$name';
void _rename() { void _rename() {
if (guardDemoAction(context)) return; if (guardDemoAction(context)) return;
showPromptDialog( showPromptDialog(
@@ -138,9 +127,8 @@ class _FileElementState extends State<FileElement>
initialValue: widget.file.name, initialValue: widget.file.name,
onConfirm: (newName) async { onConfirm: (newName) async {
if (newName.isEmpty || newName == widget.file.name) return; if (newName.isEmpty || newName == widget.file.name) return;
final parent = _parentPathOf(widget.file.path); final destination = webdavJoin(
final destination = _joinPath( webdavFolderPath(webdavParentSegmentsOf(widget.file.path)),
parent,
newName, newName,
isDirectory: widget.file.isDirectory, isDirectory: widget.file.isDirectory,
); );
@@ -1,24 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../data/sort_options.dart'; import '../data/sort_options.dart';
/// Sort direction and sort key pickers, read from and persisted to the file
/// settings.
class FilesSortActions extends StatelessWidget { class FilesSortActions extends StatelessWidget {
final SortOption currentSort; const FilesSortActions({super.key});
final bool ascending;
final ValueChanged<bool> onDirectionChanged;
final ValueChanged<SortOption> onSortChanged;
const FilesSortActions({
required this.currentSort,
required this.ascending,
required this.onDirectionChanged,
required this.onSortChanged,
super.key,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final settings = context.read<SettingsCubit>();
final (currentSort, ascending) = context.select(
(SettingsCubit c) =>
(c.state.fileSettings.sortBy, c.state.fileSettings.ascending),
);
return Row( return Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -46,7 +44,7 @@ class FilesSortActions extends StatelessWidget {
), ),
) )
.toList(), .toList(),
onSelected: onDirectionChanged, onSelected: (e) => settings.val(write: true).fileSettings.ascending = e,
), ),
PopupMenuButton<SortOption>( PopupMenuButton<SortOption>(
tooltip: tooltip:
@@ -70,7 +68,7 @@ class FilesSortActions extends StatelessWidget {
), ),
) )
.toList(), .toList(),
onSelected: onSortChanged, onSelected: (e) => settings.val(write: true).fileSettings.sortBy = e,
), ),
], ],
); );
@@ -12,6 +12,7 @@ import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/timetable_favorites_settings.dart'; import '../../../storage/timetable_favorites_settings.dart';
import '../../../utils/haptics.dart'; import '../../../utils/haptics.dart';
import '../../../widget/app_progress_indicator.dart'; import '../../../widget/app_progress_indicator.dart';
import 'element_type_icon.dart';
/// Full-screen picker: choose an element type (or "Alle" to search across all /// Full-screen picker: choose an element type (or "Alle" to search across all
/// types), filter the (potentially large) list client-side, and pick an element /// types), filter the (potentially large) list client-side, and pick an element
@@ -221,7 +222,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
return Padding( return Padding(
padding: const EdgeInsets.only(right: 8), padding: const EdgeInsets.only(right: 8),
child: ChoiceChip( child: ChoiceChip(
avatar: Icon(type == null ? Icons.apps : _iconFor(type), size: 18), avatar: Icon(type == null ? Icons.apps : type.icon, size: 18),
showCheckmark: false, showCheckmark: false,
label: Text(type?.label ?? 'Alle'), label: Text(type?.label ?? 'Alle'),
selected: _selectedType == type, selected: _selectedType == type,
@@ -293,7 +294,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
} }
} }
final item = filtered[index - headerCount]; final item = filtered[index - headerCount];
return _itemTile(item); return _itemTile(item, favorites);
}, },
); );
}, },
@@ -316,7 +317,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
Widget _favoriteTile(FavoriteTimetableElement favorite) { Widget _favoriteTile(FavoriteTimetableElement favorite) {
return ListTile( return ListTile(
leading: Icon(_iconFor(favorite.type)), leading: Icon(favorite.type.icon),
title: Text(favorite.label), title: Text(favorite.label),
subtitle: Text(favorite.type.label), subtitle: Text(favorite.type.label),
trailing: IconButton( trailing: IconButton(
@@ -329,12 +330,10 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
); );
} }
Widget _itemTile(_PickerItem item) { Widget _itemTile(_PickerItem item, List<FavoriteTimetableElement> favorites) {
final isFavorite = context final isFavorite = favorites.any(
.watch<SettingsCubit>() (f) => f.type == item.type && f.id == item.id,
.val() );
.timetableFavoritesSettings
.isFavorite(item.type, item.id);
// In the "Alle" tab the type is otherwise ambiguous, so surface it. // In the "Alle" tab the type is otherwise ambiguous, so surface it.
final subtitleParts = <String>[ final subtitleParts = <String>[
@@ -344,7 +343,7 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
]; ];
return ListTile( return ListTile(
leading: Icon(_iconFor(item.type)), leading: Icon(item.type.icon),
title: Text(item.primary), title: Text(item.primary),
subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')), subtitle: subtitleParts.isEmpty ? null : Text(subtitleParts.join(' · ')),
trailing: IconButton( trailing: IconButton(
@@ -355,19 +354,6 @@ class _ElementPickerPageState extends State<ElementPickerPage> {
onTap: () => _open(item), onTap: () => _open(item),
); );
} }
static IconData _iconFor(TimetableElementType type) {
switch (type) {
case TimetableElementType.student:
return Icons.person_outline;
case TimetableElementType.teacher:
return Icons.school_outlined;
case TimetableElementType.room:
return Icons.meeting_room_outlined;
case TimetableElementType.schoolClass:
return Icons.groups_outlined;
}
}
} }
class _PickerItem { class _PickerItem {
@@ -0,0 +1,12 @@
import 'package:flutter/material.dart';
import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
extension TimetableElementTypeIcon on TimetableElementType {
IconData get icon => switch (this) {
TimetableElementType.student => Icons.person_outline,
TimetableElementType.teacher => Icons.school_outlined,
TimetableElementType.room => Icons.meeting_room_outlined,
TimetableElementType.schoolClass => Icons.groups_outlined,
};
}
@@ -15,14 +15,14 @@ class GradeAveragesView extends StatelessWidget {
create: (context) => GradeAveragesBloc(), create: (context) => GradeAveragesBloc(),
child: BlocBuilder<GradeAveragesBloc, GradeAveragesState>( child: BlocBuilder<GradeAveragesBloc, GradeAveragesState>(
builder: (context, state) { builder: (context, state) {
var bloc = context.watch<GradeAveragesBloc>(); final bloc = context.read<GradeAveragesBloc>();
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Notendurchschnittsrechner'), title: const Text('Notendurchschnittsrechner'),
actions: [ actions: [
Visibility( Visibility(
visible: bloc.state.grades.isNotEmpty, visible: state.grades.isNotEmpty,
child: IconButton( child: IconButton(
tooltip: 'Alle zurücksetzen', tooltip: 'Alle zurücksetzen',
onPressed: () => ConfirmDialog( onPressed: () => ConfirmDialog(
@@ -59,7 +59,7 @@ class GradeAveragesView extends StatelessWidget {
) )
.toList(), .toList(),
onSelected: (isMiddleSchool) { onSelected: (isMiddleSchool) {
if (bloc.state.grades.isNotEmpty) { if (state.grades.isNotEmpty) {
ConfirmDialog( ConfirmDialog(
title: 'Notensystem wechseln', title: 'Notensystem wechseln',
content: content:
+1 -1
View File
@@ -78,7 +78,7 @@ class HolidaysView extends StatelessWidget {
}, },
child: (state, loading) { child: (state, loading) {
final holidays = bloc.getHolidays(); final holidays = bloc.getHolidays();
if (holidays == null || holidays.isEmpty) { if (holidays.isEmpty) {
return const PlaceholderView( return const PlaceholderView(
icon: Icons.beach_access_outlined, icon: Icons.beach_access_outlined,
text: 'Keine Schulferien verfügbar', text: 'Keine Schulferien verfügbar',
@@ -71,20 +71,16 @@ class MarianumDatesView extends StatelessWidget {
IconButton( IconButton(
tooltip: 'Suchen', tooltip: 'Suchen',
icon: const Icon(Icons.search), icon: const Icon(Icons.search),
onPressed: () { onPressed: () => showSearch(
final events = bloc.getEvents() ?? const <MarianumDate>[];
showSearch(
context: context, context: context,
delegate: SearchMarianumDates(events), delegate: SearchMarianumDates(bloc.getEvents()),
); ),
},
), ),
], ],
), ),
body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>( body: LoadableStateConsumer<MarianumDatesBloc, MarianumDatesState>(
child: (state, loading) { child: (state, loading) {
final events = bloc.getEvents() ?? const <MarianumDate>[]; final groups = _groupByMonth(bloc.getEvents());
final groups = _groupByMonth(events);
if (groups.isEmpty) { if (groups.isEmpty) {
return const PlaceholderView( return const PlaceholderView(
+4 -2
View File
@@ -17,6 +17,8 @@ class Overhang extends StatefulWidget {
} }
class _OverhangState extends State<Overhang> { class _OverhangState extends State<Overhang> {
late final Future<bool> _reviewAvailable = InAppReview.instance.isAvailable();
@override @override
Widget build(BuildContext context) => Scaffold( Widget build(BuildContext context) => Scaffold(
appBar: AppBar( appBar: AppBar(
@@ -48,7 +50,7 @@ class _OverhangState extends State<Overhang> {
onTap: () => AppRoutes.openQrShare(context), onTap: () => AppRoutes.openQrShare(context),
), ),
FutureBuilder( FutureBuilder(
future: InAppReview.instance.isAvailable(), future: _reviewAvailable,
builder: (context, snapshot) { builder: (context, snapshot) {
String? getPlatformStoreName() { String? getPlatformStoreName() {
if (Platform.isAndroid) return 'Play store'; if (Platform.isAndroid) return 'Play store';
@@ -83,7 +85,7 @@ class _OverhangState extends State<Overhang> {
(data) => Text('Im $data'), (data) => Text('Im $data'),
), ),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: !ready ? null : rate onTap: !ready ? null : rate,
); );
}, },
), ),
@@ -9,6 +9,7 @@ import '../../../storage/chat_background_settings.dart';
import '../../../storage/settings.dart' as model; import '../../../storage/settings.dart' as model;
import '../../../utils/app_paths.dart'; import '../../../utils/app_paths.dart';
import '../../../utils/haptics.dart'; import '../../../utils/haptics.dart';
import '../../../utils/json_equality.dart';
import '../../../widget/async_action_button.dart'; import '../../../widget/async_action_button.dart';
import '../../../widget/chat_background.dart'; import '../../../widget/chat_background.dart';
import '../../../widget/chat_background_picker_sheet.dart'; import '../../../widget/chat_background_picker_sheet.dart';
@@ -43,9 +44,10 @@ class ChatBackgroundSettingsPage extends StatelessWidget {
builder: (context, _) { builder: (context, _) {
final settings = context.read<SettingsCubit>(); final settings = context.read<SettingsCubit>();
final s = settings.val().chatBackgroundSettings; final s = settings.val().chatBackgroundSettings;
final isModified = final isModified = !sameJson(
s.toJson().toString() != s,
DefaultSettings.get().chatBackgroundSettings.toJson().toString(); DefaultSettings.get().chatBackgroundSettings,
);
final showImageControls = final showImageControls =
s.type == ChatBackgroundType.pattern || s.type == ChatBackgroundType.pattern ||
s.type == ChatBackgroundType.image; s.type == ChatBackgroundType.image;
@@ -5,6 +5,7 @@ import '../../../state/app/modules/app_modules.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/settings.dart' as model; import '../../../storage/settings.dart' as model;
import '../../../utils/haptics.dart'; import '../../../utils/haptics.dart';
import '../../../utils/json_equality.dart';
import '../../../widget/confirm_dialog.dart'; import '../../../widget/confirm_dialog.dart';
import 'data/default_settings.dart'; import 'data/default_settings.dart';
@@ -147,9 +148,10 @@ class ModulesSettingsPage extends StatelessWidget {
BlocBuilder<SettingsCubit, model.Settings>( BlocBuilder<SettingsCubit, model.Settings>(
builder: (context, _) { builder: (context, _) {
final settings = context.read<SettingsCubit>(); final settings = context.read<SettingsCubit>();
final isModified = final isModified = !sameJson(
settings.val().modulesSettings.toJson().toString() != settings.val().modulesSettings,
DefaultSettings.get().modulesSettings.toJson().toString(); DefaultSettings.get().modulesSettings,
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Module'), title: const Text('Module'),
@@ -50,10 +50,7 @@ class AboutSection extends StatelessWidget {
_toggleDeveloperMode(context, settings, state), _toggleDeveloperMode(context, settings, state),
), ),
), ),
Visibility( if (settings.val().devToolsEnabled) DevToolsSection(settings: settings),
visible: settings.val().devToolsEnabled,
child: DevToolsSection(settings: settings),
),
], ],
); );
} }
@@ -104,7 +101,9 @@ class AboutSection extends StatelessWidget {
Icon(Icons.send_time_extension_outlined), Icon(Icons.send_time_extension_outlined),
), ),
title: const Text('Infos zu mhsl'), title: const Text('Infos zu mhsl'),
subtitle: const Text('Für Push, Kalendertermine, Marianum Message und mehr'), subtitle: const Text(
'Für Push, Kalendertermine, Marianum Message und mehr',
),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => PrivacyInfo( onTap: () => PrivacyInfo(
providerText: 'mhsl', providerText: 'mhsl',
@@ -22,21 +22,14 @@ import '../../../../widget/user_avatar.dart';
String? _cachedDisplayName; String? _cachedDisplayName;
String? _cachedDisplayNameFor; String? _cachedDisplayNameFor;
class AccountSection extends StatelessWidget { class AccountSection extends StatefulWidget {
const AccountSection({super.key}); const AccountSection({super.key});
@override @override
Widget build(BuildContext context) => const _SchoolAccount(); State<AccountSection> createState() => _AccountSectionState();
} }
class _SchoolAccount extends StatefulWidget { class _AccountSectionState extends State<AccountSection> {
const _SchoolAccount();
@override
State<_SchoolAccount> createState() => _SchoolAccountState();
}
class _SchoolAccountState extends State<_SchoolAccount> {
int _avatarVersion = 0; int _avatarVersion = 0;
bool _avatarBusy = false; bool _avatarBusy = false;
String? _displayName; String? _displayName;
@@ -41,25 +41,15 @@ class AppearanceSection extends StatelessWidget {
); );
} }
IconData _hapticIcon(HapticLevel level) { IconData _hapticIcon(HapticLevel level) => switch (level) {
switch (level) { HapticLevel.off => Icons.notifications_off_outlined,
case HapticLevel.off: HapticLevel.reduced => Icons.notifications_paused_outlined,
return Icons.notifications_off_outlined; HapticLevel.full => Icons.notifications_active_outlined,
case HapticLevel.reduced: };
return Icons.notifications_paused_outlined;
case HapticLevel.full:
return Icons.notifications_active_outlined;
}
}
String _hapticLabel(HapticLevel level) { String _hapticLabel(HapticLevel level) => switch (level) {
switch (level) { HapticLevel.off => 'Aus',
case HapticLevel.off: HapticLevel.reduced => 'Reduziert',
return 'Aus'; HapticLevel.full => 'Vollständig',
case HapticLevel.reduced: };
return 'Reduziert';
case HapticLevel.full:
return 'Vollständig';
}
}
} }
@@ -26,15 +26,10 @@ import '../widgets/endpoint_picker.dart';
import '../widgets/settings_checkbox_tile.dart'; import '../widgets/settings_checkbox_tile.dart';
import '../widgets/settings_dropdown_tile.dart'; import '../widgets/settings_dropdown_tile.dart';
class DevToolsSection extends StatefulWidget { class DevToolsSection extends StatelessWidget {
final SettingsCubit settings; final SettingsCubit settings;
const DevToolsSection({required this.settings, super.key}); const DevToolsSection({required this.settings, super.key});
@override
State<DevToolsSection> createState() => _DevToolsSectionState();
}
class _DevToolsSectionState extends State<DevToolsSection> {
@override @override
Widget build(BuildContext context) => Column( Widget build(BuildContext context) => Column(
children: [ children: [
@@ -54,11 +49,11 @@ class _DevToolsSectionState extends State<DevToolsSection> {
context, context,
children: (sheetCtx) => [ children: (sheetCtx) => [
BlocBuilder<SettingsCubit, model.Settings>( BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings, bloc: settings,
builder: (_, _) { builder: (_, _) {
final dev = widget.settings.val().devToolsSettings; final dev = settings.val().devToolsSettings;
DevToolsSettings write() => DevToolsSettings write() =>
widget.settings.val(write: true).devToolsSettings; settings.val(write: true).devToolsSettings;
return Column( return Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@@ -122,17 +117,17 @@ class _DevToolsSectionState extends State<DevToolsSection> {
title: const Text('Marianum-Connect-Server'), title: const Text('Marianum-Connect-Server'),
subtitle: Text( subtitle: Text(
MarianumConnectEndpointPicker.labelFor( MarianumConnectEndpointPicker.labelFor(
widget.settings.val().devToolsSettings, settings.val().devToolsSettings,
), ),
), ),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => onTap: () =>
MarianumConnectEndpointPicker.show(context, widget.settings), MarianumConnectEndpointPicker.show(context, settings),
), ),
BlocBuilder<SettingsCubit, model.Settings>( BlocBuilder<SettingsCubit, model.Settings>(
bloc: widget.settings, bloc: settings,
builder: (_, _) { builder: (_, _) {
final override = widget.settings final override = settings
.val() .val()
.devToolsSettings .devToolsSettings
.emergencyNoticeUrl .emergencyNoticeUrl
@@ -143,7 +138,7 @@ class _DevToolsSectionState extends State<DevToolsSection> {
subtitle: Text(override.isEmpty ? 'Standardquelle' : override), subtitle: Text(override.isEmpty ? 'Standardquelle' : override),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
onTap: () => onTap: () =>
_EmergencyNoticeUrlEditor.show(context, widget.settings), _EmergencyNoticeUrlEditor.show(context, settings),
); );
}, },
), ),
@@ -152,18 +147,18 @@ class _DevToolsSectionState extends State<DevToolsSection> {
icon: Icons.settings_applications_outlined, icon: Icons.settings_applications_outlined,
title: 'Einstellungen', title: 'Einstellungen',
describe: () async => filesize( describe: () async => filesize(
utf8.encode(jsonEncode(widget.settings.val().toJson())).length, utf8.encode(jsonEncode(settings.val().toJson())).length,
), ),
onOpen: (_) async => AppRoutes.openJsonViewer( onOpen: (_) async => AppRoutes.openJsonViewer(
context, context,
widget.settings.val().toJson(), settings.val().toJson(),
title: 'Einstellungen', title: 'Einstellungen',
), ),
clearTitle: 'Einstellungen zurücksetzen', clearTitle: 'Einstellungen zurücksetzen',
clearContent: clearContent:
'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten ' 'Alle Einstellungen gehen verloren! Accountdaten sowie App-Daten '
'sind nicht betroffen.', 'sind nicht betroffen.',
onClear: widget.settings.reset, onClear: settings.reset,
), ),
_StorageTile( _StorageTile(
icon: Icons.account_tree_outlined, icon: Icons.account_tree_outlined,
@@ -5,11 +5,11 @@ import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/details_bottom_sheet.dart'; import '../../../../widget/details_bottom_sheet.dart';
class PrivacyInfo { class PrivacyInfo {
String providerText; final String providerText;
String privacyUrl; final String privacyUrl;
String imprintUrl; final String imprintUrl;
PrivacyInfo({ const PrivacyInfo({
required this.providerText, required this.providerText,
required this.imprintUrl, required this.imprintUrl,
required this.privacyUrl, required this.privacyUrl,
@@ -228,11 +228,7 @@ class _PushStatusBodyState extends State<_PushStatusBody>
/// otherwise re-registering IS the primary next step and the test action is /// otherwise re-registering IS the primary next step and the test action is
/// omitted (it could not succeed and the checklist explains why). /// omitted (it could not succeed and the checklist explains why).
Widget _actions(PushStatusReport report) { Widget _actions(PushStatusReport report) {
const spinner = SizedBox( const spinner = AppProgressIndicator.small();
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
);
final ready = report.readyForTestNotification; final ready = report.readyForTestNotification;
return Row( return Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -67,7 +67,12 @@ class ShareChatPicker extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final talkSettings = context.watch<SettingsCubit>().val().talkSettings; final (favoritesToTop, unreadToTop) = context.select(
(SettingsCubit c) => (
c.state.talkSettings.sortFavoritesToTop,
c.state.talkSettings.sortUnreadToTop,
),
);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
title: const Text('Talk-Chat auswählen'), title: const Text('Talk-Chat auswählen'),
@@ -100,8 +105,8 @@ class ShareChatPicker extends StatelessWidget {
if (rooms == null) return const SizedBox.shrink(); if (rooms == null) return const SizedBox.shrink();
final sorted = rooms final sorted = rooms
.sortBy( .sortBy(
favoritesToTop: talkSettings.sortFavoritesToTop, favoritesToTop: favoritesToTop,
unreadToTop: talkSettings.sortUnreadToTop, unreadToTop: unreadToTop,
) )
// Hide chats the user can't write to (announcement channels, // Hide chats the user can't write to (announcement channels,
// archived rooms, …) — uploading there would only fail at the // archived rooms, …) — uploading there would only fail at the
@@ -195,8 +200,8 @@ Future<void> _runShareFlow(
try { try {
await action(); await action();
} catch (e) { } catch (e) {
if (context.mounted) Navigator.of(context).pop();
if (context.mounted) { if (context.mounted) {
Navigator.of(context).pop();
InfoDialog.show( InfoDialog.show(
context, context,
errorToUserMessage(e), errorToUserMessage(e),
@@ -13,11 +13,10 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con
import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart';
import '../../../state/app/modules/files/bloc/files_bloc.dart'; import '../../../state/app/modules/files/bloc/files_bloc.dart';
import '../../../state/app/modules/files/bloc/files_state.dart'; import '../../../state/app/modules/files/bloc/files_state.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/centered_leading.dart'; import '../../../widget/centered_leading.dart';
import '../../../widget/info_dialog.dart'; import '../../../widget/info_dialog.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
import '../files/data/sort_options.dart'; import '../files/data/file_sort.dart';
import '../files/files_upload_dialog.dart'; import '../files/files_upload_dialog.dart';
import '../files/widgets/add_file_menu.dart'; import '../files/widgets/add_file_menu.dart';
import '../files/widgets/file_leading.dart'; import '../files/widgets/file_leading.dart';
@@ -73,22 +72,6 @@ class _ShareFolderPickerView extends StatefulWidget {
} }
class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> { class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
late final SettingsCubit _settings;
late SortOption _currentSort;
late bool _ascending;
@override
void initState() {
super.initState();
_settings = context.read<SettingsCubit>();
_currentSort = _settings.val().fileSettings.sortBy;
_ascending = _settings.val().fileSettings.ascending;
}
void _enter(FilesBloc bloc, List<String> currentPath, String folderName) {
bloc.setPath([...currentPath, folderName]);
}
void _goUp(FilesBloc bloc, List<String> currentPath) { void _goUp(FilesBloc bloc, List<String> currentPath) {
if (currentPath.isEmpty) return; if (currentPath.isEmpty) return;
bloc.setPath(currentPath.sublist(0, currentPath.length - 1)); bloc.setPath(currentPath.sublist(0, currentPath.length - 1));
@@ -97,6 +80,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bloc = context.read<FilesBloc>(); final bloc = context.read<FilesBloc>();
// Selected here, not in the LoadableStateConsumer child: that closure runs
// during the consumer's build, where this context may not subscribe.
final sort = context.select(fileSortOf);
return BlocBuilder<FilesBloc, LoadableState<FilesState>>( return BlocBuilder<FilesBloc, LoadableState<FilesState>>(
buildWhen: (a, b) => a.data?.currentPath != b.data?.currentPath, buildWhen: (a, b) => a.data?.currentPath != b.data?.currentPath,
builder: (_, outerState) { builder: (_, outerState) {
@@ -109,9 +95,9 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
canPop: currentPath.isEmpty, canPop: currentPath.isEmpty,
onPopInvokedWithResult: (didPop, _) { onPopInvokedWithResult: (didPop, _) {
if (didPop) return; if (didPop) return;
if (currentPath.isNotEmpty) _goUp(bloc, currentPath); _goUp(bloc, currentPath);
}, },
child: _buildScaffold(context, bloc, currentPath), child: _buildScaffold(context, bloc, currentPath, sort),
); );
}, },
); );
@@ -121,6 +107,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
BuildContext context, BuildContext context,
FilesBloc bloc, FilesBloc bloc,
List<String> currentPath, List<String> currentPath,
FileSort sort,
) => Scaffold( ) => Scaffold(
appBar: AppBar( appBar: AppBar(
title: Text( title: Text(
@@ -133,22 +120,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
tooltip: 'Ordner erstellen', tooltip: 'Ordner erstellen',
onPressed: () => showCreateFolderDialog(context, bloc), onPressed: () => showCreateFolderDialog(context, bloc),
), ),
FilesSortActions( const FilesSortActions(),
currentSort: _currentSort,
ascending: _ascending,
onDirectionChanged: (e) {
setState(() {
_ascending = e;
_settings.val(write: true).fileSettings.ascending = e;
});
},
onSortChanged: (e) {
setState(() {
_currentSort = e;
_settings.val(write: true).fileSettings.sortBy = e;
});
},
),
], ],
), ),
floatingActionButton: FloatingActionButton.extended( floatingActionButton: FloatingActionButton.extended(
@@ -160,12 +132,7 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
body: LoadableStateConsumer<FilesBloc, FilesState>( body: LoadableStateConsumer<FilesBloc, FilesState>(
isReady: (state) => state.listing != null, isReady: (state) => state.listing != null,
child: (state, _) { child: (state, _) {
final listing = state.listing!; final entries = state.listing!.sortedBy(sort);
final entries = listing.sortBy(
sortOption: _currentSort,
foldersToTop: _settings.val().fileSettings.sortFoldersToTop,
reversed: _ascending,
);
if (entries.isEmpty) { if (entries.isEmpty) {
return PlaceholderView( return PlaceholderView(
@@ -181,18 +148,16 @@ class _ShareFolderPickerViewState extends State<_ShareFolderPickerView> {
itemCount: entries.length, itemCount: entries.length,
itemBuilder: (context, i) { itemBuilder: (context, i) {
final entry = entries[i]; final entry = entries[i];
if (entry.isDirectory) {
return ListTile( return ListTile(
enabled: entry.isDirectory,
leading: CenteredLeading(FileLeading(file: entry)), leading: CenteredLeading(FileLeading(file: entry)),
title: Text(entry.name), title: Text(entry.name),
trailing: const Icon(Icons.chevron_right), trailing: entry.isDirectory
onTap: () => _enter(bloc, state.currentPath, entry.name), ? const Icon(Icons.chevron_right)
); : null,
} onTap: entry.isDirectory
return ListTile( ? () => bloc.setPath([...state.currentPath, entry.name])
enabled: false, : null,
leading: CenteredLeading(FileLeading(file: entry)),
title: Text(entry.name),
); );
}, },
); );
+42 -59
View File
@@ -61,15 +61,15 @@ class _ChatViewState extends State<ChatView> with RouteAware {
// Captured in initState because the framework has unmounted us by the // Captured in initState because the framework has unmounted us by the
// time dispose runs. // time dispose runs.
ChatBloc? _chatBlocRef; late final ChatBloc _chatBloc;
ChatListBloc? _chatListBlocRef; late final ChatListBloc _chatListBloc;
PageRoute<dynamic>? _subscribedRoute; PageRoute<dynamic>? _subscribedRoute;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_chatBlocRef = context.read<ChatBloc>(); _chatBloc = context.read<ChatBloc>();
_chatListBlocRef = context.read<ChatListBloc>(); _chatListBloc = context.read<ChatListBloc>();
_positionsListener.itemPositions.addListener(_onScrollPositions); _positionsListener.itemPositions.addListener(_onScrollPositions);
NotificationTasks.clearNotificationsForChat(widget.room.token); NotificationTasks.clearNotificationsForChat(widget.room.token);
} }
@@ -83,9 +83,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
// before the user reaches the top — the load stays invisible. // before the user reaches the top — the load stays invisible.
final maxIndex = positions.map((p) => p.index).reduce(math.max); final maxIndex = positions.map((p) => p.index).reduce(math.max);
if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return; if (maxIndex < _itemCount - _kLoadOlderPrefetchRows) return;
final data = _chatBlocRef?.state.data; _chatBloc.loadOlder();
if (data == null || !data.hasMoreOld || data.isLoadingOlder) return;
_chatBlocRef?.loadOlder();
} }
@override @override
@@ -107,7 +105,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
// A stacked chat above us was just popped (typical: notification tap // A stacked chat above us was just popped (typical: notification tap
// opened another chat). The global ChatBloc currently points at that // opened another chat). The global ChatBloc currently points at that
// other chat's token, so our isReady predicate fails until we re-claim. // other chat's token, so our isReady predicate fails until we re-claim.
_chatBlocRef?.setToken(widget.room.token); _chatBloc.setToken(widget.room.token);
} }
@override @override
@@ -117,7 +115,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
} }
_positionsListener.itemPositions.removeListener(_onScrollPositions); _positionsListener.itemPositions.removeListener(_onScrollPositions);
_markAsReadFinal(); _markAsReadFinal();
_chatBlocRef?.leaveChat(widget.room.token); _chatBloc.leaveChat(widget.room.token);
_searchTextController.dispose(); _searchTextController.dispose();
Debouncer.cancel(_searchDebounceTag); Debouncer.cancel(_searchDebounceTag);
super.dispose(); super.dispose();
@@ -129,15 +127,15 @@ class _ChatViewState extends State<ChatView> with RouteAware {
/// belongs to a different room, and writing its max-id as our marker /// belongs to a different room, and writing its max-id as our marker
/// would regress our server cursor. /// would regress our server cursor.
void _markAsReadFinal() { void _markAsReadFinal() {
final state = _chatBlocRef?.state.data; final state = _chatBloc.state.data;
if (state == null) return; if (state == null) return;
if (state.currentToken != widget.room.token) return; if (state.currentToken != widget.room.token) return;
final response = state.chatResponse; final response = state.chatResponse;
if (response == null) return; if (response == null) return;
final maxId = response.data.map((m) => m.id).fold<int>(0, math.max); final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
if (maxId == 0) return; if (maxId == 0) return;
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId); _chatListBloc.markRoomAsRead(widget.room.token, maxId);
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId)); unawaited(_chatBloc.sendServerReadMarker(widget.room.token, maxId));
} }
@override @override
@@ -148,31 +146,34 @@ class _ChatViewState extends State<ChatView> with RouteAware {
} }
} }
void _refresh() { void _refetch() => _chatBloc.setToken(widget.room.token);
context.read<ChatBloc>().setToken(widget.room.token);
}
void _refetch({bool renew = false}) => _refresh();
// The built rows only depend on the chat data and the search state; the list // The built rows only depend on the chat data and the search state; the list
// itself rebuilds far more often (loading flips, keyboard, parent rebuilds), // itself rebuilds far more often (loading flips, keyboard, parent rebuilds),
// and re-sorting plus re-creating every bubble each time is wasted work. // and re-sorting plus re-creating every bubble each time is wasted work.
// The top marker is cached separately so a loading-older flip doesn't
// rebuild every message row.
Object? _messagesKey;
List<Widget> _messages = const [];
Object? _itemsKey; Object? _itemsKey;
List<Widget> _items = const []; List<Widget> _items = const [];
List<Widget> _itemsFor(ChatState state) { List<Widget> _itemsFor(ChatState state) {
final key = ( final messagesKey = (
state.chatResponse, state.chatResponse,
state.isLoadingOlder,
state.hasMoreOld,
_searchActive, _searchActive,
_searchQuery, _searchQuery,
_activeMatchIndex, _activeMatchIndex,
widget.room, widget.room,
); );
if (messagesKey != _messagesKey) {
_messagesKey = messagesKey;
_messages = _buildMessages(state.chatResponse!).reversed.toList();
}
final key = (_messages, state.isLoadingOlder, state.hasMoreOld);
if (key == _itemsKey) return _items; if (key == _itemsKey) return _items;
_itemsKey = key; _itemsKey = key;
final items = _buildMessages(state.chatResponse!).reversed.toList(); final items = [..._messages];
// reverse:true renders index 0 at the bottom, so the top marker // reverse:true renders index 0 at the bottom, so the top marker
// (spinner / start-of-chat) goes at the end. // (spinner / start-of-chat) goes at the end.
if (state.isLoadingOlder) { if (state.isLoadingOlder) {
@@ -191,26 +192,20 @@ class _ChatViewState extends State<ChatView> with RouteAware {
return _items = items; return _items = items;
} }
void _enterSearchMode() { void _enterSearchMode() => _resetSearch(active: true);
setState(() {
_searchActive = true;
_searchQuery = '';
_matches = const [];
_activeMatchIndex = 0;
_matchesComputedFor = null;
_matchesComputedQuery = null;
_searchTextController.clear();
});
}
void _exitSearchMode() { void _exitSearchMode() {
Debouncer.cancel(_searchDebounceTag); Debouncer.cancel(_searchDebounceTag);
_matchIndices.clear();
_resetSearch(active: false);
}
void _resetSearch({required bool active}) {
setState(() { setState(() {
_searchActive = false; _searchActive = active;
_searchQuery = ''; _searchQuery = '';
_matches = const []; _matches = const [];
_activeMatchIndex = 0; _activeMatchIndex = 0;
_matchIndices.clear();
_matchesComputedFor = null; _matchesComputedFor = null;
_matchesComputedQuery = null; _matchesComputedQuery = null;
_searchTextController.clear(); _searchTextController.clear();
@@ -233,7 +228,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
} }
void _applySearch(String q) { void _applySearch(String q) {
final chatResponse = context.read<ChatBloc>().state.data?.chatResponse; final chatResponse = _chatBloc.state.data?.chatResponse;
setState(() { setState(() {
_searchQuery = q; _searchQuery = q;
_activeMatchIndex = 0; _activeMatchIndex = 0;
@@ -260,19 +255,12 @@ class _ChatViewState extends State<ChatView> with RouteAware {
_matchesComputedQuery = _searchQuery; _matchesComputedQuery = _searchQuery;
} }
void _goToPreviousMatch() { // Matches are newest-first, so "previous" (older) steps forward.
if (_matches.isEmpty) return; void _stepMatch(int delta) {
setState(() {
_activeMatchIndex = (_activeMatchIndex + 1) % _matches.length;
});
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
}
void _goToNextMatch() {
if (_matches.isEmpty) return; if (_matches.isEmpty) return;
setState(() { setState(() {
_activeMatchIndex = _activeMatchIndex =
(_activeMatchIndex - 1 + _matches.length) % _matches.length; (_activeMatchIndex + delta + _matches.length) % _matches.length;
}); });
WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch()); WidgetsBinding.instance.addPostFrameCallback((_) => _scrollToActiveMatch());
} }
@@ -379,6 +367,11 @@ class _ChatViewState extends State<ChatView> with RouteAware {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final input = ChatTextfield(
widget.room.token,
selfId: widget.selfId,
roomType: widget.room.type,
);
return _KeyboardDismissPopScope( return _KeyboardDismissPopScope(
child: Scaffold( child: Scaffold(
backgroundColor: const Color(0xffefeae2), backgroundColor: const Color(0xffefeae2),
@@ -389,8 +382,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex, activeIndex: _matches.isEmpty ? -1 : _activeMatchIndex,
onChanged: _onSearchChanged, onChanged: _onSearchChanged,
onClose: _exitSearchMode, onClose: _exitSearchMode,
onPrevious: _matches.isEmpty ? null : _goToPreviousMatch, onPrevious: _matches.isEmpty ? null : () => _stepMatch(1),
onNext: _matches.isEmpty ? null : _goToNextMatch, onNext: _matches.isEmpty ? null : () => _stepMatch(-1),
) )
: ClickableAppBar( : ClickableAppBar(
onTap: () => onTap: () =>
@@ -443,18 +436,8 @@ class _ChatViewState extends State<ChatView> with RouteAware {
ColoredBox( ColoredBox(
color: Theme.of(context).colorScheme.surface, color: Theme.of(context).colorScheme.surface,
child: TalkNavigator.isSecondaryVisible(context) child: TalkNavigator.isSecondaryVisible(context)
? ChatTextfield( ? input
widget.room.token, : SafeArea(child: input),
selfId: widget.selfId,
roomType: widget.room.type,
)
: SafeArea(
child: ChatTextfield(
widget.room.token,
selfId: widget.selfId,
roomType: widget.room.type,
),
),
), ),
], ],
), ),

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