feat: chat view with timeline, message bubbles, reply and send
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<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
|
||||
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<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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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}';
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user