Files
sepot/app/lib/features/conversation/conversation_screen.dart
T
Julius Joska 641d70b76e fix: Settings crash, photo preview before sending, incoming-content hardening
Settings crashed with a TypeError: the profile card passed a
SepotFingerprint object where the localized string formatter expects
a String (hidden behind a `dynamic` parameter). Also fixes RangeError
in avatar initials (empty names, double spaces) and switches initials
to grapheme clusters so emoji don't get split.

New photo preview screen: a captured or picked photo no longer sends
immediately — it opens in a preview with freehand drawing (7 colours,
undo), draggable text overlay, 90° rotation and a caption field.
Nothing leaves the device until Send is tapped.

Incoming-content hardening: decryption and content processing are now
separated in the delivery path. Once an envelope is decrypted the
ratchet has advanced, so redelivery can never help — permanently
malformed content (FormatException/TypeError) is acked and dropped
instead of poisoning the queue on every reconnect. Attachment metadata
now validates base64 of the crypto fields up front, and a corrupt
inline thumbnail no longer breaks bubble rendering.

PNG without real transparency (screenshots, edited photos) is now
re-encoded as JPEG — several times smaller on the wire.

Tests: 116 (new widget tests for Settings and the photo preview,
AttachmentMeta wire validation).
2026-07-13 08:15:01 +02:00

1630 lines
57 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.
import 'dart:async';
import 'dart:io';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import 'package:record/record.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:image_picker/image_picker.dart';
import 'package:intl/intl.dart';
import 'package:sepot/core/constants/app_constants.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/widgets/attachment_content.dart';
import 'package:sepot/models/contact.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/group.dart';
import 'package:sepot/models/identity.dart';
import 'package:sepot/features/conversation/photo_preview_screen.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/conversations_provider.dart';
import 'package:sepot/providers/groups_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/storage_provider.dart';
import 'package:sepot/widgets/avatar.dart';
import 'package:sepot/widgets/e2e_badge.dart';
enum _AttachAction { gallery, camera, document }
/// Chat obrazovka — zprávy s kontaktem
class ConversationScreen extends ConsumerStatefulWidget {
final String contactId;
const ConversationScreen({super.key, required this.contactId});
@override
ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
}
class _ConversationScreenState extends ConsumerState<ConversationScreen> {
final _messageController = TextEditingController();
final _scrollController = ScrollController();
bool _isSending = false;
Message? _replyingTo; // aktivní odpověď (nad inputem)
String? _editingId; // id právě upravované zprávy
// Nahrávání hlasové zprávy
final _recorder = AudioRecorder();
bool _isRecording = false;
Duration _recordDur = Duration.zero;
Timer? _recordTimer;
String? _recordPath;
/// Malá sada emoji pro rychlé reakce.
static const _quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏'];
@override
void initState() {
super.initState();
// Označ zprávy jako přečtené
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.read(conversationsProvider.notifier).markAsRead(widget.contactId);
});
}
@override
void dispose() {
_messageController.dispose();
_scrollController.dispose();
_recordTimer?.cancel();
_recorder.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final contacts = ref.watch(contactsProvider).valueOrNull ?? [];
final contact = contacts
.where((c) => c.publicIdentity.id == widget.contactId)
.firstOrNull;
final conversationAsync = ref.watch(conversationProvider(widget.contactId));
final identity = ref.watch(identityProvider).valueOrNull;
// Skupinová konverzace: contactId == gid
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
if (group != null) {
return _buildGroupScaffold(context, group, conversationAsync, identity,
contacts: contacts);
}
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: Row(
children: [
SepotAvatar(name: contact?.displayName ?? '?', size: 36),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
(contact == null || contact.displayName.isEmpty)
? context.l10n.commonUnknownContact
: contact.displayName,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
// Stav ověření místo syrového ID —
// klepnutí vede na safety number obrazovku
if (contact != null)
GestureDetector(
onTap: () => context.push('/verify/${contact.id}'),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
contact.verified
? Icons.verified
: Icons.shield_outlined,
size: 12,
color: contact.verified
? SepotColors.sage
: SepotColors.textMuted,
),
const SizedBox(width: 4),
Text(
contact.verified
? context.l10n.verifyVerified
: context.l10n.verifyUnverified,
style: TextStyle(
fontSize: 11,
color: contact.verified
? SepotColors.sage
: SepotColors.textMuted,
),
),
],
),
),
],
),
),
],
),
actions: [
const E2EBadge(compact: true),
const SizedBox(width: 8),
if (contact != null)
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'verify':
context.push('/verify/${contact.id}');
case 'disappearing':
_configureDisappearing();
case 'block':
_setBlocked(contact, true);
case 'unblock':
_setBlocked(contact, false);
case 'report':
_reportAndBlock(contact);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'verify',
child: Text(context.l10n.chatMenuVerifyContact),
),
PopupMenuItem(
value: 'disappearing',
child: Text(context.l10n.chatDisappearingMessages),
),
const PopupMenuDivider(),
if (contact.blocked)
PopupMenuItem(
value: 'unblock',
child: Text(context.l10n.chatMenuUnblockContact),
)
else ...[
PopupMenuItem(
value: 'block',
child: Text(context.l10n.chatMenuBlockContact),
),
PopupMenuItem(
value: 'report',
child: Text(context.l10n.reportAndBlock,
style: const TextStyle(color: SepotColors.error)),
),
],
],
),
],
),
body: Column(
children: [
// Seznam zpráv
Expanded(
child: conversationAsync.when(
data: (conversation) {
final messages = conversation?.messages ?? [];
if (messages.isEmpty) {
return _buildEmptyChat();
}
return ListView.builder(
controller: _scrollController,
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 8,
),
reverse: true,
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
final isMine = msg.isMine(identity?.id ?? '');
final showDate = index == messages.length - 1 ||
!_isSameDay(
msg.timestamp,
messages[messages.length - 2 - index].timestamp,
);
final repliedTo = msg.replyToId == null
? null
: messages
.where((m) => m.id == msg.replyToId)
.firstOrNull;
return Column(
children: [
if (showDate) _buildDateDivider(msg.timestamp),
_MessageBubble(
message: msg,
isMine: isMine,
contactId: widget.contactId,
repliedTo: repliedTo,
onLongPress: () => _showMessageActions(msg, isMine),
onToggleReaction: (emoji) =>
_toggleReaction(msg, emoji),
),
],
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
),
error: (e, _) =>
Center(child: Text(describeError(context.l10n, e))),
),
),
// Input bar — nebo banner, když je kontakt blokovaný
if (contact?.blocked == true)
_buildBlockedBanner(contact!)
else
_buildInputBar(),
],
),
);
}
/// Skupinová konverzace : jména autorů nad cizími
/// bublinami, menu Členové / Přejmenovat / Opustit, žádný verify badge
/// (ověřují se jednotliví členové v Kontaktech).
Widget _buildGroupScaffold(
BuildContext context,
Group group,
AsyncValue<Conversation?> conversationAsync,
LocalIdentity? identity, {
required List<Contact> contacts,
}) {
// Fingerprint → jméno pro popisky autorů. Členy, kteří ještě nejsou
// v kontaktech (roster je zakládá až s první zprávou), zkrátíme na id.
String autorJmeno(String fingerprint) {
final jmeno = contacts
.where((c) => c.publicIdentity.id == fingerprint)
.firstOrNull
?.displayName;
if (jmeno == null) {
return 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}';
}
return jmeno.isEmpty ? context.l10n.commonUnknownContact : jmeno;
}
return Scaffold(
appBar: AppBar(
titleSpacing: 0,
title: Row(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: SepotColors.ember.withValues(alpha: 0.15),
shape: BoxShape.circle,
),
child: const Icon(Icons.group_rounded,
size: 20, color: SepotColors.ember),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
group.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
GestureDetector(
onTap: () => _showGroupMembers(group, autorJmeno),
child: Text(
group.left
? context.l10n.groupsYouLeftGroup
: context.l10n
.groupsMemberCount(group.memberIds.length),
style: const TextStyle(
fontSize: 11,
color: SepotColors.textMuted,
),
),
),
],
),
),
],
),
actions: [
const E2EBadge(compact: true),
const SizedBox(width: 8),
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) {
switch (value) {
case 'members':
_showGroupMembers(group, autorJmeno);
case 'rename':
_renameGroup(group);
case 'disappearing':
_configureDisappearing();
case 'leave':
_leaveGroup(group);
}
},
itemBuilder: (context) => [
PopupMenuItem(
value: 'members', child: Text(context.l10n.groupsMembers)),
if (!group.left) ...[
PopupMenuItem(
value: 'rename', child: Text(context.l10n.groupsRename)),
PopupMenuItem(
value: 'disappearing',
child: Text(context.l10n.chatDisappearingMessages)),
const PopupMenuDivider(),
PopupMenuItem(
value: 'leave',
child: Text(context.l10n.groupsLeave,
style: const TextStyle(color: SepotColors.error)),
),
],
],
),
],
),
body: Column(
children: [
Expanded(
child: conversationAsync.when(
data: (conversation) {
final messages = conversation?.messages ?? [];
if (messages.isEmpty) return _buildEmptyChat();
return ListView.builder(
controller: _scrollController,
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
reverse: true,
itemCount: messages.length,
itemBuilder: (context, index) {
final msg = messages[messages.length - 1 - index];
final isMine = msg.isMine(identity?.id ?? '');
final showDate = index == messages.length - 1 ||
!_isSameDay(
msg.timestamp,
messages[messages.length - 2 - index].timestamp,
);
final repliedTo = msg.replyToId == null
? null
: messages
.where((m) => m.id == msg.replyToId)
.firstOrNull;
// Jméno autora jen u cizí zprávy a jen když ji nepíše
// tentýž autor jako předchozí (shlukování bublin).
final predchozi = index == messages.length - 1
? null
: messages[messages.length - 2 - index];
final zobrazAutora = !isMine &&
(showDate ||
predchozi == null ||
predchozi.senderId != msg.senderId);
return Column(
children: [
if (showDate) _buildDateDivider(msg.timestamp),
if (zobrazAutora)
Align(
alignment: Alignment.centerLeft,
child: Padding(
padding:
const EdgeInsets.only(left: 12, bottom: 2),
child: Text(
autorJmeno(msg.senderId),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: SepotColors.textMuted,
),
),
),
),
_MessageBubble(
message: msg,
isMine: isMine,
contactId: widget.contactId,
repliedTo: repliedTo,
onLongPress: () => _showMessageActions(msg, isMine),
onToggleReaction: (emoji) =>
_toggleReaction(msg, emoji),
),
],
);
},
);
},
loading: () => const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
),
error: (e, _) =>
Center(child: Text(describeError(context.l10n, e))),
),
),
if (group.left) _buildLeftGroupBanner() else _buildInputBar(),
],
),
);
}
/// Banner místo inputu ve skupině, kterou jsem opustil.
Widget _buildLeftGroupBanner() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(color: SepotColors.surface),
child: Text(
context.l10n.groupsLeftBanner,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 13, color: SepotColors.textMuted),
),
);
}
void _showGroupMembers(Group group, String Function(String) autorJmeno) {
final identity = ref.read(identityProvider).valueOrNull;
showModalBottomSheet<void>(
context: context,
backgroundColor: SepotColors.surface,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Text(
context.l10n.groupsMembersWithCount(group.memberIds.length),
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w600),
),
),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
for (final f in group.memberIds)
// Sebe ukazuj vlastním jménem — autorJmeno() by pro můj
// fingerprint spadlo na „SEP-…" (v kontaktech nejsem).
ListTile(
leading: SepotAvatar(
name: f == identity?.id
? (identity?.displayName ?? context.l10n.groupsYou)
: autorJmeno(f),
size: 36,
),
title: Text(
f == identity?.id
? context.l10n.groupsYou
: autorJmeno(f),
style: const TextStyle(fontSize: 14),
),
subtitle: f == group.createdBy
? Text(context.l10n.groupsFounder,
style: const TextStyle(fontSize: 11))
: null,
// Ověření členů běží přes Kontakty (skupina nemá
// vlastní safety number — každá dvojice má svůj).
onTap: f == identity?.id
? null
: () {
Navigator.of(ctx).pop();
context.push('/verify/$f');
},
),
],
),
),
const SizedBox(height: 8),
],
),
),
);
}
Future<void> _renameGroup(Group group) async {
final controller = TextEditingController(text: group.name);
final novy = await showDialog<String>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.groupsRename),
content: TextField(
controller: controller,
autofocus: true,
maxLength: 64,
decoration:
InputDecoration(hintText: context.l10n.groupsNameHint),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(),
child: Text(context.l10n.commonCancel)),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
child: Text(context.l10n.commonSave),
),
],
),
);
if (novy == null || novy.isEmpty || novy == group.name) return;
try {
await ref.read(groupsProvider.notifier).rename(group, novy);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n
.groupsRenameFailed(describeError(context.l10n, e)))),
);
}
}
}
Future<void> _leaveGroup(Group group) async {
final potvrzeno = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.groupsLeaveConfirmTitle),
content: Text(
context.l10n.groupsLeaveConfirmBody,
style: const TextStyle(color: SepotColors.textSecondary),
),
actions: [
TextButton(
onPressed: () => Navigator.of(ctx).pop(false),
child: Text(context.l10n.commonCancel)),
ElevatedButton(
onPressed: () => Navigator.of(ctx).pop(true),
style: ElevatedButton.styleFrom(
backgroundColor: SepotColors.error),
child: Text(context.l10n.groupsLeaveAction),
),
],
),
);
if (potvrzeno != true) return;
try {
await ref.read(groupsProvider.notifier).leave(group);
if (mounted) context.pop();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n
.groupsLeaveFailed(describeError(context.l10n, e)))),
);
}
}
}
/// Banner místo inputu u blokovaného kontaktu.
Widget _buildBlockedBanner(Contact contact) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: const BoxDecoration(
color: SepotColors.surface,
border: Border(top: BorderSide(color: SepotColors.border)),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
context.l10n.chatBlockedBanner,
textAlign: TextAlign.center,
style: const TextStyle(
color: SepotColors.textSecondary, fontSize: 13),
),
const SizedBox(height: 10),
TextButton(
onPressed: () => _setBlocked(contact, false),
child: Text(context.l10n.chatUnblock),
),
],
),
),
);
}
Future<void> _setBlocked(Contact contact, bool blocked) async {
await ref
.read(contactsProvider.notifier)
.setBlocked(contact.id, blocked);
if (mounted) {
final jmeno = contact.displayName.isEmpty
? context.l10n.commonUnknownContact
: contact.displayName;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(blocked
? context.l10n.chatContactBlocked(jmeno)
: context.l10n.chatContactUnblocked(jmeno)),
),
);
}
}
Future<void> _reportAndBlock(Contact contact) async {
final jmeno = contact.displayName.isEmpty
? context.l10n.commonUnknownContact
: contact.displayName;
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.reportAndBlock),
content: Text(context.l10n.reportAndBlockBody(jmeno)),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(context.l10n.commonCancel)),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(context.l10n.reportAndBlock,
style: const TextStyle(color: SepotColors.error)),
),
],
),
);
if (ok != true) return;
await _setBlocked(contact, true);
if (!mounted) return;
final q = <String, String>{
'contact': contact.id,
'name': contact.displayName,
};
context.push(Uri(path: '/report', queryParameters: q).toString());
}
Widget _buildEmptyChat() {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const E2EBadge(),
const SizedBox(height: 16),
Text(
context.l10n.chatEmptyEncrypted,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
color: SepotColors.textSecondary,
),
),
const SizedBox(height: 4),
Text(
context.l10n.chatEmptyNoOneElse,
style: const TextStyle(
fontFamily: kFontSans,
fontSize: 14,
color: SepotColors.textSecondary,
),
),
],
),
);
}
Widget _buildDateDivider(DateTime date) {
final now = DateTime.now();
String text;
if (_isSameDay(date, now)) {
text = context.l10n.commonToday;
} else if (_isSameDay(date, now.subtract(const Duration(days: 1)))) {
text = context.l10n.commonYesterday;
} else {
text = DateFormat.yMMMd(Localizations.localeOf(context).toString())
.format(date);
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: SepotColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
),
child: Text(
text,
style: const TextStyle(
fontSize: 12,
color: SepotColors.textMuted,
),
),
),
),
);
}
Widget _buildInputBar() {
final hasText =
_messageController.text.trim().isNotEmpty || _editingId != null;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
decoration: const BoxDecoration(
color: SepotColors.surface,
border: Border(
top: BorderSide(color: SepotColors.border),
),
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_replyingTo != null || _editingId != null) _buildComposeBanner(),
if (_isRecording)
_buildRecordingBar()
else
// Jedno pole s tenkým obrysem (odlišené od plných bublin),
// příloha i mikrofon/odeslat UVNITŘ. `filled: false` PŘEBÍJÍ
// globální InputDecorationTheme (jinak by vzniklo vnořené pole).
Container(
decoration: BoxDecoration(
color: SepotColors.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(color: SepotColors.border),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
IconButton(
icon: const Icon(Icons.attach_file_rounded,
size: 22, color: SepotColors.textMuted),
visualDensity: VisualDensity.compact,
onPressed: (_isSending || _editingId != null)
? null
: _pickAndSendAttachment,
),
Expanded(
child: TextField(
controller: _messageController,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: context.l10n.chatMessageHint,
filled: false,
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(vertical: 9),
),
maxLines: 5,
minLines: 1,
textCapitalization: TextCapitalization.sentences,
),
),
IconButton(
icon: _isSending
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: SepotColors.ember),
)
: Icon(
hasText
? (_editingId != null
? Icons.check_rounded
: Icons.send_rounded)
: Icons.mic_rounded,
size: 24,
color: SepotColors.ember),
visualDensity: VisualDensity.compact,
onPressed: _isSending
? null
: (hasText ? _sendMessage : _startRecording),
),
],
),
),
],
),
),
);
}
String _fmtRec(Duration d) {
final m = d.inMinutes.remainder(60).toString();
final s = d.inSeconds.remainder(60).toString().padLeft(2, '0');
return '$m:$s';
}
/// Lišta při nahrávání hlasové zprávy: zahodit · čas · odeslat.
Widget _buildRecordingBar() {
return Row(
children: [
IconButton(
icon: const Icon(Icons.delete_outline, color: SepotColors.error),
onPressed: _cancelRecording,
tooltip: context.l10n.chatDiscardRecording,
),
Container(
width: 10,
height: 10,
decoration: const BoxDecoration(
shape: BoxShape.circle, color: SepotColors.error),
),
const SizedBox(width: 10),
Text(_fmtRec(_recordDur),
style: const TextStyle(
color: SepotColors.textPrimary,
fontFeatures: [FontFeature.tabularFigures()])),
const SizedBox(width: 12),
Text(context.l10n.chatRecording,
style: const TextStyle(color: SepotColors.textMuted, fontSize: 13)),
const Spacer(),
Container(
decoration: const BoxDecoration(
shape: BoxShape.circle, color: SepotColors.ember),
child: IconButton(
icon: const Icon(Icons.send_rounded, color: Colors.white),
onPressed: _stopAndSend,
),
),
],
);
}
Future<void> _startRecording() async {
try {
if (!await _recorder.hasPermission()) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(context.l10n.chatMicPermissionMissing)),
);
}
return;
}
final dir = await getTemporaryDirectory();
final path =
'${dir.path}/voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
await _recorder.start(
const RecordConfig(
encoder: AudioEncoder.aacLc, bitRate: 64000, sampleRate: 44100),
path: path,
);
_recordPath = path;
setState(() {
_isRecording = true;
_recordDur = Duration.zero;
});
_recordTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) {
setState(() => _recordDur += const Duration(seconds: 1));
}
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n
.chatRecordingFailed(describeError(context.l10n, e)))),
);
}
}
}
Future<void> _cancelRecording() async {
_recordTimer?.cancel();
try {
await _recorder.stop();
} catch (_) {}
final p = _recordPath;
if (p != null) {
try {
await File(p).delete();
} catch (_) {}
}
if (mounted) {
setState(() {
_isRecording = false;
_recordPath = null;
});
}
}
Future<void> _stopAndSend() async {
_recordTimer?.cancel();
final dur = _recordDur;
String? path;
try {
path = await _recorder.stop();
} catch (_) {}
setState(() => _isRecording = false);
if (path == null) return;
final file = File(path);
try {
// Moc krátké klepnutí — zahoď (uživatel to nechtěl poslat)
if (dur.inMilliseconds < 800) {
await file.delete();
return;
}
final bytes = await file.readAsBytes();
await ref.read(conversationsProvider.notifier).sendVoiceMessage(
widget.contactId,
bytes: bytes,
durationMs: dur.inMilliseconds,
);
ref.invalidate(conversationProvider(widget.contactId));
await file.delete();
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n
.chatVoiceSendFailed(describeError(context.l10n, e)))),
);
}
}
}
/// Banner nad inputem: aktivní odpověď nebo režim úpravy.
Widget _buildComposeBanner() {
final editing = _editingId != null;
final preview = editing
? _messageController.text
: (_replyingTo?.isDeleted ?? false
? context.l10n.chatDeletedMessagePreview
: (_replyingTo?.attachment?.isAudio == true
? context.l10n.chatVoiceMessagePreview
: (_replyingTo?.content?.isNotEmpty == true
? _replyingTo!.content!
: (_replyingTo?.type == MessageType.image
? context.l10n.chatPhotoPreview
: context.l10n.chatAttachmentPreview))));
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
Container(width: 3, height: 34, color: SepotColors.ember),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
editing
? context.l10n.chatEditingMessage
: context.l10n.chatReply,
style: const TextStyle(
fontSize: 12,
color: SepotColors.ember,
fontWeight: FontWeight.w600)),
Text(preview,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13, color: SepotColors.textSecondary)),
],
),
),
IconButton(
icon: const Icon(Icons.close, size: 18),
color: SepotColors.textMuted,
onPressed: () => setState(() {
_replyingTo = null;
_editingId = null;
if (editing) _messageController.clear();
}),
),
],
),
);
}
/// Akční menu zprávy (long-press): odpovědět / reagovat / upravit /
/// smazat / kopírovat.
void _showMessageActions(Message msg, bool isMine) {
if (msg.isDeleted) return;
showModalBottomSheet<void>(
context: context,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Řádek rychlých reakcí
Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
for (final e in _quickReactions)
GestureDetector(
onTap: () {
Navigator.pop(ctx);
_toggleReaction(msg, e);
},
child: Text(e, style: const TextStyle(fontSize: 28)),
),
],
),
),
const Divider(height: 1, color: SepotColors.border),
ListTile(
leading: const Icon(Icons.reply_rounded),
title: Text(context.l10n.chatActionReply),
onTap: () {
Navigator.pop(ctx);
setState(() {
_replyingTo = msg;
_editingId = null;
});
},
),
if (msg.type == MessageType.text && !msg.isDeleted)
ListTile(
leading: const Icon(Icons.copy_rounded),
title: Text(context.l10n.commonCopy),
onTap: () {
Navigator.pop(ctx);
Clipboard.setData(ClipboardData(text: msg.content ?? ''));
},
),
if (isMine && msg.type == MessageType.text)
ListTile(
leading: const Icon(Icons.edit_rounded),
title: Text(context.l10n.commonEdit),
onTap: () {
Navigator.pop(ctx);
setState(() {
_editingId = msg.id;
_replyingTo = null;
_messageController.text = msg.content ?? '';
});
},
),
ListTile(
leading: const Icon(Icons.delete_outline),
title: Text(context.l10n.chatDeleteForMe),
onTap: () {
Navigator.pop(ctx);
ref
.read(conversationsProvider.notifier)
.deleteLocalMessage(widget.contactId, msg.id);
ref.invalidate(conversationProvider(widget.contactId));
},
),
if (isMine && !msg.isDeleted)
ListTile(
leading: const Icon(Icons.delete_forever,
color: SepotColors.error),
title: Text(context.l10n.chatDeleteForEveryone,
style: const TextStyle(color: SepotColors.error)),
onTap: () {
Navigator.pop(ctx);
ref
.read(conversationsProvider.notifier)
.deleteMessage(widget.contactId, msg.id);
ref.invalidate(conversationProvider(widget.contactId));
},
),
],
),
),
);
}
Future<void> _toggleReaction(Message msg, String emoji) async {
final mine = ref.read(identityProvider).valueOrNull?.fingerprint.display;
final already = mine != null && msg.reactions[mine] == emoji;
await ref
.read(conversationsProvider.notifier)
.reactToMessage(widget.contactId, msg.id, already ? null : emoji);
ref.invalidate(conversationProvider(widget.contactId));
}
/// Per-konverzace TTL mizících zpráv. Platí pro NOVĚ odeslané zprávy
/// (TTL cestuje E2E kanálem, protistrana ho respektuje). Poctivá
/// hranice: je to dohoda klientů, ne DRM — screenshot nezastavíš.
Future<void> _configureDisappearing() async {
final storage = ref.read(storageProvider);
final conv = await storage.loadConversation(widget.contactId) ??
Conversation(contactId: widget.contactId);
if (!mounted) return;
final options = <(int, String)>[
(-1, context.l10n.commonOff),
(30, context.l10n.chatDisappearing30s),
(300, context.l10n.chatDisappearing5m),
(3600, context.l10n.chatDisappearing1h),
(86400, context.l10n.chatDisappearing1d),
(604800, context.l10n.chatDisappearing1w),
];
final current = conv.disappearAfterSeconds ?? -1;
final picked = await showDialog<int>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(context.l10n.chatDisappearingMessages),
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
child: Text(
context.l10n.chatDisappearingDescription,
style: const TextStyle(
fontSize: 13, color: SepotColors.textSecondary),
),
),
for (final (value, label) in options)
SimpleDialogOption(
onPressed: () => Navigator.of(ctx).pop(value),
child: Row(
children: [
Icon(
value == current
? Icons.radio_button_checked
: Icons.radio_button_off,
size: 18,
color: value == current
? SepotColors.ember
: SepotColors.textMuted,
),
const SizedBox(width: 12),
Text(label),
],
),
),
],
),
);
if (picked == null) return;
conv.disappearAfterSeconds = picked == -1 ? null : picked;
await storage.saveConversation(conv);
ref.invalidate(conversationProvider(widget.contactId));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text(picked == -1
? context.l10n.chatDisappearingDisabled
: context.l10n.chatDisappearingEnabled(
options.firstWhere((o) => o.$1 == picked).$2)),
));
}
}
Future<void> _pickAndSendAttachment() async {
final action = await showModalBottomSheet<_AttachAction>(
context: context,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
for (final (icon, label, value) in [
(
Icons.photo_library_outlined,
context.l10n.attachmentGallery,
_AttachAction.gallery
),
(
Icons.photo_camera_outlined,
context.l10n.attachmentTakePhoto,
_AttachAction.camera
),
(
Icons.description_outlined,
context.l10n.attachmentDocument,
_AttachAction.document
),
])
ListTile(
leading: Icon(icon, color: SepotColors.ember),
title: Text(label),
onTap: () => Navigator.pop(context, value),
),
const SizedBox(height: 8),
],
),
),
);
if (action == null) return;
Uint8List? bytes;
var fileName = '';
String? mime;
String? caption;
try {
switch (action) {
case _AttachAction.gallery:
case _AttachAction.camera:
// maxWidth/Height 2048 = strop ImageProcessoru; menší vstup šetří
// RAM při náhledu i finálním renderu úprav.
final picked = await ImagePicker().pickImage(
source: action == _AttachAction.gallery
? ImageSource.gallery
: ImageSource.camera,
maxWidth: 2048,
maxHeight: 2048,
imageQuality: 92,
);
if (picked == null) return;
final pickedBytes = await picked.readAsBytes();
if (!mounted) return;
// Nic neodchází rovnou z kamery — náhled s úpravami a potvrzením.
final preview = await Navigator.of(context).push<PhotoPreviewResult>(
MaterialPageRoute(
fullscreenDialog: true,
builder: (_) => PhotoPreviewScreen(
bytes: pickedBytes,
fileName: picked.name,
mime: picked.mimeType,
),
),
);
if (preview == null || !mounted) return;
bytes = preview.bytes;
fileName = preview.fileName;
mime = preview.mime;
caption = preview.caption;
case _AttachAction.document:
final result = await FilePicker.pickFiles(withData: true);
final file = result?.files.firstOrNull;
if (file == null || file.bytes == null) return;
if (file.size > AppConstants.maxAttachmentBytes) {
if (mounted) {
_showError(context.l10n.attachmentTooLarge(
AppConstants.maxAttachmentBytes ~/ (1024 * 1024)));
}
return;
}
bytes = file.bytes;
fileName = file.name;
}
} catch (e) {
if (mounted) {
_showError(
context.l10n.attachmentPickFailed(describeError(context.l10n, e)));
}
return;
}
if (bytes == null) return;
setState(() => _isSending = true);
try {
await ref.read(conversationsProvider.notifier).sendAttachment(
widget.contactId,
bytes: bytes,
fileName: fileName,
mime: mime,
caption: caption,
);
ref.invalidate(conversationProvider(widget.contactId));
} catch (e) {
if (mounted) {
_showError(
context.l10n.attachmentSendFailed(describeError(context.l10n, e)));
}
} finally {
if (mounted) setState(() => _isSending = false);
}
}
void _showError(String text) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
Future<void> _sendMessage() async {
final text = _messageController.text.trim();
if (text.isEmpty) return;
final editingId = _editingId;
final replyToId = _replyingTo?.id;
setState(() => _isSending = true);
_messageController.clear();
try {
final notifier = ref.read(conversationsProvider.notifier);
if (editingId != null) {
await notifier.editMessage(widget.contactId, editingId, text);
} else {
await notifier.sendMessage(widget.contactId, text,
replyToId: replyToId);
}
if (mounted) {
setState(() {
_replyingTo = null;
_editingId = null;
});
}
ref.invalidate(conversationProvider(widget.contactId));
} catch (e) {
if (mounted) {
_messageController.text = text;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
context.l10n.chatSendFailed(describeError(context.l10n, e)))),
);
}
} finally {
if (mounted) {
setState(() => _isSending = false);
}
}
}
bool _isSameDay(DateTime a, DateTime b) {
return a.year == b.year && a.month == b.month && a.day == b.day;
}
}
/// Šířka bubliny: textové zprávy se scalují podle obsahu (IntrinsicWidth),
/// obrázky/přílohy zůstávají „plné" — AspectRatio potřebuje šířku od rodiče.
class _CompactWidth extends StatelessWidget {
const _CompactWidth({required this.compact, required this.child});
final bool compact;
final Widget child;
@override
Widget build(BuildContext context) =>
compact ? IntrinsicWidth(child: child) : child;
}
/// Bublina se zprávou (text, obrázek, dokument)
class _MessageBubble extends StatelessWidget {
final Message message;
final bool isMine;
final String contactId;
final Message? repliedTo; // zpráva, na kterou tahle odpovídá
final VoidCallback? onLongPress;
final void Function(String emoji)? onToggleReaction;
const _MessageBubble({
required this.message,
required this.isMine,
required this.contactId,
this.repliedTo,
this.onLongPress,
this.onToggleReaction,
});
/// Ztlumený inkoust pro metadata na ember bublině.
static const _inkSoft = Color(0x8C2B120A);
@override
Widget build(BuildContext context) {
final hasAttachment = !message.isDeleted &&
message.attachment != null &&
(message.type == MessageType.image ||
message.type == MessageType.file);
if (message.type != MessageType.text &&
!hasAttachment &&
!message.isDeleted) {
return const SizedBox.shrink();
}
final isImage = hasAttachment && message.type == MessageType.image;
final isAudio = hasAttachment && message.attachment!.isAudio;
final ink = isMine ? SepotColors.emberInk : SepotColors.textPrimary;
return Align(
alignment: isMine ? Alignment.centerRight : Alignment.centerLeft,
child: Column(
crossAxisAlignment:
isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start,
children: [
GestureDetector(
onLongPress: message.isDeleted ? null : onLongPress,
child: Container(
margin: const EdgeInsets.symmetric(vertical: 2),
padding: isImage
? const EdgeInsets.all(4)
: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.75,
),
decoration: BoxDecoration(
color: isMine
? SepotColors.bubbleSent
: SepotColors.bubbleReceived,
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(16),
topRight: const Radius.circular(16),
bottomLeft: isMine
? const Radius.circular(16)
: const Radius.circular(4),
bottomRight: isMine
? const Radius.circular(4)
: const Radius.circular(16),
),
),
child: _CompactWidth(
compact: !isImage && !hasAttachment,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (message.isDeleted)
Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.block, size: 14, color: ink.withValues(alpha: .6)),
const SizedBox(width: 6),
Text(context.l10n.chatMessageDeleted,
style: TextStyle(
fontSize: 14,
fontStyle: FontStyle.italic,
color: ink.withValues(alpha: .7))),
],
)
else ...[
if (repliedTo != null) _buildQuote(context, ink),
if (isImage)
ImageAttachmentContent(
contactId: contactId, message: message)
else if (isAudio)
AudioAttachmentContent(
contactId: contactId,
message: message,
isMine: isMine)
else if (hasAttachment)
DocumentAttachmentContent(
contactId: contactId, message: message),
if (message.content != null && message.content!.isNotEmpty)
Padding(
padding: hasAttachment
? const EdgeInsets.only(top: 6, bottom: 2)
: EdgeInsets.zero,
child: Text(
message.content!,
style: TextStyle(
fontSize: 15, color: ink, height: 1.4),
),
),
],
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (message.editedAt != null && !message.isDeleted)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(context.l10n.chatEdited,
style: TextStyle(
fontSize: 10,
color: isMine
? _inkSoft
: SepotColors.textMuted)),
),
if (message.isDisappearing)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Icon(Icons.timer_outlined,
size: 12,
color: isMine
? _inkSoft
: SepotColors.textMuted),
),
Text(
DateFormat.Hm(
Localizations.localeOf(context).toString())
.format(message.timestamp),
style: TextStyle(
fontSize: 11,
color:
isMine ? _inkSoft : SepotColors.textMuted),
),
if (isMine && !message.isDeleted) ...[
const SizedBox(width: 4),
Icon(
_statusIcon(message.status),
size: 14,
color: message.status == MessageStatus.failed
? SepotColors.error
: message.status == MessageStatus.read
? SepotColors.mist
: _inkSoft,
),
],
],
),
],
),
),
),
),
if (message.reactions.isNotEmpty) _buildReactions(),
],
),
);
}
Widget _buildQuote(BuildContext context, Color ink) {
final r = repliedTo!;
final preview = r.isDeleted
? context.l10n.chatDeletedMessagePreview
: (r.attachment?.isAudio == true
? context.l10n.chatVoiceMessagePreview
: (r.content?.isNotEmpty == true
? r.content!
: (r.type == MessageType.image
? context.l10n.chatPhotoPreview
: context.l10n.chatAttachmentPreview)));
return Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(8),
border: Border(
left: BorderSide(color: ink.withValues(alpha: .5), width: 3)),
),
child: Text(preview,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 13, color: ink.withValues(alpha: .8))),
);
}
/// Seskupené reakce (emoji × počet).
Widget _buildReactions() {
final counts = <String, int>{};
for (final e in message.reactions.values) {
counts[e] = (counts[e] ?? 0) + 1;
}
return Padding(
padding: const EdgeInsets.only(top: 2, bottom: 4),
child: Wrap(
spacing: 4,
children: [
for (final entry in counts.entries)
GestureDetector(
onTap: () => onToggleReaction?.call(entry.key),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: SepotColors.surfaceVariant,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: SepotColors.border),
),
child: Text(
entry.value > 1 ? '${entry.key} ${entry.value}' : entry.key,
style: const TextStyle(fontSize: 13),
),
),
),
],
),
);
}
}
/// Ikona stavu odeslané zprávy (⏱ / ✓ / ✓✓ / ✕). Sdílí ji bublina i
/// seznam konverzací, ať se stav zobrazuje konzistentně.
IconData _statusIcon(MessageStatus status) => switch (status) {
MessageStatus.sending => Icons.schedule,
MessageStatus.sent => Icons.done,
MessageStatus.delivered => Icons.done_all,
MessageStatus.read => Icons.done_all,
MessageStatus.failed => Icons.error_outline,
};