simplify: dedupe boilerplate and remove dead code across all layers
This commit is contained in:
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -38,9 +37,6 @@ class AutocompleteApi {
|
||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
return AutocompleteResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
return AutocompleteResponse.fromJson(NextcloudOcs.decode(response.body));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import '../../model/account_data.dart';
|
||||
import '../../model/endpoint_data.dart';
|
||||
|
||||
@@ -6,6 +8,11 @@ import '../../model/endpoint_data.dart';
|
||||
class NextcloudOcs {
|
||||
NextcloudOcs._();
|
||||
|
||||
/// Decodes an OCS v2 JSON envelope and returns its `ocs` object (the wrapper
|
||||
/// every response nests its `meta`/`data` under).
|
||||
static Map<String, dynamic> decode(String raw) =>
|
||||
(jsonDecode(raw) as Map<String, dynamic>)['ocs'] as Map<String, dynamic>;
|
||||
|
||||
static Map<String, String> headers() => {
|
||||
'Accept': 'application/json',
|
||||
'OCS-APIRequest': 'true',
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
@@ -28,9 +27,7 @@ class SearchFiles {
|
||||
'Files search failed with ${response.statusCode}: ${response.body}',
|
||||
);
|
||||
}
|
||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||
final ocs = decoded['ocs'] as Map<String, dynamic>;
|
||||
final data = ocs['data'] as Map<String, dynamic>;
|
||||
final data = NextcloudOcs.decode(response.body)['data'] as Map<String, dynamic>;
|
||||
return SearchFilesResponse.fromJson(data);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_chat_params.dart';
|
||||
import 'get_chat_response.dart';
|
||||
@@ -15,10 +14,8 @@ class GetChat extends TalkApi<GetChatResponse> {
|
||||
: super('v1/chat/$chatToken', null, getParameters: params.toJson());
|
||||
|
||||
@override
|
||||
GetChatResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
GetChatResponse assemble(String raw) =>
|
||||
GetChatResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<Response> request(
|
||||
|
||||
@@ -1,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<String, dynamic>;
|
||||
return GetChatResponse.fromJson(decoded['ocs'] as Map<String, dynamic>)
|
||||
return GetChatResponse.fromJson(NextcloudOcs.decode(response.body))
|
||||
..headers = response.headers;
|
||||
}
|
||||
throw ServerException(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../get_poll/get_poll_state_response.dart';
|
||||
import '../talk_api.dart';
|
||||
|
||||
@@ -12,12 +11,8 @@ class ClosePoll extends TalkApi<GetPollStateResponse> {
|
||||
: super('v1/poll/$token/$pollId', null);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetPollStateResponse assemble(String raw) =>
|
||||
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'create_room_params.dart';
|
||||
import 'create_room_response.dart';
|
||||
@@ -13,10 +12,8 @@ class CreateRoom extends TalkApi<CreateRoomResponse> {
|
||||
CreateRoom(this.params) : super('v4/room', params);
|
||||
|
||||
@override
|
||||
CreateRoomResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
CreateRoomResponse assemble(String raw) =>
|
||||
CreateRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<Response>? request(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_participants_response.dart';
|
||||
|
||||
@@ -10,12 +9,8 @@ class GetParticipants extends TalkApi<GetParticipantsResponse> {
|
||||
GetParticipants(this.token) : super('v4/room/$token/participants', null);
|
||||
|
||||
@override
|
||||
GetParticipantsResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetParticipantsResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetParticipantsResponse assemble(String raw) =>
|
||||
GetParticipantsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_poll_state_response.dart';
|
||||
|
||||
@@ -12,12 +11,8 @@ class GetPollState extends TalkApi<GetPollStateResponse> {
|
||||
: super('v1/poll/$token/$pollId', null);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetPollStateResponse assemble(String raw) =>
|
||||
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:http/http.dart';
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_reactions_response.dart';
|
||||
|
||||
@@ -14,12 +13,8 @@ class GetReactions extends TalkApi<GetReactionsResponse> {
|
||||
: super('v1/reaction/$chatToken/$messageId', null);
|
||||
|
||||
@override
|
||||
GetReactionsResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetReactionsResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetReactionsResponse assemble(String raw) =>
|
||||
GetReactionsResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<Response>? request(
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'get_room_params.dart';
|
||||
import 'get_room_response.dart';
|
||||
@@ -11,10 +10,8 @@ class GetRoom extends TalkApi<GetRoomResponse> {
|
||||
GetRoom(this.params) : super('v4/room', null, getParameters: params.toJson());
|
||||
|
||||
@override
|
||||
GetRoomResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
|
||||
}
|
||||
GetRoomResponse assemble(String raw) =>
|
||||
GetRoomResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response> request(
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:convert';
|
||||
import 'package:http/http.dart' as http;
|
||||
|
||||
import '../../../api_params.dart';
|
||||
import '../../nextcloud_ocs.dart';
|
||||
import '../get_poll/get_poll_state_response.dart';
|
||||
import '../talk_api.dart';
|
||||
import 'vote_poll_params.dart';
|
||||
@@ -22,12 +23,8 @@ class VotePoll extends TalkApi<GetPollStateResponse> {
|
||||
);
|
||||
|
||||
@override
|
||||
GetPollStateResponse assemble(String raw) {
|
||||
final decoded = jsonDecode(raw) as Map<String, dynamic>;
|
||||
return GetPollStateResponse.fromJson(
|
||||
decoded['ocs'] as Map<String, dynamic>,
|
||||
);
|
||||
}
|
||||
GetPollStateResponse assemble(String raw) =>
|
||||
GetPollStateResponse.fromJson(NextcloudOcs.decode(raw));
|
||||
|
||||
@override
|
||||
Future<http.Response>? request(
|
||||
|
||||
@@ -10,11 +10,25 @@ import 'auth/auth_interceptor.dart';
|
||||
class MarianumConnectApi {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 20);
|
||||
static const Duration _plainReceiveTimeout = Duration(seconds: 15);
|
||||
|
||||
static final Dio _instance = _build();
|
||||
|
||||
static Dio dio() => _instance;
|
||||
|
||||
/// A fresh dio with the standard JSON options but no interceptors — used by
|
||||
/// the auth queries (login/verify) that must bypass the bearer/demo
|
||||
/// interceptors to avoid a re-auth loop.
|
||||
static Dio plainDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
sendTimeout: _connectTimeout,
|
||||
receiveTimeout: _plainReceiveTimeout,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json',
|
||||
),
|
||||
);
|
||||
|
||||
static Dio _build() {
|
||||
final dio = Dio(
|
||||
BaseOptions(
|
||||
|
||||
@@ -26,4 +26,36 @@ abstract class MarianumConnectQuery {
|
||||
throw mapMarianumConnectError(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// GETs [path] and parses the JSON object body with [fromJson].
|
||||
Future<T> getObject<T>(
|
||||
String path,
|
||||
T Function(Map<String, dynamic> json) fromJson, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint(path),
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
return fromJson(response.data!);
|
||||
});
|
||||
|
||||
/// GETs [path] and maps each element of the JSON array body with [fromJson].
|
||||
Future<List<T>> getList<T>(
|
||||
String path,
|
||||
T Function(Map<String, dynamic> json) fromJson, {
|
||||
Map<String, dynamic>? queryParameters,
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint(path),
|
||||
queryParameters: queryParameters,
|
||||
);
|
||||
return response.data!
|
||||
.map((e) => fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
|
||||
/// Formats [d] as an ISO `yyyy-MM-dd` day for MarianumConnect query params.
|
||||
String isoDate(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
import 'auth_login_response.dart';
|
||||
|
||||
@@ -9,9 +10,6 @@ import 'auth_login_response.dart';
|
||||
/// run through the shared dio instance — that one has the interceptor, which
|
||||
/// would attempt to re-auth us into a loop if our credentials are wrong.
|
||||
class AuthLogin extends MarianumConnectQuery {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
|
||||
AuthLogin({
|
||||
@@ -19,17 +17,7 @@ class AuthLogin extends MarianumConnectQuery {
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
super(dio: dio ?? _buildDio());
|
||||
|
||||
static Dio _buildDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
receiveTimeout: _receiveTimeout,
|
||||
sendTimeout: _connectTimeout,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json',
|
||||
),
|
||||
);
|
||||
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||
|
||||
Future<AuthLoginResponse> run({
|
||||
required String username,
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
|
||||
|
||||
import '../../../errors/auth_exception.dart';
|
||||
import '../../auth/token_storage.dart';
|
||||
import '../../marianumconnect_api.dart';
|
||||
import '../../marianumconnect_query.dart';
|
||||
|
||||
/// Probes that the stored bearer token still maps to the given credentials.
|
||||
@@ -12,9 +13,6 @@ import '../../marianumconnect_query.dart';
|
||||
/// Bypasses the shared dio singleton so the auth interceptor doesn't kick in
|
||||
/// and obscure a real 401 with a silent re-login.
|
||||
class AuthVerify extends MarianumConnectQuery {
|
||||
static const Duration _connectTimeout = Duration(seconds: 10);
|
||||
static const Duration _receiveTimeout = Duration(seconds: 15);
|
||||
|
||||
final MarianumConnectTokenStorage _tokenStorage;
|
||||
|
||||
AuthVerify({
|
||||
@@ -22,17 +20,7 @@ class AuthVerify extends MarianumConnectQuery {
|
||||
const MarianumConnectTokenStorage(),
|
||||
Dio? dio,
|
||||
}) : _tokenStorage = tokenStorage,
|
||||
super(dio: dio ?? _buildDio());
|
||||
|
||||
static Dio _buildDio() => Dio(
|
||||
BaseOptions(
|
||||
connectTimeout: _connectTimeout,
|
||||
sendTimeout: _connectTimeout,
|
||||
receiveTimeout: _receiveTimeout,
|
||||
responseType: ResponseType.json,
|
||||
contentType: 'application/json',
|
||||
),
|
||||
);
|
||||
super(dio: dio ?? MarianumConnectApi.plainDio());
|
||||
|
||||
/// Throws [AuthException] on 401 (credentials no longer match the token's
|
||||
/// user, token missing, or token rejected), other [AppException]s on
|
||||
|
||||
@@ -7,8 +7,6 @@ import 'get_breakers_response.dart';
|
||||
class GetBreakers extends MarianumConnectQuery {
|
||||
GetBreakers({super.dio});
|
||||
|
||||
Future<GetBreakersResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
|
||||
return GetBreakersResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<GetBreakersResponse> run() =>
|
||||
getObject('breaker', GetBreakersResponse.fromJson);
|
||||
}
|
||||
|
||||
@@ -7,10 +7,6 @@ import 'get_capabilities_response.dart';
|
||||
class GetCapabilities extends MarianumConnectQuery {
|
||||
GetCapabilities({super.dio});
|
||||
|
||||
Future<CapabilitiesResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('me/capabilities'),
|
||||
);
|
||||
return CapabilitiesResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<CapabilitiesResponse> run() =>
|
||||
getObject('me/capabilities', CapabilitiesResponse.fromJson);
|
||||
}
|
||||
|
||||
@@ -4,10 +4,5 @@ import '../../models/mc_holiday.dart';
|
||||
class GetHolidays extends MarianumConnectQuery {
|
||||
GetHolidays({super.dio});
|
||||
|
||||
Future<List<McHoliday>> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(endpoint('holidays'));
|
||||
return response.data!
|
||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
});
|
||||
Future<List<McHoliday>> run() => getList('holidays', McHoliday.fromJson);
|
||||
}
|
||||
|
||||
@@ -6,8 +6,6 @@ import 'get_ticker_response.dart';
|
||||
class GetTicker extends MarianumConnectQuery {
|
||||
GetTicker({super.dio});
|
||||
|
||||
Future<TickerResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker'));
|
||||
return TickerResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<TickerResponse> run() =>
|
||||
getObject('ticker', TickerResponse.fromJson);
|
||||
}
|
||||
|
||||
@@ -6,10 +6,6 @@ import 'get_ticker_nav_response.dart';
|
||||
class GetTickerNav extends MarianumConnectQuery {
|
||||
GetTickerNav({super.dio});
|
||||
|
||||
Future<TickerNavResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('ticker/pages'),
|
||||
);
|
||||
return TickerNavResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<TickerNavResponse> run() =>
|
||||
getObject('ticker/pages', TickerNavResponse.fromJson);
|
||||
}
|
||||
|
||||
+4
-6
@@ -4,10 +4,8 @@ import '../../marianumconnect_query.dart';
|
||||
class TimetableCustomEventsGet extends MarianumConnectQuery {
|
||||
TimetableCustomEventsGet({super.dio});
|
||||
|
||||
Future<GetCustomTimetableEventResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('timetable/custom-events'),
|
||||
);
|
||||
return GetCustomTimetableEventResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<GetCustomTimetableEventResponse> run() => getObject(
|
||||
'timetable/custom-events',
|
||||
GetCustomTimetableEventResponse.fromJson,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import 'timetable_get_classes_response.dart';
|
||||
class TimetableGetClasses extends MarianumConnectQuery {
|
||||
TimetableGetClasses({super.dio});
|
||||
|
||||
Future<TimetableGetClassesResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/elements/classes'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetClassesResponse(result: list);
|
||||
});
|
||||
Future<TimetableGetClassesResponse> run() async =>
|
||||
TimetableGetClassesResponse(
|
||||
result: await getList(
|
||||
'timetable/elements/classes',
|
||||
McTimetableClass.fromJson,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+5
-10
@@ -13,14 +13,9 @@ class TimetableGetElementWeek extends MarianumConnectQuery {
|
||||
required int id,
|
||||
required DateTime from,
|
||||
required DateTime until,
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('timetable/${type.pathSegment}/$id'),
|
||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||
);
|
||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||
});
|
||||
|
||||
String _format(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
}) => getObject(
|
||||
'timetable/${type.pathSegment}/$id',
|
||||
TimetableGetWeekResponse.fromJson,
|
||||
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,8 @@ import 'timetable_get_holidays_response.dart';
|
||||
class TimetableGetHolidays extends MarianumConnectQuery {
|
||||
TimetableGetHolidays({super.dio});
|
||||
|
||||
Future<TimetableGetHolidaysResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/holidays'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetHolidaysResponse(result: list);
|
||||
});
|
||||
Future<TimetableGetHolidaysResponse> run() async =>
|
||||
TimetableGetHolidaysResponse(
|
||||
result: await getList('timetable/holidays', McHoliday.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,11 +4,7 @@ import 'timetable_get_rooms_response.dart';
|
||||
class TimetableGetRooms extends MarianumConnectQuery {
|
||||
TimetableGetRooms({super.dio});
|
||||
|
||||
Future<TimetableGetRoomsResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(endpoint('timetable/rooms'));
|
||||
final list = response.data!
|
||||
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetRoomsResponse(result: list);
|
||||
});
|
||||
Future<TimetableGetRoomsResponse> run() async => TimetableGetRoomsResponse(
|
||||
result: await getList('timetable/rooms', McRoom.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
+2
-6
@@ -4,10 +4,6 @@ import 'timetable_get_schoolyear_response.dart';
|
||||
class TimetableGetSchoolyear extends MarianumConnectQuery {
|
||||
TimetableGetSchoolyear({super.dio});
|
||||
|
||||
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('timetable/schoolyear'),
|
||||
);
|
||||
return TimetableGetSchoolyearResponse.fromJson(response.data!);
|
||||
});
|
||||
Future<TimetableGetSchoolyearResponse> run() =>
|
||||
getObject('timetable/schoolyear', TimetableGetSchoolyearResponse.fromJson);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import 'timetable_get_students_response.dart';
|
||||
class TimetableGetStudents extends MarianumConnectQuery {
|
||||
TimetableGetStudents({super.dio});
|
||||
|
||||
Future<TimetableGetStudentsResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/elements/students'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetStudentsResponse(result: list);
|
||||
});
|
||||
Future<TimetableGetStudentsResponse> run() async =>
|
||||
TimetableGetStudentsResponse(
|
||||
result: await getList(
|
||||
'timetable/elements/students',
|
||||
McTimetableStudent.fromJson,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,8 @@ import 'timetable_get_subjects_response.dart';
|
||||
class TimetableGetSubjects extends MarianumConnectQuery {
|
||||
TimetableGetSubjects({super.dio});
|
||||
|
||||
Future<TimetableGetSubjectsResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/subjects'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetSubjectsResponse(result: list);
|
||||
});
|
||||
Future<TimetableGetSubjectsResponse> run() async =>
|
||||
TimetableGetSubjectsResponse(
|
||||
result: await getList('timetable/subjects', McSubject.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ import 'timetable_get_teachers_response.dart';
|
||||
class TimetableGetTeachers extends MarianumConnectQuery {
|
||||
TimetableGetTeachers({super.dio});
|
||||
|
||||
Future<TimetableGetTeachersResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/elements/teachers'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McTimetableTeacherElement.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetTeachersResponse(result: list);
|
||||
});
|
||||
Future<TimetableGetTeachersResponse> run() async =>
|
||||
TimetableGetTeachersResponse(
|
||||
result: await getList(
|
||||
'timetable/elements/teachers',
|
||||
McTimetableTeacherElement.fromJson,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,13 +4,8 @@ import 'timetable_get_timegrid_response.dart';
|
||||
class TimetableGetTimegrid extends MarianumConnectQuery {
|
||||
TimetableGetTimegrid({super.dio});
|
||||
|
||||
Future<TimetableGetTimegridResponse> run() => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('timetable/timegrid'),
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return TimetableGetTimegridResponse(result: list);
|
||||
});
|
||||
Future<TimetableGetTimegridResponse> run() async =>
|
||||
TimetableGetTimegridResponse(
|
||||
result: await getList('timetable/timegrid', McTimegridUnit.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,9 @@ class TimetableGetWeek extends MarianumConnectQuery {
|
||||
Future<TimetableGetWeekResponse> run({
|
||||
required DateTime from,
|
||||
required DateTime until,
|
||||
}) => guard(() async {
|
||||
final response = await dio.get<Map<String, dynamic>>(
|
||||
endpoint('timetable/me'),
|
||||
queryParameters: {'from': _format(from), 'until': _format(until)},
|
||||
);
|
||||
return TimetableGetWeekResponse.fromJson(response.data!);
|
||||
});
|
||||
|
||||
String _format(DateTime d) =>
|
||||
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
}) => getObject(
|
||||
'timetable/me',
|
||||
TimetableGetWeekResponse.fromJson,
|
||||
queryParameters: {'from': isoDate(from), 'until': isoDate(until)},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,14 +7,11 @@ import 'user_search_response.dart';
|
||||
class UserSearch extends MarianumConnectQuery {
|
||||
UserSearch({super.dio});
|
||||
|
||||
Future<UserSearchResponse> run(String query) => guard(() async {
|
||||
final response = await dio.get<List<dynamic>>(
|
||||
endpoint('users/search'),
|
||||
Future<UserSearchResponse> run(String query) async => UserSearchResponse(
|
||||
result: await getList(
|
||||
'users/search',
|
||||
McUserSearchResult.fromJson,
|
||||
queryParameters: {'q': query},
|
||||
);
|
||||
final list = response.data!
|
||||
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
|
||||
.toList();
|
||||
return UserSearchResponse(result: list);
|
||||
});
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+12
-14
@@ -196,15 +196,10 @@ class AccountData {
|
||||
/// Prefer this over embedding credentials in URLs — error logs and crash
|
||||
/// reports often capture the URL but not headers.
|
||||
String getBasicAuthHeader() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
'AccountData (e.g. username or password) is not initialized!',
|
||||
);
|
||||
}
|
||||
_requirePopulated();
|
||||
// Prefer the scoped app password once available; it survives real-password
|
||||
// rotation and is what the push-v2 registration is bound to.
|
||||
final secret = _appPassword ?? _password;
|
||||
return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
||||
return _basicAuth(_appPassword ?? _password!);
|
||||
}
|
||||
|
||||
/// Basic-auth header using the Talk app password — authenticates the
|
||||
@@ -212,29 +207,32 @@ class AccountData {
|
||||
/// talk password has not been minted yet; callers treat that as a failed
|
||||
/// talk registration and retry on the next start.
|
||||
String getTalkBasicAuthHeader() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
'AccountData (e.g. username or password) is not initialized!',
|
||||
);
|
||||
}
|
||||
_requirePopulated();
|
||||
if (!hasAppPasswordTalk()) {
|
||||
throw StateError('Talk app password not available yet');
|
||||
}
|
||||
return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}';
|
||||
return _basicAuth(_appPasswordTalk!);
|
||||
}
|
||||
|
||||
/// Basic-auth header that always uses the real password. Needed exactly once,
|
||||
/// to mint the app password via `core/getapppassword` (an app password cannot
|
||||
/// mint another).
|
||||
String getRealPasswordBasicAuthHeader() {
|
||||
_requirePopulated();
|
||||
return _basicAuth(_password!);
|
||||
}
|
||||
|
||||
void _requirePopulated() {
|
||||
if (!isPopulated()) {
|
||||
throw Exception(
|
||||
'AccountData (e.g. username or password) is not initialized!',
|
||||
);
|
||||
}
|
||||
return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}';
|
||||
}
|
||||
|
||||
String _basicAuth(String secret) =>
|
||||
'Basic ${base64Encode(utf8.encode('$_username:$secret'))}';
|
||||
|
||||
/// Convenience wrapper around [getBasicAuthHeader] returning a single-entry
|
||||
/// header map ready to merge into HTTP request headers.
|
||||
Map<String, String> authHeaders() => {'Authorization': getBasicAuthHeader()};
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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 ?? '');
|
||||
}
|
||||
|
||||
@@ -440,7 +440,7 @@ class AppRoutes {
|
||||
static bool goToTab(BuildContext context, Modules module) {
|
||||
final index = AppModule.getBottomBarModules(
|
||||
context,
|
||||
).map((e) => e.module).toList().indexOf(module);
|
||||
).indexWhere((e) => e.module == module);
|
||||
if (index == -1) return false;
|
||||
Main.bottomNavigator.jumpToTab(index);
|
||||
return true;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
class PendingShare {
|
||||
final List<String> filePaths;
|
||||
final String? text;
|
||||
@@ -17,12 +19,6 @@ class PendingShare {
|
||||
/// fires two `open(url)` requests per share (see ShareViewController), so
|
||||
/// the same share can arrive twice on the media stream — receivedAt is
|
||||
/// deliberately ignored here so such duplicates compare equal.
|
||||
bool contentEquals(PendingShare other) {
|
||||
if (text != other.text) return false;
|
||||
if (filePaths.length != other.filePaths.length) return false;
|
||||
for (var i = 0; i < filePaths.length; i++) {
|
||||
if (filePaths[i] != other.filePaths[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
bool contentEquals(PendingShare other) =>
|
||||
text == other.text && listEquals(filePaths, other.filePaths);
|
||||
}
|
||||
|
||||
+15
-10
@@ -123,23 +123,28 @@ abstract class LoadableHydratedBloc<
|
||||
fetch();
|
||||
}
|
||||
|
||||
/// Maps [e] through the shared error mapper and emits it as an [Error] event.
|
||||
/// Does not guard [isClosed] — callers decide whether a late error still
|
||||
/// applies.
|
||||
void addLoadingError(Object e) => add(
|
||||
Error(
|
||||
LoadingError(
|
||||
message: errorToUserMessage(e),
|
||||
technicalDetails: errorToTechnicalDetails(e),
|
||||
allowRetry: errorAllowsRetry(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
void fetch() {
|
||||
log('Fetching data for ${TState.toString()}');
|
||||
gatherData()
|
||||
.catchError((e) {
|
||||
.catchError((Object e) {
|
||||
log('Error while fetching ${TState.toString()}: ${e.toString()}');
|
||||
// The bloc may have been closed before this async error landed;
|
||||
// adding to a closed bloc throws, so swallow that case.
|
||||
if (isClosed) return;
|
||||
add(
|
||||
Error(
|
||||
LoadingError(
|
||||
message: errorToUserMessage(e),
|
||||
technicalDetails: errorToTechnicalDetails(e),
|
||||
allowRetry: errorAllowsRetry(e),
|
||||
),
|
||||
),
|
||||
);
|
||||
addLoadingError(e);
|
||||
})
|
||||
.then((value) {
|
||||
log('Fetch for ${TState.toString()} completed!');
|
||||
|
||||
@@ -4,13 +4,11 @@ import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/long_poll_chat.dart';
|
||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
|
||||
import '../../../../../api/marianumcloud/talk/set_read_marker/set_read_marker_params.dart';
|
||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||
import '../../chat_list/bloc/chat_list_bloc.dart';
|
||||
@@ -181,17 +179,7 @@ class ChatBloc
|
||||
|
||||
if (!stillCurrent()) return;
|
||||
|
||||
if (capturedError != null) {
|
||||
add(
|
||||
Error(
|
||||
LoadingError(
|
||||
message: errorToUserMessage(capturedError),
|
||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
||||
allowRetry: errorAllowsRetry(capturedError),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (capturedError != null) addLoadingError(capturedError!);
|
||||
}
|
||||
|
||||
void _startLongPoll(String token) {
|
||||
|
||||
@@ -3,10 +3,8 @@ import 'dart:developer';
|
||||
|
||||
import 'package:flutter_app_badge/flutter_app_badge.dart';
|
||||
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../../api/marianumcloud/talk/chat/get_chat_response.dart';
|
||||
import '../../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||
import '../repository/chat_list_repository.dart';
|
||||
@@ -87,17 +85,7 @@ class ChatListBloc
|
||||
} catch (e) {
|
||||
capturedError = e;
|
||||
}
|
||||
if (capturedError != null) {
|
||||
add(
|
||||
Error(
|
||||
LoadingError(
|
||||
message: errorToUserMessage(capturedError),
|
||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
||||
allowRetry: errorAllowsRetry(capturedError),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (capturedError != null) addLoadingError(capturedError!);
|
||||
}
|
||||
|
||||
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
|
||||
|
||||
@@ -2,9 +2,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
|
||||
import '../../../../../api/errors/error_mapper.dart';
|
||||
import '../../../../../api/marianumcloud/webdav/queries/list_files/list_files_response.dart';
|
||||
import '../../../infrastructure/loadable_state/loading_error.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||
import '../repository/files_repository.dart';
|
||||
@@ -112,16 +110,6 @@ class FilesBloc
|
||||
);
|
||||
add(DataGathered((s) => s.copyWith(listing: listing)));
|
||||
}
|
||||
if (capturedError != null) {
|
||||
add(
|
||||
Error(
|
||||
LoadingError(
|
||||
message: errorToUserMessage(capturedError),
|
||||
technicalDetails: errorToTechnicalDetails(capturedError),
|
||||
allowRetry: errorAllowsRetry(capturedError),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (capturedError != null) addLoadingError(capturedError!);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import '../../../../../api/marianumconnect/queries/get_ticker/get_ticker_response.dart';
|
||||
import '../../../../../api/marianumconnect/queries/get_ticker_nav/get_ticker_nav_response.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc.dart';
|
||||
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
|
||||
import '../repository/ticker_repository.dart';
|
||||
@@ -10,9 +8,7 @@ class TickerBloc
|
||||
extends LoadableHydratedBloc<TickerEvent, TickerState, TickerRepository> {
|
||||
@override
|
||||
Future<void> gatherData() async {
|
||||
final results = await Future.wait([repo.getTicker(), repo.getNav()]);
|
||||
final ticker = results[0] as TickerResponse;
|
||||
final nav = results[1] as TickerNavResponse;
|
||||
final (ticker, nav) = await (repo.getTicker(), repo.getNav()).wait;
|
||||
add(DataGathered((state) => state.copyWith(ticker: ticker, nav: nav)));
|
||||
}
|
||||
|
||||
|
||||
@@ -62,12 +62,14 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
);
|
||||
}
|
||||
|
||||
void _resetProgress() {
|
||||
_isUploading = false;
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
}
|
||||
|
||||
void _showUploadError(String message) {
|
||||
setState(() {
|
||||
_isUploading = false;
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
});
|
||||
setState(_resetProgress);
|
||||
InfoDialog.show(
|
||||
context,
|
||||
message,
|
||||
@@ -157,9 +159,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
|
||||
if (replaceFiles != true) {
|
||||
setState(() {
|
||||
_isUploading = false;
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
_resetProgress();
|
||||
for (var element in conflictingFiles) {
|
||||
element.isConflicting = true;
|
||||
}
|
||||
@@ -222,11 +222,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
|
||||
if (uploadTask.statusCode < 200 || uploadTask.statusCode > 299) {
|
||||
setState(() {
|
||||
_isUploading = false;
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
});
|
||||
setState(_resetProgress);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showHttpErrorCode(uploadTask.statusCode);
|
||||
@@ -235,11 +231,7 @@ class _FilesUploadDialogState extends State<FilesUploadDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isUploading = false;
|
||||
_overallProgressValue = 0.0;
|
||||
_infoText = '';
|
||||
});
|
||||
setState(_resetProgress);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
widget.onUploadFinished(uploadetFilePaths);
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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<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/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,
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_pdfviewer/pdfviewer.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../../api/marianumconnect/queries/get_newsletter_file/get_newsletter_file.dart';
|
||||
import '../../../widget/app_progress_indicator.dart';
|
||||
@@ -39,20 +38,8 @@ class _MessageViewState extends State<MessageView> {
|
||||
return SfPdfViewer.memory(
|
||||
snapshot.data!,
|
||||
enableHyperlinkNavigation: true,
|
||||
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
title: 'Link öffnen',
|
||||
content: 'Möchtest du den folgenden Link öffnen?\n${e.uri}',
|
||||
confirmButton: 'Öffnen',
|
||||
onConfirm: () => launchUrl(
|
||||
Uri.parse(e.uri),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
onHyperlinkClicked: (PdfHyperlinkClickedDetails e) =>
|
||||
ConfirmDialog.openBrowser(context, e.uri),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
|
||||
import '../../../widget/centered_leading.dart';
|
||||
import '../../../widget/placeholder_view.dart';
|
||||
|
||||
class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
|
||||
@@ -45,10 +46,7 @@ class SearchMarianumMessages extends SearchDelegate<MarianumMessage?> {
|
||||
itemBuilder: (_, i) {
|
||||
final message = matches[i];
|
||||
return ListTile(
|
||||
leading: const Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [Icon(Icons.newspaper)],
|
||||
),
|
||||
leading: const CenteredLeading(Icon(Icons.newspaper)),
|
||||
title: Text(message.name, overflow: TextOverflow.ellipsis),
|
||||
subtitle: Text('vom ${message.date}'),
|
||||
trailing: const Icon(Icons.arrow_right),
|
||||
|
||||
@@ -1,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',
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -166,13 +166,24 @@ Future<void> _afterExternalFilesUploaded(
|
||||
GetRoomResponseObject room,
|
||||
List<String> uploadedRemotePaths,
|
||||
PendingShare share,
|
||||
) async {
|
||||
) => _runShareFlow(
|
||||
context,
|
||||
action: () =>
|
||||
shareFilesToChat(token: room.token, remoteFilePaths: uploadedRemotePaths),
|
||||
onSuccess: () => _setExternalDraftAndOpenChat(context, room, share),
|
||||
);
|
||||
|
||||
/// Shared share-flow scaffolding: shows the blocking spinner, runs [action],
|
||||
/// maps failures to an error dialog (popping the spinner first), and invokes
|
||||
/// [onSuccess] on success while still mounted.
|
||||
Future<void> _runShareFlow(
|
||||
BuildContext context, {
|
||||
required Future<void> Function() action,
|
||||
required VoidCallback onSuccess,
|
||||
}) async {
|
||||
unawaited(_showBlockingSpinner(context));
|
||||
try {
|
||||
await shareFilesToChat(
|
||||
token: room.token,
|
||||
remoteFilePaths: uploadedRemotePaths,
|
||||
);
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
@@ -186,7 +197,7 @@ Future<void> _afterExternalFilesUploaded(
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
_setExternalDraftAndOpenChat(context, room, share);
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
void _setExternalDraftAndOpenChat(
|
||||
@@ -213,61 +224,30 @@ Future<void> _internalShareFlow(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
RemoteFileRef file,
|
||||
) async {
|
||||
unawaited(_showBlockingSpinner(context));
|
||||
try {
|
||||
await shareFilesToChat(
|
||||
token: room.token,
|
||||
remoteFilePaths: [file.path],
|
||||
);
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
errorToUserMessage(e),
|
||||
title: 'Fehler',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
_finishWithChat(context, room);
|
||||
}
|
||||
) => _runShareFlow(
|
||||
context,
|
||||
action: () =>
|
||||
shareFilesToChat(token: room.token, remoteFilePaths: [file.path]),
|
||||
onSuccess: () => _finishWithChat(context, room),
|
||||
);
|
||||
|
||||
Future<void> _forwardMessageFlow(
|
||||
BuildContext context,
|
||||
GetRoomResponseObject room,
|
||||
String? text,
|
||||
RemoteFileRef? file,
|
||||
) async {
|
||||
unawaited(_showBlockingSpinner(context));
|
||||
try {
|
||||
) => _runShareFlow(
|
||||
context,
|
||||
action: () async {
|
||||
if (file != null) {
|
||||
await shareFilesToChat(
|
||||
token: room.token,
|
||||
remoteFilePaths: [file.path],
|
||||
);
|
||||
await shareFilesToChat(token: room.token, remoteFilePaths: [file.path]);
|
||||
}
|
||||
if (text != null && text.isNotEmpty) {
|
||||
await SendMessage(room.token, SendMessageParams(text)).run();
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) Navigator.of(context).pop();
|
||||
if (context.mounted) {
|
||||
InfoDialog.show(
|
||||
context,
|
||||
errorToUserMessage(e),
|
||||
title: 'Fehler',
|
||||
copyable: true,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!context.mounted) return;
|
||||
_finishWithChat(context, room);
|
||||
}
|
||||
},
|
||||
onSuccess: () => _finishWithChat(context, room),
|
||||
);
|
||||
|
||||
/// Modal progress overlay shown during share-API roundtrips. The dialog is
|
||||
/// popped together with the picker by the subsequent popUntil(isFirst).
|
||||
|
||||
@@ -121,26 +121,15 @@ class ShareTargetPage extends StatelessWidget {
|
||||
|
||||
Widget _buildFilePreview(BuildContext context) {
|
||||
if (share.filePaths.length == 1) {
|
||||
final path = share.filePaths.first;
|
||||
final name = path.split(Platform.pathSeparator).last;
|
||||
return ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 320),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isImagePath(path)
|
||||
? Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.contain,
|
||||
// Decode at most ~1080px so 50-MP gallery photos don't
|
||||
// balloon the decode buffer just to render at <320px high.
|
||||
cacheWidth: 1080,
|
||||
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
|
||||
)
|
||||
: _fileFallbackLarge(name),
|
||||
// Decode at most ~1080px so 50-MP gallery photos don't balloon the
|
||||
// decode buffer just to render at <320px high.
|
||||
child: _filePreviewTile(
|
||||
context,
|
||||
share.filePaths.first,
|
||||
fit: BoxFit.contain,
|
||||
cacheWidth: 1080,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -153,28 +142,38 @@ class ShareTargetPage extends StatelessWidget {
|
||||
mainAxisSpacing: 10,
|
||||
),
|
||||
itemCount: share.filePaths.length,
|
||||
itemBuilder: (context, i) {
|
||||
final path = share.filePaths[i];
|
||||
final name = path.split(Platform.pathSeparator).last;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isImagePath(path)
|
||||
? Image.file(
|
||||
File(path),
|
||||
fit: BoxFit.cover,
|
||||
// Grid tiles are ~half-screen wide; 480px decode is
|
||||
// sharp on 3x displays without blowing up memory when
|
||||
// many files are shared at once.
|
||||
cacheWidth: 480,
|
||||
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
|
||||
)
|
||||
: _fileFallbackLarge(name),
|
||||
);
|
||||
},
|
||||
// Grid tiles are ~half-screen wide; 480px decode is sharp on 3x displays
|
||||
// without blowing up memory when many files are shared at once.
|
||||
itemBuilder: (context, i) => _filePreviewTile(
|
||||
context,
|
||||
share.filePaths[i],
|
||||
fit: BoxFit.cover,
|
||||
cacheWidth: 480,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _filePreviewTile(
|
||||
BuildContext context,
|
||||
String path, {
|
||||
required BoxFit fit,
|
||||
required int cacheWidth,
|
||||
}) {
|
||||
final name = path.split(Platform.pathSeparator).last;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: _isImagePath(path)
|
||||
? Image.file(
|
||||
File(path),
|
||||
fit: fit,
|
||||
cacheWidth: cacheWidth,
|
||||
errorBuilder: (_, _, _) => _fileFallbackLarge(name),
|
||||
)
|
||||
: _fileFallbackLarge(name),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -110,10 +110,7 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
if (state.currentToken != widget.room.token) return;
|
||||
final response = state.chatResponse;
|
||||
if (response == null) return;
|
||||
var maxId = 0;
|
||||
for (final m in response.data) {
|
||||
if (m.id > maxId) maxId = m.id;
|
||||
}
|
||||
final maxId = response.data.map((m) => m.id).fold<int>(0, math.max);
|
||||
if (maxId == 0) return;
|
||||
_chatListBlocRef?.markRoomAsRead(widget.room.token, maxId);
|
||||
unawaited(_chatBlocRef!.sendServerReadMarker(widget.room.token, maxId));
|
||||
@@ -230,21 +227,20 @@ class _ChatViewState extends State<ChatView> with RouteAware {
|
||||
? _searchQuery
|
||||
: null;
|
||||
|
||||
final commonRead = int.parse(
|
||||
response.headers?['x-chat-last-common-read'] ?? '0',
|
||||
);
|
||||
|
||||
final messages = <Widget>[];
|
||||
final chronologicalMatchIndex = <int, int>{};
|
||||
var lastDate = DateTime.now();
|
||||
for (final element in response.sortByTimestamp()) {
|
||||
if (ChatSearchController.isHiddenSystemMessage(element)) continue;
|
||||
|
||||
final elementDate = DateTime.fromMillisecondsSinceEpoch(
|
||||
element.timestamp * 1000,
|
||||
);
|
||||
|
||||
if (element.systemMessage.contains('reaction')) continue;
|
||||
if (element.systemMessage.contains('poll_voted')) continue;
|
||||
if (element.systemMessage.contains('message_deleted')) continue;
|
||||
final commonRead = int.parse(
|
||||
response.headers?['x-chat-last-common-read'] ?? '0',
|
||||
);
|
||||
|
||||
if (!elementDate.isSameDay(lastDate)) {
|
||||
lastDate = elementDate;
|
||||
messages.add(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -16,8 +16,6 @@ class ChatMessage {
|
||||
RichObjectString? file;
|
||||
String content = '';
|
||||
|
||||
bool get containsFile => file != null;
|
||||
|
||||
ChatMessage({required this.originalMessage, this.originalData}) {
|
||||
if (originalData?.containsKey('file') ?? false) {
|
||||
file = originalData?['file'];
|
||||
|
||||
@@ -10,6 +10,14 @@ class ChatSearchMatch {
|
||||
}
|
||||
|
||||
class ChatSearchController {
|
||||
/// System messages that are folded into other bubbles (reactions, poll
|
||||
/// votes, deletions) and therefore never rendered nor searched as their own
|
||||
/// entry.
|
||||
static bool isHiddenSystemMessage(GetChatResponseObject element) =>
|
||||
element.systemMessage.contains('reaction') ||
|
||||
element.systemMessage.contains('poll_voted') ||
|
||||
element.systemMessage.contains('message_deleted');
|
||||
|
||||
static List<ChatSearchMatch> findMatches(
|
||||
GetChatResponse response,
|
||||
String query,
|
||||
@@ -19,9 +27,7 @@ class ChatSearchController {
|
||||
|
||||
final matches = <ChatSearchMatch>[];
|
||||
for (final element in response.sortByTimestamp()) {
|
||||
if (element.systemMessage.contains('reaction')) continue;
|
||||
if (element.systemMessage.contains('poll_voted')) continue;
|
||||
if (element.systemMessage.contains('message_deleted')) continue;
|
||||
if (isHiddenSystemMessage(element)) continue;
|
||||
|
||||
final haystackText = RichObjectStringProcessor.parseToString(
|
||||
element.message,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<Appointment> 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<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 ln = (longname ?? '').trim();
|
||||
if (ln.isNotEmpty && ln != name) parts.add('($ln)');
|
||||
final ex = (extra ?? '').trim();
|
||||
if (ex.isNotEmpty) parts.add('· $ex');
|
||||
return parts.join(' ');
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -3,6 +3,11 @@ part of '../async_action_button.dart';
|
||||
typedef AsyncActionCallback = Future<void> 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.
|
||||
|
||||
@@ -44,10 +44,7 @@ class _AsyncDialogActionState extends State<AsyncDialogAction> {
|
||||
child: Text(
|
||||
err,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Theme.of(context).colorScheme.error,
|
||||
fontSize: 13,
|
||||
),
|
||||
style: _asyncErrorTextStyle(context),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
|
||||
@@ -76,13 +76,7 @@ class _AsyncListTileState extends State<AsyncListTile> {
|
||||
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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
],
|
||||
|
||||
@@ -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<CacheView> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
extension FutureExtension<T> on Future<T> {
|
||||
bool isCompleted() {
|
||||
final completer = Completer<T>();
|
||||
then(completer.complete).catchError(completer.completeError);
|
||||
return completer.isCompleted;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,7 +17,6 @@ class FilePick {
|
||||
|
||||
static Future<List<String>?> 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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,21 +39,15 @@ class WidgetSync {
|
||||
_initialised = true;
|
||||
}
|
||||
|
||||
static Future<void> writeDayData(WidgetTimetableData data) async {
|
||||
await ensureInitialized();
|
||||
await HomeWidget.saveWidgetData<String>(dayDataKey, jsonEncode(data.toJson()));
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
fetchedAtKey,
|
||||
data.fetchedAt.toIso8601String(),
|
||||
);
|
||||
}
|
||||
static Future<void> writeDayData(WidgetTimetableData data) =>
|
||||
_writeData(dayDataKey, data);
|
||||
|
||||
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 HomeWidget.saveWidgetData<String>(
|
||||
weekDataKey,
|
||||
jsonEncode(data.toJson()),
|
||||
);
|
||||
await HomeWidget.saveWidgetData<String>(key, jsonEncode(data.toJson()));
|
||||
await HomeWidget.saveWidgetData<String>(
|
||||
fetchedAtKey,
|
||||
data.fetchedAt.toIso8601String(),
|
||||
|
||||
Reference in New Issue
Block a user