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:
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user