diff --git a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart index 82a913b..1423c43 100644 --- a/lib/api/marianumcloud/autocomplete/autocomplete_api.dart +++ b/lib/api/marianumcloud/autocomplete/autocomplete_api.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; @@ -38,9 +37,6 @@ class AutocompleteApi { technicalDetails: 'core/autocomplete/get: ${response.body}', ); } - final decoded = jsonDecode(response.body) as Map; - return AutocompleteResponse.fromJson( - decoded['ocs'] as Map, - ); + return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body)); } } diff --git a/lib/api/marianumcloud/nextcloud_ocs.dart b/lib/api/marianumcloud/nextcloud_ocs.dart index c7086a0..c48121f 100644 --- a/lib/api/marianumcloud/nextcloud_ocs.dart +++ b/lib/api/marianumcloud/nextcloud_ocs.dart @@ -1,3 +1,5 @@ +import 'dart:convert'; + import '../../model/account_data.dart'; import '../../model/endpoint_data.dart'; @@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart'; class NextcloudOcs { NextcloudOcs._(); + /// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper + /// every response nests its `meta`/`data` under). + static Map decode(String raw) => + (jsonDecode(raw) as Map)['ocs'] as Map; + static Map headers() => { 'Accept': 'application/json', 'OCS-APIRequest': 'true', diff --git a/lib/api/marianumcloud/search/search_files.dart b/lib/api/marianumcloud/search/search_files.dart index 0d2c382..838a167 100644 --- a/lib/api/marianumcloud/search/search_files.dart +++ b/lib/api/marianumcloud/search/search_files.dart @@ -1,4 +1,3 @@ -import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; @@ -28,9 +27,7 @@ class SearchFiles { 'Files search failed with ${response.statusCode}: ${response.body}', ); } - final decoded = jsonDecode(response.body) as Map; - final ocs = decoded['ocs'] as Map; - final data = ocs['data'] as Map; + final data = NextcloudOcs.decode(response.body)['data'] as Map; return SearchFilesResponse.fromJson(data); } } diff --git a/lib/api/marianumcloud/talk/chat/get_chat.dart b/lib/api/marianumcloud/talk/chat/get_chat.dart index ff13903..9268b62 100644 --- a/lib/api/marianumcloud/talk/chat/get_chat.dart +++ b/lib/api/marianumcloud/talk/chat/get_chat.dart @@ -1,8 +1,7 @@ -import 'dart:convert'; - import 'package:http/http.dart' as http; import 'package:http/http.dart'; +import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_chat_params.dart'; import 'get_chat_response.dart'; @@ -15,10 +14,8 @@ class GetChat extends TalkApi { : super('v1/chat/$chatToken', null, getParameters: params.toJson()); @override - GetChatResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return GetChatResponse.fromJson(decoded['ocs'] as Map); - } + GetChatResponse assemble(String raw) => + GetChatResponse.fromJson(NextcloudOcs.decode(raw)); @override Future request( diff --git a/lib/api/marianumcloud/talk/chat/long_poll_chat.dart b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart index 80a58aa..ad3bcd3 100644 --- a/lib/api/marianumcloud/talk/chat/long_poll_chat.dart +++ b/lib/api/marianumcloud/talk/chat/long_poll_chat.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; @@ -57,8 +56,7 @@ class LongPollChat { final status = response.statusCode; if (status == 304) return null; if (status >= 200 && status < 300) { - final decoded = jsonDecode(response.body) as Map; - return GetChatResponse.fromJson(decoded['ocs'] as Map) + return GetChatResponse.fromJson(NextcloudOcs.decode(response.body)) ..headers = response.headers; } throw ServerException( diff --git a/lib/api/marianumcloud/talk/close_poll/close_poll.dart b/lib/api/marianumcloud/talk/close_poll/close_poll.dart index 5b9c68f..e1446df 100644 --- a/lib/api/marianumcloud/talk/close_poll/close_poll.dart +++ b/lib/api/marianumcloud/talk/close_poll/close_poll.dart @@ -1,8 +1,7 @@ -import 'dart:convert'; - import 'package:http/http.dart' as http; import '../../../api_params.dart'; +import '../../nextcloud_ocs.dart'; import '../get_poll/get_poll_state_response.dart'; import '../talk_api.dart'; @@ -12,12 +11,8 @@ class ClosePoll extends TalkApi { : super('v1/poll/$token/$pollId', null); @override - GetPollStateResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return GetPollStateResponse.fromJson( - decoded['ocs'] as Map, - ); - } + GetPollStateResponse assemble(String raw) => + GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); @override Future request( diff --git a/lib/api/marianumcloud/talk/create_room/create_room.dart b/lib/api/marianumcloud/talk/create_room/create_room.dart index 0eeb5be..b200252 100644 --- a/lib/api/marianumcloud/talk/create_room/create_room.dart +++ b/lib/api/marianumcloud/talk/create_room/create_room.dart @@ -1,8 +1,7 @@ -import 'dart:convert'; - import 'package:http/http.dart' as http; import 'package:http/http.dart'; +import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'create_room_params.dart'; import 'create_room_response.dart'; @@ -13,10 +12,8 @@ class CreateRoom extends TalkApi { CreateRoom(this.params) : super('v4/room', params); @override - CreateRoomResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return CreateRoomResponse.fromJson(decoded['ocs'] as Map); - } + CreateRoomResponse assemble(String raw) => + CreateRoomResponse.fromJson(NextcloudOcs.decode(raw)); @override Future? request( diff --git a/lib/api/marianumcloud/talk/get_participants/get_participants.dart b/lib/api/marianumcloud/talk/get_participants/get_participants.dart index c37d7ce..b44be0b 100644 --- a/lib/api/marianumcloud/talk/get_participants/get_participants.dart +++ b/lib/api/marianumcloud/talk/get_participants/get_participants.dart @@ -1,7 +1,6 @@ -import 'dart:convert'; - import 'package:http/http.dart' as http; +import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_participants_response.dart'; @@ -10,12 +9,8 @@ class GetParticipants extends TalkApi { GetParticipants(this.token) : super('v4/room/$token/participants', null); @override - GetParticipantsResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return GetParticipantsResponse.fromJson( - decoded['ocs'] as Map, - ); - } + GetParticipantsResponse assemble(String raw) => + GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw)); @override Future request( diff --git a/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart index 3b6ccd2..396b8b6 100644 --- a/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart +++ b/lib/api/marianumcloud/talk/get_poll/get_poll_state.dart @@ -1,7 +1,6 @@ -import 'dart:convert'; - import 'package:http/http.dart' as http; +import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_poll_state_response.dart'; @@ -12,12 +11,8 @@ class GetPollState extends TalkApi { : super('v1/poll/$token/$pollId', null); @override - GetPollStateResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return GetPollStateResponse.fromJson( - decoded['ocs'] as Map, - ); - } + GetPollStateResponse assemble(String raw) => + GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); @override Future request( diff --git a/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart index 882eb29..e154207 100644 --- a/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart +++ b/lib/api/marianumcloud/talk/get_reactions/get_reactions.dart @@ -1,9 +1,8 @@ -import 'dart:convert'; - import 'package:http/http.dart' as http; import 'package:http/http.dart'; import '../../../api_params.dart'; +import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_reactions_response.dart'; @@ -14,12 +13,8 @@ class GetReactions extends TalkApi { : super('v1/reaction/$chatToken/$messageId', null); @override - GetReactionsResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return GetReactionsResponse.fromJson( - decoded['ocs'] as Map, - ); - } + GetReactionsResponse assemble(String raw) => + GetReactionsResponse.fromJson(NextcloudOcs.decode(raw)); @override Future? request( diff --git a/lib/api/marianumcloud/talk/room/get_room.dart b/lib/api/marianumcloud/talk/room/get_room.dart index c2049ef..14df26a 100644 --- a/lib/api/marianumcloud/talk/room/get_room.dart +++ b/lib/api/marianumcloud/talk/room/get_room.dart @@ -1,7 +1,6 @@ -import 'dart:convert'; - import 'package:http/http.dart' as http; +import '../../nextcloud_ocs.dart'; import '../talk_api.dart'; import 'get_room_params.dart'; import 'get_room_response.dart'; @@ -11,10 +10,8 @@ class GetRoom extends TalkApi { GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); @override - GetRoomResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return GetRoomResponse.fromJson(decoded['ocs'] as Map); - } + GetRoomResponse assemble(String raw) => + GetRoomResponse.fromJson(NextcloudOcs.decode(raw)); @override Future request( diff --git a/lib/api/marianumcloud/talk/vote_poll/vote_poll.dart b/lib/api/marianumcloud/talk/vote_poll/vote_poll.dart index 829b3ea..72a1047 100644 --- a/lib/api/marianumcloud/talk/vote_poll/vote_poll.dart +++ b/lib/api/marianumcloud/talk/vote_poll/vote_poll.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:http/http.dart' as http; import '../../../api_params.dart'; +import '../../nextcloud_ocs.dart'; import '../get_poll/get_poll_state_response.dart'; import '../talk_api.dart'; import 'vote_poll_params.dart'; @@ -22,12 +23,8 @@ class VotePoll extends TalkApi { ); @override - GetPollStateResponse assemble(String raw) { - final decoded = jsonDecode(raw) as Map; - return GetPollStateResponse.fromJson( - decoded['ocs'] as Map, - ); - } + GetPollStateResponse assemble(String raw) => + GetPollStateResponse.fromJson(NextcloudOcs.decode(raw)); @override Future? request( diff --git a/lib/api/marianumconnect/marianumconnect_api.dart b/lib/api/marianumconnect/marianumconnect_api.dart index e9d226e..5030ea0 100644 --- a/lib/api/marianumconnect/marianumconnect_api.dart +++ b/lib/api/marianumconnect/marianumconnect_api.dart @@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart'; class MarianumConnectApi { static const Duration _connectTimeout = Duration(seconds: 10); static const Duration _receiveTimeout = Duration(seconds: 20); + static const Duration _plainReceiveTimeout = Duration(seconds: 15); static final Dio _instance = _build(); static Dio dio() => _instance; + /// A fresh dio with the standard JSON options but no interceptors — used by + /// the auth queries (login/verify) that must bypass the bearer/demo + /// interceptors to avoid a re-auth loop. + static Dio plainDio() => Dio( + BaseOptions( + connectTimeout: _connectTimeout, + sendTimeout: _connectTimeout, + receiveTimeout: _plainReceiveTimeout, + responseType: ResponseType.json, + contentType: 'application/json', + ), + ); + static Dio _build() { final dio = Dio( BaseOptions( diff --git a/lib/api/marianumconnect/marianumconnect_query.dart b/lib/api/marianumconnect/marianumconnect_query.dart index 9de04f8..a69e373 100644 --- a/lib/api/marianumconnect/marianumconnect_query.dart +++ b/lib/api/marianumconnect/marianumconnect_query.dart @@ -26,4 +26,36 @@ abstract class MarianumConnectQuery { throw mapMarianumConnectError(e); } } + + /// GETs [path] and parses the JSON object body with [fromJson]. + Future getObject( + String path, + T Function(Map json) fromJson, { + Map? queryParameters, + }) => guard(() async { + final response = await dio.get>( + endpoint(path), + queryParameters: queryParameters, + ); + return fromJson(response.data!); + }); + + /// GETs [path] and maps each element of the JSON array body with [fromJson]. + Future> getList( + String path, + T Function(Map json) fromJson, { + Map? queryParameters, + }) => guard(() async { + final response = await dio.get>( + endpoint(path), + queryParameters: queryParameters, + ); + return response.data! + .map((e) => fromJson(e as Map)) + .toList(); + }); + + /// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params. + String isoDate(DateTime d) => + '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; } diff --git a/lib/api/marianumconnect/queries/auth_login/auth_login.dart b/lib/api/marianumconnect/queries/auth_login/auth_login.dart index 99f6779..a4279bb 100644 --- a/lib/api/marianumconnect/queries/auth_login/auth_login.dart +++ b/lib/api/marianumconnect/queries/auth_login/auth_login.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; import '../../marianumconnect_query.dart'; import 'auth_login_response.dart'; @@ -9,9 +10,6 @@ import 'auth_login_response.dart'; /// run through the shared dio instance — that one has the interceptor, which /// would attempt to re-auth us into a loop if our credentials are wrong. class AuthLogin extends MarianumConnectQuery { - static const Duration _connectTimeout = Duration(seconds: 10); - static const Duration _receiveTimeout = Duration(seconds: 15); - final MarianumConnectTokenStorage _tokenStorage; AuthLogin({ @@ -19,17 +17,7 @@ class AuthLogin extends MarianumConnectQuery { const MarianumConnectTokenStorage(), Dio? dio, }) : _tokenStorage = tokenStorage, - super(dio: dio ?? _buildDio()); - - static Dio _buildDio() => Dio( - BaseOptions( - connectTimeout: _connectTimeout, - receiveTimeout: _receiveTimeout, - sendTimeout: _connectTimeout, - responseType: ResponseType.json, - contentType: 'application/json', - ), - ); + super(dio: dio ?? MarianumConnectApi.plainDio()); Future run({ required String username, diff --git a/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart index 8e3f8a2..561bc1e 100644 --- a/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart +++ b/lib/api/marianumconnect/queries/auth_verify/auth_verify.dart @@ -2,6 +2,7 @@ import 'package:dio/dio.dart'; import '../../../errors/auth_exception.dart'; import '../../auth/token_storage.dart'; +import '../../marianumconnect_api.dart'; import '../../marianumconnect_query.dart'; /// Probes that the stored bearer token still maps to the given credentials. @@ -12,9 +13,6 @@ import '../../marianumconnect_query.dart'; /// Bypasses the shared dio singleton so the auth interceptor doesn't kick in /// and obscure a real 401 with a silent re-login. class AuthVerify extends MarianumConnectQuery { - static const Duration _connectTimeout = Duration(seconds: 10); - static const Duration _receiveTimeout = Duration(seconds: 15); - final MarianumConnectTokenStorage _tokenStorage; AuthVerify({ @@ -22,17 +20,7 @@ class AuthVerify extends MarianumConnectQuery { const MarianumConnectTokenStorage(), Dio? dio, }) : _tokenStorage = tokenStorage, - super(dio: dio ?? _buildDio()); - - static Dio _buildDio() => Dio( - BaseOptions( - connectTimeout: _connectTimeout, - sendTimeout: _connectTimeout, - receiveTimeout: _receiveTimeout, - responseType: ResponseType.json, - contentType: 'application/json', - ), - ); + super(dio: dio ?? MarianumConnectApi.plainDio()); /// Throws [AuthException] on 401 (credentials no longer match the token's /// user, token missing, or token rejected), other [AppException]s on diff --git a/lib/api/marianumconnect/queries/get_breakers/get_breakers.dart b/lib/api/marianumconnect/queries/get_breakers/get_breakers.dart index ea14ab2..322e372 100644 --- a/lib/api/marianumconnect/queries/get_breakers/get_breakers.dart +++ b/lib/api/marianumconnect/queries/get_breakers/get_breakers.dart @@ -7,8 +7,6 @@ import 'get_breakers_response.dart'; class GetBreakers extends MarianumConnectQuery { GetBreakers({super.dio}); - Future run() => guard(() async { - final response = await dio.get>(endpoint('breaker')); - return GetBreakersResponse.fromJson(response.data!); - }); + Future run() => + getObject('breaker', GetBreakersResponse.fromJson); } diff --git a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart index 2dd3080..1cd344d 100644 --- a/lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart +++ b/lib/api/marianumconnect/queries/get_capabilities/get_capabilities.dart @@ -7,10 +7,6 @@ import 'get_capabilities_response.dart'; class GetCapabilities extends MarianumConnectQuery { GetCapabilities({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('me/capabilities'), - ); - return CapabilitiesResponse.fromJson(response.data!); - }); + Future run() => + getObject('me/capabilities', CapabilitiesResponse.fromJson); } diff --git a/lib/api/marianumconnect/queries/get_holidays/get_holidays.dart b/lib/api/marianumconnect/queries/get_holidays/get_holidays.dart index 51896dd..a757b6b 100644 --- a/lib/api/marianumconnect/queries/get_holidays/get_holidays.dart +++ b/lib/api/marianumconnect/queries/get_holidays/get_holidays.dart @@ -4,10 +4,5 @@ import '../../models/mc_holiday.dart'; class GetHolidays extends MarianumConnectQuery { GetHolidays({super.dio}); - Future> run() => guard(() async { - final response = await dio.get>(endpoint('holidays')); - return response.data! - .map((e) => McHoliday.fromJson(e as Map)) - .toList(); - }); + Future> run() => getList('holidays', McHoliday.fromJson); } diff --git a/lib/api/marianumconnect/queries/get_ticker/get_ticker.dart b/lib/api/marianumconnect/queries/get_ticker/get_ticker.dart index c725114..70d56ab 100644 --- a/lib/api/marianumconnect/queries/get_ticker/get_ticker.dart +++ b/lib/api/marianumconnect/queries/get_ticker/get_ticker.dart @@ -6,8 +6,6 @@ import 'get_ticker_response.dart'; class GetTicker extends MarianumConnectQuery { GetTicker({super.dio}); - Future run() => guard(() async { - final response = await dio.get>(endpoint('ticker')); - return TickerResponse.fromJson(response.data!); - }); + Future run() => + getObject('ticker', TickerResponse.fromJson); } diff --git a/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart index e4d8a84..39c1dad 100644 --- a/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart +++ b/lib/api/marianumconnect/queries/get_ticker_nav/get_ticker_nav.dart @@ -6,10 +6,6 @@ import 'get_ticker_nav_response.dart'; class GetTickerNav extends MarianumConnectQuery { GetTickerNav({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('ticker/pages'), - ); - return TickerNavResponse.fromJson(response.data!); - }); + Future run() => + getObject('ticker/pages', TickerNavResponse.fromJson); } diff --git a/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart index 3acdc2c..6b688d1 100644 --- a/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart +++ b/lib/api/marianumconnect/queries/timetable_custom_events/timetable_custom_events_get.dart @@ -4,10 +4,8 @@ import '../../marianumconnect_query.dart'; class TimetableCustomEventsGet extends MarianumConnectQuery { TimetableCustomEventsGet({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/custom-events'), - ); - return GetCustomTimetableEventResponse.fromJson(response.data!); - }); + Future run() => getObject( + 'timetable/custom-events', + GetCustomTimetableEventResponse.fromJson, + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart index 0528772..4dc7beb 100644 --- a/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart +++ b/lib/api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart @@ -4,13 +4,11 @@ import 'timetable_get_classes_response.dart'; class TimetableGetClasses extends MarianumConnectQuery { TimetableGetClasses({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/elements/classes'), - ); - final list = response.data! - .map((e) => McTimetableClass.fromJson(e as Map)) - .toList(); - return TimetableGetClassesResponse(result: list); - }); + Future run() async => + TimetableGetClassesResponse( + result: await getList( + 'timetable/elements/classes', + McTimetableClass.fromJson, + ), + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart b/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart index 34c4bd8..4bb222a 100644 --- a/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart +++ b/lib/api/marianumconnect/queries/timetable_get_element_week/timetable_get_element_week.dart @@ -13,14 +13,9 @@ class TimetableGetElementWeek extends MarianumConnectQuery { required int id, required DateTime from, required DateTime until, - }) => guard(() async { - final response = await dio.get>( - endpoint('timetable/${type.pathSegment}/$id'), - queryParameters: {'from': _format(from), 'until': _format(until)}, - ); - return TimetableGetWeekResponse.fromJson(response.data!); - }); - - String _format(DateTime d) => - '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + }) => getObject( + 'timetable/${type.pathSegment}/$id', + TimetableGetWeekResponse.fromJson, + queryParameters: {'from': isoDate(from), 'until': isoDate(until)}, + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart index 5cd1892..171c85b 100644 --- a/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart +++ b/lib/api/marianumconnect/queries/timetable_get_holidays/timetable_get_holidays.dart @@ -4,13 +4,8 @@ import 'timetable_get_holidays_response.dart'; class TimetableGetHolidays extends MarianumConnectQuery { TimetableGetHolidays({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/holidays'), - ); - final list = response.data! - .map((e) => McHoliday.fromJson(e as Map)) - .toList(); - return TimetableGetHolidaysResponse(result: list); - }); + Future run() async => + TimetableGetHolidaysResponse( + result: await getList('timetable/holidays', McHoliday.fromJson), + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart index ac52846..202d940 100644 --- a/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart +++ b/lib/api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart @@ -4,11 +4,7 @@ import 'timetable_get_rooms_response.dart'; class TimetableGetRooms extends MarianumConnectQuery { TimetableGetRooms({super.dio}); - Future run() => guard(() async { - final response = await dio.get>(endpoint('timetable/rooms')); - final list = response.data! - .map((e) => McRoom.fromJson(e as Map)) - .toList(); - return TimetableGetRoomsResponse(result: list); - }); + Future run() async => TimetableGetRoomsResponse( + result: await getList('timetable/rooms', McRoom.fromJson), + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart index 9e95c36..8e117dd 100644 --- a/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart +++ b/lib/api/marianumconnect/queries/timetable_get_schoolyear/timetable_get_schoolyear.dart @@ -4,10 +4,6 @@ import 'timetable_get_schoolyear_response.dart'; class TimetableGetSchoolyear extends MarianumConnectQuery { TimetableGetSchoolyear({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/schoolyear'), - ); - return TimetableGetSchoolyearResponse.fromJson(response.data!); - }); + Future run() => + getObject('timetable/schoolyear', TimetableGetSchoolyearResponse.fromJson); } diff --git a/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart index c173627..70de24d 100644 --- a/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart +++ b/lib/api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart @@ -4,13 +4,11 @@ import 'timetable_get_students_response.dart'; class TimetableGetStudents extends MarianumConnectQuery { TimetableGetStudents({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/elements/students'), - ); - final list = response.data! - .map((e) => McTimetableStudent.fromJson(e as Map)) - .toList(); - return TimetableGetStudentsResponse(result: list); - }); + Future run() async => + TimetableGetStudentsResponse( + result: await getList( + 'timetable/elements/students', + McTimetableStudent.fromJson, + ), + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart index 98967fe..3f20ed1 100644 --- a/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart +++ b/lib/api/marianumconnect/queries/timetable_get_subjects/timetable_get_subjects.dart @@ -4,13 +4,8 @@ import 'timetable_get_subjects_response.dart'; class TimetableGetSubjects extends MarianumConnectQuery { TimetableGetSubjects({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/subjects'), - ); - final list = response.data! - .map((e) => McSubject.fromJson(e as Map)) - .toList(); - return TimetableGetSubjectsResponse(result: list); - }); + Future run() async => + TimetableGetSubjectsResponse( + result: await getList('timetable/subjects', McSubject.fromJson), + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart index 4fb281f..3ceff7c 100644 --- a/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart +++ b/lib/api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart @@ -4,13 +4,11 @@ import 'timetable_get_teachers_response.dart'; class TimetableGetTeachers extends MarianumConnectQuery { TimetableGetTeachers({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/elements/teachers'), - ); - final list = response.data! - .map((e) => McTimetableTeacherElement.fromJson(e as Map)) - .toList(); - return TimetableGetTeachersResponse(result: list); - }); + Future run() async => + TimetableGetTeachersResponse( + result: await getList( + 'timetable/elements/teachers', + McTimetableTeacherElement.fromJson, + ), + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart index c719385..ac306b9 100644 --- a/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart +++ b/lib/api/marianumconnect/queries/timetable_get_timegrid/timetable_get_timegrid.dart @@ -4,13 +4,8 @@ import 'timetable_get_timegrid_response.dart'; class TimetableGetTimegrid extends MarianumConnectQuery { TimetableGetTimegrid({super.dio}); - Future run() => guard(() async { - final response = await dio.get>( - endpoint('timetable/timegrid'), - ); - final list = response.data! - .map((e) => McTimegridUnit.fromJson(e as Map)) - .toList(); - return TimetableGetTimegridResponse(result: list); - }); + Future run() async => + TimetableGetTimegridResponse( + result: await getList('timetable/timegrid', McTimegridUnit.fromJson), + ); } diff --git a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart index 62a4453..5ded0c0 100644 --- a/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart +++ b/lib/api/marianumconnect/queries/timetable_get_week/timetable_get_week.dart @@ -7,14 +7,9 @@ class TimetableGetWeek extends MarianumConnectQuery { Future run({ required DateTime from, required DateTime until, - }) => guard(() async { - final response = await dio.get>( - endpoint('timetable/me'), - queryParameters: {'from': _format(from), 'until': _format(until)}, - ); - return TimetableGetWeekResponse.fromJson(response.data!); - }); - - String _format(DateTime d) => - '${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'; + }) => getObject( + 'timetable/me', + TimetableGetWeekResponse.fromJson, + queryParameters: {'from': isoDate(from), 'until': isoDate(until)}, + ); } diff --git a/lib/api/marianumconnect/queries/user_search/user_search.dart b/lib/api/marianumconnect/queries/user_search/user_search.dart index 28ffbbe..493681b 100644 --- a/lib/api/marianumconnect/queries/user_search/user_search.dart +++ b/lib/api/marianumconnect/queries/user_search/user_search.dart @@ -7,14 +7,11 @@ import 'user_search_response.dart'; class UserSearch extends MarianumConnectQuery { UserSearch({super.dio}); - Future run(String query) => guard(() async { - final response = await dio.get>( - endpoint('users/search'), + Future run(String query) async => UserSearchResponse( + result: await getList( + 'users/search', + McUserSearchResult.fromJson, queryParameters: {'q': query}, - ); - final list = response.data! - .map((e) => McUserSearchResult.fromJson(e as Map)) - .toList(); - return UserSearchResponse(result: list); - }); + ), + ); } diff --git a/lib/extensions/date_time.dart b/lib/extensions/date_time.dart index 2476ca7..485b275 100644 --- a/lib/extensions/date_time.dart +++ b/lib/extensions/date_time.dart @@ -13,12 +13,8 @@ extension IsSameDay on DateTime { TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute); - bool isSameDateTime(DateTime other) { - var isSameDay = this.isSameDay(other); - var isSameTimeOfDay = (toTimeOfDay() == other.toTimeOfDay()); - - return isSameDay && isSameTimeOfDay; - } + bool isSameDateTime(DateTime other) => + isSameDay(other) && toTimeOfDay() == other.toTimeOfDay(); bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other); } diff --git a/lib/model/account_data.dart b/lib/model/account_data.dart index 3172a91..3b92fdb 100644 --- a/lib/model/account_data.dart +++ b/lib/model/account_data.dart @@ -196,15 +196,10 @@ class AccountData { /// Prefer this over embedding credentials in URLs — error logs and crash /// reports often capture the URL but not headers. String getBasicAuthHeader() { - if (!isPopulated()) { - throw Exception( - 'AccountData (e.g. username or password) is not initialized!', - ); - } + _requirePopulated(); // Prefer the scoped app password once available; it survives real-password // rotation and is what the push-v2 registration is bound to. - final secret = _appPassword ?? _password; - return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}'; + return _basicAuth(_appPassword ?? _password!); } /// Basic-auth header using the Talk app password — authenticates the @@ -212,29 +207,32 @@ class AccountData { /// talk password has not been minted yet; callers treat that as a failed /// talk registration and retry on the next start. String getTalkBasicAuthHeader() { - if (!isPopulated()) { - throw Exception( - 'AccountData (e.g. username or password) is not initialized!', - ); - } + _requirePopulated(); if (!hasAppPasswordTalk()) { throw StateError('Talk app password not available yet'); } - return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}'; + return _basicAuth(_appPasswordTalk!); } /// Basic-auth header that always uses the real password. Needed exactly once, /// to mint the app password via `core/getapppassword` (an app password cannot /// mint another). String getRealPasswordBasicAuthHeader() { + _requirePopulated(); + return _basicAuth(_password!); + } + + void _requirePopulated() { if (!isPopulated()) { throw Exception( 'AccountData (e.g. username or password) is not initialized!', ); } - return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}'; } + String _basicAuth(String secret) => + 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}'; + /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry /// header map ready to merge into HTTP request headers. Map authHeaders() => {'Authorization': getBasicAuthHeader()}; diff --git a/lib/model/endpoint_data.dart b/lib/model/endpoint_data.dart index 4e19214..5aec4b1 100644 --- a/lib/model/endpoint_data.dart +++ b/lib/model/endpoint_data.dart @@ -29,13 +29,10 @@ class EndpointData { EndpointData._construct(); - EndpointMode getEndpointMode() { - late String existingName; - existingName = AccountData().getUsername(); - return existingName.startsWith('google') - ? EndpointMode.stage - : EndpointMode.live; - } + EndpointMode getEndpointMode() => + AccountData().getUsername().startsWith('google') + ? EndpointMode.stage + : EndpointMode.live; Endpoint nextcloud() => EndpointOptions( live: Endpoint(domain: 'cloud.marianum-fulda.de'), diff --git a/lib/push/push_renderer.dart b/lib/push/push_renderer.dart index bcdbc2d..678c90c 100644 --- a/lib/push/push_renderer.dart +++ b/lib/push/push_renderer.dart @@ -371,13 +371,7 @@ class PushRenderer { jsonEncode({'chatToken': ?chatToken, 'nid': nid}); /// Deterministic non-negative 31-bit id from a string, used when the push - /// carries no `nid`. - int _fallbackId(String? seed) { - if (seed == null || seed.isEmpty) return 0; - var hash = 0; - for (final unit in seed.codeUnits) { - hash = (hash * 31 + unit) & 0x7fffffff; - } - return hash; - } + /// carries no `nid`. Shares the hash with [stableChatNotificationId] (an + /// empty/null seed hashes to 0). + int _fallbackId(String? seed) => stableChatNotificationId(seed ?? ''); } diff --git a/lib/routing/app_routes.dart b/lib/routing/app_routes.dart index d7a0c82..2c389dc 100644 --- a/lib/routing/app_routes.dart +++ b/lib/routing/app_routes.dart @@ -440,7 +440,7 @@ class AppRoutes { static bool goToTab(BuildContext context, Modules module) { final index = AppModule.getBottomBarModules( context, - ).map((e) => e.module).toList().indexOf(module); + ).indexWhere((e) => e.module == module); if (index == -1) return false; Main.bottomNavigator.jumpToTab(index); return true; diff --git a/lib/share_intent/pending_share.dart b/lib/share_intent/pending_share.dart index 6ecd836..9f8590e 100644 --- a/lib/share_intent/pending_share.dart +++ b/lib/share_intent/pending_share.dart @@ -1,3 +1,5 @@ +import 'package:flutter/foundation.dart'; + class PendingShare { final List filePaths; final String? text; @@ -17,12 +19,6 @@ class PendingShare { /// fires two `open(url)` requests per share (see ShareViewController), so /// the same share can arrive twice on the media stream — receivedAt is /// deliberately ignored here so such duplicates compare equal. - bool contentEquals(PendingShare other) { - if (text != other.text) return false; - if (filePaths.length != other.filePaths.length) return false; - for (var i = 0; i < filePaths.length; i++) { - if (filePaths[i] != other.filePaths[i]) return false; - } - return true; - } + bool contentEquals(PendingShare other) => + text == other.text && listEquals(filePaths, other.filePaths); } diff --git a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart index 9601c00..8fe3ffd 100644 --- a/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart +++ b/lib/state/app/infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart @@ -123,23 +123,28 @@ abstract class LoadableHydratedBloc< fetch(); } + /// Maps [e] through the shared error mapper and emits it as an [Error] event. + /// Does not guard [isClosed] — callers decide whether a late error still + /// applies. + void addLoadingError(Object e) => add( + Error( + LoadingError( + message: errorToUserMessage(e), + technicalDetails: errorToTechnicalDetails(e), + allowRetry: errorAllowsRetry(e), + ), + ), + ); + void fetch() { log('Fetching data for ${TState.toString()}'); gatherData() - .catchError((e) { + .catchError((Object e) { log('Error while fetching ${TState.toString()}: ${e.toString()}'); // The bloc may have been closed before this async error landed; // adding to a closed bloc throws, so swallow that case. if (isClosed) return; - add( - Error( - LoadingError( - message: errorToUserMessage(e), - technicalDetails: errorToTechnicalDetails(e), - allowRetry: errorAllowsRetry(e), - ), - ), - ); + addLoadingError(e); }) .then((value) { log('Fetch for ${TState.toString()} completed!'); diff --git a/lib/state/app/modules/chat/bloc/chat_bloc.dart b/lib/state/app/modules/chat/bloc/chat_bloc.dart index cacd874..ca5a1f0 100644 --- a/lib/state/app/modules/chat/bloc/chat_bloc.dart +++ b/lib/state/app/modules/chat/bloc/chat_bloc.dart @@ -4,13 +4,11 @@ import 'dart:math' as math; import 'package:flutter/widgets.dart'; -import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart'; import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart'; -import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../../chat_list/bloc/chat_list_bloc.dart'; @@ -181,17 +179,7 @@ class ChatBloc if (!stillCurrent()) return; - if (capturedError != null) { - add( - Error( - LoadingError( - message: errorToUserMessage(capturedError), - technicalDetails: errorToTechnicalDetails(capturedError), - allowRetry: errorAllowsRetry(capturedError), - ), - ), - ); - } + if (capturedError != null) addLoadingError(capturedError!); } void _startLongPoll(String token) { diff --git a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart index 9ceec58..a789254 100644 --- a/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart +++ b/lib/state/app/modules/chat_list/bloc/chat_list_bloc.dart @@ -3,10 +3,8 @@ import 'dart:developer'; import 'package:flutter_app_badge/flutter_app_badge.dart'; -import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart'; import '../../../../../api/marianumcloud/talk/room/get_room_response.dart'; -import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/chat_list_repository.dart'; @@ -87,17 +85,7 @@ class ChatListBloc } catch (e) { capturedError = e; } - if (capturedError != null) { - add( - Error( - LoadingError( - message: errorToUserMessage(capturedError), - technicalDetails: errorToTechnicalDetails(capturedError), - allowRetry: errorAllowsRetry(capturedError), - ), - ), - ); - } + if (capturedError != null) addLoadingError(capturedError!); } /// Creates (or resolves) a 1:1 chat and returns its room token, or null in diff --git a/lib/state/app/modules/files/bloc/files_bloc.dart b/lib/state/app/modules/files/bloc/files_bloc.dart index d4b7bad..caa9162 100644 --- a/lib/state/app/modules/files/bloc/files_bloc.dart +++ b/lib/state/app/modules/files/bloc/files_bloc.dart @@ -2,9 +2,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; -import '../../../../../api/errors/error_mapper.dart'; import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart'; -import '../../../infrastructure/loadable_state/loading_error.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/files_repository.dart'; @@ -112,16 +110,6 @@ class FilesBloc ); add(DataGathered((s) => s.copyWith(listing: listing))); } - if (capturedError != null) { - add( - Error( - LoadingError( - message: errorToUserMessage(capturedError), - technicalDetails: errorToTechnicalDetails(capturedError), - allowRetry: errorAllowsRetry(capturedError), - ), - ), - ); - } + if (capturedError != null) addLoadingError(capturedError!); } } diff --git a/lib/state/app/modules/ticker/bloc/ticker_bloc.dart b/lib/state/app/modules/ticker/bloc/ticker_bloc.dart index 350b509..8b0b0b0 100644 --- a/lib/state/app/modules/ticker/bloc/ticker_bloc.dart +++ b/lib/state/app/modules/ticker/bloc/ticker_bloc.dart @@ -1,5 +1,3 @@ -import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart'; -import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart'; import '../repository/ticker_repository.dart'; @@ -10,9 +8,7 @@ class TickerBloc extends LoadableHydratedBloc { @override Future gatherData() async { - final results = await Future.wait([repo.getTicker(), repo.getNav()]); - final ticker = results[0] as TickerResponse; - final nav = results[1] as TickerNavResponse; + final (ticker, nav) = await (repo.getTicker(), repo.getNav()).wait; add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav))); } diff --git a/lib/view/pages/files/files_upload_dialog.dart b/lib/view/pages/files/files_upload_dialog.dart index 40fc630..95502b7 100644 --- a/lib/view/pages/files/files_upload_dialog.dart +++ b/lib/view/pages/files/files_upload_dialog.dart @@ -62,12 +62,14 @@ class _FilesUploadDialogState extends State { ); } + void _resetProgress() { + _isUploading = false; + _overallProgressValue = 0.0; + _infoText = ''; + } + void _showUploadError(String message) { - setState(() { - _isUploading = false; - _overallProgressValue = 0.0; - _infoText = ''; - }); + setState(_resetProgress); InfoDialog.show( context, message, @@ -157,9 +159,7 @@ class _FilesUploadDialogState extends State { if (replaceFiles != true) { setState(() { - _isUploading = false; - _overallProgressValue = 0.0; - _infoText = ''; + _resetProgress(); for (var element in conflictingFiles) { element.isConflicting = true; } @@ -222,11 +222,7 @@ class _FilesUploadDialogState extends State { } if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { - setState(() { - _isUploading = false; - _overallProgressValue = 0.0; - _infoText = ''; - }); + setState(_resetProgress); if (!mounted) return; Navigator.of(context).pop(); showHttpErrorCode(uploadTask.statusCode); @@ -235,11 +231,7 @@ class _FilesUploadDialogState extends State { } } - setState(() { - _isUploading = false; - _overallProgressValue = 0.0; - _infoText = ''; - }); + setState(_resetProgress); if (!mounted) return; Navigator.of(context).pop(); widget.onUploadFinished(uploadetFilePaths); diff --git a/lib/view/pages/grade_averages/grade_averages_view.dart b/lib/view/pages/grade_averages/grade_averages_view.dart index 06ccba2..7670592 100644 --- a/lib/view/pages/grade_averages/grade_averages_view.dart +++ b/lib/view/pages/grade_averages/grade_averages_view.dart @@ -24,19 +24,12 @@ class GradeAveragesView extends StatelessWidget { Visibility( visible: bloc.state.grades.isNotEmpty, child: IconButton( - onPressed: () { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Zurücksetzen?', - content: 'Alle Einträge werden entfernt.', - confirmButton: 'Zurücksetzen', - onConfirm: () { - bloc.add(ResetAll()); - }, - ), - ); - }, + onPressed: () => ConfirmDialog( + title: 'Zurücksetzen?', + content: 'Alle Einträge werden entfernt.', + confirmButton: 'Zurücksetzen', + onConfirm: () => bloc.add(ResetAll()), + ).asDialog(context), icon: const Icon(Icons.delete_forever), ), ), @@ -64,17 +57,14 @@ class GradeAveragesView extends StatelessWidget { .toList(), onSelected: (isMiddleSchool) { if (bloc.state.grades.isNotEmpty) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Notensystem wechseln', - content: - 'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.', - confirmButton: 'Fortfahren', - onConfirm: () => - bloc.add(GradingSystemChanged(isMiddleSchool)), - ), - ); + ConfirmDialog( + title: 'Notensystem wechseln', + content: + 'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.', + confirmButton: 'Fortfahren', + onConfirm: () => + bloc.add(GradingSystemChanged(isMiddleSchool)), + ).asDialog(context); } else { bloc.add(GradingSystemChanged(isMiddleSchool)); } diff --git a/lib/view/pages/marianum_dates/marianum_dates_view.dart b/lib/view/pages/marianum_dates/marianum_dates_view.dart index 585457a..98d464c 100644 --- a/lib/view/pages/marianum_dates/marianum_dates_view.dart +++ b/lib/view/pages/marianum_dates/marianum_dates_view.dart @@ -27,7 +27,7 @@ class MarianumDatesView extends StatelessWidget { return keys.map((key) { final first = byMonth[key]!.first.start; final label = first.formatMonthYear().toUpperCase(); - return _MonthGroup(key: key, label: label, events: byMonth[key]!); + return _MonthGroup(label: label, events: byMonth[key]!); }).toList(); } @@ -117,8 +117,7 @@ class MarianumDatesView extends StatelessWidget { } class _MonthGroup { - final String key; final String label; final List events; - _MonthGroup({required this.key, required this.label, required this.events}); + _MonthGroup({required this.label, required this.events}); } diff --git a/lib/view/pages/marianum_message/marianum_message_list_view.dart b/lib/view/pages/marianum_message/marianum_message_list_view.dart index 3c50cb3..58abb6e 100644 --- a/lib/view/pages/marianum_message/marianum_message_list_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_list_view.dart @@ -6,6 +6,7 @@ import '../../../state/app/infrastructure/loadable_state/view/loadable_state_con import '../../../state/app/infrastructure/utility_widgets/bloc_module.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_bloc.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import '../../../widget/centered_leading.dart'; import 'search_marianum_messages.dart'; class MarianumMessageListView extends StatelessWidget { @@ -40,12 +41,9 @@ class MarianumMessageListView extends StatelessWidget { child: (state, loading) => ListView.builder( itemCount: state.messageList.messages.length, itemBuilder: (context, index) { - var message = state.messageList.messages.toList()[index]; + var message = state.messageList.messages[index]; return ListTile( - leading: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.newspaper)], - ), + leading: const CenteredLeading(Icon(Icons.newspaper)), title: Text(message.name, overflow: TextOverflow.ellipsis), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/lib/view/pages/marianum_message/marianum_message_view.dart b/lib/view/pages/marianum_message/marianum_message_view.dart index ddc8a49..9647aa1 100644 --- a/lib/view/pages/marianum_message/marianum_message_view.dart +++ b/lib/view/pages/marianum_message/marianum_message_view.dart @@ -2,7 +2,6 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart'; import '../../../widget/app_progress_indicator.dart'; @@ -39,20 +38,8 @@ class _MessageViewState extends State { return SfPdfViewer.memory( snapshot.data!, enableHyperlinkNavigation: true, - onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { - showDialog( - context: context, - builder: (context) => ConfirmDialog( - title: 'Link öffnen', - content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}', - confirmButton: 'Öffnen', - onConfirm: () => launchUrl( - Uri.parse(e.uri), - mode: LaunchMode.externalApplication, - ), - ), - ); - }, + onHyperlinkClicked: (PdfHyperlinkClickedDetails e) => + ConfirmDialog.openBrowser(context, e.uri), ); }, ), diff --git a/lib/view/pages/marianum_message/search_marianum_messages.dart b/lib/view/pages/marianum_message/search_marianum_messages.dart index d03686a..c66d4ff 100644 --- a/lib/view/pages/marianum_message/search_marianum_messages.dart +++ b/lib/view/pages/marianum_message/search_marianum_messages.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../../../routing/app_routes.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; +import '../../../widget/centered_leading.dart'; import '../../../widget/placeholder_view.dart'; class SearchMarianumMessages extends SearchDelegate { @@ -45,10 +46,7 @@ class SearchMarianumMessages extends SearchDelegate { itemBuilder: (_, i) { final message = matches[i]; return ListTile( - leading: const Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [Icon(Icons.newspaper)], - ), + leading: const CenteredLeading(Icon(Icons.newspaper)), title: Text(message.name, overflow: TextOverflow.ellipsis), subtitle: Text('vom ${message.date}'), trailing: const Icon(Icons.arrow_right), diff --git a/lib/view/pages/settings/sections/about_section.dart b/lib/view/pages/settings/sections/about_section.dart index cea8183..aed8218 100644 --- a/lib/view/pages/settings/sections/about_section.dart +++ b/lib/view/pages/settings/sections/about_section.dart @@ -1,7 +1,6 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:jiffy/jiffy.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; @@ -71,7 +70,7 @@ class AboutSection extends StatelessWidget { applicationLegalese: 'Alles für deinen Schulalltag am Marianum Fulda.\n\n' "${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\n\n" - 'Marianum Fulda\n2019-2020 & 2022-${Jiffy.now().year}\nElias Müller', + 'Marianum Fulda\n2019-2020 & 2022-${DateTime.now().year}\nElias Müller', ); } diff --git a/lib/view/pages/share_intent/share_chat_picker.dart b/lib/view/pages/share_intent/share_chat_picker.dart index ef1c0d3..a5692ce 100644 --- a/lib/view/pages/share_intent/share_chat_picker.dart +++ b/lib/view/pages/share_intent/share_chat_picker.dart @@ -166,13 +166,24 @@ Future _afterExternalFilesUploaded( GetRoomResponseObject room, List uploadedRemotePaths, PendingShare share, -) async { +) => _runShareFlow( + context, + action: () => + shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths), + onSuccess: () => _setExternalDraftAndOpenChat(context, room, share), +); + +/// Shared share-flow scaffolding: shows the blocking spinner, runs [action], +/// maps failures to an error dialog (popping the spinner first), and invokes +/// [onSuccess] on success while still mounted. +Future _runShareFlow( + BuildContext context, { + required Future Function() action, + required VoidCallback onSuccess, +}) async { unawaited(_showBlockingSpinner(context)); try { - await shareFilesToChat( - token: room.token, - remoteFilePaths: uploadedRemotePaths, - ); + await action(); } catch (e) { if (context.mounted) Navigator.of(context).pop(); if (context.mounted) { @@ -186,7 +197,7 @@ Future _afterExternalFilesUploaded( return; } if (!context.mounted) return; - _setExternalDraftAndOpenChat(context, room, share); + onSuccess(); } void _setExternalDraftAndOpenChat( @@ -213,61 +224,30 @@ Future _internalShareFlow( BuildContext context, GetRoomResponseObject room, RemoteFileRef file, -) async { - unawaited(_showBlockingSpinner(context)); - try { - await shareFilesToChat( - token: room.token, - remoteFilePaths: [file.path], - ); - } catch (e) { - if (context.mounted) Navigator.of(context).pop(); - if (context.mounted) { - InfoDialog.show( - context, - errorToUserMessage(e), - title: 'Fehler', - copyable: true, - ); - } - return; - } - if (!context.mounted) return; - _finishWithChat(context, room); -} +) => _runShareFlow( + context, + action: () => + shareFilesToChat(token: room.token, remoteFilePaths: [file.path]), + onSuccess: () => _finishWithChat(context, room), +); Future _forwardMessageFlow( BuildContext context, GetRoomResponseObject room, String? text, RemoteFileRef? file, -) async { - unawaited(_showBlockingSpinner(context)); - try { +) => _runShareFlow( + context, + action: () async { if (file != null) { - await shareFilesToChat( - token: room.token, - remoteFilePaths: [file.path], - ); + await shareFilesToChat(token: room.token, remoteFilePaths: [file.path]); } if (text != null && text.isNotEmpty) { await SendMessage(room.token, SendMessageParams(text)).run(); } - } catch (e) { - if (context.mounted) Navigator.of(context).pop(); - if (context.mounted) { - InfoDialog.show( - context, - errorToUserMessage(e), - title: 'Fehler', - copyable: true, - ); - } - return; - } - if (!context.mounted) return; - _finishWithChat(context, room); -} + }, + onSuccess: () => _finishWithChat(context, room), +); /// Modal progress overlay shown during share-API roundtrips. The dialog is /// popped together with the picker by the subsequent popUntil(isFirst). diff --git a/lib/view/pages/share_intent/share_target_page.dart b/lib/view/pages/share_intent/share_target_page.dart index 5064570..87585bf 100644 --- a/lib/view/pages/share_intent/share_target_page.dart +++ b/lib/view/pages/share_intent/share_target_page.dart @@ -121,26 +121,15 @@ class ShareTargetPage extends StatelessWidget { Widget _buildFilePreview(BuildContext context) { if (share.filePaths.length == 1) { - final path = share.filePaths.first; - final name = path.split(Platform.pathSeparator).last; return ConstrainedBox( constraints: const BoxConstraints(maxHeight: 320), - child: Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(12), - ), - clipBehavior: Clip.antiAlias, - child: _isImagePath(path) - ? Image.file( - File(path), - fit: BoxFit.contain, - // Decode at most ~1080px so 50-MP gallery photos don't - // balloon the decode buffer just to render at <320px high. - cacheWidth: 1080, - errorBuilder: (_, _, _) => _fileFallbackLarge(name), - ) - : _fileFallbackLarge(name), + // Decode at most ~1080px so 50-MP gallery photos don't balloon the + // decode buffer just to render at <320px high. + child: _filePreviewTile( + context, + share.filePaths.first, + fit: BoxFit.contain, + cacheWidth: 1080, ), ); } @@ -153,28 +142,38 @@ class ShareTargetPage extends StatelessWidget { mainAxisSpacing: 10, ), itemCount: share.filePaths.length, - itemBuilder: (context, i) { - final path = share.filePaths[i]; - final name = path.split(Platform.pathSeparator).last; - return Container( - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(12), - ), - clipBehavior: Clip.antiAlias, - child: _isImagePath(path) - ? Image.file( - File(path), - fit: BoxFit.cover, - // Grid tiles are ~half-screen wide; 480px decode is - // sharp on 3x displays without blowing up memory when - // many files are shared at once. - cacheWidth: 480, - errorBuilder: (_, _, _) => _fileFallbackLarge(name), - ) - : _fileFallbackLarge(name), - ); - }, + // Grid tiles are ~half-screen wide; 480px decode is sharp on 3x displays + // without blowing up memory when many files are shared at once. + itemBuilder: (context, i) => _filePreviewTile( + context, + share.filePaths[i], + fit: BoxFit.cover, + cacheWidth: 480, + ), + ); + } + + Widget _filePreviewTile( + BuildContext context, + String path, { + required BoxFit fit, + required int cacheWidth, + }) { + final name = path.split(Platform.pathSeparator).last; + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(12), + ), + clipBehavior: Clip.antiAlias, + child: _isImagePath(path) + ? Image.file( + File(path), + fit: fit, + cacheWidth: cacheWidth, + errorBuilder: (_, _, _) => _fileFallbackLarge(name), + ) + : _fileFallbackLarge(name), ); } diff --git a/lib/view/pages/talk/chat_view.dart b/lib/view/pages/talk/chat_view.dart index 122b4c0..a23d7f3 100644 --- a/lib/view/pages/talk/chat_view.dart +++ b/lib/view/pages/talk/chat_view.dart @@ -110,10 +110,7 @@ class _ChatViewState extends State with RouteAware { if (state.currentToken != widget.room.token) return; final response = state.chatResponse; if (response == null) return; - var maxId = 0; - for (final m in response.data) { - if (m.id > maxId) maxId = m.id; - } + final maxId = response.data.map((m) => m.id).fold(0, math.max); if (maxId == 0) return; _chatListBlocRef?.markRoomAsRead(widget.room.token, maxId); unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId)); @@ -230,21 +227,20 @@ class _ChatViewState extends State with RouteAware { ? _searchQuery : null; + final commonRead = int.parse( + response.headers?['x-chat-last-common-read'] ?? '0', + ); + final messages = []; final chronologicalMatchIndex = {}; var lastDate = DateTime.now(); for (final element in response.sortByTimestamp()) { + if (ChatSearchController.isHiddenSystemMessage(element)) continue; + final elementDate = DateTime.fromMillisecondsSinceEpoch( element.timestamp * 1000, ); - if (element.systemMessage.contains('reaction')) continue; - if (element.systemMessage.contains('poll_voted')) continue; - if (element.systemMessage.contains('message_deleted')) continue; - final commonRead = int.parse( - response.headers?['x-chat-last-common-read'] ?? '0', - ); - if (!elementDate.isSameDay(lastDate)) { lastDate = elementDate; messages.add( diff --git a/lib/view/pages/talk/data/chat_bubble_styles.dart b/lib/view/pages/talk/data/chat_bubble_styles.dart index c5c5f21..3098cc0 100644 --- a/lib/view/pages/talk/data/chat_bubble_styles.dart +++ b/lib/view/pages/talk/data/chat_bubble_styles.dart @@ -4,18 +4,6 @@ import '../../../../theming/app_theme.dart'; import '../widgets/bubble.dart'; extension ColorExtensions on Color { - Color invert() { - final invertedR = 1.0 - r; - final invertedG = 1.0 - g; - final invertedB = 1.0 - b; - return Color.from( - alpha: a, - red: invertedR, - green: invertedG, - blue: invertedB, - ); - } - Color withWhite(int whiteValue) { final value = whiteValue / 255.0; return Color.from(alpha: a, red: value, green: value, blue: value); diff --git a/lib/view/pages/talk/data/chat_message.dart b/lib/view/pages/talk/data/chat_message.dart index 6e0a857..a66e490 100644 --- a/lib/view/pages/talk/data/chat_message.dart +++ b/lib/view/pages/talk/data/chat_message.dart @@ -16,8 +16,6 @@ class ChatMessage { RichObjectString? file; String content = ''; - bool get containsFile => file != null; - ChatMessage({required this.originalMessage, this.originalData}) { if (originalData?.containsKey('file') ?? false) { file = originalData?['file']; diff --git a/lib/view/pages/talk/data/chat_search_controller.dart b/lib/view/pages/talk/data/chat_search_controller.dart index 6b9c891..7e53a78 100644 --- a/lib/view/pages/talk/data/chat_search_controller.dart +++ b/lib/view/pages/talk/data/chat_search_controller.dart @@ -10,6 +10,14 @@ class ChatSearchMatch { } class ChatSearchController { + /// System messages that are folded into other bubbles (reactions, poll + /// votes, deletions) and therefore never rendered nor searched as their own + /// entry. + static bool isHiddenSystemMessage(GetChatResponseObject element) => + element.systemMessage.contains('reaction') || + element.systemMessage.contains('poll_voted') || + element.systemMessage.contains('message_deleted'); + static List findMatches( GetChatResponse response, String query, @@ -19,9 +27,7 @@ class ChatSearchController { final matches = []; for (final element in response.sortByTimestamp()) { - if (element.systemMessage.contains('reaction')) continue; - if (element.systemMessage.contains('poll_voted')) continue; - if (element.systemMessage.contains('message_deleted')) continue; + if (isHiddenSystemMessage(element)) continue; final haystackText = RichObjectStringProcessor.parseToString( element.message, diff --git a/lib/view/pages/talk/widgets/answer_reference.dart b/lib/view/pages/talk/widgets/answer_reference.dart index 6508ae5..a1c4e27 100644 --- a/lib/view/pages/talk/widgets/answer_reference.dart +++ b/lib/view/pages/talk/widgets/answer_reference.dart @@ -17,28 +17,17 @@ class AnswerReference extends StatelessWidget { @override Widget build(BuildContext context) { - var style = ChatBubbleStyles(context); + final style = ChatBubbleStyles(context); + final isSelf = referenceMessage.actorId == selfId; + final accent = isSelf + ? style.getSelfStyle(false).color!.withGreen(200) + : style.getRemoteStyle(false).color!.withWhite(200); return DecoratedBox( decoration: BoxDecoration( - color: referenceMessage.actorId == selfId - ? style - .getSelfStyle(false) - .color! - .withGreen(200) - .withValues(alpha: 0.2) - : style - .getRemoteStyle(false) - .color! - .withWhite(200) - .withValues(alpha: 0.2), + color: accent.withValues(alpha: 0.2), borderRadius: const BorderRadius.all(Radius.circular(5)), border: Border( - left: BorderSide( - color: referenceMessage.actorId == selfId - ? style.getSelfStyle(false).color!.withGreen(200) - : style.getRemoteStyle(false).color!.withWhite(200), - width: 5, - ), + left: BorderSide(color: accent, width: 5), ), ), child: Padding( @@ -51,9 +40,7 @@ class AnswerReference extends StatelessWidget { maxLines: 1, style: TextStyle( overflow: TextOverflow.ellipsis, - color: referenceMessage.actorId == selfId - ? style.getSelfStyle(false).color!.withGreen(200) - : style.getRemoteStyle(false).color!.withWhite(200), + color: accent, fontSize: 12, ), ), diff --git a/lib/view/pages/timetable/data/timetable_appointment_factory.dart b/lib/view/pages/timetable/data/timetable_appointment_factory.dart index 0bf4d55..9af54cc 100644 --- a/lib/view/pages/timetable/data/timetable_appointment_factory.dart +++ b/lib/view/pages/timetable/data/timetable_appointment_factory.dart @@ -8,6 +8,7 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart' import '../../../../storage/timetable_settings.dart'; import 'arbitrary_appointment.dart'; import 'lesson_color.dart'; +import 'lesson_merger.dart'; import 'lesson_status.dart'; import 'lesson_type_label.dart'; import 'rrule_with_exceptions.dart'; @@ -33,7 +34,7 @@ class TimetableAppointmentFactory { List build() { final source = settings.connectDoubleLessons - ? _mergeAdjacentLessons(lessons) + ? LessonMerger.merge(lessons) : lessons; return [ ...source.map(_lessonToAppointment), @@ -267,68 +268,4 @@ class TimetableAppointmentFactory { .trim(); return cleaned.isEmpty ? null : cleaned; } - - // Pure: builds a new list, does not mutate inputs. The previous version - // mutated `previous.endTime` in place which caused merged blocks to grow - // further on subsequent rebuilds when the same lesson objects were observed - // again by the next merge pass. - static List _mergeAdjacentLessons( - List input, { - Duration maxGap = const Duration(minutes: 5), - }) { - if (input.isEmpty) return const []; - - final sorted = [...input] - ..sort((a, b) => a.startDateTime.compareTo(b.startDateTime)); - - final merged = []; - for (final current in sorted) { - if (merged.isNotEmpty && _canMerge(merged.last, current, maxGap)) { - final prev = merged.removeLast(); - merged.add(_extendedEnd(prev, current.endTime)); - } else { - merged.add(current); - } - } - return merged; - } - - static McTimetableEntry _extendedEnd( - McTimetableEntry source, - DateTime newEndTime, - ) => McTimetableEntry( - id: source.id, - date: source.date, - startTime: source.startTime, - endTime: newEndTime, - subjects: source.subjects, - teachers: source.teachers, - rooms: source.rooms, - classNames: source.classNames, - lessonType: source.lessonType, - status: source.status, - substitutionText: source.substitutionText, - lessonText: source.lessonText, - infoText: source.infoText, - ); - - static bool _canMerge( - McTimetableEntry a, - McTimetableEntry b, - Duration maxGap, - ) { - if (a.subjects.firstOrNull != b.subjects.firstOrNull) return false; - if (a.rooms.firstOrNull != b.rooms.firstOrNull) return false; - if (a.teachers.firstOrNull?.shortName != - b.teachers.firstOrNull?.shortName) { - return false; - } - if (a.status != b.status) return false; - - // Merge only sequential lessons (b starts at or after a ends, within the - // tolerance). Without the lower bound, identical-metadata lessons that - // overlap in time would silently collapse into one. - final gap = b.startDateTime.difference(a.endDateTime); - return !gap.isNegative && gap <= maxGap; - } } diff --git a/lib/view/pages/timetable/details/lesson_sheet.dart b/lib/view/pages/timetable/details/lesson_sheet.dart index b0dfb1c..3ea8504 100644 --- a/lib/view/pages/timetable/details/lesson_sheet.dart +++ b/lib/view/pages/timetable/details/lesson_sheet.dart @@ -268,12 +268,10 @@ class LessonSheet { ); } - static String _line(String name, {String? longname, String? extra}) { + static String _line(String name, {String? longname}) { final parts = [if (name.isNotEmpty) name else '?']; final ln = (longname ?? '').trim(); if (ln.isNotEmpty && ln != name) parts.add('($ln)'); - final ex = (extra ?? '').trim(); - if (ex.isNotEmpty) parts.add('· $ex'); return parts.join(' '); } diff --git a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart index c3fd17c..7044b7d 100644 --- a/lib/view/pages/timetable/widgets/calendar/outside_chips.dart +++ b/lib/view/pages/timetable/widgets/calendar/outside_chips.dart @@ -116,12 +116,9 @@ class _OutsideDayColumn extends StatelessWidget { static String _subtitleFor(Appointment a) { if (isAllDayLike(a)) return 'Ganztägig'; - return '${_hm(a.startTime)}–${_hm(a.endTime)}'; + return '${a.startTime.formatHm()}–${a.endTime.formatHm()}'; } - static String _hm(DateTime t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; - @override Widget build(BuildContext context) { if (appointments.isEmpty) return const SizedBox.shrink(); @@ -182,9 +179,7 @@ class _OutsideChip extends StatelessWidget { Widget build(BuildContext context) { final theme = Theme.of(context); final allDay = isAllDayLike(appointment); - final timeLabel = allDay - ? null - : '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}'; + final timeLabel = allDay ? null : appointment.startTime.formatHm(); // Past chips fade further, future/ongoing ones get a more saturated tint // so the strip no longer reads as one uniform grey block. diff --git a/lib/view/pages/timetable/widgets/calendar/week_grid.dart b/lib/view/pages/timetable/widgets/calendar/week_grid.dart index 9c49381..def0ffc 100644 --- a/lib/view/pages/timetable/widgets/calendar/week_grid.dart +++ b/lib/view/pages/timetable/widgets/calendar/week_grid.dart @@ -289,14 +289,11 @@ class _DayColumn extends StatelessWidget { } static String _overflowSubtitle(Appointment apt) { - final time = '${_formatHm(apt.startTime)}–${_formatHm(apt.endTime)}'; + final time = '${apt.startTime.formatHm()}–${apt.endTime.formatHm()}'; final loc = apt.location?.replaceAll('\n', ' · '); return loc != null && loc.isNotEmpty ? '$time · $loc' : time; } - static String _formatHm(DateTime t) => - '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; - @override Widget build(BuildContext context) { final theme = Theme.of(context); diff --git a/lib/widget/async_actions/async_action_controller.dart b/lib/widget/async_actions/async_action_controller.dart index 51c385d..0d27c65 100644 --- a/lib/widget/async_actions/async_action_controller.dart +++ b/lib/widget/async_actions/async_action_controller.dart @@ -3,6 +3,11 @@ part of '../async_action_button.dart'; typedef AsyncActionCallback = Future Function(); typedef AsyncErrorBuilder = String Function(Object error); +/// Shared style for the inline error message the async-button family renders +/// beneath a control after a caught failure. +TextStyle _asyncErrorTextStyle(BuildContext context) => + TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 13); + /// Wraps [action] with a try/catch that pops up an [InfoDialog] on failure /// (using [errorBuilder] or the default error mapper). Returns `true` on /// success, `false` on caught failure. diff --git a/lib/widget/async_actions/async_dialog_action.dart b/lib/widget/async_actions/async_dialog_action.dart index e96d59b..43f202d 100644 --- a/lib/widget/async_actions/async_dialog_action.dart +++ b/lib/widget/async_actions/async_dialog_action.dart @@ -44,10 +44,7 @@ class _AsyncDialogActionState extends State { child: Text( err, textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 13, - ), + style: _asyncErrorTextStyle(context), ), ), Row( diff --git a/lib/widget/async_actions/async_list_tile.dart b/lib/widget/async_actions/async_list_tile.dart index 5422679..461f98b 100644 --- a/lib/widget/async_actions/async_list_tile.dart +++ b/lib/widget/async_actions/async_list_tile.dart @@ -76,13 +76,7 @@ class _AsyncListTileState extends State { if (err != null) Padding( padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), - child: Text( - err, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 13, - ), - ), + child: Text(err, style: _asyncErrorTextStyle(context)), ), ], ); diff --git a/lib/widget/async_actions/async_mixin.dart b/lib/widget/async_actions/async_mixin.dart index c4700f6..13763fe 100644 --- a/lib/widget/async_actions/async_mixin.dart +++ b/lib/widget/async_actions/async_mixin.dart @@ -108,10 +108,7 @@ class _InlineErrorWrapper extends StatelessWidget { Text( err, textAlign: TextAlign.center, - style: TextStyle( - color: Theme.of(context).colorScheme.error, - fontSize: 13, - ), + style: _asyncErrorTextStyle(context), ), ], ], diff --git a/lib/widget/debug/cache_view.dart b/lib/widget/debug/cache_view.dart index 98804b3..cbe095c 100644 --- a/lib/widget/debug/cache_view.dart +++ b/lib/widget/debug/cache_view.dart @@ -1,4 +1,3 @@ -import 'dart:async'; import 'dart:convert'; import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; @@ -85,11 +84,3 @@ class _CacheViewState extends State { ), ); } - -extension FutureExtension on Future { - bool isCompleted() { - final completer = Completer(); - then(completer.complete).catchError(completer.completeError); - return completer.isCompleted; - } -} diff --git a/lib/widget/file_pick.dart b/lib/widget/file_pick.dart index a729999..056e2a3 100644 --- a/lib/widget/file_pick.dart +++ b/lib/widget/file_pick.dart @@ -17,7 +17,6 @@ class FilePick { static Future?> documentPick() async { final result = await FilePicker.pickFiles(allowMultiple: true); - final paths = result?.files.nonNulls.map((e) => e.path).toList(); - return paths?.nonNulls.toList(); + return result?.files.map((e) => e.path).nonNulls.toList(); } } diff --git a/lib/widget_data/widget_sync.dart b/lib/widget_data/widget_sync.dart index 4200142..d94cbd4 100644 --- a/lib/widget_data/widget_sync.dart +++ b/lib/widget_data/widget_sync.dart @@ -39,21 +39,15 @@ class WidgetSync { _initialised = true; } - static Future writeDayData(WidgetTimetableData data) async { - await ensureInitialized(); - await HomeWidget.saveWidgetData(dayDataKey, jsonEncode(data.toJson())); - await HomeWidget.saveWidgetData( - fetchedAtKey, - data.fetchedAt.toIso8601String(), - ); - } + static Future writeDayData(WidgetTimetableData data) => + _writeData(dayDataKey, data); - static Future writeWeekData(WidgetTimetableData data) async { + static Future writeWeekData(WidgetTimetableData data) => + _writeData(weekDataKey, data); + + static Future _writeData(String key, WidgetTimetableData data) async { await ensureInitialized(); - await HomeWidget.saveWidgetData( - weekDataKey, - jsonEncode(data.toJson()), - ); + await HomeWidget.saveWidgetData(key, jsonEncode(data.toJson())); await HomeWidget.saveWidgetData( fetchedAtKey, data.fetchedAt.toIso8601String(),