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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ class MarianumDateRow extends StatelessWidget {
|
||||
final MarianumDate event;
|
||||
const MarianumDateRow({required this.event, super.key});
|
||||
|
||||
String _dayLabel() => event.start.day.toString().padLeft(2, '0');
|
||||
String _dayLabel() => "${event.start.day.toString().padLeft(2, '0')}.";
|
||||
|
||||
String _monthYearLabel() =>
|
||||
'${event.start.month.toString().padLeft(2, '0')}.${event.start.year}';
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../../../storage/modules_settings.dart';
|
||||
import '../../../../storage/notification_settings.dart';
|
||||
import '../../../../storage/settings.dart';
|
||||
import '../../../../storage/talk_settings.dart';
|
||||
import '../../../../storage/timetable_favorites_settings.dart';
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
import '../../../../view/pages/timetable/data/timetable_name_mode.dart';
|
||||
import '../../files/data/sort_options.dart';
|
||||
@@ -40,6 +41,7 @@ class DefaultSettings {
|
||||
connectDoubleLessons: true,
|
||||
timetableNameMode: TimetableNameMode.name,
|
||||
),
|
||||
timetableFavoritesSettings: TimetableFavoritesSettings(favorites: []),
|
||||
talkSettings: TalkSettings(
|
||||
sortFavoritesToTop: true,
|
||||
sortUnreadToTop: false,
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../../../api/marianumcloud/talk/get_reactions/get_reactions.dart';
|
||||
import '../../../../api/marianumcloud/talk/get_reactions/get_reactions_response.dart';
|
||||
import '../../../../model/account_data.dart';
|
||||
import '../../../../widget/centered_leading.dart';
|
||||
import '../../../../widget/emoji_text.dart';
|
||||
import '../../../../widget/loading_spinner.dart';
|
||||
import '../../../../widget/placeholder_view.dart';
|
||||
import '../../../../widget/user_avatar.dart';
|
||||
@@ -59,7 +60,7 @@ class _MessageReactionsState extends State<MessageReactions> {
|
||||
collapsedIconColor: Theme.of(context).colorScheme.onSurface,
|
||||
|
||||
subtitle: const Text('Tippe für mehr'),
|
||||
leading: CenteredLeading(Text(entry.key)),
|
||||
leading: CenteredLeading(EmojiText(entry.key)),
|
||||
title: Text('${entry.value.length} mal reagiert'),
|
||||
children: entry.value.map((e) {
|
||||
final isSelf = AccountData().getUsername() == e.actorId;
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../../../../api/marianumcloud/talk/react_message/react_message.dart';
|
||||
import '../../../../api/marianumcloud/talk/react_message/react_message_params.dart';
|
||||
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/emoji_text.dart';
|
||||
|
||||
/// Reactions wrap shown beneath a chat bubble. Tapping a reaction toggles
|
||||
/// the user's own reaction via the Talk API and notifies via [onChanged].
|
||||
@@ -42,7 +43,14 @@ class ChatBubbleReactions extends StatelessWidget {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(right: 2.5, left: 2.5),
|
||||
child: ActionChip(
|
||||
label: Text('${e.key} ${e.value}'),
|
||||
label: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
EmojiText(e.key, size: EmojiText.sizeInline),
|
||||
const SizedBox(width: 4),
|
||||
Text('${e.value}'),
|
||||
],
|
||||
),
|
||||
visualDensity: const VisualDensity(
|
||||
vertical: VisualDensity.minimumDensity,
|
||||
horizontal: VisualDensity.minimumDensity,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart' as emojis;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
@@ -16,6 +15,8 @@ import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/confirm_dialog.dart';
|
||||
import '../../../../widget/debug/debug_tile.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/emoji_picker_dialog.dart';
|
||||
import '../../../../widget/emoji_text.dart';
|
||||
import '../data/open_direct_chat.dart';
|
||||
|
||||
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
|
||||
@@ -222,7 +223,7 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
||||
minimumSize: const Size(40, 40),
|
||||
),
|
||||
onPressed: busy ? null : () => _react(emoji),
|
||||
child: Text(emoji),
|
||||
child: EmojiText(emoji, size: EmojiText.sizeLarge),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
@@ -256,56 +257,8 @@ class _ReactionsRowState extends State<_ReactionsRow> {
|
||||
},
|
||||
);
|
||||
|
||||
void _showEmojiPicker(BuildContext rowContext) {
|
||||
showDialog(
|
||||
context: rowContext,
|
||||
builder: (pickerCtx) => AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(15),
|
||||
titlePadding: const EdgeInsets.only(left: 6, top: 15),
|
||||
title: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(pickerCtx).pop(),
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('Reagieren'),
|
||||
],
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 256,
|
||||
height: 270,
|
||||
child: emojis.EmojiPicker(
|
||||
config: emojis.Config(
|
||||
height: 256,
|
||||
emojiViewConfig: emojis.EmojiViewConfig(
|
||||
backgroundColor: Theme.of(pickerCtx).canvasColor,
|
||||
recentsLimit: 67,
|
||||
emojiSizeMax: 25,
|
||||
noRecents: const Text('Keine zuletzt verwendeten Emojis'),
|
||||
columns: 7,
|
||||
),
|
||||
bottomActionBarConfig: const emojis.BottomActionBarConfig(
|
||||
enabled: false,
|
||||
),
|
||||
categoryViewConfig: emojis.CategoryViewConfig(
|
||||
backgroundColor: Theme.of(pickerCtx).hoverColor,
|
||||
iconColorSelected: Theme.of(pickerCtx).primaryColor,
|
||||
indicatorColor: Theme.of(pickerCtx).primaryColor,
|
||||
),
|
||||
searchViewConfig: emojis.SearchViewConfig(
|
||||
backgroundColor: Theme.of(pickerCtx).dividerColor,
|
||||
hintText: 'Suchen',
|
||||
buttonIconColor: Colors.white,
|
||||
),
|
||||
),
|
||||
onEmojiSelected: (_, emoji) {
|
||||
Navigator.of(pickerCtx).pop();
|
||||
_react(emoji.emoji);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
Future<void> _showEmojiPicker(BuildContext rowContext) async {
|
||||
final emoji = await showEmojiPicker(rowContext, title: 'Reagieren');
|
||||
if (emoji != null && mounted) await _react(emoji);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../widget/async_action_button.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../../../../widget/emoji_picker_dialog.dart';
|
||||
import '../../../../widget/file_pick.dart';
|
||||
import '../../../../widget/focus_behaviour.dart';
|
||||
import '../../files/files_upload_dialog.dart';
|
||||
@@ -32,6 +33,7 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
late SettingsCubit settings;
|
||||
final TextEditingController _textBoxController = TextEditingController();
|
||||
final AsyncActionController _sendController = AsyncActionController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
String? _sendError;
|
||||
|
||||
void share(List<String> uploadedRemotePaths) {
|
||||
@@ -103,9 +105,71 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
@override
|
||||
void dispose() {
|
||||
_sendController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showAttachmentSheet() {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_open),
|
||||
title: const Text('Aus Dateien auswählen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(mediaUpload);
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image),
|
||||
title: const Text('Aus Galerie auswählen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if (value != null) {
|
||||
mediaUpload(value.map((e) => e.path).toList());
|
||||
}
|
||||
});
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () {
|
||||
FilePick.cameraPick().then((image) {
|
||||
if (image != null) mediaUpload([image.path]);
|
||||
});
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickEmoji() async {
|
||||
final emoji = await showEmojiPicker(context);
|
||||
if (emoji == null || !mounted) return;
|
||||
_insertEmoji(emoji);
|
||||
// Keep the field focused so the user can keep typing after inserting.
|
||||
_focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _insertEmoji(String emoji) {
|
||||
final selection = _textBoxController.selection;
|
||||
final text = _textBoxController.text;
|
||||
// Selection is invalid (-1) until the field was focused once — append then.
|
||||
final start = selection.start < 0 ? text.length : selection.start;
|
||||
final end = selection.end < 0 ? text.length : selection.end;
|
||||
final newText = text.replaceRange(start, end, emoji);
|
||||
_textBoxController.value = TextEditingValue(
|
||||
text: newText,
|
||||
selection: TextSelection.collapsed(offset: start + emoji.length),
|
||||
);
|
||||
// Programmatic edits skip the TextField's onChanged, so persist manually.
|
||||
_setDraft(newText);
|
||||
}
|
||||
|
||||
Future<void> _sendMessage(ChatBloc chatBloc) async {
|
||||
if (_textBoxController.text.isEmpty) return;
|
||||
final text = _textBoxController.text;
|
||||
@@ -199,94 +263,89 @@ class _ChatTextfieldState extends State<ChatTextfield> {
|
||||
),
|
||||
),
|
||||
Row(
|
||||
// Outer row centers the pill against the (taller) send FAB.
|
||||
// The inner row keeps end-alignment so the icon buttons drop
|
||||
// to the bottom once the text wraps to multiple lines.
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
showDetailsBottomSheet(
|
||||
context,
|
||||
children: (sheetCtx) => [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_open),
|
||||
title: const Text('Aus Dateien auswählen'),
|
||||
onTap: () {
|
||||
FilePick.documentPick().then(mediaUpload);
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
Expanded(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: <Widget>[
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'Emoji einfügen',
|
||||
icon: Icon(
|
||||
Icons.emoji_emotions_outlined,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: _pickEmoji,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.image),
|
||||
title: const Text('Aus Galerie auswählen'),
|
||||
onTap: () {
|
||||
FilePick.multipleGalleryPick().then((value) {
|
||||
if (value != null) {
|
||||
mediaUpload(
|
||||
value.map((e) => e.path).toList(),
|
||||
);
|
||||
}
|
||||
});
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
Expanded(
|
||||
child: Padding(
|
||||
// 7px keeps a single line as tall as the
|
||||
// 32px icon buttons, so end-alignment reads as
|
||||
// centered for one line but drops the buttons
|
||||
// to the bottom once the text wraps.
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 7,
|
||||
),
|
||||
child: TextField(
|
||||
autocorrect: true,
|
||||
textCapitalization:
|
||||
TextCapitalization.sentences,
|
||||
controller: _textBoxController,
|
||||
focusNode: _focusNode,
|
||||
maxLines: 7,
|
||||
minLines: 1,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Nachricht schreiben...',
|
||||
border: InputBorder.none,
|
||||
isCollapsed: true,
|
||||
),
|
||||
onChanged: (text) {
|
||||
if (text.trim().toLowerCase() ==
|
||||
'marbot marbot marbot') {
|
||||
const newText =
|
||||
'Roboter sind cool und so, aber Marbots sind besser!';
|
||||
_textBoxController.text = newText;
|
||||
text = newText;
|
||||
}
|
||||
_setDraft(text);
|
||||
},
|
||||
onTapOutside: (_) =>
|
||||
FocusBehaviour.textFieldTapOutside(
|
||||
context,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('Foto aufnehmen'),
|
||||
onTap: () {
|
||||
FilePick.cameraPick().then((image) {
|
||||
if (image != null) mediaUpload([image.path]);
|
||||
});
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
IconButton(
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: const EdgeInsets.all(4),
|
||||
constraints: const BoxConstraints(),
|
||||
tooltip: 'Anhang',
|
||||
icon: Icon(
|
||||
Icons.attach_file_outlined,
|
||||
color: Theme.of(context).hintColor,
|
||||
),
|
||||
onPressed: _showAttachmentSheet,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
child: Material(
|
||||
elevation: 5,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Container(
|
||||
height: 30,
|
||||
width: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).primaryColor,
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.attach_file_outlined,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
autocorrect: true,
|
||||
textCapitalization: TextCapitalization.sentences,
|
||||
controller: _textBoxController,
|
||||
maxLines: 7,
|
||||
minLines: 1,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Nachricht schreiben...',
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onChanged: (text) {
|
||||
if (text.trim().toLowerCase() ==
|
||||
'marbot marbot marbot') {
|
||||
const newText =
|
||||
'Roboter sind cool und so, aber Marbots sind besser!';
|
||||
_textBoxController.text = newText;
|
||||
text = newText;
|
||||
}
|
||||
_setDraft(text);
|
||||
},
|
||||
onTapOutside: (_) =>
|
||||
FocusBehaviour.textFieldTapOutside(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
const SizedBox(width: 8),
|
||||
ValueListenableBuilder<TextEditingValue>(
|
||||
valueListenable: _textBoxController,
|
||||
builder: (context, value, _) => AsyncFab(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import 'custom_event_sheet.dart';
|
||||
import 'lesson_sheet.dart';
|
||||
@@ -9,15 +9,14 @@ import 'lesson_sheet.dart';
|
||||
class AppointmentDetailsDispatcher {
|
||||
static void show(
|
||||
BuildContext context,
|
||||
TimetableBloc bloc,
|
||||
TimetableState? state,
|
||||
Appointment appointment,
|
||||
) {
|
||||
final id = appointment.id;
|
||||
if (id is! ArbitraryAppointment) return;
|
||||
|
||||
id.when(
|
||||
lesson: (entry) =>
|
||||
LessonSheet.show(context, bloc, appointment, entry),
|
||||
lesson: (entry) => LessonSheet.show(context, state, appointment, entry),
|
||||
custom: (event) => CustomEventSheet.show(context, event),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../../../../api/marianumconnect/queries/timetable_get_week/timetable_get
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../extensions/text.dart';
|
||||
import '../../../../routing/app_routes.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../widget/debug/debug_tile.dart';
|
||||
import '../../../../widget/details_bottom_sheet.dart';
|
||||
import '../data/lesson_type_label.dart';
|
||||
@@ -13,11 +13,10 @@ import '../data/lesson_type_label.dart';
|
||||
class LessonSheet {
|
||||
static void show(
|
||||
BuildContext context,
|
||||
TimetableBloc bloc,
|
||||
TimetableState? state,
|
||||
Appointment appointment,
|
||||
McTimetableEntry lesson,
|
||||
) {
|
||||
final state = bloc.state.data;
|
||||
if (state == null) return;
|
||||
|
||||
final subjectShort = lesson.subjects.firstOrNull;
|
||||
|
||||
@@ -1,21 +1,19 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../api/marianumconnect/queries/timetable_get_element_week/timetable_element_type.dart';
|
||||
import '../../../extensions/date_time.dart';
|
||||
import '../../../routing/app_routes.dart';
|
||||
import '../../../state/app/infrastructure/loadable_state/view/loadable_state_consumer.dart';
|
||||
import '../../../state/app/modules/capabilities/bloc/capabilities_cubit.dart';
|
||||
import '../../../state/app/modules/foreign_timetable/bloc/foreign_timetable_bloc.dart';
|
||||
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_bloc.dart';
|
||||
import '../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../storage/timetable_settings.dart';
|
||||
import '../../../utils/haptics.dart';
|
||||
import 'custom_events/custom_event_edit_dialog.dart';
|
||||
import 'data/arbitrary_appointment.dart';
|
||||
import 'data/lesson_period_schedule.dart';
|
||||
import 'data/timetable_appointment_factory.dart';
|
||||
import 'details/appointment_details_dispatcher.dart';
|
||||
import 'widgets/custom_workweek_calendar.dart';
|
||||
import 'widgets/special_regions_builder.dart';
|
||||
import 'widgets/timetable_calendar_view.dart';
|
||||
|
||||
enum _CalendarAction { addEvent, viewEvents }
|
||||
|
||||
@@ -27,17 +25,30 @@ class Timetable extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _TimetableState extends State<Timetable> {
|
||||
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
|
||||
GlobalKey<CustomWorkWeekCalendarState>();
|
||||
final GlobalKey<TimetableCalendarViewState> _calendarKey =
|
||||
GlobalKey<TimetableCalendarViewState>();
|
||||
|
||||
List<Appointment>? _cachedAppointments;
|
||||
int? _lastDataVersion;
|
||||
TimetableSettings? _lastTimetableSettings;
|
||||
/// When non-null the view shows this element's plan inline instead of the
|
||||
/// user's own. Cleared (back to own plan) via the viewing banner.
|
||||
TimetableElementRef? _selected;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||
|
||||
void _jumpToToday() {
|
||||
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
|
||||
_calendarKey.currentState?.jumpToToday();
|
||||
}
|
||||
|
||||
bool _isOnInitialWeek(TimetableState state) =>
|
||||
state.startDate == _mondayOf(_initialDisplayDate());
|
||||
|
||||
Future<void> _openPicker() async {
|
||||
final ref = await AppRoutes.openElementPicker(context);
|
||||
if (!mounted || ref == null) return;
|
||||
setState(() => _selected = ref);
|
||||
}
|
||||
|
||||
void _backToOwnPlan() {
|
||||
setState(() => _selected = null);
|
||||
}
|
||||
|
||||
void _onAction(_CalendarAction action) {
|
||||
@@ -53,43 +64,44 @@ class _TimetableState extends State<Timetable> {
|
||||
}
|
||||
}
|
||||
|
||||
List<Appointment> _appointments(TimetableState state) {
|
||||
final timetableSettings = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableSettings;
|
||||
if (_cachedAppointments != null &&
|
||||
_lastDataVersion == state.dataVersion &&
|
||||
identical(_lastTimetableSettings, timetableSettings)) {
|
||||
return _cachedAppointments!;
|
||||
}
|
||||
_lastDataVersion = state.dataVersion;
|
||||
_lastTimetableSettings = timetableSettings;
|
||||
|
||||
return _cachedAppointments = TimetableAppointmentFactory(
|
||||
lessons: state.getAllKnownLessons().toList(),
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
subjects: state.subjects?.result ?? const [],
|
||||
settings: timetableSettings,
|
||||
now: DateTime.now(),
|
||||
).build();
|
||||
void _onCreateEventAt(DateTime start, DateTime end) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
CustomEventEditDialog(initialStart: start, initialEnd: end),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
bool _isCrossedOut(Appointment appointment) {
|
||||
final id = appointment.id;
|
||||
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
|
||||
return false;
|
||||
}
|
||||
|
||||
bool _isOnInitialWeek(TimetableState state) =>
|
||||
state.startDate == _mondayOf(_initialDisplayDate());
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selected = _selected;
|
||||
if (selected == null) return _buildOwnPlan(context);
|
||||
// Scope the foreign bloc to the current selection so switching elements
|
||||
// (or back to the own plan) tears it down and builds a fresh one.
|
||||
return BlocProvider<ForeignTimetableBloc>(
|
||||
key: ValueKey('${selected.type.name}-${selected.id}'),
|
||||
create: (_) => ForeignTimetableBloc(
|
||||
type: selected.type,
|
||||
elementId: selected.id,
|
||||
title: selected.label,
|
||||
),
|
||||
// Builder gives us a context *below* the provider so the foreign bloc is
|
||||
// resolvable inside _buildForeignPlan.
|
||||
child: Builder(
|
||||
builder: (context) => _buildForeignPlan(context, selected),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildOwnPlan(BuildContext context) {
|
||||
final bloc = context.read<TimetableBloc>();
|
||||
final loadableState = context.watch<TimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final canViewForeign = context
|
||||
.watch<CapabilitiesCubit>()
|
||||
.canViewForeignTimetables;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
@@ -118,125 +130,178 @@ class _TimetableState extends State<Timetable> {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (canViewForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
onPressed: _openPicker,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: LoadableStateConsumer<TimetableBloc, TimetableState>(
|
||||
// Without this predicate the consumer treats the freshly-initialised
|
||||
// empty TimetableState as "has content" and only shows the error bar
|
||||
// on top — but `_calendar` collapses to `SizedBox.shrink()` while the
|
||||
// reference data is missing, leaving the user with a blank screen.
|
||||
// Telling the consumer that "ready" means having reference data
|
||||
// flips it into the proper error-screen path instead.
|
||||
// on top — but the calendar view collapses to `SizedBox.shrink()`
|
||||
// while the reference data is missing, leaving the user with a blank
|
||||
// screen. Telling the consumer that "ready" means having reference
|
||||
// data flips it into the proper error-screen path instead.
|
||||
isReady: (state) => state.hasReferenceData,
|
||||
child: (state, _) => _calendar(state, bloc),
|
||||
child: (state, _) => TimetableCalendarView(
|
||||
key: _calendarKey,
|
||||
state: state,
|
||||
onWeekChanged: bloc.changeWeek,
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
customEvents: state.customEvents?.events ?? const [],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _calendar(TimetableState state, TimetableBloc bloc) {
|
||||
if (!state.hasReferenceData) return const SizedBox.shrink();
|
||||
|
||||
final schedule = LessonPeriodSchedule.fromState(state);
|
||||
final appointments = _appointments(state);
|
||||
final regions = SpecialRegionsBuilder(
|
||||
holidays: state.schoolHolidays!,
|
||||
schedule: schedule,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
disabledColor: Theme.of(context).disabledColor,
|
||||
).build();
|
||||
|
||||
// Scroll bounds follow the Webuntis school-year API: the calendar lets
|
||||
// the user navigate every week the server has data for. A two-week
|
||||
// fallback is used only while the school-year payload hasn't loaded yet
|
||||
// (first launch / offline), so the calendar still mounts.
|
||||
final (minDate, maxDate) = _scrollBounds(state);
|
||||
|
||||
return CustomWorkWeekCalendar(
|
||||
key: _calendarKey,
|
||||
schedule: schedule,
|
||||
appointments: appointments,
|
||||
timeRegions: regions,
|
||||
initialDate: _initialDisplayDate(),
|
||||
minDate: minDate,
|
||||
maxDate: maxDate,
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, bloc, apt),
|
||||
onWeekChanged: (start, end) => bloc.changeWeek(start, end),
|
||||
isCrossedOut: _isCrossedOut,
|
||||
onCreateEvent: _onCreateEventAt,
|
||||
Widget _buildForeignPlan(BuildContext context, TimetableElementRef selected) {
|
||||
final bloc = context.read<ForeignTimetableBloc>();
|
||||
final loadableState = context.watch<ForeignTimetableBloc>().state;
|
||||
final innerState = loadableState.data;
|
||||
final atToday = innerState != null && _isOnInitialWeek(innerState);
|
||||
final canViewForeign = context
|
||||
.watch<CapabilitiesCubit>()
|
||||
.canViewForeignTimetables;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Stunden & Vertretungsplan'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.home_outlined),
|
||||
onPressed: atToday ? null : _jumpToToday,
|
||||
),
|
||||
if (canViewForeign)
|
||||
IconButton(
|
||||
icon: const Icon(Icons.person_search),
|
||||
tooltip: 'Anderen Stundenplan öffnen',
|
||||
onPressed: _openPicker,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_ViewingBanner(element: selected, onClose: _backToOwnPlan),
|
||||
Expanded(
|
||||
child: LoadableStateConsumer<ForeignTimetableBloc, TimetableState>(
|
||||
// Foreign plans never carry custom events, so unlike the own-plan
|
||||
// view we must not require `customEvents` here.
|
||||
isReady: (state) =>
|
||||
state.rooms != null &&
|
||||
state.subjects != null &&
|
||||
state.schoolHolidays != null,
|
||||
child: (state, _) => TimetableCalendarView(
|
||||
key: _calendarKey,
|
||||
state: state,
|
||||
onWeekChanged: bloc.changeWeek,
|
||||
onAppointmentTap: (apt) =>
|
||||
AppointmentDetailsDispatcher.show(context, state, apt),
|
||||
customEvents: const [],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _onCreateEventAt(DateTime start, DateTime end) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (_) =>
|
||||
CustomEventEditDialog(initialStart: start, initialEnd: end),
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
|
||||
/// school year (or a stale persisted bound) would let the user scroll
|
||||
/// further, we never expose more than this much around the current week —
|
||||
/// containment for any future date-math bug that might otherwise teleport
|
||||
/// the user months away from today.
|
||||
static const int _maxWeeksBack = 4;
|
||||
static const int _maxWeeksForward = 2;
|
||||
|
||||
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
|
||||
/// Starts from the Webuntis school year (or a tight window when that
|
||||
/// hasn't loaded yet), tightens by anything the bloc has learned from
|
||||
/// `-7004 no allowed date` errors during scroll — so the user can't
|
||||
/// slide off into territory Webuntis would refuse anyway — and finally
|
||||
/// clamps to a fixed window around today.
|
||||
///
|
||||
/// minDate is snapped *forward* to the next Monday because the calendar's
|
||||
/// internal `_mondayOf()` would otherwise pull a mid-week minDate back
|
||||
/// into the just-rejected week. maxDate is passed through unsnapped —
|
||||
/// `_mondayOf()` correctly walks back to the Monday of its own week,
|
||||
/// which is the last fully-allowed week.
|
||||
(DateTime, DateTime) _scrollBounds(TimetableState state) {
|
||||
final year = state.schoolyear;
|
||||
final DateTime baseMin;
|
||||
final DateTime baseMax;
|
||||
if (year != null) {
|
||||
baseMin = year.startDate;
|
||||
baseMax = year.endDate;
|
||||
} else {
|
||||
final now = DateTime.now();
|
||||
baseMin = now.subtractDays(14);
|
||||
baseMax = now.addDays(7);
|
||||
}
|
||||
final effectiveMin = state.accessibleStartDate != null
|
||||
? (state.accessibleStartDate!.isAfter(baseMin)
|
||||
? state.accessibleStartDate!
|
||||
: baseMin)
|
||||
: baseMin;
|
||||
final effectiveMax = state.accessibleEndDate != null
|
||||
? (state.accessibleEndDate!.isBefore(baseMax)
|
||||
? state.accessibleEndDate!
|
||||
: baseMax)
|
||||
: baseMax;
|
||||
final todayMonday = _mondayOf(DateTime.now());
|
||||
final cappedMin = effectiveMin.isBefore(
|
||||
todayMonday.subtractDays(_maxWeeksBack * 7),
|
||||
)
|
||||
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
||||
: effectiveMin;
|
||||
final cappedMax = effectiveMax.isAfter(
|
||||
todayMonday.addDays(_maxWeeksForward * 7 + 6),
|
||||
)
|
||||
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
||||
: effectiveMax;
|
||||
final daysToMonday =
|
||||
(DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek;
|
||||
final mondayMin = cappedMin.addDays(daysToMonday);
|
||||
return (mondayMin, cappedMax);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime d) {
|
||||
final monday = d.subtractDays(d.weekday - 1);
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
}
|
||||
|
||||
/// Slim banner shown at the top of the timetable while a foreign element's plan
|
||||
/// is being viewed. Displays which element is shown, lets the user star it, and
|
||||
/// offers a one-tap return to the own plan.
|
||||
class _ViewingBanner extends StatelessWidget {
|
||||
final TimetableElementRef element;
|
||||
final VoidCallback onClose;
|
||||
|
||||
const _ViewingBanner({required this.element, required this.onClose});
|
||||
|
||||
void _toggleFavorite(BuildContext context) {
|
||||
Haptics.selection();
|
||||
context
|
||||
.read<SettingsCubit>()
|
||||
.val(write: true)
|
||||
.timetableFavoritesSettings
|
||||
.toggle(element.type, element.id, element.label);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final isFavorite = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableFavoritesSettings
|
||||
.isFavorite(element.type, element.id);
|
||||
|
||||
final onColor = theme.colorScheme.onSecondaryContainer;
|
||||
// Compact icon button: ~32px square, no extra padding, so the banner stays
|
||||
// slim instead of inheriting the default 48px touch target height.
|
||||
Widget compactButton({
|
||||
required IconData icon,
|
||||
required String tooltip,
|
||||
required VoidCallback onPressed,
|
||||
}) => IconButton(
|
||||
icon: Icon(icon),
|
||||
iconSize: 18,
|
||||
color: onColor,
|
||||
tooltip: tooltip,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.zero,
|
||||
constraints: const BoxConstraints(minWidth: 32, minHeight: 32),
|
||||
onPressed: onPressed,
|
||||
);
|
||||
|
||||
return Material(
|
||||
color: theme.colorScheme.secondaryContainer,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 2, 6, 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(_iconFor(element.type), size: 16, color: onColor),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
element.label,
|
||||
style: theme.textTheme.labelLarge?.copyWith(color: onColor),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
compactButton(
|
||||
icon: isFavorite ? Icons.star : Icons.star_border,
|
||||
tooltip: isFavorite ? 'Favorit entfernen' : 'Als Favorit markieren',
|
||||
onPressed: () => _toggleFavorite(context),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
compactButton(
|
||||
icon: Icons.close,
|
||||
tooltip: 'Zurück zum eigenen Plan',
|
||||
onPressed: onClose,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:syncfusion_flutter_calendar/calendar.dart';
|
||||
|
||||
import '../../../../api/mhsl/custom_timetable_event/custom_timetable_event.dart';
|
||||
import '../../../../extensions/date_time.dart';
|
||||
import '../../../../state/app/modules/settings/bloc/settings_cubit.dart';
|
||||
import '../../../../state/app/modules/timetable/bloc/timetable_state.dart';
|
||||
import '../../../../storage/timetable_settings.dart';
|
||||
import '../data/arbitrary_appointment.dart';
|
||||
import '../data/lesson_period_schedule.dart';
|
||||
import '../data/timetable_appointment_factory.dart';
|
||||
import 'custom_workweek_calendar.dart';
|
||||
import 'special_regions_builder.dart';
|
||||
|
||||
/// Renders a weekly timetable from a [TimetableState]. Shared by the user's own
|
||||
/// plan and the foreign-element view; the only differences are which custom
|
||||
/// events to overlay (none for foreign plans) and whether tapping an empty slot
|
||||
/// can create an event ([onCreateEvent] is null for read-only foreign plans).
|
||||
///
|
||||
/// The week navigation and appointment-tap callbacks are supplied by the host
|
||||
/// page so each can route them to its own bloc.
|
||||
class TimetableCalendarView extends StatefulWidget {
|
||||
final TimetableState state;
|
||||
final void Function(DateTime weekStart, DateTime weekEnd) onWeekChanged;
|
||||
final void Function(Appointment appointment) onAppointmentTap;
|
||||
final void Function(DateTime start, DateTime end)? onCreateEvent;
|
||||
final List<CustomTimetableEvent> customEvents;
|
||||
|
||||
const TimetableCalendarView({
|
||||
super.key,
|
||||
required this.state,
|
||||
required this.onWeekChanged,
|
||||
required this.onAppointmentTap,
|
||||
this.onCreateEvent,
|
||||
this.customEvents = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
State<TimetableCalendarView> createState() => TimetableCalendarViewState();
|
||||
}
|
||||
|
||||
class TimetableCalendarViewState extends State<TimetableCalendarView> {
|
||||
final GlobalKey<CustomWorkWeekCalendarState> _calendarKey =
|
||||
GlobalKey<CustomWorkWeekCalendarState>();
|
||||
|
||||
List<Appointment>? _cachedAppointments;
|
||||
int? _lastDataVersion;
|
||||
TimetableSettings? _lastTimetableSettings;
|
||||
List<CustomTimetableEvent>? _lastCustomEvents;
|
||||
|
||||
DateTime _initialDisplayDate() => DateTime.now().addDays(2);
|
||||
|
||||
/// Snaps the calendar back to the current week. Exposed so host pages can
|
||||
/// wire it to a "today" AppBar action.
|
||||
void jumpToToday() {
|
||||
_calendarKey.currentState?.jumpToDate(_initialDisplayDate());
|
||||
}
|
||||
|
||||
bool isOnInitialWeek() =>
|
||||
widget.state.startDate == _mondayOf(_initialDisplayDate());
|
||||
|
||||
List<Appointment> _appointments(TimetableState state) {
|
||||
final timetableSettings = context
|
||||
.watch<SettingsCubit>()
|
||||
.val()
|
||||
.timetableSettings;
|
||||
if (_cachedAppointments != null &&
|
||||
_lastDataVersion == state.dataVersion &&
|
||||
identical(_lastTimetableSettings, timetableSettings) &&
|
||||
identical(_lastCustomEvents, widget.customEvents)) {
|
||||
return _cachedAppointments!;
|
||||
}
|
||||
_lastDataVersion = state.dataVersion;
|
||||
_lastTimetableSettings = timetableSettings;
|
||||
_lastCustomEvents = widget.customEvents;
|
||||
|
||||
return _cachedAppointments = TimetableAppointmentFactory(
|
||||
lessons: state.getAllKnownLessons().toList(),
|
||||
customEvents: widget.customEvents,
|
||||
subjects: state.subjects?.result ?? const [],
|
||||
settings: timetableSettings,
|
||||
now: DateTime.now(),
|
||||
).build();
|
||||
}
|
||||
|
||||
bool _isCrossedOut(Appointment appointment) {
|
||||
final id = appointment.id;
|
||||
if (id is LessonAppointment) return id.entry.status == 'CANCELLED';
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = widget.state;
|
||||
// Reference data is gated by the host's LoadableStateConsumer isReady
|
||||
// predicate, but guard the one hard dereference (schoolHolidays) so a
|
||||
// transient null can never crash the build.
|
||||
if (state.schoolHolidays == null) return const SizedBox.shrink();
|
||||
|
||||
final schedule = LessonPeriodSchedule.fromState(state);
|
||||
final appointments = _appointments(state);
|
||||
final regions = SpecialRegionsBuilder(
|
||||
holidays: state.schoolHolidays!,
|
||||
schedule: schedule,
|
||||
colorScheme: Theme.of(context).colorScheme,
|
||||
disabledColor: Theme.of(context).disabledColor,
|
||||
).build();
|
||||
|
||||
final (minDate, maxDate) = _scrollBounds(state);
|
||||
|
||||
return CustomWorkWeekCalendar(
|
||||
key: _calendarKey,
|
||||
schedule: schedule,
|
||||
appointments: appointments,
|
||||
timeRegions: regions,
|
||||
initialDate: _initialDisplayDate(),
|
||||
minDate: minDate,
|
||||
maxDate: maxDate,
|
||||
onAppointmentTap: widget.onAppointmentTap,
|
||||
onWeekChanged: widget.onWeekChanged,
|
||||
isCrossedOut: _isCrossedOut,
|
||||
onCreateEvent: widget.onCreateEvent,
|
||||
);
|
||||
}
|
||||
|
||||
/// Hard caps applied on top of whatever Webuntis would allow. Even if the
|
||||
/// school year (or a stale persisted bound) would let the user scroll
|
||||
/// further, we never expose more than this much around the current week.
|
||||
static const int _maxWeeksBack = 4;
|
||||
static const int _maxWeeksForward = 2;
|
||||
|
||||
/// Returns the (minDate, maxDate) the user is allowed to scroll between.
|
||||
/// Starts from the Webuntis school year (or a tight window when that hasn't
|
||||
/// loaded yet), tightens by anything the bloc has learned from past denials,
|
||||
/// and finally clamps to a fixed window around today.
|
||||
(DateTime, DateTime) _scrollBounds(TimetableState state) {
|
||||
final year = state.schoolyear;
|
||||
final DateTime baseMin;
|
||||
final DateTime baseMax;
|
||||
if (year != null) {
|
||||
baseMin = year.startDate;
|
||||
baseMax = year.endDate;
|
||||
} else {
|
||||
final now = DateTime.now();
|
||||
baseMin = now.subtractDays(14);
|
||||
baseMax = now.addDays(7);
|
||||
}
|
||||
final effectiveMin = state.accessibleStartDate != null
|
||||
? (state.accessibleStartDate!.isAfter(baseMin)
|
||||
? state.accessibleStartDate!
|
||||
: baseMin)
|
||||
: baseMin;
|
||||
final effectiveMax = state.accessibleEndDate != null
|
||||
? (state.accessibleEndDate!.isBefore(baseMax)
|
||||
? state.accessibleEndDate!
|
||||
: baseMax)
|
||||
: baseMax;
|
||||
final todayMonday = _mondayOf(DateTime.now());
|
||||
final cappedMin = effectiveMin.isBefore(
|
||||
todayMonday.subtractDays(_maxWeeksBack * 7),
|
||||
)
|
||||
? todayMonday.subtractDays(_maxWeeksBack * 7)
|
||||
: effectiveMin;
|
||||
final cappedMax = effectiveMax.isAfter(
|
||||
todayMonday.addDays(_maxWeeksForward * 7 + 6),
|
||||
)
|
||||
? todayMonday.addDays(_maxWeeksForward * 7 + 6)
|
||||
: effectiveMax;
|
||||
final daysToMonday =
|
||||
(DateTime.monday - cappedMin.weekday) % DateTime.daysPerWeek;
|
||||
final mondayMin = cappedMin.addDays(daysToMonday);
|
||||
return (mondayMin, cappedMax);
|
||||
}
|
||||
|
||||
static DateTime _mondayOf(DateTime d) {
|
||||
final monday = d.subtractDays(d.weekday - 1);
|
||||
return DateTime(monday.year, monday.month, monday.day);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user