10 Commits

Author SHA1 Message Date
MineTec baa26a6e79 implemented a comprehensive Nextcloud file sharing system with support for user, group, and public link shares with gating based on server-side permissions; added sharing management interfaces including a share sheet; updated the file list with visual badges for incoming shares and improved OCS API response handling. 2026-06-02 21:42:08 +02:00
MineTec b6d06dd3b4 implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable TimetableCalendarView component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions. 2026-05-31 21:29:16 +02:00
MineTec 6e12da08c0 implemented a customizable chat background system with support for patterns, solid colors, and gallery images; added a dedicated settings page with live preview and adjustable blur/dim effects, updated the image cropper to support flexible aspect ratios for wallpapers, and integrated file cleanup logic during account logout. 2026-05-31 19:20:18 +02:00
MineTec 5ebf5bccdb implemented avatar management for user profiles and chat rooms, including 1:1 cropping, integrated OCS and Spreed avatar APIs, added cache invalidation logic, and updated the account settings view to display user info and profile pictures. 2026-05-31 18:42:30 +02:00
MineTec f966cf302b implemented favorite and leave actions for chat/rooms info view 2026-05-30 14:05:00 +02:00
MineTec 582432dbb9 implemented support for viewing large group profile pictures 2026-05-30 13:57:26 +02:00
MineTec ece0669f7d implemented a central haptic feedback system with configurable levels (off, reduced, full), added a Haptics facade providing semantic feedback methods, integrated haptic cues across navigation, settings toggles, and async action results, and updated version to 1.1.0+54 2026-05-30 13:54:19 +02:00
MineTec 01b4b44010 migrated holidays module to MarianumConnect API, replaced local Holiday model. 2026-05-24 17:49:25 +02:00
MineTec 93b9929f8f migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic. 2026-05-23 17:32:42 +02:00
MineTec 2858f910c9 implemented DST-safe date arithmetic with new addDays and subtractDays extensions, updated timetable state to reset view and scroll boundaries on initialization to prevent stale views, added hard caps to calendar navigation, and updated version to 1.0.3+52 2026-05-22 15:08:30 +02:00
291 changed files with 9711 additions and 12904 deletions
@@ -4,4 +4,10 @@
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
<!-- Allow cleartext HTTP in debug builds so developers can point the
Marianum-Connect custom endpoint at a local backend (e.g.
http://10.0.2.2:8080 from the Android emulator). Release builds
keep the default cleartext block. -->
<application android:usesCleartextTraffic="true" />
</manifest>
+1 -5
View File
@@ -4,8 +4,7 @@
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:fullBackupContent="@xml/backup_rules"
android:dataExtractionRules="@xml/data_extraction_rules"
android:networkSecurityConfig="@xml/network_security_config">
android:dataExtractionRules="@xml/data_extraction_rules">
<activity
android:name=".MainActivity"
android:exported="true"
@@ -107,7 +106,4 @@
<!-- Workmanager periodic widget refresh needs to reschedule after device
reboot, otherwise the widget freezes until the user opens the app. -->
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- RMV "in meiner Nähe"-Suche. Coarse reicht (RMV-Suchradius >= 500 m). -->
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
</manifest>
@@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Allow cleartext for the MarianumConnect test instance only. Once the
production URL with HTTPS is live, drop this domain-config entry. -->
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">muelleel.ddns.net</domain>
</domain-config>
</network-security-config>
-15
View File
@@ -43,21 +43,6 @@
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
<key>NSLocationWhenInUseUsageDescription</key>
<string>Um Haltestellen in deiner Nähe im RMV-Fahrplan zu finden, wird dein aktueller Standort benötigt.</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSExceptionDomains</key>
<dict>
<key>muelleel.ddns.net</key>
<dict>
<key>NSExceptionAllowsInsecureHTTPLoads</key>
<true/>
<key>NSIncludesSubdomains</key>
<true/>
</dict>
</dict>
</dict>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
-24
View File
@@ -1,24 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import 'login_request.dart';
import 'login_response.dart';
class Login extends ConnectApi<LoginResponse> {
final LoginRequest payload;
Login(this.payload) : super('auth/login');
@override
bool get requiresAuth => false;
@override
ConnectHttpMethod get method => ConnectHttpMethod.post;
@override
Object? get body => payload.toJson();
@override
LoginResponse assemble(String raw) =>
LoginResponse.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -1,18 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
part 'login_request.g.dart';
@JsonSerializable()
class LoginRequest {
final String username;
final String password;
final String tokenName;
LoginRequest({
required this.username,
required this.password,
required this.tokenName,
});
Map<String, dynamic> toJson() => _$LoginRequestToJson(this);
}
@@ -1,20 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'login_request.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LoginRequest _$LoginRequestFromJson(Map<String, dynamic> json) => LoginRequest(
username: json['username'] as String,
password: json['password'] as String,
tokenName: json['tokenName'] as String,
);
Map<String, dynamic> _$LoginRequestToJson(LoginRequest instance) =>
<String, dynamic>{
'username': instance.username,
'password': instance.password,
'tokenName': instance.tokenName,
};
@@ -1,65 +0,0 @@
/// Hand-rolled to be tolerant of the actual server payload: only [token] is
/// load-bearing. `expiresAt` may be `null` (server-issued tokens without an
/// explicit expiry); every other field shape is also tolerated so a stray
/// rename on the backend does not break login for everyone.
class LoginResponse {
final String token;
final String? tokenId;
/// `null` when the backend did not provide an expiry. In that case the
/// token is treated as long-lived; callers should refresh on 401.
final DateTime? expiresAt;
final ConnectUserDto? user;
LoginResponse({
required this.token,
required this.tokenId,
required this.expiresAt,
required this.user,
});
factory LoginResponse.fromJson(Map<String, dynamic> json) {
final token = json['token'];
if (token is! String || token.isEmpty) {
throw const FormatException('login response missing "token" string');
}
final expiresRaw = json['expiresAt'];
final expires = expiresRaw is String ? DateTime.tryParse(expiresRaw) : null;
final userJson = json['user'];
return LoginResponse(
token: token,
tokenId: json['tokenId']?.toString(),
expiresAt: expires,
user: userJson is Map<String, dynamic>
? ConnectUserDto.fromJson(userJson)
: null,
);
}
}
class ConnectUserDto {
final String? id;
final String? username;
final String? firstName;
final String? lastName;
final String? userType;
final String? className;
ConnectUserDto({
this.id,
this.username,
this.firstName,
this.lastName,
this.userType,
this.className,
});
factory ConnectUserDto.fromJson(Map<String, dynamic> json) => ConnectUserDto(
id: json['id']?.toString(),
username: json['username']?.toString(),
firstName: json['firstName']?.toString(),
lastName: json['lastName']?.toString(),
userType: json['userType']?.toString(),
className: json['className']?.toString(),
);
}
-179
View File
@@ -1,179 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../api_request.dart';
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import 'connect_auth_store.dart';
import 'connect_endpoint.dart';
import 'errors/connect_exception.dart';
import 'errors/rmv_rate_limited_exception.dart';
import 'errors/rmv_upstream_exception.dart';
enum ConnectHttpMethod { get, post }
/// Mirrors the [MhslApi] pattern: each endpoint subclasses this, declares the
/// subpath/query/body, and implements [assemble]. Handles bearer-token
/// injection (via [ConnectAuthStore]), one transparent 401-retry after a
/// fresh login, and turns the structured `RmvController.wrap` error strings
/// into typed exceptions.
abstract class ConnectApi<T> extends ApiRequest {
final String subpath;
ConnectApi(this.subpath);
/// Override to `false` for endpoints that must NOT receive a bearer token
/// (currently only login itself, to avoid an infinite refresh loop).
bool get requiresAuth => true;
ConnectHttpMethod get method => ConnectHttpMethod.get;
Map<String, String>? get queryParameters => null;
/// Returns the body to send for POST requests. Should be JSON-encodable.
Object? get body => null;
T assemble(String raw);
Future<T> run() async {
final response = await _runOnce(forceTokenRefresh: false);
if (response.statusCode == 401 && requiresAuth) {
// Single transparent retry after a forced refresh, then bail.
await ConnectAuthStore.instance.invalidate();
final retry = await _runOnce(forceTokenRefresh: true);
if (retry.statusCode == 401) {
throw ConnectException.authFailed(
technicalDetails:
'connect $subpath HTTP 401 after token refresh: ${_safeBody(retry)}',
);
}
return _handleResponse(retry);
}
return _handleResponse(response);
}
Future<http.Response> _runOnce({required bool forceTokenRefresh}) async {
final uri = ConnectEndpoint.resolve(subpath).replace(
queryParameters: _normaliseQuery(queryParameters),
);
final headers = <String, String>{
if (method == ConnectHttpMethod.post) 'Content-Type': 'application/json',
'Accept': 'application/json',
};
if (requiresAuth) {
final token = await ConnectAuthStore.instance.getToken(
forceRefresh: forceTokenRefresh,
);
headers['Authorization'] = 'Bearer $token';
}
try {
switch (method) {
case ConnectHttpMethod.get:
return await http.get(uri, headers: headers);
case ConnectHttpMethod.post:
final payload = body;
return await http.post(
uri,
headers: headers,
body: payload == null ? null : jsonEncode(payload),
);
}
} on SocketException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on TimeoutException catch (e) {
throw NetworkException.timeout(
technicalDetails: 'connect $subpath: $e',
);
} on http.ClientException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath: ${e.message}',
);
} on HandshakeException catch (e) {
throw NetworkException(
technicalDetails: 'connect $subpath TLS: ${e.message}',
);
}
}
T _handleResponse(http.Response response) {
final status = response.statusCode;
final bodyText = _safeBody(response);
if (status == 503) {
final retryAfter = _parseRetryAfter(bodyText);
throw RmvRateLimitedException(
retryAfter: retryAfter,
technicalDetails: 'connect $subpath HTTP 503: $bodyText',
);
}
if (status == 502) {
final code = _parseUpstreamErrorCode(bodyText);
throw RmvUpstreamException(
errorCode: code,
technicalDetails: 'connect $subpath HTTP 502: $bodyText',
);
}
if (status > 299) {
throw ServerException(
statusCode: status,
technicalDetails: 'connect $subpath HTTP $status: $bodyText',
);
}
try {
return assemble(bodyText);
} catch (e, st) {
final preview = bodyText.length > 1024
? '${bodyText.substring(0, 1024)}'
: bodyText;
log(
'connect $subpath assemble failed: $e\nbody: $preview',
stackTrace: st,
);
throw ParseException(
technicalDetails: 'connect $subpath assemble: $e',
);
}
}
String _safeBody(http.Response response) {
try {
return utf8.decode(response.bodyBytes);
} catch (_) {
return response.body;
}
}
/// Body format from `RmvController.wrap`: `upstream_rate_limited|retryAfter=60`.
Duration _parseRetryAfter(String body) {
final match = RegExp(r'retryAfter=(\d+)').firstMatch(body);
final seconds = match == null ? 60 : int.tryParse(match.group(1)!) ?? 60;
return Duration(seconds: seconds);
}
/// Body format: `upstream_error|H390` — the segment after the pipe is the
/// RMV/HaFAS error code.
String? _parseUpstreamErrorCode(String body) {
final idx = body.indexOf('|');
if (idx < 0 || idx >= body.length - 1) return null;
return body.substring(idx + 1).trim();
}
Map<String, String>? _normaliseQuery(Map<String, String>? raw) {
if (raw == null) return null;
final cleaned = <String, String>{};
raw.forEach((key, value) {
if (value.isNotEmpty) cleaned[key] = value;
});
return cleaned.isEmpty ? null : cleaned;
}
}
-109
View File
@@ -1,109 +0,0 @@
import 'dart:async';
import 'dart:developer';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../../model/account_data.dart';
import 'auth/login/login.dart';
import 'auth/login/login_request.dart';
import 'auth/login/login_response.dart';
import 'errors/connect_exception.dart';
/// Holds the Bearer token issued by `POST /auth/login` so that subsequent
/// RMV calls can attach it without prompting the user. Uses the LDAP
/// credentials already kept in [AccountData], so this is transparent to the
/// user — no extra login UI.
class ConnectAuthStore {
static const _tokenKey = 'connect_bearer_token';
static const _expiresAtKey = 'connect_token_expires_at';
static const _tokenName = 'MarianumMobile App';
static const _expiryGuard = Duration(minutes: 1);
static final ConnectAuthStore _instance = ConnectAuthStore._();
factory ConnectAuthStore() => _instance;
static ConnectAuthStore get instance => _instance;
ConnectAuthStore._();
final FlutterSecureStorage _storage = const FlutterSecureStorage();
String? _token;
DateTime? _expiresAt;
bool _hydrated = false;
Future<String>? _inflightLogin;
Future<void> _hydrate() async {
if (_hydrated) return;
_token = await _storage.read(key: _tokenKey);
final rawExp = await _storage.read(key: _expiresAtKey);
_expiresAt = rawExp == null ? null : DateTime.tryParse(rawExp);
_hydrated = true;
}
bool _isUsable() {
if (_token == null || _token!.isEmpty) return false;
final exp = _expiresAt;
if (exp == null) return true;
return DateTime.now().add(_expiryGuard).isBefore(exp);
}
/// Returns a usable bearer token, logging in if necessary. Concurrent
/// callers share the same in-flight login future so a single 401 doesn't
/// trigger N parallel logins.
Future<String> getToken({bool forceRefresh = false}) async {
await _hydrate();
if (!forceRefresh && _isUsable()) return _token!;
return _inflightLogin ??= _login().whenComplete(() {
_inflightLogin = null;
});
}
Future<String> _login() async {
if (!AccountData().isPopulated()) {
throw ConnectException.notAuthenticated();
}
final username = AccountData().getUsername();
final password = AccountData().getPassword();
final LoginResponse response;
try {
response = await Login(
LoginRequest(
username: username,
password: password,
tokenName: _tokenName,
),
).run();
} on ConnectException {
rethrow;
} catch (e, st) {
log('connect login threw: $e', stackTrace: st);
throw ConnectException(
userMessage:
'Anmeldung am Connect-Server fehlgeschlagen. Bitte später erneut versuchen.',
technicalDetails: 'connect login failed: $e',
);
}
_token = response.token;
_expiresAt = response.expiresAt;
await _storage.write(key: _tokenKey, value: response.token);
if (response.expiresAt != null) {
await _storage.write(
key: _expiresAtKey,
value: response.expiresAt!.toIso8601String(),
);
} else {
await _storage.delete(key: _expiresAtKey);
}
return response.token;
}
Future<void> invalidate() async {
_token = null;
_expiresAt = null;
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _expiresAtKey);
}
/// Same as [invalidate] — separate method to make logout call-sites read
/// clearly.
Future<void> clear() => invalidate();
}
-12
View File
@@ -1,12 +0,0 @@
/// Base URL for the MarianumConnect backend. Hardcoded against the test
/// instance for now; once the production URL is finalised this should move
/// into `EndpointData` alongside webuntis/nextcloud.
class ConnectEndpoint {
ConnectEndpoint._();
static const String _baseUrl = 'http://muelleel.ddns.net:8080';
static const String _apiPrefix = '/api/mobile/v1';
static Uri resolve(String subpath) =>
Uri.parse('$_baseUrl$_apiPrefix/${subpath.startsWith('/') ? subpath.substring(1) : subpath}');
}
@@ -1,25 +0,0 @@
import '../../errors/app_exception.dart';
class ConnectException extends AppException {
const ConnectException({
super.userMessage =
'Verbindung zum Marianum-Connect-Server fehlgeschlagen.',
super.technicalDetails,
super.allowRetry,
});
factory ConnectException.authFailed({String? technicalDetails}) =>
ConnectException(
userMessage:
'Anmeldung am Connect-Server fehlgeschlagen. Bitte prüfe deine Anmeldedaten.',
technicalDetails: technicalDetails,
allowRetry: false,
);
factory ConnectException.notAuthenticated() => const ConnectException(
userMessage:
'Für diese Funktion ist eine Anmeldung am Connect-Server nötig.',
technicalDetails: 'AccountData missing while trying to log in to connect',
allowRetry: false,
);
}
@@ -1,12 +0,0 @@
import '../../errors/app_exception.dart';
class RmvRateLimitedException extends AppException {
final Duration retryAfter;
RmvRateLimitedException({required this.retryAfter, super.technicalDetails})
: super(
userMessage:
'Die Fahrplanauskunft ist gerade überlastet. Bitte in ${retryAfter.inSeconds} Sekunden erneut versuchen.',
allowRetry: true,
);
}
@@ -1,28 +0,0 @@
import '../../errors/app_exception.dart';
class RmvUpstreamException extends AppException {
final String? errorCode;
RmvUpstreamException({required this.errorCode, super.technicalDetails})
: super(userMessage: _mapMessage(errorCode), allowRetry: true);
static String _mapMessage(String? code) {
switch (code) {
case 'H390':
return 'Keine Verbindung gefunden.';
case 'H891':
return 'Eine der angegebenen Stationen ist ungültig.';
case 'H895':
return 'Start- und Zielhaltestelle sind identisch.';
case 'H900':
case 'H892':
return 'Die Fahrplanauskunft ist gerade nicht verfügbar.';
case 'H910':
return 'Die angegebene Zeit liegt außerhalb des Fahrplans.';
case null:
return 'Die Fahrplanauskunft konnte keine Antwort liefern.';
default:
return 'Die Fahrplanauskunft hat einen Fehler gemeldet ($code).';
}
}
}
-36
View File
@@ -1,36 +0,0 @@
/// ISO-8601 duration (`PT1H30M5S`) ↔ Dart `Duration`. Backend serialises
/// `java.time.Duration` in this format; Dart has no builtin parser.
class IsoDuration {
IsoDuration._();
static final RegExp _pattern = RegExp(
r'^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+(?:\.\d+)?)S)?)?$',
);
static Duration? fromJson(String? iso) {
if (iso == null || iso.isEmpty) return null;
final match = _pattern.firstMatch(iso);
if (match == null) return null;
final hours = int.parse(match.group(1) ?? '0');
final minutes = int.parse(match.group(2) ?? '0');
final secondsRaw = match.group(3) ?? '0';
final secondsValue = double.parse(secondsRaw);
return Duration(
hours: hours,
minutes: minutes,
milliseconds: (secondsValue * 1000).round(),
);
}
static String? toJson(Duration? d) {
if (d == null) return null;
final hours = d.inHours;
final minutes = d.inMinutes.remainder(60);
final seconds = d.inSeconds.remainder(60);
final buf = StringBuffer('PT');
if (hours > 0) buf.write('${hours}H');
if (minutes > 0) buf.write('${minutes}M');
if (seconds > 0 || (hours == 0 && minutes == 0)) buf.write('${seconds}S');
return buf.toString();
}
}
@@ -1,13 +0,0 @@
/// Formats a [DateTime] as `2026-05-19T14:30:00` for Java's
/// `LocalDateTime` parser (no timezone, no millis).
String formatLocalDateTime(DateTime dt) {
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}T'
'${two(dt.hour)}:${two(dt.minute)}:${two(dt.second)}';
}
/// Formats a [DateTime] as `2026-05-19` for Java's `LocalDate` parser.
String formatLocalDate(DateTime dt) {
String two(int v) => v.toString().padLeft(2, '0');
return '${dt.year}-${two(dt.month)}-${two(dt.day)}';
}
@@ -1,32 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetArrivals extends ConnectApi<List<Arrival>> {
final String stopId;
final DateTime? when;
final int durationMinutes;
final int maxJourneys;
GetArrivals({
required this.stopId,
this.when,
this.durationMinutes = 60,
this.maxJourneys = -1,
}) : super('rmv/arrivals');
@override
Map<String, String>? get queryParameters => {
'stopId': stopId,
if (when != null) 'when': formatLocalDateTime(when!),
'duration': durationMinutes.toString(),
'max': maxJourneys.toString(),
};
@override
List<Arrival> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => Arrival.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -1,32 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetDepartures extends ConnectApi<List<Departure>> {
final String stopId;
final DateTime? when;
final int durationMinutes;
final int maxJourneys;
GetDepartures({
required this.stopId,
this.when,
this.durationMinutes = 60,
this.maxJourneys = -1,
}) : super('rmv/departures');
@override
Map<String, String>? get queryParameters => {
'stopId': stopId,
if (when != null) 'when': formatLocalDateTime(when!),
'duration': durationMinutes.toString(),
'max': maxJourneys.toString(),
};
@override
List<Departure> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => Departure.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -1,20 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetDisruptions extends ConnectApi<List<HimMessage>> {
final DateTime? when;
GetDisruptions({this.when}) : super('rmv/disruptions');
@override
Map<String, String>? get queryParameters =>
when == null ? null : {'when': formatLocalDateTime(when!)};
@override
List<HimMessage> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => HimMessage.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -1,23 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class GetJourneyDetail extends ConnectApi<JourneyDetail> {
final String journeyRef;
final DateTime? date;
GetJourneyDetail({required this.journeyRef, this.date})
: super('rmv/journey');
@override
Map<String, String>? get queryParameters => {
'ref': journeyRef,
if (date != null) 'date': formatLocalDate(date!),
};
@override
JourneyDetail assemble(String raw) =>
JourneyDetail.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -1,17 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class MoreTrips extends ConnectApi<TripSearchResult> {
final String ctx;
MoreTrips({required this.ctx}) : super('rmv/trips/more');
@override
Map<String, String>? get queryParameters => {'ctx': ctx};
@override
TripSearchResult assemble(String raw) =>
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
@@ -1,31 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class NearbyStops extends ConnectApi<List<StopLocation>> {
final double lat;
final double lon;
final int radiusMeters;
final int max;
NearbyStops({
required this.lat,
required this.lon,
this.radiusMeters = 1000,
this.max = 20,
}) : super('rmv/stops/nearby');
@override
Map<String, String>? get queryParameters => {
'lat': lat.toString(),
'lon': lon.toString(),
'radius': radiusMeters.toString(),
'max': max.toString(),
};
@override
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -1,22 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
class SearchStops extends ConnectApi<List<StopLocation>> {
final String query;
final int max;
SearchStops({required this.query, this.max = 10}) : super('rmv/stops');
@override
Map<String, String>? get queryParameters => {
'q': query,
'max': max.toString(),
};
@override
List<StopLocation> assemble(String raw) => (jsonDecode(raw) as List)
.map((e) => StopLocation.fromJson(e as Map<String, dynamic>))
.toList(growable: false);
}
@@ -1,31 +0,0 @@
import 'dart:convert';
import '../../connect_api.dart';
import '../rmv_models.dart';
import '_query_format.dart';
class SearchTrips extends ConnectApi<TripSearchResult> {
final String fromStopId;
final String toStopId;
final DateTime? when;
final bool searchByArrival;
SearchTrips({
required this.fromStopId,
required this.toStopId,
this.when,
this.searchByArrival = false,
}) : super('rmv/trips');
@override
Map<String, String>? get queryParameters => {
'from': fromStopId,
'to': toStopId,
if (when != null) 'when': formatLocalDateTime(when!),
'searchByArrival': searchByArrival.toString(),
};
@override
TripSearchResult assemble(String raw) =>
TripSearchResult.fromJson(jsonDecode(raw) as Map<String, dynamic>);
}
-243
View File
@@ -1,243 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
import 'iso_duration.dart';
part 'rmv_models.freezed.dart';
part 'rmv_models.g.dart';
@freezed
abstract class Product with _$Product {
const factory Product({
String? name,
String? line,
String? displayNumber,
String? category,
String? categoryCode,
String? operator,
}) = _Product;
factory Product.fromJson(Map<String, Object?> json) =>
_$ProductFromJson(json);
}
@freezed
abstract class StopLocation with _$StopLocation {
const factory StopLocation({
required String id,
String? extId,
required String name,
String? description,
double? lat,
double? lon,
int? products,
int? distanceMeters,
}) = _StopLocation;
factory StopLocation.fromJson(Map<String, Object?> json) =>
_$StopLocationFromJson(json);
}
@freezed
abstract class Departure with _$Departure {
const factory Departure({
required String stopId,
String? stopExtId,
required String stopName,
required String name,
required String direction,
String? directionFlag,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
@Default(false) bool cancelled,
@Default(true) bool reachable,
Product? product,
String? journeyRef,
}) = _Departure;
factory Departure.fromJson(Map<String, Object?> json) =>
_$DepartureFromJson(json);
}
@freezed
abstract class Arrival with _$Arrival {
const factory Arrival({
required String stopId,
String? stopExtId,
required String stopName,
required String name,
required String origin,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
@Default(false) bool cancelled,
Product? product,
String? journeyRef,
}) = _Arrival;
factory Arrival.fromJson(Map<String, Object?> json) =>
_$ArrivalFromJson(json);
}
@freezed
abstract class TripEndpoint with _$TripEndpoint {
const factory TripEndpoint({
required String stopId,
String? stopExtId,
required String name,
double? lat,
double? lon,
required DateTime scheduledTime,
DateTime? realTime,
int? delayMinutes,
String? track,
String? realTrack,
String? type,
}) = _TripEndpoint;
factory TripEndpoint.fromJson(Map<String, Object?> json) =>
_$TripEndpointFromJson(json);
}
@freezed
abstract class JourneyStop with _$JourneyStop {
const factory JourneyStop({
required String id,
String? extId,
required String name,
double? lat,
double? lon,
int? routeIdx,
DateTime? scheduledArrival,
DateTime? scheduledDeparture,
DateTime? realArrival,
DateTime? realDeparture,
String? arrTrack,
String? depTrack,
String? realArrTrack,
String? realDepTrack,
@Default(false) bool cancelled,
@Default(false) bool cancelledArrival,
@Default(false) bool cancelledDeparture,
}) = _JourneyStop;
factory JourneyStop.fromJson(Map<String, Object?> json) =>
_$JourneyStopFromJson(json);
}
enum LegType {
@JsonValue('JOURNEY')
journey,
@JsonValue('WALK')
walk,
@JsonValue('TRANSFER')
transfer,
@JsonValue('BIKE')
bike,
@JsonValue('CAR')
car,
@JsonValue('PARK_RIDE')
parkRide,
@JsonValue('TAXI')
taxi,
@JsonValue('CHECK_IN')
checkIn,
@JsonValue('CHECK_OUT')
checkOut,
@JsonValue('DUMMY')
dummy,
@JsonValue('UNKNOWN')
unknown,
}
@freezed
abstract class Leg with _$Leg {
const factory Leg({
required String id,
required int idx,
@Default(LegType.unknown) LegType type,
String? name,
String? category,
String? number,
String? direction,
required TripEndpoint origin,
required TripEndpoint destination,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? duration,
@Default(false) bool cancelled,
@Default(false) bool partCancelled,
@Default(true) bool reachable,
Product? product,
String? journeyRef,
@Default(<JourneyStop>[]) List<JourneyStop> stops,
}) = _Leg;
factory Leg.fromJson(Map<String, Object?> json) => _$LegFromJson(json);
}
@freezed
abstract class Trip with _$Trip {
const factory Trip({
String? tripId,
String? ctxRecon,
String? checksum,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? duration,
@JsonKey(fromJson: IsoDuration.fromJson, toJson: IsoDuration.toJson)
Duration? realDuration,
int? transferCount,
@Default(<Leg>[]) List<Leg> legs,
}) = _Trip;
factory Trip.fromJson(Map<String, Object?> json) => _$TripFromJson(json);
}
@freezed
abstract class TripSearchResult with _$TripSearchResult {
const factory TripSearchResult({
@Default(<Trip>[]) List<Trip> trips,
String? scrollContextLater,
String? scrollContextEarlier,
}) = _TripSearchResult;
factory TripSearchResult.fromJson(Map<String, Object?> json) =>
_$TripSearchResultFromJson(json);
}
@freezed
abstract class JourneyDetail with _$JourneyDetail {
const factory JourneyDetail({
String? journeyId,
Product? product,
String? direction,
@Default(<JourneyStop>[]) List<JourneyStop> stops,
}) = _JourneyDetail;
factory JourneyDetail.fromJson(Map<String, Object?> json) =>
_$JourneyDetailFromJson(json);
}
@freezed
abstract class HimMessage with _$HimMessage {
const factory HimMessage({
required String id,
String? externalId,
String? head,
String? lead,
String? text,
String? category,
String? company,
int? priority,
int? products,
DateTime? startValidity,
DateTime? endValidity,
DateTime? modified,
}) = _HimMessage;
factory HimMessage.fromJson(Map<String, Object?> json) =>
_$HimMessageFromJson(json);
}
File diff suppressed because it is too large Load Diff
-368
View File
@@ -1,368 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'rmv_models.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_Product _$ProductFromJson(Map<String, dynamic> json) => _Product(
name: json['name'] as String?,
line: json['line'] as String?,
displayNumber: json['displayNumber'] as String?,
category: json['category'] as String?,
categoryCode: json['categoryCode'] as String?,
operator: json['operator'] as String?,
);
Map<String, dynamic> _$ProductToJson(_Product instance) => <String, dynamic>{
'name': instance.name,
'line': instance.line,
'displayNumber': instance.displayNumber,
'category': instance.category,
'categoryCode': instance.categoryCode,
'operator': instance.operator,
};
_StopLocation _$StopLocationFromJson(Map<String, dynamic> json) =>
_StopLocation(
id: json['id'] as String,
extId: json['extId'] as String?,
name: json['name'] as String,
description: json['description'] as String?,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
products: (json['products'] as num?)?.toInt(),
distanceMeters: (json['distanceMeters'] as num?)?.toInt(),
);
Map<String, dynamic> _$StopLocationToJson(_StopLocation instance) =>
<String, dynamic>{
'id': instance.id,
'extId': instance.extId,
'name': instance.name,
'description': instance.description,
'lat': instance.lat,
'lon': instance.lon,
'products': instance.products,
'distanceMeters': instance.distanceMeters,
};
_Departure _$DepartureFromJson(Map<String, dynamic> json) => _Departure(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
stopName: json['stopName'] as String,
name: json['name'] as String,
direction: json['direction'] as String,
directionFlag: json['directionFlag'] as String?,
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
reachable: json['reachable'] as bool? ?? true,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
);
Map<String, dynamic> _$DepartureToJson(_Departure instance) =>
<String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'stopName': instance.stopName,
'name': instance.name,
'direction': instance.direction,
'directionFlag': instance.directionFlag,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'cancelled': instance.cancelled,
'reachable': instance.reachable,
'product': instance.product,
'journeyRef': instance.journeyRef,
};
_Arrival _$ArrivalFromJson(Map<String, dynamic> json) => _Arrival(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
stopName: json['stopName'] as String,
name: json['name'] as String,
origin: json['origin'] as String,
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
);
Map<String, dynamic> _$ArrivalToJson(_Arrival instance) => <String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'stopName': instance.stopName,
'name': instance.name,
'origin': instance.origin,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'cancelled': instance.cancelled,
'product': instance.product,
'journeyRef': instance.journeyRef,
};
_TripEndpoint _$TripEndpointFromJson(Map<String, dynamic> json) =>
_TripEndpoint(
stopId: json['stopId'] as String,
stopExtId: json['stopExtId'] as String?,
name: json['name'] as String,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
scheduledTime: DateTime.parse(json['scheduledTime'] as String),
realTime: json['realTime'] == null
? null
: DateTime.parse(json['realTime'] as String),
delayMinutes: (json['delayMinutes'] as num?)?.toInt(),
track: json['track'] as String?,
realTrack: json['realTrack'] as String?,
type: json['type'] as String?,
);
Map<String, dynamic> _$TripEndpointToJson(_TripEndpoint instance) =>
<String, dynamic>{
'stopId': instance.stopId,
'stopExtId': instance.stopExtId,
'name': instance.name,
'lat': instance.lat,
'lon': instance.lon,
'scheduledTime': instance.scheduledTime.toIso8601String(),
'realTime': instance.realTime?.toIso8601String(),
'delayMinutes': instance.delayMinutes,
'track': instance.track,
'realTrack': instance.realTrack,
'type': instance.type,
};
_JourneyStop _$JourneyStopFromJson(Map<String, dynamic> json) => _JourneyStop(
id: json['id'] as String,
extId: json['extId'] as String?,
name: json['name'] as String,
lat: (json['lat'] as num?)?.toDouble(),
lon: (json['lon'] as num?)?.toDouble(),
routeIdx: (json['routeIdx'] as num?)?.toInt(),
scheduledArrival: json['scheduledArrival'] == null
? null
: DateTime.parse(json['scheduledArrival'] as String),
scheduledDeparture: json['scheduledDeparture'] == null
? null
: DateTime.parse(json['scheduledDeparture'] as String),
realArrival: json['realArrival'] == null
? null
: DateTime.parse(json['realArrival'] as String),
realDeparture: json['realDeparture'] == null
? null
: DateTime.parse(json['realDeparture'] as String),
arrTrack: json['arrTrack'] as String?,
depTrack: json['depTrack'] as String?,
realArrTrack: json['realArrTrack'] as String?,
realDepTrack: json['realDepTrack'] as String?,
cancelled: json['cancelled'] as bool? ?? false,
cancelledArrival: json['cancelledArrival'] as bool? ?? false,
cancelledDeparture: json['cancelledDeparture'] as bool? ?? false,
);
Map<String, dynamic> _$JourneyStopToJson(_JourneyStop instance) =>
<String, dynamic>{
'id': instance.id,
'extId': instance.extId,
'name': instance.name,
'lat': instance.lat,
'lon': instance.lon,
'routeIdx': instance.routeIdx,
'scheduledArrival': instance.scheduledArrival?.toIso8601String(),
'scheduledDeparture': instance.scheduledDeparture?.toIso8601String(),
'realArrival': instance.realArrival?.toIso8601String(),
'realDeparture': instance.realDeparture?.toIso8601String(),
'arrTrack': instance.arrTrack,
'depTrack': instance.depTrack,
'realArrTrack': instance.realArrTrack,
'realDepTrack': instance.realDepTrack,
'cancelled': instance.cancelled,
'cancelledArrival': instance.cancelledArrival,
'cancelledDeparture': instance.cancelledDeparture,
};
_Leg _$LegFromJson(Map<String, dynamic> json) => _Leg(
id: json['id'] as String,
idx: (json['idx'] as num).toInt(),
type: $enumDecodeNullable(_$LegTypeEnumMap, json['type']) ?? LegType.unknown,
name: json['name'] as String?,
category: json['category'] as String?,
number: json['number'] as String?,
direction: json['direction'] as String?,
origin: TripEndpoint.fromJson(json['origin'] as Map<String, dynamic>),
destination: TripEndpoint.fromJson(
json['destination'] as Map<String, dynamic>,
),
duration: IsoDuration.fromJson(json['duration'] as String?),
cancelled: json['cancelled'] as bool? ?? false,
partCancelled: json['partCancelled'] as bool? ?? false,
reachable: json['reachable'] as bool? ?? true,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
journeyRef: json['journeyRef'] as String?,
stops:
(json['stops'] as List<dynamic>?)
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
.toList() ??
const <JourneyStop>[],
);
Map<String, dynamic> _$LegToJson(_Leg instance) => <String, dynamic>{
'id': instance.id,
'idx': instance.idx,
'type': _$LegTypeEnumMap[instance.type]!,
'name': instance.name,
'category': instance.category,
'number': instance.number,
'direction': instance.direction,
'origin': instance.origin,
'destination': instance.destination,
'duration': IsoDuration.toJson(instance.duration),
'cancelled': instance.cancelled,
'partCancelled': instance.partCancelled,
'reachable': instance.reachable,
'product': instance.product,
'journeyRef': instance.journeyRef,
'stops': instance.stops,
};
const _$LegTypeEnumMap = {
LegType.journey: 'JOURNEY',
LegType.walk: 'WALK',
LegType.transfer: 'TRANSFER',
LegType.bike: 'BIKE',
LegType.car: 'CAR',
LegType.parkRide: 'PARK_RIDE',
LegType.taxi: 'TAXI',
LegType.checkIn: 'CHECK_IN',
LegType.checkOut: 'CHECK_OUT',
LegType.dummy: 'DUMMY',
LegType.unknown: 'UNKNOWN',
};
_Trip _$TripFromJson(Map<String, dynamic> json) => _Trip(
tripId: json['tripId'] as String?,
ctxRecon: json['ctxRecon'] as String?,
checksum: json['checksum'] as String?,
duration: IsoDuration.fromJson(json['duration'] as String?),
realDuration: IsoDuration.fromJson(json['realDuration'] as String?),
transferCount: (json['transferCount'] as num?)?.toInt(),
legs:
(json['legs'] as List<dynamic>?)
?.map((e) => Leg.fromJson(e as Map<String, dynamic>))
.toList() ??
const <Leg>[],
);
Map<String, dynamic> _$TripToJson(_Trip instance) => <String, dynamic>{
'tripId': instance.tripId,
'ctxRecon': instance.ctxRecon,
'checksum': instance.checksum,
'duration': IsoDuration.toJson(instance.duration),
'realDuration': IsoDuration.toJson(instance.realDuration),
'transferCount': instance.transferCount,
'legs': instance.legs,
};
_TripSearchResult _$TripSearchResultFromJson(Map<String, dynamic> json) =>
_TripSearchResult(
trips:
(json['trips'] as List<dynamic>?)
?.map((e) => Trip.fromJson(e as Map<String, dynamic>))
.toList() ??
const <Trip>[],
scrollContextLater: json['scrollContextLater'] as String?,
scrollContextEarlier: json['scrollContextEarlier'] as String?,
);
Map<String, dynamic> _$TripSearchResultToJson(_TripSearchResult instance) =>
<String, dynamic>{
'trips': instance.trips,
'scrollContextLater': instance.scrollContextLater,
'scrollContextEarlier': instance.scrollContextEarlier,
};
_JourneyDetail _$JourneyDetailFromJson(Map<String, dynamic> json) =>
_JourneyDetail(
journeyId: json['journeyId'] as String?,
product: json['product'] == null
? null
: Product.fromJson(json['product'] as Map<String, dynamic>),
direction: json['direction'] as String?,
stops:
(json['stops'] as List<dynamic>?)
?.map((e) => JourneyStop.fromJson(e as Map<String, dynamic>))
.toList() ??
const <JourneyStop>[],
);
Map<String, dynamic> _$JourneyDetailToJson(_JourneyDetail instance) =>
<String, dynamic>{
'journeyId': instance.journeyId,
'product': instance.product,
'direction': instance.direction,
'stops': instance.stops,
};
_HimMessage _$HimMessageFromJson(Map<String, dynamic> json) => _HimMessage(
id: json['id'] as String,
externalId: json['externalId'] as String?,
head: json['head'] as String?,
lead: json['lead'] as String?,
text: json['text'] as String?,
category: json['category'] as String?,
company: json['company'] as String?,
priority: (json['priority'] as num?)?.toInt(),
products: (json['products'] as num?)?.toInt(),
startValidity: json['startValidity'] == null
? null
: DateTime.parse(json['startValidity'] as String),
endValidity: json['endValidity'] == null
? null
: DateTime.parse(json['endValidity'] as String),
modified: json['modified'] == null
? null
: DateTime.parse(json['modified'] as String),
);
Map<String, dynamic> _$HimMessageToJson(_HimMessage instance) =>
<String, dynamic>{
'id': instance.id,
'externalId': instance.externalId,
'head': instance.head,
'lead': instance.lead,
'text': instance.text,
'category': instance.category,
'company': instance.company,
'priority': instance.priority,
'products': instance.products,
'startValidity': instance.startValidity?.toIso8601String(),
'endValidity': instance.endValidity?.toIso8601String(),
'modified': instance.modified?.toIso8601String(),
};
-4
View File
@@ -6,13 +6,11 @@ import 'package:http/http.dart' as http;
import '../api_error.dart';
import '../marianumcloud/talk/talk_error.dart';
import '../webuntis/webuntis_error.dart';
import 'app_exception.dart';
import 'network_exception.dart';
import 'parse_exception.dart';
import 'server_exception.dart';
import 'talk_exception.dart';
import 'webuntis_exception.dart';
const String _defaultFallback =
'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
@@ -57,7 +55,6 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
if (error is AppException) return error.userMessage;
if (error is TalkError) return TalkException(error).userMessage;
if (error is WebuntisError) return WebuntisException(error).userMessage;
if (error is DioException) {
final mapped = _dioToAppException(error);
@@ -90,7 +87,6 @@ String? errorToTechnicalDetails(Object? error) {
if (error == null) return null;
if (error is AppException) return error.technicalDetails ?? error.toString();
if (error is TalkError) return TalkException(error).technicalDetails;
if (error is WebuntisError) return WebuntisException(error).technicalDetails;
if (error is DioException) {
final mapped = _dioToAppException(error);
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
-31
View File
@@ -1,31 +0,0 @@
import '../webuntis/webuntis_error.dart';
import 'app_exception.dart';
class WebuntisException extends AppException {
final WebuntisError source;
WebuntisException(this.source)
: super(
userMessage: _mapMessage(source),
technicalDetails: 'WebUntis (${source.code}): ${source.message}',
allowRetry: true,
);
static String _mapMessage(WebuntisError e) {
switch (e.code) {
case -8504:
case -8502:
return 'WebUntis-Anmeldung abgelaufen. Bitte erneut anmelden.';
case -8520:
return 'Bitte melde dich erneut an.';
case -7004:
return 'Für diesen Zeitraum sind keine Stundenplandaten verfügbar.';
case -32601:
return 'WebUntis kennt diese Anfrage nicht. Bitte App aktualisieren.';
default:
return e.message.isNotEmpty
? 'WebUntis: ${e.message}'
: 'WebUntis konnte die Anfrage nicht bearbeiten (Code ${e.code}).';
}
}
}
-16
View File
@@ -1,16 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'nominatim_result.freezed.dart';
part 'nominatim_result.g.dart';
@freezed
abstract class NominatimResult with _$NominatimResult {
const factory NominatimResult({
required String displayName,
required double lat,
required double lon,
}) = _NominatimResult;
factory NominatimResult.fromJson(Map<String, Object?> json) =>
_$NominatimResultFromJson(json);
}
@@ -1,283 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
// coverage:ignore-file
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'nominatim_result.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
// dart format off
T _$identity<T>(T value) => value;
/// @nodoc
mixin _$NominatimResult {
String get displayName; double get lat; double get lon;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
$NominatimResultCopyWith<NominatimResult> get copyWith => _$NominatimResultCopyWithImpl<NominatimResult>(this as NominatimResult, _$identity);
/// Serializes this NominatimResult to a JSON map.
Map<String, dynamic> toJson();
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
@override
String toString() {
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
}
}
/// @nodoc
abstract mixin class $NominatimResultCopyWith<$Res> {
factory $NominatimResultCopyWith(NominatimResult value, $Res Function(NominatimResult) _then) = _$NominatimResultCopyWithImpl;
@useResult
$Res call({
String displayName, double lat, double lon
});
}
/// @nodoc
class _$NominatimResultCopyWithImpl<$Res>
implements $NominatimResultCopyWith<$Res> {
_$NominatimResultCopyWithImpl(this._self, this._then);
final NominatimResult _self;
final $Res Function(NominatimResult) _then;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
return _then(_self.copyWith(
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
/// Adds pattern-matching-related methods to [NominatimResult].
extension NominatimResultPatterns on NominatimResult {
/// A variant of `map` that fallback to returning `orElse`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeMap<TResult extends Object?>(TResult Function( _NominatimResult value)? $default,{required TResult orElse(),}){
final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// Callbacks receives the raw object, upcasted.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case final Subclass2 value:
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult map<TResult extends Object?>(TResult Function( _NominatimResult value) $default,){
final _that = this;
switch (_that) {
case _NominatimResult():
return $default(_that);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `map` that fallback to returning `null`.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case final Subclass value:
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? mapOrNull<TResult extends Object?>(TResult? Function( _NominatimResult value)? $default,){
final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that);case _:
return null;
}
}
/// A variant of `when` that fallback to an `orElse` callback.
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return orElse();
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String displayName, double lat, double lon)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that.displayName,_that.lat,_that.lon);case _:
return orElse();
}
}
/// A `switch`-like method, using callbacks.
///
/// As opposed to `map`, this offers destructuring.
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case Subclass2(:final field2):
/// return ...;
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String displayName, double lat, double lon) $default,) {final _that = this;
switch (_that) {
case _NominatimResult():
return $default(_that.displayName,_that.lat,_that.lon);case _:
throw StateError('Unexpected subclass');
}
}
/// A variant of `when` that fallback to returning `null`
///
/// It is equivalent to doing:
/// ```dart
/// switch (sealedClass) {
/// case Subclass(:final field):
/// return ...;
/// case _:
/// return null;
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String displayName, double lat, double lon)? $default,) {final _that = this;
switch (_that) {
case _NominatimResult() when $default != null:
return $default(_that.displayName,_that.lat,_that.lon);case _:
return null;
}
}
}
/// @nodoc
@JsonSerializable()
class _NominatimResult implements NominatimResult {
const _NominatimResult({required this.displayName, required this.lat, required this.lon});
factory _NominatimResult.fromJson(Map<String, dynamic> json) => _$NominatimResultFromJson(json);
@override final String displayName;
@override final double lat;
@override final double lon;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@override @JsonKey(includeFromJson: false, includeToJson: false)
@pragma('vm:prefer-inline')
_$NominatimResultCopyWith<_NominatimResult> get copyWith => __$NominatimResultCopyWithImpl<_NominatimResult>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$NominatimResultToJson(this, );
}
@override
bool operator ==(Object other) {
return identical(this, other) || (other.runtimeType == runtimeType&&other is _NominatimResult&&(identical(other.displayName, displayName) || other.displayName == displayName)&&(identical(other.lat, lat) || other.lat == lat)&&(identical(other.lon, lon) || other.lon == lon));
}
@JsonKey(includeFromJson: false, includeToJson: false)
@override
int get hashCode => Object.hash(runtimeType,displayName,lat,lon);
@override
String toString() {
return 'NominatimResult(displayName: $displayName, lat: $lat, lon: $lon)';
}
}
/// @nodoc
abstract mixin class _$NominatimResultCopyWith<$Res> implements $NominatimResultCopyWith<$Res> {
factory _$NominatimResultCopyWith(_NominatimResult value, $Res Function(_NominatimResult) _then) = __$NominatimResultCopyWithImpl;
@override @useResult
$Res call({
String displayName, double lat, double lon
});
}
/// @nodoc
class __$NominatimResultCopyWithImpl<$Res>
implements _$NominatimResultCopyWith<$Res> {
__$NominatimResultCopyWithImpl(this._self, this._then);
final _NominatimResult _self;
final $Res Function(_NominatimResult) _then;
/// Create a copy of NominatimResult
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? displayName = null,Object? lat = null,Object? lon = null,}) {
return _then(_NominatimResult(
displayName: null == displayName ? _self.displayName : displayName // ignore: cast_nullable_to_non_nullable
as String,lat: null == lat ? _self.lat : lat // ignore: cast_nullable_to_non_nullable
as double,lon: null == lon ? _self.lon : lon // ignore: cast_nullable_to_non_nullable
as double,
));
}
}
// dart format on
-21
View File
@@ -1,21 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'nominatim_result.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_NominatimResult _$NominatimResultFromJson(Map<String, dynamic> json) =>
_NominatimResult(
displayName: json['displayName'] as String,
lat: (json['lat'] as num).toDouble(),
lon: (json['lon'] as num).toDouble(),
);
Map<String, dynamic> _$NominatimResultToJson(_NominatimResult instance) =>
<String, dynamic>{
'displayName': instance.displayName,
'lat': instance.lat,
'lon': instance.lon,
};
-72
View File
@@ -1,72 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../errors/network_exception.dart';
import '../errors/parse_exception.dart';
import '../errors/server_exception.dart';
import 'nominatim_result.dart';
/// Tiny wrapper around the public Nominatim geocoder. Only used in the
/// commute-settings flow to look up a home address; not called from any
/// hot path. The User-Agent header is **required** by the Nominatim usage
/// policy — without it the service throttles/blocks the client.
class NominatimSearch {
static const _userAgent = 'MarianumMobile/1.0 (contact@elias-mueller.com)';
static final Uri _base = Uri.parse('https://nominatim.openstreetmap.org/search');
/// Returns up to [limit] geocoded matches for the user-typed [query].
Future<List<NominatimResult>> run(String query, {int limit = 5}) async {
final uri = _base.replace(
queryParameters: {
'q': query,
'format': 'json',
'limit': limit.toString(),
'addressdetails': '0',
'accept-language': 'de',
},
);
final http.Response response;
try {
response = await http
.get(uri, headers: {'User-Agent': _userAgent, 'Accept': 'application/json'})
.timeout(const Duration(seconds: 15));
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'nominatim: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'nominatim: ${e.message}');
}
if (response.statusCode > 299) {
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'nominatim HTTP ${response.statusCode}',
);
}
try {
final raw = jsonDecode(utf8.decode(response.bodyBytes)) as List;
return raw
.map((e) => _resultFromRaw(e as Map<String, dynamic>))
.toList(growable: false);
} catch (e) {
throw ParseException(technicalDetails: 'nominatim assemble: $e');
}
}
static NominatimResult _resultFromRaw(Map<String, dynamic> json) {
// Nominatim returns lat/lon as strings, not numbers. Normalise here.
final lat = double.parse(json['lat'].toString());
final lon = double.parse(json['lon'].toString());
return NominatimResult(
displayName: json['display_name'] as String? ?? '?',
lat: lat,
lon: lon,
);
}
}
@@ -3,25 +3,39 @@ import 'dart:io';
import 'package:http/http.dart' as http;
import '../../../model/endpoint_data.dart';
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
import 'autocomplete_response.dart';
class AutocompleteApi {
Future<AutocompleteResponse> find(String query) async {
final endpoint = NextcloudOcs.uri(
'core/autocomplete/get',
queryParameters: {
/// Searches sharees (users by default). Pass [shareTypes] to widen the search
/// — e.g. `[0, 1]` for both users and groups (0 = user, 1 = group).
Future<AutocompleteResponse> find(
String query, {
List<int> shareTypes = const [0],
}) async {
// NextcloudOcs.uri serialises every query value via `toString()`, which
// would turn the `shareTypes[]` list into `"[0, 1]"`. Build the Uri here so
// Dart encodes the list as repeated `shareTypes[]=0&shareTypes[]=1` params.
final endpoint = EndpointData().nextcloud();
final uri = Uri.https(
endpoint.domain,
'${endpoint.path}/ocs/v2.php/core/autocomplete/get',
{
'format': 'json',
'search': query,
'itemType': ' ',
'itemId': ' ',
'shareTypes[]': ['0'],
'shareTypes[]': shareTypes.map((t) => t.toString()).toList(),
'limit': '10',
},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
final response = await http.get(uri, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Api call failed with ${response.statusCode}: ${response.body}',
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'core/autocomplete/get: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
@@ -1,7 +1,17 @@
import 'package:json_annotation/json_annotation.dart';
import '../files_sharing/queries/share/share.dart';
part 'autocomplete_response.g.dart';
/// Maps an autocomplete result's `source` to the matching Nextcloud share type.
/// Groups become [kShareTypeGroup]; everything else (users, and any unknown
/// source) defaults to [kShareTypeUser].
int shareTypeFromSource(String? source) {
if (source != null && source.startsWith('groups')) return kShareTypeGroup;
return kShareTypeUser;
}
@JsonSerializable(explicitToJson: true)
class AutocompleteResponse {
List<AutocompleteResponseObject> data;
@@ -0,0 +1,46 @@
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
import 'nextcloud_sharing_capabilities.dart';
/// Fetches the current user's Nextcloud capabilities via OCS
/// `GET cloud/capabilities` and extracts the `files_sharing` block. This is the
/// per-user, group-aware source of truth the sharing UI gates on — no custom
/// backend involved.
class GetNextcloudCapabilities {
Future<NextcloudSharingCapabilities> run() async {
final endpoint = NextcloudOcs.uri(
'cloud/capabilities',
queryParameters: {'format': 'json'},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'cloud/capabilities: ${response.body}',
);
}
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
final data =
(decoded['ocs'] as Map<String, dynamic>?)?['data']
as Map<String, dynamic>?;
final capabilities = data?['capabilities'] as Map<String, dynamic>?;
final filesSharing = capabilities?['files_sharing'];
if (filesSharing is! Map<String, dynamic>) {
// Server doesn't advertise files_sharing (app disabled) — treat as no
// sharing capability rather than failing the whole load.
return const NextcloudSharingCapabilities();
}
final passwordPolicy = capabilities?['password_policy'];
return NextcloudSharingCapabilities.fromFilesSharing(
filesSharing,
passwordPolicy: passwordPolicy is Map<String, dynamic>
? passwordPolicy
: null,
);
}
}
@@ -0,0 +1,131 @@
/// Subset of Nextcloud's `files_sharing` capabilities block that the mobile
/// sharing UI gates on. Nextcloud reports these per authenticated user, so a
/// group that an admin excluded from creating public links sees
/// `public.enabled == false` here — exactly how the web UI hides those buttons.
///
/// The block is deeply nested and varies between server versions, so this is
/// parsed by hand from the raw OCS map with safe fallbacks rather than via
/// code generation. Missing fields default to the most restrictive value so a
/// newer/older server never accidentally unlocks a capability.
class NextcloudSharingCapabilities {
/// `files_sharing.api_enabled` — master switch. When false the user may not
/// create any share (user, group or link).
final bool apiEnabled;
/// `files_sharing.public.enabled` — public link shares allowed.
final bool publicEnabled;
/// `files_sharing.public.multiple_links` — more than one link per file.
final bool publicMultipleLinks;
/// `files_sharing.public.upload` — public upload / file-drop folders.
final bool publicUploadEnabled;
/// `files_sharing.public.password.enforced` — a password is mandatory on
/// public links, so the create flow must collect one upfront.
final bool publicPasswordEnforced;
/// `files_sharing.public.expire_date.enabled`.
final bool publicExpireEnabled;
/// `files_sharing.public.expire_date.days` — default/maximum lifetime.
final int? publicExpireDays;
/// `files_sharing.public.expire_date.enforced` — expiry cannot be removed.
final bool publicExpireEnforced;
/// `files_sharing.group.enabled` (falls back to the older `group_sharing`).
final bool groupEnabled;
/// `files_sharing.resharing` — recipients may reshare.
final bool resharing;
// --- password_policy (a sibling capability of files_sharing) ---
// These let the link-password UI state the rules up front instead of only
// surfacing them after the server rejects a weak password. The
// "non-common password" (breach) check can only be enforced server-side.
/// `password_policy.minLength`.
final int? passwordMinLength;
/// `password_policy.enforceUpperLowerCase`.
final bool passwordEnforceUpperLower;
/// `password_policy.enforceNumericCharacters`.
final bool passwordEnforceNumeric;
/// `password_policy.enforceSpecialCharacters`.
final bool passwordEnforceSpecial;
const NextcloudSharingCapabilities({
this.apiEnabled = false,
this.publicEnabled = false,
this.publicMultipleLinks = false,
this.publicUploadEnabled = false,
this.publicPasswordEnforced = false,
this.publicExpireEnabled = false,
this.publicExpireDays,
this.publicExpireEnforced = false,
this.groupEnabled = false,
this.resharing = false,
this.passwordMinLength,
this.passwordEnforceUpperLower = false,
this.passwordEnforceNumeric = false,
this.passwordEnforceSpecial = false,
});
/// Parses the `files_sharing` sub-map of an OCS `cloud/capabilities`
/// response, plus the optional sibling `password_policy` map. Tolerates
/// missing intermediate maps and type drift.
factory NextcloudSharingCapabilities.fromFilesSharing(
Map<String, dynamic> filesSharing, {
Map<String, dynamic>? passwordPolicy,
}) {
Map<String, dynamic>? sub(Map<String, dynamic>? m, String key) {
final value = m?[key];
return value is Map<String, dynamic> ? value : null;
}
bool boolAt(Map<String, dynamic>? m, String key) => m?[key] == true;
int? intAt(Map<String, dynamic>? m, String key) {
final v = m?[key];
if (v is int) return v;
if (v is String) return int.tryParse(v);
return null;
}
final public = sub(filesSharing, 'public');
final password = sub(public, 'password');
final expire = sub(public, 'expire_date');
final group = sub(filesSharing, 'group');
return NextcloudSharingCapabilities(
apiEnabled: boolAt(filesSharing, 'api_enabled'),
publicEnabled: boolAt(public, 'enabled'),
publicMultipleLinks: boolAt(public, 'multiple_links'),
publicUploadEnabled: boolAt(public, 'upload'),
publicPasswordEnforced: boolAt(password, 'enforced'),
publicExpireEnabled: boolAt(expire, 'enabled'),
publicExpireDays: intAt(expire, 'days'),
publicExpireEnforced: boolAt(expire, 'enforced'),
// Newer servers nest it under `group.enabled`; older ones expose a flat
// `group_sharing` boolean.
groupEnabled:
boolAt(group, 'enabled') || boolAt(filesSharing, 'group_sharing'),
resharing: boolAt(filesSharing, 'resharing'),
passwordMinLength: intAt(passwordPolicy, 'minLength'),
passwordEnforceUpperLower: boolAt(
passwordPolicy,
'enforceUpperLowerCase',
),
passwordEnforceNumeric: boolAt(
passwordPolicy,
'enforceNumericCharacters',
),
passwordEnforceSpecial: boolAt(
passwordPolicy,
'enforceSpecialCharacters',
),
);
}
}
@@ -0,0 +1,131 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../model/account_data.dart';
import '../../../model/endpoint_data.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/not_found_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
/// Mix of two Nextcloud surfaces:
/// - User info comes from the OCS provisioning API
/// (`/ocs/v2.php/cloud/users/{userId}`).
/// - The own-avatar upload/delete uses the *core* AvatarController at
/// `/avatar/` — the OCS provisioning route has no POST (it answers 405).
/// This is the same controller the read path (`/avatar/{id}/{size}` in
/// [UserAvatar]) already talks to. CSRF is bypassed because we use Basic
/// Auth without a session cookie.
/// Core AvatarController endpoint for the logged-in user (POST sets, DELETE
/// removes). Built against the bare Nextcloud base (domain + optional path),
/// not the OCS wrapper.
Uri _coreAvatarUri() {
final endpoint = EndpointData().nextcloud();
return Uri.https(endpoint.domain, '${endpoint.path}/avatar/');
}
Uri _userInfoUri() =>
NextcloudOcs.uri('cloud/users/${AccountData().getUsername()}');
Future<http.Response> _send(
Future<http.Response> Function(Uri uri, Map<String, String> headers)
perform,
Uri uri,
) async {
final headers = NextcloudOcs.headers();
final http.Response response;
try {
response = await perform(uri, headers);
} on SocketException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
} on TimeoutException catch (e) {
throw NetworkException.timeout(technicalDetails: 'Cloud $uri: $e');
} on http.ClientException catch (e) {
throw NetworkException(technicalDetails: 'Cloud $uri: ${e.message}');
}
final status = response.statusCode;
if (status >= 200 && status < 300) return response;
final body = response.body.replaceAll(RegExp(r'\s+'), ' ').trim();
final preview = body.length > 500 ? '${body.substring(0, 500)}' : body;
final detail = body.isEmpty
? 'Cloud $uri -> HTTP $status'
: 'Cloud $uri -> HTTP $status body=$preview';
log(detail);
if (status == 401) throw AuthException.unauthorized(technicalDetails: detail);
if (status == 403) throw AuthException.forbidden(technicalDetails: detail);
if (status == 404) throw NotFoundException(technicalDetails: detail);
throw ServerException(statusCode: status, technicalDetails: detail);
}
class SetUserAvatar {
final Uint8List bytes;
final String filename;
SetUserAvatar(this.bytes, {this.filename = 'avatar.jpg'});
Future<void> run() async {
await _send((uri, headers) async {
// Core AvatarController reads $_FILES['files']['error'][0] — the field
// must be `files[]` so PHP exposes it as an array, matching the web UI.
final req = http.MultipartRequest('POST', uri)
..headers.addAll(headers)
..files.add(
http.MultipartFile.fromBytes('files[]', bytes, filename: filename),
);
final streamed = await req.send();
return http.Response.fromStream(streamed);
}, _coreAvatarUri());
}
}
class DeleteUserAvatar {
Future<void> run() async {
await _send(
(uri, headers) => http.delete(uri, headers: headers),
_coreAvatarUri(),
);
}
}
class CloudUserInfo {
final String userId;
final String displayName;
const CloudUserInfo({required this.userId, required this.displayName});
}
/// Reads the current user's provisioning record. The OCS wrapper looks like:
/// `{ "ocs": { "meta": {...}, "data": { "id": "...", "displayname": "...", ... } } }`.
/// We only need displayname; everything else is discarded.
class GetUserInfo {
Future<CloudUserInfo> run() async {
final uri = _userInfoUri();
final response = await _send(
(u, headers) => http.get(u, headers: headers),
uri,
);
try {
final root = jsonDecode(response.body) as Map<String, dynamic>;
final data =
(root['ocs'] as Map<String, dynamic>)['data']
as Map<String, dynamic>;
return CloudUserInfo(
userId: data['id'] as String,
displayName: (data['displayname'] as String?) ?? '',
);
} catch (e) {
throw ParseException(
technicalDetails: 'Cloud $uri user info parse: $e',
);
}
}
}
@@ -1,21 +1,144 @@
import 'dart:io';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../errors/server_exception.dart';
import '../nextcloud_ocs.dart';
import 'file_sharing_api_params.dart';
import 'queries/share/share.dart';
import 'queries/share/share_update_params.dart';
/// OCS `files_sharing` API (Nextcloud Sharing API v1). Per the official docs:
/// list/delete pass parameters via the URL (query / path), create and update
/// pass them in the request **body** (form-urlencoded), and every call adds
/// `format=json` so the server replies with JSON instead of XML.
///
/// All calls surface failures as [ServerException] so they map to friendly
/// messages via `errorToUserMessage`.
class FileSharingApi {
Future<void> share(FileSharingApiParams query) async {
static const String _base = 'apps/files_sharing/api/v1/shares';
/// Creates a share. Returns the created [Share] (callers that don't need it —
/// e.g. Talk file sharing — can ignore the result).
Future<Share> share(FileSharingApiParams query) async {
final endpoint = NextcloudOcs.uri(
'apps/files_sharing/api/v1/shares',
queryParameters: query.toJson(),
_base,
queryParameters: {'format': 'json'},
);
final response = await http.post(endpoint, headers: NextcloudOcs.headers());
if (response.statusCode != HttpStatus.ok) {
throw Exception(
'Api call failed with ${response.statusCode}: ${response.body}',
final response = await http.post(
endpoint,
headers: NextcloudOcs.headers(),
body: _stringForm(query.toJson()),
);
return _decodeShare(response, action: 'Freigabe erstellen');
}
/// Lists shares for the given OCS [path] (see `ocs_path.dart`). [reshares]
/// includes shares the current user received and re-shared.
Future<List<Share>> listForPath(String path, {bool reshares = false}) async {
final endpoint = NextcloudOcs.uri(
_base,
queryParameters: {
'format': 'json',
'path': path,
'reshares': reshares.toString(),
'subfiles': 'false',
},
);
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
final data = _decodeData(response, action: 'Freigaben laden');
if (data is! List) return const [];
return data
.whereType<Map<String, dynamic>>()
.map(Share.fromJson)
.toList(growable: false);
}
/// Updates an existing share. Returns the updated [Share].
Future<Share> update(int shareId, ShareUpdateParams params) async {
final endpoint = NextcloudOcs.uri(
'$_base/$shareId',
queryParameters: {'format': 'json'},
);
final response = await http.put(
endpoint,
headers: NextcloudOcs.headers(),
body: params.toQuery(),
);
return _decodeShare(response, action: 'Freigabe ändern');
}
/// Deletes (revokes) a share.
Future<void> remove(int shareId) async {
final endpoint = NextcloudOcs.uri(
'$_base/$shareId',
queryParameters: {'format': 'json'},
);
final response = await http.delete(
endpoint,
headers: NextcloudOcs.headers(),
);
_decodeData(response, action: 'Freigabe löschen');
}
/// Stringifies a json map into form fields (the OCS body is
/// `application/x-www-form-urlencoded`). Null values are already dropped by
/// the params' `includeIfNull: false`.
Map<String, String> _stringForm(Map<String, dynamic> json) =>
json.map((key, value) => MapEntry(key, value.toString()));
/// Decodes a single-share response (create/update). Throws if the payload is
/// not a share object.
Share _decodeShare(http.Response response, {required String action}) {
final data = _decodeData(response, action: action);
if (data is! Map<String, dynamic>) {
throw ServerException(
statusCode: response.statusCode,
technicalDetails: 'Unerwartete Antwort für "$action": ${response.body}',
);
}
return Share.fromJson(data);
}
/// Validates the HTTP/OCS envelope and returns the `ocs.data` payload, or
/// throws a [ServerException] carrying the server's OCS message when present.
///
/// Every access is type-checked rather than cast: PHP's `json_encode` turns
/// an empty associative array into a JSON array (`[]`), and `statuscode` can
/// arrive as a string — a hard `as` cast on either would throw a raw
/// TypeError that surfaces as the generic "something went wrong" message.
Object? _decodeData(http.Response response, {required String action}) {
dynamic decoded;
try {
decoded = jsonDecode(response.body);
} catch (_) {
decoded = null;
}
final ocs = decoded is Map<String, dynamic> ? decoded['ocs'] : null;
final ocsMap = ocs is Map<String, dynamic> ? ocs : null;
final meta = ocsMap?['meta'];
final metaMap = meta is Map<String, dynamic> ? meta : null;
final rawStatus = metaMap?['statuscode'];
final ocsStatus = rawStatus is int
? rawStatus
: (rawStatus is String ? int.tryParse(rawStatus) : null);
final rawMessage = metaMap?['message'];
final ocsMessage = rawMessage is String ? rawMessage : null;
// OCS v2 mirrors the HTTP status; accept any 2xx. Success OCS statuscodes
// are 100 (v1 carry-over) or 200.
final httpOk = response.statusCode >= 200 && response.statusCode < 300;
final ocsOk = ocsStatus == null || ocsStatus == 100 || ocsStatus == 200;
if (!httpOk || !ocsOk) {
throw ServerException(
statusCode: response.statusCode,
userMessage: ocsMessage != null && ocsMessage.isNotEmpty
? '$action fehlgeschlagen: $ocsMessage'
: null,
technicalDetails: '$action: ${response.statusCode} ${response.body}',
);
}
return ocsMap?['data'];
}
}
@@ -2,7 +2,10 @@ import 'package:json_annotation/json_annotation.dart';
part 'file_sharing_api_params.g.dart';
@JsonSerializable()
// includeIfNull:false so the optional sharing fields below are only sent when
// set — otherwise the OCS query would carry `permissions=null` etc. and be
// rejected.
@JsonSerializable(includeIfNull: false)
class FileSharingApiParams {
int shareType;
String shareWith;
@@ -10,12 +13,24 @@ class FileSharingApiParams {
String? referenceId;
String? talkMetaData;
/// Permission bitmask (see `share_permissions.dart`).
int? permissions;
/// Public link password.
String? password;
/// Expiry as `YYYY-MM-DD`.
String? expireDate;
FileSharingApiParams({
required this.shareType,
required this.shareWith,
required this.path,
this.referenceId,
this.talkMetaData,
this.permissions,
this.password,
this.expireDate,
});
factory FileSharingApiParams.fromJson(Map<String, dynamic> json) =>
@@ -14,6 +14,9 @@ FileSharingApiParams _$FileSharingApiParamsFromJson(
path: json['path'] as String,
referenceId: json['referenceId'] as String?,
talkMetaData: json['talkMetaData'] as String?,
permissions: (json['permissions'] as num?)?.toInt(),
password: json['password'] as String?,
expireDate: json['expireDate'] as String?,
);
Map<String, dynamic> _$FileSharingApiParamsToJson(
@@ -22,6 +25,9 @@ Map<String, dynamic> _$FileSharingApiParamsToJson(
'shareType': instance.shareType,
'shareWith': instance.shareWith,
'path': instance.path,
'referenceId': instance.referenceId,
'talkMetaData': instance.talkMetaData,
'referenceId': ?instance.referenceId,
'talkMetaData': ?instance.talkMetaData,
'permissions': ?instance.permissions,
'password': ?instance.password,
'expireDate': ?instance.expireDate,
};
@@ -0,0 +1,18 @@
import '../webdav/queries/list_files/cacheable_file.dart';
/// Converts a [CacheableFile.path] (relative to the WebDAV files root, folders
/// ending in `/`) into the path the OCS `files_sharing` API expects: rooted
/// with a single leading slash and without a trailing slash on folders.
///
/// Examples:
/// '' -> '/' (files root)
/// 'Documents/x.pdf' -> '/Documents/x.pdf'
/// 'Documents/' -> '/Documents'
/// '/Shared/a/' -> '/Shared/a'
String ocsPathFor(String webdavPath) {
final trimmed = webdavPath.replaceAll(RegExp(r'^/+|/+$'), '');
return trimmed.isEmpty ? '/' : '/$trimmed';
}
/// Convenience wrapper for a [CacheableFile].
String ocsPathOf(CacheableFile file) => ocsPathFor(file.path);
@@ -0,0 +1,108 @@
/// Nextcloud share types (subset the app uses).
const int kShareTypeUser = 0;
const int kShareTypeGroup = 1;
const int kShareTypePublicLink = 3;
const int kShareTypeEmail = 4;
/// A Talk conversation ("room") the file is linked into.
const int kShareTypeRoom = 10;
/// A single share as returned by the OCS `files_sharing` API.
///
/// Parsed by hand rather than via code generation: OCS is inconsistent about
/// types across versions (e.g. `id`/`share_type` may arrive as either strings
/// or numbers) and omits optional fields entirely, so defensive parsing is
/// safer than generated `as int` casts.
class Share {
final int id;
final int shareType;
final int permissions;
/// Server path of the shared item (e.g. `/Documents/x.pdf`).
final String? path;
/// `'file'` or `'folder'`.
final String? itemType;
/// Recipient id (user/group id); empty for public links.
final String? shareWith;
final String? shareWithDisplayname;
/// Public link URL (only set for [kShareTypePublicLink]).
final String? url;
/// Raw expiration as `"YYYY-MM-DD HH:MM:SS"` (or null when none).
final String? expiration;
final String? label;
/// Redacted password marker: the server returns `null` when no password is
/// set and a placeholder (`"redacted"`) when one is — never the real value.
final String? password;
const Share({
required this.id,
required this.shareType,
required this.permissions,
this.path,
this.itemType,
this.shareWith,
this.shareWithDisplayname,
this.url,
this.expiration,
this.label,
this.password,
});
bool get isPublicLink => shareType == kShareTypePublicLink;
bool get isGroup => shareType == kShareTypeGroup;
bool get isEmail => shareType == kShareTypeEmail;
bool get isRoom => shareType == kShareTypeRoom;
bool get isFolder => itemType == 'folder';
/// Whether a (link) password is currently set. See [password].
bool get hasPassword => password != null && password!.isNotEmpty;
/// Best display title for the share row.
String get displayTitle {
if (isPublicLink) return label?.isNotEmpty == true ? label! : 'Link';
final name = shareWithDisplayname;
if (name != null && name.isNotEmpty) return name;
return shareWith ?? 'Unbekannt';
}
/// Human label for the kind of share (for subtitles/headers).
String get kindLabel {
if (isPublicLink) return 'Öffentlicher Link';
if (isGroup) return 'Gruppe';
if (isRoom) return 'Talk-Chat';
if (isEmail) return 'E-Mail';
if (shareType == kShareTypeUser) return 'Person';
return 'Freigabe';
}
static int _asInt(Object? value, {int fallback = 0}) {
if (value is int) return value;
if (value is String) return int.tryParse(value) ?? fallback;
return fallback;
}
static String? _asString(Object? value) {
if (value == null) return null;
final s = value.toString();
return s.isEmpty ? null : s;
}
factory Share.fromJson(Map<String, dynamic> json) => Share(
id: _asInt(json['id']),
shareType: _asInt(json['share_type']),
permissions: _asInt(json['permissions']),
path: _asString(json['path']),
itemType: _asString(json['item_type']),
shareWith: _asString(json['share_with']),
shareWithDisplayname: _asString(json['share_with_displayname']),
url: _asString(json['url']),
expiration: _asString(json['expiration']),
label: _asString(json['label']),
password: _asString(json['password']),
);
}
@@ -0,0 +1,20 @@
/// Parameters for updating an existing share via OCS `PUT shares/{id}`.
/// Only the fields that are explicitly set are sent — every field is optional
/// and a null is omitted from the request (sending `permissions=null` would be
/// rejected by the server).
///
/// Use an empty string for [expireDate]/[password] to explicitly clear the
/// value server-side (Nextcloud treats `expireDate=` as "remove expiry").
class ShareUpdateParams {
final int? permissions;
final String? password;
final String? expireDate;
const ShareUpdateParams({this.permissions, this.password, this.expireDate});
Map<String, String> toQuery() => {
if (permissions != null) 'permissions': permissions.toString(),
'password': ?password,
'expireDate': ?expireDate,
};
}
@@ -0,0 +1,82 @@
/// Nextcloud share permission bitmask helpers. These mirror the constants the
/// OCS `files_sharing` API expects in the `permissions` field. Kept as pure
/// functions (no Flutter/IO) so they are unit-testable.
library;
/// Individual permission bits (Nextcloud `OCS\Constants`).
const int kPermissionRead = 1;
const int kPermissionUpdate = 2;
const int kPermissionCreate = 4;
const int kPermissionDelete = 8;
const int kPermissionShare = 16;
/// User-facing presets that map onto a bitmask.
enum SharePreset {
/// Recipient can only view/download.
readOnly,
/// Recipient can view, edit, add and remove (full editing).
edit,
/// Upload-only "file request" — recipient can add files to a folder but not
/// see existing contents. Only meaningful for folders.
fileDrop,
}
extension SharePresetLabel on SharePreset {
String get label {
switch (this) {
case SharePreset.readOnly:
return 'Nur Lesen';
case SharePreset.edit:
return 'Bearbeiten';
case SharePreset.fileDrop:
return 'Datei-Anfrage';
}
}
}
/// Returns true if [mask] contains the given [flag].
bool hasPermission(int mask, int flag) => mask & flag == flag;
/// Builds the permission bitmask for a [preset].
///
/// [isFolder] matters for the `edit` preset: a file can only carry
/// read+update, while a folder additionally supports create+delete. Nextcloud
/// rejects create/delete on a file ("Failed to update share"), so they must be
/// omitted there. When [allowReshare] is true the reshare bit is added to the
/// editing presets — mirroring how the Nextcloud clients respect the
/// `resharing` capability.
int permissionsFor(
SharePreset preset, {
bool allowReshare = false,
bool isFolder = false,
}) {
switch (preset) {
case SharePreset.readOnly:
return kPermissionRead;
case SharePreset.edit:
var base = kPermissionRead | kPermissionUpdate;
if (isFolder) base |= kPermissionCreate | kPermissionDelete;
return allowReshare ? base | kPermissionShare : base;
case SharePreset.fileDrop:
return kPermissionCreate;
}
}
/// Classifies an arbitrary permission bitmask into the closest preset, or null
/// if it doesn't match any (e.g. a custom combination). The reshare bit is
/// ignored for matching so an "edit" share stays "edit" regardless of reshare.
SharePreset? presetFromBitmask(int mask) {
final normalized = mask & ~kPermissionShare;
if (normalized == kPermissionCreate) return SharePreset.fileDrop;
if (normalized == kPermissionRead) return SharePreset.readOnly;
// Any read share that also carries a write bit (update/create/delete) is
// surfaced as "edit".
const writeBits = kPermissionUpdate | kPermissionCreate | kPermissionDelete;
if (hasPermission(normalized, kPermissionRead) &&
normalized & writeBits != 0) {
return SharePreset.edit;
}
return null;
}
@@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:http/http.dart' as http;
import '../../../api_params.dart';
@@ -62,3 +64,44 @@ class DeleteMessage extends TalkApi {
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
class SetRoomAvatar extends TalkApi {
final String chatToken;
final Uint8List bytes;
final String filename;
SetRoomAvatar(this.chatToken, this.bytes, {this.filename = 'avatar.jpg'})
: super('v1/room/$chatToken/avatar', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) async {
final req = http.MultipartRequest('POST', uri)
..headers.addAll(headers ?? const {})
..files.add(http.MultipartFile.fromBytes('file', bytes, filename: filename));
final streamed = await req.send();
return http.Response.fromStream(streamed);
}
}
class DeleteRoomAvatar extends TalkApi {
final String chatToken;
DeleteRoomAvatar(this.chatToken) : super('v1/room/$chatToken/avatar', null);
@override
ApiResponse? assemble(String raw) => null;
@override
Future<http.Response> request(
Uri uri,
ApiParams? body,
Map<String, String>? headers,
) => http.delete(uri, headers: headers);
}
@@ -24,6 +24,11 @@ class CacheableFile {
/// when a preview is going to load anyway.
bool? hasPreview;
/// True when this entry is an incoming share — i.e. shared with the current
/// user by someone else (`nc:mount-type == 'shared'`). Used to badge the
/// file/folder in the list. Nullable so older cached entries decode fine.
bool? isSharedWithMe;
CacheableFile({
required this.path,
required this.isDirectory,
@@ -35,6 +40,7 @@ class CacheableFile {
this.modifiedAt,
this.fileId,
this.hasPreview,
this.isSharedWithMe,
});
CacheableFile.fromDavFile(WebDavFile file) {
@@ -48,6 +54,11 @@ class CacheableFile {
modifiedAt = file.lastModified;
fileId = int.tryParse(file.fileId ?? '');
hasPreview = file.hasPreview;
// Incoming share: the item is mounted into the user's files by someone
// else. Outgoing shares ([isSharedByMe]) can't be derived from WebDAV with
// the pinned package, so they are filled in by ListFiles via one OCS call
// per folder.
isSharedWithMe = file.props.ncmounttype == 'shared';
}
factory CacheableFile.fromJson(Map<String, dynamic> json) =>
@@ -22,6 +22,7 @@ CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
: DateTime.parse(json['modifiedAt'] as String),
fileId: (json['fileId'] as num?)?.toInt(),
hasPreview: json['hasPreview'] as bool?,
isSharedWithMe: json['isSharedWithMe'] as bool?,
);
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
@@ -36,4 +37,5 @@ Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
'modifiedAt': instance.modifiedAt?.toIso8601String(),
'fileId': instance.fileId,
'hasPreview': instance.hasPreview,
'isSharedWithMe': instance.isSharedWithMe,
};
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:nextcloud/nextcloud.dart';
import '../../webdav_api.dart';
@@ -37,19 +39,46 @@ class ListFiles extends WebdavApi<ListFilesParams> {
ocsize: true,
nccreationtime: true,
nchaspreview: true,
// 'shared' here means an incoming share (mounted into the user's files
// by someone else); used to badge those entries in the list.
ncmounttype: true,
);
var files = await _fetch(webdav, prop, timeout);
// A freshly-entered incoming share sometimes answers its first PROPFIND
// without the OC/NC props (no fileid / has-preview / mount-type) while the
// share mount warms up server-side — which drops thumbnails AND share
// badges together. Retry a couple of times so the folder self-heals
// instead of needing manual re-entry.
for (var attempt = 0; attempt < 2 && _looksIncomplete(files); attempt++) {
await Future<void>.delayed(const Duration(milliseconds: 700));
files = await _fetch(webdav, prop, timeout);
}
return ListFilesResponse(files);
}
Future<Set<CacheableFile>> _fetch(
WebDavClient webdav,
WebDavPropWithoutValues prop,
Duration timeout,
) async {
final davFiles =
(await webdav
.propfind(PathUri.parse(params.path), prop: prop)
.timeout(timeout))
.toWebDavFiles();
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
// somehow the current working folder is also listed, it is filtered here.
files.removeWhere(
(element) => element.path == '/${params.path}/' || element.path == '/',
);
return ListFilesResponse(files);
return files;
}
/// True when the server returned entries but none carry a `fileId` — a sign
/// the OC/NC properties were omitted (cold share mount), so thumbnails and
/// share badges would be missing for the whole folder.
bool _looksIncomplete(Set<CacheableFile> files) =>
files.isNotEmpty && files.every((file) => file.fileId == null);
}
@@ -40,9 +40,7 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
/// [invalidate].
static int _cacheTimeFor(String path) {
final stripped = path.replaceAll('/', '').trim();
return stripped.isEmpty
? RequestCache.cacheDay
: RequestCache.cacheNothing;
return stripped.isEmpty ? RequestCache.cacheDay : RequestCache.cacheNothing;
}
/// Triggers a root-listing fetch in the background if no cached payload
@@ -0,0 +1,110 @@
import 'package:dio/dio.dart';
import '../../../model/account_data.dart';
import '../queries/auth_login/auth_login.dart';
import 'device_token_name.dart';
import 'token_storage.dart';
/// Adds the bearer token to outgoing Marianum-Connect requests and, on 401,
/// re-logs in once with the credentials in [AccountData] before retrying.
class MarianumConnectAuthInterceptor extends Interceptor {
static const _retriedKey = 'mc_auth_retried';
final MarianumConnectTokenStorage _tokenStorage;
final Dio _retryDio;
final AuthLogin _loginClient;
// Single-flight lock: parallel 401s share the same login Future instead of
// each spawning a fresh row in api_tokens.
Future<bool>? _pendingReLogin;
MarianumConnectAuthInterceptor({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? retryDio,
AuthLogin? loginClient,
}) : _tokenStorage = tokenStorage,
_retryDio = retryDio ?? Dio(),
_loginClient = loginClient ?? AuthLogin();
@override
Future<void> onRequest(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
// Wait for an in-flight re-login so nachrückende Requests den frischen
// Token mitschicken statt ein eigenes 401 einzufangen.
final pending = _pendingReLogin;
if (pending != null) await pending;
final token = await _tokenStorage.readToken();
if (token != null && token.isNotEmpty) {
options.headers['Authorization'] = 'Bearer $token';
}
handler.next(options);
}
@override
Future<void> onError(
DioException err,
ErrorInterceptorHandler handler,
) async {
final response = err.response;
if (response?.statusCode != 401 ||
err.requestOptions.extra[_retriedKey] == true) {
handler.next(err);
return;
}
final refreshed = await _attemptReLogin();
if (!refreshed) {
handler.next(err);
return;
}
try {
final retried = await _retryWithFreshToken(err.requestOptions);
handler.resolve(retried);
} on DioException catch (retryError) {
handler.next(retryError);
}
}
Future<bool> _attemptReLogin() {
final inFlight = _pendingReLogin;
if (inFlight != null) return inFlight;
final fresh = _performReLogin();
_pendingReLogin = fresh;
fresh.whenComplete(() {
if (identical(_pendingReLogin, fresh)) _pendingReLogin = null;
});
return fresh;
}
Future<bool> _performReLogin() async {
if (!AccountData().isPopulated()) return false;
try {
await _loginClient.run(
username: AccountData().getUsername(),
password: AccountData().getPassword(),
tokenName: await DeviceTokenName.resolve(),
);
return true;
} catch (_) {
await _tokenStorage.clear();
return false;
}
}
Future<Response<dynamic>> _retryWithFreshToken(
RequestOptions originalOptions,
) async {
final freshToken = await _tokenStorage.readToken();
final headers = Map<String, dynamic>.of(originalOptions.headers);
if (freshToken != null && freshToken.isNotEmpty) {
headers['Authorization'] = 'Bearer $freshToken';
}
final clone = originalOptions.copyWith(
headers: headers,
extra: {...originalOptions.extra, _retriedKey: true},
);
return _retryDio.fetch<dynamic>(clone);
}
}
@@ -0,0 +1,41 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
/// Bearer-token display name shown in the dashboard token list, in the form
/// `"Marianum Fulda App (Pixel 10)"`. Cached because device-info never
/// changes at runtime.
class DeviceTokenName {
static const String _appName = 'Marianum Fulda App';
static String? _cached;
static Future<String> resolve() async {
if (_cached != null) return _cached!;
final device = await _deviceLabel();
_cached = device.isEmpty ? _appName : '$_appName ($device)';
return _cached!;
}
static Future<String> _deviceLabel() async {
try {
final info = DeviceInfoPlugin();
if (Platform.isAndroid) {
final android = await info.androidInfo;
final model = android.model.trim();
return model.isNotEmpty ? model : android.device.trim();
}
if (Platform.isIOS) {
final ios = await info.iosInfo;
// utsname.machine bleibt auch ohne user-zugewiesenen Gerätenamen
// verfügbar; ios.name liefert auf iOS 16+ nur noch Generika.
final machine = ios.utsname.machine.trim();
if (machine.isNotEmpty) return machine;
return ios.name.trim();
}
} catch (_) {
// Device-Plugin nicht verfügbar (z.B. Tests).
}
return '';
}
}
@@ -0,0 +1,32 @@
import 'dart:developer';
import '../../../model/account_data.dart';
import '../../errors/auth_exception.dart';
import '../queries/auth_logout/auth_logout.dart';
import '../queries/auth_verify/auth_verify.dart';
import 'token_storage.dart';
/// Background credential probe — a server-side password rotation forces a
/// re-login on the next cold start even when the bearer token would still
/// be accepted.
class SessionValidator {
static Future<void> probeStored({
required Future<void> Function() onInvalidated,
}) async {
if (!AccountData().isPopulated()) return;
final username = AccountData().getUsername();
final password = AccountData().getPassword();
try {
await AuthVerify().run(username: username, password: password);
} on AuthException catch (e) {
if (e.statusCode != 401) return;
log('MC: stored credentials rejected — forcing re-login');
await AuthLogout().run();
await const MarianumConnectTokenStorage().clear();
await AccountData().removeData();
await onInvalidated();
} catch (e) {
log('MC: background credential check failed (transient): $e');
}
}
}
@@ -0,0 +1,45 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 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.
class MarianumConnectTokenStorage {
static const _tokenKey = 'mc_bearer_token';
static const _tokenIdKey = 'mc_token_id';
static const _expiresAtKey = 'mc_token_expires_at';
final FlutterSecureStorage _storage;
const MarianumConnectTokenStorage([
this._storage = const FlutterSecureStorage(),
]);
Future<String?> readToken() => _storage.read(key: _tokenKey);
Future<String?> readTokenId() => _storage.read(key: _tokenIdKey);
Future<DateTime?> readExpiresAt() async {
final raw = await _storage.read(key: _expiresAtKey);
if (raw == null || raw.isEmpty) return null;
return DateTime.tryParse(raw);
}
Future<void> write({
required String token,
required String tokenId,
required DateTime? expiresAt,
}) async {
await _storage.write(key: _tokenKey, value: token);
await _storage.write(key: _tokenIdKey, value: tokenId);
await _storage.write(
key: _expiresAtKey,
value: expiresAt?.toIso8601String() ?? '',
);
}
Future<void> clear() async {
await _storage.delete(key: _tokenKey);
await _storage.delete(key: _tokenIdKey);
await _storage.delete(key: _expiresAtKey);
}
}
@@ -0,0 +1,49 @@
import 'package:dio/dio.dart';
import '../../errors/app_exception.dart';
import '../../errors/auth_exception.dart';
import '../../errors/network_exception.dart';
import '../../errors/parse_exception.dart';
import '../../errors/server_exception.dart';
/// Converts a DioException raised against the Marianum-Connect API into one of
/// the app's typed AppExceptions. Keeps the dio dependency out of call sites
/// that just want to render an error message.
AppException mapMarianumConnectError(DioException error) {
switch (error.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
return NetworkException.timeout(technicalDetails: error.message);
case DioExceptionType.connectionError:
return NetworkException(technicalDetails: error.message);
case DioExceptionType.badCertificate:
return const NetworkException(
userMessage:
'Die sichere Verbindung zum Marianum-Connect-Server wurde abgelehnt.',
);
case DioExceptionType.badResponse:
final status = error.response?.statusCode ?? -1;
if (status == 401) {
return AuthException.unauthorized(
technicalDetails: 'MC 401: ${error.response?.data}',
);
}
if (status == 403) {
return AuthException.forbidden(
technicalDetails: 'MC 403: ${error.response?.data}',
);
}
return ServerException(
statusCode: status,
technicalDetails: 'MC HTTP $status: ${error.response?.data}',
);
case DioExceptionType.cancel:
case DioExceptionType.unknown:
final inner = error.error;
if (inner is FormatException) {
return ParseException(technicalDetails: inner.message);
}
return NetworkException(technicalDetails: error.message);
}
}
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import 'auth/auth_interceptor.dart';
/// Singleton dio instance for the Marianum-Connect mobile API. Wired with the
/// bearer auth interceptor at startup; the base URL is resolved per request
/// through [MarianumConnectEndpoint] so settings changes take effect without
/// recreating the client.
class MarianumConnectApi {
static const Duration _connectTimeout = Duration(seconds: 10);
static const Duration _receiveTimeout = Duration(seconds: 20);
static final Dio _instance = _build();
static Dio dio() => _instance;
static Dio _build() {
final dio = Dio(
BaseOptions(
connectTimeout: _connectTimeout,
sendTimeout: _connectTimeout,
receiveTimeout: _receiveTimeout,
responseType: ResponseType.json,
contentType: 'application/json',
),
);
dio.interceptors.add(MarianumConnectAuthInterceptor());
return dio;
}
}
@@ -0,0 +1,22 @@
import '../../storage/dev_tools_settings.dart';
/// Singleton holding the currently active Marianum-Connect base URL. Fed by a
/// SettingsCubit listener in app.dart so every dio call picks up endpoint
/// changes without holding a reference to the cubit.
class MarianumConnectEndpoint {
static String _baseUrl = DevToolsSettings.liveUrl;
static String current() => _baseUrl;
static void update(String baseUrl) {
_baseUrl = baseUrl;
}
/// Joins the base URL with the mobile API prefix and the given path.
static String resolve(String relativePath) {
final path = relativePath.startsWith('/')
? relativePath.substring(1)
: relativePath;
return '$_baseUrl/api/mobile/v1/$path';
}
}
@@ -0,0 +1,30 @@
import 'package:json_annotation/json_annotation.dart';
part 'mc_holiday.g.dart';
@JsonSerializable(explicitToJson: true)
class McHoliday {
final String shortName;
final String longName;
@JsonKey(fromJson: _dateFromJson, toJson: _dateToJson)
final DateTime startDate;
@JsonKey(fromJson: _dateFromJson, toJson: _dateToJson)
final DateTime endDate;
McHoliday({
required this.shortName,
required this.longName,
required this.startDate,
required this.endDate,
});
factory McHoliday.fromJson(Map<String, dynamic> json) =>
_$McHolidayFromJson(json);
Map<String, dynamic> toJson() => _$McHolidayToJson(this);
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')}';
}
@@ -0,0 +1,21 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'mc_holiday.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McHoliday _$McHolidayFromJson(Map<String, dynamic> json) => McHoliday(
shortName: json['shortName'] as String,
longName: json['longName'] as String,
startDate: McHoliday._dateFromJson(json['startDate'] as String),
endDate: McHoliday._dateFromJson(json['endDate'] as String),
);
Map<String, dynamic> _$McHolidayToJson(McHoliday instance) => <String, dynamic>{
'shortName': instance.shortName,
'longName': instance.longName,
'startDate': McHoliday._dateToJson(instance.startDate),
'endDate': McHoliday._dateToJson(instance.endDate),
};
@@ -0,0 +1,61 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.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 {
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',
),
);
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);
}
}
}
@@ -0,0 +1,54 @@
import 'package:json_annotation/json_annotation.dart';
part 'auth_login_response.g.dart';
@JsonSerializable()
class AuthLoginUser {
final String id;
final String username;
final String firstName;
final String lastName;
final String? userType;
final String? className;
AuthLoginUser({
required this.id,
required this.username,
required this.firstName,
required this.lastName,
required this.userType,
required this.className,
});
factory AuthLoginUser.fromJson(Map<String, dynamic> json) =>
_$AuthLoginUserFromJson(json);
Map<String, dynamic> toJson() => _$AuthLoginUserToJson(this);
}
@JsonSerializable()
class AuthLoginResponse {
final String token;
final String tokenId;
@JsonKey(fromJson: _expiresFromJson)
final DateTime? expiresAt;
final AuthLoginUser user;
AuthLoginResponse({
required this.token,
required this.tokenId,
required this.expiresAt,
required this.user,
});
factory AuthLoginResponse.fromJson(Map<String, dynamic> json) =>
_$AuthLoginResponseFromJson(json);
Map<String, dynamic> toJson() => _$AuthLoginResponseToJson(this);
static DateTime? _expiresFromJson(Object? value) {
if (value == null) return null;
if (value is String) return DateTime.tryParse(value);
return null;
}
}
@@ -0,0 +1,43 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'auth_login_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthLoginUser _$AuthLoginUserFromJson(Map<String, dynamic> json) =>
AuthLoginUser(
id: json['id'] as String,
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> _$AuthLoginUserToJson(AuthLoginUser instance) =>
<String, dynamic>{
'id': instance.id,
'username': instance.username,
'firstName': instance.firstName,
'lastName': instance.lastName,
'userType': instance.userType,
'className': instance.className,
};
AuthLoginResponse _$AuthLoginResponseFromJson(Map<String, dynamic> json) =>
AuthLoginResponse(
token: json['token'] as String,
tokenId: json['tokenId'] as String,
expiresAt: AuthLoginResponse._expiresFromJson(json['expiresAt']),
user: AuthLoginUser.fromJson(json['user'] as Map<String, dynamic>),
);
Map<String, dynamic> _$AuthLoginResponseToJson(AuthLoginResponse instance) =>
<String, dynamic>{
'token': instance.token,
'tokenId': instance.tokenId,
'expiresAt': instance.expiresAt?.toIso8601String(),
'user': instance.user,
};
@@ -0,0 +1,30 @@
import 'package:dio/dio.dart';
import '../../auth/token_storage.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.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 {
final MarianumConnectTokenStorage _tokenStorage;
final Dio _dio;
AuthLogout({
MarianumConnectTokenStorage tokenStorage =
const MarianumConnectTokenStorage(),
Dio? dio,
}) : _tokenStorage = tokenStorage,
_dio = dio ?? MarianumConnectApi.dio();
Future<void> run() async {
try {
await _dio.post<void>(MarianumConnectEndpoint.resolve('auth/logout'));
} on DioException catch (_) {
// ignore — local clear below still happens
} finally {
await _tokenStorage.clear();
}
}
}
@@ -0,0 +1,62 @@
import 'package:dio/dio.dart';
import '../../../errors/auth_exception.dart';
import '../../auth/token_storage.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_endpoint.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
/// token was issued for — a password rotation on that user's account flips
/// it to 401 even if the token itself would still be accepted.
///
/// 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 {
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',
),
);
/// Throws [AuthException] on 401 (credentials no longer match the token's
/// user, token missing, or token rejected), other [AppException]s on
/// network/server errors. Completes silently on success.
Future<void> run({
required String username,
required String password,
}) async {
final token = await _tokenStorage.readToken();
if (token == null || token.isEmpty) {
throw AuthException.unauthorized(
technicalDetails: 'AuthVerify: no bearer token in storage',
);
}
try {
await _dio.post<void>(
MarianumConnectEndpoint.resolve('auth/verify'),
data: {'username': username, 'password': password},
options: Options(headers: {'Authorization': 'Bearer $token'}),
);
} on DioException catch (e) {
throw mapMarianumConnectError(e);
}
}
}
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.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;
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);
}
}
}
@@ -0,0 +1,19 @@
import 'package:json_annotation/json_annotation.dart';
part 'get_capabilities_response.g.dart';
/// Slimmed-down capability flags the mobile UI gates features on. The backend
/// only returns the handful of permissions the app actually consumes — not a
/// full permission dump. Unknown/missing fields default to `false` so a stale
/// client never accidentally enables a feature it shouldn't.
@JsonSerializable()
class CapabilitiesResponse {
@JsonKey(defaultValue: false)
final bool viewForeignTimetables;
CapabilitiesResponse({required this.viewForeignTimetables});
factory CapabilitiesResponse.fromJson(Map<String, dynamic> json) =>
_$CapabilitiesResponseFromJson(json);
Map<String, dynamic> toJson() => _$CapabilitiesResponseToJson(this);
}
@@ -0,0 +1,17 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'get_capabilities_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
CapabilitiesResponse _$CapabilitiesResponseFromJson(
Map<String, dynamic> json,
) => CapabilitiesResponse(
viewForeignTimetables: json['viewForeignTimetables'] as bool? ?? false,
);
Map<String, dynamic> _$CapabilitiesResponseToJson(
CapabilitiesResponse instance,
) => <String, dynamic>{'viewForeignTimetables': instance.viewForeignTimetables};
@@ -0,0 +1,25 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import '../../models/mc_holiday.dart';
class GetHolidays {
final Dio _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);
}
}
}
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_classes_response.dart';
class TimetableGetClasses {
final Dio _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);
}
}
}
@@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_classes_response.g.dart';
@JsonSerializable(explicitToJson: true)
class McTimetableClass {
final int id;
final String shortName;
final String longName;
McTimetableClass({
required this.id,
required this.shortName,
required this.longName,
});
factory McTimetableClass.fromJson(Map<String, dynamic> json) =>
_$McTimetableClassFromJson(json);
Map<String, dynamic> toJson() => _$McTimetableClassToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TimetableGetClassesResponse extends ApiResponse {
final List<McTimetableClass> result;
TimetableGetClassesResponse({required this.result});
factory TimetableGetClassesResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetClassesResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetClassesResponseToJson(this);
}
@@ -0,0 +1,40 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_classes_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McTimetableClass _$McTimetableClassFromJson(Map<String, dynamic> json) =>
McTimetableClass(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
);
Map<String, dynamic> _$McTimetableClassToJson(McTimetableClass instance) =>
<String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
};
TimetableGetClassesResponse _$TimetableGetClassesResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetClassesResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McTimetableClass.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> _$TimetableGetClassesResponseToJson(
TimetableGetClassesResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,43 @@
/// A concrete, selectable timetable element: its type, WebUntis element id and
/// a human-readable label (room short name, abbreviated student name, …). Used
/// to hand a picker selection back to the timetable view and to drive the
/// inline foreign-plan rendering.
typedef TimetableElementRef = ({TimetableElementType type, int id, String label});
/// The four kinds of timetable elements whose schedule can be requested via
/// `timetable/{type}/{id}`. `schoolClass` is named to avoid the reserved Dart
/// keyword `class`; its [pathSegment] maps back to the backend's `class`.
enum TimetableElementType {
student,
teacher,
room,
schoolClass;
/// Path segment used in the backend timetable endpoint URL.
String get pathSegment {
switch (this) {
case TimetableElementType.student:
return 'student';
case TimetableElementType.teacher:
return 'teacher';
case TimetableElementType.room:
return 'room';
case TimetableElementType.schoolClass:
return 'class';
}
}
/// Singular German label for the UI (picker segments, hints).
String get label {
switch (this) {
case TimetableElementType.student:
return 'Schüler';
case TimetableElementType.teacher:
return 'Lehrer';
case TimetableElementType.room:
return 'Raum';
case TimetableElementType.schoolClass:
return 'Klasse';
}
}
}
@@ -0,0 +1,36 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.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();
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);
}
}
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,26 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_holidays_response.dart';
class TimetableGetHolidays {
final Dio _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);
}
}
}
@@ -0,0 +1,19 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
import '../../models/mc_holiday.dart';
export '../../models/mc_holiday.dart';
part 'timetable_get_holidays_response.g.dart';
@JsonSerializable(explicitToJson: true)
class TimetableGetHolidaysResponse extends ApiResponse {
final List<McHoliday> result;
TimetableGetHolidaysResponse({required this.result});
factory TimetableGetHolidaysResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetHolidaysResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetHolidaysResponseToJson(this);
}
@@ -0,0 +1,26 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_holidays_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TimetableGetHolidaysResponse _$TimetableGetHolidaysResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetHolidaysResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McHoliday.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> _$TimetableGetHolidaysResponseToJson(
TimetableGetHolidaysResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_rooms_response.dart';
class TimetableGetRooms {
final Dio _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);
}
}
}
@@ -0,0 +1,28 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_rooms_response.g.dart';
@JsonSerializable(explicitToJson: true)
class McRoom {
final int id;
final String shortName;
final String longName;
McRoom({required this.id, required this.shortName, required this.longName});
factory McRoom.fromJson(Map<String, dynamic> json) => _$McRoomFromJson(json);
Map<String, dynamic> toJson() => _$McRoomToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TimetableGetRoomsResponse extends ApiResponse {
final List<McRoom> result;
TimetableGetRoomsResponse({required this.result});
factory TimetableGetRoomsResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetRoomsResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetRoomsResponseToJson(this);
}
@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_rooms_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McRoom _$McRoomFromJson(Map<String, dynamic> json) => McRoom(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
);
Map<String, dynamic> _$McRoomToJson(McRoom instance) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
};
TimetableGetRoomsResponse _$TimetableGetRoomsResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetRoomsResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McRoom.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> _$TimetableGetRoomsResponseToJson(
TimetableGetRoomsResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,23 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_schoolyear_response.dart';
class TimetableGetSchoolyear {
final Dio _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);
}
}
}
@@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_schoolyear_response.g.dart';
@JsonSerializable(explicitToJson: true)
class TimetableGetSchoolyearResponse extends ApiResponse {
final int id;
final String name;
@JsonKey(fromJson: _dateFromJson, toJson: _dateToJson)
final DateTime startDate;
@JsonKey(fromJson: _dateFromJson, toJson: _dateToJson)
final DateTime endDate;
TimetableGetSchoolyearResponse({
required this.id,
required this.name,
required this.startDate,
required this.endDate,
});
factory TimetableGetSchoolyearResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetSchoolyearResponseFromJson(json);
Map<String, dynamic> toJson() =>
_$TimetableGetSchoolyearResponseToJson(this);
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')}';
}
@@ -0,0 +1,34 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_schoolyear_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
TimetableGetSchoolyearResponse _$TimetableGetSchoolyearResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetSchoolyearResponse(
id: (json['id'] as num).toInt(),
name: json['name'] as String,
startDate: TimetableGetSchoolyearResponse._dateFromJson(
json['startDate'] as String,
),
endDate: TimetableGetSchoolyearResponse._dateFromJson(
json['endDate'] as String,
),
)
..headers = (json['headers'] as Map<String, dynamic>?)?.map(
(k, e) => MapEntry(k, e as String),
);
Map<String, dynamic> _$TimetableGetSchoolyearResponseToJson(
TimetableGetSchoolyearResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'id': instance.id,
'name': instance.name,
'startDate': TimetableGetSchoolyearResponse._dateToJson(instance.startDate),
'endDate': TimetableGetSchoolyearResponse._dateToJson(instance.endDate),
};
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_students_response.dart';
class TimetableGetStudents {
final Dio _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);
}
}
}
@@ -0,0 +1,35 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_students_response.g.dart';
@JsonSerializable(explicitToJson: true)
class McTimetableStudent {
final int id;
final String firstName;
final String lastName;
final String displayName;
McTimetableStudent({
required this.id,
required this.firstName,
required this.lastName,
required this.displayName,
});
factory McTimetableStudent.fromJson(Map<String, dynamic> json) =>
_$McTimetableStudentFromJson(json);
Map<String, dynamic> toJson() => _$McTimetableStudentToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TimetableGetStudentsResponse extends ApiResponse {
final List<McTimetableStudent> result;
TimetableGetStudentsResponse({required this.result});
factory TimetableGetStudentsResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetStudentsResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetStudentsResponseToJson(this);
}
@@ -0,0 +1,42 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_students_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McTimetableStudent _$McTimetableStudentFromJson(Map<String, dynamic> json) =>
McTimetableStudent(
id: (json['id'] as num).toInt(),
firstName: json['firstName'] as String,
lastName: json['lastName'] as String,
displayName: json['displayName'] as String,
);
Map<String, dynamic> _$McTimetableStudentToJson(McTimetableStudent instance) =>
<String, dynamic>{
'id': instance.id,
'firstName': instance.firstName,
'lastName': instance.lastName,
'displayName': instance.displayName,
};
TimetableGetStudentsResponse _$TimetableGetStudentsResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetStudentsResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McTimetableStudent.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> _$TimetableGetStudentsResponseToJson(
TimetableGetStudentsResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_subjects_response.dart';
class TimetableGetSubjects {
final Dio _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);
}
}
}
@@ -0,0 +1,33 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_subjects_response.g.dart';
@JsonSerializable(explicitToJson: true)
class McSubject {
final int id;
final String shortName;
final String longName;
McSubject({
required this.id,
required this.shortName,
required this.longName,
});
factory McSubject.fromJson(Map<String, dynamic> json) =>
_$McSubjectFromJson(json);
Map<String, dynamic> toJson() => _$McSubjectToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TimetableGetSubjectsResponse extends ApiResponse {
final List<McSubject> result;
TimetableGetSubjectsResponse({required this.result});
factory TimetableGetSubjectsResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetSubjectsResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetSubjectsResponseToJson(this);
}
@@ -0,0 +1,38 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_subjects_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McSubject _$McSubjectFromJson(Map<String, dynamic> json) => McSubject(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
longName: json['longName'] as String,
);
Map<String, dynamic> _$McSubjectToJson(McSubject instance) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'longName': instance.longName,
};
TimetableGetSubjectsResponse _$TimetableGetSubjectsResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetSubjectsResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McSubject.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> _$TimetableGetSubjectsResponseToJson(
TimetableGetSubjectsResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,29 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_teachers_response.dart';
class TimetableGetTeachers {
final Dio _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);
}
}
}
@@ -0,0 +1,36 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_teachers_response.g.dart';
/// Picker list entry for a teacher. Named `...Element` to avoid colliding with
/// `McTimetableteacher` from the week response, which models the teacher *of a
/// lesson* (with substitution fields) rather than a selectable element.
@JsonSerializable(explicitToJson: true)
class McTimetableTeacherElement {
final int id;
final String shortName;
final String displayName;
McTimetableTeacherElement({
required this.id,
required this.shortName,
required this.displayName,
});
factory McTimetableTeacherElement.fromJson(Map<String, dynamic> json) =>
_$McTimetableTeacherElementFromJson(json);
Map<String, dynamic> toJson() => _$McTimetableTeacherElementToJson(this);
}
@JsonSerializable(explicitToJson: true)
class TimetableGetTeachersResponse extends ApiResponse {
final List<McTimetableTeacherElement> result;
TimetableGetTeachersResponse({required this.result});
factory TimetableGetTeachersResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetTeachersResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetTeachersResponseToJson(this);
}
@@ -0,0 +1,45 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_teachers_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McTimetableTeacherElement _$McTimetableTeacherElementFromJson(
Map<String, dynamic> json,
) => McTimetableTeacherElement(
id: (json['id'] as num).toInt(),
shortName: json['shortName'] as String,
displayName: json['displayName'] as String,
);
Map<String, dynamic> _$McTimetableTeacherElementToJson(
McTimetableTeacherElement instance,
) => <String, dynamic>{
'id': instance.id,
'shortName': instance.shortName,
'displayName': instance.displayName,
};
TimetableGetTeachersResponse _$TimetableGetTeachersResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetTeachersResponse(
result: (json['result'] as List<dynamic>)
.map(
(e) =>
McTimetableTeacherElement.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> _$TimetableGetTeachersResponseToJson(
TimetableGetTeachersResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,26 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_timegrid_response.dart';
class TimetableGetTimegrid {
final Dio _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);
}
}
}
@@ -0,0 +1,98 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_timegrid_response.g.dart';
/// Java DayOfWeek serializes as the enum name (MONDAY, TUESDAY, …).
enum McDayOfWeek {
monday,
tuesday,
wednesday,
thursday,
friday,
saturday,
sunday,
}
McDayOfWeek _dayFromJson(String raw) {
switch (raw.toUpperCase()) {
case 'MONDAY':
return McDayOfWeek.monday;
case 'TUESDAY':
return McDayOfWeek.tuesday;
case 'WEDNESDAY':
return McDayOfWeek.wednesday;
case 'THURSDAY':
return McDayOfWeek.thursday;
case 'FRIDAY':
return McDayOfWeek.friday;
case 'SATURDAY':
return McDayOfWeek.saturday;
case 'SUNDAY':
return McDayOfWeek.sunday;
default:
// Unknown values keep the timetable rendering from crashing; the UI
// falls back to its hardcoded grid in that case.
return McDayOfWeek.monday;
}
}
String _dayToJson(McDayOfWeek d) {
switch (d) {
case McDayOfWeek.monday:
return 'MONDAY';
case McDayOfWeek.tuesday:
return 'TUESDAY';
case McDayOfWeek.wednesday:
return 'WEDNESDAY';
case McDayOfWeek.thursday:
return 'THURSDAY';
case McDayOfWeek.friday:
return 'FRIDAY';
case McDayOfWeek.saturday:
return 'SATURDAY';
case McDayOfWeek.sunday:
return 'SUNDAY';
}
}
@JsonSerializable(explicitToJson: true)
class McTimegridUnit {
@JsonKey(fromJson: _dayFromJson, toJson: _dayToJson)
final McDayOfWeek dayOfWeek;
final String label;
@JsonKey(fromJson: _timeFromJson, toJson: _timeToJson)
final DateTime startTime;
@JsonKey(fromJson: _timeFromJson, toJson: _timeToJson)
final DateTime endTime;
McTimegridUnit({
required this.dayOfWeek,
required this.label,
required this.startTime,
required this.endTime,
});
factory McTimegridUnit.fromJson(Map<String, dynamic> json) =>
_$McTimegridUnitFromJson(json);
Map<String, dynamic> toJson() => _$McTimegridUnitToJson(this);
static DateTime _timeFromJson(String raw) => DateTime.parse('1970-01-01T$raw');
static String _timeToJson(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
}
@JsonSerializable(explicitToJson: true)
class TimetableGetTimegridResponse extends ApiResponse {
final List<McTimegridUnit> result;
TimetableGetTimegridResponse({required this.result});
factory TimetableGetTimegridResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetTimegridResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetTimegridResponseToJson(this);
}
@@ -0,0 +1,42 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_timegrid_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McTimegridUnit _$McTimegridUnitFromJson(Map<String, dynamic> json) =>
McTimegridUnit(
dayOfWeek: _dayFromJson(json['dayOfWeek'] as String),
label: json['label'] as String,
startTime: McTimegridUnit._timeFromJson(json['startTime'] as String),
endTime: McTimegridUnit._timeFromJson(json['endTime'] as String),
);
Map<String, dynamic> _$McTimegridUnitToJson(McTimegridUnit instance) =>
<String, dynamic>{
'dayOfWeek': _dayToJson(instance.dayOfWeek),
'label': instance.label,
'startTime': McTimegridUnit._timeToJson(instance.startTime),
'endTime': McTimegridUnit._timeToJson(instance.endTime),
};
TimetableGetTimegridResponse _$TimetableGetTimegridResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetTimegridResponse(
result: (json['result'] as List<dynamic>)
.map((e) => McTimegridUnit.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> _$TimetableGetTimegridResponseToJson(
TimetableGetTimegridResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'result': instance.result.map((e) => e.toJson()).toList(),
};
@@ -0,0 +1,33 @@
import 'package:dio/dio.dart';
import '../../errors/marianumconnect_error.dart';
import '../../marianumconnect_api.dart';
import '../../marianumconnect_endpoint.dart';
import 'timetable_get_week_response.dart';
class TimetableGetWeek {
final Dio _dio;
TimetableGetWeek({Dio? dio}) : _dio = dio ?? MarianumConnectApi.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);
}
}
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,108 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_response.dart';
part 'timetable_get_week_response.g.dart';
@JsonSerializable(explicitToJson: true)
class McTimetableTeacher {
final String shortName;
final String displayName;
final String? originalShortName;
final String? originalDisplayName;
McTimetableTeacher({
required this.shortName,
required this.displayName,
this.originalShortName,
this.originalDisplayName,
});
factory McTimetableTeacher.fromJson(Map<String, dynamic> json) =>
_$McTimetableTeacherFromJson(json);
Map<String, dynamic> toJson() => _$McTimetableTeacherToJson(this);
}
@JsonSerializable(explicitToJson: true)
class McTimetableEntry {
final int id;
@JsonKey(fromJson: _dateFromJson, toJson: _dateToJson)
final DateTime date;
@JsonKey(fromJson: _timeFromJson, toJson: _timeToJson)
final DateTime startTime;
@JsonKey(fromJson: _timeFromJson, toJson: _timeToJson)
final DateTime endTime;
final List<String> subjects;
final List<McTimetableTeacher> teachers;
final List<String> rooms;
final List<String> classNames;
final String lessonType;
final String status;
final String? substitutionText;
final String? lessonText;
final String? infoText;
McTimetableEntry({
required this.id,
required this.date,
required this.startTime,
required this.endTime,
required this.subjects,
required this.teachers,
required this.rooms,
required this.classNames,
required this.lessonType,
required this.status,
required this.substitutionText,
required this.lessonText,
required this.infoText,
});
factory McTimetableEntry.fromJson(Map<String, dynamic> json) =>
_$McTimetableEntryFromJson(json);
Map<String, dynamic> toJson() => _$McTimetableEntryToJson(this);
/// Combines the calendar date with the hour/minute portion of [startTime]
/// (which carries a 1970 placeholder date) into a real DateTime.
DateTime get startDateTime =>
DateTime(date.year, date.month, date.day, startTime.hour, startTime.minute);
DateTime get endDateTime =>
DateTime(date.year, date.month, date.day, endTime.hour, endTime.minute);
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')}';
// Backend sends ISO_LOCAL_TIME (e.g. "08:00:00" or "08:00"). Parsed via a
// fixed-date prefix so we get a real DateTime out of it; only hour/minute
// are meaningful for rendering.
static DateTime _timeFromJson(String raw) => DateTime.parse('1970-01-01T$raw');
static String _timeToJson(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}';
}
@JsonSerializable(explicitToJson: true)
class TimetableGetWeekResponse extends ApiResponse {
@JsonKey(fromJson: McTimetableEntry._dateFromJson, toJson: McTimetableEntry._dateToJson)
final DateTime from;
@JsonKey(fromJson: McTimetableEntry._dateFromJson, toJson: McTimetableEntry._dateToJson)
final DateTime until;
final List<McTimetableEntry> entries;
TimetableGetWeekResponse({
required this.from,
required this.until,
required this.entries,
});
factory TimetableGetWeekResponse.fromJson(Map<String, dynamic> json) =>
_$TimetableGetWeekResponseFromJson(json);
Map<String, dynamic> toJson() => _$TimetableGetWeekResponseToJson(this);
}
@@ -0,0 +1,86 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'timetable_get_week_response.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
McTimetableTeacher _$McTimetableTeacherFromJson(Map<String, dynamic> json) =>
McTimetableTeacher(
shortName: json['shortName'] as String,
displayName: json['displayName'] as String,
originalShortName: json['originalShortName'] as String?,
originalDisplayName: json['originalDisplayName'] as String?,
);
Map<String, dynamic> _$McTimetableTeacherToJson(McTimetableTeacher instance) =>
<String, dynamic>{
'shortName': instance.shortName,
'displayName': instance.displayName,
'originalShortName': instance.originalShortName,
'originalDisplayName': instance.originalDisplayName,
};
McTimetableEntry _$McTimetableEntryFromJson(Map<String, dynamic> json) =>
McTimetableEntry(
id: (json['id'] as num).toInt(),
date: McTimetableEntry._dateFromJson(json['date'] as String),
startTime: McTimetableEntry._timeFromJson(json['startTime'] as String),
endTime: McTimetableEntry._timeFromJson(json['endTime'] as String),
subjects: (json['subjects'] as List<dynamic>)
.map((e) => e as String)
.toList(),
teachers: (json['teachers'] as List<dynamic>)
.map((e) => McTimetableTeacher.fromJson(e as Map<String, dynamic>))
.toList(),
rooms: (json['rooms'] as List<dynamic>).map((e) => e as String).toList(),
classNames: (json['classNames'] as List<dynamic>)
.map((e) => e as String)
.toList(),
lessonType: json['lessonType'] as String,
status: json['status'] as String,
substitutionText: json['substitutionText'] as String?,
lessonText: json['lessonText'] as String?,
infoText: json['infoText'] as String?,
);
Map<String, dynamic> _$McTimetableEntryToJson(McTimetableEntry instance) =>
<String, dynamic>{
'id': instance.id,
'date': McTimetableEntry._dateToJson(instance.date),
'startTime': McTimetableEntry._timeToJson(instance.startTime),
'endTime': McTimetableEntry._timeToJson(instance.endTime),
'subjects': instance.subjects,
'teachers': instance.teachers.map((e) => e.toJson()).toList(),
'rooms': instance.rooms,
'classNames': instance.classNames,
'lessonType': instance.lessonType,
'status': instance.status,
'substitutionText': instance.substitutionText,
'lessonText': instance.lessonText,
'infoText': instance.infoText,
};
TimetableGetWeekResponse _$TimetableGetWeekResponseFromJson(
Map<String, dynamic> json,
) =>
TimetableGetWeekResponse(
from: McTimetableEntry._dateFromJson(json['from'] as String),
until: McTimetableEntry._dateFromJson(json['until'] as String),
entries: (json['entries'] as List<dynamic>)
.map((e) => McTimetableEntry.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> _$TimetableGetWeekResponseToJson(
TimetableGetWeekResponse instance,
) => <String, dynamic>{
'headers': ?instance.headers,
'from': McTimetableEntry._dateToJson(instance.from),
'until': McTimetableEntry._dateToJson(instance.until),
'entries': instance.entries.map((e) => e.toJson()).toList(),
};
@@ -1,66 +0,0 @@
import 'dart:async';
import 'dart:convert';
import '../../../../model/account_data.dart';
import '../../webuntis_api.dart';
import 'authenticate_params.dart';
import 'authenticate_response.dart';
class Authenticate extends WebuntisApi {
AuthenticateParams param;
Authenticate(this.param)
: super('authenticate', param, authenticatedResponse: false);
@override
Future<AuthenticateResponse> run() async {
awaitingResponse = true;
try {
final rawAnswer = await query(this);
final decoded = jsonDecode(rawAnswer) as Map<String, dynamic>;
final response = finalize(
AuthenticateResponse.fromJson(
decoded['result'] as Map<String, dynamic>,
),
);
_lastResponse = response;
if (!awaitedResponse.isCompleted) awaitedResponse.complete();
return response;
} catch (e) {
// Surface the error to anyone waiting on the current completer, then
// install a fresh one so a future attempt can succeed. Without this,
// any later call to getSession() would hang forever on a completer
// that is already settled with no listeners (or never settles at all).
if (!awaitedResponse.isCompleted) awaitedResponse.completeError(e);
awaitedResponse = Completer<void>();
rethrow;
} finally {
awaitingResponse = false;
}
}
static bool awaitingResponse = false;
static Completer<void> awaitedResponse = Completer<void>();
static AuthenticateResponse? _lastResponse;
static Future<void> createSession() async {
_lastResponse = await Authenticate(
AuthenticateParams(
user: AccountData().getUsername(),
password: AccountData().getPassword(),
),
).run();
}
static Future<AuthenticateResponse> getSession() async {
if (awaitingResponse) {
await awaitedResponse.future;
}
if (_lastResponse == null) {
awaitingResponse = true;
await createSession();
}
return _lastResponse!;
}
}
@@ -1,17 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../../../api_params.dart';
part 'authenticate_params.g.dart';
@JsonSerializable()
class AuthenticateParams extends ApiParams {
String user;
String password;
AuthenticateParams({required this.user, required this.password});
factory AuthenticateParams.fromJson(Map<String, dynamic> json) =>
_$AuthenticateParamsFromJson(json);
Map<String, dynamic> toJson() => _$AuthenticateParamsToJson(this);
}
@@ -1,16 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'authenticate_params.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
AuthenticateParams _$AuthenticateParamsFromJson(Map<String, dynamic> json) =>
AuthenticateParams(
user: json['user'] as String,
password: json['password'] as String,
);
Map<String, dynamic> _$AuthenticateParamsToJson(AuthenticateParams instance) =>
<String, dynamic>{'user': instance.user, 'password': instance.password};

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