Files
Client/lib/view/pages/talk/widgets/chat_tile.dart
T

301 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../api/marianumcloud/talk/actions/talk_actions.dart';
import '../../../../api/marianumcloud/talk/chat/rich_object_string_processor.dart';
import '../../../../api/marianumcloud/talk/room/get_room_response.dart';
import '../../../../api/marianumcloud/talk/room/notification_level.dart';
import '../../../../api/marianumcloud/talk/set_read_marker/set_read_marker.dart';
import '../../../../extensions/date_time.dart';
import '../../../../model/account_data.dart';
import '../../../../notification/notification_tasks.dart';
import '../../../../routing/app_routes.dart';
import '../../../../state/app/modules/chat/bloc/chat_bloc.dart';
import '../../../../state/app/modules/chat_list/bloc/chat_list_bloc.dart';
import '../../../../utils/haptics.dart';
import '../../../../widget/a11y/a11y_labels.dart';
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/user_avatar.dart';
import '../data/talk_markdown.dart';
import '../talk_navigator.dart';
import 'notification_level_sheet.dart';
class ChatTile extends StatefulWidget {
final GetRoomResponseObject data;
final bool disableContextActions;
final bool hasDraft;
/// When set, replaces the default tap-into-chat behaviour. Used by the
/// share-intent picker to surface the room selection without opening the
/// chat view itself.
final void Function(GetRoomResponseObject room)? onTapOverride;
const ChatTile({
super.key,
required this.data,
this.disableContextActions = false,
this.hasDraft = false,
this.onTapOverride,
});
@override
State<ChatTile> createState() => _ChatTileState();
}
class _ChatTileState extends State<ChatTile> {
String? selfUsername;
@override
void initState() {
super.initState();
AccountData().waitForPopulation().then((_) {
if (!mounted) return;
setState(
() => selfUsername = AccountData().isPopulated()
? AccountData().getUsername()
: null,
);
});
}
void _refreshList() => context.read<ChatListBloc>().refresh();
/// One-line preview of the last message: rich-object placeholders resolved,
/// newlines flattened and — for Markdown messages — formatting stripped so
/// the list shows readable text rather than raw markers.
///
/// Memoised per last message: the tile rebuilds on every chat-list emit and
/// the Markdown strip is a full parse. Keyed by content, since every refresh
/// delivers new message objects.
String _lastMessagePreview() {
final last = widget.data.lastMessage;
final key = (last.id, last.message, last.markdown);
if (key == _previewFor) return _preview;
final text = RichObjectStringProcessor.parseToString(
last.message.replaceAll('\n', ' '),
last.messageParameters,
);
_previewFor = key;
return _preview = last.markdown ? markdownToPlainText(text) : text;
}
Object? _previewFor;
String _preview = '';
Future<void> _setCurrentAsRead() async {
final token = widget.data.token;
final lastId = widget.data.lastMessage.id;
context.read<ChatListBloc>().markRoomAsRead(token, lastId);
unawaited(NotificationTasks.clearNotificationsForChat(token));
await context.read<ChatBloc>().sendServerReadMarker(token, lastId);
if (!mounted) return;
_refreshList();
}
@override
Widget build(BuildContext context) {
// Only the open token matters here (split-view highlight); watching the
// whole bloc rebuilt every tile on each message of the open chat.
final currentToken = context.select(
(ChatBloc b) => b.state.data?.currentToken,
);
final isGroup =
widget.data.type != GetRoomResponseObjectConversationType.oneToOne;
final circleAvatar = UserAvatar(
id: isGroup ? widget.data.token : widget.data.name,
isGroup: isGroup,
);
return ListTile(
style: ListTileStyle.list,
tileColor:
currentToken == widget.data.token &&
TalkNavigator.isSecondaryVisible(context)
? Theme.of(context).primaryColor.withAlpha(100)
: null,
leading: Stack(
children: [
// Der Name steht bereits im Titel der Zeile – das Avatarbild selbst
// muss der Screenreader nicht ansagen. Nur bei Gruppen ist der
// Hinweis „Gruppe" nützlich (sonst nicht vom Einzelchat zu
// unterscheiden).
Semantics(
label: isGroup ? A11yLabels.group : null,
child: ExcludeSemantics(child: circleAvatar),
),
Visibility(
visible: widget.data.isFavorite,
child: Positioned(
right: 0,
bottom: 0,
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor.withAlpha(200),
borderRadius: BorderRadius.circular(90.0),
),
child: const Icon(
Icons.star,
color: Colors.amberAccent,
size: 15,
semanticLabel: A11yLabels.favorite,
),
),
),
),
],
),
title: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
widget.data.displayName,
overflow: TextOverflow.ellipsis,
),
),
if (widget.hasDraft) ...[
const SizedBox(width: 5),
const Icon(
Icons.edit_outlined,
size: 15,
semanticLabel: A11yLabels.draft,
),
],
if (widget.data.isMuted) ...[
const SizedBox(width: 5),
Icon(
NotificationLevel.neverNotify.icon,
size: 15,
color: Theme.of(context).colorScheme.onSurfaceVariant,
semanticLabel: A11yLabels.muted,
),
],
],
),
subtitle: Text(
'${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: '
'${_lastMessagePreview()}',
overflow: TextOverflow.ellipsis,
),
trailing: widget.data.unreadMessages <= 0
? null
: Semantics(
label: '${widget.data.unreadMessages} ${A11yLabels.unread}',
child: ExcludeSemantics(
child: Container(
padding: const EdgeInsets.all(1),
decoration: BoxDecoration(
color: Theme.of(context).primaryColor,
borderRadius: BorderRadius.circular(30),
),
constraints: const BoxConstraints(
minWidth: 20,
minHeight: 20,
),
child: Text(
'${widget.data.unreadMessages}',
style: const TextStyle(color: Colors.white, fontSize: 15),
textAlign: TextAlign.center,
),
),
),
),
onTap: () {
if (widget.onTapOverride != null) {
widget.onTapOverride!(widget.data);
return;
}
if (selfUsername == null) return;
// openChatView is the single entry point for opening a chat —
// it handles optimistic mark-as-read, tray cleanup, push, and
// setToken in one place so the notification-tap path gets the
// same treatment as a tile tap.
AppRoutes.openChatView(
context,
room: widget.data,
selfId: selfUsername!,
avatar: circleAvatar,
overrideToSingleSubScreen: true,
);
},
onLongPress: () {
if (widget.disableContextActions) return;
Haptics.longPress();
showDetailsBottomSheet(
context,
children: (sheetCtx) => [
if (widget.data.unreadMessages > 0)
AsyncListTile(
leading: const Icon(Icons.mark_chat_read_outlined),
title: const Text('Als gelesen markieren'),
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();
},
),
if (widget.data.isFavorite)
AsyncListTile(
leading: const Icon(Icons.stars_outlined),
title: const Text('Von Favoriten entfernen'),
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();
},
),
NotificationLevelTile(
level: widget.data.effectiveNotificationLevel,
onTap: () {
Navigator.of(sheetCtx).pop();
showNotificationLevelSheet(
context,
widget.data,
current: widget.data.effectiveNotificationLevel,
);
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: const Text('Talk-Chat verlassen'),
onTap: () {
Navigator.of(sheetCtx).pop();
ConfirmDialog(
title: 'Talk-Chat verlassen',
content:
'Du benötigst ggf. eine Einladung um erneut beizutreten.',
confirmButton: 'Verlassen',
onConfirmAsync: () async {
await LeaveRoom(widget.data.token).run();
if (mounted) _refreshList();
},
).asDialog(context);
},
),
DebugTile(sheetCtx).jsonData(widget.data.toJson()),
],
);
},
);
}
}