Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 46d6b3410e | |||
| 067012cc84 |
@@ -4,10 +4,4 @@
|
|||||||
to allow setting breakpoints, to provide hot reload, etc.
|
to allow setting breakpoints, to provide hot reload, etc.
|
||||||
-->
|
-->
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<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>
|
</manifest>
|
||||||
|
|||||||
@@ -4,7 +4,8 @@
|
|||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher"
|
android:icon="@mipmap/ic_launcher"
|
||||||
android:fullBackupContent="@xml/backup_rules"
|
android:fullBackupContent="@xml/backup_rules"
|
||||||
android:dataExtractionRules="@xml/data_extraction_rules">
|
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||||
|
android:networkSecurityConfig="@xml/network_security_config">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -106,4 +107,7 @@
|
|||||||
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
<!-- Workmanager periodic widget refresh needs to reschedule after device
|
||||||
reboot, otherwise the widget freezes until the user opens the app. -->
|
reboot, otherwise the widget freezes until the user opens the app. -->
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<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>
|
</manifest>
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<?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>
|
||||||
@@ -43,6 +43,21 @@
|
|||||||
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
|
<string>Um Fotos direkt aus der App aufnehmen und teilen zu können wird Zugriff auf die Kamera benötigt.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>Um Medien mit anderen zu teilen wird Zugriff zu deine Dateien benötigt.</string>
|
<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>
|
<key>UIApplicationSceneManifest</key>
|
||||||
<dict>
|
<dict>
|
||||||
<key>UIApplicationSupportsMultipleScenes</key>
|
<key>UIApplicationSupportsMultipleScenes</key>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
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>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// 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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/// 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(),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,179 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
/// 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}');
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
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,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
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).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
/// 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)}';
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
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>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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>);
|
||||||
|
}
|
||||||
@@ -0,0 +1,243 @@
|
|||||||
|
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
@@ -0,0 +1,368 @@
|
|||||||
|
// 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(),
|
||||||
|
};
|
||||||
@@ -6,11 +6,13 @@ import 'package:http/http.dart' as http;
|
|||||||
|
|
||||||
import '../api_error.dart';
|
import '../api_error.dart';
|
||||||
import '../marianumcloud/talk/talk_error.dart';
|
import '../marianumcloud/talk/talk_error.dart';
|
||||||
|
import '../webuntis/webuntis_error.dart';
|
||||||
import 'app_exception.dart';
|
import 'app_exception.dart';
|
||||||
import 'network_exception.dart';
|
import 'network_exception.dart';
|
||||||
import 'parse_exception.dart';
|
import 'parse_exception.dart';
|
||||||
import 'server_exception.dart';
|
import 'server_exception.dart';
|
||||||
import 'talk_exception.dart';
|
import 'talk_exception.dart';
|
||||||
|
import 'webuntis_exception.dart';
|
||||||
|
|
||||||
const String _defaultFallback =
|
const String _defaultFallback =
|
||||||
'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
|
'Etwas ist schiefgelaufen. Bitte versuche es erneut.';
|
||||||
@@ -55,6 +57,7 @@ String errorToUserMessage(Object? error, {String fallback = _defaultFallback}) {
|
|||||||
if (error is AppException) return error.userMessage;
|
if (error is AppException) return error.userMessage;
|
||||||
|
|
||||||
if (error is TalkError) return TalkException(error).userMessage;
|
if (error is TalkError) return TalkException(error).userMessage;
|
||||||
|
if (error is WebuntisError) return WebuntisException(error).userMessage;
|
||||||
|
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
@@ -87,6 +90,7 @@ String? errorToTechnicalDetails(Object? error) {
|
|||||||
if (error == null) return null;
|
if (error == null) return null;
|
||||||
if (error is AppException) return error.technicalDetails ?? error.toString();
|
if (error is AppException) return error.technicalDetails ?? error.toString();
|
||||||
if (error is TalkError) return TalkException(error).technicalDetails;
|
if (error is TalkError) return TalkException(error).technicalDetails;
|
||||||
|
if (error is WebuntisError) return WebuntisException(error).technicalDetails;
|
||||||
if (error is DioException) {
|
if (error is DioException) {
|
||||||
final mapped = _dioToAppException(error);
|
final mapped = _dioToAppException(error);
|
||||||
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
|
if (mapped != null) return mapped.technicalDetails ?? mapped.toString();
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
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}).';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,283 @@
|
|||||||
|
// 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
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// 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,
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
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,39 +3,25 @@ import 'dart:io';
|
|||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../model/endpoint_data.dart';
|
|
||||||
import '../../errors/server_exception.dart';
|
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
import 'autocomplete_response.dart';
|
import 'autocomplete_response.dart';
|
||||||
|
|
||||||
class AutocompleteApi {
|
class AutocompleteApi {
|
||||||
/// Searches sharees (users by default). Pass [shareTypes] to widen the search
|
Future<AutocompleteResponse> find(String query) async {
|
||||||
/// — e.g. `[0, 1]` for both users and groups (0 = user, 1 = group).
|
final endpoint = NextcloudOcs.uri(
|
||||||
Future<AutocompleteResponse> find(
|
'core/autocomplete/get',
|
||||||
String query, {
|
queryParameters: {
|
||||||
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,
|
'search': query,
|
||||||
'itemType': ' ',
|
'itemType': ' ',
|
||||||
'itemId': ' ',
|
'itemId': ' ',
|
||||||
'shareTypes[]': shareTypes.map((t) => t.toString()).toList(),
|
'shareTypes[]': ['0'],
|
||||||
'limit': '10',
|
'limit': '10',
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final response = await http.get(uri, headers: NextcloudOcs.headers());
|
final response = await http.get(endpoint, headers: NextcloudOcs.headers());
|
||||||
if (response.statusCode != HttpStatus.ok) {
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
throw ServerException(
|
throw Exception(
|
||||||
statusCode: response.statusCode,
|
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||||
technicalDetails: 'core/autocomplete/get: ${response.body}',
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
final decoded = jsonDecode(response.body) as Map<String, dynamic>;
|
||||||
|
|||||||
@@ -1,17 +1,7 @@
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
|
|
||||||
import '../files_sharing/queries/share/share.dart';
|
|
||||||
|
|
||||||
part 'autocomplete_response.g.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)
|
@JsonSerializable(explicitToJson: true)
|
||||||
class AutocompleteResponse {
|
class AutocompleteResponse {
|
||||||
List<AutocompleteResponseObject> data;
|
List<AutocompleteResponseObject> data;
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
/// 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',
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
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,144 +1,21 @@
|
|||||||
import 'dart:convert';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../errors/server_exception.dart';
|
|
||||||
import '../nextcloud_ocs.dart';
|
import '../nextcloud_ocs.dart';
|
||||||
import 'file_sharing_api_params.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 {
|
class FileSharingApi {
|
||||||
static const String _base = 'apps/files_sharing/api/v1/shares';
|
Future<void> share(FileSharingApiParams query) async {
|
||||||
|
|
||||||
/// 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(
|
final endpoint = NextcloudOcs.uri(
|
||||||
_base,
|
'apps/files_sharing/api/v1/shares',
|
||||||
queryParameters: {'format': 'json'},
|
queryParameters: query.toJson(),
|
||||||
);
|
);
|
||||||
final response = await http.post(
|
final response = await http.post(endpoint, headers: NextcloudOcs.headers());
|
||||||
endpoint,
|
if (response.statusCode != HttpStatus.ok) {
|
||||||
headers: NextcloudOcs.headers(),
|
throw Exception(
|
||||||
body: _stringForm(query.toJson()),
|
'Api call failed with ${response.statusCode}: ${response.body}',
|
||||||
);
|
|
||||||
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,10 +2,7 @@ import 'package:json_annotation/json_annotation.dart';
|
|||||||
|
|
||||||
part 'file_sharing_api_params.g.dart';
|
part 'file_sharing_api_params.g.dart';
|
||||||
|
|
||||||
// includeIfNull:false so the optional sharing fields below are only sent when
|
@JsonSerializable()
|
||||||
// set — otherwise the OCS query would carry `permissions=null` etc. and be
|
|
||||||
// rejected.
|
|
||||||
@JsonSerializable(includeIfNull: false)
|
|
||||||
class FileSharingApiParams {
|
class FileSharingApiParams {
|
||||||
int shareType;
|
int shareType;
|
||||||
String shareWith;
|
String shareWith;
|
||||||
@@ -13,24 +10,12 @@ class FileSharingApiParams {
|
|||||||
String? referenceId;
|
String? referenceId;
|
||||||
String? talkMetaData;
|
String? talkMetaData;
|
||||||
|
|
||||||
/// Permission bitmask (see `share_permissions.dart`).
|
|
||||||
int? permissions;
|
|
||||||
|
|
||||||
/// Public link password.
|
|
||||||
String? password;
|
|
||||||
|
|
||||||
/// Expiry as `YYYY-MM-DD`.
|
|
||||||
String? expireDate;
|
|
||||||
|
|
||||||
FileSharingApiParams({
|
FileSharingApiParams({
|
||||||
required this.shareType,
|
required this.shareType,
|
||||||
required this.shareWith,
|
required this.shareWith,
|
||||||
required this.path,
|
required this.path,
|
||||||
this.referenceId,
|
this.referenceId,
|
||||||
this.talkMetaData,
|
this.talkMetaData,
|
||||||
this.permissions,
|
|
||||||
this.password,
|
|
||||||
this.expireDate,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
factory FileSharingApiParams.fromJson(Map<String, dynamic> json) =>
|
factory FileSharingApiParams.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -14,9 +14,6 @@ FileSharingApiParams _$FileSharingApiParamsFromJson(
|
|||||||
path: json['path'] as String,
|
path: json['path'] as String,
|
||||||
referenceId: json['referenceId'] as String?,
|
referenceId: json['referenceId'] as String?,
|
||||||
talkMetaData: json['talkMetaData'] 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(
|
Map<String, dynamic> _$FileSharingApiParamsToJson(
|
||||||
@@ -25,9 +22,6 @@ Map<String, dynamic> _$FileSharingApiParamsToJson(
|
|||||||
'shareType': instance.shareType,
|
'shareType': instance.shareType,
|
||||||
'shareWith': instance.shareWith,
|
'shareWith': instance.shareWith,
|
||||||
'path': instance.path,
|
'path': instance.path,
|
||||||
'referenceId': ?instance.referenceId,
|
'referenceId': instance.referenceId,
|
||||||
'talkMetaData': ?instance.talkMetaData,
|
'talkMetaData': instance.talkMetaData,
|
||||||
'permissions': ?instance.permissions,
|
|
||||||
'password': ?instance.password,
|
|
||||||
'expireDate': ?instance.expireDate,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
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);
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
/// 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']),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
/// 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
/// 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,5 +1,3 @@
|
|||||||
import 'dart:typed_data';
|
|
||||||
|
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
import '../../../api_params.dart';
|
import '../../../api_params.dart';
|
||||||
@@ -64,44 +62,3 @@ class DeleteMessage extends TalkApi {
|
|||||||
Map<String, String>? headers,
|
Map<String, String>? headers,
|
||||||
) => http.delete(uri, headers: 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,11 +24,6 @@ class CacheableFile {
|
|||||||
/// when a preview is going to load anyway.
|
/// when a preview is going to load anyway.
|
||||||
bool? hasPreview;
|
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({
|
CacheableFile({
|
||||||
required this.path,
|
required this.path,
|
||||||
required this.isDirectory,
|
required this.isDirectory,
|
||||||
@@ -40,7 +35,6 @@ class CacheableFile {
|
|||||||
this.modifiedAt,
|
this.modifiedAt,
|
||||||
this.fileId,
|
this.fileId,
|
||||||
this.hasPreview,
|
this.hasPreview,
|
||||||
this.isSharedWithMe,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
CacheableFile.fromDavFile(WebDavFile file) {
|
CacheableFile.fromDavFile(WebDavFile file) {
|
||||||
@@ -54,11 +48,6 @@ class CacheableFile {
|
|||||||
modifiedAt = file.lastModified;
|
modifiedAt = file.lastModified;
|
||||||
fileId = int.tryParse(file.fileId ?? '');
|
fileId = int.tryParse(file.fileId ?? '');
|
||||||
hasPreview = file.hasPreview;
|
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) =>
|
factory CacheableFile.fromJson(Map<String, dynamic> json) =>
|
||||||
|
|||||||
@@ -22,7 +22,6 @@ CacheableFile _$CacheableFileFromJson(Map<String, dynamic> json) =>
|
|||||||
: DateTime.parse(json['modifiedAt'] as String),
|
: DateTime.parse(json['modifiedAt'] as String),
|
||||||
fileId: (json['fileId'] as num?)?.toInt(),
|
fileId: (json['fileId'] as num?)?.toInt(),
|
||||||
hasPreview: json['hasPreview'] as bool?,
|
hasPreview: json['hasPreview'] as bool?,
|
||||||
isSharedWithMe: json['isSharedWithMe'] as bool?,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
||||||
@@ -37,5 +36,4 @@ Map<String, dynamic> _$CacheableFileToJson(CacheableFile instance) =>
|
|||||||
'modifiedAt': instance.modifiedAt?.toIso8601String(),
|
'modifiedAt': instance.modifiedAt?.toIso8601String(),
|
||||||
'fileId': instance.fileId,
|
'fileId': instance.fileId,
|
||||||
'hasPreview': instance.hasPreview,
|
'hasPreview': instance.hasPreview,
|
||||||
'isSharedWithMe': instance.isSharedWithMe,
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import 'dart:async';
|
|
||||||
|
|
||||||
import 'package:nextcloud/nextcloud.dart';
|
import 'package:nextcloud/nextcloud.dart';
|
||||||
|
|
||||||
import '../../webdav_api.dart';
|
import '../../webdav_api.dart';
|
||||||
@@ -39,46 +37,19 @@ class ListFiles extends WebdavApi<ListFilesParams> {
|
|||||||
ocsize: true,
|
ocsize: true,
|
||||||
nccreationtime: true,
|
nccreationtime: true,
|
||||||
nchaspreview: 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 =
|
final davFiles =
|
||||||
(await webdav
|
(await webdav
|
||||||
.propfind(PathUri.parse(params.path), prop: prop)
|
.propfind(PathUri.parse(params.path), prop: prop)
|
||||||
.timeout(timeout))
|
.timeout(timeout))
|
||||||
.toWebDavFiles();
|
.toWebDavFiles();
|
||||||
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
final files = davFiles.map(CacheableFile.fromDavFile).toSet();
|
||||||
|
|
||||||
// somehow the current working folder is also listed, it is filtered here.
|
// somehow the current working folder is also listed, it is filtered here.
|
||||||
files.removeWhere(
|
files.removeWhere(
|
||||||
(element) => element.path == '/${params.path}/' || element.path == '/',
|
(element) => element.path == '/${params.path}/' || element.path == '/',
|
||||||
);
|
);
|
||||||
return files;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True when the server returned entries but none carry a `fileId` — a sign
|
return ListFilesResponse(files);
|
||||||
/// 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,7 +40,9 @@ class ListFilesCache extends SimpleCache<ListFilesResponse> {
|
|||||||
/// [invalidate].
|
/// [invalidate].
|
||||||
static int _cacheTimeFor(String path) {
|
static int _cacheTimeFor(String path) {
|
||||||
final stripped = path.replaceAll('/', '').trim();
|
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
|
/// Triggers a root-listing fetch in the background if no cached payload
|
||||||
|
|||||||
@@ -1,110 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
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 '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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')}';
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
// 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),
|
|
||||||
};
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
// 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,
|
|
||||||
};
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
// 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};
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
-40
@@ -1,40 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
-43
@@ -1,43 +0,0 @@
|
|||||||
/// 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';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
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')}';
|
|
||||||
}
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-19
@@ -1,19 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
-26
@@ -1,26 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
-23
@@ -1,23 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
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')}';
|
|
||||||
}
|
|
||||||
-34
@@ -1,34 +0,0 @@
|
|||||||
// 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),
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-33
@@ -1,33 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
-38
@@ -1,38 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-36
@@ -1,36 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
-45
@@ -1,45 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
-98
@@ -1,98 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
-42
@@ -1,42 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
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')}';
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
// 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(),
|
|
||||||
};
|
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
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!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// 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
Reference in New Issue
Block a user