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:
@@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user