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:
@@ -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