migrated timetable integration from WebUntis to the MarianumConnect API, implementing a Dio-based client with bearer token authentication, background session validation, and auto-refresh logic.

This commit is contained in:
2026-05-23 17:32:42 +02:00
parent 2858f910c9
commit 93b9929f8f
106 changed files with 2739 additions and 2624 deletions
@@ -0,0 +1,206 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumconnect/auth/token_storage.dart';
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../../storage/dev_tools_settings.dart';
import '../../../../storage/settings.dart' as model;
import '../../../../widget/details_bottom_sheet.dart';
/// Bottom-sheet that lets the user switch the active Marianum-Connect
/// endpoint (live / beta / custom). Shared between the dev-tools section and
/// the login screen so both places offer the exact same picker.
///
/// On commit the change lands in the SettingsCubit (which in turn updates
/// the dio singleton via the BlocBuilder in `main.dart`) and the currently
/// stored bearer token is cleared — that token belongs to the old host and
/// would be rejected by the new one.
class MarianumConnectEndpointPicker {
const MarianumConnectEndpointPicker._();
static void show(BuildContext context, SettingsCubit settings) {
showDetailsBottomSheet(
context,
header: const ListTile(title: Text('Marianum-Connect-Server')),
children: (sheetCtx) => [
BlocBuilder<SettingsCubit, model.Settings>(
bloc: settings,
builder: (_, _) {
final dev = settings.val().devToolsSettings;
return _PickerBody(
current: dev.marianumConnectEndpoint,
customUrl: dev.marianumConnectCustomUrl,
onChanged: (next, custom) async {
final mutable = settings.val(write: true).devToolsSettings;
mutable.marianumConnectEndpoint = next;
if (custom != null) mutable.marianumConnectCustomUrl = custom;
await const MarianumConnectTokenStorage().clear();
},
);
},
),
],
);
}
/// Short human-readable label of the currently selected endpoint — used by
/// the settings list tile and the login screen hint.
static String labelFor(DevToolsSettings dev) {
switch (dev.marianumConnectEndpoint) {
case MarianumConnectEndpoint.live:
return 'Normal (${DevToolsSettings.liveUrl})';
case MarianumConnectEndpoint.beta:
return 'Beta (${DevToolsSettings.betaUrl})';
case MarianumConnectEndpoint.custom:
final url = DevToolsSettings.sanitizeCustomUrl(
dev.marianumConnectCustomUrl,
);
return url == null
? 'Eigener Server (ungültig Normal wird verwendet)'
: 'Eigener Server ($url)';
}
}
}
class _PickerBody extends StatefulWidget {
final MarianumConnectEndpoint current;
final String customUrl;
final Future<void> Function(MarianumConnectEndpoint next, String? customUrl)
onChanged;
const _PickerBody({
required this.current,
required this.customUrl,
required this.onChanged,
});
@override
State<_PickerBody> createState() => _PickerBodyState();
}
class _PickerBodyState extends State<_PickerBody> {
late MarianumConnectEndpoint _selected;
late TextEditingController _customController;
String? _customError;
@override
void initState() {
super.initState();
_selected = widget.current;
_customController = TextEditingController(text: widget.customUrl);
}
@override
void dispose() {
_customController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final allowsHttp = DevToolsSettings.allowsHttpCustomEndpoint;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
RadioGroup<MarianumConnectEndpoint>(
groupValue: _selected,
onChanged: _selectEndpoint,
child: Column(
children: [
const RadioListTile<MarianumConnectEndpoint>(
title: Text('Normal'),
subtitle: Text(DevToolsSettings.liveUrl),
value: MarianumConnectEndpoint.live,
),
const RadioListTile<MarianumConnectEndpoint>(
title: Text('Beta'),
subtitle: Text(DevToolsSettings.betaUrl),
value: MarianumConnectEndpoint.beta,
),
RadioListTile<MarianumConnectEndpoint>(
title: const Text('Eigener Server'),
subtitle: Text(
allowsHttp
? 'HTTP oder HTTPS, ohne abschließenden Slash. '
'HTTP nur für lokale Entwicklung.'
: 'Nur HTTPS-URLs, ohne abschließenden Slash.',
),
value: MarianumConnectEndpoint.custom,
),
],
),
),
if (_selected == MarianumConnectEndpoint.custom)
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _customController,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: allowsHttp ? 'http(s)://...' : 'https://...',
errorText: _customError,
),
onChanged: (_) => setState(() => _customError = null),
),
if (allowsHttp && _isHttpUrl(_customController.text))
const Padding(
padding: EdgeInsets.only(top: 8),
child: Text(
'⚠ HTTP überträgt den Bearer-Token unverschlüsselt. '
'Nur für lokale Entwicklung benutzen.',
style: TextStyle(fontSize: 12, color: Colors.orange),
),
),
],
),
),
Padding(
padding: const EdgeInsets.all(16),
child: FilledButton(
onPressed: _confirm,
child: const Text('Übernehmen'),
),
),
],
);
}
void _selectEndpoint(MarianumConnectEndpoint? value) {
if (value == null) return;
setState(() {
_selected = value;
_customError = null;
});
}
Future<void> _confirm() async {
if (_selected == MarianumConnectEndpoint.custom) {
final sanitized = DevToolsSettings.sanitizeCustomUrl(
_customController.text,
);
if (sanitized == null) {
setState(
() => _customError = DevToolsSettings.allowsHttpCustomEndpoint
? 'Ungültige URL (http(s)://host[:port])'
: 'Ungültige URL — nur HTTPS erlaubt',
);
return;
}
await widget.onChanged(_selected, sanitized);
} else {
await widget.onChanged(_selected, null);
}
if (!mounted) return;
Navigator.of(context).pop();
}
static bool _isHttpUrl(String value) {
final trimmed = value.trim();
final uri = Uri.tryParse(trimmed);
return uri != null && uri.hasScheme && uri.scheme == 'http';
}
}