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.
This commit is contained in:
Julius Joska
2026-10-03 22:44:53 +02:00
parent 933a74c900
commit 5246a97db2
56 changed files with 12028 additions and 1668 deletions
@@ -0,0 +1,270 @@
/// Akce nad konverzací, kontaktem a skupinou sdílené obrazovkou chatu
/// a profily — ať se chování (potvrzení, hlášky, pořadí kroků) nerozejde.
library;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.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/models/contact.dart';
import 'package:sepot/models/conversation.dart';
import 'package:sepot/models/group.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/storage_provider.dart';
String contactLabel(BuildContext context, Contact contact) =>
contact.displayName.isEmpty
? context.l10n.commonUnknownContact
: contact.displayName;
void _snack(BuildContext context, String text) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
}
/// Popisky voleb mizících zpráv (sekundy → text); -1 = vypnuto.
List<(int, String)> disappearingOptions(BuildContext context) => [
(-1, context.l10n.commonOff),
(30, context.l10n.chatDisappearing30s),
(300, context.l10n.chatDisappearing5m),
(3600, context.l10n.chatDisappearing1h),
(86400, context.l10n.chatDisappearing1d),
(604800, context.l10n.chatDisappearing1w),
];
/// Text aktuálního nastavení mizících zpráv („1 týden“ / „Vypnuto“).
String disappearingLabel(BuildContext context, int? seconds) {
final options = disappearingOptions(context);
return options.where((o) => o.$1 == (seconds ?? -1)).firstOrNull?.$2 ??
context.l10n.commonOff;
}
/// 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(
BuildContext context, WidgetRef ref, String convId) async {
final storage = ref.read(storageProvider);
final conv = await storage.loadConversation(convId) ??
Conversation(contactId: convId);
if (!context.mounted) return;
final options = disappearingOptions(context);
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(convId));
ref.invalidate(conversationsProvider);
if (context.mounted) {
_snack(
context,
picked == -1
? context.l10n.chatDisappearingDisabled
: context.l10n.chatDisappearingEnabled(
options.firstWhere((o) => o.$1 == picked).$2));
}
}
Future<void> setContactBlocked(
BuildContext context, WidgetRef ref, Contact contact, bool blocked) async {
await ref.read(contactsProvider.notifier).setBlocked(contact.id, blocked);
if (!context.mounted) return;
final jmeno = contactLabel(context, contact);
_snack(
context,
blocked
? context.l10n.chatContactBlocked(jmeno)
: context.l10n.chatContactUnblocked(jmeno));
}
/// Nahlásit a zablokovat: nejdřív blok (okamžitá ochrana), pak formulář.
Future<void> reportAndBlock(
BuildContext context, WidgetRef ref, Contact contact) async {
final jmeno = contactLabel(context, contact);
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 || !context.mounted) return;
await setContactBlocked(context, ref, contact, true);
if (!context.mounted) return;
final q = <String, String>{'contact': contact.id, 'name': contact.displayName};
context.push(Uri(path: '/report', queryParameters: q).toString());
}
Future<void> renameGroup(
BuildContext context, WidgetRef ref, 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),
),
],
),
);
controller.dispose();
if (novy == null || novy.isEmpty || novy == group.name) return;
try {
await ref.read(groupsProvider.notifier).rename(group, novy);
} catch (e) {
if (context.mounted) {
_snack(context,
context.l10n.groupsRenameFailed(describeError(context.l10n, e)));
}
}
}
/// Odebere člena skupiny (jen správce) po potvrzení.
Future<void> removeGroupMember(BuildContext context, WidgetRef ref,
Group group, String fingerprint, String name) async {
final potvrzeno = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(context.l10n.groupsRemoveMemberConfirmTitle(name)),
content: Text(
context.l10n.groupsRemoveMemberConfirmBody,
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.groupsRemoveAction),
),
],
),
);
if (potvrzeno != true || !context.mounted) return;
await _adminAction(context,
() => ref.read(groupsProvider.notifier).removeMember(group, fingerprint));
}
/// Udělí / odebere roli správce (jen správce).
Future<void> setGroupAdmin(BuildContext context, WidgetRef ref, Group group,
String fingerprint, bool admin) =>
_adminAction(
context,
() => ref
.read(groupsProvider.notifier)
.setAdmin(group, fingerprint, admin));
Future<void> _adminAction(
BuildContext context, Future<void> Function() action) async {
try {
await action();
} catch (e) {
if (context.mounted) {
_snack(context,
context.l10n.groupsAdminActionFailed(describeError(context.l10n, e)));
}
}
}
/// Vrací true, když uživatel skupinu opravdu opustil.
Future<bool> leaveGroup(
BuildContext context, WidgetRef ref, 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 false;
try {
await ref.read(groupsProvider.notifier).leave(group);
return true;
} catch (e) {
if (context.mounted) {
_snack(context,
context.l10n.groupsLeaveFailed(describeError(context.l10n, e)));
}
return false;
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,165 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/core/utils/text_fold.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/widgets/avatar.dart';
/// Cíl přeposlání — kontakt nebo skupina, kam jde psát.
class _Target {
const _Target(this.id, this.name,
{this.isGroup = false, this.verified = false, this.isNotes = false});
final String id;
final String name;
final bool isGroup;
final bool isNotes;
final bool verified;
}
/// Výběr chatu a přeposlání zprávy. Zdrojový chat v nabídce není —
/// přeposlat zprávu tam, kde už je, nikdo nechce.
Future<void> forwardMessage(
BuildContext context,
WidgetRef ref,
Message message, {
required String fromId,
}) async {
final contacts = ref.read(contactsProvider).valueOrNull ?? const [];
final groups = ref.read(groupsProvider).valueOrNull ?? const [];
final targets = [
for (final g in groups)
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
for (final c in contacts)
if (!c.blocked && c.id != fromId)
_Target(c.id, c.displayName, verified: c.verified),
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
if (!ConversationsNotifier.isNotes(fromId)) {
targets.insert(
0,
_Target(AppConstants.notesConversationId,
context.l10n.notesTitle,
isNotes: true));
}
final target = await showModalBottomSheet<_Target>(
context: context,
isScrollControlled: true,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _ForwardPicker(targets: targets),
);
if (target == null || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
final l10n = context.l10n;
try {
await ref
.read(conversationsProvider.notifier)
.forwardMessage(message, target.id);
messenger.showSnackBar(
SnackBar(content: Text(l10n.forwardDone(target.name))));
} catch (e) {
messenger.showSnackBar(SnackBar(
content: Text(l10n.forwardFailed(describeError(l10n, e)))));
}
}
class _ForwardPicker extends StatefulWidget {
const _ForwardPicker({required this.targets});
final List<_Target> targets;
@override
State<_ForwardPicker> createState() => _ForwardPickerState();
}
class _ForwardPickerState extends State<_ForwardPicker> {
String _query = '';
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final q = fold(_query);
final shown = q.isEmpty
? widget.targets
: widget.targets
.where((t) => fold(t.name).contains(q))
.toList();
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.7,
maxChildSize: 0.92,
builder: (context, scroll) => Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
child: Text(
l10n.forwardTitle,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w700,
color: SepotColors.textPrimary,
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: TextField(
autofocus: false,
onChanged: (v) => setState(() => _query = v),
decoration: InputDecoration(
hintText: l10n.forwardSearchHint,
prefixIcon: const Icon(Icons.search_rounded),
),
),
),
const SizedBox(height: 8),
Expanded(
child: shown.isEmpty
? Center(
child: Text(
l10n.forwardEmpty,
style: const TextStyle(color: SepotColors.textSecondary),
),
)
: ListView.builder(
controller: scroll,
itemCount: shown.length,
itemBuilder: (context, i) {
final t = shown[i];
return ListTile(
leading: t.isNotes
? const NotesAvatar(size: 40)
: t.isGroup
? SepotGroupAvatar(seed: t.id, size: 40)
: SepotAvatar(
name: t.name,
seed: t.id,
size: 40,
verified: t.verified,
),
title: Text(t.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: SepotColors.textPrimary)),
onTap: () => Navigator.pop(context, t),
);
},
),
),
],
),
);
}
}
@@ -1,4 +1,4 @@
/// Fullscreen prohlížeč obrázkové přílohy.
/// Fotky konverzace přes celou obrazovku — listování, přiblížení, sdílení.
///
/// Plné rozlišení se dešifruje až tady, do PAMĚTI (na disk nikdy).
/// FLAG_SECURE platí globálně, takže screenshot/škrcení obrazovky
@@ -6,89 +6,302 @@
/// (XFile.fromData — plaintext jen v cache sdílení).
library;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/attachment.dart';
import 'package:sepot/models/message.dart';
import 'package:sepot/providers/contacts_provider.dart';
import 'package:sepot/providers/identity_provider.dart';
import 'package:sepot/providers/messaging_provider.dart';
import 'package:sepot/widgets/avatar.dart';
import 'package:sepot/widgets/glass.dart';
import 'package:share_plus/share_plus.dart';
class ImageViewerScreen extends ConsumerStatefulWidget {
const ImageViewerScreen({super.key, required this.message});
/// Fotky zprávy, které jde otevřít: stažené obrázky, ne smazané.
bool isViewableImage(Message m) =>
!m.isDeleted &&
m.type == MessageType.image &&
m.attachment?.state == AttachmentTransferState.ready;
final Message message;
class ImageViewerScreen extends ConsumerStatefulWidget {
const ImageViewerScreen({
super.key,
required this.images,
this.initialIndex = 0,
});
/// Fotky od nejstarší po nejnovější (pořadí jako v chatu).
final List<Message> images;
final int initialIndex;
/// Otevře fotku [message] a nabídne listování ostatními z [all].
static Future<void> open(
BuildContext context, Message message, List<Message> all) {
final images = all.where(isViewableImage).toList();
var index = images.indexWhere((m) => m.id == message.id);
if (index < 0) {
images
..clear()
..add(message);
index = 0;
}
return Navigator.of(context).push(
PageRouteBuilder<void>(
opaque: false,
pageBuilder: (_, _, _) =>
ImageViewerScreen(images: images, initialIndex: index),
transitionsBuilder: (_, anim, _, child) =>
FadeTransition(opacity: anim, child: child),
),
);
}
@override
ConsumerState<ImageViewerScreen> createState() => _ImageViewerScreenState();
}
class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
late final Future<Uint8List?> _decrypted;
late final _pages = PageController(initialPage: widget.initialIndex);
late int _index = widget.initialIndex;
final _decrypted = <String, Future<Uint8List?>>{};
bool _chrome = true;
@override
void initState() {
super.initState();
_decrypted = ref
.read(messagingServiceProvider)
.loadDecryptedAttachment(widget.message);
void dispose() {
_pages.dispose();
super.dispose();
}
Future<Uint8List?> _load(Message m) => _decrypted.putIfAbsent(
m.id,
() => ref.read(messagingServiceProvider).loadDecryptedAttachment(m));
Message get _current => widget.images[_index];
Future<void> _share() async {
final meta = widget.message.attachment!;
final meta = _current.attachment!;
final ext = meta.mime == 'image/png'
? 'png'
: meta.mime == 'image/gif'
? 'gif'
: 'jpg';
final name = context.l10n.attachmentSharedImageFilename(ext);
final bytes = await _decrypted;
final bytes = await _load(_current);
if (bytes == null) return;
await Share.shareXFiles([
XFile.fromData(bytes, name: name, mimeType: meta.mime),
]);
}
String _senderName(Message m) {
final me = ref.read(identityProvider).valueOrNull;
if (m.senderId == me?.id) return context.l10n.groupsYou;
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
.where((c) => c.publicIdentity.id == m.senderId)
.firstOrNull;
return contact?.displayName.isNotEmpty == true
? contact!.displayName
: context.l10n.commonUnknownContact;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.black,
appBar: AppBar(
final m = _current;
final locale = Localizations.localeOf(context).toString();
final when =
'${DateFormat.MMMd(locale).format(m.timestamp)} ${DateFormat.Hm(locale).format(m.timestamp)}';
final total = widget.images.length;
return AnnotatedRegion<SystemUiOverlayStyle>(
value: SystemUiOverlayStyle.light,
child: Scaffold(
backgroundColor: Colors.black,
iconTheme: const IconThemeData(color: Colors.white),
actions: [
IconButton(
icon: const Icon(Icons.share_outlined),
onPressed: _share,
),
],
),
body: FutureBuilder<Uint8List?>(
future: _decrypted,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
);
}
final bytes = snapshot.data;
if (snapshot.hasError || bytes == null) {
return Center(
child: Text(
context.l10n.attachmentDecryptFailed,
style: const TextStyle(color: Colors.white70),
body: Stack(
children: [
Positioned.fill(
child: GestureDetector(
onTap: () => setState(() => _chrome = !_chrome),
child: PageView.builder(
controller: _pages,
itemCount: total,
onPageChanged: (i) => setState(() => _index = i),
itemBuilder: (context, i) => _Page(future: _load(widget.images[i])),
),
),
);
}
return InteractiveViewer(
maxScale: 6,
child: Center(
child: Image.memory(bytes, fit: BoxFit.contain),
),
);
},
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
top: _chrome ? 0 : -140,
left: 0,
right: 0,
child: GlassSurface(
border: false,
padding: EdgeInsets.fromLTRB(
4, MediaQuery.paddingOf(context).top + 6, 8, 10),
child: Row(
children: [
IconButton(
tooltip: MaterialLocalizations.of(context)
.closeButtonTooltip,
icon: const Icon(Icons.close_rounded),
onPressed: () => Navigator.of(context).pop(),
),
SepotAvatar(name: _senderName(m), seed: m.senderId, size: 34),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(_senderName(m),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15, fontWeight: FontWeight.w600)),
Text(
total > 1
? '$when · ${context.l10n.chatSearchCounter(_index + 1, total)}'
: when,
style: const TextStyle(
fontSize: 12,
color: SepotColors.textEmphasis),
),
],
),
),
IconButton(
tooltip: context.l10n.commonShare,
icon: const Icon(Icons.ios_share_rounded),
onPressed: _share,
),
],
),
),
),
AnimatedPositioned(
duration: const Duration(milliseconds: 200),
bottom: _chrome ? 0 : -220,
left: 0,
right: 0,
child: (m.content?.isNotEmpty == true || total > 1)
? GlassSurface(
border: false,
padding: EdgeInsets.fromLTRB(
16, 12, 16, MediaQuery.paddingOf(context).bottom + 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (m.content?.isNotEmpty == true)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(m.content!,
style: const TextStyle(
fontSize: 15, height: 1.4)),
),
if (total > 1)
SizedBox(
height: 48,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: total,
separatorBuilder: (_, _) =>
const SizedBox(width: 6),
itemBuilder: (context, i) => _Thumb(
message: widget.images[i],
selected: i == _index,
onTap: () => _pages.animateToPage(i,
duration:
const Duration(milliseconds: 250),
curve: Curves.easeOutCubic),
),
),
),
],
),
)
: const SizedBox.shrink(),
),
],
),
),
);
}
}
class _Page extends StatelessWidget {
const _Page({required this.future});
final Future<Uint8List?> future;
@override
Widget build(BuildContext context) {
return FutureBuilder<Uint8List?>(
future: future,
builder: (context, snapshot) {
if (snapshot.connectionState != ConnectionState.done) {
return const Center(
child: CircularProgressIndicator(color: SepotColors.ember),
);
}
final bytes = snapshot.data;
if (snapshot.hasError || bytes == null) {
return Center(
child: Text(
context.l10n.attachmentDecryptFailed,
style: const TextStyle(color: Colors.white70),
),
);
}
return InteractiveViewer(
maxScale: 6,
child: Center(child: Image.memory(bytes, fit: BoxFit.contain)),
);
},
);
}
}
/// Náhled v pásu dole — stačí malý náhled, který přišel uvnitř E2E zprávy.
class _Thumb extends StatelessWidget {
const _Thumb({
required this.message,
required this.selected,
required this.onTap,
});
final Message message;
final bool selected;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final thumb = message.attachment?.thumbnail;
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 48,
height: 48,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: selected ? SepotColors.ember : Colors.transparent,
width: 2,
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Opacity(
opacity: selected ? 1 : 0.6,
child: thumb != null
? Image.memory(thumb, fit: BoxFit.cover, gaplessPlayback: true)
: const ColoredBox(color: SepotColors.surfaceVariant),
),
),
),
);
}
@@ -9,6 +9,7 @@ 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';
@@ -17,6 +18,7 @@ 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) {
@@ -65,13 +67,13 @@ class ImageAttachmentContent extends ConsumerWidget {
return GestureDetector(
onTap: switch (meta.state) {
AttachmentTransferState.ready => () {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ImageViewerScreen(message: message),
),
);
},
// 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),
@@ -220,7 +222,13 @@ class AudioAttachmentContent extends ConsumerStatefulWidget {
}
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;
@@ -232,6 +240,7 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
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);
}));
@@ -255,6 +264,21 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
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');
@@ -279,6 +303,8 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
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
@@ -356,7 +382,30 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Icon(Icons.mic, size: 13, color: ink.withValues(alpha: .6)),
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)
@@ -0,0 +1,148 @@
import 'package:flutter/material.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/poll.dart';
/// Vytvoření ankety: otázka, 2–12 možností, jedna nebo víc voleb.
/// Vrací hotovou [Poll], nebo null při zrušení.
Future<Poll?> showCreatePollSheet(BuildContext context) {
return showModalBottomSheet<Poll>(
context: context,
isScrollControlled: true,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _CreatePollSheet(),
);
}
class _CreatePollSheet extends StatefulWidget {
const _CreatePollSheet();
@override
State<_CreatePollSheet> createState() => _CreatePollSheetState();
}
class _CreatePollSheetState extends State<_CreatePollSheet> {
final _question = TextEditingController();
final _options = [TextEditingController(), TextEditingController()];
var _multi = false;
@override
void dispose() {
_question.dispose();
for (final c in _options) {
c.dispose();
}
super.dispose();
}
List<String> get _filled => [
for (final c in _options)
if (c.text.trim().isNotEmpty) c.text.trim(),
];
bool get _valid =>
_question.text.trim().isNotEmpty && _filled.length >= Poll.minOptions;
void _addOption() {
if (_options.length >= Poll.maxOptions) return;
setState(() => _options.add(TextEditingController()));
}
void _removeOption(int i) {
if (_options.length <= Poll.minOptions) return;
setState(() => _options.removeAt(i).dispose());
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.85),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
children: [
Row(
children: [
Expanded(
child: Text(l10n.pollCreateTitle,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700)),
),
FilledButton(
onPressed: _valid
? () => Navigator.of(context).pop(Poll(
question: _question.text.trim(),
options: _filled,
multi: _multi,
))
: null,
child: Text(l10n.chatSend),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _question,
autofocus: true,
maxLength: Poll.maxQuestionLength,
textCapitalization: TextCapitalization.sentences,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: l10n.pollQuestion,
counterText: '',
),
),
const SizedBox(height: 14),
for (var i = 0; i < _options.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: _options[i],
maxLength: Poll.maxOptionLength,
textCapitalization: TextCapitalization.sentences,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: l10n.pollOptionHint(i + 1),
counterText: '',
suffixIcon: _options.length > Poll.minOptions
? IconButton(
tooltip: l10n.pollRemoveOption,
icon: const Icon(Icons.close_rounded, size: 18),
onPressed: () => _removeOption(i),
)
: null,
),
),
),
if (_options.length < Poll.maxOptions)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _addOption,
icon: const Icon(Icons.add_rounded),
label: Text(l10n.pollAddOption),
),
),
const SizedBox(height: 6),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _multi,
onChanged: (v) => setState(() => _multi = v),
activeTrackColor: SepotColors.ember,
title: Text(l10n.pollAllowMultiple),
),
],
),
),
),
);
}
}
@@ -0,0 +1,228 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/poll.dart';
import 'package:sepot/widgets/avatar.dart';
/// Anketa v bublině: otázka, možnosti s pruhem výsledků a kolečky
/// hlasujících. Klepnutí na možnost hlasuje (znovu = zrušit).
class PollContent extends StatelessWidget {
const PollContent({
super.key,
required this.poll,
required this.onEmber,
required this.myId,
required this.nameOf,
required this.onVote,
});
final Poll poll;
/// Kreslí se na korálové bublině (moje anketa).
final bool onEmber;
final String myId;
final String Function(String fingerprint) nameOf;
final void Function(List<int> choice)? onVote;
static const _maxFaces = 3;
void _toggle(int index) {
final mine = poll.votes[myId] ?? const <int>[];
final List<int> next;
if (poll.multi) {
next = mine.contains(index)
? (mine.toList()..remove(index))
: [...mine, index];
} else {
next = mine.contains(index) ? const [] : [index];
}
HapticFeedback.selectionClick();
onVote?.call(next);
}
@override
Widget build(BuildContext context) {
final ink = onEmber ? SepotColors.emberInk : SepotColors.textPrimary;
final soft = onEmber
? SepotColors.emberInk.withValues(alpha: 0.65)
: SepotColors.textSecondary;
final track = onEmber
? SepotColors.emberInk.withValues(alpha: 0.12)
: SepotColors.background.withValues(alpha: 0.55);
final fill = onEmber
? SepotColors.emberInk.withValues(alpha: 0.22)
: SepotColors.ember.withValues(alpha: 0.24);
final mine = poll.votes[myId] ?? const <int>[];
final total = poll.voterCount;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
Icon(Icons.bar_chart_rounded, size: 15, color: soft),
const SizedBox(width: 5),
Text(
'${poll.multi ? context.l10n.pollMulti : context.l10n.pollSingle}'
' · ${context.l10n.pollVoters(total)}',
style: TextStyle(fontSize: 12, color: soft),
),
],
),
const SizedBox(height: 6),
Text(poll.question,
style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w700, color: ink)),
const SizedBox(height: 10),
for (var i = 0; i < poll.options.length; i++) ...[
if (i > 0) const SizedBox(height: 6),
_OptionRow(
label: poll.options[i],
count: poll.countFor(i),
share: total == 0 ? 0 : poll.countFor(i) / total,
chosen: mine.contains(i),
voters: poll.votersFor(i),
nameOf: nameOf,
ink: ink,
track: track,
fill: fill,
onEmber: onEmber,
onTap: onVote == null ? null : () => _toggle(i),
),
],
],
);
}
}
class _OptionRow extends StatelessWidget {
const _OptionRow({
required this.label,
required this.count,
required this.share,
required this.chosen,
required this.voters,
required this.nameOf,
required this.ink,
required this.track,
required this.fill,
required this.onEmber,
required this.onTap,
});
final String label;
final int count;
final double share;
final bool chosen;
final List<String> voters;
final String Function(String) nameOf;
final Color ink;
final Color track;
final Color fill;
final bool onEmber;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final accent = onEmber ? SepotColors.emberInk : SepotColors.ember;
return Semantics(
button: onTap != null,
selected: chosen,
label: '$label, $count',
child: GestureDetector(
onTap: onTap,
child: Container(
constraints: const BoxConstraints(minHeight: 44),
decoration: BoxDecoration(
color: track,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: chosen ? accent : Colors.transparent,
width: 1.5,
),
),
clipBehavior: Clip.antiAlias,
child: Stack(
children: [
Positioned.fill(
child: Align(
alignment: Alignment.centerLeft,
child: AnimatedFractionallySizedBox(
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
widthFactor: share.clamp(0.0, 1.0),
heightFactor: 1,
child: ColoredBox(color: fill),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
child: Row(
children: [
Icon(
chosen
? Icons.check_circle_rounded
: Icons.radio_button_unchecked_rounded,
size: 18,
color: chosen ? accent : ink.withValues(alpha: 0.45),
),
const SizedBox(width: 8),
Expanded(
child: Text(label,
style: TextStyle(fontSize: 14, color: ink)),
),
if (voters.isNotEmpty) ...[
const SizedBox(width: 6),
_Faces(voters: voters, nameOf: nameOf),
],
const SizedBox(width: 8),
Text(
'$count',
style: TextStyle(
fontSize: 14, fontWeight: FontWeight.w700, color: ink),
),
],
),
),
],
),
),
),
);
}
}
/// Překrývající se kolečka prvních hlasujících.
class _Faces extends StatelessWidget {
const _Faces({required this.voters, required this.nameOf});
final List<String> voters;
final String Function(String) nameOf;
@override
Widget build(BuildContext context) {
final shown = voters.take(PollContent._maxFaces).toList();
return SizedBox(
width: 20.0 + (shown.length - 1) * 13,
height: 20,
child: Stack(
children: [
for (var i = 0; i < shown.length; i++)
Positioned(
left: i * 13.0,
child: Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(color: SepotColors.surface, width: 1.5),
),
child: SepotAvatar(
name: nameOf(shown[i]), seed: shown[i], size: 17),
),
),
],
),
);
}
}