simplify: dedupe boilerplate and remove dead code across all layers

This commit is contained in:
2026-07-13 22:22:39 +02:00
parent 15791423ea
commit 398b147c76
69 changed files with 345 additions and 651 deletions
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -38,9 +37,6 @@ class AutocompleteApi {
technicalDetails: 'core/autocomplete/get: ${response.body}', technicalDetails: 'core/autocomplete/get: ${response.body}',
); );
} }
final decoded = jsonDecode(response.body) as Map<String, dynamic>; return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
return AutocompleteResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
} }
} }
+7
View File
@@ -1,3 +1,5 @@
import 'dart:convert';
import '../../model/account_data.dart'; import '../../model/account_data.dart';
import '../../model/endpoint_data.dart'; import '../../model/endpoint_data.dart';
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
class NextcloudOcs { class NextcloudOcs {
NextcloudOcs._(); NextcloudOcs._();
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
/// every response nests its `meta`/`data` under).
static Map<String, dynamic> decode(String raw) =>
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
static Map<String, String> headers() => { static Map<String, String> headers() => {
'Accept': 'application/json', 'Accept': 'application/json',
'OCS-APIRequest': 'true', 'OCS-APIRequest': 'true',
@@ -1,4 +1,3 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -28,9 +27,7 @@ class SearchFiles {
'Files search failed with ${response.statusCode}: ${response.body}', 'Files search failed with ${response.statusCode}: ${response.body}',
); );
} }
final decoded = jsonDecode(response.body) as Map<String, dynamic>; final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
final ocs = decoded['ocs'] as Map<String, dynamic>;
final data = ocs['data'] as Map<String, dynamic>;
return SearchFilesResponse.fromJson(data); return SearchFilesResponse.fromJson(data);
} }
} }
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/http.dart'; import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_chat_params.dart'; import 'get_chat_params.dart';
import 'get_chat_response.dart'; import 'get_chat_response.dart';
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
: super('v1/chat/$chatToken', null, getParameters: params.toJson()); : super('v1/chat/$chatToken', null, getParameters: params.toJson());
@override @override
GetChatResponse assemble(String raw) { GetChatResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; GetChatResponse.fromJson(NextcloudOcs.decode(raw));
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
@override @override
Future<Response> request( Future<Response> request(
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@@ -57,8 +56,7 @@ class LongPollChat {
final status = response.statusCode; final status = response.statusCode;
if (status == 304) return null; if (status == 304) return null;
if (status >= 200 && status < 300) { if (status >= 200 && status < 300) {
final decoded = jsonDecode(response.body) as Map<String, dynamic>; return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
..headers = response.headers; ..headers = response.headers;
} }
throw ServerException( throw ServerException(
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../api_params.dart'; import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart'; import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart'; import '../talk_api.dart';
@@ -12,12 +11,8 @@ class ClosePoll extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null); : super('v1/poll/$token/$pollId', null);
@override @override
GetPollStateResponse assemble(String raw) { GetPollStateResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override @override
Future<http.Response> request( Future<http.Response> request(
@@ -1,8 +1,7 @@
import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/http.dart'; import 'package:http/http.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'create_room_params.dart'; import 'create_room_params.dart';
import 'create_room_response.dart'; import 'create_room_response.dart';
@@ -13,10 +12,8 @@ class CreateRoom extends TalkApi<CreateRoomResponse> {
CreateRoom(this.params) : super('v4/room', params); CreateRoom(this.params) : super('v4/room', params);
@override @override
CreateRoomResponse assemble(String raw) { CreateRoomResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
@override @override
Future<Response>? request( Future<Response>? request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_participants_response.dart'; import 'get_participants_response.dart';
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
GetParticipants(this.token) : super('v4/room/$token/participants', null); GetParticipants(this.token) : super('v4/room/$token/participants', null);
@override @override
GetParticipantsResponse assemble(String raw) { GetParticipantsResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
return GetParticipantsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override @override
Future<http.Response> request( Future<http.Response> request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_poll_state_response.dart'; import 'get_poll_state_response.dart';
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
: super('v1/poll/$token/$pollId', null); : super('v1/poll/$token/$pollId', null);
@override @override
GetPollStateResponse assemble(String raw) { GetPollStateResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override @override
Future<http.Response> request( Future<http.Response> request(
@@ -1,9 +1,8 @@
import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:http/http.dart'; import 'package:http/http.dart';
import '../../../api_params.dart'; import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_reactions_response.dart'; import 'get_reactions_response.dart';
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
: super('v1/reaction/$chatToken/$messageId', null); : super('v1/reaction/$chatToken/$messageId', null);
@override @override
GetReactionsResponse assemble(String raw) { GetReactionsResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
return GetReactionsResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override @override
Future<Response>? request( Future<Response>? request(
@@ -1,7 +1,6 @@
import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../nextcloud_ocs.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'get_room_params.dart'; import 'get_room_params.dart';
import 'get_room_response.dart'; import 'get_room_response.dart';
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson()); GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
@override @override
GetRoomResponse assemble(String raw) { GetRoomResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
@override @override
Future<http.Response> request( Future<http.Response> request(
@@ -3,6 +3,7 @@ import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../../../api_params.dart'; import '../../../api_params.dart';
import '../../nextcloud_ocs.dart';
import '../get_poll/get_poll_state_response.dart'; import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart'; import '../talk_api.dart';
import 'vote_poll_params.dart'; import 'vote_poll_params.dart';
@@ -22,12 +23,8 @@ class VotePoll extends TalkApi<GetPollStateResponse> {
); );
@override @override
GetPollStateResponse assemble(String raw) { GetPollStateResponse assemble(String raw) =>
final decoded = jsonDecode(raw) as Map<String, dynamic>; GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override @override
Future<http.Response>? request( Future<http.Response>? request(
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
class MarianumConnectApi { class MarianumConnectApi {
static const Duration _connectTimeout = Duration(seconds: 10); static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 20); static const Duration _receiveTimeout = Duration(seconds: 20);
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
static final Dio _instance = _build(); static final Dio _instance = _build();
static Dio dio() => _instance; 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() { static Dio _build() {
final dio = Dio( final dio = Dio(
BaseOptions( BaseOptions(
@@ -26,4 +26,36 @@ abstract class MarianumConnectQuery {
throw mapMarianumConnectError(e); throw mapMarianumConnectError(e);
} }
} }
/// GETs [path] and parses the JSON object body with [fromJson].
Future<T> getObject<T>(
String path,
T Function(Map<String, dynamic> json) fromJson, {
Map<String, dynamic>? queryParameters,
}) => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint(path),
queryParameters: queryParameters,
);
return fromJson(response.data!);
});
/// GETs [path] and maps each element of the JSON array body with [fromJson].
Future<List<T>> getList<T>(
String path,
T Function(Map<String, dynamic> json) fromJson, {
Map<String, dynamic>? queryParameters,
}) => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint(path),
queryParameters: queryParameters,
);
return response.data!
.map((e) => fromJson(e as Map<String, dynamic>))
.toList();
});
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
String isoDate(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
} }
@@ -1,6 +1,7 @@
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import '../../auth/token_storage.dart'; import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
import 'auth_login_response.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 /// 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. /// would attempt to re-auth us into a loop if our credentials are wrong.
class AuthLogin extends MarianumConnectQuery { class AuthLogin extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage; final MarianumConnectTokenStorage _tokenStorage;
AuthLogin({ AuthLogin({
@@ -19,17 +17,7 @@ class AuthLogin extends MarianumConnectQuery {
const MarianumConnectTokenStorage(), const MarianumConnectTokenStorage(),
Dio? dio, Dio? dio,
}) : _tokenStorage = tokenStorage, }) : _tokenStorage = tokenStorage,
super(dio: dio ?? _buildDio()); super(dio: dio ?? MarianumConnectApi.plainDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
Future<AuthLoginResponse> run({ Future<AuthLoginResponse> run({
required String username, required String username,
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart'; import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart'; import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_query.dart'; import '../../marianumconnect_query.dart';
/// Probes that the stored bearer token still maps to the given credentials. /// 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 /// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
/// and obscure a real 401 with a silent re-login. /// and obscure a real 401 with a silent re-login.
class AuthVerify extends MarianumConnectQuery { class AuthVerify extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage; final MarianumConnectTokenStorage _tokenStorage;
AuthVerify({ AuthVerify({
@@ -22,17 +20,7 @@ class AuthVerify extends MarianumConnectQuery {
const MarianumConnectTokenStorage(), const MarianumConnectTokenStorage(),
Dio? dio, Dio? dio,
}) : _tokenStorage = tokenStorage, }) : _tokenStorage = tokenStorage,
super(dio: dio ?? _buildDio()); super(dio: dio ?? MarianumConnectApi.plainDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
/// Throws [AuthException] on 401 (credentials no longer match the token's /// Throws [AuthException] on 401 (credentials no longer match the token's
/// user, token missing, or token rejected), other [AppException]s on /// user, token missing, or token rejected), other [AppException]s on
@@ -7,8 +7,6 @@ import 'get_breakers_response.dart';
class GetBreakers extends MarianumConnectQuery { class GetBreakers extends MarianumConnectQuery {
GetBreakers({super.dio}); GetBreakers({super.dio});
Future<GetBreakersResponse> run() => guard(() async { Future<GetBreakersResponse> run() =>
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker')); getObject('breaker', GetBreakersResponse.fromJson);
return GetBreakersResponse.fromJson(response.data!);
});
} }
@@ -7,10 +7,6 @@ import 'get_capabilities_response.dart';
class GetCapabilities extends MarianumConnectQuery { class GetCapabilities extends MarianumConnectQuery {
GetCapabilities({super.dio}); GetCapabilities({super.dio});
Future<CapabilitiesResponse> run() => guard(() async { Future<CapabilitiesResponse> run() =>
final response = await dio.get<Map<String, dynamic>>( getObject('me/capabilities', CapabilitiesResponse.fromJson);
endpoint('me/capabilities'),
);
return CapabilitiesResponse.fromJson(response.data!);
});
} }
@@ -4,10 +4,5 @@ import '../../models/mc_holiday.dart';
class GetHolidays extends MarianumConnectQuery { class GetHolidays extends MarianumConnectQuery {
GetHolidays({super.dio}); GetHolidays({super.dio});
Future<List<McHoliday>> run() => guard(() async { Future<List<McHoliday>> run() => getList('holidays', McHoliday.fromJson);
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
return response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
});
} }
@@ -6,8 +6,6 @@ import 'get_ticker_response.dart';
class GetTicker extends MarianumConnectQuery { class GetTicker extends MarianumConnectQuery {
GetTicker({super.dio}); GetTicker({super.dio});
Future<TickerResponse> run() => guard(() async { Future<TickerResponse> run() =>
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker')); getObject('ticker', TickerResponse.fromJson);
return TickerResponse.fromJson(response.data!);
});
} }
@@ -6,10 +6,6 @@ import 'get_ticker_nav_response.dart';
class GetTickerNav extends MarianumConnectQuery { class GetTickerNav extends MarianumConnectQuery {
GetTickerNav({super.dio}); GetTickerNav({super.dio});
Future<TickerNavResponse> run() => guard(() async { Future<TickerNavResponse> run() =>
final response = await dio.get<Map<String, dynamic>>( getObject('ticker/pages', TickerNavResponse.fromJson);
endpoint('ticker/pages'),
);
return TickerNavResponse.fromJson(response.data!);
});
} }
@@ -4,10 +4,8 @@ import '../../marianumconnect_query.dart';
class TimetableCustomEventsGet extends MarianumConnectQuery { class TimetableCustomEventsGet extends MarianumConnectQuery {
TimetableCustomEventsGet({super.dio}); TimetableCustomEventsGet({super.dio});
Future<GetCustomTimetableEventResponse> run() => guard(() async { Future<GetCustomTimetableEventResponse> run() => getObject(
final response = await dio.get<Map<String, dynamic>>( 'timetable/custom-events',
endpoint('timetable/custom-events'), GetCustomTimetableEventResponse.fromJson,
); );
return GetCustomTimetableEventResponse.fromJson(response.data!);
});
} }
@@ -4,13 +4,11 @@ import 'timetable_get_classes_response.dart';
class TimetableGetClasses extends MarianumConnectQuery { class TimetableGetClasses extends MarianumConnectQuery {
TimetableGetClasses({super.dio}); TimetableGetClasses({super.dio});
Future<TimetableGetClassesResponse> run() => guard(() async { Future<TimetableGetClassesResponse> run() async =>
final response = await dio.get<List<dynamic>>( TimetableGetClassesResponse(
endpoint('timetable/elements/classes'), result: await getList(
'timetable/elements/classes',
McTimetableClass.fromJson,
),
); );
final list = response.data!
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetClassesResponse(result: list);
});
} }
@@ -13,14 +13,9 @@ class TimetableGetElementWeek extends MarianumConnectQuery {
required int id, required int id,
required DateTime from, required DateTime from,
required DateTime until, required DateTime until,
}) => guard(() async { }) => getObject(
final response = await dio.get<Map<String, dynamic>>( 'timetable/${type.pathSegment}/$id',
endpoint('timetable/${type.pathSegment}/$id'), TimetableGetWeekResponse.fromJson,
queryParameters: {'from': _format(from), 'until': _format(until)}, queryParameters: {'from': isoDate(from), 'until': isoDate(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')}';
} }
@@ -4,13 +4,8 @@ import 'timetable_get_holidays_response.dart';
class TimetableGetHolidays extends MarianumConnectQuery { class TimetableGetHolidays extends MarianumConnectQuery {
TimetableGetHolidays({super.dio}); TimetableGetHolidays({super.dio});
Future<TimetableGetHolidaysResponse> run() => guard(() async { Future<TimetableGetHolidaysResponse> run() async =>
final response = await dio.get<List<dynamic>>( TimetableGetHolidaysResponse(
endpoint('timetable/holidays'), result: await getList('timetable/holidays', McHoliday.fromJson),
); );
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
});
} }
@@ -4,11 +4,7 @@ import 'timetable_get_rooms_response.dart';
class TimetableGetRooms extends MarianumConnectQuery { class TimetableGetRooms extends MarianumConnectQuery {
TimetableGetRooms({super.dio}); TimetableGetRooms({super.dio});
Future<TimetableGetRoomsResponse> run() => guard(() async { Future<TimetableGetRoomsResponse> run() async => TimetableGetRoomsResponse(
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms')); result: await getList('timetable/rooms', McRoom.fromJson),
final list = response.data! );
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetRoomsResponse(result: list);
});
} }
@@ -4,10 +4,6 @@ import 'timetable_get_schoolyear_response.dart';
class TimetableGetSchoolyear extends MarianumConnectQuery { class TimetableGetSchoolyear extends MarianumConnectQuery {
TimetableGetSchoolyear({super.dio}); TimetableGetSchoolyear({super.dio});
Future<TimetableGetSchoolyearResponse> run() => guard(() async { Future<TimetableGetSchoolyearResponse> run() =>
final response = await dio.get<Map<String, dynamic>>( getObject('timetable/schoolyear', TimetableGetSchoolyearResponse.fromJson);
endpoint('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
});
} }
@@ -4,13 +4,11 @@ import 'timetable_get_students_response.dart';
class TimetableGetStudents extends MarianumConnectQuery { class TimetableGetStudents extends MarianumConnectQuery {
TimetableGetStudents({super.dio}); TimetableGetStudents({super.dio});
Future<TimetableGetStudentsResponse> run() => guard(() async { Future<TimetableGetStudentsResponse> run() async =>
final response = await dio.get<List<dynamic>>( TimetableGetStudentsResponse(
endpoint('timetable/elements/students'), result: await getList(
'timetable/elements/students',
McTimetableStudent.fromJson,
),
); );
final list = response.data!
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetStudentsResponse(result: list);
});
} }
@@ -4,13 +4,8 @@ import 'timetable_get_subjects_response.dart';
class TimetableGetSubjects extends MarianumConnectQuery { class TimetableGetSubjects extends MarianumConnectQuery {
TimetableGetSubjects({super.dio}); TimetableGetSubjects({super.dio});
Future<TimetableGetSubjectsResponse> run() => guard(() async { Future<TimetableGetSubjectsResponse> run() async =>
final response = await dio.get<List<dynamic>>( TimetableGetSubjectsResponse(
endpoint('timetable/subjects'), result: await getList('timetable/subjects', McSubject.fromJson),
); );
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
});
} }
@@ -4,13 +4,11 @@ import 'timetable_get_teachers_response.dart';
class TimetableGetTeachers extends MarianumConnectQuery { class TimetableGetTeachers extends MarianumConnectQuery {
TimetableGetTeachers({super.dio}); TimetableGetTeachers({super.dio});
Future<TimetableGetTeachersResponse> run() => guard(() async { Future<TimetableGetTeachersResponse> run() async =>
final response = await dio.get<List<dynamic>>( TimetableGetTeachersResponse(
endpoint('timetable/elements/teachers'), result: await getList(
'timetable/elements/teachers',
McTimetableTeacherElement.fromJson,
),
); );
final list = response.data!
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTeachersResponse(result: list);
});
} }
@@ -4,13 +4,8 @@ import 'timetable_get_timegrid_response.dart';
class TimetableGetTimegrid extends MarianumConnectQuery { class TimetableGetTimegrid extends MarianumConnectQuery {
TimetableGetTimegrid({super.dio}); TimetableGetTimegrid({super.dio});
Future<TimetableGetTimegridResponse> run() => guard(() async { Future<TimetableGetTimegridResponse> run() async =>
final response = await dio.get<List<dynamic>>( TimetableGetTimegridResponse(
endpoint('timetable/timegrid'), result: await getList('timetable/timegrid', McTimegridUnit.fromJson),
); );
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
});
} }
@@ -7,14 +7,9 @@ class TimetableGetWeek extends MarianumConnectQuery {
Future<TimetableGetWeekResponse> run({ Future<TimetableGetWeekResponse> run({
required DateTime from, required DateTime from,
required DateTime until, required DateTime until,
}) => guard(() async { }) => getObject(
final response = await dio.get<Map<String, dynamic>>( 'timetable/me',
endpoint('timetable/me'), TimetableGetWeekResponse.fromJson,
queryParameters: {'from': _format(from), 'until': _format(until)}, queryParameters: {'from': isoDate(from), 'until': isoDate(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')}';
} }
@@ -7,14 +7,11 @@ import 'user_search_response.dart';
class UserSearch extends MarianumConnectQuery { class UserSearch extends MarianumConnectQuery {
UserSearch({super.dio}); UserSearch({super.dio});
Future<UserSearchResponse> run(String query) => guard(() async { Future<UserSearchResponse> run(String query) async => UserSearchResponse(
final response = await dio.get<List<dynamic>>( result: await getList(
endpoint('users/search'), 'users/search',
McUserSearchResult.fromJson,
queryParameters: {'q': query}, queryParameters: {'q': query},
),
); );
final list = response.data!
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
.toList();
return UserSearchResponse(result: list);
});
} }
+2 -6
View File
@@ -13,12 +13,8 @@ extension IsSameDay on DateTime {
TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute); TimeOfDay toTimeOfDay() => TimeOfDay(hour: hour, minute: minute);
bool isSameDateTime(DateTime other) { bool isSameDateTime(DateTime other) =>
var isSameDay = this.isSameDay(other); isSameDay(other) && toTimeOfDay() == other.toTimeOfDay();
var isSameTimeOfDay = (toTimeOfDay() == other.toTimeOfDay());
return isSameDay && isSameTimeOfDay;
}
bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other); bool isSameOrAfter(DateTime other) => isSameDateTime(other) || isAfter(other);
} }
+12 -14
View File
@@ -196,15 +196,10 @@ class AccountData {
/// Prefer this over embedding credentials in URLs — error logs and crash /// Prefer this over embedding credentials in URLs — error logs and crash
/// reports often capture the URL but not headers. /// reports often capture the URL but not headers.
String getBasicAuthHeader() { String getBasicAuthHeader() {
if (!isPopulated()) { _requirePopulated();
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
// Prefer the scoped app password once available; it survives real-password // Prefer the scoped app password once available; it survives real-password
// rotation and is what the push-v2 registration is bound to. // rotation and is what the push-v2 registration is bound to.
final secret = _appPassword ?? _password; return _basicAuth(_appPassword ?? _password!);
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
} }
/// Basic-auth header using the Talk app password — authenticates the /// 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 password has not been minted yet; callers treat that as a failed
/// talk registration and retry on the next start. /// talk registration and retry on the next start.
String getTalkBasicAuthHeader() { String getTalkBasicAuthHeader() {
if (!isPopulated()) { _requirePopulated();
throw Exception(
'AccountData (e.g. username or password) is not initialized!',
);
}
if (!hasAppPasswordTalk()) { if (!hasAppPasswordTalk()) {
throw StateError('Talk app password not available yet'); 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, /// Basic-auth header that always uses the real password. Needed exactly once,
/// to mint the app password via `core/getapppassword` (an app password cannot /// to mint the app password via `core/getapppassword` (an app password cannot
/// mint another). /// mint another).
String getRealPasswordBasicAuthHeader() { String getRealPasswordBasicAuthHeader() {
_requirePopulated();
return _basicAuth(_password!);
}
void _requirePopulated() {
if (!isPopulated()) { if (!isPopulated()) {
throw Exception( throw Exception(
'AccountData (e.g. username or password) is not initialized!', '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 /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
/// header map ready to merge into HTTP request headers. /// header map ready to merge into HTTP request headers.
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()}; Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
+2 -5
View File
@@ -29,13 +29,10 @@ class EndpointData {
EndpointData._construct(); EndpointData._construct();
EndpointMode getEndpointMode() { EndpointMode getEndpointMode() =>
late String existingName; AccountData().getUsername().startsWith('google')
existingName = AccountData().getUsername();
return existingName.startsWith('google')
? EndpointMode.stage ? EndpointMode.stage
: EndpointMode.live; : EndpointMode.live;
}
Endpoint nextcloud() => EndpointOptions( Endpoint nextcloud() => EndpointOptions(
live: Endpoint(domain: 'cloud.marianum-fulda.de'), live: Endpoint(domain: 'cloud.marianum-fulda.de'),
+3 -9
View File
@@ -371,13 +371,7 @@ class PushRenderer {
jsonEncode({'chatToken': ?chatToken, 'nid': nid}); jsonEncode({'chatToken': ?chatToken, 'nid': nid});
/// Deterministic non-negative 31-bit id from a string, used when the push /// Deterministic non-negative 31-bit id from a string, used when the push
/// carries no `nid`. /// carries no `nid`. Shares the hash with [stableChatNotificationId] (an
int _fallbackId(String? seed) { /// empty/null seed hashes to 0).
if (seed == null || seed.isEmpty) return 0; int _fallbackId(String? seed) => stableChatNotificationId(seed ?? '');
var hash = 0;
for (final unit in seed.codeUnits) {
hash = (hash * 31 + unit) & 0x7fffffff;
}
return hash;
}
} }
+1 -1
View File
@@ -440,7 +440,7 @@ class AppRoutes {
static bool goToTab(BuildContext context, Modules module) { static bool goToTab(BuildContext context, Modules module) {
final index = AppModule.getBottomBarModules( final index = AppModule.getBottomBarModules(
context, context,
).map((e) => e.module).toList().indexOf(module); ).indexWhere((e) => e.module == module);
if (index == -1) return false; if (index == -1) return false;
Main.bottomNavigator.jumpToTab(index); Main.bottomNavigator.jumpToTab(index);
return true; return true;
+4 -8
View File
@@ -1,3 +1,5 @@
import 'package:flutter/foundation.dart';
class PendingShare { class PendingShare {
final List<String> filePaths; final List<String> filePaths;
final String? text; final String? text;
@@ -17,12 +19,6 @@ class PendingShare {
/// fires two `open(url)` requests per share (see ShareViewController), so /// fires two `open(url)` requests per share (see ShareViewController), so
/// the same share can arrive twice on the media stream — receivedAt is /// the same share can arrive twice on the media stream — receivedAt is
/// deliberately ignored here so such duplicates compare equal. /// deliberately ignored here so such duplicates compare equal.
bool contentEquals(PendingShare other) { bool contentEquals(PendingShare other) =>
if (text != other.text) return false; text == other.text && listEquals(filePaths, other.filePaths);
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;
}
} }
@@ -123,15 +123,10 @@ abstract class LoadableHydratedBloc<
fetch(); fetch();
} }
void fetch() { /// Maps [e] through the shared error mapper and emits it as an [Error] event.
log('Fetching data for ${TState.toString()}'); /// Does not guard [isClosed] — callers decide whether a late error still
gatherData() /// applies.
.catchError((e) { void addLoadingError(Object e) => add(
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( Error(
LoadingError( LoadingError(
message: errorToUserMessage(e), message: errorToUserMessage(e),
@@ -140,6 +135,16 @@ abstract class LoadableHydratedBloc<
), ),
), ),
); );
void fetch() {
log('Fetching data for ${TState.toString()}');
gatherData()
.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;
addLoadingError(e);
}) })
.then((value) { .then((value) {
log('Fetch for ${TState.toString()} completed!'); log('Fetch for ${TState.toString()} completed!');
+1 -13
View File
@@ -4,13 +4,11 @@ import 'dart:math' as math;
import 'package:flutter/widgets.dart'; 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/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart'; 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 '../../../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.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';
@@ -181,17 +179,7 @@ class ChatBloc
if (!stillCurrent()) return; if (!stillCurrent()) return;
if (capturedError != null) { if (capturedError != null) addLoadingError(capturedError!);
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
} }
void _startLongPoll(String token) { void _startLongPoll(String token) {
@@ -3,10 +3,8 @@ import 'dart:developer';
import 'package:flutter_app_badge/flutter_app_badge.dart'; 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/chat/get_chat_response.dart';
import '../../../../../api/marianumcloud/talk/room/get_room_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.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 '../repository/chat_list_repository.dart'; import '../repository/chat_list_repository.dart';
@@ -87,17 +85,7 @@ class ChatListBloc
} catch (e) { } catch (e) {
capturedError = e; capturedError = e;
} }
if (capturedError != null) { if (capturedError != null) addLoadingError(capturedError!);
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
} }
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in /// Creates (or resolves) a 1:1 chat and returns its room token, or null in
@@ -2,9 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import '../../../../../api/errors/error_mapper.dart';
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.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.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 '../repository/files_repository.dart'; import '../repository/files_repository.dart';
@@ -112,16 +110,6 @@ class FilesBloc
); );
add(DataGathered((s) => s.copyWith(listing: listing))); add(DataGathered((s) => s.copyWith(listing: listing)));
} }
if (capturedError != null) { if (capturedError != null) addLoadingError(capturedError!);
add(
Error(
LoadingError(
message: errorToUserMessage(capturedError),
technicalDetails: errorToTechnicalDetails(capturedError),
allowRetry: errorAllowsRetry(capturedError),
),
),
);
}
} }
} }
@@ -1,5 +1,3 @@
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart'; import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.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 '../repository/ticker_repository.dart'; import '../repository/ticker_repository.dart';
@@ -10,9 +8,7 @@ class TickerBloc
extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> { extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> {
@override @override
Future<void> gatherData() async { Future<void> gatherData() async {
final results = await Future.wait([repo.getTicker(), repo.getNav()]); final (ticker, nav) = await (repo.getTicker(), repo.getNav()).wait;
final ticker = results[0] as TickerResponse;
final nav = results[1] as TickerNavResponse;
add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav))); add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav)));
} }
+8 -16
View File
@@ -62,12 +62,14 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
); );
} }
void _showUploadError(String message) { void _resetProgress() {
setState(() {
_isUploading = false; _isUploading = false;
_overallProgressValue = 0.0; _overallProgressValue = 0.0;
_infoText = ''; _infoText = '';
}); }
void _showUploadError(String message) {
setState(_resetProgress);
InfoDialog.show( InfoDialog.show(
context, context,
message, message,
@@ -157,9 +159,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
if (replaceFiles != true) { if (replaceFiles != true) {
setState(() { setState(() {
_isUploading = false; _resetProgress();
_overallProgressValue = 0.0;
_infoText = '';
for (var element in conflictingFiles) { for (var element in conflictingFiles) {
element.isConflicting = true; element.isConflicting = true;
} }
@@ -222,11 +222,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
} }
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) { if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
setState(() { setState(_resetProgress);
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(); Navigator.of(context).pop();
showHttpErrorCode(uploadTask.statusCode); showHttpErrorCode(uploadTask.statusCode);
@@ -235,11 +231,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
} }
} }
setState(() { setState(_resetProgress);
_isUploading = false;
_overallProgressValue = 0.0;
_infoText = '';
});
if (!mounted) return; if (!mounted) return;
Navigator.of(context).pop(); Navigator.of(context).pop();
widget.onUploadFinished(uploadetFilePaths); widget.onUploadFinished(uploadetFilePaths);
@@ -24,19 +24,12 @@ class GradeAveragesView extends StatelessWidget {
Visibility( Visibility(
visible: bloc.state.grades.isNotEmpty, visible: bloc.state.grades.isNotEmpty,
child: IconButton( child: IconButton(
onPressed: () { onPressed: () => ConfirmDialog(
showDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Zurücksetzen?', title: 'Zurücksetzen?',
content: 'Alle Einträge werden entfernt.', content: 'Alle Einträge werden entfernt.',
confirmButton: 'Zurücksetzen', confirmButton: 'Zurücksetzen',
onConfirm: () { onConfirm: () => bloc.add(ResetAll()),
bloc.add(ResetAll()); ).asDialog(context),
},
),
);
},
icon: const Icon(Icons.delete_forever), icon: const Icon(Icons.delete_forever),
), ),
), ),
@@ -64,17 +57,14 @@ class GradeAveragesView extends StatelessWidget {
.toList(), .toList(),
onSelected: (isMiddleSchool) { onSelected: (isMiddleSchool) {
if (bloc.state.grades.isNotEmpty) { if (bloc.state.grades.isNotEmpty) {
showDialog( ConfirmDialog(
context: context,
builder: (context) => ConfirmDialog(
title: 'Notensystem wechseln', title: 'Notensystem wechseln',
content: content:
'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.', 'Beim Wechsel des Notensystems werden alle Einträge zurückgesetzt.',
confirmButton: 'Fortfahren', confirmButton: 'Fortfahren',
onConfirm: () => onConfirm: () =>
bloc.add(GradingSystemChanged(isMiddleSchool)), bloc.add(GradingSystemChanged(isMiddleSchool)),
), ).asDialog(context);
);
} else { } else {
bloc.add(GradingSystemChanged(isMiddleSchool)); bloc.add(GradingSystemChanged(isMiddleSchool));
} }
@@ -27,7 +27,7 @@ class MarianumDatesView extends StatelessWidget {
return keys.map((key) { return keys.map((key) {
final first = byMonth[key]!.first.start; final first = byMonth[key]!.first.start;
final label = first.formatMonthYear().toUpperCase(); final label = first.formatMonthYear().toUpperCase();
return _MonthGroup(key: key, label: label, events: byMonth[key]!); return _MonthGroup(label: label, events: byMonth[key]!);
}).toList(); }).toList();
} }
@@ -117,8 +117,7 @@ class MarianumDatesView extends StatelessWidget {
} }
class _MonthGroup { class _MonthGroup {
final String key;
final String label; final String label;
final List<MarianumDate> events; final List<MarianumDate> events;
_MonthGroup({required this.key, required this.label, required this.events}); _MonthGroup({required this.label, required this.events});
} }
@@ -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/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_bloc.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import 'search_marianum_messages.dart'; import 'search_marianum_messages.dart';
class MarianumMessageListView extends StatelessWidget { class MarianumMessageListView extends StatelessWidget {
@@ -40,12 +41,9 @@ class MarianumMessageListView extends StatelessWidget {
child: (state, loading) => ListView.builder( child: (state, loading) => ListView.builder(
itemCount: state.messageList.messages.length, itemCount: state.messageList.messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
var message = state.messageList.messages.toList()[index]; var message = state.messageList.messages[index];
return ListTile( return ListTile(
leading: const Column( leading: const CenteredLeading(Icon(Icons.newspaper)),
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
title: Text(message.name, overflow: TextOverflow.ellipsis), title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Column( subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -2,7 +2,6 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.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 '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart';
import '../../../widget/app_progress_indicator.dart'; import '../../../widget/app_progress_indicator.dart';
@@ -39,20 +38,8 @@ class _MessageViewState extends State<MessageView> {
return SfPdfViewer.memory( return SfPdfViewer.memory(
snapshot.data!, snapshot.data!,
enableHyperlinkNavigation: true, enableHyperlinkNavigation: true,
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) { onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
showDialog( ConfirmDialog.openBrowser(context, e.uri),
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,
),
),
);
},
); );
}, },
), ),
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../routing/app_routes.dart'; import '../../../routing/app_routes.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart'; import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
import '../../../widget/centered_leading.dart';
import '../../../widget/placeholder_view.dart'; import '../../../widget/placeholder_view.dart';
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> { class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
@@ -45,10 +46,7 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
itemBuilder: (_, i) { itemBuilder: (_, i) {
final message = matches[i]; final message = matches[i];
return ListTile( return ListTile(
leading: const Column( leading: const CenteredLeading(Icon(Icons.newspaper)),
mainAxisAlignment: MainAxisAlignment.center,
children: [Icon(Icons.newspaper)],
),
title: Text(message.name, overflow: TextOverflow.ellipsis), title: Text(message.name, overflow: TextOverflow.ellipsis),
subtitle: Text('vom ${message.date}'), subtitle: Text('vom ${message.date}'),
trailing: const Icon(Icons.arrow_right), trailing: const Icon(Icons.arrow_right),
@@ -1,7 +1,6 @@
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';
import 'package:jiffy/jiffy.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart'; import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
@@ -71,7 +70,7 @@ class AboutSection extends StatelessWidget {
applicationLegalese: applicationLegalese:
'Alles für deinen Schulalltag am Marianum Fulda.\n\n' 'Alles für deinen Schulalltag am Marianum Fulda.\n\n'
"${kReleaseMode ? "Production" : "Development ${kProfileMode ? "(Profiling)" : "(Debug)"}"} build.\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',
); );
} }
@@ -166,13 +166,24 @@ Future<void> _afterExternalFilesUploaded(
GetRoomResponseObject room, GetRoomResponseObject room,
List<String> uploadedRemotePaths, List<String> uploadedRemotePaths,
PendingShare share, PendingShare share,
) async { ) => _runShareFlow(
context,
action: () =>
shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths),
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
);
/// Shared share-flow scaffolding: shows the blocking spinner, runs [action],
/// maps failures to an error dialog (popping the spinner first), and invokes
/// [onSuccess] on success while still mounted.
Future<void> _runShareFlow(
BuildContext context, {
required Future<void> Function() action,
required VoidCallback onSuccess,
}) async {
unawaited(_showBlockingSpinner(context)); unawaited(_showBlockingSpinner(context));
try { try {
await shareFilesToChat( await action();
token: room.token,
remoteFilePaths: uploadedRemotePaths,
);
} catch (e) { } catch (e) {
if (context.mounted) Navigator.of(context).pop(); if (context.mounted) Navigator.of(context).pop();
if (context.mounted) { if (context.mounted) {
@@ -186,7 +197,7 @@ Future<void> _afterExternalFilesUploaded(
return; return;
} }
if (!context.mounted) return; if (!context.mounted) return;
_setExternalDraftAndOpenChat(context, room, share); onSuccess();
} }
void _setExternalDraftAndOpenChat( void _setExternalDraftAndOpenChat(
@@ -213,61 +224,30 @@ Future<void> _internalShareFlow(
BuildContext context, BuildContext context,
GetRoomResponseObject room, GetRoomResponseObject room,
RemoteFileRef file, RemoteFileRef file,
) async { ) => _runShareFlow(
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, context,
errorToUserMessage(e), action: () =>
title: 'Fehler', shareFilesToChat(token: room.token, remoteFilePaths: [file.path]),
copyable: true, onSuccess: () => _finishWithChat(context, room),
); );
}
return;
}
if (!context.mounted) return;
_finishWithChat(context, room);
}
Future<void> _forwardMessageFlow( Future<void> _forwardMessageFlow(
BuildContext context, BuildContext context,
GetRoomResponseObject room, GetRoomResponseObject room,
String? text, String? text,
RemoteFileRef? file, RemoteFileRef? file,
) async { ) => _runShareFlow(
unawaited(_showBlockingSpinner(context)); context,
try { action: () async {
if (file != null) { if (file != null) {
await shareFilesToChat( await shareFilesToChat(token: room.token, remoteFilePaths: [file.path]);
token: room.token,
remoteFilePaths: [file.path],
);
} }
if (text != null && text.isNotEmpty) { if (text != null && text.isNotEmpty) {
await SendMessage(room.token, SendMessageParams(text)).run(); await SendMessage(room.token, SendMessageParams(text)).run();
} }
} catch (e) { },
if (context.mounted) Navigator.of(context).pop(); onSuccess: () => _finishWithChat(context, room),
if (context.mounted) { );
InfoDialog.show(
context,
errorToUserMessage(e),
title: 'Fehler',
copyable: true,
);
}
return;
}
if (!context.mounted) return;
_finishWithChat(context, room);
}
/// Modal progress overlay shown during share-API roundtrips. The dialog is /// Modal progress overlay shown during share-API roundtrips. The dialog is
/// popped together with the picker by the subsequent popUntil(isFirst). /// popped together with the picker by the subsequent popUntil(isFirst).
@@ -121,26 +121,15 @@ class ShareTargetPage extends StatelessWidget {
Widget _buildFilePreview(BuildContext context) { Widget _buildFilePreview(BuildContext context) {
if (share.filePaths.length == 1) { if (share.filePaths.length == 1) {
final path = share.filePaths.first;
final name = path.split(Platform.pathSeparator).last;
return ConstrainedBox( return ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 320), constraints: const BoxConstraints(maxHeight: 320),
child: Container( // Decode at most ~1080px so 50-MP gallery photos don't balloon the
decoration: BoxDecoration( // decode buffer just to render at <320px high.
color: Theme.of(context).colorScheme.surfaceContainer, child: _filePreviewTile(
borderRadius: BorderRadius.circular(12), context,
), share.filePaths.first,
clipBehavior: Clip.antiAlias,
child: _isImagePath(path)
? Image.file(
File(path),
fit: BoxFit.contain, 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, cacheWidth: 1080,
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
)
: _fileFallbackLarge(name),
), ),
); );
} }
@@ -153,8 +142,23 @@ class ShareTargetPage extends StatelessWidget {
mainAxisSpacing: 10, mainAxisSpacing: 10,
), ),
itemCount: share.filePaths.length, itemCount: share.filePaths.length,
itemBuilder: (context, i) { // Grid tiles are ~half-screen wide; 480px decode is sharp on 3x displays
final path = share.filePaths[i]; // 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; final name = path.split(Platform.pathSeparator).last;
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -165,17 +169,12 @@ class ShareTargetPage extends StatelessWidget {
child: _isImagePath(path) child: _isImagePath(path)
? Image.file( ? Image.file(
File(path), File(path),
fit: BoxFit.cover, fit: fit,
// Grid tiles are ~half-screen wide; 480px decode is cacheWidth: cacheWidth,
// sharp on 3x displays without blowing up memory when
// many files are shared at once.
cacheWidth: 480,
errorBuilder: (_, _, _) => _fileFallbackLarge(name), errorBuilder: (_, _, _) => _fileFallbackLarge(name),
) )
: _fileFallbackLarge(name), : _fileFallbackLarge(name),
); );
},
);
} }
Widget _buildTextPreview(BuildContext context) => Card( Widget _buildTextPreview(BuildContext context) => Card(
+7 -11
View File
@@ -110,10 +110,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
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;
var maxId = 0; final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
for (final m in response.data) {
if (m.id > maxId) maxId = m.id;
}
if (maxId == 0) return; if (maxId == 0) return;
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId); _chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId)); unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
@@ -230,21 +227,20 @@ class _ChatViewState extends State<ChatView> with RouteAware {
? _searchQuery ? _searchQuery
: null; : null;
final commonRead = int.parse(
response.headers?['x-chat-last-common-read'] ?? '0',
);
final messages = <Widget>[]; final messages = <Widget>[];
final chronologicalMatchIndex = <int, int>{}; final chronologicalMatchIndex = <int, int>{};
var lastDate = DateTime.now(); var lastDate = DateTime.now();
for (final element in response.sortByTimestamp()) { for (final element in response.sortByTimestamp()) {
if (ChatSearchController.isHiddenSystemMessage(element)) continue;
final elementDate = DateTime.fromMillisecondsSinceEpoch( final elementDate = DateTime.fromMillisecondsSinceEpoch(
element.timestamp * 1000, 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)) { if (!elementDate.isSameDay(lastDate)) {
lastDate = elementDate; lastDate = elementDate;
messages.add( messages.add(
@@ -4,18 +4,6 @@ import '../../../../theming/app_theme.dart';
import '../widgets/bubble.dart'; import '../widgets/bubble.dart';
extension ColorExtensions on Color { 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) { Color withWhite(int whiteValue) {
final value = whiteValue / 255.0; final value = whiteValue / 255.0;
return Color.from(alpha: a, red: value, green: value, blue: value); return Color.from(alpha: a, red: value, green: value, blue: value);
@@ -16,8 +16,6 @@ class ChatMessage {
RichObjectString? file; RichObjectString? file;
String content = ''; String content = '';
bool get containsFile => file != null;
ChatMessage({required this.originalMessage, this.originalData}) { ChatMessage({required this.originalMessage, this.originalData}) {
if (originalData?.containsKey('file') ?? false) { if (originalData?.containsKey('file') ?? false) {
file = originalData?['file']; file = originalData?['file'];
@@ -10,6 +10,14 @@ class ChatSearchMatch {
} }
class ChatSearchController { class ChatSearchController {
/// System messages that are folded into other bubbles (reactions, poll
/// votes, deletions) and therefore never rendered nor searched as their own
/// entry.
static bool isHiddenSystemMessage(GetChatResponseObject element) =>
element.systemMessage.contains('reaction') ||
element.systemMessage.contains('poll_voted') ||
element.systemMessage.contains('message_deleted');
static List<ChatSearchMatch> findMatches( static List<ChatSearchMatch> findMatches(
GetChatResponse response, GetChatResponse response,
String query, String query,
@@ -19,9 +27,7 @@ class ChatSearchController {
final matches = <ChatSearchMatch>[]; final matches = <ChatSearchMatch>[];
for (final element in response.sortByTimestamp()) { for (final element in response.sortByTimestamp()) {
if (element.systemMessage.contains('reaction')) continue; if (isHiddenSystemMessage(element)) continue;
if (element.systemMessage.contains('poll_voted')) continue;
if (element.systemMessage.contains('message_deleted')) continue;
final haystackText = RichObjectStringProcessor.parseToString( final haystackText = RichObjectStringProcessor.parseToString(
element.message, element.message,
@@ -17,28 +17,17 @@ class AnswerReference extends StatelessWidget {
@override @override
Widget build(BuildContext context) { 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( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: referenceMessage.actorId == selfId color: accent.withValues(alpha: 0.2),
? style
.getSelfStyle(false)
.color!
.withGreen(200)
.withValues(alpha: 0.2)
: style
.getRemoteStyle(false)
.color!
.withWhite(200)
.withValues(alpha: 0.2),
borderRadius: const BorderRadius.all(Radius.circular(5)), borderRadius: const BorderRadius.all(Radius.circular(5)),
border: Border( border: Border(
left: BorderSide( left: BorderSide(color: accent, width: 5),
color: referenceMessage.actorId == selfId
? style.getSelfStyle(false).color!.withGreen(200)
: style.getRemoteStyle(false).color!.withWhite(200),
width: 5,
),
), ),
), ),
child: Padding( child: Padding(
@@ -51,9 +40,7 @@ class AnswerReference extends StatelessWidget {
maxLines: 1, maxLines: 1,
style: TextStyle( style: TextStyle(
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
color: referenceMessage.actorId == selfId color: accent,
? style.getSelfStyle(false).color!.withGreen(200)
: style.getRemoteStyle(false).color!.withWhite(200),
fontSize: 12, fontSize: 12,
), ),
), ),
@@ -8,6 +8,7 @@ import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart'
import '../../../../storage/timetable_settings.dart'; import '../../../../storage/timetable_settings.dart';
import 'arbitrary_appointment.dart'; import 'arbitrary_appointment.dart';
import 'lesson_color.dart'; import 'lesson_color.dart';
import 'lesson_merger.dart';
import 'lesson_status.dart'; import 'lesson_status.dart';
import 'lesson_type_label.dart'; import 'lesson_type_label.dart';
import 'rrule_with_exceptions.dart'; import 'rrule_with_exceptions.dart';
@@ -33,7 +34,7 @@ class TimetableAppointmentFactory {
List<Appointment> build() { List<Appointment> build() {
final source = settings.connectDoubleLessons final source = settings.connectDoubleLessons
? _mergeAdjacentLessons(lessons) ? LessonMerger.merge(lessons)
: lessons; : lessons;
return [ return [
...source.map(_lessonToAppointment), ...source.map(_lessonToAppointment),
@@ -267,68 +268,4 @@ class TimetableAppointmentFactory {
.trim(); .trim();
return cleaned.isEmpty ? null : cleaned; 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<McTimetableEntry> _mergeAdjacentLessons(
List<McTimetableEntry> 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 = <McTimetableEntry>[];
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;
}
} }
@@ -268,12 +268,10 @@ class LessonSheet {
); );
} }
static String _line(String name, {String? longname, String? extra}) { static String _line(String name, {String? longname}) {
final parts = <String>[if (name.isNotEmpty) name else '?']; final parts = <String>[if (name.isNotEmpty) name else '?'];
final ln = (longname ?? '').trim(); final ln = (longname ?? '').trim();
if (ln.isNotEmpty && ln != name) parts.add('($ln)'); if (ln.isNotEmpty && ln != name) parts.add('($ln)');
final ex = (extra ?? '').trim();
if (ex.isNotEmpty) parts.add('· $ex');
return parts.join(' '); return parts.join(' ');
} }
@@ -116,12 +116,9 @@ class _OutsideDayColumn extends StatelessWidget {
static String _subtitleFor(Appointment a) { static String _subtitleFor(Appointment a) {
if (isAllDayLike(a)) return 'Ganztägig'; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (appointments.isEmpty) return const SizedBox.shrink(); if (appointments.isEmpty) return const SizedBox.shrink();
@@ -182,9 +179,7 @@ class _OutsideChip extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
final allDay = isAllDayLike(appointment); final allDay = isAllDayLike(appointment);
final timeLabel = allDay final timeLabel = allDay ? null : appointment.startTime.formatHm();
? null
: '${appointment.startTime.hour.toString().padLeft(2, '0')}:${appointment.startTime.minute.toString().padLeft(2, '0')}';
// Past chips fade further, future/ongoing ones get a more saturated tint // Past chips fade further, future/ongoing ones get a more saturated tint
// so the strip no longer reads as one uniform grey block. // so the strip no longer reads as one uniform grey block.
@@ -289,14 +289,11 @@ class _DayColumn extends StatelessWidget {
} }
static String _overflowSubtitle(Appointment apt) { 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', ' · '); final loc = apt.location?.replaceAll('\n', ' · ');
return loc != null && loc.isNotEmpty ? '$time · $loc' : time; 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context); final theme = Theme.of(context);
@@ -3,6 +3,11 @@ part of '../async_action_button.dart';
typedef AsyncActionCallback = Future<void> Function(); typedef AsyncActionCallback = Future<void> Function();
typedef AsyncErrorBuilder = String Function(Object error); 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 /// Wraps [action] with a try/catch that pops up an [InfoDialog] on failure
/// (using [errorBuilder] or the default error mapper). Returns `true` on /// (using [errorBuilder] or the default error mapper). Returns `true` on
/// success, `false` on caught failure. /// success, `false` on caught failure.
@@ -44,10 +44,7 @@ class _AsyncDialogActionState extends State<AsyncDialogAction> {
child: Text( child: Text(
err, err,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: _asyncErrorTextStyle(context),
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
), ),
), ),
Row( Row(
@@ -76,13 +76,7 @@ class _AsyncListTileState extends State<AsyncListTile> {
if (err != null) if (err != null)
Padding( Padding(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8), padding: const EdgeInsets.only(left: 16, right: 16, bottom: 8),
child: Text( child: Text(err, style: _asyncErrorTextStyle(context)),
err,
style: TextStyle(
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
),
), ),
], ],
); );
+1 -4
View File
@@ -108,10 +108,7 @@ class _InlineErrorWrapper extends StatelessWidget {
Text( Text(
err, err,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: _asyncErrorTextStyle(context),
color: Theme.of(context).colorScheme.error,
fontSize: 13,
),
), ),
], ],
], ],
-9
View File
@@ -1,4 +1,3 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:filesize/filesize.dart'; import 'package:filesize/filesize.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -85,11 +84,3 @@ class _CacheViewState extends State<CacheView> {
), ),
); );
} }
extension FutureExtension<T> on Future<T> {
bool isCompleted() {
final completer = Completer<T>();
then(completer.complete).catchError(completer.completeError);
return completer.isCompleted;
}
}
+1 -2
View File
@@ -17,7 +17,6 @@ class FilePick {
static Future<List<String>?> documentPick() async { static Future<List<String>?> documentPick() async {
final result = await FilePicker.pickFiles(allowMultiple: true); final result = await FilePicker.pickFiles(allowMultiple: true);
final paths = result?.files.nonNulls.map((e) => e.path).toList(); return result?.files.map((e) => e.path).nonNulls.toList();
return paths?.nonNulls.toList();
} }
} }
+7 -13
View File
@@ -39,21 +39,15 @@ class WidgetSync {
_initialised = true; _initialised = true;
} }
static Future<void> writeDayData(WidgetTimetableData data) async { static Future<void> writeDayData(WidgetTimetableData data) =>
await ensureInitialized(); _writeData(dayDataKey, data);
await HomeWidget.saveWidgetData<String>(dayDataKey, jsonEncode(data.toJson()));
await HomeWidget.saveWidgetData<String>(
fetchedAtKey,
data.fetchedAt.toIso8601String(),
);
}
static Future<void> writeWeekData(WidgetTimetableData data) async { static Future<void> writeWeekData(WidgetTimetableData data) =>
_writeData(weekDataKey, data);
static Future<void> _writeData(String key, WidgetTimetableData data) async {
await ensureInitialized(); await ensureInitialized();
await HomeWidget.saveWidgetData<String>( await HomeWidget.saveWidgetData<String>(key, jsonEncode(data.toJson()));
weekDataKey,
jsonEncode(data.toJson()),
);
await HomeWidget.saveWidgetData<String>( await HomeWidget.saveWidgetData<String>(
fetchedAtKey, fetchedAtKey,
data.fetchedAt.toIso8601String(), data.fetchedAt.toIso8601String(),