25 Commits

Author SHA1 Message Date
MineTec 15791423ea added fileFetchedAt to TickerPageResponse and updated the Ticker UI to use it as the data currency timestamp for proxied files 2026-07-13 00:32:27 +02:00
MineTec 2f5a6b4ce0 split file_viewer into focused sub-widgets 2026-07-13 00:01:07 +02:00
MineTec 53bc6d5360 share weekKey extension and log splash precache failures 2026-07-12 23:56:47 +02:00
MineTec f50359b4eb use AppProgressIndicator for plain loading spinners 2026-07-12 23:45:55 +02:00
MineTec 9994a1f3fa extract SettingsDropdownTile for settings dropdowns 2026-07-12 23:42:56 +02:00
MineTec 4aa31a2e44 extract SettingsCheckboxTile for settings toggles 2026-07-12 23:41:12 +02:00
MineTec dfce3e7b5c add PromptDialog helper for text input dialogs 2026-07-12 23:36:46 +02:00
MineTec 564a334cdc route avatar crop and profile view through AppRoutes 2026-07-12 23:32:48 +02:00
MineTec db329c7299 adopt MarianumConnectQuery base in remaining queries 2026-07-12 23:28:51 +02:00
MineTec 0a2ff5c3fb add MarianumConnectQuery base and adopt it in list queries 2026-07-12 23:24:21 +02:00
MineTec 9b5198c6db add ticker page bloc and avatar disk cache 2026-07-12 23:18:53 +02:00
MineTec 94794ff092 remove unused dead code files 2026-07-12 23:17:47 +02:00
MineTec fe2b3c43b2 implemented custom subject colors for the timetable and unified the color palette system 2026-07-12 21:30:08 +02:00
MineTec a7111844b1 bugfixes, better background operation robustness and platform-specific error handling 2026-07-12 19:17:49 +02:00
MineTec babc347b18 implemented global user search integration for Talk chats with role-coded badges and direct chat creation logic 2026-07-12 19:05:27 +02:00
MineTec 44e45c9b78 implemented full interactive poll support in Talk, including creation, voting, and closing functionality 2026-07-12 17:54:30 +02:00
MineTec 3f44e9302f implemented dynamic quick reactions by surfacing emojis from message content in the chat options dialog 2026-07-12 16:42:30 +02:00
MineTec 59501d3b45 implemented enlarged emoji rendering for chat messages containing up to three emojis 2026-07-12 16:26:22 +02:00
MineTec 91a6216f66 migrated custom timetable events to Marianum-Connect and refactored push notification handling 2026-07-12 14:31:48 +02:00
MineTec c444ed54a5 migrated Marianum Message module to the Marianum-Connect API and implemented push notification deep linking 2026-07-11 17:37:50 +02:00
MineTec ff23199345 downgrade AGP and Gradle versions, update Ticker icon, and upgrade dependencies 2026-07-11 15:32:09 +02:00
MineTec 9545d2a946 upgraded Android Gradle Plugin to 9.2.1 and Gradle wrapper to 9.4.1 2026-07-10 19:16:07 +02:00
MineTec 37608e59b3 implemented Ticker page selection persistence and enhanced ProseMirror table display modes 2026-07-10 19:15:52 +02:00
MineTec 0d01f6b631 fixed push-registration not activated on app-login 2026-07-10 15:35:56 +02:00
MineTec b0d2e7a34b fixed inconsistent placement of appbar title on iOS 2026-07-09 12:04:20 +02:00
190 changed files with 6310 additions and 2420 deletions
+5 -3
View File
@@ -1,3 +1,5 @@
import 'package:intl/intl.dart';
import '../../../state/app/modules/marianum_message/bloc/marianum_message_state.dart';
/// Demo fixtures for the info messages — a single friendly welcome entry so the
@@ -6,16 +8,16 @@ class DemoMarianumMessage {
const DemoMarianumMessage._();
static MarianumMessageList list() {
final today = DateTime.now();
final date =
'${today.day.toString().padLeft(2, '0')}.${today.month.toString().padLeft(2, '0')}.${today.year}';
final date = DateFormat.yMMMM('de').format(DateTime.now());
return MarianumMessageList(
base: 'https://marianum-fulda.de',
messages: [
MarianumMessage(
id: 'demo',
name: 'Willkommen in der Marianum-App',
date: date,
description: 'Eine kurze Begrüßung zum Ausprobieren.',
url: 'https://marianum-fulda.de',
),
],
+50
View File
@@ -0,0 +1,50 @@
import '../../marianumconnect/queries/user_search/user_search_response.dart';
/// Demo fixtures for the Talk user search — a small mixed set of teachers and
/// students, filtered client-side so the demo search feels responsive.
class DemoUsers {
const DemoUsers._();
static final List<McUserSearchResult> _all = [
McUserSearchResult(
username: 'm.muster',
firstName: 'Maria',
lastName: 'Mustermann',
userType: 'TEACHER',
),
McUserSearchResult(
username: 'j.beispiel',
firstName: 'Jonas',
lastName: 'Beispiel',
userType: 'TEACHER',
),
McUserSearchResult(
username: 'l.schueler',
firstName: 'Lena',
lastName: 'Schüler',
userType: 'STUDENT',
className: '9c',
),
McUserSearchResult(
username: 'p.probe',
firstName: 'Paul',
lastName: 'Probe',
userType: 'STUDENT',
className: 'Q2',
),
];
static List<McUserSearchResult> search(String query) {
final q = query.trim().toLowerCase();
if (q.length < 2) return const [];
return _all
.where(
(u) =>
u.firstName.toLowerCase().contains(q) ||
u.lastName.toLowerCase().contains(q) ||
u.username.toLowerCase().contains(q) ||
'${u.firstName} ${u.lastName}'.toLowerCase().contains(q),
)
.toList();
}
}
+5
View File
@@ -1,6 +1,7 @@
import 'data/demo_breaker.dart';
import 'data/demo_holidays.dart';
import 'data/demo_timetable.dart';
import 'data/demo_users.dart';
/// Single source of truth for the MarianumConnect demo responses. The demo
/// interceptor asks this for the body of any MC request. Read endpoints reuse
@@ -32,6 +33,10 @@ class DemoMarianumConnect {
return DemoTimetable.holidays().result.map((e) => e.toJson()).toList();
case 'holidays':
return DemoHolidays.upcoming().map((e) => e.toJson()).toList();
case 'users/search':
return DemoUsers.search(query['q']?.toString() ?? '')
.map((e) => e.toJson())
.toList();
case 'breaker':
return DemoBreaker.none().toJson();
case 'timetable/elements/teachers':
@@ -0,0 +1,28 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
/// Schließt eine Umfrage endgültig — nur Ersteller oder Moderatoren.
class ClosePoll extends TalkApi<GetPollStateResponse> {
ClosePoll({required String token, required int pollId})
: 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>,
);
}
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
@@ -0,0 +1,31 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../talk_api.dart';
import 'create_poll_params.dart';
/// Erstellt eine Umfrage; der Server postet die Poll-Nachricht selbst in den
/// Chat, danach genügt ein Chat-Refresh. Nur in Gruppen-Chats erlaubt.
class CreatePoll extends TalkApi {
CreatePoll({required String token, required CreatePollParams params})
: super(
'v1/poll/$token',
params,
headers: {'Content-Type': 'application/json'},
);
@override
Null assemble(String raw) => null;
@override
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! CreatePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}
@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_params.dart';
part 'create_poll_params.g.dart';
@JsonSerializable()
class CreatePollParams extends ApiParams {
String question;
List<String> options;
/// 0 = Ergebnisse öffentlich, 1 = bis zum Schließen verborgen.
int resultMode;
/// Stimmen pro Teilnehmer; 0 = unbegrenzt.
int maxVotes;
CreatePollParams({
required this.question,
required this.options,
required this.resultMode,
required this.maxVotes,
});
factory CreatePollParams.fromJson(Map<String, dynamic> json) =>
_$CreatePollParamsFromJson(json);
Map<String, dynamic> toJson() => _$CreatePollParamsToJson(this);
}
@@ -0,0 +1,25 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_poll_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreatePollParams _$CreatePollParamsFromJson(Map<String, dynamic> json) =>
CreatePollParams(
question: json['question'] as String,
options: (json['options'] as List<dynamic>)
.map((e) => e as String)
.toList(),
resultMode: (json['resultMode'] as num).toInt(),
maxVotes: (json['maxVotes'] as num).toInt(),
);
Map<String, dynamic> _$CreatePollParamsToJson(CreatePollParams instance) =>
<String, dynamic>{
'question': instance.question,
'options': instance.options,
'resultMode': instance.resultMode,
'maxVotes': instance.maxVotes,
};
@@ -1,16 +1,22 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:http/http.dart';
import '../talk_api.dart';
import 'create_room_params.dart';
import 'create_room_response.dart';
class CreateRoom extends TalkApi {
class CreateRoom extends TalkApi<CreateRoomResponse> {
CreateRoomParams params;
CreateRoom(this.params) : super('v4/room', params);
@override
Null assemble(String raw) => null;
CreateRoomResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return CreateRoomResponse.fromJson(decoded['ocs'] as Map<String, dynamic>);
}
@override
Future<Response>? request(
@@ -0,0 +1,27 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'create_room_response.g.dart';
@JsonSerializable(explicitToJson: true)
class CreateRoomResponse extends ApiResponse {
final CreateRoomResponseData data;
CreateRoomResponse(this.data);
factory CreateRoomResponse.fromJson(Map<String, dynamic> json) =>
_$CreateRoomResponseFromJson(json);
Map<String, dynamic> toJson() => _$CreateRoomResponseToJson(this);
}
@JsonSerializable()
class CreateRoomResponseData {
final String token;
CreateRoomResponseData(this.token);
factory CreateRoomResponseData.fromJson(Map<String, dynamic> json) =>
_$CreateRoomResponseDataFromJson(json);
Map<String, dynamic> toJson() => _$CreateRoomResponseDataToJson(this);
}
@@ -0,0 +1,29 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'create_room_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CreateRoomResponse _$CreateRoomResponseFromJson(Map<String, dynamic> json) =>
CreateRoomResponse(
CreateRoomResponseData.fromJson(json['data'] as Map<String, dynamic>),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$CreateRoomResponseToJson(CreateRoomResponse instance) =>
<String, dynamic>{
'headers': ?instance.headers,
'data': instance.data.toJson(),
};
CreateRoomResponseData _$CreateRoomResponseDataFromJson(
Map<String, dynamic> json,
) => CreateRoomResponseData(json['token'] as String);
Map<String, dynamic> _$CreateRoomResponseDataToJson(
CreateRoomResponseData instance,
) => <String, dynamic>{'token': instance.token};
@@ -4,6 +4,18 @@ import '../../../api_response.dart';
part 'get_poll_state_response.g.dart';
/// Poll-`status`-Werte der Talk-API.
const int pollStatusOpen = 0;
const int pollStatusClosed = 1;
/// Poll-`resultMode`-Werte der Talk-API.
const int pollResultModePublic = 0;
const int pollResultModeHidden = 1;
/// `participantType`-Werte (aus dem Room), die eine Umfrage schließen dürfen:
/// Owner (1), Moderator (2) und Gast-Moderator (6).
const Set<int> pollModeratorParticipantTypes = {1, 2, 6};
@JsonSerializable(explicitToJson: true)
class GetPollStateResponse extends ApiResponse {
GetPollStateResponseObject data;
@@ -50,4 +62,32 @@ class GetPollStateResponseObject {
factory GetPollStateResponseObject.fromJson(Map<String, dynamic> json) =>
_$GetPollStateResponseObjectFromJson(json);
Map<String, dynamic> toJson() => _$GetPollStateResponseObjectToJson(this);
bool get isClosed => status == pollStatusClosed;
bool get resultsHidden => resultMode == pollResultModeHidden;
/// Ergebnisse sichtbar: öffentliche Umfragen jederzeit, verborgene erst nach
/// dem Schließen. Der Typ von `votes` taugt nicht als Signal (siehe unten).
bool get resultsVisible => resultMode == pollResultModePublic || isClosed;
/// Normalisiert das dynamische `votes`-Feld zu einer Map: der Server liefert
/// bei verborgenen Ergebnissen (und ohne Stimmen) eine leere Liste statt Map.
Map<String, num> get voteCounts {
final raw = votes;
if (raw is! Map) return const {};
final result = <String, num>{};
raw.forEach((key, value) {
if (key is String && value is num) result[key] = value;
});
return result;
}
/// Darf der Nutzer die (offene) Umfrage schließen: als Ersteller oder Moderator.
bool canClose({required String selfId, required int participantType}) {
if (isClosed) return false;
final isCreator = actorType == 'users' && actorId == selfId;
final isModerator = pollModeratorParticipantTypes.contains(participantType);
return isCreator || isModerator;
}
}
@@ -0,0 +1,41 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
import '../get_poll/get_poll_state_response.dart';
import '../talk_api.dart';
import 'vote_poll_params.dart';
class VotePoll extends TalkApi<GetPollStateResponse> {
// Body als echtes JSON (nicht form-encoded wie die anderen Endpunkte): nur
// so kommt das int-Array an; sonst liest der Server optionIds als [] und
// löscht die eigene Stimme (Ursache des Readonly-Fallbacks, Issue #42).
VotePoll({
required String token,
required int pollId,
required VotePollParams params,
}) : super(
'v1/poll/$token/$pollId',
params,
headers: {'Content-Type': 'application/json'},
);
@override
GetPollStateResponse assemble(String raw) {
final decoded = jsonDecode(raw) as Map<String, dynamic>;
return GetPollStateResponse.fromJson(
decoded['ocs'] as Map<String, dynamic>,
);
}
@override
Future<http.Response>? request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) {
if (body is! VotePollParams) return null;
return http.post(uri, headers: headers, body: jsonEncode(body.toJson()));
}
}
@@ -0,0 +1,16 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_params.dart';
part 'vote_poll_params.g.dart';
@JsonSerializable()
class VotePollParams extends ApiParams {
/// Indizes der gewählten Optionen; leer = eigene Stimme zurückziehen.
List<int> optionIds;
VotePollParams({required this.optionIds});
factory VotePollParams.fromJson(Map<String, dynamic> json) =>
_$VotePollParamsFromJson(json);
Map<String, dynamic> toJson() => _$VotePollParamsToJson(this);
}
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'vote_poll_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
VotePollParams _$VotePollParamsFromJson(Map<String, dynamic> json) =>
VotePollParams(
optionIds: (json['optionIds'] as List<dynamic>)
.map((e) => (e as num).toInt())
.toList(),
);
Map<String, dynamic> _$VotePollParamsToJson(VotePollParams instance) =>
<String, dynamic>{'optionIds': instance.optionIds};
@@ -1,14 +0,0 @@
import '../../../../api_response.dart';
import '../../webdav_api.dart';
import 'download_file_params.dart';
class DownloadFile extends WebdavApi<DownloadFileParams> {
DownloadFileParams params;
DownloadFile(this.params) : super(params);
@override
Future<ApiResponse> run() async {
throw UnimplementedError();
}
}
@@ -1,22 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../../api_params.dart';
part 'download_file_params.g.dart';
@JsonSerializable()
class DownloadFileParams extends ApiParams {
String webdavSourcePath;
String localTargetPath;
String filename;
DownloadFileParams(
this.webdavSourcePath,
this.localTargetPath,
this.filename,
);
factory DownloadFileParams.fromJson(Map<String, dynamic> json) =>
_$DownloadFileParamsFromJson(json);
Map<String, dynamic> toJson() => _$DownloadFileParamsToJson(this);
}
@@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'download_file_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DownloadFileParams _$DownloadFileParamsFromJson(Map<String, dynamic> json) =>
DownloadFileParams(
json['webdavSourcePath'] as String,
json['localTargetPath'] as String,
json['filename'] as String,
);
Map<String, dynamic> _$DownloadFileParamsToJson(DownloadFileParams instance) =>
<String, dynamic>{
'webdavSourcePath': instance.webdavSourcePath,
'localTargetPath': instance.localTargetPath,
'filename': instance.filename,
};
@@ -1,14 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'download_file_response.g.dart';
@JsonSerializable()
class DownloadFileResponse {
String path;
DownloadFileResponse(this.path);
factory DownloadFileResponse.fromJson(Map<String, dynamic> json) =>
_$DownloadFileResponseFromJson(json);
Map<String, dynamic> toJson() => _$DownloadFileResponseToJson(this);
}
@@ -1,15 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'download_file_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
DownloadFileResponse _$DownloadFileResponseFromJson(
Map<String, dynamic> json,
) => DownloadFileResponse(json['path'] as String);
Map<String, dynamic> _$DownloadFileResponseToJson(
DownloadFileResponse instance,
) => <String, dynamic>{'path': instance.path};
@@ -36,7 +36,15 @@ class MarianumConnectAuthInterceptor extends Interceptor {
// Token mitschicken statt ein eigenes 401 einzufangen.
final pending = _pendingReLogin;
if (pending != null) await pending;
final token = await _tokenStorage.readToken();
// Reading the keystore can throw while the device is locked (iOS
// errSecInteractionNotAllowed on background requests). Degrade to an
// unauthenticated request instead of surfacing a platform error.
String? token;
try {
token = await _tokenStorage.readToken();
} catch (_) {
token = null;
}
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
@@ -1,5 +1,13 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// `first_unlock` accessibility so the token can be read during background
/// requests (telemetry heartbeat, push-triggered syncs) after the first device
/// unlock following a reboot. The keychain default (`whenUnlocked`) throws
/// `-25308 errSecInteractionNotAllowed` when the device is locked.
const IOSOptions _mcIosOptions = IOSOptions(
accessibility: KeychainAccessibility.first_unlock,
);
/// Persists the Marianum-Connect bearer token in the platform keystore. Kept
/// separate from `AccountData` because the username/password live on (Nextcloud
/// + MHSL still need them) while the MC token is short-lived and per-endpoint.
@@ -11,7 +19,7 @@ class MarianumConnectTokenStorage {
final FlutterSecureStorage _storage;
const MarianumConnectTokenStorage([
this._storage = const FlutterSecureStorage(),
this._storage = const FlutterSecureStorage(iOptions: _mcIosOptions),
]);
Future<String?> readToken() => _storage.read(key: _tokenKey);
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import 'errors/marianumconnect_error.dart';
import 'marianumconnect_api.dart';
import 'marianumconnect_endpoint.dart';
/// Shared base for MarianumConnect API queries. Owns the [dio] client (the
/// shared authenticated singleton by default) and routes calls through [guard]
/// so every query maps a DioException to the app's typed AppExceptions the same
/// way instead of repeating the try/catch. Subclasses with bespoke error or
/// lifecycle handling (own dio, silent failure, custom status mapping) may skip
/// [guard] and still reuse [dio]/[endpoint].
abstract class MarianumConnectQuery {
final Dio dio;
MarianumConnectQuery({Dio? dio}) : dio = dio ?? MarianumConnectApi.dio();
/// Resolves [path] against the active mobile-API base URL.
String endpoint(String path) => MarianumConnectEndpoint.resolve(path);
/// Runs [body], converting any DioException into the matching AppException.
Future<T> guard<T>(Future<T> Function() body) async {
try {
return await body();
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -1,61 +1,55 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'auth_login_response.dart';
/// Performs the Marianum-Connect bearer login. Used both by the foreground
/// login flow and by the auth interceptor's silent re-auth on 401. Does *not*
/// 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 {
class AuthLogin extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthLogin({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio =
dio ??
Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? _buildDio());
static Dio _buildDio() => Dio(
BaseOptions(
connectTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
sendTimeout: _connectTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
Future<AuthLoginResponse> run({
required String username,
required String password,
required String tokenName,
}) async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('auth/login'),
data: {
'username': username,
'password': password,
'tokenName': tokenName,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint('auth/login'),
data: {
'username': username,
'password': password,
'tokenName': tokenName,
},
);
final payload = AuthLoginResponse.fromJson(response.data!);
await _tokenStorage.write(
token: payload.token,
tokenId: payload.tokenId,
expiresAt: payload.expiresAt,
);
return payload;
});
}
@@ -1,26 +1,23 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Revokes the stored MC bearer token both server-side and locally. Best-effort
/// — a network error still clears the local token so the user isn't stuck with
/// an unusable session.
class AuthLogout {
class AuthLogout extends MarianumConnectQuery {
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthLogout({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio = dio ?? MarianumConnectApi.dio();
super.dio,
}) : _tokenStorage = tokenStorage;
Future<void> run() async {
try {
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
await dio.post<void>(endpoint('auth/logout'));
} on DioException catch (_) {
// ignore — local clear below still happens
} finally {
@@ -2,8 +2,7 @@ import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Probes that the stored bearer token still maps to the given credentials.
/// Server returns 200 only when the credentials belong to the user that the
@@ -12,29 +11,28 @@ import '../../marianumconnect_endpoint.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 {
class AuthVerify extends MarianumConnectQuery {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 15);
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthVerify({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio =
dio ??
Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
super(dio: dio ?? _buildDio());
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
/// user, token missing, or token rejected), other [AppException]s on
@@ -49,14 +47,12 @@ class AuthVerify {
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('auth/verify'),
return guard(() async {
await dio.post<void>(
endpoint('auth/verify'),
data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
});
}
}
@@ -1,26 +1,14 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_breakers_response.dart';
/// Fetches the app maintenance breaker rules from `GET /api/mobile/v1/breaker`.
/// The endpoint is public: the bearer token is attached if present but not
/// required, so this also works before login (e.g. to block the whole app).
class GetBreakers {
final Dio _dio;
class GetBreakers extends MarianumConnectQuery {
GetBreakers({super.dio});
GetBreakers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<GetBreakersResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('breaker'),
);
return GetBreakersResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<GetBreakersResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('breaker'));
return GetBreakersResponse.fromJson(response.data!);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_capabilities_response.dart';
/// Fetches the current user's mobile capability flags from
/// `GET /api/mobile/v1/me/capabilities`. Goes through the shared dio singleton
/// so the bearer token is attached automatically.
class GetCapabilities {
final Dio _dio;
class GetCapabilities extends MarianumConnectQuery {
GetCapabilities({super.dio});
GetCapabilities({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<CapabilitiesResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('me/capabilities'),
);
return CapabilitiesResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<CapabilitiesResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('me/capabilities'),
);
return CapabilitiesResponse.fromJson(response.data!);
});
}
@@ -1,25 +1,13 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import '../../models/mc_holiday.dart';
class GetHolidays {
final Dio _dio;
class GetHolidays extends MarianumConnectQuery {
GetHolidays({super.dio});
GetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<List<McHoliday>> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('holidays'),
);
return response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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();
});
}
@@ -0,0 +1,25 @@
import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw PDF bytes of a Marianum Message from
/// `GET /api/mobile/v1/newsletter/{id}/file`.
///
/// Goes through the shared MC dio so the bearer token is attached automatically;
/// the bytes are handed to `SfPdfViewer.memory` so no auth header has to be
/// plumbed into the viewer itself.
class GetNewsletterFile extends MarianumConnectQuery {
final String id;
GetNewsletterFile(this.id, {super.dio});
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('newsletter/${Uri.encodeComponent(id)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
}
@@ -1,25 +1,13 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_ticker_response.dart';
/// Fetches the current "Aktuelles" ticker post from
/// `GET /api/mobile/v1/ticker`. Bearer token is attached by the shared dio.
class GetTicker {
final Dio _dio;
class GetTicker extends MarianumConnectQuery {
GetTicker({super.dio});
GetTicker({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('ticker'),
);
return TickerResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TickerResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(endpoint('ticker'));
return TickerResponse.fromJson(response.data!);
});
}
@@ -1,25 +1,15 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_ticker_nav_response.dart';
/// Fetches the filtered ticker page tree from
/// `GET /api/mobile/v1/ticker/pages`.
class GetTickerNav {
final Dio _dio;
class GetTickerNav extends MarianumConnectQuery {
GetTickerNav({super.dio});
GetTickerNav({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerNavResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('ticker/pages'),
);
return TickerNavResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TickerNavResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('ticker/pages'),
);
return TickerNavResponse.fromJson(response.data!);
});
}
@@ -4,8 +4,7 @@ import 'package:dio/dio.dart';
import '../../../errors/ticker_content_unavailable_exception.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'get_ticker_page_response.dart';
/// Fetches a single ticker page from
@@ -15,19 +14,17 @@ import 'get_ticker_page_response.dart';
/// 404 `{ "error": "CONTENT_UNAVAILABLE", "webUrl": ... }`; that case is mapped
/// to a dedicated [TickerContentUnavailableException] carrying the browser
/// fallback URL, so the detail screen can offer "open in browser" instead of a
/// generic error.
class GetTickerPage {
/// generic error. The bespoke 404 handling is why this keeps its own try/catch
/// instead of the base [guard].
class GetTickerPage extends MarianumConnectQuery {
final String slug;
final Dio _dio;
GetTickerPage(this.slug, {Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
GetTickerPage(this.slug, {super.dio});
Future<TickerPageResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve(
'ticker/pages/${Uri.encodeComponent(slug)}',
),
final response = await dio.get<Map<String, dynamic>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}'),
);
return TickerPageResponse.fromJson(response.data!);
} on DioException catch (e) {
@@ -39,6 +39,17 @@ class TickerPageResponse {
final String? hash;
final String? webUrl;
/// ISO timestamp the page was last published/updated (`ticker_pages.published_at`),
/// shown as "Aktualisiert am …" — the same date the web view displays. Null
/// when the page has never been published.
final String? publishedAt;
/// PROXIED_FILE only: ISO timestamp of the last successful re-fetch of the
/// file by the server proxy (`ticker_pages.proxy_last_success_at`) — the
/// document's data currency, shown as "Aktualisiert am …" instead of
/// [publishedAt]. Null for other kinds / files never fetched yet.
final String? fileFetchedAt;
TickerPageResponse({
required this.schemaVersion,
this.slug,
@@ -52,6 +63,8 @@ class TickerPageResponse {
this.filename,
this.hash,
this.webUrl,
this.publishedAt,
this.fileFetchedAt,
});
factory TickerPageResponse.fromJson(Map<String, dynamic> json) =>
@@ -20,6 +20,8 @@ TickerPageResponse _$TickerPageResponseFromJson(Map<String, dynamic> json) =>
filename: json['filename'] as String?,
hash: json['hash'] as String?,
webUrl: json['webUrl'] as String?,
publishedAt: json['publishedAt'] as String?,
fileFetchedAt: json['fileFetchedAt'] as String?,
);
Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
@@ -36,4 +38,6 @@ Map<String, dynamic> _$TickerPageResponseToJson(TickerPageResponse instance) =>
'filename': instance.filename,
'hash': instance.hash,
'webUrl': instance.webUrl,
'publishedAt': instance.publishedAt,
'fileFetchedAt': instance.fileFetchedAt,
};
@@ -2,9 +2,7 @@ import 'dart:typed_data';
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Downloads the raw bytes of a PROXIED_FILE ticker page from
/// `GET /api/mobile/v1/ticker/pages/{slug}/file`.
@@ -12,24 +10,16 @@ import '../../marianumconnect_endpoint.dart';
/// Goes through the shared MC dio so the bearer token is attached automatically
/// (server enforces page visibility); the bytes are handed to `SfPdfViewer.memory`
/// so no auth header has to be plumbed into the viewer itself.
class GetTickerPageFile {
class GetTickerPageFile extends MarianumConnectQuery {
final String slug;
final Dio _dio;
GetTickerPageFile(this.slug, {Dio? dio})
: _dio = dio ?? MarianumConnectApi.dio();
GetTickerPageFile(this.slug, {super.dio});
Future<Uint8List> run() async {
try {
final response = await _dio.get<List<int>>(
MarianumConnectEndpoint.resolve(
'ticker/pages/${Uri.encodeComponent(slug)}/file',
),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<Uint8List> run() => guard(() async {
final response = await dio.get<List<int>>(
endpoint('ticker/pages/${Uri.encodeComponent(slug)}/file'),
options: Options(responseType: ResponseType.bytes),
);
return Uint8List.fromList(response.data!);
});
}
@@ -1,25 +0,0 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'get_ticker_sync_response.dart';
/// Fetches the ticker/nav change hashes from
/// `GET /api/mobile/v1/ticker/sync`.
class GetTickerSync {
final Dio _dio;
GetTickerSync({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TickerSyncResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('ticker/sync'),
);
return TickerSyncResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -1,18 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_ticker_sync_response.g.dart';
/// Cheap change-detection poll from `GET /api/mobile/v1/ticker/sync`. Both
/// hashes let the app decide whether the ticker post and/or the page tree need
/// a full refetch without paying for the full payloads.
@JsonSerializable()
class TickerSyncResponse {
final String? tickerHash;
final String? navHash;
TickerSyncResponse({this.tickerHash, this.navHash});
factory TickerSyncResponse.fromJson(Map<String, dynamic> json) =>
_$TickerSyncResponseFromJson(json);
Map<String, dynamic> toJson() => _$TickerSyncResponseToJson(this);
}
@@ -1,19 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_ticker_sync_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TickerSyncResponse _$TickerSyncResponseFromJson(Map<String, dynamic> json) =>
TickerSyncResponse(
tickerHash: json['tickerHash'] as String?,
navHash: json['navHash'] as String?,
);
Map<String, dynamic> _$TickerSyncResponseToJson(TickerSyncResponse instance) =>
<String, dynamic>{
'tickerHash': instance.tickerHash,
'navHash': instance.navHash,
};
@@ -1,17 +1,11 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Registers (upserts) this device's push subscription with MarianumConnect via
/// `PUT /api/mobile/v1/me/push-device`. The backend verifies the Nextcloud
/// device-identifier signature, stores the routing metadata and starts
/// forwarding Nextcloud pushes to this device's FCM token. Responds 204.
class PushDeviceRegister {
final Dio _dio;
PushDeviceRegister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class PushDeviceRegister extends MarianumConnectQuery {
PushDeviceRegister({super.dio});
Future<void> run({
required String deviceIdentifier,
@@ -21,24 +15,20 @@ class PushDeviceRegister {
required String platform,
required String registrationType,
String? appVersion,
}) async {
try {
await _dio.put<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
'registrationType': registrationType,
'appVersion': ?appVersion,
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
await dio.put<void>(
endpoint('me/push-device'),
data: {
'deviceIdentifier': deviceIdentifier,
'deviceIdentifierSignature': deviceIdentifierSignature,
'userPublicKey': userPublicKey,
'pushToken': pushToken,
'platform': platform,
// 'general' | 'talk' — the backend derives the NC hash comparison
// value from it (general = sha512(token), talk = sha512(token+'#talk')).
'registrationType': registrationType,
'appVersion': ?appVersion,
},
);
});
}
@@ -1,25 +1,15 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Triggers a test push to all of the current user's registered devices via
/// `POST /api/mobile/v1/me/push-device/test`. Returns the number of devices the
/// backend dispatched to (0 when none are registered).
class PushDeviceTest {
final Dio _dio;
class PushDeviceTest extends MarianumConnectQuery {
PushDeviceTest({super.dio});
PushDeviceTest({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<int> run() async {
try {
final response = await _dio.post<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('me/push-device/test'),
);
return (response.data?['devices'] as num?)?.toInt() ?? 0;
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<int> run() => guard(() async {
final response = await dio.post<Map<String, dynamic>>(
endpoint('me/push-device/test'),
);
return (response.data?['devices'] as num?)?.toInt() ?? 0;
});
}
@@ -1,25 +1,15 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Removes this device's push subscription from MarianumConnect via
/// `DELETE /api/mobile/v1/me/push-device?deviceIdentifier=...`. Idempotent
/// (204 even when the row is already gone).
class PushDeviceUnregister {
final Dio _dio;
class PushDeviceUnregister extends MarianumConnectQuery {
PushDeviceUnregister({super.dio});
PushDeviceUnregister({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<void> run({required String deviceIdentifier}) async {
try {
await _dio.delete<void>(
MarianumConnectEndpoint.resolve('me/push-device'),
queryParameters: {'deviceIdentifier': deviceIdentifier},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<void> run({required String deviceIdentifier}) => guard(() async {
await dio.delete<void>(
endpoint('me/push-device'),
queryParameters: {'deviceIdentifier': deviceIdentifier},
);
});
}
@@ -1,17 +1,11 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Sends a single client-side error report to MarianumConnect
/// (`POST client-errors`). The endpoint is public, so reports that happen
/// before login are still captured; when a bearer token is present the shared
/// dio interceptor attaches it and the server attributes the report to that user.
class ReportClientError {
final Dio _dio;
ReportClientError({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class ReportClientError extends MarianumConnectQuery {
ReportClientError({super.dio});
Future<void> run({
required String errorType,
@@ -21,22 +15,18 @@ class ReportClientError {
String? platform,
String? appVersion,
String? deviceModel,
}) async {
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('client-errors'),
data: {
'errorType': errorType,
'message': ?message,
'stacktrace': ?stacktrace,
'context': ?context,
'platform': ?platform,
'appVersion': ?appVersion,
'deviceModel': ?deviceModel,
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
await dio.post<void>(
endpoint('client-errors'),
data: {
'errorType': errorType,
'message': ?message,
'stacktrace': ?stacktrace,
'context': ?context,
'platform': ?platform,
'appVersion': ?appVersion,
'deviceModel': ?deviceModel,
},
);
});
}
@@ -3,46 +3,37 @@ import 'dart:io';
import 'dart:typed_data';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
/// Submits user feedback to MarianumConnect (`POST me/feedback`, bearer-auth).
/// The optional screenshot is sent base64-encoded. Replaces the legacy mhsl.eu
/// `server/feedback` endpoint — the user no longer needs to be sent explicitly,
/// the bearer token identifies them.
class SubmitFeedback {
final Dio _dio;
SubmitFeedback({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class SubmitFeedback extends MarianumConnectQuery {
SubmitFeedback({super.dio});
Future<void> run({
required String message,
Uint8List? screenshot,
String screenshotContentType = 'image/png',
}) async {
try {
final package = await PackageInfo.fromPlatform();
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
await _dio.post<void>(
MarianumConnectEndpoint.resolve('me/feedback'),
data: {
'message': message,
'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': _platform(),
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(),
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => guard(() async {
final package = await PackageInfo.fromPlatform();
final screenshotBase64 = screenshot != null ? base64Encode(screenshot) : null;
await dio.post<void>(
endpoint('me/feedback'),
data: {
'message': message,
'screenshot': ?screenshotBase64,
'screenshotContentType': screenshot != null ? screenshotContentType : null,
'platform': _platform(),
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': await _deviceModel(),
},
);
});
static String? _platform() {
if (Platform.isAndroid) return 'android';
@@ -3,72 +3,75 @@ import 'dart:convert';
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart';
import 'package:package_info_plus/package_info_plus.dart';
import '../../../../push/push_registration_store.dart';
import '../../../../push/push_registration_type.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'telemetry_device_id.dart';
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) — one
/// upsert per app start carrying the stable install id, platform, app version
/// and device info. Bearer-authenticated via the shared dio interceptor.
/// Replaces the legacy mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat {
final Dio _dio;
TelemetryHeartbeat({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
/// Sends a telemetry heartbeat to MarianumConnect (`POST me/telemetry`) —
/// upserts the stable install id, platform, app version and device info. Sent
/// once on app start and again once push registration completes that session
/// (so a fresh registration isn't under-reported until the next launch).
/// Bearer-authenticated via the shared dio interceptor. Replaces the legacy
/// mhsl.eu `server/userIndex/update` call.
class TelemetryHeartbeat extends MarianumConnectQuery {
TelemetryHeartbeat({super.dio});
/// Fire-and-forget: schedules a heartbeat and swallows any error, so a failed
/// send never disrupts app start. Used from the app shell's initState.
static void report() {
unawaited(TelemetryHeartbeat().send().catchError((Object _) {}));
/// send never disrupts app start. Used from the app shell's initState and
/// re-emitted once push registration completes (see `_MainState._syncPush`).
static void report({required bool notificationsEnabled}) {
unawaited(
TelemetryHeartbeat()
.send(notificationsEnabled: notificationsEnabled)
.catchError((Object _) {}),
);
}
Future<void> send() async {
try {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
Future<void> send({required bool notificationsEnabled}) => guard(() async {
final info = DeviceInfoPlugin();
final package = await PackageInfo.fromPlatform();
final deviceIdentifier = await TelemetryDeviceId.resolve();
final pushDeviceIdentifier = await const PushRegistrationStore()
.deviceIdentifier(PushRegistrationType.general);
var platform = 'unknown';
String? deviceModel;
String? osVersion;
var raw = <String, dynamic>{};
if (Platform.isAndroid) {
platform = 'android';
final androidInfo = await info.androidInfo;
deviceModel = androidInfo.model;
osVersion = androidInfo.version.release;
raw = androidInfo.data;
} else if (Platform.isIOS) {
platform = 'ios';
final appleInfo = await info.iosInfo;
deviceModel = appleInfo.utsname.machine;
osVersion = appleInfo.systemVersion;
raw = appleInfo.data;
}
await _dio.post<void>(
MarianumConnectEndpoint.resolve('me/telemetry'),
data: {
'deviceIdentifier': deviceIdentifier,
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': deviceModel,
'osVersion': osVersion,
'deviceInfo': jsonEncode(raw),
},
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
var platform = 'unknown';
String? deviceModel;
String? osVersion;
var raw = <String, dynamic>{};
if (Platform.isAndroid) {
platform = 'android';
final androidInfo = await info.androidInfo;
deviceModel = androidInfo.model;
osVersion = androidInfo.version.release;
raw = androidInfo.data;
} else if (Platform.isIOS) {
platform = 'ios';
final appleInfo = await info.iosInfo;
deviceModel = appleInfo.utsname.machine;
osVersion = appleInfo.systemVersion;
raw = appleInfo.data;
}
}
await dio.post<void>(
endpoint('me/telemetry'),
data: {
'deviceIdentifier': deviceIdentifier,
// `pushDeviceIdentifier` reflects a *completed* registration and is
// absent until it lands; `pushEnabled` carries the user's intent
// (the notification toggle) so the backend can tell "user wants push"
// apart from "registration not finished yet".
'pushDeviceIdentifier': ?pushDeviceIdentifier,
'pushEnabled': notificationsEnabled,
'platform': platform,
'appVersion': package.version,
'appBuild': int.tryParse(package.buildNumber),
'deviceModel': deviceModel,
'osVersion': osVersion,
'deviceInfo': jsonEncode(raw),
},
);
});
}
@@ -0,0 +1,68 @@
import 'dart:developer';
import 'package:localstore/localstore.dart';
import '../../../../model/account_data.dart';
import '../../../demo/demo_mode.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_params.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event.dart';
import '../../../mhsl/custom_timetable_event/remove/remove_custom_timetable_event_params.dart';
import 'timetable_custom_events_add.dart';
/// One-time migration of the user's custom timetable events from the legacy
/// MHSL backend to Marianum-Connect. Runs at most once per install, guarded by
/// a persisted flag.
///
/// The MHSL identity is `sha512(username:password)` — computed here exactly as
/// the app always did, so the fetch matches the user's own events perfectly.
/// Each event is POSTed to Marianum-Connect and then deleted from MHSL, which
/// makes the whole run idempotent and resumable: a re-run after a mid-way
/// failure only sees the events that were not yet moved, so nothing is
/// duplicated. The flag is set only once MHSL reports no remaining events.
class CustomEventsMigration {
static const String _collection = 'MarianumMobile';
static const String _document = 'customEventsMigration';
static const String _doneKey = 'migratedToMc';
const CustomEventsMigration._();
static Future<void> runOnce() async {
if (DemoMode.active) return;
if (await _isDone()) return;
try {
final response = await GetCustomTimetableEvent(
GetCustomTimetableEventParams(AccountData().getUserSecret()),
).run();
for (final event in response.events) {
await TimetableCustomEventsAdd().run(event);
await RemoveCustomTimetableEvent(
RemoveCustomTimetableEventParams(event.id),
).run();
}
await _markDone();
log('Custom events migration: moved ${response.events.length} event(s) to Marianum-Connect.');
} catch (e) {
// Leave the flag unset so the next launch retries; the delete-after-post
// above keeps a partial run duplicate-free.
log('Custom events migration failed, will retry on next launch: $e');
}
}
static Future<bool> _isDone() async {
final data = await Localstore.instance
.collection(_collection)
.doc(_document)
.get();
return data != null && data[_doneKey] == true;
}
static Future<void> _markDone() async {
await Localstore.instance.collection(_collection).doc(_document).set({
_doneKey: true,
});
}
}
@@ -0,0 +1,13 @@
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../marianumconnect_query.dart';
class TimetableCustomEventsAdd extends MarianumConnectQuery {
TimetableCustomEventsAdd({super.dio});
Future<void> run(CustomTimetableEvent event) => guard(() async {
await dio.post<void>(
endpoint('timetable/custom-events'),
data: event.toJson(),
);
});
}
@@ -0,0 +1,15 @@
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
import '../../../request_cache.dart';
import 'timetable_custom_events_get.dart';
class TimetableCustomEventsCache
extends SimpleCache<GetCustomTimetableEventResponse> {
TimetableCustomEventsCache({super.onUpdate, super.onError, super.renew})
: super(
cacheTime: RequestCache.cacheMinute,
loader: () => TimetableCustomEventsGet().run(),
fromJson: GetCustomTimetableEventResponse.fromJson,
) {
start('customTimetableEvents');
}
}
@@ -0,0 +1,13 @@
import '../../../mhsl/custom_timetable_event/get/get_custom_timetable_event_response.dart';
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!);
});
}
@@ -0,0 +1,9 @@
import '../../marianumconnect_query.dart';
class TimetableCustomEventsRemove extends MarianumConnectQuery {
TimetableCustomEventsRemove({super.dio});
Future<void> run(String id) => guard(() async {
await dio.delete<void>(endpoint('timetable/custom-events/$id'));
});
}
@@ -0,0 +1,13 @@
import '../../../mhsl/custom_timetable_event/custom_timetable_event.dart';
import '../../marianumconnect_query.dart';
class TimetableCustomEventsUpdate extends MarianumConnectQuery {
TimetableCustomEventsUpdate({super.dio});
Future<void> run(String id, CustomTimetableEvent event) => guard(() async {
await dio.put<void>(
endpoint('timetable/custom-events/$id'),
data: event.toJson(),
);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_classes_response.dart';
class TimetableGetClasses {
final Dio _dio;
class TimetableGetClasses extends MarianumConnectQuery {
TimetableGetClasses({super.dio});
TimetableGetClasses({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetClassesResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/elements/classes'),
);
final list = response.data!
.map((e) => McTimetableClass.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetClassesResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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);
});
}
@@ -1,35 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import '../timetable_get_week/timetable_get_week_response.dart';
import 'timetable_element_type.dart';
/// Fetches a foreign element's weekly timetable from
/// `timetable/{student|teacher|room|class}/{id}`. The response shape is
/// identical to `timetable/me`, so [TimetableGetWeekResponse] is reused.
class TimetableGetElementWeek {
final Dio _dio;
TimetableGetElementWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class TimetableGetElementWeek extends MarianumConnectQuery {
TimetableGetElementWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required TimetableElementType type,
required int id,
required DateTime from,
required DateTime until,
}) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/${type.pathSegment}/$id'),
queryParameters: {'from': _format(from), 'until': _format(until)},
);
return TimetableGetWeekResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => 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')}';
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_holidays_response.dart';
class TimetableGetHolidays {
final Dio _dio;
class TimetableGetHolidays extends MarianumConnectQuery {
TimetableGetHolidays({super.dio});
TimetableGetHolidays({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetHolidaysResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/holidays'),
);
final list = response.data!
.map((e) => McHoliday.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetHolidaysResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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);
});
}
@@ -1,26 +1,14 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_rooms_response.dart';
class TimetableGetRooms {
final Dio _dio;
class TimetableGetRooms extends MarianumConnectQuery {
TimetableGetRooms({super.dio});
TimetableGetRooms({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetRoomsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/rooms'),
);
final list = response.data!
.map((e) => McRoom.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetRoomsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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);
});
}
@@ -1,23 +1,13 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_schoolyear_response.dart';
class TimetableGetSchoolyear {
final Dio _dio;
class TimetableGetSchoolyear extends MarianumConnectQuery {
TimetableGetSchoolyear({super.dio});
TimetableGetSchoolyear({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetSchoolyearResponse> run() async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
Future<TimetableGetSchoolyearResponse> run() => guard(() async {
final response = await dio.get<Map<String, dynamic>>(
endpoint('timetable/schoolyear'),
);
return TimetableGetSchoolyearResponse.fromJson(response.data!);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_students_response.dart';
class TimetableGetStudents {
final Dio _dio;
class TimetableGetStudents extends MarianumConnectQuery {
TimetableGetStudents({super.dio});
TimetableGetStudents({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetStudentsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/elements/students'),
);
final list = response.data!
.map((e) => McTimetableStudent.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetStudentsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_subjects_response.dart';
class TimetableGetSubjects {
final Dio _dio;
class TimetableGetSubjects extends MarianumConnectQuery {
TimetableGetSubjects({super.dio});
TimetableGetSubjects({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetSubjectsResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/subjects'),
);
final list = response.data!
.map((e) => McSubject.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetSubjectsResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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);
});
}
@@ -10,10 +10,15 @@ class McSubject {
final String shortName;
final String longName;
/// Persönliche Fach-Farbe (Palette-Name aus [SubjectColor]), serverseitig pro
/// Nutzer gepflegt. `null`, wenn für dieses Fach keine Farbe gesetzt ist.
final String? color;
McSubject({
required this.id,
required this.shortName,
required this.longName,
this.color,
});
factory McSubject.fromJson(Map<String, dynamic> json) =>
@@ -10,12 +10,14 @@ McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
color: json['color'] as String?,
);
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
'color': instance.color,
};
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
@@ -1,29 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_teachers_response.dart';
class TimetableGetTeachers {
final Dio _dio;
class TimetableGetTeachers extends MarianumConnectQuery {
TimetableGetTeachers({super.dio});
TimetableGetTeachers({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetTeachersResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/elements/teachers'),
);
final list = response.data!
.map(
(e) =>
McTimetableTeacherElement.fromJson(e as Map<String, dynamic>),
)
.toList();
return TimetableGetTeachersResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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);
});
}
@@ -1,26 +1,16 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_timegrid_response.dart';
class TimetableGetTimegrid {
final Dio _dio;
class TimetableGetTimegrid extends MarianumConnectQuery {
TimetableGetTimegrid({super.dio});
TimetableGetTimegrid({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<TimetableGetTimegridResponse> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('timetable/timegrid'),
);
final list = response.data!
.map((e) => McTimegridUnit.fromJson(e as Map<String, dynamic>))
.toList();
return TimetableGetTimegridResponse(result: list);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
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);
});
}
@@ -1,32 +1,19 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../marianumconnect_query.dart';
import 'timetable_get_week_response.dart';
class TimetableGetWeek {
final Dio _dio;
TimetableGetWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
class TimetableGetWeek extends MarianumConnectQuery {
TimetableGetWeek({super.dio});
Future<TimetableGetWeekResponse> run({
required DateTime from,
required DateTime until,
}) async {
try {
final response = await _dio.get<Map<String, dynamic>>(
MarianumConnectEndpoint.resolve('timetable/me'),
queryParameters: {
'from': _format(from),
'until': _format(until),
},
);
return TimetableGetWeekResponse.fromJson(response.data!);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}) => 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')}';
@@ -0,0 +1,14 @@
import '../../marianumconnect_query.dart';
/// Entfernt die persönliche Farbe eines Fachs (Fach fällt auf die Status-Farbe
/// zurück). Schlüssel ist das Fach-Kürzel (`shortName`).
class TimetableSubjectColorRemove extends MarianumConnectQuery {
TimetableSubjectColorRemove({super.dio});
Future<void> run(String subjectShort) => guard(() async {
await dio.delete<void>(
endpoint('timetable/subject-colors'),
queryParameters: {'subject': subjectShort},
);
});
}
@@ -0,0 +1,14 @@
import '../../marianumconnect_query.dart';
/// Setzt (oder überschreibt) die persönliche Farbe eines Fachs. Der Schlüssel
/// ist das Fach-Kürzel (`shortName`); die Farbe ist ein Palette-Name.
class TimetableSubjectColorSet extends MarianumConnectQuery {
TimetableSubjectColorSet({super.dio});
Future<void> run(String subjectShort, String color) => guard(() async {
await dio.put<void>(
endpoint('timetable/subject-colors'),
data: {'subject': subjectShort, 'color': color},
);
});
}
@@ -0,0 +1,20 @@
import '../../marianumconnect_query.dart';
import 'user_search_response.dart';
/// Searches active users (students, teachers, staff) via the MarianumConnect
/// mobile API. Returns each match's Nextcloud username plus role, so the Talk
/// search can start a direct chat and label results without hitting Nextcloud.
class UserSearch extends MarianumConnectQuery {
UserSearch({super.dio});
Future<UserSearchResponse> run(String query) => guard(() async {
final response = await dio.get<List<dynamic>>(
endpoint('users/search'),
queryParameters: {'q': query},
);
final list = response.data!
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
.toList();
return UserSearchResponse(result: list);
});
}
@@ -0,0 +1,40 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'user_search_response.g.dart';
@JsonSerializable(explicitToJson: true)
class McUserSearchResult {
/// Nextcloud-/Talk-Username — dient als `invite` beim Chat-Start.
final String username;
final String firstName;
final String lastName;
/// STUDENT, TEACHER oder STAFF.
final String userType;
final String? className;
McUserSearchResult({
required this.username,
required this.firstName,
required this.lastName,
required this.userType,
this.className,
});
factory McUserSearchResult.fromJson(Map<String, dynamic> json) =>
_$McUserSearchResultFromJson(json);
Map<String, dynamic> toJson() => _$McUserSearchResultToJson(this);
}
@JsonSerializable(explicitToJson: true)
class UserSearchResponse extends ApiResponse {
final List<McUserSearchResult> result;
UserSearchResponse({required this.result});
factory UserSearchResponse.fromJson(Map<String, dynamic> json) =>
_$UserSearchResponseFromJson(json);
Map<String, dynamic> toJson() => _$UserSearchResponseToJson(this);
}
@@ -0,0 +1,41 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'user_search_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McUserSearchResult _$McUserSearchResultFromJson(Map<String, dynamic> json) =>
McUserSearchResult(
username: json['username'] as String,
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
userType: json['userType'] as String,
className: json['className'] as String?,
);
Map<String, dynamic> _$McUserSearchResultToJson(McUserSearchResult instance) =>
<String, dynamic>{
'username': instance.username,
'firstName': instance.firstName,
'lastName': instance.lastName,
'userType': instance.userType,
'className': instance.className,
};
UserSearchResponse _$UserSearchResponseFromJson(Map<String, dynamic> json) =>
UserSearchResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McUserSearchResult.fromJson(e as Map<String, dynamic>))
.toList(),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$UserSearchResponseToJson(UserSearchResponse instance) =>
<String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -1,22 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:http/http.dart' as http;
import '../../mhsl_api.dart';
import 'add_custom_timetable_event_params.dart';
class AddCustomTimetableEvent extends MhslApi<void> {
AddCustomTimetableEventParams params;
AddCustomTimetableEvent(this.params) : super('server/timetable/customEvents');
@override
void assemble(String raw) {}
@override
Future<Response>? request(Uri uri) {
var body = jsonEncode(params.toJson());
return http.post(uri, body: body);
}
}
@@ -1,17 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../custom_timetable_event.dart';
part 'add_custom_timetable_event_params.g.dart';
@JsonSerializable(explicitToJson: true)
class AddCustomTimetableEventParams {
String user;
CustomTimetableEvent event;
AddCustomTimetableEventParams(this.user, this.event);
factory AddCustomTimetableEventParams.fromJson(Map<String, dynamic> json) =>
_$AddCustomTimetableEventParamsFromJson(json);
Map<String, dynamic> toJson() => _$AddCustomTimetableEventParamsToJson(this);
}
@@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'add_custom_timetable_event_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AddCustomTimetableEventParams _$AddCustomTimetableEventParamsFromJson(
Map<String, dynamic> json,
) => AddCustomTimetableEventParams(
json['user'] as String,
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AddCustomTimetableEventParamsToJson(
AddCustomTimetableEventParams instance,
) => <String, dynamic>{'user': instance.user, 'event': instance.event.toJson()};
@@ -1,7 +1,5 @@
import 'package:json_annotation/json_annotation.dart';
import '../mhsl_api.dart';
part 'custom_timetable_event.g.dart';
@JsonSerializable()
@@ -9,15 +7,23 @@ class CustomTimetableEvent {
String id;
String title;
String description;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime startDate;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime endDate;
String? color;
String rrule;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
/// When true, occurrences of a recurring event that fall inside a school
/// holiday are hidden in the calendar. Purely client-side: the backend only
/// stores the flag, the exclusion is applied at render time against the
/// holiday list. Ignored for non-recurring events (empty [rrule]).
@JsonKey(defaultValue: false)
bool skipHolidays;
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime createdAt;
@JsonKey(toJson: MhslApi.dateTimeToJson, fromJson: MhslApi.dateTimeFromJson)
@JsonKey(toJson: _dateToJson, fromJson: _dateFromJson)
DateTime updatedAt;
CustomTimetableEvent({
@@ -28,6 +34,7 @@ class CustomTimetableEvent {
required this.endDate,
required this.color,
required this.rrule,
this.skipHolidays = false,
required this.createdAt,
required this.updatedAt,
});
@@ -35,4 +42,13 @@ class CustomTimetableEvent {
factory CustomTimetableEvent.fromJson(Map<String, dynamic> json) =>
_$CustomTimetableEventFromJson(json);
Map<String, dynamic> toJson() => _$CustomTimetableEventToJson(this);
// Marianum-Connect serializes LocalDateTime as ISO-8601 (`yyyy-MM-ddTHH:mm:ss`)
// and its GSON parser rejects anything else. DateTime.parse still reads the
// old MHSL `yyyy-MM-dd HH:mm:ss` shape too, so events fetched during the
// one-time migration deserialize unchanged.
static DateTime _dateFromJson(String raw) => DateTime.parse(raw);
static String _dateToJson(DateTime d) =>
'${d.year.toString().padLeft(4, '0')}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}'
'T${d.hour.toString().padLeft(2, '0')}:${d.minute.toString().padLeft(2, '0')}:${d.second.toString().padLeft(2, '0')}';
}
@@ -12,12 +12,13 @@ CustomTimetableEvent _$CustomTimetableEventFromJson(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String,
startDate: MhslApi.dateTimeFromJson(json['startDate'] as String),
endDate: MhslApi.dateTimeFromJson(json['endDate'] as String),
startDate: CustomTimetableEvent._dateFromJson(json['startDate'] as String),
endDate: CustomTimetableEvent._dateFromJson(json['endDate'] as String),
color: json['color'] as String?,
rrule: json['rrule'] as String,
createdAt: MhslApi.dateTimeFromJson(json['createdAt'] as String),
updatedAt: MhslApi.dateTimeFromJson(json['updatedAt'] as String),
skipHolidays: json['skipHolidays'] as bool? ?? false,
createdAt: CustomTimetableEvent._dateFromJson(json['createdAt'] as String),
updatedAt: CustomTimetableEvent._dateFromJson(json['updatedAt'] as String),
);
Map<String, dynamic> _$CustomTimetableEventToJson(
@@ -26,10 +27,11 @@ Map<String, dynamic> _$CustomTimetableEventToJson(
'id': instance.id,
'title': instance.title,
'description': instance.description,
'startDate': MhslApi.dateTimeToJson(instance.startDate),
'endDate': MhslApi.dateTimeToJson(instance.endDate),
'startDate': CustomTimetableEvent._dateToJson(instance.startDate),
'endDate': CustomTimetableEvent._dateToJson(instance.endDate),
'color': instance.color,
'rrule': instance.rrule,
'createdAt': MhslApi.dateTimeToJson(instance.createdAt),
'updatedAt': MhslApi.dateTimeToJson(instance.updatedAt),
'skipHolidays': instance.skipHolidays,
'createdAt': CustomTimetableEvent._dateToJson(instance.createdAt),
'updatedAt': CustomTimetableEvent._dateToJson(instance.updatedAt),
};
@@ -1,20 +0,0 @@
import '../../../request_cache.dart';
import 'get_custom_timetable_event.dart';
import 'get_custom_timetable_event_params.dart';
import 'get_custom_timetable_event_response.dart';
class GetCustomTimetableEventCache
extends SimpleCache<GetCustomTimetableEventResponse> {
GetCustomTimetableEventCache(
GetCustomTimetableEventParams params, {
super.onUpdate,
super.onError,
super.renew,
}) : super(
cacheTime: RequestCache.cacheMinute,
loader: () => GetCustomTimetableEvent(params).run(),
fromJson: GetCustomTimetableEventResponse.fromJson,
) {
start('customTimetableEvents');
}
}
@@ -1,21 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart';
import 'package:http/http.dart' as http;
import '../../mhsl_api.dart';
import 'update_custom_timetable_event_params.dart';
class UpdateCustomTimetableEvent extends MhslApi<void> {
UpdateCustomTimetableEventParams params;
UpdateCustomTimetableEvent(this.params)
: super('server/timetable/customEvents');
@override
void assemble(String raw) {}
@override
Future<Response>? request(Uri uri) =>
http.patch(uri, body: jsonEncode(params.toJson()));
}
@@ -1,19 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../custom_timetable_event.dart';
part 'update_custom_timetable_event_params.g.dart';
@JsonSerializable(explicitToJson: true)
class UpdateCustomTimetableEventParams {
String id;
CustomTimetableEvent event;
UpdateCustomTimetableEventParams(this.id, this.event);
factory UpdateCustomTimetableEventParams.fromJson(
Map<String, dynamic> json,
) => _$UpdateCustomTimetableEventParamsFromJson(json);
Map<String, dynamic> toJson() =>
_$UpdateCustomTimetableEventParamsToJson(this);
}
@@ -1,18 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'update_custom_timetable_event_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
UpdateCustomTimetableEventParams _$UpdateCustomTimetableEventParamsFromJson(
Map<String, dynamic> json,
) => UpdateCustomTimetableEventParams(
json['id'] as String,
CustomTimetableEvent.fromJson(json['event'] as Map<String, dynamic>),
);
Map<String, dynamic> _$UpdateCustomTimetableEventParamsToJson(
UpdateCustomTimetableEventParams instance,
) => <String, dynamic>{'id': instance.id, 'event': instance.event.toJson()};
+22 -12
View File
@@ -93,6 +93,13 @@ class _AppState extends State<App> with WidgetsBindingObserver {
NotificationTasks.navigateToTalk(context, chatToken: token);
}
void _onNewsletterTapPending() {
final id = PushTapRouter.pendingNewsletterId.value;
if (id == null || !mounted) return;
PushTapRouter.pendingNewsletterId.value = null;
AppRoutes.openNewsletterById(context, id: id);
}
Future<void> _handlePendingWidgetNavigation() async {
final pending = await WidgetNavigation.consumePendingTimetableTap();
if (!pending || !mounted) return;
@@ -171,26 +178,28 @@ class _AppState extends State<App> with WidgetsBindingObserver {
if (mounted) setState(() {});
});
TelemetryHeartbeat.report();
TelemetryHeartbeat.report(
notificationsEnabled:
context.read<SettingsCubit>().val().notificationSettings.enabled,
);
// A refreshed FCM token invalidates the existing push subscription — the
// NC device identifier stays stable, so we simply re-register (NC first,
// then the proxy). Debounced so a burst of refreshes triggers one call.
if (context.read<SettingsCubit>().val().notificationSettings.enabled) {
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((
_,
) {
Debouncer.debounce(
'pushTokenRefresh',
const Duration(seconds: 3),
() => unawaited(PushRegistration().onTokenRefresh()),
);
});
}
// Not gated on the notification toggle: registration is kept alive even
// when notifications are off so silent sync pushes keep flowing.
_fcmTokenRefreshSub = FirebaseMessaging.instance.onTokenRefresh.listen((_) {
Debouncer.debounce(
'pushTokenRefresh',
const Duration(seconds: 3),
() => unawaited(PushRegistration().onTokenRefresh()),
);
});
// Android renders pushes locally, so a tap arrives via the local
// notifications callback (PushTapRouter) rather than onMessageOpenedApp.
PushTapRouter.pendingChatToken.addListener(_onPushTapPending);
PushTapRouter.pendingNewsletterId.addListener(_onNewsletterTapPending);
_onMessageSub = FirebaseMessaging.onMessage.listen((message) {
if (!mounted) return;
@@ -221,6 +230,7 @@ class _AppState extends State<App> with WidgetsBindingObserver {
_onMessageOpenedAppSub?.cancel();
_fcmTokenRefreshSub?.cancel();
PushTapRouter.pendingChatToken.removeListener(_onPushTapPending);
PushTapRouter.pendingNewsletterId.removeListener(_onNewsletterTapPending);
ShareIntentListener.pending.removeListener(_handlePendingShare);
ShareIntentListener.instance.detach();
Main.bottomNavigator.removeListener(_onTabControllerChanged);
+3
View File
@@ -64,6 +64,9 @@ extension DateTimeFormatting on DateTime {
String formatRelative() => Jiffy.parseFromDateTime(this).fromNow();
/// Compact `yyyyMMdd` key, e.g. to identify a week-start in timetable caches.
String weekKey() => Jiffy.parseFromDateTime(this).format(pattern: 'yyyyMMdd');
String timeRangeTo(DateTime end) => '${formatHm()} - ${end.formatHm()}';
String formatDateRelativeShort({DateTime? now}) {
-10
View File
@@ -1,10 +0,0 @@
import 'package:flutter/material.dart';
extension TimeOfDayExt on TimeOfDay {
bool isBefore(TimeOfDay other) => hour < other.hour && minute < other.minute;
bool isAfter(TimeOfDay other) => hour > other.hour && minute > other.minute;
TimeOfDay add({int hours = 0, int minutes = 0}) =>
replacing(hour: hour + hours, minute: minute + minutes);
}
+50 -9
View File
@@ -22,6 +22,7 @@ import 'api/marianumconnect/auth/session_validator.dart';
import 'api/marianumconnect/marianumconnect_endpoint.dart';
import 'api/marianumconnect/queries/get_breakers/get_breakers_response.dart';
import 'api/marianumconnect/queries/report_client_error/client_error_reporter.dart';
import 'api/marianumconnect/queries/telemetry_heartbeat/telemetry_heartbeat.dart';
import 'app.dart';
import 'background/widget_background_task.dart';
import 'firebase_options.dart';
@@ -29,6 +30,7 @@ import 'model/account_data.dart';
import 'notification/notification_service.dart';
import 'push/push_message_handler.dart';
import 'push/push_registration.dart';
import 'push/push_registration_store.dart';
import 'push/push_renderer.dart';
import 'routing/app_routes.dart';
import 'share_intent/share_intent_listener.dart';
@@ -49,6 +51,7 @@ import 'utils/downloads/download_manager.dart';
import 'view/login/login.dart';
import 'view/login/post_login_splash.dart';
import 'widget/app_progress_indicator.dart';
import 'widget/avatar_disk_cache.dart';
import 'widget/breaker/breaker.dart';
import 'widget/debug/cache_view.dart';
import 'widget/downloads/download_tray.dart';
@@ -119,10 +122,19 @@ Future<void> main() async {
),
);
// Diagnostic log only. getToken() can fail transiently during cold start
// (Android: "IOException: FCM Registration failed!" from Play Services,
// iOS: apns-token-not-set until APNS registration completes), so tolerate
// the failure instead of letting it surface as an uncaught async error that
// gets reported to the telemetry backend as noise.
unawaited(
FirebaseMessaging.instance.getToken().then(
(token) => log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
),
FirebaseMessaging.instance
.getToken()
.then(
(token) =>
log('Firebase token: ${token ?? "Error: no Firebase token!"}'),
)
.onError((e, _) => log('Firebase token unavailable: $e')),
);
// Warm up the Nextcloud root listing in the background while the user is
@@ -139,6 +151,11 @@ Future<void> main() async {
);
}
// Resolve the avatar cache directory ahead of the first avatar render so the
// synchronous disk read hits and cold-start avatars appear without a blank
// placeholder flash.
AvatarDiskCache.instance.warmUp();
if (kReleaseMode) {
ErrorWidget.builder = (error) => Material(
color: Colors.white,
@@ -251,14 +268,23 @@ class _MainState extends State<Main> {
unawaited(ListFilesCache.prefetchRootListing());
}
/// Registers/self-heals the push subscription when push is user-enabled and
/// the backend advertises the capability. Fire-and-forget.
/// Registers/self-heals the push subscription whenever the backend advertises
/// the capability — independent of the notification toggle, so a user with
/// notifications off stays registered for silent sync pushes. Fire-and-forget.
void _syncPush(SettingsCubit settings, CapabilitiesCubit capabilities) {
final enabled = settings.val().notificationSettings.enabled;
unawaited(
PushRegistration.syncSubscription(
enabled: settings.val().notificationSettings.enabled,
capable: capabilities.canReceivePushNotifications,
),
).then((registered) {
// The app-start heartbeat runs before this async registration
// finishes, so it reports the pre-registration state. Re-emit once
// the identifier is persisted so the new push status shows up this
// session instead of only after the next launch.
if (registered) {
TelemetryHeartbeat.report(notificationsEnabled: enabled);
}
}),
);
}
@@ -290,6 +316,13 @@ class _MainState extends State<Main> {
final mcBaseUrl = devToolsSettings.resolveMarianumConnectBaseUrl();
MarianumConnectEndpoint.update(mcBaseUrl);
unawaited(WidgetSync.setMarianumConnectBaseUrl(mcBaseUrl));
// Mirror the notification toggle into group-scoped storage so the FCM
// background isolate and the iOS NSE can suppress rendering when off.
unawaited(
const PushRegistrationStore().setNotificationsEnabled(
settings.notificationSettings.enabled,
),
);
return MaterialApp(
showPerformanceOverlay: devToolsSettings.showPerformanceOverlay,
checkerboardOffscreenLayers:
@@ -335,9 +368,17 @@ class _MainState extends State<Main> {
previous.status != current.status,
listener: (context, accountState) {
// Fresh login (loggedOut -> loggedIn): pull capability flags
// for the newly authenticated user.
// for the newly authenticated user, then register push right
// away instead of deferring it to the next app start.
if (accountState.status == AccountStatus.loggedIn) {
unawaited(context.read<CapabilitiesCubit>().load());
final settingsCubit = context.read<SettingsCubit>();
final capabilitiesCubit = context.read<CapabilitiesCubit>();
unawaited(
capabilitiesCubit.load().then((_) {
if (!mounted) return;
_syncPush(settingsCubit, capabilitiesCubit);
}),
);
unawaited(
context.read<NextcloudCapabilitiesCubit>().load(),
);
-9
View File
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -57,14 +56,6 @@ class AccountData {
.convert(utf8.encode('${getUsername()}:${getPassword()}'))
.toString();
Future<String> getDeviceId() async => sha512
.convert(
utf8.encode(
'${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}',
),
)
.toString();
Future<void> setData(String username, String password) async {
await _secureStorage.write(key: _usernameField, value: username);
await _secureStorage.write(key: _passwordField, value: password);
+1 -1
View File
@@ -9,7 +9,7 @@ class DataCleaner {
.get();
cacheData?.forEach((key, value) async {
final lastUpdate = DateTime.fromMillisecondsSinceEpoch(
value['lastupdate'] as int,
((value['lastupdate'] as num?) ?? 0).toInt(),
);
if (DateTime.now()
.subtract(const Duration(days: 200))
+19 -4
View File
@@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../push/push_message_handler.dart';
import '../routing/app_routes.dart';
import '../state/app/modules/chat/bloc/chat_bloc.dart';
import '../widget/debug/debug_tile.dart';
import '../widget/debug/json_viewer.dart';
@@ -41,10 +42,19 @@ class NotificationController {
RemoteMessage message,
BuildContext context,
) async {
NotificationTasks.navigateToTalk(
context,
chatToken: _extractChatToken(message),
);
final newsletterId = _extractNewsletterId(message);
if (newsletterId != null) {
AppRoutes.openNewsletterById(
context,
id: newsletterId,
title: message.notification?.title,
);
} else {
NotificationTasks.navigateToTalk(
context,
chatToken: _extractChatToken(message),
);
}
NotificationTasks.updateProviders(context);
unawaited(NotificationTasks.refreshBadge());
@@ -66,4 +76,9 @@ class NotificationController {
}
return null;
}
static String? _extractNewsletterId(RemoteMessage message) {
final value = message.data['newsletterId'];
return value is String && value.isNotEmpty ? value : null;
}
}
+20 -1
View File
@@ -77,15 +77,24 @@ class PushMessageHandler {
String? openChatToken,
}) async {
final data = message.data;
// The device stays registered even when the user turns notifications off,
// so silent sync pushes (deletes, data refresh) keep arriving. When off we
// still process the message but skip raising a visible notification.
final notificationsEnabled = await _registrationStore.notificationsEnabled();
switch (classifyPush(data)) {
case PushKind.connect:
await _handleConnect(message, foreground: foreground);
await _handleConnect(
message,
foreground: foreground,
notificationsEnabled: notificationsEnabled,
);
break;
case PushKind.nextcloud:
await _handleNextcloud(
data,
foreground: foreground,
openChatToken: openChatToken,
notificationsEnabled: notificationsEnabled,
);
break;
case PushKind.unknown:
@@ -96,7 +105,11 @@ class PushMessageHandler {
Future<void> _handleConnect(
RemoteMessage message, {
required bool foreground,
required bool notificationsEnabled,
}) async {
// Connect pushes carry no silent side effects, so nothing to do when the
// user has notifications off.
if (!notificationsEnabled) return;
// On iOS the alert is delivered natively by the system; only Android needs
// to render the plaintext payload locally.
final data = message.data;
@@ -114,6 +127,7 @@ class PushMessageHandler {
Map<String, dynamic> data, {
required bool foreground,
required String? openChatToken,
required bool notificationsEnabled,
}) async {
final subjectBase64 = data['subject'] as String;
final signatureBase64 = data['signature'] as String;
@@ -153,6 +167,11 @@ class PushMessageHandler {
return;
}
// Notifications turned off: the push was still processed (deletes above,
// plus the foreground badge/provider refresh in NotificationController) —
// only the visible tray notification is suppressed.
if (!notificationsEnabled) return;
await _renderer.render(subject);
}
+19 -13
View File
@@ -350,21 +350,26 @@ class PushRegistration {
}
}
/// Registers this device when push is both user-enabled and backend-capable.
/// Only registers when the OS notification permission is *already* granted —
/// it never triggers the OS prompt itself. Requesting the permission is the
/// job of the first Talk visit (see `maybePromptTalkNotifications`), which
/// keeps the prompt out of the cold-start path. Safe to call on every start —
/// Nextcloud dedups an unchanged registration — which also self-heals a
/// device whose registration was lost.
static Future<void> syncSubscription({
required bool enabled,
required bool capable,
}) async {
if (!(enabled && capable)) return;
/// Registers this device whenever the backend advertises the push capability.
/// Deliberately independent of the in-app notification toggle: a user who
/// turned notifications off stays registered so silent sync pushes keep
/// flowing — the display is suppressed downstream via the mirrored flag (see
/// [PushRegistrationStore.notificationsEnabled]). Only registers when the OS
/// notification permission is *already* granted — it never triggers the OS
/// prompt itself. Requesting the permission is the job of the first Talk visit
/// (see `maybePromptTalkNotifications`), which keeps the prompt out of the
/// cold-start path. Safe to call on every start — Nextcloud dedups an
/// unchanged registration — which also self-heals a device whose registration
/// was lost.
/// Returns whether registration was actually *attempted* (all gates passed).
/// Even a partial success persists the `general` device identifier, so the
/// caller re-emits telemetry on `true` to reflect the fresh registration in
/// the same session instead of lagging until the next launch.
static Future<bool> syncSubscription({required bool capable}) async {
if (!capable) return false;
if (!await isOsPermissionGranted()) {
log('Push: OS notification permission not granted, skipping registration');
return;
return false;
}
final registration = PushRegistration();
// register() below refreshes an unchanged subscription anyway; the check
@@ -373,6 +378,7 @@ class PushRegistration {
log('Push: registered endpoints outdated, re-registering');
}
await registration.register();
return true;
}
/// Re-registers after an FCM token refresh. The Nextcloud device identifier
+18
View File
@@ -27,6 +27,11 @@ class PushRegistrationStore {
// (AccountData writes `nextcloud_app_password` group-scoped).
static const _usernameKey = 'nextcloud_username';
static const _baseUrlKey = 'nextcloud_base_url';
// Mirror of the in-app notification toggle (`notificationSettings.enabled`),
// written group-scoped so the FCM background isolate (no bloc access) and the
// iOS NSE can gate rendering. The device stays *registered* when off so silent
// sync pushes keep flowing — only the visible alert is suppressed.
static const _notificationsEnabledKey = 'push_notifications_enabled';
static const _perTypeKeys = [
_deviceIdentifierKey,
@@ -82,6 +87,19 @@ class PushRegistrationStore {
await _storage.write(key: _baseUrlKey, value: baseUrl);
}
/// Mirrors the in-app notification toggle so the background isolate / iOS NSE
/// can read it without bloc access.
Future<void> setNotificationsEnabled(bool enabled) =>
_storage.write(
key: _notificationsEnabledKey,
value: enabled ? '1' : '0',
);
/// The mirrored notification toggle. Defaults to `true` when unset (fresh
/// install / pre-mirror build) so a missing mirror never silences pushes.
Future<bool> notificationsEnabled() async =>
await _storage.read(key: _notificationsEnabledKey) != '0';
Future<String?> deviceIdentifier(PushRegistrationType type) =>
_storage.read(key: keyFor(_deviceIdentifierKey, type));
+19 -5
View File
@@ -16,6 +16,10 @@ class PushTapRouter {
/// listens to this and opens the chat, then resets it to null.
static final ValueNotifier<String?> pendingChatToken = ValueNotifier(null);
/// Newsletter id of the most recently tapped Marianum-Message notification,
/// or null. [App] listens to this and opens the message, then resets it.
static final ValueNotifier<String?> pendingNewsletterId = ValueNotifier(null);
static void handleResponse(NotificationResponse response) {
final actionId = response.actionId;
if (actionId == kTalkReplyActionId || actionId == kTalkMarkReadActionId) {
@@ -23,18 +27,28 @@ class PushTapRouter {
PushActions.handleBackgroundResponse(response);
return;
}
final token = _chatTokenFrom(response.payload);
final map = _payloadMap(response.payload);
if (map == null) return;
final newsletterId = _stringValue(map, 'newsletterId');
if (newsletterId != null) {
pendingNewsletterId.value = newsletterId;
return;
}
final token = _stringValue(map, 'chatToken');
if (token != null) pendingChatToken.value = token;
}
static String? _chatTokenFrom(String? payload) {
static Map<String, dynamic>? _payloadMap(String? payload) {
if (payload == null || payload.isEmpty) return null;
try {
final map = jsonDecode(payload) as Map<String, dynamic>;
final token = map['chatToken'];
return token is String && token.isNotEmpty ? token : null;
return jsonDecode(payload) as Map<String, dynamic>;
} on Object {
return null;
}
}
static String? _stringValue(Map<String, dynamic> map, String key) {
final value = map[key];
return value is String && value.isNotEmpty ? value : null;
}
}
+49 -2
View File
@@ -36,8 +36,11 @@ import '../view/pages/talk/details/message_reactions.dart';
import '../view/pages/talk/talk_navigator.dart';
import '../view/pages/ticker/ticker_page_view.dart';
import '../view/pages/timetable/custom_events/custom_events_view.dart';
import '../view/pages/timetable/subject_colors/subject_colors_view.dart';
import '../widget/avatar_crop_page.dart';
import '../widget/debug/cache_view.dart';
import '../widget/file_viewer.dart';
import '../widget/large_profile_picture_view.dart';
import '../widget/user_avatar.dart';
/// Single entry point for full-page navigations. Dialogs and bottom sheets
@@ -90,6 +93,34 @@ class AppRoutes {
pushScreen(context, withNavBar: false, screen: const CustomEventsView());
}
static void openSubjectColors(BuildContext context) {
pushScreen(context, withNavBar: false, screen: const SubjectColorsView());
}
/// Opens the full-screen cropper on [imageBytes] and resolves to the cropped
/// bytes (or null if cancelled). [aspectRatio] defaults to 1:1 for avatars;
/// pass null for a free-form crop (e.g. chat backgrounds).
static Future<Uint8List?> openAvatarCrop(
BuildContext context, {
required Uint8List imageBytes,
double? aspectRatio = 1,
}) {
return Navigator.of(context).push<Uint8List>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) =>
AvatarCropPage(imageBytes: imageBytes, aspectRatio: aspectRatio),
),
);
}
/// Opens the tappable, zoomable profile-picture viewer for [id].
static void openLargeProfilePicture(BuildContext context, String id) {
Navigator.of(context).push(
MaterialPageRoute<void>(builder: (_) => LargeProfilePictureView(id: id)),
);
}
/// Opens the picker for choosing a foreign timetable element and resolves to
/// the selected element (or null if dismissed). The timetable view renders
/// the chosen plan inline. Gated behind the `viewForeignTimetables`
@@ -131,13 +162,29 @@ class AppRoutes {
static void openMarianumMessage(
BuildContext context,
String basePath,
MarianumMessage message,
) {
pushScreen(
context,
withNavBar: false,
screen: MessageView(basePath: basePath, message: message),
screen: MessageView(id: message.id, title: message.name),
);
}
/// Opens a Marianum Message by id — used for push deep links where only the
/// id (and the notification title) are known.
static void openNewsletterById(
BuildContext context, {
required String id,
String? title,
}) {
pushScreen(
context,
withNavBar: false,
screen: MessageView(
id: id,
title: title == null || title.isEmpty ? 'Marianum Message' : title,
),
);
}
@@ -1,10 +0,0 @@
import 'package:dio/dio.dart';
import '../../infrastructure/data_loader/data_loader.dart';
abstract class MhslDataLoader<TResult> extends DataLoader<TResult> {
MhslDataLoader()
: super(
Dio(BaseOptions(baseUrl: 'https://mhsl.eu/marianum/marianummobile/')),
);
}
@@ -27,8 +27,12 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
}
});
void emitConnectivity(List<ConnectivityResult> result) =>
add(ConnectivityChanged(LoadableStateState(connections: result)));
void emitConnectivity(List<ConnectivityResult> result) {
// The initial checkConnectivity() future is not cancellable and may
// resolve after the bloc was disposed, so guard against a closed sink.
if (isClosed) return;
add(ConnectivityChanged(LoadableStateState(connections: result)));
}
Connectivity().checkConnectivity().then(emitConnectivity);
_updateStream = Connectivity().onConnectivityChanged.listen(
@@ -48,10 +52,10 @@ class LoadableStateBloc extends Bloc<LoadableStateEvent, LoadableStateState>
// Re-check connectivity so the resulting [ConnectivityChanged] handler
// clears a stale error bar and triggers [reFetch] once reachable again.
unawaited(
Connectivity().checkConnectivity().then(
(result) =>
add(ConnectivityChanged(LoadableStateState(connections: result))),
),
Connectivity().checkConnectivity().then((result) {
if (isClosed) return;
add(ConnectivityChanged(LoadableStateState(connections: result)));
}),
);
}
+2 -2
View File
@@ -54,8 +54,8 @@ class AppModule {
Modules.ticker,
name: 'Ticker',
// Icons.newspaper is already taken by the "Marianum Message" module;
// use feed to keep the two visually distinct.
icon: () => Icon(Icons.feed),
// use campaign to keep the two visually distinct.
icon: () => Icon(Icons.campaign),
breakerArea: BreakerArea.ticker,
create: TickerView.new,
),
@@ -100,9 +100,12 @@ class ChatListBloc
}
}
Future<void> createDirectChat(String invite) async {
await repo.data.createDirectRoom(invite);
/// Creates (or resolves) a 1:1 chat and returns its room token, or null in
/// demo mode. Refreshes the list so the room shows up.
Future<String?> createDirectChat(String invite) async {
final token = await repo.data.createDirectRoom(invite);
await refresh();
return token;
}
int? lastReadMessageFor(String token) {
@@ -20,8 +20,13 @@ class ChatListDataProvider {
);
}
Future<void> createDirectRoom(String invite) {
if (DemoMode.active) return Future.value();
return CreateRoom(CreateRoomParams(roomType: 1, invite: invite)).run();
/// Returns the token of the created (or already existing) 1:1 room, or null
/// in demo mode where no room is actually created.
Future<String?> createDirectRoom(String invite) async {
if (DemoMode.active) return null;
final response = await CreateRoom(
CreateRoomParams(roomType: 1, invite: invite),
).run();
return response.data.token;
}
}
@@ -1,7 +1,5 @@
import 'dart:developer';
import 'package:intl/intl.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../../../api/marianumconnect/queries/timetable_get_week/timetable_get_week_response.dart';
import '../../../../../extensions/date_time.dart';
@@ -24,7 +22,6 @@ class ForeignTimetableBloc
TimetableState,
ForeignTimetableRepository
> {
static final DateFormat _weekKeyFormat = DateFormat('yyyyMMdd');
final TimetableElementType type;
// Named `elementId` rather than `id` to avoid shadowing HydratedMixin's
@@ -183,7 +180,7 @@ class ForeignTimetableBloc
}
void _writeWeekToCache(DateTime weekStart, TimetableGetWeekResponse week) {
final key = _weekKeyFormat.format(weekStart);
final key = weekStart.weekKey();
add(
Emit((s) {
final updated = Map<String, TimetableGetWeekResponse>.of(s.weekCache);
@@ -27,8 +27,10 @@ abstract class MarianumMessageList with _$MarianumMessageList {
@freezed
abstract class MarianumMessage with _$MarianumMessage {
const factory MarianumMessage({
@Default('') String id,
required String name,
required String date,
@Default('') String description,
required String url,
}) = _MarianumMessage;
@@ -568,7 +568,7 @@ as List<MarianumMessage>,
/// @nodoc
mixin _$MarianumMessage {
String get name; String get date; String get url;
String get id; String get name; String get date; String get description; String get url;
/// Create a copy of MarianumMessage
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -581,16 +581,16 @@ $MarianumMessageCopyWith<MarianumMessage> get copyWith => _$MarianumMessageCopyW
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is MarianumMessage&&(identical(other.name, name) || other.name == name)&&(identical(other.date, date) || other.date == date)&&(identical(other.url, url) || other.url == url));
return identical(this, other) || (other.runtimeType == runtimeType&&other is MarianumMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.date, date) || other.date == date)&&(identical(other.description, description) || other.description == description)&&(identical(other.url, url) || other.url == url));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,date,url);
int get hashCode => Object.hash(runtimeType,id,name,date,description,url);
@override
String toString() {
return 'MarianumMessage(name: $name, date: $date, url: $url)';
return 'MarianumMessage(id: $id, name: $name, date: $date, description: $description, url: $url)';
}
@@ -601,7 +601,7 @@ abstract mixin class $MarianumMessageCopyWith<$Res> {
factory $MarianumMessageCopyWith(MarianumMessage value, $Res Function(MarianumMessage) _then) = _$MarianumMessageCopyWithImpl;
@useResult
$Res call({
String name, String date, String url
String id, String name, String date, String description, String url
});
@@ -618,10 +618,12 @@ class _$MarianumMessageCopyWithImpl<$Res>
/// Create a copy of MarianumMessage
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? name = null,Object? date = null,Object? url = null,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? name = null,Object? date = null,Object? description = null,Object? url = null,}) {
return _then(_self.copyWith(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,
));
@@ -708,10 +710,10 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String name, String date, String url)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String name, String date, String description, String url)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _MarianumMessage() when $default != null:
return $default(_that.name,_that.date,_that.url);case _:
return $default(_that.id,_that.name,_that.date,_that.description,_that.url);case _:
return orElse();
}
@@ -729,10 +731,10 @@ return $default(_that.name,_that.date,_that.url);case _:
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String name, String date, String url) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String name, String date, String description, String url) $default,) {final _that = this;
switch (_that) {
case _MarianumMessage():
return $default(_that.name,_that.date,_that.url);case _:
return $default(_that.id,_that.name,_that.date,_that.description,_that.url);case _:
throw StateError('Unexpected subclass');
}
@@ -749,10 +751,10 @@ return $default(_that.name,_that.date,_that.url);case _:
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String name, String date, String url)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String name, String date, String description, String url)? $default,) {final _that = this;
switch (_that) {
case _MarianumMessage() when $default != null:
return $default(_that.name,_that.date,_that.url);case _:
return $default(_that.id,_that.name,_that.date,_that.description,_that.url);case _:
return null;
}
@@ -764,11 +766,13 @@ return $default(_that.name,_that.date,_that.url);case _:
@JsonSerializable()
class _MarianumMessage implements MarianumMessage {
const _MarianumMessage({required this.name, required this.date, required this.url});
const _MarianumMessage({this.id = '', required this.name, required this.date, this.description = '', required this.url});
factory _MarianumMessage.fromJson(Map<String, dynamic> json) => _$MarianumMessageFromJson(json);
@override@JsonKey() final String id;
@override final String name;
@override final String date;
@override@JsonKey() final String description;
@override final String url;
/// Create a copy of MarianumMessage
@@ -784,16 +788,16 @@ Map<String, dynamic> toJson() {
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MarianumMessage&&(identical(other.name, name) || other.name == name)&&(identical(other.date, date) || other.date == date)&&(identical(other.url, url) || other.url == url));
return identical(this, other) || (other.runtimeType == runtimeType&&other is _MarianumMessage&&(identical(other.id, id) || other.id == id)&&(identical(other.name, name) || other.name == name)&&(identical(other.date, date) || other.date == date)&&(identical(other.description, description) || other.description == description)&&(identical(other.url, url) || other.url == url));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,name,date,url);
int get hashCode => Object.hash(runtimeType,id,name,date,description,url);
@override
String toString() {
return 'MarianumMessage(name: $name, date: $date, url: $url)';
return 'MarianumMessage(id: $id, name: $name, date: $date, description: $description, url: $url)';
}
@@ -804,7 +808,7 @@ abstract mixin class _$MarianumMessageCopyWith<$Res> implements $MarianumMessage
factory _$MarianumMessageCopyWith(_MarianumMessage value, $Res Function(_MarianumMessage) _then) = __$MarianumMessageCopyWithImpl;
@override @useResult
$Res call({
String name, String date, String url
String id, String name, String date, String description, String url
});
@@ -821,10 +825,12 @@ class __$MarianumMessageCopyWithImpl<$Res>
/// Create a copy of MarianumMessage
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? name = null,Object? date = null,Object? url = null,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? name = null,Object? date = null,Object? description = null,Object? url = null,}) {
return _then(_MarianumMessage(
name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,name: null == name ? _self.name : name // ignore: cast_nullable_to_non_nullable
as String,date: null == date ? _self.date : date // ignore: cast_nullable_to_non_nullable
as String,description: null == description ? _self.description : description // ignore: cast_nullable_to_non_nullable
as String,url: null == url ? _self.url : url // ignore: cast_nullable_to_non_nullable
as String,
));
@@ -32,14 +32,18 @@ Map<String, dynamic> _$MarianumMessageListToJson(
_MarianumMessage _$MarianumMessageFromJson(Map<String, dynamic> json) =>
_MarianumMessage(
id: json['id'] as String? ?? '',
name: json['name'] as String,
date: json['date'] as String,
description: json['description'] as String? ?? '',
url: json['url'] as String,
);
Map<String, dynamic> _$MarianumMessageToJson(_MarianumMessage instance) =>
<String, dynamic>{
'id': instance.id,
'name': instance.name,
'date': instance.date,
'description': instance.description,
'url': instance.url,
};
@@ -1,13 +1,53 @@
import 'package:dio/dio.dart';
import 'package:intl/intl.dart';
import '../../../basis/dataloader/mhsl_data_loader.dart';
import '../../../infrastructure/data_loader/data_loader.dart';
import '../../../../../api/marianumconnect/errors/marianumconnect_error.dart';
import '../../../../../api/marianumconnect/marianumconnect_api.dart';
import '../../../../../api/marianumconnect/marianumconnect_endpoint.dart';
import '../bloc/marianum_message_state.dart';
class MarianumMessageGetMessages extends MhslDataLoader<MarianumMessageList> {
@override
Future<Response<String>> fetch() async => dio.get('/message/messages.json');
@override
MarianumMessageList assemble(DataLoaderResult data) =>
MarianumMessageList.fromJson(data.asMap());
/// Loads the "Marianum Message" list from `GET /api/mobile/v1/newsletter`
/// (formerly the mhsl.eu `message/messages.json` endpoint). Bearer token is
/// attached by the shared Marianum-Connect dio.
class MarianumMessageGetMessages {
final Dio _dio;
MarianumMessageGetMessages({Dio? dio}) : _dio = dio ?? MarianumConnectApi.dio();
Future<MarianumMessageList> run() async {
try {
final response = await _dio.get<List<dynamic>>(
MarianumConnectEndpoint.resolve('newsletter'),
);
final messages = (response.data ?? const [])
.cast<Map<String, dynamic>>()
.map(_mapItem)
.toList();
return MarianumMessageList(
base: MarianumConnectEndpoint.current(),
messages: messages,
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
MarianumMessage _mapItem(Map<String, dynamic> map) => MarianumMessage(
id: map['id'] as String,
name: map['title'] as String? ?? '',
date: _formatDate(map['date'] as String?),
description: map['description'] as String? ?? '',
url: map['fileUrl'] as String? ?? '',
);
static final DateFormat _monthYear = DateFormat.yMMMM('de');
/// Server sends ISO `yyyy-MM-dd`; the list shows only the German month + year
/// (e.g. "April 2024").
static String _formatDate(String? iso) {
if (iso == null || iso.isEmpty) return '';
final parsed = DateTime.tryParse(iso);
if (parsed == null) return iso;
return _monthYear.format(parsed);
}
}
@@ -0,0 +1,60 @@
import '../../../../../api/errors/ticker_content_unavailable_exception.dart';
import '../../../../../api/marianumconnect/queries/get_ticker_page/get_ticker_page_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_page_repository.dart';
import 'ticker_page_event.dart';
import 'ticker_page_state.dart';
/// Per-slug loadable bloc for a single ticker page; [id] is the slug so each
/// page keeps its own hydrated cache entry.
class TickerPageBloc
extends
LoadableHydratedBloc<
TickerPageEvent,
TickerPageState,
TickerPageRepository
> {
final String slug;
TickerPageBloc(this.slug);
@override
String get id => slug;
@override
Future<void> gatherData() async {
try {
final page = await repo.getPage(slug);
add(DataGathered((state) => state.copyWith(page: page)));
} on TickerContentUnavailableException catch (e) {
// Content, not error: a content-less page keeps the "open in browser"
// branch and stays cached offline.
add(
DataGathered(
(state) => state.copyWith(
page: TickerPageResponse(
schemaVersion: 1,
slug: slug,
kind: TickerPageKind.content,
webUrl: e.webUrl,
),
),
),
);
}
}
@override
TickerPageRepository repository() => TickerPageRepository();
@override
TickerPageState fromNothing() => const TickerPageState();
@override
TickerPageState fromStorage(Map<String, dynamic> json) =>
TickerPageState.fromJson(json);
@override
Map<String, dynamic>? toStorage(TickerPageState state) => state.toJson();
}
@@ -0,0 +1,6 @@
import '../../../infrastructure/utility_widgets/loadable_hydrated_bloc/loadable_hydrated_bloc_event.dart';
import 'ticker_page_state.dart';
sealed class TickerPageEvent extends LoadableHydratedBlocEvent<TickerPageState> {}
class TickerPageLoadEvent extends TickerPageEvent {}

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