Files
sepot/app/lib/features/conversation/widgets/attachment_content.dart
T
Julius Joska 5246a97db2 feat(app): redesigned interface, polls, drafts, forwarding and mentions
A new look across the app and a set of everyday messaging features.

Interface
- Home screen with Chats, Contacts and Settings tabs, a large title,
  search across chats and message text (diacritics-insensitive), filters
  and pinned chats.
- Conversation view with a translucent header and composer, grouped
  bubbles, author avatars and colours in groups, swipe to reply, an
  "N new messages" marker, a jump-to-latest button and in-chat search.
- Contact and group profiles: verification, shared photos, groups in
  common, disappearing messages, mute, block; members, admin badges,
  adding people, rename and leave for groups.
- Full-screen photo viewer that pages through the whole conversation.
- A stable avatar colour per person and a clearly visible read state.

Messaging
- Polls in chats and groups, single or multiple choice, with votes that
  can be changed or withdrawn; older clients see them as text.
- Drafts survive leaving a chat and show in the chat list.
- Forwarding of messages, photos, files and voice messages.
- Notes to self: a local-only, encrypted chat that never reaches the
  relay.
- @mentions in groups that notify even when the group is muted.
- Typing indicators sent as ephemeral messages, opt-in and mutual like
  read receipts.
- Delivery and read times per message and per group member.
- Profile photos, cropped, resized and stripped of metadata, delivered
  encrypted to contacts.
- Voice message playback at 1.5x and 2x.
- Call buttons in 1:1 chats and call log entries.

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
2026-10-03 22:44:53 +02:00

570 lines
18 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// Obsah bublin s přílohou — obrázek (thumbnail + stav) a dokument
/// (karta s názvem/velikostí). Plné rozlišení se dešifruje až ve vieweru;
/// v chatu stačí inline náhled, který dorazil UVNITŘ E2E zprávy.
library;
import 'dart:async';
import 'dart:typed_data';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:sepot/core/l10n/error_messages.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/features/conversation/image_viewer_screen.dart';
import 'package:sepot/models/attachment.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:share_plus/share_plus.dart';
String formatBytes(AppLocalizations l10n, int bytes) {
if (bytes < 1024) return l10n.attachmentSizeBytes(bytes);
if (bytes < 1024 * 1024) {
return l10n.attachmentSizeKb((bytes / 1024).toStringAsFixed(0));
}
return l10n.attachmentSizeMb((bytes / (1024 * 1024)).toStringAsFixed(1));
}
/// Cache dešifrovaných plných obrázků (blob_id → bajty), ať se nedešifruje
/// při každém překreslení seznamu. Prostý strop, nejstarší se zahazují.
final _fullImageCache = <String, Uint8List>{};
const _fullImageCacheMax = 30;
void _cachePut(String blobId, Uint8List bytes) {
_fullImageCache.remove(blobId);
_fullImageCache[blobId] = bytes;
while (_fullImageCache.length > _fullImageCacheMax) {
_fullImageCache.remove(_fullImageCache.keys.first);
}
}
/// Obrázková příloha: náhled + overlay podle stavu přenosu.
class ImageAttachmentContent extends ConsumerWidget {
const ImageAttachmentContent({
super.key,
required this.contactId,
required this.message,
});
final String contactId;
final Message message;
@override
Widget build(BuildContext context, WidgetRef ref) {
final meta = message.attachment!;
final thumb = meta.thumbnail;
final aspect = (meta.width != null &&
meta.height != null &&
meta.width! > 0 &&
meta.height! > 0)
? meta.width! / meta.height!
: 4 / 3;
return GestureDetector(
onTap: switch (meta.state) {
// Listovat jde všemi staženými fotkami konverzace.
AttachmentTransferState.ready => () => ImageViewerScreen.open(
context,
message,
ref.read(conversationProvider(contactId)).valueOrNull?.messages ??
[message],
),
AttachmentTransferState.failed => () => ref
.read(conversationsProvider.notifier)
.retryAttachmentDownload(contactId, message),
_ => null,
},
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: aspect.clamp(0.5, 2.5),
child: Stack(
fit: StackFit.expand,
children: [
// Náhled jako podklad/placeholder (rozmazaný, ne kostičkovaný).
// Klíč podle blob_id — jinak ListView recykluje widget a
// s gaplessPlayback ukáže náhled PŘEDCHOZÍ fotky.
if (thumb != null)
Image.memory(thumb,
key: ValueKey('thumb-${meta.blobId}'),
fit: BoxFit.cover,
gaplessPlayback: true)
else
const ColoredBox(
color: SepotColors.surfaceVariant,
child: Icon(Icons.image_outlined,
size: 48, color: SepotColors.textMuted),
),
// Jakmile je blob stažený, překryj OSTROU plnou fotkou.
// Klíč podle blob_id, ať se stav nedědí z jiné fotky.
if (meta.state == AttachmentTransferState.ready)
_FullImage(
key: ValueKey('full-${meta.blobId}'),
contactId: contactId,
message: message),
if (meta.state != AttachmentTransferState.ready)
ColoredBox(
color: Colors.black45,
child: Center(child: _stateIndicator(context, meta.state)),
),
],
),
),
),
);
}
Widget _stateIndicator(BuildContext context, AttachmentTransferState state) {
return switch (state) {
AttachmentTransferState.downloading ||
AttachmentTransferState.pending =>
const SizedBox(
width: 32,
height: 32,
child: CircularProgressIndicator(
strokeWidth: 2.5,
color: Colors.white,
),
),
AttachmentTransferState.failed => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.refresh_rounded, color: Colors.white, size: 32),
const SizedBox(height: 4),
Text(
context.l10n.attachmentDownloadFailedTapToRetry,
textAlign: TextAlign.center,
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
),
AttachmentTransferState.ready => const SizedBox.shrink(),
};
}
}
/// Ostrá plná fotka do bubliny (stav ready). Dešifruje blob do paměti
/// s cache; než je hotová, prosvítá náhled pod ní (žádné kostičky).
class _FullImage extends ConsumerStatefulWidget {
const _FullImage(
{super.key, required this.contactId, required this.message});
final String contactId;
final Message message;
@override
ConsumerState<_FullImage> createState() => _FullImageState();
}
class _FullImageState extends ConsumerState<_FullImage> {
Uint8List? _bytes;
@override
void initState() {
super.initState();
_load();
}
@override
void didUpdateWidget(_FullImage old) {
super.didUpdateWidget(old);
// Kdyby se widget přece jen recykloval na jinou fotku (jiný blob_id),
// zahoď staré bajty a načti čerstvě — žádné zděděné náhledy.
if (old.message.attachment!.blobId != widget.message.attachment!.blobId) {
_bytes = null;
_load();
}
}
Future<void> _load() async {
final blobId = widget.message.attachment!.blobId;
final cached = _fullImageCache[blobId];
if (cached != null) {
setState(() => _bytes = cached);
return;
}
final bytes = await ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
if (bytes != null) _cachePut(blobId, bytes);
if (mounted) setState(() => _bytes = bytes);
}
@override
Widget build(BuildContext context) {
final bytes = _bytes;
if (bytes == null) return const SizedBox.shrink();
return Image.memory(bytes, fit: BoxFit.cover, gaplessPlayback: true);
}
}
/// Hlasová zpráva: play/pause, průběh a délka. Blob se dešifruje do paměti
/// až při prvním přehrání (plaintext audio nikdy nejde na disk).
class AudioAttachmentContent extends ConsumerStatefulWidget {
const AudioAttachmentContent({
super.key,
required this.contactId,
required this.message,
required this.isMine,
});
final String contactId;
final Message message;
final bool isMine;
@override
ConsumerState<AudioAttachmentContent> createState() => _AudioState();
}
class _AudioState extends ConsumerState<AudioAttachmentContent> {
/// Kolečko rychlostí jako u ostatních messengerů; zvolená rychlost platí
/// pro všechny hlasovky a pamatuje si ji šifrované nastavení.
static const _rates = [1.0, 1.5, 2.0];
static const rateSettingKey = 'voice_playback_rate';
final _player = AudioPlayer();
double _rate = 1.0;
final _subs = <StreamSubscription<dynamic>>[];
bool _loading = false;
bool _playing = false;
Duration _pos = Duration.zero;
Duration? _total;
@override
void initState() {
super.initState();
final dur = widget.message.attachment!.durationMs;
_total = dur != null ? Duration(milliseconds: dur) : null;
_loadRate();
_subs.add(_player.onPositionChanged.listen((p) {
if (mounted) setState(() => _pos = p);
}));
_subs.add(_player.onDurationChanged.listen((d) {
if (mounted && d > Duration.zero) setState(() => _total = d);
}));
_subs.add(_player.onPlayerStateChanged.listen((s) {
if (mounted) setState(() => _playing = s == PlayerState.playing);
}));
_subs.add(_player.onPlayerComplete.listen((_) {
if (mounted) setState(() => _pos = Duration.zero);
}));
}
@override
void dispose() {
for (final s in _subs) {
s.cancel();
}
_player.dispose();
super.dispose();
}
Future<void> _loadRate() async {
final saved =
await ref.read(storageProvider).getSetting<num>(rateSettingKey);
if (mounted && saved != null && _rates.contains(saved.toDouble())) {
setState(() => _rate = saved.toDouble());
}
}
Future<void> _cycleRate() async {
final next = _rates[(_rates.indexOf(_rate) + 1) % _rates.length];
setState(() => _rate = next);
await _player.setPlaybackRate(next);
await ref.read(storageProvider).setSetting(rateSettingKey, next);
}
String _fmt(Duration d) {
final m = d.inMinutes.remainder(60).toString();
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
Future<void> _toggle() async {
final meta = widget.message.attachment!;
if (_playing) {
await _player.pause();
return;
}
if (_pos > Duration.zero && _pos < (_total ?? Duration.zero)) {
await _player.resume();
return;
}
setState(() => _loading = true);
final bytes = await ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
if (!mounted) return;
setState(() => _loading = false);
if (bytes == null) return;
await _player.play(BytesSource(bytes, mimeType: meta.mime));
// Rychlost až po startu — část platforem ji před přehráním zahodí.
if (_rate != 1.0) await _player.setPlaybackRate(_rate);
}
@override
Widget build(BuildContext context) {
final meta = widget.message.attachment!;
final ink = widget.isMine ? SepotColors.emberInk : SepotColors.textPrimary;
final accent = widget.isMine ? SepotColors.emberInk : SepotColors.ember;
if (meta.state != AttachmentTransferState.ready) {
return SizedBox(
width: 200,
child: Row(
children: [
const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
const SizedBox(width: 10),
Text(
meta.state == AttachmentTransferState.failed
? context.l10n.attachmentDownloadFailed
: context.l10n.attachmentDownloading,
style: TextStyle(color: ink, fontSize: 13),
),
],
),
);
}
final total = _total ?? Duration.zero;
final progress = total.inMilliseconds == 0
? 0.0
: (_pos.inMilliseconds / total.inMilliseconds).clamp(0.0, 1.0);
return SizedBox(
width: 210,
child: Row(
children: [
GestureDetector(
onTap: _loading ? null : _toggle,
child: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: accent.withValues(alpha: 0.18),
),
child: _loading
? Padding(
padding: const EdgeInsets.all(10),
child: CircularProgressIndicator(
strokeWidth: 2, color: accent),
)
: Icon(_playing ? Icons.pause : Icons.play_arrow,
color: accent, size: 24),
),
),
const SizedBox(width: 10),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
minHeight: 4,
backgroundColor: ink.withValues(alpha: 0.2),
valueColor: AlwaysStoppedAnimation(accent),
),
),
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
if (_playing || _pos > Duration.zero)
GestureDetector(
onTap: _cycleRate,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(8),
),
child: Text(
'${NumberFormat.decimalPattern(
Localizations.localeOf(context).toLanguageTag(),
).format(_rate)}×',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: accent),
),
),
)
else
Icon(Icons.mic,
size: 13, color: ink.withValues(alpha: .6)),
Text(
_playing || _pos > Duration.zero
? _fmt(_pos)
: _fmt(total),
style: TextStyle(
fontSize: 11, color: ink.withValues(alpha: .8)),
),
],
),
],
),
),
],
),
);
}
}
/// Dokument: karta s ikonou, názvem a velikostí. Klepnutí (stav ready)
/// dešifruje do paměti a otevře systémový share sheet — plaintext se na
/// disk dostane jen do cache sdílení (spravuje OS), nikdy do galerie.
class DocumentAttachmentContent extends ConsumerStatefulWidget {
const DocumentAttachmentContent({
super.key,
required this.contactId,
required this.message,
});
final String contactId;
final Message message;
@override
ConsumerState<DocumentAttachmentContent> createState() =>
_DocumentAttachmentContentState();
}
class _DocumentAttachmentContentState
extends ConsumerState<DocumentAttachmentContent> {
bool _opening = false;
Future<void> _open() async {
final meta = widget.message.attachment!;
final l10n = context.l10n;
setState(() => _opening = true);
try {
final bytes = await ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
if (bytes == null) return;
await Share.shareXFiles([
XFile.fromData(
bytes,
name: meta.name ?? l10n.attachmentSharedFileFallbackName,
mimeType: meta.mime,
),
]);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content:
Text(l10n.attachmentOpenFailed(describeError(l10n, e)))),
);
}
} finally {
if (mounted) setState(() => _opening = false);
}
}
@override
Widget build(BuildContext context) {
final meta = widget.message.attachment!;
return InkWell(
onTap: switch (meta.state) {
AttachmentTransferState.ready => _opening ? null : _open,
AttachmentTransferState.failed => () => ref
.read(conversationsProvider.notifier)
.retryAttachmentDownload(widget.contactId, widget.message),
_ => null,
},
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_leadingIcon(meta.state),
const SizedBox(width: 10),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
meta.name ?? context.l10n.commonFile,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: SepotColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
meta.state == AttachmentTransferState.failed
? context.l10n.attachmentDownloadFailedTapToRetry
: formatBytes(context.l10n, meta.size),
style: const TextStyle(
fontSize: 12,
color: SepotColors.textMuted,
),
),
],
),
),
],
),
),
);
}
Widget _leadingIcon(AttachmentTransferState state) {
const size = 38.0;
if (_opening ||
state == AttachmentTransferState.downloading ||
state == AttachmentTransferState.pending) {
return const SizedBox(
width: size,
height: size,
child: Padding(
padding: EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
color: SepotColors.ember,
),
),
);
}
return Container(
width: size,
height: size,
decoration: BoxDecoration(
color: SepotColors.ember.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(10),
),
child: Icon(
state == AttachmentTransferState.failed
? Icons.refresh_rounded
: Icons.description_outlined,
color: SepotColors.ember,
size: 20,
),
);
}
}