feat: chat view with timeline, message bubbles, reply and send

This commit is contained in:
Bernd Steckmeister
2026-04-19 20:09:43 +02:00
parent 5a909f0e0d
commit c108e5b8e2
4 changed files with 520 additions and 3 deletions
+175
View File
@@ -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<ChatInput> createState() => _ChatInputState();
}
class _ChatInputState extends State<ChatInput> {
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<void> _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;
}
}
+145 -3
View File
@@ -1,14 +1,156 @@
import 'package:flutter/material.dart'; 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; final String roomId;
const ChatPage({super.key, required this.roomId}); const ChatPage({super.key, required this.roomId});
@override
ConsumerState<ChatPage> createState() => _ChatPageState();
}
class _ChatPageState extends ConsumerState<ChatPage> {
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final room = ref.watch(roomProvider(_roomId));
final timelineAsync = ref.watch(timelineProvider(_roomId));
return Scaffold( return Scaffold(
appBar: AppBar(title: Text(roomId)), appBar: AppBar(
body: const Center(child: Text('Chat')), 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<Event> 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<String, dynamic>('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;
}
+22
View File
@@ -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<Timeline, String>((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<Room?, String>((ref, roomId) {
final client = ref.watch(matrixClientProvider).valueOrNull;
return client?.getRoomById(roomId);
});
+178
View File
@@ -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}';
}
}