loading state and error handling refactor

This commit is contained in:
2026-05-06 10:11:45 +02:00
parent 2c376afd91
commit 4b1d4379a0
48 changed files with 1377 additions and 354 deletions
+9 -3
View File
@@ -12,6 +12,7 @@ import '../../../state/app/modules/chatList/bloc/chat_list_state.dart';
import '../../../notification/notify_updater.dart';
import '../../../state/app/modules/settings/bloc/settings_cubit.dart';
import '../../../widget/confirm_dialog.dart';
import '../../../widget/placeholder_view.dart';
import 'widgets/chat_tile.dart';
import 'widgets/split_view_placeholder.dart';
import 'join_chat.dart';
@@ -144,9 +145,7 @@ class _ChatListViewState extends State<_ChatListView> {
title: 'Chat starten',
content: "Möchtest du einen Chat mit Nutzer '$username' starten?",
confirmButton: 'Chat starten',
onConfirm: () {
bloc.createDirectChat(username);
},
onConfirmAsync: () => bloc.createDirectChat(username),
).asDialog(context);
});
},
@@ -164,6 +163,13 @@ class _ChatListViewState extends State<_ChatListView> {
unreadToTop: talkSettings.sortUnreadToTop,
);
if (sorted.isEmpty) {
return const PlaceholderView(
icon: Icons.chat_bubble_outline,
text: 'Noch keine Chats — starte einen über das +-Symbol.',
);
}
return ListView(
padding: EdgeInsets.zero,
children: sorted.map((room) {
+10 -13
View File
@@ -2,9 +2,11 @@
import 'package:async/async.dart';
import 'package:flutter/material.dart';
import '../../../api/errors/error_mapper.dart';
import '../../../api/marianumcloud/autocomplete/autocompleteApi.dart';
import '../../../api/marianumcloud/autocomplete/autocompleteResponse.dart';
import '../../../model/endpoint_data.dart';
import '../../../widget/app_progress_indicator.dart';
import '../../../widget/placeholder_view.dart';
class JoinChat extends SearchDelegate<String> {
@@ -16,17 +18,9 @@ class JoinChat extends SearchDelegate<String> {
future: future!.value,
builder: (context, snapshot) {
if(snapshot.connectionState != ConnectionState.done) {
return Container(
padding: const EdgeInsets.all(10),
child: const Center(
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 3,
),
),
),
return const Padding(
padding: EdgeInsets.all(10),
child: Center(child: AppProgressIndicator.medium()),
);
}
return const SizedBox.shrink();
@@ -76,10 +70,13 @@ class JoinChat extends SearchDelegate<String> {
}
);
} else if(snapshot.hasError) {
return const PlaceholderView(icon: Icons.search_off, text: 'Ein fehler ist aufgetreten. Bist du mit dem Internet verbunden?');
return PlaceholderView(
icon: Icons.search_off,
text: errorToUserMessage(snapshot.error),
);
}
return const Center(child: CircularProgressIndicator());
return const Center(child: AppProgressIndicator.large());
},
);
}
+17 -16
View File
@@ -14,6 +14,7 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../extensions/text.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/loading_spinner.dart';
import '../../files/widgets/file_element.dart';
import '../data/chat_bubble_styles.dart';
@@ -306,22 +307,22 @@ class _ChatBubbleState extends State<ChatBubble> with SingleTickerProviderStateM
padding: EdgeInsets.zero,
backgroundColor: hasSelfReacted ? Theme.of(context).primaryColor : null,
onPressed: () {
if(hasSelfReacted) {
// Delete existing reaction
DeleteReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: DeleteReactMessageParams(e.key),
).run().then((value) => widget.refetch(renew: true));
} else {
// Add reaction
ReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: ReactMessageParams(e.key)
).run().then((value) => widget.refetch(renew: true));
}
runWithErrorDialog(context, () async {
if (hasSelfReacted) {
await DeleteReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: DeleteReactMessageParams(e.key),
).run();
} else {
await ReactMessage(
chatToken: widget.chatData.token,
messageId: widget.bubbleData.id,
params: ReactMessageParams(e.key),
).run();
}
widget.refetch(renew: true);
});
},
),
);
@@ -11,6 +11,8 @@ import '../../../../api/marianumcloud/talk/reactMessage/reactMessageParams.dart'
import '../../../../api/marianumcloud/talk/room/getRoomResponse.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../widget/app_progress_indicator.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/debug/debug_tile.dart';
const _commonReactions = <String>['👍', '👎', '😆', '❤️', '👀'];
@@ -78,14 +80,12 @@ Future<void> showChatMessageOptionsDialog(
onTap: () => Navigator.of(dialogCtx).pop(),
),
if (canDelete)
ListTile(
AsyncListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Nachricht löschen'),
onTap: () async {
onPressed: () async {
await DeleteMessage(chatData.token, bubbleData.id).run();
if (!dialogCtx.mounted) return;
dialogCtx.read<ChatBloc>().refresh();
Navigator.of(dialogCtx).pop();
if (dialogCtx.mounted) dialogCtx.read<ChatBloc>().refresh();
},
),
DebugTile(dialogCtx).jsonData(bubbleData.toJson()),
@@ -94,7 +94,7 @@ Future<void> showChatMessageOptionsDialog(
);
}
class _ReactionsRow extends StatelessWidget {
class _ReactionsRow extends StatefulWidget {
final String chatToken;
final int messageId;
final void Function({bool renew}) onRefetch;
@@ -107,46 +107,83 @@ class _ReactionsRow extends StatelessWidget {
required this.dialogContext,
});
void _react(String emoji) {
Navigator.of(dialogContext).pop();
ReactMessage(
chatToken: chatToken,
messageId: messageId,
params: ReactMessageParams(emoji),
).run().then((_) => onRefetch(renew: true));
@override
State<_ReactionsRow> createState() => _ReactionsRowState();
}
class _ReactionsRowState extends State<_ReactionsRow> {
final AsyncActionController _controller = AsyncActionController();
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _react(String emoji) async {
final ok = await _controller.run(() async {
await ReactMessage(
chatToken: widget.chatToken,
messageId: widget.messageId,
params: ReactMessageParams(emoji),
).run();
});
if (!mounted) return;
if (ok) {
widget.onRefetch(renew: true);
if (widget.dialogContext.mounted) Navigator.of(widget.dialogContext).pop();
}
}
@override
Widget build(BuildContext context) => Column(
mainAxisSize: MainAxisSize.min,
children: [
Wrap(
alignment: WrapAlignment.center,
Widget build(BuildContext context) => AnimatedBuilder(
animation: _controller,
builder: (context, _) {
final busy = _controller.busy;
final err = _controller.error;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
..._commonReactions.map(
(emoji) => TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
Wrap(
alignment: WrapAlignment.center,
children: [
..._commonReactions.map(
(emoji) => TextButton(
style: TextButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
onPressed: busy ? null : () => _react(emoji),
child: Text(emoji),
),
),
onPressed: () => _react(emoji),
child: Text(emoji),
),
IconButton(
onPressed: busy ? null : () => _showEmojiPicker(context),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
),
icon: busy
? const AppProgressIndicator.small()
: const Icon(Icons.add_circle_outline_outlined),
),
],
),
IconButton(
onPressed: () => _showEmojiPicker(context),
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
minimumSize: const Size(40, 40),
if (err != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: Text(
err,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
),
),
icon: const Icon(Icons.add_circle_outline_outlined),
),
const Divider(),
],
),
const Divider(),
],
);
},
);
void _showEmojiPicker(BuildContext rowContext) {
+47 -31
View File
@@ -12,6 +12,7 @@ import '../../../../api/marianumcloud/talk/sendMessage/sendMessageParams.dart';
import '../../../../api/marianumcloud/webdav/webdavApi.dart';
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/file_pick.dart';
import '../../../../widget/focus_behaviour.dart';
import '../../files/files_upload_dialog.dart';
@@ -30,7 +31,8 @@ class ChatTextfield extends StatefulWidget {
class _ChatTextfieldState extends State<ChatTextfield> {
late SettingsCubit settings;
final TextEditingController _textBoxController = TextEditingController();
bool isLoading = false;
final AsyncActionController _sendController = AsyncActionController();
String? _sendError;
void share(String shareFolder, List<String> filePaths) {
for (final element in filePaths) {
@@ -92,6 +94,29 @@ class _ChatTextfieldState extends State<ChatTextfield> {
}
}
@override
void dispose() {
_sendController.dispose();
super.dispose();
}
Future<void> _sendMessage(ChatBloc chatBloc) async {
if (_textBoxController.text.isEmpty) return;
final text = _textBoxController.text;
final replyTo = chatBloc.state.data?.referenceMessageId?.toString();
setState(() => _sendError = null);
await SendMessage(
widget.sendToToken,
SendMessageParams(text, replyTo: replyTo),
).run();
if (!mounted) return;
chatBloc.refresh();
_textBoxController.text = '';
_setDraft('');
chatBloc.setReferenceMessageId(null);
_setDraftReply(null);
}
@override
Widget build(BuildContext context) {
_textBoxController.text = settings.val().talkSettings.drafts[widget.sendToToken] ?? '';
@@ -135,6 +160,14 @@ class _ChatTextfieldState extends State<ChatTextfield> {
child: Column(
children: [
replyBanner,
if (_sendError != null)
Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Text(
_sendError!,
style: TextStyle(color: Theme.of(context).colorScheme.error, fontSize: 12),
),
),
Row(children: <Widget>[
GestureDetector(
onTap: () {
@@ -200,36 +233,19 @@ class _ChatTextfieldState extends State<ChatTextfield> {
),
),
const SizedBox(width: 15),
FloatingActionButton(
mini: true,
onPressed: () {
if (_textBoxController.text.isEmpty || isLoading) return;
setState(() => isLoading = true);
SendMessage(
widget.sendToToken,
SendMessageParams(
_textBoxController.text,
replyTo: chatBloc.state.data?.referenceMessageId?.toString(),
),
).run().then((_) {
if (!mounted) return;
chatBloc.refresh();
setState(() => isLoading = false);
_textBoxController.text = '';
_setDraft('');
chatBloc.setReferenceMessageId(null);
_setDraftReply(null);
});
},
backgroundColor: Theme.of(context).primaryColor,
elevation: 5,
child: isLoading
? Container(
padding: const EdgeInsets.all(10),
child: const CircularProgressIndicator(color: Colors.white, strokeWidth: 2),
)
: const Icon(Icons.send, color: Colors.white, size: 18),
ValueListenableBuilder<TextEditingValue>(
valueListenable: _textBoxController,
builder: (context, value, _) => AsyncFab(
mini: true,
heroTag: 'chatSend_${widget.sendToToken}',
icon: Icons.send,
backgroundColor: Theme.of(context).primaryColor,
foregroundColor: Colors.white,
controller: _sendController,
onPressed: value.text.trim().isEmpty ? null : () => _sendMessage(chatBloc),
onError: (message) => setState(() => _sendError = message),
onSuccess: () => setState(() => _sendError = null),
),
),
]),
],
+39 -50
View File
@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -11,6 +12,7 @@ import '../../../../api/marianumcloud/talk/setReadMarker/setReadMarkerParams.dar
import '../../../../model/account_data.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chatList/bloc/chat_list_bloc.dart';
import '../../../../widget/async_action_button.dart';
import '../../../../widget/confirm_dialog.dart';
import '../../../../widget/debug/debug_tile.dart';
import '../../../../widget/user_avatar.dart';
@@ -42,15 +44,14 @@ class _ChatTileState extends State<ChatTile> {
void _refreshList() => context.read<ChatListBloc>().refresh();
void setCurrentAsRead() {
SetReadMarker(
Future<void> _setCurrentAsRead() async {
await SetReadMarker(
widget.data.token,
true,
setReadMarkerParams: SetReadMarkerParams(lastReadMessage: widget.data.lastMessage.id),
).run().then((_) {
if (!mounted) return;
_refreshList();
});
).run();
if (!mounted) return;
_refreshList();
}
@override
@@ -116,7 +117,7 @@ class _ChatTileState extends State<ChatTile> {
),
onTap: () {
if (selfUsername == null) return;
setCurrentAsRead();
unawaited(_setCurrentAsRead());
final view = ChatView(room: widget.data, selfId: selfUsername!, avatar: circleAvatar);
TalkNavigator.pushSplitView(context, view, overrideToSingleSubScreen: true);
context.read<ChatBloc>().setToken(widget.data.token);
@@ -125,65 +126,53 @@ class _ChatTileState extends State<ChatTile> {
if (widget.disableContextActions) return;
showDialog(context: context, builder: (dialogCtx) => SimpleDialog(
children: [
Visibility(
visible: widget.data.unreadMessages > 0,
replacement: ListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: const Text('Als ungelesen markieren'),
onTap: () {
SetReadMarker(widget.data.token, false).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
},
),
child: ListTile(
if (widget.data.unreadMessages > 0)
AsyncListTile(
leading: const Icon(Icons.mark_chat_read_outlined),
title: const Text('Als gelesen markieren'),
onTap: () {
setCurrentAsRead();
Navigator.of(dialogCtx).pop();
onPressed: _setCurrentAsRead,
)
else
AsyncListTile(
leading: const Icon(Icons.mark_chat_unread_outlined),
title: const Text('Als ungelesen markieren'),
onPressed: () async {
await SetReadMarker(widget.data.token, false).run();
if (mounted) _refreshList();
},
),
),
Visibility(
visible: widget.data.isFavorite,
replacement: ListTile(
leading: const Icon(Icons.star_outline),
title: const Text('Zu Favoriten hinzufügen'),
onTap: () {
SetFavorite(widget.data.token, true).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
},
),
child: ListTile(
if (widget.data.isFavorite)
AsyncListTile(
leading: const Icon(Icons.stars_outlined),
title: const Text('Von Favoriten entfernen'),
onTap: () {
SetFavorite(widget.data.token, false).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
onPressed: () async {
await SetFavorite(widget.data.token, false).run();
if (mounted) _refreshList();
},
)
else
AsyncListTile(
leading: const Icon(Icons.star_outline),
title: const Text('Zu Favoriten hinzufügen'),
onPressed: () async {
await SetFavorite(widget.data.token, true).run();
if (mounted) _refreshList();
},
),
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Konversation verlassen'),
onTap: () {
Navigator.of(dialogCtx).pop();
ConfirmDialog(
title: 'Chat verlassen',
content: 'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Löschen',
onConfirm: () {
LeaveRoom(widget.data.token).run().then((_) {
if (mounted) _refreshList();
});
Navigator.of(dialogCtx).pop();
confirmButton: 'Verlassen',
onConfirmAsync: () async {
await LeaveRoom(widget.data.token).run();
if (mounted) _refreshList();
},
).asDialog(dialogCtx);
).asDialog(context);
},
),
DebugTile(dialogCtx).jsonData(widget.data.toJson()),