252 lines
8.8 KiB
Dart
252 lines
8.8 KiB
Dart
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/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 '../talk_navigator.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();
|
||
|
||
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) {
|
||
final chatBloc = context.watch<ChatBloc>();
|
||
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:
|
||
chatBloc.state.data?.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,
|
||
),
|
||
],
|
||
],
|
||
),
|
||
subtitle: Text(
|
||
'${DateTime.fromMillisecondsSinceEpoch(widget.data.lastMessage.timestamp * 1000).formatRelative()}: '
|
||
'${RichObjectStringProcessor.parseToString(widget.data.lastMessage.message.replaceAll("\n", " "), widget.data.lastMessage.messageParameters)}',
|
||
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();
|
||
},
|
||
),
|
||
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()),
|
||
],
|
||
);
|
||
},
|
||
);
|
||
}
|
||
}
|