implemented foreign timetable support for students, teachers, rooms, and classes, including a searchable element picker with favorites support, introduced a capabilities system for feature gating, refactored the timetable UI into a reusable TimetableCalendarView component, and redesigned the chat input field with a unified emoji picker and integrated attachment actions.

This commit is contained in:
2026-05-31 21:29:16 +02:00
parent 6e12da08c0
commit b6d06dd3b4
41 changed files with 2325 additions and 290 deletions
@@ -0,0 +1,396 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../api/errors/error_mapper.dart';
import '../../../api/marianumconnect/queries/timetable_get_classes/timetable_get_classes.dart';
import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
import '../../../api/marianumconnect/queries/timetable_get_rooms/timetable_get_rooms.dart';
import '../../../api/marianumconnect/queries/timetable_get_students/timetable_get_students.dart';
import '../../../api/marianumconnect/queries/timetable_get_teachers/timetable_get_teachers.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../storage/timetable_favorites_settings.dart';
import '../../../utils/haptics.dart';
import '../../../widget/app_progress_indicator.dart';
/// Full-screen picker: choose an element type (or "Alle" to search across all
/// types), filter the (potentially large) list client-side, and pick an element
/// whose timetable is then shown inline. Starred elements appear at the top for
/// quick access.
class ElementPickerPage extends StatefulWidget {
const ElementPickerPage({super.key});
@override
State<ElementPickerPage> createState() => _ElementPickerPageState();
}
class _ElementPickerPageState extends State<ElementPickerPage> {
/// `null` = the "Alle" tab (search across every type). Default.
TimetableElementType? _selectedType;
String _query = '';
final TextEditingController _searchController = TextEditingController();
// One in-flight/resolved future per type so switching tabs (or rebuilds)
// never re-fetches a list that's already loaded.
final Map<TimetableElementType, Future<List<_PickerItem>>> _futures = {};
// Memoised combined future for the "Alle" tab; rebuilt on retry.
Future<List<_PickerItem>>? _allFuture;
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
Future<List<_PickerItem>> _currentFuture() {
final type = _selectedType;
if (type != null) return _loadFor(type);
return _allFuture ??= _loadAll();
}
Future<List<_PickerItem>> _loadFor(TimetableElementType type) =>
_futures.putIfAbsent(type, () => _fetch(type));
Future<List<_PickerItem>> _loadAll() async {
final lists = await Future.wait(TimetableElementType.values.map(_loadFor));
return lists.expand((e) => e).toList();
}
Future<List<_PickerItem>> _fetch(TimetableElementType type) async {
switch (type) {
case TimetableElementType.student:
final r = await TimetableGetStudents().run();
return r.result
.map(
(s) => _PickerItem(
type: type,
id: s.id,
primary: s.displayName,
secondary: '${s.lastName}, ${s.firstName}',
search: '${s.displayName} ${s.firstName} ${s.lastName}'
.toLowerCase(),
),
)
.toList();
case TimetableElementType.teacher:
final r = await TimetableGetTeachers().run();
return r.result
.map(
(t) => _PickerItem(
type: type,
id: t.id,
primary: t.displayName,
secondary: t.shortName,
search: '${t.displayName} ${t.shortName}'.toLowerCase(),
),
)
.toList();
case TimetableElementType.room:
final r = await TimetableGetRooms().run();
return r.result
.map(
(rm) => _PickerItem(
type: type,
id: rm.id,
primary: rm.shortName,
secondary: rm.longName,
search: '${rm.shortName} ${rm.longName}'.toLowerCase(),
),
)
.toList();
case TimetableElementType.schoolClass:
final r = await TimetableGetClasses().run();
return r.result
.map(
(c) => _PickerItem(
type: type,
id: c.id,
primary: c.shortName,
secondary: c.longName,
search: '${c.shortName} ${c.longName}'.toLowerCase(),
),
)
.toList();
}
}
void _open(_PickerItem item) {
Haptics.selection();
// Hand the selection back to the timetable view, which renders the foreign
// plan inline. We do not navigate to a new page.
Navigator.of(context).pop((
type: item.type,
id: item.id,
label: item.primary,
));
}
void _openFavorite(FavoriteTimetableElement favorite) {
Haptics.selection();
Navigator.of(context).pop((
type: favorite.type,
id: favorite.id,
label: favorite.label,
));
}
void _toggleFavorite(TimetableElementType type, int id, String label) {
Haptics.selection();
context
.read<SettingsCubit>()
.val(write: true)
.timetableFavoritesSettings
.toggle(type, id, label);
}
@override
Widget build(BuildContext context) {
final favorites = context
.watch<SettingsCubit>()
.val()
.timetableFavoritesSettings
.favorites;
return Scaffold(
appBar: AppBar(title: const Text('Stundenplan öffnen')),
body: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 8),
child: TextField(
controller: _searchController,
onChanged: (value) => setState(() => _query = value.trim()),
decoration: InputDecoration(
hintText: 'Suchen…',
prefixIcon: const Icon(Icons.search),
suffixIcon: _query.isEmpty
? null
: IconButton(
icon: const Icon(Icons.clear),
onPressed: () {
_searchController.clear();
setState(() => _query = '');
},
),
border: const OutlineInputBorder(),
isDense: true,
),
),
),
SizedBox(height: 40, child: _typeSelector()),
const SizedBox(height: 8),
Expanded(child: _list(favorites)),
],
),
);
}
Widget _typeSelector() {
return ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
_typeChip(null),
...TimetableElementType.values.map(_typeChip),
],
);
}
Widget _typeChip(TimetableElementType? type) {
return Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
avatar: Icon(type == null ? Icons.apps : _iconFor(type), size: 18),
showCheckmark: false,
label: Text(type?.label ?? 'Alle'),
selected: _selectedType == type,
onSelected: (_) {
Haptics.selection();
setState(() => _selectedType = type);
},
),
);
}
Widget _list(List<FavoriteTimetableElement> favorites) {
return FutureBuilder<List<_PickerItem>>(
future: _currentFuture(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Center(child: AppProgressIndicator.medium());
}
if (snapshot.hasError) {
return _ErrorBody(
message: errorToUserMessage(snapshot.error),
onRetry: () => setState(() {
_futures.clear();
_allFuture = null;
}),
);
}
final items = snapshot.data ?? const <_PickerItem>[];
final query = _query.toLowerCase();
final filtered = query.isEmpty
? items
: items.where((i) => i.search.contains(query)).toList();
// On a specific type tab only that type's favorites are relevant; the
// "Alle" tab shows them all.
final visibleFavorites = _selectedType == null
? favorites
: favorites.where((f) => f.type == _selectedType).toList();
// Favorites only make sense while not actively searching.
final showFavorites = _query.isEmpty && visibleFavorites.isNotEmpty;
if (filtered.isEmpty && !showFavorites) {
return const Center(child: Text('Keine Einträge gefunden.'));
}
// Leading rows when favorites are shown: a "Favoriten" header, one row
// per favorite, then a divider + "Weitere" header that separates them
// from the regular results.
final headerCount = showFavorites ? visibleFavorites.length + 2 : 0;
return ListView.builder(
itemCount: filtered.length + headerCount,
itemBuilder: (context, index) {
if (showFavorites) {
if (index == 0) return _sectionHeader('Favoriten');
if (index <= visibleFavorites.length) {
return _favoriteTile(visibleFavorites[index - 1]);
}
if (index == visibleFavorites.length + 1) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const Divider(height: 1),
_sectionHeader('Weitere'),
],
);
}
}
final item = filtered[index - headerCount];
return _itemTile(item);
},
);
},
);
}
Widget _sectionHeader(String label) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 4),
child: Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.primary,
fontWeight: FontWeight.bold,
),
),
);
}
Widget _favoriteTile(FavoriteTimetableElement favorite) {
return ListTile(
leading: Icon(_iconFor(favorite.type)),
title: Text(favorite.label),
subtitle: Text(favorite.type.label),
trailing: IconButton(
icon: const Icon(Icons.star),
tooltip: 'Favorit entfernen',
onPressed: () =>
_toggleFavorite(favorite.type, favorite.id, favorite.label),
),
onTap: () => _openFavorite(favorite),
);
}
Widget _itemTile(_PickerItem item) {
final isFavorite = context
.watch<SettingsCubit>()
.val()
.timetableFavoritesSettings
.isFavorite(item.type, item.id);
// In the "Alle" tab the type is otherwise ambiguous, so surface it.
final subtitleParts = <String>[
if (item.secondary.isNotEmpty && item.secondary != item.primary)
item.secondary,
if (_selectedType == null) item.type.label,
];
return ListTile(
leading: Icon(_iconFor(item.type)),
title: Text(item.primary),
subtitle: subtitleParts.isEmpty
? null
: Text(subtitleParts.join(' · ')),
trailing: IconButton(
icon: Icon(isFavorite ? Icons.star : Icons.star_border),
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
onPressed: () => _toggleFavorite(item.type, item.id, item.primary),
),
onTap: () => _open(item),
);
}
static IconData _iconFor(TimetableElementType type) {
switch (type) {
case TimetableElementType.student:
return Icons.person_outline;
case TimetableElementType.teacher:
return Icons.school_outlined;
case TimetableElementType.room:
return Icons.meeting_room_outlined;
case TimetableElementType.schoolClass:
return Icons.groups_outlined;
}
}
}
class _PickerItem {
final TimetableElementType type;
final int id;
final String primary;
final String secondary;
final String search;
_PickerItem({
required this.type,
required this.id,
required this.primary,
required this.secondary,
required this.search,
});
}
class _ErrorBody extends StatelessWidget {
final String message;
final VoidCallback onRetry;
const _ErrorBody({required this.message, required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.error_outline, size: 40),
const SizedBox(height: 12),
Text(message, textAlign: TextAlign.center),
const SizedBox(height: 16),
FilledButton.tonal(
onPressed: onRetry,
child: const Text('Erneut versuchen'),
),
],
),
),
);
}
}