simplified code across the app and consolidated duplicated helpers
This commit is contained in:
@@ -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}) {
|
||||||
|
|||||||
@@ -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>,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
),
|
||||||
|
timeout: Duration(seconds: timeoutSeconds + 15),
|
||||||
);
|
);
|
||||||
final uri = NextcloudOcs.uri(
|
|
||||||
'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) =>
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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}',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
-1
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
+7
-44
@@ -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 }
|
|
||||||
|
|||||||
+2
-1
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,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';
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)}';
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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('/');
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user