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:
parent
171daabce3
commit
6cff0d0b11
27 changed files with 1793 additions and 264 deletions
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
59
apps/app_seeds/lib/ui/peer_avatar.dart
Normal file
59
apps/app_seeds/lib/ui/peer_avatar.dart
Normal 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue