feat(chat): message grouping, day separators, quick-reaction row (v1.9.0+13)
B1 grouping: same-sender runs within 5 min collapse to one name + one avatar (last-of-run), tight padding, tail corner only on last bubble B2 day separators: Today/Yesterday/d MMM chip above each local day's first message, Column wraps the gesture widget (long-press preserved) B4 quick reactions: six preset emoji + full-picker button atop the long-press menu, wired to the existing sendReaction provider Verified end-to-end in headless Chromium on the production build; tapping a preset sends a real m.reaction (confirmed server-side). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -106,3 +106,11 @@
|
|||||||
- **Deferred (documented in spec):** B1 message grouping, B2 day separators, B4 quick-reaction row, B5 panic hold-ring, B6 scroll-to-latest FAB, C1 swipe-to-reply (iOS PWA back-gesture risk), C2 group sender prefix (needs RoomModel field), C3 demote Help nav.
|
- **Deferred (documented in spec):** B1 message grouping, B2 day separators, B4 quick-reaction row, B5 panic hold-ring, B6 scroll-to-latest FAB, C1 swipe-to-reply (iOS PWA back-gesture risk), C2 group sender prefix (needs RoomModel field), C3 demote Help nav.
|
||||||
- **Verified:** headless Chromium at 390x844 on production build — gradient login, distinct avatar colours, compose FAB, AA bubble contrast all confirmed.
|
- **Verified:** headless Chromium at 390x844 on production build — gradient login, distinct avatar colours, compose FAB, AA bubble contrast all confirmed.
|
||||||
- **Status:** built + deployed app2.m8chat.au v1.8.0+12 (backup pre180 tarball). Analyze clean, tests pass.
|
- **Status:** built + deployed app2.m8chat.au v1.8.0+12 (backup pre180 tarball). Analyze clean, tests pass.
|
||||||
|
|
||||||
|
### 2026-07-04 08:10 — Deferred UI items built (v1.9.0+13)
|
||||||
|
- **B1 message grouping:** MessageBubble gains optional isFirstInGroup/isLastInGroup (defaulted true so callers keep compiling). _Timeline computes them from reverse-index neighbours (older=index+1, newer=index-1) + 5-min gap. Sender name only on first-of-run, avatar only on last-of-run (32px gutter holds space otherwise), tail corner only on last-of-run, tight 1px padding mid-run.
|
||||||
|
- **B2 day separators:** _DayChip (Today/Yesterday/d MMM, toLocal AEST) rendered in a Column above the first message of each local day. Column wraps the gesture widget so long-press stays intact.
|
||||||
|
- **B4 quick-reaction row:** _MessageContextMenu gains a top row of six presets (👍❤️😂😮😢🙏, 44dp each) + "+" opening the existing EmojiPicker, above Reply. Uses existing sendReaction provider.
|
||||||
|
- **Verified in headless Chromium on production build:** grouping renders (Sam's 3-run collapsed to tight blue bubbles; Alex's 2-run shows name once + avatar on last bubble only, amber deterministic disc), "Today" chip renders, quick-react row renders with all six presets + picker button, tapping ❤️ sent a real m.reaction (confirmed via server /messages). Long-press context menu still fires (all of Reply/React/Copy/Edit/Delete intact).
|
||||||
|
- **Note:** cross-day separators are logic-only verified (can't backdate events without an appservice); the same-day "Today" chip confirms the render path.
|
||||||
|
- **Status:** built + deployed app2.m8chat.au v1.9.0+13 (backup pre190 tarball). Analyze clean, format clean, tests pass.
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ abstract final class AppConfig {
|
|||||||
'turns:matrix.m8chat.au:5349',
|
'turns:matrix.m8chat.au:5349',
|
||||||
];
|
];
|
||||||
static const String appName = 'M8Chat';
|
static const String appName = 'M8Chat';
|
||||||
static const String appVersion = '1.8.0';
|
static const String appVersion = '1.9.0';
|
||||||
|
|
||||||
// Jitsi conferencing
|
// Jitsi conferencing
|
||||||
static const String jitsiDomain = 'conf.m8chat.au';
|
static const String jitsiDomain = 'conf.m8chat.au';
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Full chat screen — timeline + input + typing indicators + read receipts
|
// Full chat screen — timeline + input + typing indicators + read receipts
|
||||||
// + long-press context menu (reply, react, copy, delete) + room options sheet.
|
// + long-press context menu (quick reactions, reply, react, copy, edit,
|
||||||
|
// delete) + day separators + same-sender message grouping + room options.
|
||||||
|
|
||||||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
import 'package:matrix/matrix.dart' show MatrixFile, Room;
|
import 'package:matrix/matrix.dart' show MatrixFile, Room;
|
||||||
|
|
||||||
|
import '../../../app/theme.dart';
|
||||||
import '../../../core/network/matrix_client.dart';
|
import '../../../core/network/matrix_client.dart';
|
||||||
import '../../../shared/utils/matrix_id.dart';
|
import '../../../shared/utils/matrix_id.dart';
|
||||||
import '../../../shared/utils/mxc_url.dart';
|
import '../../../shared/utils/mxc_url.dart';
|
||||||
@@ -161,10 +164,43 @@ class _Timeline extends ConsumerWidget {
|
|||||||
itemCount: messages.length,
|
itemCount: messages.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final message = messages[index];
|
final message = messages[index];
|
||||||
return _MessageWithGestures(
|
// List is reverse:true, so the visually-older neighbour is at
|
||||||
|
// index+1 and the visually-newer neighbour is at index-1.
|
||||||
|
final older = index < messages.length - 1
|
||||||
|
? messages[index + 1]
|
||||||
|
: null;
|
||||||
|
final newer = index > 0 ? messages[index - 1] : null;
|
||||||
|
|
||||||
|
const groupGap = Duration(minutes: 5);
|
||||||
|
final isFirstInGroup =
|
||||||
|
older == null ||
|
||||||
|
older.senderId != message.senderId ||
|
||||||
|
message.timestamp.difference(older.timestamp) > groupGap;
|
||||||
|
final isLastInGroup =
|
||||||
|
newer == null ||
|
||||||
|
newer.senderId != message.senderId ||
|
||||||
|
newer.timestamp.difference(message.timestamp) > groupGap;
|
||||||
|
|
||||||
|
// A day chip sits above the first message of each local day.
|
||||||
|
final showDay =
|
||||||
|
older == null ||
|
||||||
|
!_sameLocalDay(message.timestamp, older.timestamp);
|
||||||
|
|
||||||
|
final bubble = _MessageWithGestures(
|
||||||
message: message,
|
message: message,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
onReply: () => onReply(message),
|
onReply: () => onReply(message),
|
||||||
|
isFirstInGroup: isFirstInGroup,
|
||||||
|
isLastInGroup: isLastInGroup,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!showDay) return bubble;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_DayChip(timestamp: message.timestamp),
|
||||||
|
bubble,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -173,6 +209,54 @@ class _Timeline extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when two timestamps fall on the same day in the local timezone.
|
||||||
|
bool _sameLocalDay(DateTime a, DateTime b) {
|
||||||
|
final la = a.toLocal();
|
||||||
|
final lb = b.toLocal();
|
||||||
|
return la.year == lb.year && la.month == lb.month && la.day == lb.day;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Centred date pill shown above the first message of each local day.
|
||||||
|
class _DayChip extends StatelessWidget {
|
||||||
|
const _DayChip({required this.timestamp});
|
||||||
|
|
||||||
|
final DateTime timestamp;
|
||||||
|
|
||||||
|
String _label() {
|
||||||
|
final now = DateTime.now().toLocal();
|
||||||
|
final local = timestamp.toLocal();
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
final that = DateTime(local.year, local.month, local.day);
|
||||||
|
final diff = today.difference(that).inDays;
|
||||||
|
if (diff == 0) return 'Today';
|
||||||
|
if (diff == 1) return 'Yesterday';
|
||||||
|
if (local.year == now.year) return DateFormat('d MMM').format(local);
|
||||||
|
return DateFormat('d MMM yyyy').format(local);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
return Center(
|
||||||
|
child: Container(
|
||||||
|
margin: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_label(),
|
||||||
|
style: theme.textTheme.labelSmall?.copyWith(
|
||||||
|
color: M8Colours.subtleText,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Long-press context menu
|
// Long-press context menu
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -182,17 +266,25 @@ class _MessageWithGestures extends ConsumerWidget {
|
|||||||
required this.message,
|
required this.message,
|
||||||
required this.roomId,
|
required this.roomId,
|
||||||
required this.onReply,
|
required this.onReply,
|
||||||
|
this.isFirstInGroup = true,
|
||||||
|
this.isLastInGroup = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
final String roomId;
|
final String roomId;
|
||||||
final VoidCallback onReply;
|
final VoidCallback onReply;
|
||||||
|
final bool isFirstInGroup;
|
||||||
|
final bool isLastInGroup;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onLongPress: () => _showContextMenu(context, ref),
|
onLongPress: () => _showContextMenu(context, ref),
|
||||||
child: MessageBubble(message: message),
|
child: MessageBubble(
|
||||||
|
message: message,
|
||||||
|
isFirstInGroup: isFirstInGroup,
|
||||||
|
isLastInGroup: isLastInGroup,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +294,12 @@ class _MessageWithGestures extends ConsumerWidget {
|
|||||||
builder: (_) => _MessageContextMenu(
|
builder: (_) => _MessageContextMenu(
|
||||||
message: message,
|
message: message,
|
||||||
roomId: roomId,
|
roomId: roomId,
|
||||||
|
onQuickReact: (emoji) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
ref
|
||||||
|
.read(sendReactionProvider.notifier)
|
||||||
|
.react(roomId, message.eventId, emoji);
|
||||||
|
},
|
||||||
onReply: () {
|
onReply: () {
|
||||||
Navigator.pop(context);
|
Navigator.pop(context);
|
||||||
onReply();
|
onReply();
|
||||||
@@ -261,9 +359,9 @@ class _MessageWithGestures extends ConsumerWidget {
|
|||||||
.read(editMessageProvider.notifier)
|
.read(editMessageProvider.notifier)
|
||||||
.edit(roomId, message.eventId, newText);
|
.edit(roomId, message.eventId, newText);
|
||||||
if (error != null && context.mounted) {
|
if (error != null && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Edit failed: $error')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Edit failed: $error')));
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text('Save'),
|
child: const Text('Save'),
|
||||||
@@ -346,9 +444,9 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
|||||||
} on Exception catch (e) {
|
} on Exception catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
setState(() => _leaving = false);
|
setState(() => _leaving = false);
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('Could not leave room: $e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('Could not leave room: $e')));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -409,10 +507,8 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
|||||||
itemCount: members.length,
|
itemCount: members.length,
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final member = members[index];
|
final member = members[index];
|
||||||
final name =
|
final name = member.displayName ?? member.id.matrixLocalpart;
|
||||||
member.displayName ?? member.id.matrixLocalpart;
|
final avatarUrl = resolveMxcUrl(client, member.avatarUrl);
|
||||||
final avatarUrl =
|
|
||||||
resolveMxcUrl(client, member.avatarUrl);
|
|
||||||
final isMe = member.id == client.userID;
|
final isMe = member.id == client.userID;
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
@@ -456,10 +552,7 @@ class _RoomOptionsSheetState extends ConsumerState<_RoomOptionsSheet> {
|
|||||||
height: 24,
|
height: 24,
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(Icons.exit_to_app, color: theme.colorScheme.error),
|
||||||
Icons.exit_to_app,
|
|
||||||
color: theme.colorScheme.error,
|
|
||||||
),
|
|
||||||
title: Text(
|
title: Text(
|
||||||
'Leave room',
|
'Leave room',
|
||||||
style: TextStyle(color: theme.colorScheme.error),
|
style: TextStyle(color: theme.colorScheme.error),
|
||||||
@@ -478,6 +571,7 @@ class _MessageContextMenu extends StatelessWidget {
|
|||||||
const _MessageContextMenu({
|
const _MessageContextMenu({
|
||||||
required this.message,
|
required this.message,
|
||||||
required this.roomId,
|
required this.roomId,
|
||||||
|
required this.onQuickReact,
|
||||||
required this.onReply,
|
required this.onReply,
|
||||||
required this.onReact,
|
required this.onReact,
|
||||||
required this.onCopy,
|
required this.onCopy,
|
||||||
@@ -485,8 +579,12 @@ class _MessageContextMenu extends StatelessWidget {
|
|||||||
this.onDelete,
|
this.onDelete,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/// Six one-tap presets shown across the top of the menu.
|
||||||
|
static const _quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
|
||||||
|
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
final String roomId;
|
final String roomId;
|
||||||
|
final void Function(String emoji) onQuickReact;
|
||||||
final VoidCallback onReply;
|
final VoidCallback onReply;
|
||||||
final VoidCallback onReact;
|
final VoidCallback onReact;
|
||||||
final VoidCallback onCopy;
|
final VoidCallback onCopy;
|
||||||
@@ -499,6 +597,43 @@ class _MessageContextMenu extends StatelessWidget {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
|
// Quick-reaction row: one-tap presets + open the full picker.
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
for (final emoji in _quickReactions)
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
onTap: () => onQuickReact(emoji),
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
emoji,
|
||||||
|
style: const TextStyle(fontSize: 24),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
|
onTap: onReact,
|
||||||
|
child: SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Icon(
|
||||||
|
Icons.add,
|
||||||
|
color: Theme.of(context).colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Divider(height: 1),
|
||||||
ListTile(
|
ListTile(
|
||||||
leading: const Icon(Icons.reply),
|
leading: const Icon(Icons.reply),
|
||||||
title: const Text('Reply'),
|
title: const Text('Reply'),
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// Version: 1.4.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
// Version: 1.5.0 | Created: 2026-04-01 | Updated: 2026-07-04
|
||||||
// Message bubble widget. Handles text, images, files, redacted, replies.
|
// Message bubble widget. Handles text, images, files, redacted, replies.
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
@@ -10,10 +10,21 @@ import '../../../shared/widgets/matrix_avatar.dart';
|
|||||||
import '../domain/message_model.dart';
|
import '../domain/message_model.dart';
|
||||||
|
|
||||||
class MessageBubble extends StatelessWidget {
|
class MessageBubble extends StatelessWidget {
|
||||||
const MessageBubble({super.key, required this.message});
|
const MessageBubble({
|
||||||
|
super.key,
|
||||||
|
required this.message,
|
||||||
|
this.isFirstInGroup = true,
|
||||||
|
this.isLastInGroup = true,
|
||||||
|
});
|
||||||
|
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
|
|
||||||
|
/// First message of a same-sender run: show the sender name, add top space.
|
||||||
|
final bool isFirstInGroup;
|
||||||
|
|
||||||
|
/// Last message of a same-sender run: show the avatar, keep the tail corner.
|
||||||
|
final bool isLastInGroup;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
@@ -21,8 +32,8 @@ class MessageBubble extends StatelessWidget {
|
|||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
top: 2,
|
top: isFirstInGroup ? 8 : 1,
|
||||||
bottom: 2,
|
bottom: isLastInGroup ? 2 : 1,
|
||||||
left: isMine ? 48 : 8,
|
left: isMine ? 48 : 8,
|
||||||
right: isMine ? 8 : 48,
|
right: isMine ? 8 : 48,
|
||||||
),
|
),
|
||||||
@@ -33,11 +44,16 @@ class MessageBubble extends StatelessWidget {
|
|||||||
: MainAxisAlignment.start,
|
: MainAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!isMine) ...[
|
if (!isMine) ...[
|
||||||
|
// Only the last message of a run carries the avatar; earlier ones
|
||||||
|
// hold the gutter so the column stays aligned.
|
||||||
|
if (isLastInGroup)
|
||||||
MatrixAvatar(
|
MatrixAvatar(
|
||||||
name: message.senderDisplayName,
|
name: message.senderDisplayName,
|
||||||
avatarUrl: message.senderAvatarUrl,
|
avatarUrl: message.senderAvatarUrl,
|
||||||
radius: 16,
|
radius: 16,
|
||||||
),
|
)
|
||||||
|
else
|
||||||
|
const SizedBox(width: 32),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
],
|
],
|
||||||
Flexible(
|
Flexible(
|
||||||
@@ -46,7 +62,7 @@ class MessageBubble extends StatelessWidget {
|
|||||||
? CrossAxisAlignment.end
|
? CrossAxisAlignment.end
|
||||||
: CrossAxisAlignment.start,
|
: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
if (!isMine)
|
if (!isMine && isFirstInGroup)
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 2),
|
padding: const EdgeInsets.only(left: 4, bottom: 2),
|
||||||
child: Text(
|
child: Text(
|
||||||
@@ -57,7 +73,11 @@ class MessageBubble extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_BubbleContent(message: message, isMine: isMine),
|
_BubbleContent(
|
||||||
|
message: message,
|
||||||
|
isMine: isMine,
|
||||||
|
isLastInGroup: isLastInGroup,
|
||||||
|
),
|
||||||
if (message.reactions.isNotEmpty)
|
if (message.reactions.isNotEmpty)
|
||||||
_ReactionsRow(reactions: message.reactions),
|
_ReactionsRow(reactions: message.reactions),
|
||||||
],
|
],
|
||||||
@@ -70,10 +90,15 @@ class MessageBubble extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _BubbleContent extends StatelessWidget {
|
class _BubbleContent extends StatelessWidget {
|
||||||
const _BubbleContent({required this.message, required this.isMine});
|
const _BubbleContent({
|
||||||
|
required this.message,
|
||||||
|
required this.isMine,
|
||||||
|
this.isLastInGroup = true,
|
||||||
|
});
|
||||||
|
|
||||||
final MessageModel message;
|
final MessageModel message;
|
||||||
final bool isMine;
|
final bool isMine;
|
||||||
|
final bool isLastInGroup;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -85,6 +110,9 @@ class _BubbleContent extends StatelessWidget {
|
|||||||
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
|
final textColour = isMine ? Colors.white : theme.colorScheme.onSurface;
|
||||||
final maxBubbleWidth =
|
final maxBubbleWidth =
|
||||||
(MediaQuery.sizeOf(context).width.clamp(0, 560) * 0.78).toDouble();
|
(MediaQuery.sizeOf(context).width.clamp(0, 560) * 0.78).toDouble();
|
||||||
|
// Only the last bubble of a same-sender run gets the pointed tail corner;
|
||||||
|
// mid-run bubbles stay fully rounded so the run reads as one block.
|
||||||
|
final tail = isLastInGroup ? const Radius.circular(4) : null;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
constraints: BoxConstraints(maxWidth: maxBubbleWidth),
|
||||||
@@ -96,8 +124,12 @@ class _BubbleContent extends StatelessWidget {
|
|||||||
borderRadius: BorderRadius.only(
|
borderRadius: BorderRadius.only(
|
||||||
topLeft: const Radius.circular(16),
|
topLeft: const Radius.circular(16),
|
||||||
topRight: const Radius.circular(16),
|
topRight: const Radius.circular(16),
|
||||||
bottomLeft: Radius.circular(isMine ? 16 : 4),
|
bottomLeft: isMine
|
||||||
bottomRight: Radius.circular(isMine ? 4 : 16),
|
? const Radius.circular(16)
|
||||||
|
: (tail ?? const Radius.circular(16)),
|
||||||
|
bottomRight: isMine
|
||||||
|
? (tail ?? const Radius.circular(16))
|
||||||
|
: const Radius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -240,14 +272,10 @@ class _ImageViewer extends StatelessWidget {
|
|||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
fit: BoxFit.contain,
|
fit: BoxFit.contain,
|
||||||
placeholder: (_, __) => const CircularProgressIndicator(
|
placeholder: (_, __) =>
|
||||||
color: Colors.white,
|
const CircularProgressIndicator(color: Colors.white),
|
||||||
),
|
errorWidget: (_, __, ___) =>
|
||||||
errorWidget: (_, __, ___) => const Icon(
|
const Icon(Icons.broken_image, color: Colors.white, size: 64),
|
||||||
Icons.broken_image,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 64,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
name: m8chat_app
|
name: m8chat_app
|
||||||
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
description: "M8Chat — Matrix chat client for Android, iOS, and Web."
|
||||||
publish_to: 'none'
|
publish_to: 'none'
|
||||||
version: 1.8.0+12
|
version: 1.9.0+13
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: '>=3.11.0 <4.0.0'
|
sdk: '>=3.11.0 <4.0.0'
|
||||||
|
|||||||
Reference in New Issue
Block a user