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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user