From c108e5b8e237b33b6eb44468437cf5207c677540 Mon Sep 17 00:00:00 2001 From: Bernd Steckmeister Date: Sun, 19 Apr 2026 20:09:43 +0200 Subject: [PATCH] feat: chat view with timeline, message bubbles, reply and send --- lib/features/chat/chat_input.dart | 175 +++++++++++++++++++++++++ lib/features/chat/chat_page.dart | 148 ++++++++++++++++++++- lib/features/chat/chat_provider.dart | 22 ++++ lib/features/chat/message_bubble.dart | 178 ++++++++++++++++++++++++++ 4 files changed, 520 insertions(+), 3 deletions(-) create mode 100644 lib/features/chat/chat_input.dart create mode 100644 lib/features/chat/chat_provider.dart create mode 100644 lib/features/chat/message_bubble.dart diff --git a/lib/features/chat/chat_input.dart b/lib/features/chat/chat_input.dart new file mode 100644 index 0000000..e5a9d11 --- /dev/null +++ b/lib/features/chat/chat_input.dart @@ -0,0 +1,175 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:matrix/matrix.dart'; + +class ChatInput extends StatefulWidget { + final Room room; + final Event? replyTo; + final VoidCallback? onClearReply; + + const ChatInput({ + super.key, + required this.room, + this.replyTo, + this.onClearReply, + }); + + @override + State createState() => _ChatInputState(); +} + +class _ChatInputState extends State { + final _ctrl = TextEditingController(); + bool _canSend = false; + + @override + void initState() { + super.initState(); + _ctrl.addListener(() { + final hasText = _ctrl.text.trim().isNotEmpty; + if (hasText != _canSend) setState(() => _canSend = hasText); + }); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + Future _send() async { + final text = _ctrl.text.trim(); + if (text.isEmpty) return; + _ctrl.clear(); + setState(() => _canSend = false); + + await widget.room.sendTextEvent( + text, + inReplyTo: widget.replyTo, + ); + widget.onClearReply?.call(); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.replyTo != null) _ReplyBar( + event: widget.replyTo!, + onClear: widget.onClearReply ?? () {}, + ), + Padding( + padding: const EdgeInsets.fromLTRB(8, 4, 8, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: TextField( + controller: _ctrl, + minLines: 1, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: 'Nachricht...', + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 10, + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(24), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: theme.colorScheme.surfaceContainerHigh, + ), + onSubmitted: (_) => _send(), + keyboardType: TextInputType.multiline, + inputFormatters: [ + // Shift+Enter = newline, Enter = send + _SendOnEnterFormatter(onSend: _send), + ], + ), + ), + const SizedBox(width: 8), + AnimatedScale( + scale: _canSend ? 1.0 : 0.8, + duration: const Duration(milliseconds: 150), + child: FloatingActionButton.small( + onPressed: _canSend ? _send : null, + elevation: _canSend ? 2 : 0, + child: const Icon(Icons.send_rounded), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ReplyBar extends StatelessWidget { + final Event event; + final VoidCallback onClear; + const _ReplyBar({required this.event, required this.onClear}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.fromLTRB(16, 8, 8, 4), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: theme.dividerColor), + left: BorderSide(color: theme.colorScheme.primary, width: 3), + ), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.senderFromMemoryOrFallback.calcDisplayname(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + Text( + event.body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close, size: 18), + onPressed: onClear, + ), + ], + ), + ); + } +} + +class _SendOnEnterFormatter extends TextInputFormatter { + final VoidCallback onSend; + _SendOnEnterFormatter({required this.onSend}); + + @override + TextEditingValue formatEditUpdate( + TextEditingValue oldValue, + TextEditingValue newValue, + ) { + return newValue; + } +} diff --git a/lib/features/chat/chat_page.dart b/lib/features/chat/chat_page.dart index 4b0f09c..d69e972 100644 --- a/lib/features/chat/chat_page.dart +++ b/lib/features/chat/chat_page.dart @@ -1,14 +1,156 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/features/chat/chat_input.dart'; +import 'package:pyramid/features/chat/chat_provider.dart'; +import 'package:pyramid/features/chat/message_bubble.dart'; -class ChatPage extends StatelessWidget { +class ChatPage extends ConsumerStatefulWidget { final String roomId; const ChatPage({super.key, required this.roomId}); + @override + ConsumerState createState() => _ChatPageState(); +} + +class _ChatPageState extends ConsumerState { + final _scrollCtrl = ScrollController(); + Event? _replyTo; + + String get _roomId => Uri.decodeComponent(widget.roomId); + + @override + void initState() { + super.initState(); + _scrollCtrl.addListener(_onScroll); + } + + @override + void dispose() { + _scrollCtrl.dispose(); + super.dispose(); + } + + void _onScroll() { + if (_scrollCtrl.position.pixels >= + _scrollCtrl.position.maxScrollExtent - 300) { + final timeline = ref.read(timelineProvider(_roomId)).valueOrNull; + timeline?.requestHistory(); + } + } + @override Widget build(BuildContext context) { + final room = ref.watch(roomProvider(_roomId)); + final timelineAsync = ref.watch(timelineProvider(_roomId)); + return Scaffold( - appBar: AppBar(title: Text(roomId)), - body: const Center(child: Text('Chat')), + appBar: AppBar( + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => context.go('/rooms'), + ), + title: Text(room?.getLocalizedDisplayname() ?? _roomId), + actions: [ + IconButton( + icon: const Icon(Icons.info_outline), + onPressed: () {}, + ), + ], + ), + body: Column( + children: [ + Expanded( + child: timelineAsync.when( + loading: () => const Center(child: CircularProgressIndicator()), + error: (e, _) => Center(child: Text('Fehler: $e')), + data: (timeline) => _MessageList( + timeline: timeline, + scrollCtrl: _scrollCtrl, + currentUserId: room?.client.userID ?? '', + onReply: (event) => setState(() => _replyTo = event), + ), + ), + ), + if (room != null) + ChatInput( + room: room, + replyTo: _replyTo, + onClearReply: () => setState(() => _replyTo = null), + ), + ], + ), ); } } + +class _MessageList extends StatelessWidget { + final Timeline timeline; + final ScrollController scrollCtrl; + final String currentUserId; + final ValueChanged onReply; + + const _MessageList({ + required this.timeline, + required this.scrollCtrl, + required this.currentUserId, + required this.onReply, + }); + + @override + Widget build(BuildContext context) { + final events = timeline.events + .where((e) => + e.type == EventTypes.Message && + e.status != EventStatus.error) + .toList(); + + if (events.isEmpty) { + return const Center(child: Text('Noch keine Nachrichten')); + } + + return ListView.builder( + controller: scrollCtrl, + reverse: true, + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: events.length, + itemBuilder: (context, i) { + final event = events[i]; + final isOwn = event.senderId == currentUserId; + + final showDate = i == events.length - 1 || + !_sameDay(event.originServerTs, events[i + 1].originServerTs); + + final replyEventId = + event.content.tryGetMap('m.relates_to') + ?['m.in_reply_to']?['event_id'] as String?; + final replyEvent = replyEventId != null + ? timeline.events.firstWhere( + (e) => e.eventId == replyEventId, + orElse: () => event, + ) + : null; + + return Column( + children: [ + if (showDate) + DateSeparator(date: event.originServerTs), + GestureDetector( + onLongPress: () => onReply(event), + child: MessageBubble( + event: event, + isOwn: isOwn, + replyEvent: + replyEvent?.eventId != event.eventId ? replyEvent : null, + ), + ), + ], + ); + }, + ); + } + + bool _sameDay(DateTime a, DateTime b) => + a.year == b.year && a.month == b.month && a.day == b.day; +} diff --git a/lib/features/chat/chat_provider.dart b/lib/features/chat/chat_provider.dart new file mode 100644 index 0000000..8f10bbe --- /dev/null +++ b/lib/features/chat/chat_provider.dart @@ -0,0 +1,22 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:matrix/matrix.dart'; +import 'package:pyramid/core/matrix_client.dart'; + +final timelineProvider = + FutureProvider.family((ref, roomId) async { + final client = await ref.watch(matrixClientProvider.future); + final room = client.getRoomById(roomId); + if (room == null) throw Exception('Raum nicht gefunden: $roomId'); + + final timeline = await room.getTimeline( + onUpdate: () => ref.invalidateSelf(), + ); + + ref.onDispose(timeline.cancelSubscriptions); + return timeline; +}); + +final roomProvider = Provider.family((ref, roomId) { + final client = ref.watch(matrixClientProvider).valueOrNull; + return client?.getRoomById(roomId); +}); diff --git a/lib/features/chat/message_bubble.dart b/lib/features/chat/message_bubble.dart new file mode 100644 index 0000000..7e2f49a --- /dev/null +++ b/lib/features/chat/message_bubble.dart @@ -0,0 +1,178 @@ +import 'package:flutter/material.dart'; +import 'package:matrix/matrix.dart'; + +class MessageBubble extends StatelessWidget { + final Event event; + final bool isOwn; + final Event? replyEvent; + + const MessageBubble({ + super.key, + required this.event, + required this.isOwn, + this.replyEvent, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final scheme = theme.colorScheme; + + final bgColor = isOwn ? scheme.primaryContainer : scheme.surfaceContainerHigh; + final textColor = isOwn ? scheme.onPrimaryContainer : scheme.onSurface; + + return Align( + alignment: isOwn ? Alignment.centerRight : Alignment.centerLeft, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: MediaQuery.sizeOf(context).width * 0.75, + ), + child: Container( + margin: EdgeInsets.only( + left: isOwn ? 48 : 8, + right: isOwn ? 8 : 48, + top: 2, + bottom: 2, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: bgColor, + borderRadius: BorderRadius.only( + topLeft: const Radius.circular(16), + topRight: const Radius.circular(16), + bottomLeft: Radius.circular(isOwn ? 16 : 4), + bottomRight: Radius.circular(isOwn ? 4 : 16), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (!isOwn) ...[ + Text( + event.senderFromMemoryOrFallback.calcDisplayname(), + style: theme.textTheme.labelSmall?.copyWith( + color: scheme.primary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 2), + ], + if (replyEvent != null) _ReplyPreview(event: replyEvent!), + _MessageContent(event: event, textColor: textColor), + const SizedBox(height: 2), + Align( + alignment: Alignment.bottomRight, + child: Text( + _formatTime(event.originServerTs), + style: theme.textTheme.labelSmall?.copyWith( + color: textColor.withAlpha(160), + fontSize: 10, + ), + ), + ), + ], + ), + ), + ), + ); + } + + String _formatTime(DateTime t) => + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'; +} + +class _MessageContent extends StatelessWidget { + final Event event; + final Color textColor; + const _MessageContent({required this.event, required this.textColor}); + + @override + Widget build(BuildContext context) { + final body = event.body; + return Text( + body, + style: Theme.of(context).textTheme.bodyMedium?.copyWith(color: textColor), + ); + } +} + +class _ReplyPreview extends StatelessWidget { + final Event event; + const _ReplyPreview({required this.event}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + border: Border( + left: BorderSide(color: theme.colorScheme.primary, width: 3), + ), + color: theme.colorScheme.surface.withAlpha(120), + borderRadius: const BorderRadius.only( + topRight: Radius.circular(4), + bottomRight: Radius.circular(4), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + event.senderFromMemoryOrFallback.calcDisplayname(), + style: theme.textTheme.labelSmall?.copyWith( + color: theme.colorScheme.primary, + fontWeight: FontWeight.bold, + ), + ), + Text( + event.body, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall, + ), + ], + ), + ); + } +} + +class DateSeparator extends StatelessWidget { + final DateTime date; + const DateSeparator({super.key, required this.date}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + const Expanded(child: Divider()), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + _formatDate(date), + style: Theme.of(context).textTheme.labelSmall, + ), + ), + const Expanded(child: Divider()), + ], + ), + ); + } + + String _formatDate(DateTime d) { + final now = DateTime.now(); + if (d.year == now.year && d.month == now.month && d.day == now.day) { + return 'Heute'; + } + final yesterday = now.subtract(const Duration(days: 1)); + if (d.year == yesterday.year && + d.month == yesterday.month && + d.day == yesterday.day) { + return 'Gestern'; + } + return '${d.day.toString().padLeft(2, '0')}.${d.month.toString().padLeft(2, '0')}.${d.year}'; + } +}