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
@@ -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);
}
}
+139 -80
View File
@@ -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(