feat(chat): usable 1:1 chat — bottom-anchored, Drift-backed, dated

- Anchor the message list to the bottom (`reverse: true`) so new messages
  stay in view instead of landing below the fold.
- Move chat history from the OS keystore (O(n²) JSON blob, silent 200-msg
  cap) to a separate encrypted Drift/SQLCipher DB (`ChatDatabase`):
  indexed append, uncapped history, dedup as a unique-key invariant. It's
  an ephemeral per-device cache, isolated from the inventory schema, its
  migrations, and its sync. No data migration (pre-release).
- Add day separators (Today/Yesterday/locale date) and a per-bubble time,
  all via ICU (12/24h per locale; Localizations locale maps Asturian →
  Spanish for intl date symbols).
- Add peer avatars (deterministic colour from the pubkey + name initial),
  surface send failures that were previously silent, and make bubble text
  selectable (addresses, links).
- New i18n keys in en/es/pt/ast; tests for grouping, formatting, avatars,
  scroll anchoring, storage and send errors.

Docs: docs/design/chat-storage.md + open-decisions.md.
This commit is contained in:
vjrj 2026-07-11 06:39:39 +02:00
parent 171daabce3
commit 6cff0d0b11
27 changed files with 1793 additions and 264 deletions

View file

@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:url_launcher/url_launcher.dart';
import '../di/injector.dart';
import '../domain/chat_timeline.dart';
import '../i18n/strings.g.dart';
import '../services/message_store.dart';
import '../services/profile_cache.dart';
@ -17,6 +18,7 @@ import '../services/unread_service.dart';
import '../services/wot_settings.dart';
import '../state/messages_cubit.dart';
import '../state/trust_cubit.dart';
import 'peer_avatar.dart';
import 'theme.dart';
/// A 1:1 encrypted chat with one peer (redesign screen 10). Private and
@ -40,7 +42,8 @@ class ChatScreen extends StatefulWidget {
final SocialConnection connection;
final String peerPubkey;
/// Optional persistence for chat history (keystore-backed); null in tests.
/// Optional persistence for chat history (encrypted [ChatDatabase]-backed);
/// null in tests.
final MessageStore? messageStore;
/// Optional cache of peer display names; null in tests.
@ -70,8 +73,9 @@ class _ChatScreenState extends State<ChatScreen> {
@override
void initState() {
super.initState();
_unread =
getIt.isRegistered<UnreadService>() ? getIt<UnreadService>() : null;
_unread = getIt.isRegistered<UnreadService>()
? getIt<UnreadService>()
: null;
// Set synchronously (before any await) so a message landing during _init is
// already suppressed.
_unread?.activePeer = widget.peerPubkey;
@ -85,8 +89,7 @@ class _ChatScreenState extends State<ChatScreen> {
final session = await widget.connection.session();
final cachedName = await widget.profileCache?.name(widget.peerPubkey);
final referents = await widget.trustReferents?.all() ?? const <String>{};
final wotParams =
await widget.wotSettings?.params() ?? WotParams.duniter;
final wotParams = await widget.wotSettings?.params() ?? WotParams.duniter;
if (!mounted) return; // shared session is owned by the connection, not us
final self = widget.social.publicKeyHex;
final messages = MessagesCubit(
@ -118,8 +121,10 @@ class _ChatScreenState extends State<ChatScreen> {
final profile = await live.profile.fetch(widget.peerPubkey);
if (profile != null && mounted) {
if (profile.name.isNotEmpty) {
await widget.profileCache!
.setName(widget.peerPubkey, profile.name);
await widget.profileCache!.setName(
widget.peerPubkey,
profile.name,
);
}
setState(() {
if (profile.name.isNotEmpty) _peerName = profile.name;
@ -203,17 +208,26 @@ class _ChatScreenState extends State<ChatScreen> {
BlocProvider.value(value: messages),
BlocProvider.value(value: trust),
],
child: _ChatBody(controller: _input, onSend: _send),
child: _ChatBody(
controller: _input,
onSend: _send,
peerName: _peerName,
),
),
);
}
}
class _ChatBody extends StatelessWidget {
const _ChatBody({required this.controller, required this.onSend});
const _ChatBody({
required this.controller,
required this.onSend,
this.peerName,
});
final TextEditingController controller;
final Future<void> Function() onSend;
final String? peerName;
@override
Widget build(BuildContext context) {
@ -231,31 +245,68 @@ class _ChatBody extends StatelessWidget {
),
);
}
return Column(
children: [
const _TrustBanner(),
Expanded(
child: BlocBuilder<MessagesCubit, ChatState>(
builder: (context, state) {
if (state.messages.isEmpty) {
return Center(
child: Text(t.chat.empty,
style: const TextStyle(color: seedMuted)),
);
}
return ListView.builder(
padding: const EdgeInsets.all(12),
itemCount: state.messages.length,
itemBuilder: (context, i) {
final m = state.messages[i];
return _Bubble(text: m.text, mine: cubit.isMine(m));
},
);
},
),
),
_Composer(controller: controller, onSend: onSend),
],
// Surface a failed send instead of losing it silently (relay down, etc.).
return BlocListener<MessagesCubit, ChatState>(
listenWhen: (prev, cur) => cur.error != null && prev.error != cur.error,
listener: (context, state) {
ScaffoldMessenger.of(context)
..hideCurrentSnackBar()
..showSnackBar(SnackBar(content: Text(t.chat.sendError)));
},
child: Column(
children: [
const _TrustBanner(),
Expanded(child: ChatMessageList(peerName: peerName)),
_Composer(controller: controller, onSend: onSend),
],
),
);
}
}
/// The scrolling list of chat bubbles. Anchored at the bottom (chat
/// convention): the newest message is always in view, and new arrivals show up
/// in place instead of landing below the fold. Reads the [MessagesCubit] from
/// context.
class ChatMessageList extends StatelessWidget {
const ChatMessageList({this.peerName, super.key});
/// The peer's display name, for their avatar's initial (null a glyph).
final String? peerName;
@override
Widget build(BuildContext context) {
final t = context.t;
final cubit = context.read<MessagesCubit>();
return BlocBuilder<MessagesCubit, ChatState>(
builder: (context, state) {
if (state.messages.isEmpty) {
return Center(
child: Text(t.chat.empty, style: const TextStyle(color: seedMuted)),
);
}
// Group into bubbles + day separators (oldest-first), then render
// bottom-anchored: with `reverse: true` index 0 is the bottom-most
// (newest), so map it to the tail. Scrolling up to read history stays
// put when new messages come in.
final items = chatTimeline(state.messages);
return ListView.builder(
reverse: true,
padding: const EdgeInsets.all(12),
itemCount: items.length,
itemBuilder: (context, i) {
final item = items[items.length - 1 - i];
return switch (item) {
ChatDaySeparator(:final day) => _DaySeparator(day: day),
ChatMessageRow(:final message) => _MessageRow(
message: message,
mine: cubit.isMine(message),
peerName: peerName,
),
};
},
);
},
);
}
}
@ -278,10 +329,10 @@ class _TrustBanner extends StatelessWidget {
TrustTier.networkMember => (Icons.verified, t.trust.member, true),
TrustTier.inYourCircle => (Icons.verified_user, t.trust.circle, true),
TrustTier.vouched => (
Icons.people_outline,
t.trust.count(n: state.certifierCount),
false,
),
Icons.people_outline,
t.trust.count(n: state.certifierCount),
false,
),
TrustTier.unknown => (Icons.person_outline, t.trust.none, false),
};
return Container(
@ -315,33 +366,124 @@ class _TrustBanner extends StatelessWidget {
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.text, required this.mine});
/// A centered pill introducing a new day ("Today", "Yesterday", or a
/// locale-formatted date). Localized via [chatDayLabel].
class _DaySeparator extends StatelessWidget {
const _DaySeparator({required this.day});
final String text;
final DateTime day;
@override
Widget build(BuildContext context) {
final t = context.t;
// Localizations locale, not the raw app locale: `ast` has no intl date
// symbols and is mapped to Spanish for the framework (see app.dart).
final localeCode = Localizations.localeOf(context).languageCode;
final label = chatDayLabel(
day,
now: DateTime.now(),
today: t.chat.today,
yesterday: t.chat.yesterday,
localeCode: localeCode,
);
return Center(
key: const Key('chat.daySeparator'),
child: Container(
margin: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: seedMuted.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Text(
label,
style: const TextStyle(
color: seedMuted,
fontSize: 12,
fontWeight: FontWeight.w500,
),
),
),
);
}
}
/// One message line: my messages align to the trailing edge with no avatar;
/// the peer's align to the leading edge with their avatar (1:1 convention).
class _MessageRow extends StatelessWidget {
const _MessageRow({required this.message, required this.mine, this.peerName});
final PrivateMessage message;
final bool mine;
final String? peerName;
@override
Widget build(BuildContext context) {
if (mine) {
return Align(
alignment: AlignmentDirectional.centerEnd,
child: _Bubble(message: message, mine: true),
);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
PeerAvatar(pubkey: message.fromPubkey, name: peerName),
const SizedBox(width: 8),
Flexible(child: _Bubble(message: message, mine: false)),
],
),
);
}
}
class _Bubble extends StatelessWidget {
const _Bubble({required this.message, required this.mine});
final PrivateMessage message;
final bool mine;
@override
Widget build(BuildContext context) {
return Align(
alignment: mine ? AlignmentDirectional.centerEnd : AlignmentDirectional.centerStart,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: mine ? seedGreen : seedPrimaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Text(
text,
style: TextStyle(
color: mine ? Colors.white : seedOnSurface,
fontSize: 15,
final localeCode = Localizations.localeOf(context).languageCode;
final time = chatBubbleTime(message.at, localeCode);
return Container(
margin: const EdgeInsets.symmetric(vertical: 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: mine ? seedGreen : seedPrimaryContainer,
borderRadius: BorderRadius.circular(16),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Long text can be selected/copied useful for addresses, links.
SelectableText(
message.text,
style: TextStyle(
color: mine ? Colors.white : seedOnSurface,
fontSize: 15,
),
),
),
const SizedBox(height: 2),
// A subtle timestamp, trailing edge (RTL-aware).
Align(
alignment: AlignmentDirectional.centerEnd,
child: Text(
time,
style: TextStyle(
color: mine ? Colors.white70 : seedMuted,
fontSize: 11,
),
),
),
],
),
);
}
@ -376,8 +518,10 @@ class _Composer extends StatelessWidget {
borderRadius: BorderRadius.circular(24),
borderSide: BorderSide.none,
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
contentPadding: const EdgeInsets.symmetric(
horizontal: 18,
vertical: 10,
),
),
),
),

View file

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
/// A small round avatar for a chat peer. We have no profile photos yet, so it's
/// a deterministic disc coloured from the peer's pubkey, showing the first
/// letter of their [name] when known (else a person glyph). Same pubkey same
/// colour on every device, so the peer is visually recognizable.
class PeerAvatar extends StatelessWidget {
const PeerAvatar({
required this.pubkey,
this.name,
this.radius = 14,
super.key,
});
final String pubkey;
final String? name;
final double radius;
@override
Widget build(BuildContext context) {
final letter = _initial(name);
return CircleAvatar(
radius: radius,
backgroundColor: peerAvatarColor(pubkey),
child: letter == null
? Icon(Icons.person_outline, size: radius, color: Colors.white)
: Text(
letter,
style: TextStyle(
color: Colors.white,
fontSize: radius,
fontWeight: FontWeight.w600,
),
),
);
}
static String? _initial(String? name) {
if (name == null) return null;
final trimmed = name.trim();
if (trimmed.isEmpty) return null;
// characters.first handles emoji/combining marks safely.
return trimmed.characters.first.toUpperCase();
}
}
/// A deterministic, readable avatar colour derived from [pubkey]. Hashes the
/// key to a hue, then fixes saturation/lightness so white text stays legible on
/// top. Pure and stable exposed for testing.
Color peerAvatarColor(String pubkey) {
// FNV-1a over the code units cheap, stable, well-spread across hues.
var hash = 0x811c9dc5;
for (final unit in pubkey.codeUnits) {
hash = (hash ^ unit) * 0x01000193;
hash &= 0xffffffff;
}
final hue = (hash % 360).toDouble();
return HSLColor.fromAHSL(1, hue, 0.45, 0.42).toColor();
}