From 5246a97db2855d97c19edef725ba6e052fbf1baf Mon Sep 17 00:00:00 2001 From: Julius Joska Date: Sat, 3 Oct 2026 22:44:53 +0200 Subject: [PATCH] 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. --- app/lib/app.dart | 38 + app/lib/core/constants/app_constants.dart | 4 + app/lib/core/l10n/error_messages.dart | 3 + app/lib/core/network/relay_client.dart | 22 +- app/lib/core/services/avatar_image.dart | 38 + app/lib/core/services/messaging_service.dart | 1253 +++++++- app/lib/core/services/typing_tracker.dart | 74 + app/lib/core/theme/app_theme.dart | 46 + app/lib/core/utils/text_fold.dart | 40 + .../features/chat_list/chat_list_screen.dart | 747 ++++- .../features/chat_list/chat_list_tile.dart | 260 +- .../features/chat_list/push_hint_banner.dart | 77 +- .../features/contacts/contacts_screen.dart | 51 +- .../features/conversation/chat_actions.dart | 270 ++ .../conversation/conversation_screen.dart | 2713 +++++++++++------ .../features/conversation/forward_sheet.dart | 165 + .../conversation/image_viewer_screen.dart | 307 +- .../widgets/attachment_content.dart | 65 +- .../widgets/create_poll_sheet.dart | 148 + .../conversation/widgets/poll_content.dart | 228 ++ .../features/groups/create_group_screen.dart | 98 +- .../profile/contact_profile_screen.dart | 234 ++ .../profile/group_profile_screen.dart | 426 +++ app/lib/features/profile/profile_widgets.dart | 316 ++ .../features/settings/settings_screen.dart | 125 +- app/lib/l10n/app_cs.arb | 630 +++- app/lib/l10n/app_de.arb | 135 +- app/lib/l10n/app_en.arb | 135 +- app/lib/l10n/app_it.arb | 135 +- app/lib/l10n/app_ru.arb | 135 +- app/lib/l10n/generated/app_localizations.dart | 770 ++++- .../l10n/generated/app_localizations_cs.dart | 455 ++- .../l10n/generated/app_localizations_de.dart | 455 ++- .../l10n/generated/app_localizations_en.dart | 454 ++- .../l10n/generated/app_localizations_it.dart | 455 ++- .../l10n/generated/app_localizations_ru.dart | 458 ++- app/lib/models/avatar_ref.dart | 79 + app/lib/models/contact.dart | 7 + app/lib/models/conversation.dart | 6 +- app/lib/models/message.dart | 78 +- app/lib/models/poll.dart | 109 + app/lib/providers/conversations_provider.dart | 162 +- app/lib/providers/drafts_provider.dart | 38 + app/lib/providers/groups_provider.dart | 14 + app/lib/providers/messaging_provider.dart | 38 +- app/lib/providers/settings_provider.dart | 24 + app/lib/widgets/avatar.dart | 177 +- app/lib/widgets/glass.dart | 83 + app/lib/widgets/message_status_icon.dart | 50 + app/test/avatar_test.dart | 65 + app/test/groups_admin_flow_test.dart | 412 +++ app/test/mentions_test.dart | 38 + app/test/message_receipts_test.dart | 94 + app/test/poll_test.dart | 71 + app/test/typing_tracker_test.dart | 65 + app/test/widget/group_profile_admin_test.dart | 121 + 56 files changed, 12028 insertions(+), 1668 deletions(-) create mode 100644 app/lib/core/services/avatar_image.dart create mode 100644 app/lib/core/services/typing_tracker.dart create mode 100644 app/lib/core/utils/text_fold.dart create mode 100644 app/lib/features/conversation/chat_actions.dart create mode 100644 app/lib/features/conversation/forward_sheet.dart create mode 100644 app/lib/features/conversation/widgets/create_poll_sheet.dart create mode 100644 app/lib/features/conversation/widgets/poll_content.dart create mode 100644 app/lib/features/profile/contact_profile_screen.dart create mode 100644 app/lib/features/profile/group_profile_screen.dart create mode 100644 app/lib/features/profile/profile_widgets.dart create mode 100644 app/lib/models/avatar_ref.dart create mode 100644 app/lib/models/poll.dart create mode 100644 app/lib/providers/drafts_provider.dart create mode 100644 app/lib/widgets/glass.dart create mode 100644 app/lib/widgets/message_status_icon.dart create mode 100644 app/test/avatar_test.dart create mode 100644 app/test/groups_admin_flow_test.dart create mode 100644 app/test/mentions_test.dart create mode 100644 app/test/message_receipts_test.dart create mode 100644 app/test/poll_test.dart create mode 100644 app/test/typing_tracker_test.dart create mode 100644 app/test/widget/group_profile_admin_test.dart diff --git a/app/lib/app.dart b/app/lib/app.dart index 8e5152f..fdffdf2 100644 --- a/app/lib/app.dart +++ b/app/lib/app.dart @@ -5,7 +5,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/core/calls/call_controller.dart'; import 'package:sepot/features/auth/change_pin_screen.dart'; +import 'package:sepot/features/calls/call_screen.dart'; import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/features/onboarding/onboarding_screen.dart'; import 'package:sepot/features/onboarding/restore_screen.dart'; @@ -17,6 +19,8 @@ import 'package:sepot/features/conversation/conversation_screen.dart'; import 'package:sepot/features/contacts/contact_verify_screen.dart'; import 'package:sepot/features/contacts/contacts_screen.dart'; import 'package:sepot/features/groups/create_group_screen.dart'; +import 'package:sepot/features/profile/contact_profile_screen.dart'; +import 'package:sepot/features/profile/group_profile_screen.dart'; import 'package:sepot/features/settings/settings_screen.dart'; import 'package:sepot/features/safety/report_screen.dart'; import 'package:sepot/features/qr_pairing/qr_share_screen.dart'; @@ -62,6 +66,21 @@ final _router = GoRouter( path: '/conversation/:contactId', builder: (context, state) => ConversationScreen( contactId: state.pathParameters['contactId']!, + startSearch: state.uri.queryParameters['search'] == '1', + ), + ), + GoRoute( + path: '/contact/:contactId', + builder: (context, state) => ContactProfileScreen( + contactId: state.pathParameters['contactId']!, + fromChat: state.uri.queryParameters['from'] == 'chat', + ), + ), + GoRoute( + path: '/group/:groupId', + builder: (context, state) => GroupProfileScreen( + groupId: state.pathParameters['groupId']!, + fromChat: state.uri.queryParameters['from'] == 'chat', ), ), GoRoute( @@ -72,6 +91,11 @@ final _router = GoRouter( path: '/create-group', builder: (context, state) => const CreateGroupScreen(), ), + GoRoute( + path: '/group/:groupId/add', + builder: (context, state) => + CreateGroupScreen(addToGroupId: state.pathParameters['groupId']), + ), GoRoute( path: '/verify/:contactId', builder: (context, state) => ContactVerifyScreen( @@ -101,6 +125,10 @@ final _router = GoRouter( path: '/change-pin', builder: (context, state) => const ChangePinScreen(), ), + GoRoute( + path: '/call', + builder: (context, state) => const CallScreen(), + ), ], ); @@ -116,6 +144,16 @@ class SepotApp extends ConsumerWidget { // null = systémový jazyk; než se volba načte z keystore, jede systém. final locale = ref.watch(localeProvider).valueOrNull; + // Příchozí i odchozí hovor otevře obrazovku hovoru odkudkoli. + ref.listen(callProvider.select((s) => s.phase), (previous, phase) { + final starting = + phase == CallPhase.incoming || phase == CallPhase.outgoing; + final path = _router.routerDelegate.currentConfiguration.uri.path; + if (starting && previous != phase && path != '/call') { + _router.push('/call'); + } + }); + return _AutoLockGate( child: MaterialApp.router( onGenerateTitle: (context) => context.l10n.appTitle, diff --git a/app/lib/core/constants/app_constants.dart b/app/lib/core/constants/app_constants.dart index 6ebb9a6..3af9ebf 100644 --- a/app/lib/core/constants/app_constants.dart +++ b/app/lib/core/constants/app_constants.dart @@ -5,6 +5,10 @@ class AppConstants { static const String appName = 'Šepot'; static const String appAuthor = 'ajtak.it'; + /// Id konverzace „Poznámky pro sebe“. Nekoliduje s fingerprintem (hex) + /// ani s gid skupiny (UUID); podle něj se pozná, že nic nejde do sítě. + static const String notesConversationId = 'notes'; + // Relay server (REST + WS na jednom portu). Přepsatelné při buildu: // flutter build --dart-define=SEPOT_RELAY=http://:8470 static const String relayUrl = String.fromEnvironment( diff --git a/app/lib/core/l10n/error_messages.dart b/app/lib/core/l10n/error_messages.dart index dc837b5..665b45d 100644 --- a/app/lib/core/l10n/error_messages.dart +++ b/app/lib/core/l10n/error_messages.dart @@ -15,7 +15,10 @@ String describeError(AppLocalizations l10n, Object error) => switch (error) { ContactLinkIncompleteError() => l10n.errorContactLinkIncomplete, AttachmentTooLargeError(:final maxMb) => l10n.errorAttachmentTooLarge(maxMb), + AttachmentUnavailableError() => l10n.errorAttachmentUnavailable, GroupNeedsMemberError() => l10n.errorGroupNeedsMember, + NotGroupAdminError() => l10n.errorGroupNotAdmin, + LastGroupAdminError() => l10n.errorGroupLastAdmin, GroupTooLargeError(:final maxMembers) => l10n.errorGroupMaxMembers(maxMembers), MemberNoDeliveryAddressError(:final displayName) => diff --git a/app/lib/core/network/relay_client.dart b/app/lib/core/network/relay_client.dart index 063f39c..7108aa8 100644 --- a/app/lib/core/network/relay_client.dart +++ b/app/lib/core/network/relay_client.dart @@ -63,6 +63,7 @@ class RelayClient { StreamController.broadcast(); final _deliverController = StreamController.broadcast(); final _receiptController = StreamController.broadcast(); + final _turnController = StreamController.broadcast(); var _state = RelayConnectionState.disconnected; @@ -340,6 +341,10 @@ class RelayClient { if (!_receiptController.isClosed) { _receiptController.add(frame.receipt); } + } else if (frame.hasTurnCredentials()) { + if (!_turnController.isClosed) { + _turnController.add(frame.turnCredentials); + } } }, onDone: _onDisconnected, @@ -373,10 +378,11 @@ class RelayClient { } /// Odešle sealed-sender obálku. Vrací Receipt (STORED_OFFLINE / - /// DELIVERED_ONLINE) spárovaný přes msg_uuid. + /// DELIVERED_ONLINE, u EPHEMERAL i DROPPED) spárovaný přes msg_uuid. Future sendEnvelope({ required Uint8List dstAddress, required Uint8List payload, + pb.ContentHint hint = pb.ContentHint.CONTENT_HINT_DEFAULT, Duration timeout = const Duration(seconds: 10), }) async { final msgUuid = randomBytes(16); @@ -387,13 +393,24 @@ class RelayClient { envelope: pb.Envelope( dstAddress: dstAddress, msgUuid: msgUuid, - contentHint: pb.ContentHint.CONTENT_HINT_DEFAULT, + contentHint: hint, payload: payload, ), )); return receiptFuture; } + /// Dočasné údaje k TURN serveru pro hovor. Relay je vydá jen přihlášenému + /// zařízení; bez TURN na relayi (503) doběhne timeout a hovor zkusí jen + /// přímé spojení přes STUN. + Future requestTurn({ + Duration timeout = const Duration(seconds: 5), + }) { + final future = _turnController.stream.first.timeout(timeout); + _send(pb.ClientFrame(turnRequest: pb.TurnRequest())); + return future; + } + /// Potvrdí zpracování obálky — relay ji smaže z fronty. Volat až PO /// uložení zprávy na disk. void ack(List msgUuid) { @@ -437,6 +454,7 @@ class RelayClient { await stop(); _transport.close(); await _stateController.close(); + await _turnController.close(); await _deliverController.close(); await _receiptController.close(); } diff --git a/app/lib/core/services/avatar_image.dart b/app/lib/core/services/avatar_image.dart new file mode 100644 index 0000000..408e00c --- /dev/null +++ b/app/lib/core/services/avatar_image.dart @@ -0,0 +1,38 @@ +import 'dart:isolate'; +import 'dart:math' as math; +import 'dart:typed_data'; + +import 'package:image/image.dart' as img; +import 'package:sepot/core/services/image_processor.dart'; + +/// Strana profilové fotky v pixelech — víc se v appce nikde nezobrazí. +const avatarSide = 256; + +/// Vybranou fotku otočí podle EXIF, ořízne na středový čtverec, zmenší +/// a znovu zakóduje do JPEG bez metadat (EXIF, GPS, model telefonu). +/// Null = obrázek nejde přečíst. +Future prepareAvatarJpeg(Uint8List input) => Isolate.run(() { + final img.Image? decoded; + try { + decoded = img.decodeImage(input); + } on Object { + // Dekodéry na poškozených datech háží místo null. + return null; + } + if (decoded == null) return null; + final upright = img.bakeOrientation(decoded); + final side = math.min(upright.width, upright.height); + final square = img.copyCrop( + upright, + x: (upright.width - side) ~/ 2, + y: (upright.height - side) ~/ 2, + width: side, + height: side, + ); + final small = img.copyResize(square, + width: avatarSide, + height: avatarSide, + interpolation: img.Interpolation.average); + ImageProcessor.stripMetadata(small); + return Uint8List.fromList(img.encodeJpg(small, quality: 85)); + }); diff --git a/app/lib/core/services/messaging_service.dart b/app/lib/core/services/messaging_service.dart index 32ab505..eb567cb 100644 --- a/app/lib/core/services/messaging_service.dart +++ b/app/lib/core/services/messaging_service.dart @@ -22,27 +22,34 @@ library; import 'dart:async'; import 'dart:convert'; +import 'dart:math'; import 'dart:typed_data'; import 'package:flutter/foundation.dart' show visibleForTesting; -import 'package:libsignal/libsignal.dart' show IdentityKeyPair, ProtocolAddress; +import 'package:libsignal/libsignal.dart' + show IdentityKeyPair, PrivateKey, ProtocolAddress; import 'package:mime/mime.dart' show lookupMimeType; import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/crypto/attachment_cipher.dart'; +import 'package:sepot/core/crypto/group_admin.dart'; import 'package:sepot/core/errors.dart'; import 'package:sepot/core/crypto/sepot_crypto.dart'; import 'package:sepot/core/network/relay_client.dart'; import 'package:sepot/core/network/relay_transport.dart'; +import 'package:sepot/core/services/group_admin_policy.dart'; import 'package:sepot/core/services/image_processor.dart'; +import 'package:sepot/core/services/typing_tracker.dart'; import 'package:sepot/core/storage/attachment_store.dart'; import 'package:sepot/core/storage/crypto_db.dart'; import 'package:sepot/core/storage/persistent_stores.dart'; import 'package:sepot/core/storage/secure_storage.dart'; import 'package:sepot/models/attachment.dart'; +import 'package:sepot/models/avatar_ref.dart'; import 'package:sepot/models/contact.dart'; import 'package:sepot/models/group.dart'; import 'package:sepot/models/identity.dart'; import 'package:sepot/models/message.dart'; +import 'package:sepot/models/poll.dart'; import 'package:sepot/proto/sepot.pb.dart' as pb; import 'package:uuid/uuid.dart'; @@ -62,7 +69,26 @@ class MessagingService { /// Přijatá zpráva (po uložení + ACK) — provider přes ni ukazuje /// systémovou notifikaci, když appka není v popředí. - void Function(Contact contact, Message message)? onMessageReceived; + /// `convId` = gid u skupiny, jinak id kontaktu — podle něj se řídí + /// ztlumení (ztlumená skupina ≠ ztlumený chat s autorem). + void Function(Contact contact, Message message, String convId)? + onMessageReceived; + + /// TURN údaje pro hovor (null = relay je nevydal, zkusí se jen STUN). + Future turnCredentials() async { + final relay = _relay; + if (relay == null || relay.state != RelayConnectionState.connected) { + return null; + } + try { + return await relay.requestTurn(); + } on TimeoutException { + return null; + } + } + + /// Příchozí signalizace hovoru (nabídka, odpověď, zavěšení…). + void Function(Contact contact, Map signal)? onCallSignal; CryptoDb? _db; SepotCrypto? _crypto; @@ -98,6 +124,82 @@ class MessagingService { pb.RegisterPush? _lastPush; bool get isStarted => _crypto != null; + + // ── „píše…“ ──────────────────────────────────────────────────────── + // + // Stejně jako potvrzení přečtení: volitelné, výchozí vypnuto, reciproční. + // Jde šifrovaným kanálem jako obálka EPHEMERAL — relay ji doručí jen + // živému spojení, offline příjemce se nebudí a nic se neukládá. + + static const typingSettingKey = 'typing_indicators_enabled'; + + /// Nejvýš jedno „píšu“ za tuhle dobu na konverzaci. + static const _typingThrottle = Duration(seconds: 4); + + /// Po DROPPED (příjemce offline) chvíli nic neposílat: každá zahozená + /// obálka posune ratchet a příjemce by pak mezeru musel přeskakovat. + static const _typingBackoff = Duration(minutes: 1); + + /// Ve větší skupině by každé „píšu“ znamenalo desítky obálek. + static const _typingMaxGroup = 20; + + final typing = TypingTracker(); + final _typingSentAt = {}; + final _typingBackoffUntil = {}; + + Future _typingEnabled() async => + (await storage.getSetting(typingSettingKey)) ?? false; + + /// Dá protistraně (členům skupiny) vědět, že píšu. Volat při psaní — + /// omezení frekvence řeší metoda sama. Best-effort, nikdy nehází. + Future sendTyping(String convId) async { + if (!isStarted || !await _typingEnabled()) return; + final now = DateTime.now(); + final last = _typingSentAt[convId]; + if (last != null && now.difference(last) < _typingThrottle) return; + _typingSentAt[convId] = now; + + final group = await storage.loadGroup(convId); + if (group != null && + (group.left || group.memberIds.length > _typingMaxGroup)) { + return; + } + final contacts = await storage.loadContacts(); + final targets = contacts.where((c) => + !c.blocked && + (group == null + ? c.id == convId + : group.memberIds.contains(c.id) && c.id != _identity?.id)); + for (final c in targets) { + final until = _typingBackoffUntil[c.id]; + if (until != null && until.isAfter(now)) continue; + unawaited(_sendTypingTo(c, group?.id)); + } + } + + Future _sendTypingTo(Contact contact, String? gid) async { + try { + final status = await _sendSealed( + contact, + { + 'v': 1, + 'id': const Uuid().v4(), + 't': 'typ', + 'ts': DateTime.now().millisecondsSinceEpoch, + 'gid': ?gid, + }, + ephemeral: true, + ); + if (status == pb.Receipt_Status.STATUS_DROPPED) { + _typingBackoffUntil[contact.id] = + DateTime.now().add(_typingBackoff); + } else { + _typingBackoffUntil.remove(contact.id); + } + } catch (_) { + // „píše…“ je jen ozdoba — výpadek sítě nesmí nic rozbít ani hlásit. + } + } bool get isTorEnabled => _useTor; RelayConnectionState get connectionState => _relay?.state ?? RelayConnectionState.disconnected; @@ -135,6 +237,9 @@ class MessagingService { regId = int.parse(utf8.decode(regIdRaw)); } + _myAvatar = AvatarRef.fromJson( + await storage.getSetting>(_myAvatarKey)); + // Transport podle uloženého nastavení (odemčený storage — čte se z DB). _useTor = (await storage.getSetting('tor_enabled')) ?? false; _socksPort = (await storage.getSetting('tor_socks_port')) ?? 9050; @@ -163,7 +268,10 @@ class MessagingService { _deliverSub = relay.deliveries.listen(_onDeliver); // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích. _stateSub = relay.stateStream.listen((state) { - if (state == RelayConnectionState.connected) unawaited(_flushOutbox()); + if (state == RelayConnectionState.connected) { + unawaited(_flushOutbox()); + unawaited(_refreshMyAvatarIfOld()); + } }); if (existingAccount != null) { @@ -231,7 +339,10 @@ class MessagingService { _deliverSub = relay.deliveries.listen(_onDeliver); // Po každém (re)connectu dorazit, co zůstalo viset ve frontě odchozích. _stateSub = relay.stateStream.listen((state) { - if (state == RelayConnectionState.connected) unawaited(_flushOutbox()); + if (state == RelayConnectionState.connected) { + unawaited(_flushOutbox()); + unawaited(_refreshMyAvatarIfOld()); + } }); final existingAccount = accountId; @@ -349,8 +460,9 @@ class MessagingService { // ── Odeslání ──────────────────────────────────────────────────────── + /// S [poll] je [text] záložní podoba ankety pro starší appky. Future sendText(Contact contact, String text, - {String? replyToId}) async { + {String? replyToId, Poll? poll}) async { final identity = _identity ?? (throw StateError('Service not started')); final ttl = await _conversationTtl(contact.id); @@ -363,6 +475,7 @@ class MessagingService { replyToId: replyToId, disappearAfterSeconds: ttl, status: MessageStatus.sending, + poll: poll, ); await storage.addMessage(contact.id, message); onUpdate?.call(); @@ -376,6 +489,7 @@ class MessagingService { 'txt': text, 're': ?replyToId, 'ttl': ?ttl, + 'poll': ?poll?.toWire(), 'p': _profile(), }); final sent = message.copyWith( @@ -465,8 +579,8 @@ class MessagingService { required String fileName, String? mime, int? durationMs, + bool upload = true, }) async { - final relay = _relay!; final guessedMime = mime ?? lookupMimeType(fileName, headerBytes: bytes) ?? 'application/octet-stream'; @@ -497,7 +611,9 @@ class MessagingService { } final encrypted = await AttachmentCipher.encrypt(payload); - final blobId = await relay.uploadAttachment(encrypted.blob); + final blobId = upload + ? await _relay!.uploadAttachment(encrypted.blob) + : _localBlobId(); await _attachments.save(blobId, encrypted.blob); return AttachmentMeta( @@ -516,6 +632,32 @@ class MessagingService { ); } + /// Příloha poznámky pro sebe: stejné zpracování i šifrování jako při + /// odeslání, ale blob zůstane jen v telefonu — na relay nemá co dělat. + Future prepareNoteAttachment({ + required Uint8List bytes, + required String fileName, + String? mime, + int? durationMs, + }) => + _prepareAttachment( + bytes: bytes, + fileName: fileName, + mime: mime, + durationMs: durationMs, + upload: false, + ); + + /// Stejný tvar jako id blobu na relayi (32 hex), aby úložiště příloh + /// nemuselo rozlišovat původ. + String _localBlobId() { + final rnd = Random.secure(); + return [ + for (var i = 0; i < 16; i++) + rnd.nextInt(256).toRadixString(16).padLeft(2, '0'), + ].join(); + } + /// TTL mizících zpráv nastavené na konverzaci (null = vypnuto). Future _conversationTtl(String contactId) async { final conv = await storage.loadConversation(contactId); @@ -532,9 +674,129 @@ class MessagingService { // pole vynecháme (protistrana ho doplní z pozdějšího profilu), NIKDY // `da!` (null-check pád = „Null check operator used on a null value"). 'da': ?(da == null ? null : base64Encode(da)), + // Profil BEZ 'av' = fotku jsem odebral (protistrana ji smaže). + 'av': ?_myAvatar?.toWire(), }; } + // ── Profilová fotka ───────────────────────────────────────────────── + + static const _myAvatarKey = 'my_avatar'; + + /// Blob na relayi žije 30 dní — s týdenní rezervou se nahraje znovu, + /// aby ho stáhli i lidé, kteří mi napíšou poprvé až za měsíc. + static const _avatarRefreshAfter = Duration(days: 23); + + AvatarRef? _myAvatar; + final _avatarCache = {}; + + AvatarRef? get myAvatar => _myAvatar; + + /// Nastaví moji fotku ([jpeg] už zmenšený a bez metadat). Protistrany + /// se o ní dozví s další zprávou (odkaz jede v profilu). + Future setMyAvatar(Uint8List jpeg) async { + final relay = _relay ?? (throw StateError('Service not started')); + final encrypted = await AttachmentCipher.encrypt(jpeg); + final blobId = await relay.uploadAttachment(encrypted.blob); + await _attachments.save(blobId, encrypted.blob); + final old = _myAvatar; + _myAvatar = AvatarRef( + blobId: blobId, + key: encrypted.key, + nonce: encrypted.nonce, + digest: encrypted.digest, + uploadedAt: DateTime.now(), + ); + await storage.setSetting(_myAvatarKey, _myAvatar!.toJson()); + _avatarCache[blobId] = jpeg; + if (old != null) await _forgetAvatar(old); + onUpdate?.call(); + } + + Future removeMyAvatar() async { + final old = _myAvatar; + if (old == null) return; + _myAvatar = null; + await storage.setSetting(_myAvatarKey, null); + await _forgetAvatar(old); + onUpdate?.call(); + } + + Future _forgetAvatar(AvatarRef ref) async { + _avatarCache.remove(ref.blobId); + await _attachments.delete(ref.blobId); + } + + /// Znovu nahraje TENTÝŽ šifrovaný blob pod novým id (klíč se nemění), + /// než na relayi vyprší. Best-effort — zkusí se při dalším připojení. + Future _refreshMyAvatarIfOld() async { + final ref = _myAvatar; + final relay = _relay; + if (ref == null || relay == null) return; + final uploaded = ref.uploadedAt; + if (uploaded != null && + DateTime.now().difference(uploaded) < _avatarRefreshAfter) { + return; + } + try { + final blob = await _attachments.load(ref.blobId); + if (blob == null) return; + final blobId = await relay.uploadAttachment(blob); + await _attachments.save(blobId, blob); + _myAvatar = AvatarRef( + blobId: blobId, + key: ref.key, + nonce: ref.nonce, + digest: ref.digest, + uploadedAt: DateTime.now(), + ); + await storage.setSetting(_myAvatarKey, _myAvatar!.toJson()); + await _attachments.delete(ref.blobId); + } catch (_) { + // Bez sítě / relay ještě nezná zařízení — zkusí se příště. + } + } + + /// Stáhne fotku kontaktu, ověří velikost a digest a uloží ji šifrovanou. + Future _downloadAvatar(AvatarRef ref) async { + final relay = _relay; + if (relay == null || await _attachments.exists(ref.blobId)) return; + try { + final blob = await relay.downloadAttachment(ref.blobId); + if (blob.length > AvatarRef.maxBlobBytes) return; + final digest = await AttachmentCipher.sha256(blob); + if (!_listEquals(digest, ref.digest)) return; + await _attachments.save(ref.blobId, blob); + onUpdate?.call(); + } catch (_) { + // Blob vypršel nebo síť selhala — ukáží se iniciály, další profil + // (s obnoveným blobem) to zkusí znovu. + } + } + + /// Dešifrovaná fotka pro UI (null = není stažená / vadná → iniciály). + Future loadAvatar(AvatarRef ref) async { + final cached = _avatarCache[ref.blobId]; + if (cached != null) return cached; + final blob = await _attachments.load(ref.blobId); + if (blob == null) { + unawaited(_downloadAvatar(ref)); + return null; + } + try { + final bytes = await AttachmentCipher.decrypt( + blob: blob, + key: ref.key, + nonce: ref.nonce, + expectedDigest: ref.digest, + ); + _avatarCache[ref.blobId] = bytes; + return bytes; + } on Object { + return null; + } + } + // ── Reakce / editace / mazání (control zprávy) ────────────────────── // // Nevytvářejí novou bublinu — mění existující zprávu podle jejího id @@ -572,14 +834,15 @@ class MessagingService { } /// Pošle odesílateli potvrzení přečtení pro dané zprávy — jen když mám - /// read receipts ZAPNUTÉ (default vypnuto). Best-effort. - Future sendReadReceipts( - Contact contact, List messageIds) async { + /// read receipts ZAPNUTÉ (default vypnuto). Best-effort. Ve skupině + /// (`gid`) jde jen autorovi zpráv, ne všem členům. + Future sendReadReceipts(Contact contact, List messageIds, + {String? gid}) async { if (messageIds.isEmpty) return; final enabled = (await storage.getSetting('read_receipts_enabled')) ?? false; if (!enabled) return; - await _sendControl(contact, {'t': 'rr', 'ids': messageIds}); + await _sendControl(contact, {'t': 'rr', 'ids': messageIds, 'gid': ?gid}); } /// Smaže zprávu pro všechny (tombstone u obou stran). @@ -596,6 +859,9 @@ class MessagingService { await _sendContent(contact, { 'v': 1, 'id': const Uuid().v4(), + // Čas vzniku, ne doručení — potvrzení přečtení z offline telefonu + // má ukázat, kdy si to člověk přečetl, ne kdy se připojil. + 'ts': DateTime.now().millisecondsSinceEpoch, ...extra, 'p': _profile(), }); @@ -605,6 +871,29 @@ class MessagingService { } } + /// Signalizace hovoru (`t:'call'`). Na rozdíl od ostatních + /// řídicích zpráv chybu nepolyká — hovor, který neodešel, musí UI ukončit. + /// [ring] = nabídka: relay offline příjemce budí VoIP pushem. + Future sendCallSignal( + Contact contact, + Map signal, { + bool ring = false, + }) { + final content = { + 'v': 1, + 'id': const Uuid().v4(), + 'ts': DateTime.now().millisecondsSinceEpoch, + 't': 'call', + ...signal, + }; + final tap = debugSendTap; + if (tap != null) { + tap(contact.id, content); + return Future.value(pb.Receipt_Status.STATUS_DELIVERED_ONLINE); + } + return _sendSealed(contact, content, ring: ring); + } + /// Najde zprávu podle id v konverzaci a aplikuje transformaci. Future _mutateLocalMessage(String contactId, String messageId, Message Function(Message) transform) async { @@ -617,13 +906,17 @@ class MessagingService { } // ── Skupiny (párový fanout) ───────────────────────────────────────── + + static const _groupControlTypes = {'ginv', 'gleave', 'gmeta', 'gop', 'gsync'}; // // Skupina nemá vlastní krypto vrstvu — každá zpráva jede existujícím // párovým Double Ratchet kanálem každému členovi zvlášť (`gid` v E2E // obsahu ji směruje do skupinové konverzace). Autor = sealed-sender // fingerprint, nikdy self-declared pole. - /// Založí skupinu a rozešle pozvánku (`ginv` s úplným rosterem). + /// Založí skupinu a rozešle pozvánku (`ginv` s úplným rosterem). Pozvánka + /// nese i podepsaný stav epochy 0, takže v2 členové znají správce hned; + /// v1 členové použijí samotný `ginv`. /// Všichni členové musí mít routing (aid+da) — bez něj jim fanout /// nemá kam doručit. Future createGroup(String name, List members) async { @@ -651,12 +944,24 @@ class MessagingService { await storage.ensureConversation(group.id); onUpdate?.call(); - await _fanoutInvite(group); + final genesis = GroupAdminOp( + gid: group.id, + epoch: 0, + action: GroupAdminAction.add, + actor: _myFingerprint, + members: group.memberIds, + admins: group.admins, + name: group.name, + prev: Uint8List(0), + ); + await _fanoutInvite(group, op: _signOp(genesis)); return group; } /// Přidá členy: rozšíří roster a rozešle nový úplný stav VŠEM členům - /// (noví z něj poprvé uvidí skupinu). + /// (noví z něj poprvé uvidí skupinu). Správce přidává podepsanou + /// operací; ne-správce jen v kompatibilním režimu starým `ginv`, jak to + /// v1 klienti ve skupině stejně umějí. Future addGroupMembers(Group group, List added) async { final ids = {...group.memberIds}; for (final m in added) { @@ -669,40 +974,111 @@ class MessagingService { if (ids.length > Group.maxMembers) { throw GroupTooLargeError(Group.maxMembers); } + if (group.isAdmin(_myFingerprint)) { + await _publishAdminOp( + group, + buildGroupOp( + group: group, + me: _myFingerprint, + action: GroupAdminAction.add, + members: ids.toList(), + ), + ); + return; + } + if (group.isEnforced(_myFingerprint)) throw const NotGroupAdminError(); group.memberIds = ids.toList(); await storage.saveGroup(group); onUpdate?.call(); await _fanoutInvite(group); } - /// Přejmenuje skupinu (lokálně hned, členům `gmeta` best-effort). + /// Odebere člena (jen správce). Odebraný dostane výsledný stav taky — + /// v2 klient se z něj dozví, že ve skupině už není. + Future removeGroupMember(Group group, String fingerprint) async { + if (fingerprint == _myFingerprint) return leaveGroup(group); + await _publishAdminOp( + group, + buildGroupOp( + group: group, + me: _myFingerprint, + action: GroupAdminAction.remove, + members: group.memberIds.where((f) => f != fingerprint).toList(), + admins: group.admins.where((f) => f != fingerprint).toList(), + ), + ); + } + + /// Udělí nebo odebere roli správce (jen správce). Sebe smí správce + /// degradovat, jen když zůstane jiný správce. + Future setGroupAdmin( + Group group, String fingerprint, bool admin) async { + if (!group.isMember(fingerprint)) return; + if (group.isAdmin(fingerprint) == admin) return; + await _publishAdminOp( + group, + buildGroupOp( + group: group, + me: _myFingerprint, + action: admin ? GroupAdminAction.promote : GroupAdminAction.demote, + admins: admin + ? [...group.admins, fingerprint] + : group.admins.where((f) => f != fingerprint).toList(), + ), + ); + } + + /// Přejmenuje skupinu (jen správce; lokálně hned, členům best-effort). Future renameGroup(Group group, String newName) async { - group.name = newName.trim(); - await storage.saveGroup(group); - onUpdate?.call(); - await _fanoutToGroup(group, { - 'v': 1, - 't': 'gmeta', - 'id': const Uuid().v4(), - 'gid': group.id, - 'gname': group.name, - 'p': _profile(), - }); + await _publishAdminOp( + group, + buildGroupOp( + group: group, + me: _myFingerprint, + action: GroupAdminAction.rename, + name: newName.trim(), + ), + ); } /// Opustí skupinu: oznámí členům `gleave`, lokálně označí `left` /// (historie zůstává, vstup i příjem jsou mrtvé). + /// + /// Správce navíc odchod podepíše jako odebrání sebe sama, aby v2 členové + /// zůstali na stejném řetězu. Kdyby odcházel poslední správce, předá + /// roli nejmenšímu fingerprintu — tomu, koho by si bez podpisu stejně + /// vybrali všichni přes Group.normalizeAdmins, takže `gleave` i podepsaná + /// operace vedou ke stejnému výsledku. Future leaveGroup(Group group) async { - await _fanoutToGroup(group, { - 'v': 1, - 't': 'gleave', - 'id': const Uuid().v4(), - 'gid': group.id, - 'p': _profile(), - }); + final others = group.memberIds.where((f) => f != _myFingerprint).toList(); + if (group.isAdmin(_myFingerprint) && others.isNotEmpty) { + var admins = group.admins.where((f) => f != _myFingerprint).toList(); + if (admins.isEmpty) admins = [([...others]..sort()).first]; + await _publishAdminOp( + group, + buildGroupOp( + group: group, + me: _myFingerprint, + action: GroupAdminAction.remove, + members: others, + admins: admins, + ), + ); + } + await _fanoutToGroup( + group, + { + 'v': 1, + 't': 'gleave', + 'id': const Uuid().v4(), + 'gid': group.id, + 'p': _profile(), + }, + recipients: others, + ); group.left = true; - group.memberIds = - group.memberIds.where((f) => f != _myFingerprint).toList(); + group.memberIds = others; + group.normalizeAdmins(); await storage.saveGroup(group); onUpdate?.call(); } @@ -711,7 +1087,7 @@ class MessagingService { /// člen přijal → `sent`; všichni selhali → `failed`; `delivered` až po /// první doručence. Future sendGroupText(Group group, String text, - {String? replyToId}) async { + {String? replyToId, Poll? poll, List mentions = const []}) async { final identity = _identity ?? (throw StateError('Service not started')); final ttl = await _conversationTtl(group.id); @@ -722,6 +1098,8 @@ class MessagingService { replyToId: replyToId, disappearAfterSeconds: ttl, status: MessageStatus.sending, + poll: poll, + mentions: mentions, ); await storage.addMessage(group.id, message); onUpdate?.call(); @@ -736,6 +1114,8 @@ class MessagingService { 'txt': text, 're': ?replyToId, 'ttl': ?ttl, + 'poll': ?poll?.toWire(), + if (mentions.isNotEmpty) 'mn': mentions, 'p': _profile(), }); final sent = message.copyWith( @@ -808,6 +1188,32 @@ class MessagingService { } } + /// Můj hlas v anketě (prázdná volba = hlas zrušen). Lokálně hned, + /// ostatním jako řídicí zpráva — ve skupině všem členům, ať počty sedí + /// každému. + Future votePoll(String convId, String pollId, List choice, + {Group? group, Contact? contact}) async { + await _mutateLocalMessage(convId, pollId, (m) { + final poll = m.poll; + return poll == null + ? m + : m.copyWith(poll: poll.withVote(_myFingerprint, choice)); + }); + final control = {'t': 'vote', 'ref': pollId, 'o': choice}; + if (group != null) { + await _fanoutToGroup(group, { + 'v': 1, + 'id': const Uuid().v4(), + 'ts': DateTime.now().millisecondsSinceEpoch, + 'gid': group.id, + ...control, + 'p': _profile(), + }); + } else if (contact != null) { + await _sendControl(contact, control); + } + } + /// Reakce ve skupině (emoji == null = odebrat). Future sendGroupReaction( Group group, String targetId, String? emoji) async { @@ -861,8 +1267,9 @@ class MessagingService { }); } - /// Rozešle `ginv` s úplným rosterem všem členům kromě mě. - Future _fanoutInvite(Group group) async { + /// Rozešle `ginv` s úplným rosterem všem členům kromě mě. S [op] nese + /// pozvánka i podepsaný stav pro v2 členy (v1 klient pole `gop` ignoruje). + Future _fanoutInvite(Group group, {GroupOpMessage? op}) async { final roster = await _buildRoster(group); await _fanoutToGroup(group, { 'v': 1, @@ -871,10 +1278,78 @@ class MessagingService { 'gid': group.id, 'gname': group.name, 'roster': roster, + 'gop': ?op?.toWire(), 'p': _profile(), }); } + /// Podepíše operaci mým identity klíčem (XEdDSA, stejný klíč jako AUTH). + GroupOpMessage _signOp(GroupAdminOp op, + {List>? roster}) { + final key = PrivateKey.deserialize( + bytes: _identity!.identityPrivateKey.toList()); + return GroupOpMessage( + op: op, + sig: op.signWith( + (m) => Uint8List.fromList(key.sign(message: m).toList())), + roster: roster, + ); + } + + /// Aplikuje mou správcovskou operaci lokálně a rozešle ji starým i novým + /// členům (odebraný se tak dozví, že už ve skupině není). + /// + /// Dokud je ve skupině člen bez v2, jede operace uvnitř legacy `ginv` / + /// `gmeta` — v1 klient z nich převezme výsledný roster a jméno, v2 klient + /// ověří vnořený `gop` a stín ignoruje. Jedna obálka místo dvou: nemůže + /// se stát, že stín dorazí bez operace nebo v opačném pořadí. + Future _publishAdminOp(Group group, GroupAdminOp op) async { + final before = {...group.memberIds}; + applyGroupOp(group, op, _myFingerprint); + await storage.saveGroup(group); + onUpdate?.call(); + + final recipients = {...before, ...group.memberIds}..remove(_myFingerprint); + final enforced = group.isEnforced(_myFingerprint); + final isRename = op.action == GroupAdminAction.rename; + // Roster potřebuje nový člen (add) a v1 stín `ginv`; `gmeta` ho nenese. + final roster = (enforced ? op.action == GroupAdminAction.add : !isRename) + ? await _buildRoster(group) + : null; + final Map content; + if (enforced) { + content = { + 'v': 1, + 't': 'gop', + 'id': const Uuid().v4(), + ..._signOp(op, roster: roster).toWire(), + 'p': _profile(), + }; + } else if (isRename) { + content = { + 'v': 1, + 't': 'gmeta', + 'id': const Uuid().v4(), + 'gid': group.id, + 'gname': group.name, + 'gop': _signOp(op).toWire(), + 'p': _profile(), + }; + } else { + content = { + 'v': 1, + 't': 'ginv', + 'id': const Uuid().v4(), + 'gid': group.id, + 'gname': group.name, + 'roster': roster, + 'gop': _signOp(op).toWire(), + 'p': _profile(), + }; + } + await _fanoutToGroup(group, content, recipients: recipients); + } + /// Testovací vstup do skupinové příjmové cesty bez relaye a krypta: /// dosadí identitu příjemce a zavolá handlery, jejichž bezpečnostní /// pravidla (anti-spoof rosteru, membership guard) chceme ověřit @@ -887,13 +1362,26 @@ class MessagingService { String senderFp, Map content) => _handleGroupInvite(senderFp, content); + /// Libovolná správa skupiny (`ginv`/`gmeta`/`gleave`/`gop`/`gsync`). + @visibleForTesting + Future debugHandleGroupControl( + String senderFp, Map content) => + _handleGroupControl(senderFp, content['t'] as String?, content); + @visibleForTesting Future debugGroupForIncoming(String gid, String senderFp) => _groupForIncoming(gid, senderFp); - /// Roster = [{f, n, ik, aid, da}] pro všechny členy včetně mě. + /// Když je nastavený, odchozí obsah místo relaye dostane test + /// (fingerprint příjemce + content JSON) a odeslání se bere jako úspěšné. + @visibleForTesting + void Function(String toFingerprint, Map content)? + debugSendTap; + + /// Roster = [{f, n, ik, aid, da, v}] pro všechny členy včetně mě. /// Člen bez routingu se vynechá (příjemci mu psát nemohou, ale - /// zprávy od něj membership guardem projdou). + /// zprávy od něj membership guardem projdou). `v: 2` = člen inzeroval + /// podporu správců; v1 klient pole ignoruje. Future>> _buildRoster(Group group) async { final contacts = await storage.loadContacts(); final byFp = {for (final c in contacts) c.id: c}; @@ -904,8 +1392,12 @@ class MessagingService { 'f': _myFingerprint, 'n': me.displayName, 'ik': base64Encode(me.identityKey), - 'aid': me.accountId, - 'da': base64Encode(me.deliveryAddress!), + // Před doběhnutím registrace routing nemám — vynechat, ne `!`. + 'aid': ?me.accountId, + 'da': ?(me.deliveryAddress == null + ? null + : base64Encode(me.deliveryAddress!)), + 'v': groupProtocolVersion, }); for (final fp in group.memberIds) { @@ -921,25 +1413,32 @@ class MessagingService { 'ik': base64Encode(p.identityKey), 'aid': p.accountId, 'da': base64Encode(p.deliveryAddress!), + if (group.v2Members.contains(fp)) 'v': groupProtocolVersion, }); } return roster; } - /// Fanout obsahu všem členům kromě mě. Vrací true, když aspoň jedno - /// odeslání prošlo (per-member retry v1 není — stejná hranice jako - /// u 1:1 control zpráv). - Future _fanoutToGroup( - Group group, Map content) async { + /// Verze skupinového protokolu, kterou inzeruju (`gv` v obsahu, `v` + /// v rosteru). 2 = rozumím správcům a `gop`. + static const groupProtocolVersion = 2; + + /// Fanout obsahu členům kromě mě (nebo jen [recipients]). Vrací true, + /// když aspoň jedno odeslání prošlo (per-member retry v1 není — stejná + /// hranice jako u 1:1 control zpráv). Každý skupinový obsah nese `gv`, + /// aby ostatní poznali, kdy už ve skupině žádný v1 klient není. + Future _fanoutToGroup(Group group, Map content, + {Iterable? recipients}) async { final contacts = await storage.loadContacts(); final byFp = {for (final c in contacts) c.id: c}; + final payload = {...content, 'gv': groupProtocolVersion}; var anyOk = false; - for (final fp in group.memberIds) { + for (final fp in recipients ?? group.memberIds) { if (fp == _myFingerprint) continue; final c = byFp[fp]; if (c == null || c.blocked) continue; try { - await _sendContent(c, content); + await _sendContent(c, payload); anyOk = true; } catch (_) { // člen offline/bez routingu — v1 bez per-member retry fronty @@ -957,27 +1456,306 @@ class MessagingService { return group; } - /// Zpracuje `ginv` (pozvánka / úplná aktualizace rosteru). - /// Bezpečnostní pravidla: fingerprint každého záznamu - /// musí sedět s jeho identity klíčem, odesílatel i já musíme být - /// v rosteru, identity klíč existujícího kontaktu se NIKDY nepřepisuje. - Future _handleGroupInvite( - String senderFp, Map content) async { - final gid = content['gid'] as String?; - final gname = (content['gname'] as String?)?.trim(); - final rosterRaw = content['roster'] as List?; - if (gid == null || gid.isEmpty || gid.length > 64) return; - if (gname == null || gname.isEmpty || rosterRaw == null) return; - if (rosterRaw.length > Group.maxMembers) return; + /// Zapamatuje si, že člen inzeruje v2 (`gv` je v obsahu každé skupinové + /// zprávy v2 klienta). Vrací true, když se stav skupiny změnil. + bool _noteCapability(Group group, String fp, Map content) { + final gv = content['gv']; + if (gv is! int || gv < groupProtocolVersion) return false; + if (!group.isMember(fp)) return false; + return group.v2Members.add(fp); + } - // Ověř roster: f musí být fingerprint ik (anti-spoof). + // ── Správa skupin: příjem ─────────────────────────────────────────── + // + // Pravda o členství/rolích/jménu je řetěz podepsaných operací `gop`. + // Legacy `ginv`/`gmeta` od ne-správce se bere jen + // v kompatibilním režimu (ve skupině je aspoň jeden v1 člen) — v1 klient + // jinou cestu nemá a odmítnutí by skupinu rozdělilo. + + /// Jak často nejvýš žádat správce o stav jedné skupiny. Chrání před + /// smyčkou i zahlcením, když správce nemá co poslat nebo je offline. + static const _groupSyncRequestEvery = Duration(minutes: 10); + + /// Jak často nejvýš odpovídat stejnému členovi na žádost o stav. + static const _groupSyncAnswerEvery = Duration(minutes: 1); + + final _groupSyncRequestedAt = {}; + final _groupSyncAnsweredAt = {}; + + Future _handleGroupControl( + String senderFp, String? type, Map content) async { + final gid = content['gid']; + if (gid is! String || gid.isEmpty || gid.length > 64) return; + if (type == 'gsync') return _answerGroupSync(senderFp, gid, content); + + final opRaw = type == 'gop' ? content : content['gop']; + if (opRaw != null) { + final msg = GroupOpMessage.fromWire(opRaw); + final consumed = msg != null && + msg.op.gid == gid && + await _handleGroupOp(senderFp, msg, content['roster']); + // Odmítnutý vnořený `gop` nechá promluvit legacy stín: odesílatel je + // pak posuzován jako kterýkoli v1 člen (kompatibilní režim). + if (consumed || type == 'gop') return; + } + + switch (type) { + case 'ginv': + await _handleGroupInvite(senderFp, content); + case 'gmeta': + await _handleLegacyMeta(senderFp, gid, content); + case 'gleave': + await _handleLeave(senderFp, gid); + } + } + + /// Zpracuje podepsanou operaci. Vrací true, když je tím zpráva vyřízená + /// (aplikovaná, zastaralá, podržená); false = operace neplatí a legacy + /// stín se má posoudit podle pravidel kompatibilního režimu. + Future _handleGroupOp( + String senderFp, GroupOpMessage msg, Object? outerRoster) async { + final local = await storage.loadGroup(msg.op.gid); + if (local == null) return _joinFromOp(senderFp, msg, outerRoster); + // Opuštěná skupina se znovu neotevírá (stejně jako v1 pozvánka). + if (local.left) return true; + + final keys = await _identityKeysByFp(); + final verdict = checkGroupOp( + local: local, + msg: msg, + senderFp: senderFp, + me: _myFingerprint, + actorIdentityKey: keys[msg.op.actor], + ); + switch (verdict) { + case GopVerdict.apply: + await _applyVerifiedOp(local, msg, senderFp, outerRoster); + return true; + case GopVerdict.stale: + return true; + case GopVerdict.gap: + await _holdAndResync(local, senderFp, msg, outerRoster); + return true; + case GopVerdict.fork: + // Operace navazuje na stav, který nemám — nejde ji podržet (nikdy + // nenaváže). Stav od správce s vyšší epochou ji stejně obsahuje. + local.needsResync = true; + await storage.saveGroup(local); + onUpdate?.call(); + await _requestGroupSync(local, senderFp); + return true; + case GopVerdict.notAdmin when msg.op.epoch > local.epoch + 1: + // Z budoucnosti: podepisující mohl správcem (nebo i členem) být + // jmenován v epoše, která mi chybí. Neaplikovat, ale ani nezahodit. + await _holdAndResync(local, senderFp, msg, outerRoster); + return true; + case GopVerdict.notAdmin: + case GopVerdict.badSignature: + case GopVerdict.spoof: + case GopVerdict.invalid: + local.rejectedOpAt = DateTime.now(); + await storage.saveGroup(local); + onUpdate?.call(); + return false; + } + } + + Future> _identityKeysByFp() async => { + for (final c in await storage.loadContacts()) + c.id: c.publicIdentity.identityKey, + }; + + Future _applyVerifiedOp(Group group, GroupOpMessage msg, + String senderFp, Object? outerRoster) async { + applyGroupOp(group, msg.op, _myFingerprint); + group.v2Members.add(senderFp); + await _absorbOpRoster(group, msg, outerRoster); + await _drainPendingOps(group); + group.needsResync = group.pendingOps.isNotEmpty; + await storage.saveGroup(group); + onUpdate?.call(); + } + + /// Z rosteru ověřené operace založí kontakty nových členů (stejná TOFU + /// pravidla jako `ginv`) a převezme jejich inzerovanou v2 — tu tu ručí + /// podepsaný správce. + Future _absorbOpRoster( + Group group, GroupOpMessage msg, Object? outerRoster) async { + final roster = _validRosterEntries(msg.roster ?? outerRoster); + roster.removeWhere((f, _) => !group.isMember(f)); + await _ingestRoster(roster.values); + for (final e in roster.values) { + if (e['v'] == groupProtocolVersion) group.v2Members.add(e['f'] as String); + } + } + + Future _holdAndResync(Group group, String senderFp, + GroupOpMessage msg, Object? outerRoster) async { + final roster = msg.roster ?? + (outerRoster is List + ? [ + for (final e in outerRoster) + if (e is Map) e.cast(), + ] + : null); + holdGroupOp( + group, + PendingGroupOp( + senderFp: senderFp, + wire: GroupOpMessage(op: msg.op, sig: msg.sig, roster: roster) + .toWire(), + ), + ); + group.needsResync = true; + await storage.saveGroup(group); + onUpdate?.call(); + await _requestGroupSync(group, senderFp); + } + + /// Zkusí podržené operace znovu po posunu stavu. Každá se ověřuje celá + /// znovu (správci se mezitím mohli změnit). + Future _drainPendingOps(Group group) async { + var progressed = true; + while (progressed && group.pendingOps.isNotEmpty) { + progressed = false; + final keys = await _identityKeysByFp(); + for (final p in [...group.pendingOps]) { + final msg = GroupOpMessage.fromWire(p.wire); + final verdict = msg == null + ? GopVerdict.invalid + : checkGroupOp( + local: group, + msg: msg, + senderFp: p.senderFp, + me: _myFingerprint, + actorIdentityKey: keys[msg.op.actor], + ); + final stillFuture = verdict == GopVerdict.gap || + (verdict == GopVerdict.notAdmin && + msg!.op.epoch > group.epoch + 1); + if (stillFuture) continue; + group.pendingOps = group.pendingOps.where((x) => x != p).toList(); + if (verdict == GopVerdict.apply) { + applyGroupOp(group, msg!.op, _myFingerprint); + group.v2Members.add(p.senderFp); + await _absorbOpRoster(group, msg, null); + progressed = true; + break; + } + } + } + } + + /// Požádá správce (a odesílatele operace, která nenavázala) o aktuální + /// podepsaný stav. Omezené frekvencí — odpověď žádost nikdy nevyvolá, + /// takže smyčka nevznikne. + Future _requestGroupSync(Group group, String hintFp) async { + final now = DateTime.now(); + final last = _groupSyncRequestedAt[group.id]; + if (last != null && now.difference(last) < _groupSyncRequestEvery) return; + _groupSyncRequestedAt[group.id] = now; + await _fanoutToGroup( + group, + { + 'v': 1, + 't': 'gsync', + 'id': const Uuid().v4(), + 'gid': group.id, + 'ep': group.epoch, + 'p': _profile(), + }, + recipients: {...group.admins, if (group.isMember(hintFp)) hintFp}, + ); + } + + /// Odpověď správce na žádost o stav: můj aktuální stav podepsaný jako + /// `sync`. Jen členovi, jen když jsem opravdu dál, a jen jednou za chvíli. + Future _answerGroupSync( + String senderFp, String gid, Map content) async { + final group = await _groupForIncoming(gid, senderFp); + if (group == null || !group.isAdmin(_myFingerprint)) return; + final theirs = content['ep']; + if (theirs is! int || theirs >= group.epoch) return; + + final key = '$gid|$senderFp'; + final now = DateTime.now(); + final last = _groupSyncAnsweredAt[key]; + if (last != null && now.difference(last) < _groupSyncAnswerEvery) return; + _groupSyncAnsweredAt[key] = now; + + final snapshot = _signOp( + buildGroupOp( + group: group, + me: _myFingerprint, + action: GroupAdminAction.sync, + ), + roster: await _buildRoster(group), + ); + await _fanoutToGroup( + group, + { + 'v': 1, + 't': 'gop', + 'id': const Uuid().v4(), + ...snapshot.toWire(), + 'p': _profile(), + }, + recipients: [senderFp], + ); + } + + /// Pozvánka do skupiny, kterou ještě neznám, s podepsaným stavem. Klíč + /// podepisujícího: z mých kontaktů, jinak z rosteru (fingerprint ověřený + /// proti klíči — stejná důvěra jako u v1 pozvánky). + Future _joinFromOp( + String senderFp, GroupOpMessage msg, Object? outerRoster) async { + final op = msg.op; + final roster = _validRosterEntries(msg.roster ?? outerRoster); + final keys = await _identityKeysByFp(); + final rosterIk = roster[op.actor]?['ik'] as String?; + final verdict = checkJoinOp( + msg: msg, + senderFp: senderFp, + me: _myFingerprint, + actorIdentityKey: + keys[op.actor] ?? (rosterIk == null ? null : base64Decode(rosterIk)), + ); + if (verdict != GopVerdict.apply) return false; + + roster.removeWhere((f, _) => !op.members.contains(f)); + await _ingestRoster(roster.values); + final group = Group( + id: op.gid, + name: op.name, + memberIds: op.members, + createdBy: op.actor, + admins: op.admins, + epoch: op.epoch, + v2Members: { + senderFp, + for (final e in roster.values) + if (e['v'] == groupProtocolVersion) e['f'] as String, + }, + ); + await storage.saveGroup(group); + await storage.ensureConversation(group.id); + onUpdate?.call(); + return true; + } + + /// Roster entry, jejichž fingerprint sedí s identity klíčem (anti-spoof); + /// ostatní se tiše vynechají. Nad strop členů = nic. + Map> _validRosterEntries(Object? rosterRaw) { final members = >{}; + if (rosterRaw is! List || rosterRaw.length > Group.maxMembers) { + return members; + } for (final raw in rosterRaw) { if (raw is! Map) continue; final e = raw.cast(); - final f = e['f'] as String?; - final ikB64 = e['ik'] as String?; - if (f == null || ikB64 == null) continue; + final f = e['f']; + final ikB64 = e['ik']; + if (f is! String || ikB64 is! String) continue; final Uint8List ik; try { ik = base64Decode(ikB64); @@ -987,21 +1765,15 @@ class MessagingService { if (SepotFingerprint.ofPublicKey(ik).display != f) continue; members[f] = e; } - if (!members.containsKey(senderFp)) return; - if (!members.containsKey(_myFingerprint)) return; + return members; + } - final existing = await storage.loadGroup(gid); - if (existing != null) { - if (existing.left) return; - // Aktualizaci rosteru smí poslat jen stávající člen. - if (!existing.isMember(senderFp)) return; - } - - // Neznámé členy založ jako běžné (neověřené) kontakty; existujícím - // nanejvýš doplň chybějící routing (jen při shodě identity klíče). + /// Neznámé členy založ jako běžné (neověřené) kontakty; existujícím + /// nanejvýš doplň chybějící routing (jen při shodě identity klíče). + Future _ingestRoster(Iterable> entries) async { final contacts = await storage.loadContacts(); final byFp = {for (final c in contacts) c.id: c}; - for (final e in members.values) { + for (final e in entries) { final f = e['f'] as String; if (f == _myFingerprint) continue; final aid = e['aid'] as String?; @@ -1036,19 +1808,99 @@ class MessagingService { deliveryAddress: daB64 == null ? null : base64Decode(daB64), ))); } + } + /// Zpracuje legacy `ginv` (pozvánka / aktualizace rosteru bez podpisu). + /// Bezpečnostní pravidla: fingerprint každého záznamu + /// musí sedět s jeho identity klíčem, odesílatel i já musíme být + /// v rosteru, identity klíč existujícího kontaktu se NIKDY nepřepisuje. + /// + /// U známé skupiny jen PŘIDÁVÁ: v1 klient jinou změnu než přidání + /// neumí, takže chybějící člen v jeho rosteru znamená nanejvýš, že ho + /// nezná — ne že ho má kdo odebrat. Odebírá jen podepsaný `gop` a `gleave`. + Future _handleGroupInvite( + String senderFp, Map content) async { + final gid = content['gid'] as String?; + final gname = (content['gname'] as String?)?.trim(); + final rosterRaw = content['roster'] as List?; + if (gid == null || gid.isEmpty || gid.length > 64) return; + if (gname == null || gname.isEmpty || rosterRaw == null) return; + if (rosterRaw.length > Group.maxMembers) return; + + final members = _validRosterEntries(rosterRaw); + if (!members.containsKey(senderFp)) return; + if (!members.containsKey(_myFingerprint)) return; + final senderV2 = members[senderFp]!['v'] == groupProtocolVersion; + + final existing = await storage.loadGroup(gid); + if (existing != null) { + if (existing.left) return; + // Aktualizaci rosteru smí poslat jen stávající člen. + if (!existing.isMember(senderFp)) return; + if (senderV2) existing.v2Members.add(senderFp); + _noteCapability(existing, senderFp, content); + final merged = {...existing.memberIds, ...members.keys}; + if (merged.length > Group.maxMembers) return; + if (!existing.isAdmin(senderFp) && + existing.isEnforced(_myFingerprint)) { + existing.rejectedOpAt = DateTime.now(); + await storage.saveGroup(existing); + onUpdate?.call(); + return; + } + await _ingestRoster(members.values); + existing + ..memberIds = merged.toList() + ..name = gname; + await storage.saveGroup(existing); + onUpdate?.call(); + return; + } + + await _ingestRoster(members.values); + // Bez podpisu nevím, kdo je skutečný správce — odhad je odesílatel, + // první podepsaný stav ho opraví (Group.provisional). final group = Group( id: gid, name: gname, memberIds: members.keys.toList(), - createdBy: existing?.createdBy ?? senderFp, - createdAt: existing?.createdAt, + createdBy: senderFp, + provisional: true, + v2Members: {if (senderV2) senderFp}, ); await storage.saveGroup(group); await storage.ensureConversation(gid); onUpdate?.call(); } + /// Legacy přejmenování (`gmeta` bez podpisu): od správce vždy, od + /// ostatních jen v kompatibilním režimu. + Future _handleLegacyMeta( + String senderFp, String gid, Map content) async { + final group = await _groupForIncoming(gid, senderFp); + if (group == null) return; + _noteCapability(group, senderFp, content); + final gname = (content['gname'] as String?)?.trim(); + if (!group.isAdmin(senderFp) && group.isEnforced(_myFingerprint)) { + group.rejectedOpAt = DateTime.now(); + } else if (gname != null && gname.isNotEmpty) { + group.name = gname; + } + await storage.saveGroup(group); + onUpdate?.call(); + } + + /// Odchod smí oznámit každý sám za sebe; odešel-li poslední správce, + /// nastoupí náhradník podle Group.normalizeAdmins. + Future _handleLeave(String senderFp, String gid) async { + final group = await _groupForIncoming(gid, senderFp); + if (group == null) return; + group.memberIds = group.memberIds.where((f) => f != senderFp).toList(); + group.normalizeAdmins(); + await storage.saveGroup(group); + onUpdate?.call(); + } + /// Společná odesílací cesta: session bootstrap (X3DH s anti-MITM /// kontrolou identity klíče), sealed encrypt, obálka na relay. /// Vrací true = doručeno online, false = uloženo do offline fronty. @@ -1165,6 +2017,24 @@ class MessagingService { Contact contact, Map content, ) async { + final tap = debugSendTap; + if (tap != null) { + tap(contact.id, content); + return true; + } + return await _sendSealed(contact, content) == + pb.Receipt_Status.STATUS_DELIVERED_ONLINE; + } + + /// Zašifruje obsah pro kontakt a pošle obálku. U [ephemeral] se nová + /// session nezakládá (X3DH kvůli „píše…“ nedává smysl) — bez session + /// vrací null. + Future _sendSealed( + Contact contact, + Map content, { + bool ephemeral = false, + bool ring = false, + }) async { final crypto = _crypto ?? (throw StateError('Service not started')); final relay = _relay!; @@ -1186,6 +2056,7 @@ class MessagingService { // První zpráva: X3DH z bundle. Identity klíč v bundle MUSÍ sedět // s klíčem z kontaktu (jinak by relay mohl podstrčit vlastní). if (!await crypto.hasSession(peerAddr)) { + if (ephemeral) return null; final bundle = await relay.fetchBundle(peerDa); if (!_listEquals(bundle.identityKey, peer.identityKey)) { throw const IdentityKeyMismatchError(); @@ -1199,9 +2070,16 @@ class MessagingService { senderCertificate: await _senderCertificate(), ); - final receipt = - await relay.sendEnvelope(dstAddress: peerDa, payload: sealed); - return receipt.status == pb.Receipt_Status.STATUS_DELIVERED_ONLINE; + final receipt = await relay.sendEnvelope( + dstAddress: peerDa, + payload: sealed, + hint: ephemeral + ? pb.ContentHint.CONTENT_HINT_EPHEMERAL + : ring + ? pb.ContentHint.CONTENT_HINT_CALL + : pb.ContentHint.CONTENT_HINT_DEFAULT, + ); + return receipt.status; } // ── Příjem ────────────────────────────────────────────────────────── @@ -1233,8 +2111,8 @@ class MessagingService { jsonDecode(utf8.decode(opened.plaintext)) as Map; final type = content['t'] as String?; const known = { - 'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', // 1:1 + skupiny - 'ginv', 'gleave', 'gmeta', // správa skupin + 'txt', 'att', 'rx', 'edit', 'del', 'dlv', 'rr', 'typ', 'vote', + ..._groupControlTypes, }; if (content['v'] != 1 || !known.contains(type)) { // Neznámý typ/verze: obsah je pro tuhle verzi navždy nečitelný @@ -1253,29 +2131,20 @@ class MessagingService { return; } - // Správa skupin: pozvánka/roster, odchod, přejmenování. Autorita - // = sealed-sender fingerprint; guardy uvnitř handlerů. ACK vždy - // (retry z fronty nepomůže). - if (type == 'ginv') { - await _handleGroupInvite(senderFp, content); + // Hovory jen 1:1; skupinový gid tu nemá co dělat. + // Nic se neukládá — záznam „Hovor · 3:12“ si zapíše obsluha hovoru. + if (type == 'call') { + if (content['gid'] == null) onCallSignal?.call(contact, content); _relay!.ack(envelope.msgUuid); return; } - if (type == 'gleave' || type == 'gmeta') { - final gid = content['gid'] as String?; - final group = - gid == null ? null : await _groupForIncoming(gid, senderFp); - if (group != null) { - if (type == 'gleave') { - group.memberIds = - group.memberIds.where((f) => f != senderFp).toList(); - } else { - final gname = (content['gname'] as String?)?.trim(); - if (gname != null && gname.isNotEmpty) group.name = gname; - } - await storage.saveGroup(group); - onUpdate?.call(); - } + + // Správa skupin: pozvánka/roster, odchod, přejmenování, podepsané + // operace správců a žádost o jejich stav. Autorita = sealed-sender + // fingerprint; guardy uvnitř handlerů. ACK vždy (retry z fronty + // nepomůže). + if (_groupControlTypes.contains(type)) { + await _handleGroupControl(senderFp, type, content); _relay!.ack(envelope.msgUuid); return; } @@ -1283,52 +2152,55 @@ class MessagingService { // Skupinový obsah: membership guard (skupinu znám, neopustil jsem // ji, odesílatel je člen) — jinak tichý drop s ACK. final gid = content['gid'] as String?; - if (gid != null && await _groupForIncoming(gid, senderFp) == null) { + final group = + gid == null ? null : await _groupForIncoming(gid, senderFp); + if (gid != null && group == null) { _relay!.ack(envelope.msgUuid); return; } + if (group != null && _noteCapability(group, senderFp, content)) { + await storage.saveGroup(group); + } // Konverzace, do které obsah patří: skupina (gid), jinak 1:1. final convId = gid ?? contact.id; - // Doručenka: příjemce potvrdil, že moje zpráva dorazila na jeho - // zařízení → povýšíme ✓ (odesláno) na ✓✓ (doručeno). Off-line zprávy - // se tak dočkají ✓✓ ve chvíli, kdy si je protistrana vyzvedne. - // Ve skupině povyšuje první doručenka (per-member matice v2). - if (type == 'dlv') { - final ref = content['ref'] as String?; - if (ref != null) { - await _mutateLocalMessage( - convId, - ref, - (m) => m.isMine(_identity!.id) && - m.status != MessageStatus.delivered - ? m.copyWith( - isDelivered: true, status: MessageStatus.delivered) - : m, - ); - } + // „píše…“: jen když ho sám sdílím a jen čerstvé — zdržené v síti + // by ukazovalo psaní, které už dávno skončilo. + if (type == 'typ') { + final fresh = DateTime.now().difference(wireSentAt(content['ts'])) < + TypingTracker.showFor; + if (fresh && await _typingEnabled()) typing.mark(convId, senderFp); _relay!.ack(envelope.msgUuid); - onUpdate?.call(); return; } - // Potvrzení přečtení: povýší moje doručené zprávy na „přečteno". - // Respektujeme jen když mám read receipts ZAPNUTÉ (reciproční). - if (type == 'rr') { - final enabled = - (await storage.getSetting('read_receipts_enabled')) ?? false; + // Doručenka (✓✓) a potvrzení přečtení: zapíšou čas od daného + // příjemce. Off-line zprávy se ✓✓ dočkají, až si je protistrana + // vyzvedne. Potvrzení přečtení respektujeme jen když ho sám sdílím + // (reciproční, default vypnuto). + if (type == 'dlv' || type == 'rr') { + final read = type == 'rr'; + final enabled = !read || + ((await storage.getSetting('read_receipts_enabled')) ?? + false); if (enabled) { // whereType přeskočí netypové prvky — podvržené 'ids' nesmí // shodit příjem (jinak by se obálka bez ACK doručovala donekonečna). - final ids = - (content['ids'] as List?)?.whereType() ?? const []; + final ids = read + ? (content['ids'] as List?)?.whereType() ?? + const [] + : [?content['ref'] as String?]; + final at = wireSentAt(content['ts']); + final recipients = group == null + ? 1 + : group.memberIds.where((f) => f != _identity!.id).length; for (final id in ids) { await _mutateLocalMessage( convId, id, - (m) => m.isMine(_identity!.id) && - m.status != MessageStatus.read - ? m.copyWith(status: MessageStatus.read, isDelivered: true) + (m) => m.isMine(_identity!.id) + ? applyReceipt(m, + from: senderFp, at: at, read: read, recipients: recipients) : m, ); } @@ -1340,7 +2212,7 @@ class MessagingService { // Control zprávy (reakce/edit/del) mění existující zprávu, nevytvářejí // novou bublinu. Aplikujeme a ACKneme (retry z fronty nepomůže). - if (type == 'rx' || type == 'edit' || type == 'del') { + if (type == 'rx' || type == 'edit' || type == 'del' || type == 'vote') { await _applyIncomingControl(convId, type!, content, opened); _relay!.ack(envelope.msgUuid); onUpdate?.call(); @@ -1373,6 +2245,11 @@ class MessagingService { timestamp: wireSentAt(content['ts']), isDelivered: true, disappearAfterSeconds: ttl, + // Vadná anketa = null → ukáže se záložní text, nic se nerozbije. + poll: Poll.fromWire(content['poll']), + // Zmínky dávají smysl jen ve skupině; v 1:1 by šly zneužít + // k obejití ztlumení. + mentions: gid == null ? const [] : mentionsFromWire(content['mn']), ); } else { final AttachmentMeta meta; @@ -1403,6 +2280,8 @@ class MessagingService { ); } final isNew = await storage.addMessage(convId, message); + // Zpráva dorazila → „píše…“ od autora skončilo. + typing.clear(convId, senderFp); // Uloženo na disku → teprve teď smí relay obálku zahodit. // (Blob přílohy do fronty nepatří — stahuje se na pozadí a při @@ -1422,7 +2301,7 @@ class MessagingService { } onUpdate?.call(); - onMessageReceived?.call(contact, message); + onMessageReceived?.call(contact, message, convId); // Pošli odesílateli doručenku (✓✓) — best-effort, nesmí blokovat // příjem ani ACK. Jen pro skutečné zprávy (txt/att), ne pro control. @@ -1452,6 +2331,51 @@ class MessagingService { } } + /// Zapíše doručenku/potvrzení přečtení od příjemce `from` do mé zprávy. + /// + /// Stav nikdy nesestupuje: doručenka poslaná znovu po výpadku spojení + /// často dorazí až PO potvrzení přečtení a nesmí vrátit „přečteno" na + /// „doručeno". Ve skupině platí ✓✓ / přečteno až za všechny ostatní + /// členy (`recipients`); dílčí stav ukáže Info o zprávě. Opakované + /// potvrzení čas nepřepíše — platí první. + @visibleForTesting + static Message applyReceipt( + Message m, { + required String from, + required DateTime at, + required bool read, + int recipients = 1, + }) { + // Hodiny příjemce se můžou opožďovat — přečtení před odesláním + // by v Info o zprávě vypadalo jako chyba. + final when = at.isBefore(m.timestamp) ? m.timestamp : at; + final deliveredTo = {...m.deliveredTo}..putIfAbsent(from, () => when); + final readBy = + read ? ({...m.readBy}..putIfAbsent(from, () => when)) : m.readBy; + + int rank(MessageStatus s) => switch (s) { + MessageStatus.sending || MessageStatus.failed => 0, + MessageStatus.sent => 1, + MessageStatus.delivered => 2, + MessageStatus.read => 3, + }; + // Doručenka sama dokazuje, že zpráva odešla — i když ji odesílatel + // stihl označit za neodeslanou (timeout před ACK relaye). + final target = readBy.length >= recipients + ? MessageStatus.read + : deliveredTo.length >= recipients + ? MessageStatus.delivered + : MessageStatus.sent; + final status = rank(target) > rank(m.status) ? target : m.status; + + return m.copyWith( + status: status, + isDelivered: true, + deliveredTo: deliveredTo, + readBy: readBy, + ); + } + /// Čas odeslání z drátu (ms). Bez něj by zpráva dostala čas doručení — /// po vyzvednutí offline fronty by celá konverzace uskočila na „teď" /// a každý člen skupiny by u téže zprávy viděl jiný čas. @@ -1506,6 +2430,12 @@ class MessagingService { if (m.senderId != senderFp) return m; return m.copyWith( isDeleted: true, content: '', reactions: const {}); + case 'vote': + final poll = m.poll; + final choice = content['o']; + if (poll == null || m.isDeleted || choice is! List) return m; + return m.copyWith( + poll: poll.withVote(senderFp, choice.whereType().toList())); default: return m; } @@ -1592,31 +2522,45 @@ class MessagingService { final profile = content['p'] as Map?; final profileDa = profile?['da'] as String?; + // Fotka: jen zprávy s profilem ji mění; profil bez 'av' = odebraná. + final avatar = AvatarRef.fromWire(profile?['av']); for (final c in contacts) { if (c.publicIdentity.fingerprint.display != senderFp) continue; // Doplnit/aktualizovat routing (accountId poprvé, rotace adresy…) final known = c.publicIdentity; - if (known.accountId != opened.senderName || + final routingChanged = known.accountId != opened.senderName || (profileDa != null && (known.deliveryAddress == null || - base64Encode(known.deliveryAddress!) != profileDa))) { - final updated = Contact( - displayName: c.displayName, - publicIdentity: known.withRouting( - accountId: opened.senderName, - deliveryAddress: profileDa != null - ? base64Decode(profileDa) - : known.deliveryAddress ?? Uint8List(0), - ), - addedAt: c.addedAt, - verified: c.verified, - notes: c.notes, - ); - await storage.saveContact(updated); - return updated; + base64Encode(known.deliveryAddress!) != profileDa)); + final avatarChanged = + profile != null && avatar?.blobId != c.avatar?.blobId; + if (!routingChanged && !avatarChanged) return c; + + final updated = Contact( + displayName: c.displayName, + publicIdentity: routingChanged + ? known.withRouting( + accountId: opened.senderName, + deliveryAddress: profileDa != null + ? base64Decode(profileDa) + : known.deliveryAddress ?? Uint8List(0), + ) + : known, + addedAt: c.addedAt, + verified: c.verified, + notes: c.notes, + // Bez tohohle se zablokovaný kontakt při změně adresy odblokoval. + blocked: c.blocked, + avatar: avatarChanged ? avatar : c.avatar, + ); + await storage.saveContact(updated); + if (avatarChanged) { + final old = c.avatar; + if (old != null) await _forgetAvatar(old); + if (avatar != null) unawaited(_downloadAvatar(avatar)); } - return c; + return updated; } // Neznámý odesílatel → nový (neověřený) kontakt @@ -1629,8 +2573,9 @@ class MessagingService { accountId: opened.senderName, deliveryAddress: profileDa == null ? null : base64Decode(profileDa), ); - final contact = Contact.fromPublicIdentity(identity); + final contact = Contact.fromPublicIdentity(identity)..avatar = avatar; await storage.saveContact(contact); + if (avatar != null) unawaited(_downloadAvatar(avatar)); return contact; } @@ -1681,6 +2626,8 @@ class MessagingService { Future stop() async { _registerRetryTimer?.cancel(); + typing.dispose(); + _avatarCache.clear(); _purgeTimer?.cancel(); _purgeTimer = null; await _deliverSub?.cancel(); diff --git a/app/lib/core/services/typing_tracker.dart b/app/lib/core/services/typing_tracker.dart new file mode 100644 index 0000000..976f2f3 --- /dev/null +++ b/app/lib/core/services/typing_tracker.dart @@ -0,0 +1,74 @@ +import 'dart:async'; + +/// Kdo právě píše: konverzace → otisky pisatelů. +/// +/// Indikátor nemá „konec psaní“ — odesílatel posílá „píšu“ nejvýš jednou +/// za pár sekund a tady každý záznam sám vyprší. Zpráva, která pak dorazí, +/// ho smaže hned ([clear]). +class TypingTracker { + TypingTracker({DateTime Function()? clock}) : _now = clock ?? DateTime.now; + + /// Jak dlouho po posledním „píšu“ indikátor svítí. Musí být delší než + /// interval, ve kterém odesílatel „píšu“ opakuje (4 s), jinak by blikal. + static const showFor = Duration(seconds: 6); + + final DateTime Function() _now; + final _expires = >{}; + final _changes = StreamController>>.broadcast(); + Timer? _sweep; + + Stream>> get changes => _changes.stream; + + /// Aktuální pisatelé po konverzacích (bez vypršelých). + Map> get current { + final now = _now(); + return { + for (final e in _expires.entries) + if (e.value.values.any((t) => t.isAfter(now))) + e.key: { + for (final w in e.value.entries) + if (w.value.isAfter(now)) w.key, + }, + }; + } + + void mark(String convId, String writer) { + (_expires[convId] ??= {})[writer] = _now().add(showFor); + _emit(); + _sweep ??= Timer.periodic(const Duration(seconds: 1), (_) => prune()); + } + + void clear(String convId, String writer) { + final writers = _expires[convId]; + if (writers?.remove(writer) == null) return; + if (writers!.isEmpty) _expires.remove(convId); + _emit(); + } + + /// Zahodí vypršelé záznamy; když už nikdo nepíše, zastaví časovač. + void prune() { + final now = _now(); + var changed = false; + _expires.removeWhere((_, writers) { + final before = writers.length; + writers.removeWhere((_, t) => !t.isAfter(now)); + changed |= writers.length != before; + return writers.isEmpty; + }); + if (changed) _emit(); + if (_expires.isEmpty) { + _sweep?.cancel(); + _sweep = null; + } + } + + void dispose() { + _sweep?.cancel(); + _sweep = null; + _expires.clear(); + } + + void _emit() { + if (!_changes.isClosed) _changes.add(current); + } +} diff --git a/app/lib/core/theme/app_theme.dart b/app/lib/core/theme/app_theme.dart index 7fc28f3..dd7d3e9 100644 --- a/app/lib/core/theme/app_theme.dart +++ b/app/lib/core/theme/app_theme.dart @@ -19,6 +19,7 @@ class SepotColors { // Text static const Color textPrimary = Color(0xFFF0EEE8); // mléko static const Color textSecondary = Color(0xFF9AA0B5); // mlha + static const Color textEmphasis = Color(0xFFC4C8D6); // mlha, zvýrazněná static const Color textMuted = Color(0xFF626880); // Akcentní barvy @@ -39,12 +40,57 @@ class SepotColors { // Bubliny zpráv — odeslaná teplá (ember), přijatá chladná (povrch) static const Color bubbleSent = Color(0xFFF0795A); static const Color bubbleReceived = Color(0xFF191C28); + + /// Plovoucí lišty nad obsahem (hlavička chatu, pole pro psaní, navigace). + /// Průhlednost nese rozmazání pod sebou — bez něj by působila jako chyba. + static const Color glass = Color(0xC712141D); + static const Color glassBorder = Color(0x0FFFFFFF); + + /// Podklad hlavičky profilu — o tón teplejší než noc, ať avatar vystoupí. + static const Color hero = Color(0xFF14131A); +} + +/// Tóny avatarů: tlumený podklad + světlejší inkoust téže barvy. Člověk +/// dostane tón podle otisku klíče, takže má všude a u všech stejnou barvu. +class SepotTint { + const SepotTint(this.background, this.ink); + + final Color background; + final Color ink; + + static const all = [ + SepotTint(Color(0xFF3A2A33), Color(0xFFF2A58F)), // ember + SepotTint(Color(0xFF22352B), Color(0xFFA6CFB2)), // sage + SepotTint(Color(0xFF1E2A3A), Color(0xFF9FB6D6)), // mist + SepotTint(Color(0xFF3A3222), Color(0xFFE6C27A)), // jantar + SepotTint(Color(0xFF2C2638), Color(0xFFC3B2E0)), // levandule + ]; + + /// Stabilní napříč spuštěními — String.hashCode to Dart nezaručuje. + static SepotTint of(String seed) { + var h = 0x811c9dc5; + for (final c in seed.codeUnits) { + h = ((h ^ c) * 0x01000193) & 0xFFFFFFFF; + } + return all[h % all.length]; + } } /// Témata aplikace class SepotTheme { SepotTheme._(); + /// Velký nadpis hlavních karet (Chaty, Kontakty, Nastavení). + static const largeTitle = TextStyle( + fontSize: 32, + fontWeight: FontWeight.w800, + letterSpacing: -0.6, + color: SepotColors.textPrimary, + ); + + /// Výška lišty s velkým nadpisem. + static const largeTitleBarHeight = 76.0; + static ThemeData get dark { return ThemeData( useMaterial3: true, diff --git a/app/lib/core/utils/text_fold.dart b/app/lib/core/utils/text_fold.dart new file mode 100644 index 0000000..cf66749 --- /dev/null +++ b/app/lib/core/utils/text_fold.dart @@ -0,0 +1,40 @@ +/// Porovnávání textu pro hledání: bez ohledu na velikost písmen a diakritiku. +/// Lidé na mobilu často píšou „prectu“ místo „přečtu“ — hledání to má najít. +library; + +const _map = { + 'á': 'a', 'ä': 'a', 'č': 'c', 'ď': 'd', 'é': 'e', 'ě': 'e', 'ë': 'e', + 'í': 'i', 'ľ': 'l', 'ĺ': 'l', 'ň': 'n', 'ó': 'o', 'ô': 'o', 'ö': 'o', + 'ř': 'r', 'ŕ': 'r', 'š': 's', 'ť': 't', 'ú': 'u', 'ů': 'u', 'ü': 'u', + 'ý': 'y', 'ž': 'z', 'ß': 'ss', +}; + +/// Malá písmena bez diakritiky. Délka se může změnit jen u „ß“, proto +/// pozice shod počítej přes [foldedIndexes], ne přes indexy výsledku. +String fold(String input) { + final out = StringBuffer(); + for (final ch in input.toLowerCase().split('')) { + out.write(_map[ch] ?? ch); + } + return out.toString(); +} + +/// Začátky všech výskytů [query] v [text] (indexy do PŮVODNÍHO textu). +/// Znaky se skládají po jednom, takže indexy sedí i pro zvýraznění. +List foldedIndexes(String text, String query) { + final q = fold(query); + if (q.isEmpty) return const []; + final chars = text.split(''); + final folded = chars.map(fold).toList(); + final hits = []; + for (var i = 0; i < chars.length; i++) { + var j = i; + var matched = ''; + while (j < chars.length && matched.length < q.length) { + matched += folded[j]; + j++; + } + if (matched.startsWith(q)) hits.add(i); + } + return hits; +} diff --git a/app/lib/features/chat_list/chat_list_screen.dart b/app/lib/features/chat_list/chat_list_screen.dart index bca9fa1..2ee85b7 100644 --- a/app/lib/features/chat_list/chat_list_screen.dart +++ b/app/lib/features/chat_list/chat_list_screen.dart @@ -1,173 +1,467 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.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/core/utils/text_fold.dart'; import 'package:sepot/features/chat_list/chat_list_tile.dart'; import 'package:sepot/features/chat_list/push_hint_banner.dart'; +import 'package:sepot/features/contacts/contacts_screen.dart'; +import 'package:sepot/features/settings/settings_screen.dart'; +import 'package:sepot/models/contact.dart'; import 'package:sepot/models/conversation.dart'; +import 'package:sepot/models/group.dart'; +import 'package:sepot/models/message.dart'; import 'package:sepot/providers/contacts_provider.dart'; +import 'package:sepot/providers/drafts_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/messaging_provider.dart'; import 'package:sepot/providers/push_provider.dart'; +import 'package:sepot/widgets/avatar.dart'; import 'package:sepot/widgets/e2e_badge.dart'; +import 'package:sepot/widgets/glass.dart'; -/// Hlavní obrazovka — seznam konverzací -class ChatListScreen extends ConsumerWidget { +/// Výška plovoucí navigace včetně okraje — obsah karet pod ní končí. +const _navHeight = 66.0 + 18.0; + +/// Domovská obrazovka: karty Chaty / Kontakty / Nastavení pod průsvitnou +/// plovoucí navigací. +class ChatListScreen extends ConsumerStatefulWidget { const ChatListScreen({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _ChatListScreenState(); +} + +class _ChatListScreenState extends ConsumerState { + var _tab = 0; + + @override + Widget build(BuildContext context) { // Nastartuje krypto + spojení s relayem (idempotentní) ref.watch(messagingStartProvider); - // Zaregistruje push callbacky (po startu messagingu) — obnoví registraci, - // pokud už distributor byl dřív vybraný. Push zapíná uživatel v nastavení. + // Zaregistruje push callbacky (po startu messagingu) a obnoví režim. ref.watch(pushInitProvider); - final conversationsAsync = ref.watch(conversationsProvider); - final contactsAsync = ref.watch(contactsProvider); - final groupsAsync = ref.watch(groupsProvider); - final identityAsync = ref.watch(identityProvider); + final l10n = context.l10n; + // S klávesnicí (hledání) by navigace i „Nový chat“ plavaly nad ní. + final keyboard = MediaQuery.viewInsetsOf(context).bottom > 0; + // Pod plovoucí navigací je ještě systémová lišta telefonu. + final tabBottom = _navHeight + MediaQuery.paddingOf(context).bottom; return Scaffold( - appBar: AppBar( - title: Text( - context.l10n.commonAppName, - style: const TextStyle( - fontFamily: kFontSans, - fontWeight: FontWeight.w700, - fontSize: 22, + extendBody: true, + body: IndexedStack( + index: _tab, + children: [ + _ChatsTab(onOpenSettings: () => setState(() => _tab = 2)), + // Kontakty a Nastavení mají vlastní Scaffold; spodní okraj, ať + // poslední položka neskončí pod navigací. + Padding( + padding: EdgeInsets.only(bottom: tabBottom), + child: const ContactsScreen(), ), - ), - actions: [ - IconButton( - icon: const Icon(Icons.search_rounded), - onPressed: () { - // TODO: Implementovat vyhledávání - }, - ), - IconButton( - icon: const Icon(Icons.settings_outlined), - onPressed: () => context.push('/settings'), + Padding( + padding: EdgeInsets.only(bottom: tabBottom), + child: const SettingsScreen(), ), ], ), - body: conversationsAsync.when( - data: (conversations) { - final active = conversations.where((c) => !c.archived).toList(); - final archived = conversations.where((c) => c.archived).toList(); - if (active.isEmpty && archived.isEmpty) { - return _buildEmptyState(context); - } - final hasArchive = archived.isNotEmpty; + floatingActionButton: _tab == 0 && !keyboard + ? FloatingActionButton.extended( + onPressed: () => context.push('/contacts'), + icon: const Icon(Icons.edit_outlined), + label: Text(l10n.chatListNewChat, + style: const TextStyle(fontWeight: FontWeight.w700)), + ) + : null, + bottomNavigationBar: keyboard + ? null + : SafeArea( + minimum: const EdgeInsets.fromLTRB(14, 0, 14, 12), + child: GlassSurface( + borderRadius: BorderRadius.circular(24), + child: SizedBox( + height: 66, + child: Row( + children: [ + _NavItem( + icon: Icons.chat_bubble_outline_rounded, + activeIcon: Icons.chat_bubble_rounded, + label: l10n.navChats, + selected: _tab == 0, + onTap: () => setState(() => _tab = 0), + ), + _NavItem( + icon: Icons.people_outline_rounded, + activeIcon: Icons.people_rounded, + label: l10n.contactsTitle, + selected: _tab == 1, + onTap: () => setState(() => _tab = 1), + ), + _NavItem( + icon: Icons.settings_outlined, + activeIcon: Icons.settings_rounded, + label: l10n.settingsTitle, + selected: _tab == 2, + onTap: () => setState(() => _tab = 2), + ), + ], + ), + ), + ), + ), + ); + } +} - return Column( +class _NavItem extends StatelessWidget { + const _NavItem({ + required this.icon, + required this.activeIcon, + required this.label, + required this.selected, + required this.onTap, + }); + + final IconData icon; + final IconData activeIcon; + final String label; + final bool selected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final color = selected ? SepotColors.ember : SepotColors.textSecondary; + return Expanded( + child: Semantics( + button: true, + selected: selected, + child: InkWell( + borderRadius: BorderRadius.circular(20), + onTap: () { + HapticFeedback.selectionClick(); + onTap(); + }, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, children: [ - // Bez budíčků appka na pozadí spí — ať uživatel ví, proč - // mu zprávy chodí, až když Šepot otevře. - const PushHintBanner(), - Expanded( - child: ListView.builder( - padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: active.length + (hasArchive ? 1 : 0), - itemBuilder: (context, index) { - if (hasArchive && index == 0) { - return ListTile( - leading: const Icon(Icons.archive_outlined, - color: SepotColors.textMuted), - title: Text(context.l10n.chatListArchivedCount(archived.length), - style: const TextStyle(color: SepotColors.textSecondary)), - onTap: () => context.push('/archived'), - ); - } - final conv = active[index - (hasArchive ? 1 : 0)]; - final group = groupsAsync.valueOrNull - ?.where((g) => g.id == conv.contactId) - .firstOrNull; - final contact = contactsAsync.valueOrNull - ?.where((c) => c.publicIdentity.id == conv.contactId) - .firstOrNull; - final myId = identityAsync.valueOrNull?.id ?? ''; - final name = group?.name ?? - contact?.displayName ?? - context.l10n.commonUnknownContact; - - return ChatListTile( - conversation: conv, - contactName: name, - myId: myId, - isGroup: group != null, - onTap: () => context.push('/conversation/${conv.contactId}'), - onLongPress: () => _showChatActions(context, ref, conv, name), - ); - }, + Icon(selected ? activeIcon : icon, size: 23, color: color), + const SizedBox(height: 3), + Text( + label, + style: TextStyle( + fontSize: 11, + fontWeight: selected ? FontWeight.w600 : FontWeight.w500, + color: color, ), ), ], + ), + ), + ), + ); + } +} + +enum _Filter { all, unread, groups } + +/// Karta Chaty: nadpis, hledání, filtry, připnuté a seznam. +class _ChatsTab extends ConsumerStatefulWidget { + const _ChatsTab({required this.onOpenSettings}); + + final VoidCallback onOpenSettings; + + @override + ConsumerState<_ChatsTab> createState() => _ChatsTabState(); +} + +class _ChatsTabState extends ConsumerState<_ChatsTab> { + final _search = TextEditingController(); + var _filter = _Filter.all; + + @override + void dispose() { + _search.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + final conversationsAsync = ref.watch(conversationsProvider); + final contacts = ref.watch(contactsProvider).valueOrNull ?? const []; + final groups = ref.watch(groupsProvider).valueOrNull ?? const []; + final identity = ref.watch(identityProvider).valueOrNull; + final myId = identity?.id ?? ''; + final typing = ref.watch(typingProvider).valueOrNull ?? const {}; + final drafts = ref.watch(draftsProvider).valueOrNull ?? const {}; + + Contact? contactOf(String id) => + contacts.where((c) => c.publicIdentity.id == id).firstOrNull; + Group? groupOf(String id) => groups.where((g) => g.id == id).firstOrNull; + String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId) + ? l10n.notesTitle + : groupOf(c.contactId)?.name ?? + contactOf(c.contactId)?.displayName ?? + l10n.commonUnknownContact; + + return SafeArea( + bottom: false, + child: conversationsAsync.when( + loading: () => const Center( + child: CircularProgressIndicator(color: SepotColors.ember)), + error: (e, _) => Center(child: Text(describeError(l10n, e))), + data: (conversations) { + final active = conversations.where((c) => !c.archived).toList(); + final archived = conversations.where((c) => c.archived).length; + final query = _search.text.trim(); + final searching = query.isNotEmpty; + + // Hledání: jméno i obsah zpráv, bez diakritiky. U shody ve zprávě + // se místo poslední zprávy ukáže ta nalezená. + final hits = {}; + if (searching) { + final q = fold(query); + for (final c in conversations) { + if (fold(nameOf(c)).contains(q)) { + hits[c] = null; + continue; + } + final m = c.messages.reversed + .where((m) => + !m.isDeleted && + m.content != null && + fold(m.content!).contains(q)) + .firstOrNull; + if (m != null) hits[c] = m; + } + } + + final unreadCount = + active.where((c) => c.unreadCount(myId) > 0).length; + final pinned = active.where((c) => c.pinned).toList(); + final list = searching + ? hits.keys.toList() + : active.where((c) { + if (_filter == _Filter.all && c.pinned) return false; + return switch (_filter) { + _Filter.all => true, + _Filter.unread => c.unreadCount(myId) > 0, + _Filter.groups => groupOf(c.contactId) != null, + }; + }).toList(); + + Widget tile(Conversation c) { + final group = groupOf(c.contactId); + final last = c.lastMessage; + final writers = typing[c.contactId] ?? const {}; + final typingLabel = writers.isEmpty + ? null + : group == null + ? l10n.chatTyping + : writers.length == 1 + ? l10n.chatTypingNamed( + (contactOf(writers.first)?.displayName ?? '?') + .split(' ') + .first) + : l10n.chatTypingSeveral; + return ChatListTile( + conversation: c, + contactName: nameOf(c), + myId: myId, + isGroup: group != null, + verified: contactOf(c.contactId)?.verified ?? false, + lastAuthor: group != null && last != null + ? contactOf(last.senderId)?.displayName + : null, + highlight: hits[c], + typingLabel: typingLabel, + draft: hits[c] == null ? drafts[c.contactId] : null, + onTap: () => context.push('/conversation/${c.contactId}'), + onLongPress: () => _showChatActions(c, nameOf(c)), + ); + } + + return CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 12, 0), + child: Row( + children: [ + Expanded( + child: Text(l10n.navChats, + style: SepotTheme.largeTitle), + ), + IconButton.filledTonal( + tooltip: l10n.qrScanTitle, + style: IconButton.styleFrom( + backgroundColor: SepotColors.surface, + foregroundColor: SepotColors.textPrimary), + onPressed: () => context.push('/qr-scan'), + icon: const Icon(Icons.qr_code_scanner_rounded, + size: 21), + ), + const SizedBox(width: 6), + Semantics( + button: true, + label: l10n.settingsTitle, + child: GestureDetector( + onTap: widget.onOpenSettings, + child: SepotAvatar( + name: identity?.displayName ?? '?', + seed: myId, + size: 42, + ), + ), + ), + ], + ), + ), + ), + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 4), + child: TextField( + controller: _search, + onChanged: (_) => setState(() {}), + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: l10n.chatListSearchHint, + prefixIcon: const Icon(Icons.search_rounded, + color: SepotColors.textSecondary), + suffixIcon: searching + ? IconButton( + tooltip: l10n.commonCancel, + icon: const Icon(Icons.close_rounded), + onPressed: () => + setState(() => _search.clear()), + ) + : null, + filled: true, + fillColor: SepotColors.surface, + contentPadding: const EdgeInsets.symmetric(vertical: 10), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: const BorderSide( + color: SepotColors.ember, width: 1.5), + ), + ), + ), + ), + ), + // Bez budíčků appka na pozadí spí — ať uživatel ví, proč + // mu zprávy chodí, až když Šepot otevře. + const SliverToBoxAdapter(child: PushHintBanner()), + if (!searching) + SliverToBoxAdapter( + child: SizedBox( + height: 52, + child: ListView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 8, 16, 8), + children: [ + _FilterChip( + label: l10n.chatFilterAll, + selected: _filter == _Filter.all, + onTap: () => setState(() => _filter = _Filter.all), + ), + _FilterChip( + label: l10n.chatFilterUnread, + count: unreadCount, + selected: _filter == _Filter.unread, + onTap: () => + setState(() => _filter = _Filter.unread), + ), + _FilterChip( + label: l10n.chatFilterGroups, + selected: _filter == _Filter.groups, + onTap: () => + setState(() => _filter = _Filter.groups), + ), + if (archived > 0) + _FilterChip( + label: l10n.chatFilterArchive, + count: archived, + icon: Icons.archive_outlined, + selected: false, + onTap: () => context.push('/archived'), + ), + ], + ), + ), + ), + if (!searching && _filter == _Filter.all && pinned.isNotEmpty) + SliverToBoxAdapter( + child: SizedBox( + height: 100, + child: ListView.separated( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.fromLTRB(16, 6, 16, 4), + itemCount: pinned.length, + separatorBuilder: (_, _) => const SizedBox(width: 14), + itemBuilder: (context, i) { + final c = pinned[i]; + return _PinnedChat( + name: nameOf(c), + seed: c.contactId, + isGroup: groupOf(c.contactId) != null, + unread: c.unreadCount(myId) > 0 && !c.isMuted, + onTap: () => + context.push('/conversation/${c.contactId}'), + onLongPress: () => _showChatActions(c, nameOf(c)), + ); + }, + ), + ), + ), + if (conversations.isEmpty) + SliverFillRemaining( + hasScrollBody: false, + child: _EmptyState(), + ) + else if (list.isEmpty) + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(48), + child: Text( + searching ? l10n.chatListNoResults : l10n.chatListFilterEmpty, + textAlign: TextAlign.center, + style: const TextStyle(color: SepotColors.textSecondary), + ), + ), + ) + else + SliverList.builder( + itemCount: list.length, + itemBuilder: (context, i) => tile(list[i]), + ), + // Místo pro „Nový chat“ a plovoucí navigaci. + const SliverToBoxAdapter(child: SizedBox(height: 170)), + ], ); }, - loading: () => const Center( - child: CircularProgressIndicator(color: SepotColors.ember), - ), - error: (e, _) => - Center(child: Text(describeError(context.l10n, e))), - ), - floatingActionButton: FloatingActionButton( - onPressed: () => context.push('/contacts'), - child: const Icon(Icons.chat_bubble_outline_rounded), ), ); } - Widget _buildEmptyState(BuildContext context) { - return Center( - child: Padding( - padding: const EdgeInsets.all(48), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: SepotColors.ember.withValues(alpha: 0.1), - ), - child: const Icon( - Icons.chat_bubble_outline_rounded, - size: 40, - color: SepotColors.ember, - ), - ), - const SizedBox(height: 24), - Text( - context.l10n.chatListEmptyTitle, - style: const TextStyle( - fontFamily: kFontSans, - fontSize: 18, - fontWeight: FontWeight.w600, - color: SepotColors.textPrimary, - ), - ), - const SizedBox(height: 8), - Text( - context.l10n.chatListEmptySubtitle, - style: const TextStyle(color: SepotColors.textSecondary), - ), - const SizedBox(height: 32), - const E2EBadge(), - ], - ), - ), - ); - } - - /// Akce nad konverzací (long-press): ztlumit / archivovat. - void _showChatActions( - BuildContext context, WidgetRef ref, Conversation conv, String name) { + /// Akce nad konverzací (long-press): připnout / ztlumit / archivovat. + void _showChatActions(Conversation conv, String name) { final notifier = ref.read(conversationsProvider.notifier); + final l10n = context.l10n; + HapticFeedback.mediumImpact(); showModalBottomSheet( context: context, backgroundColor: SepotColors.surface, @@ -178,13 +472,30 @@ class ChatListScreen extends ConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 8), + child: Text(name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700)), + ), + ListTile( + leading: Icon(conv.pinned + ? Icons.push_pin_outlined + : Icons.push_pin_rounded), + title: Text(conv.pinned ? l10n.chatListUnpin : l10n.chatListPin), + onTap: () { + Navigator.pop(ctx); + notifier.setPinned(conv.contactId, !conv.pinned); + }, + ), ListTile( leading: Icon(conv.isMuted ? Icons.notifications_active_outlined : Icons.notifications_off_outlined), - title: Text(conv.isMuted - ? context.l10n.chatListUnmute - : context.l10n.chatListMute), + title: Text( + conv.isMuted ? l10n.chatListUnmute : l10n.chatListMute), onTap: () { Navigator.pop(ctx); notifier.setMuted(conv.contactId, !conv.isMuted); @@ -192,7 +503,7 @@ class ChatListScreen extends ConsumerWidget { ), ListTile( leading: const Icon(Icons.archive_outlined), - title: Text(context.l10n.chatListArchive), + title: Text(l10n.chatListArchive), onTap: () { Navigator.pop(ctx); notifier.setArchived(conv.contactId, true); @@ -204,3 +515,173 @@ class ChatListScreen extends ConsumerWidget { ); } } + +class _FilterChip extends StatelessWidget { + const _FilterChip({ + required this.label, + required this.selected, + required this.onTap, + this.count, + this.icon, + }); + + final String label; + final bool selected; + final VoidCallback onTap; + final int? count; + final IconData? icon; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 8), + child: Semantics( + button: true, + selected: selected, + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + padding: const EdgeInsets.symmetric(horizontal: 14), + decoration: BoxDecoration( + color: selected ? SepotColors.ember : SepotColors.surface, + borderRadius: BorderRadius.circular(18), + border: Border.all( + color: selected ? SepotColors.ember : SepotColors.border), + ), + alignment: Alignment.center, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[ + Icon(icon, size: 15, color: SepotColors.textEmphasis), + const SizedBox(width: 6), + ], + Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: selected ? FontWeight.w700 : FontWeight.w500, + color: selected + ? SepotColors.emberInk + : SepotColors.textEmphasis, + ), + ), + if (count != null && count! > 0) ...[ + const SizedBox(width: 6), + Text( + '$count', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: selected + ? SepotColors.emberInk + : SepotColors.ember, + ), + ), + ], + ], + ), + ), + ), + ), + ); + } +} + +class _PinnedChat extends StatelessWidget { + const _PinnedChat({ + required this.name, + required this.seed, + required this.isGroup, + required this.unread, + required this.onTap, + required this.onLongPress, + }); + + final String name; + final String seed; + final bool isGroup; + final bool unread; + final VoidCallback onTap; + final VoidCallback onLongPress; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(16), + onTap: onTap, + onLongPress: onLongPress, + child: SizedBox( + width: 66, + child: Column( + children: [ + isGroup + ? Container( + padding: const EdgeInsets.all(2.5), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: unread ? SepotColors.ember : Colors.transparent, + width: 2, + ), + ), + child: SepotGroupAvatar(seed: seed, size: 54), + ) + : SepotAvatar( + name: name, + seed: seed, + size: 54, + ring: unread ? SepotColors.ember : SepotColors.border, + ), + const SizedBox(height: 6), + Text( + name.split(' ').first, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontSize: 12), + ), + ], + ), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(48), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: SepotColors.ember.withValues(alpha: 0.1), + ), + child: const Icon(Icons.chat_bubble_outline_rounded, + size: 40, color: SepotColors.ember), + ), + const SizedBox(height: 24), + Text( + context.l10n.chatListEmptyTitle, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + context.l10n.chatListEmptySubtitle, + textAlign: TextAlign.center, + style: const TextStyle(color: SepotColors.textSecondary), + ), + const SizedBox(height: 32), + const E2EBadge(), + ], + ), + ); + } +} diff --git a/app/lib/features/chat_list/chat_list_tile.dart b/app/lib/features/chat_list/chat_list_tile.dart index d2b2c4d..05dd47f 100644 --- a/app/lib/features/chat_list/chat_list_tile.dart +++ b/app/lib/features/chat_list/chat_list_tile.dart @@ -1,10 +1,13 @@ import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:sepot/core/constants/app_constants.dart'; import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/features/calls/call_log_text.dart'; import 'package:sepot/models/conversation.dart'; import 'package:sepot/models/message.dart'; import 'package:sepot/widgets/avatar.dart'; +import 'package:sepot/widgets/message_status_icon.dart'; /// Dlaždice konverzace v seznamu chatů. Sdílená hlavním i archivovaným /// seznamem, aby se logika (fajfky, náhled, ztlumení) nerozešla. @@ -13,6 +16,20 @@ class ChatListTile extends StatelessWidget { final String contactName; final String myId; final bool isGroup; + final bool verified; + + /// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý). + final String? lastAuthor; + + /// Nalezená zpráva při hledání — ukáže se místo poslední. + final Message? highlight; + + /// Kdo právě píše (už složený text „píše…“ / „Alice píše…“), null = nikdo. + final String? typingLabel; + + /// Rozepsaná zpráva v tomhle chatu — v náhledu má přednost před poslední + /// zprávou, jinak by se na ni snadno zapomnělo. + final String? draft; final VoidCallback onTap; final VoidCallback? onLongPress; @@ -22,37 +39,41 @@ class ChatListTile extends StatelessWidget { required this.contactName, required this.myId, this.isGroup = false, + this.verified = false, + this.lastAuthor, + this.highlight, + this.typingLabel, + this.draft, required this.onTap, this.onLongPress, }); @override Widget build(BuildContext context) { - final lastMsg = conversation.lastMessage; + final lastMsg = highlight ?? conversation.lastMessage; final unread = conversation.unreadCount(myId); final muted = conversation.isMuted; + final mine = lastMsg != null && lastMsg.isMine(myId); + final preview = previewOf(context.l10n, lastMsg); return InkWell( onTap: onTap, onLongPress: onLongPress, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), child: Row( children: [ - isGroup - ? Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: SepotColors.surfaceVariant, - border: Border.all(color: SepotColors.border), - ), - child: const Icon(Icons.group_rounded, - color: SepotColors.ember, size: 24), - ) - : SepotAvatar(name: contactName), - const SizedBox(width: 12), + conversation.contactId == AppConstants.notesConversationId + ? const NotesAvatar(size: 54) + : isGroup + ? SepotGroupAvatar(seed: conversation.contactId, size: 54) + : SepotAvatar( + name: contactName, + seed: conversation.contactId, + size: 54, + verified: verified, + ), + const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -65,26 +86,28 @@ class ChatListTile extends StatelessWidget { style: TextStyle( fontSize: 16, fontWeight: - unread > 0 ? FontWeight.w700 : FontWeight.w500, + unread > 0 ? FontWeight.w700 : FontWeight.w600, color: SepotColors.textPrimary, ), overflow: TextOverflow.ellipsis, ), ), - if (muted) + if (conversation.pinned) const Padding( padding: EdgeInsets.only(right: 6), - child: Icon(Icons.notifications_off, - size: 15, color: SepotColors.textMuted), + child: Icon(Icons.push_pin_rounded, + size: 14, color: SepotColors.textMuted), ), if (lastMsg != null) Text( - _formatTime(context, lastMsg.timestamp), + formatListTime(context, lastMsg.timestamp), style: TextStyle( fontSize: 12, + fontWeight: + unread > 0 ? FontWeight.w600 : FontWeight.w400, color: unread > 0 && !muted ? SepotColors.ember - : SepotColors.textMuted, + : SepotColors.textSecondary, ), ), ], @@ -92,58 +115,106 @@ class ChatListTile extends StatelessWidget { const SizedBox(height: 4), Row( children: [ - if (lastMsg != null && lastMsg.isMine(myId)) - Padding( - padding: const EdgeInsets.only(right: 4), - child: Icon( - switch (lastMsg.status) { - MessageStatus.sending => Icons.schedule, - MessageStatus.sent => Icons.done, - MessageStatus.delivered => Icons.done_all, - MessageStatus.read => Icons.done_all, - MessageStatus.failed => Icons.error_outline, - }, - size: 16, - color: lastMsg.status == MessageStatus.failed - ? SepotColors.error - : lastMsg.status == MessageStatus.read - ? SepotColors.mist - : SepotColors.textMuted, + if (typingLabel != null) + Expanded( + child: Text( + typingLabel!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: SepotColors.ember, + ), + ), + ) + else if (draft != null) + Expanded( + child: Text.rich( + TextSpan(children: [ + TextSpan( + text: '${context.l10n.chatListDraft}: ', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: SepotColors.ember, + ), + ), + TextSpan(text: draft!.replaceAll('\n', ' ')), + ]), + style: const TextStyle( + fontSize: 14, + color: SepotColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), + ) + else ...[ + if (mine && !lastMsg.isDeleted) ...[ + MessageStatusIcon(status: lastMsg.status), + const SizedBox(width: 6), + ], + if (preview.icon != null) ...[ + Icon(preview.icon, + size: 15, color: SepotColors.textSecondary), + const SizedBox(width: 4), + ], + Expanded( + child: Text.rich( + TextSpan(children: [ + if (isGroup && !mine && lastAuthor != null) + TextSpan( + text: '$lastAuthor: ', + style: const TextStyle( + fontWeight: FontWeight.w600, + color: SepotColors.textEmphasis, + ), + ), + TextSpan(text: preview.text), + ]), + style: TextStyle( + fontSize: 14, + color: unread > 0 + ? SepotColors.textPrimary + : SepotColors.textSecondary, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, ), ), - Expanded( - child: Text( - _getLastMessageText(context.l10n, lastMsg), - style: TextStyle( - fontSize: 14, - color: unread > 0 - ? SepotColors.textPrimary - : SepotColors.textSecondary, - ), - overflow: TextOverflow.ellipsis, - maxLines: 1, + ], + if (muted) + const Padding( + padding: EdgeInsets.only(left: 6), + child: Icon(Icons.notifications_off_outlined, + size: 16, color: SepotColors.textMuted), ), - ), - if (unread > 0) + if (unread > 0) ...[ + const SizedBox(width: 8), Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, vertical: 2), + constraints: const BoxConstraints(minWidth: 22), + height: 22, + padding: const EdgeInsets.symmetric(horizontal: 7), + alignment: Alignment.center, decoration: BoxDecoration( // Ztlumené = šedý badge (počet ano, důraz ne) color: muted - ? SepotColors.textMuted + ? SepotColors.surfaceVariant : SepotColors.ember, - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(11), ), child: Text( - unread.toString(), - style: const TextStyle( - color: Colors.white, + unread > 99 ? '99+' : unread.toString(), + style: TextStyle( + color: muted + ? SepotColors.textSecondary + : SepotColors.emberInk, fontSize: 12, - fontWeight: FontWeight.w600, + fontWeight: FontWeight.w700, ), ), ), + ], ], ), ], @@ -155,33 +226,60 @@ class ChatListTile extends StatelessWidget { ); } - static String _getLastMessageText(AppLocalizations l10n, Message? msg) { - if (msg == null) return l10n.chatListNewConversation; - if (msg.isDeleted) return l10n.chatMessageDeleted; - if (msg.attachment?.isAudio == true) return l10n.chatVoiceMessagePreview; - switch (msg.type) { - case MessageType.text: - return msg.content ?? ''; - case MessageType.image: - return msg.content?.isNotEmpty == true - ? l10n.chatListPhotoWithCaptionPreview(msg.content!) - : l10n.chatPhotoPreview; - case MessageType.file: - return l10n.chatListFilePreview(msg.fileName ?? l10n.commonFile); - case MessageType.readReceipt: - return l10n.chatListReadReceiptPreview; - default: - return ''; + /// Náhled zprávy: ikona typu (fotka, soubor, hlas) + text. Ikona místo + /// emoji — emoji se na každém systému kreslí jinak a v náhledu ruší. + static ({IconData? icon, String text}) previewOf( + AppLocalizations l10n, Message? msg) { + if (msg == null) return (icon: null, text: l10n.chatListNewConversation); + if (msg.isDeleted) { + return (icon: Icons.block_rounded, text: l10n.chatMessageDeleted); } + if (msg.attachment?.isAudio == true) { + return (icon: Icons.mic_none_rounded, text: l10n.chatVoiceMessagePreview); + } + if (msg.poll != null) { + return (icon: Icons.bar_chart_rounded, text: msg.poll!.question); + } + final call = msg.callLog; + if (call != null) { + final label = callLogLabel(l10n, call); + return (icon: label.icon, text: label.text); + } + return switch (msg.type) { + MessageType.text => (icon: null, text: msg.content ?? ''), + MessageType.image => ( + icon: Icons.photo_outlined, + text: msg.content?.isNotEmpty == true + ? msg.content! + : l10n.chatPhotoPreview, + ), + MessageType.file => ( + icon: Icons.attach_file_rounded, + text: msg.fileName ?? l10n.commonFile, + ), + MessageType.readReceipt => ( + icon: null, + text: l10n.chatListReadReceiptPreview, + ), + _ => (icon: null, text: ''), + }; } - static String _formatTime(BuildContext context, DateTime time) { + static String formatListTime(BuildContext context, DateTime time) { final locale = Localizations.localeOf(context).toString(); final now = DateTime.now(); final diff = now.difference(time); - if (diff.inMinutes < 1) return context.l10n.chatListTimeNow; - if (diff.inHours < 1) return context.l10n.chatListTimeMinutes(diff.inMinutes); - if (diff.inDays < 1) return DateFormat.Hm(locale).format(time); + // Záporný rozdíl = hodiny odesílatele jdou napřed; „teď“ by lhalo. + if (!diff.isNegative && diff.inMinutes < 1) { + return context.l10n.chatListTimeNow; + } + if (!diff.isNegative && diff.inHours < 1) { + return context.l10n.chatListTimeMinutes(diff.inMinutes); + } + final sameDay = now.year == time.year && + now.month == time.month && + now.day == time.day; + if (sameDay) return DateFormat.Hm(locale).format(time); if (diff.inDays < 7) return DateFormat.E(locale).format(time); return DateFormat.Md(locale).format(time); } diff --git a/app/lib/features/chat_list/push_hint_banner.dart b/app/lib/features/chat_list/push_hint_banner.dart index 4c08df2..3210e81 100644 --- a/app/lib/features/chat_list/push_hint_banner.dart +++ b/app/lib/features/chat_list/push_hint_banner.dart @@ -27,44 +27,49 @@ class PushHintBanner extends ConsumerWidget { ? context.l10n.pushHintFailed : context.l10n.pushHintOff; - return Material( - color: SepotColors.surfaceVariant, - child: InkWell( - onTap: () => context.push('/settings'), - child: Padding( - padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), - child: Row( - children: [ - Icon( - Icons.notifications_off_outlined, - size: 18, - color: health == PushHealth.failed - ? SepotColors.ember - : SepotColors.textMuted, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - text, - style: const TextStyle( - fontFamily: kFontSans, - fontSize: 13, - color: SepotColors.textSecondary, + return Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 2), + child: Material( + color: SepotColors.surface, + borderRadius: BorderRadius.circular(16), + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.push('/settings'), + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), + child: Row( + children: [ + Icon( + Icons.notifications_off_outlined, + size: 18, + color: health == PushHealth.failed + ? SepotColors.ember + : SepotColors.textMuted, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + text, + style: const TextStyle( + fontFamily: kFontSans, + fontSize: 13, + color: SepotColors.textSecondary, + ), ), ), - ), - IconButton( - icon: const Icon(Icons.close, size: 18), - color: SepotColors.textMuted, - tooltip: context.l10n.commonClose, - onPressed: () async { - await ref - .read(storageProvider) - .setSetting('push_hint_dismissed', true); - ref.invalidate(pushHintDismissedProvider); - }, - ), - ], + IconButton( + icon: const Icon(Icons.close, size: 18), + color: SepotColors.textMuted, + tooltip: context.l10n.commonClose, + onPressed: () async { + await ref + .read(storageProvider) + .setSetting('push_hint_dismissed', true); + ref.invalidate(pushHintDismissedProvider); + }, + ), + ], + ), ), ), ), diff --git a/app/lib/features/contacts/contacts_screen.dart b/app/lib/features/contacts/contacts_screen.dart index 4dbce40..dc279a6 100644 --- a/app/lib/features/contacts/contacts_screen.dart +++ b/app/lib/features/contacts/contacts_screen.dart @@ -3,8 +3,10 @@ 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/constants/app_constants.dart'; import 'package:sepot/core/theme/app_theme.dart'; import 'package:sepot/models/identity.dart'; +import 'package:sepot/providers/conversations_provider.dart'; import 'package:sepot/providers/contacts_provider.dart'; import 'package:sepot/widgets/avatar.dart'; @@ -32,7 +34,8 @@ class _ContactsScreenState extends ConsumerState { return Scaffold( appBar: AppBar( - title: Text(context.l10n.contactsTitle), + toolbarHeight: SepotTheme.largeTitleBarHeight, + title: Text(context.l10n.contactsTitle, style: SepotTheme.largeTitle), actions: [ IconButton( icon: const Icon(Icons.group_add_outlined), @@ -72,6 +75,22 @@ class _ContactsScreenState extends ConsumerState { // Sdílet vlastní QR _buildShareCard(context), const Divider(height: 1), + ListTile( + leading: const NotesAvatar(size: 40), + title: Text(context.l10n.notesTitle, + style: const TextStyle(fontWeight: FontWeight.w500)), + subtitle: Text(context.l10n.notesSubtitle, + style: const TextStyle( + fontSize: 12, color: SepotColors.textSecondary)), + onTap: () async { + await ref.read(conversationsProvider.notifier).ensureNotes(); + if (context.mounted) { + context.push( + '/conversation/${AppConstants.notesConversationId}'); + } + }, + ), + const Divider(height: 1), // Seznam kontaktů Expanded( child: contactsAsync.when( @@ -115,8 +134,16 @@ class _ContactsScreenState extends ConsumerState { final displayName = contact.displayName.isEmpty ? context.l10n.commonUnknownContact : contact.displayName; + final id = contact.publicIdentity.id; return ListTile( - leading: SepotAvatar(name: displayName), + leading: GestureDetector( + onTap: () => context.push('/contact/$id'), + child: SepotAvatar( + name: displayName, + seed: id, + verified: contact.verified, + ), + ), title: Text( displayName, style: const TextStyle(fontWeight: FontWeight.w500), @@ -127,23 +154,15 @@ class _ContactsScreenState extends ConsumerState { contact.sepotId, style: SepotTheme.cryptoText.copyWith(fontSize: 11), ), - if (contact.verified) ...[ - const SizedBox(width: 8), - const Icon( - Icons.verified, - size: 14, - color: SepotColors.ember, - ), - ], ], ), - trailing: const Icon( - Icons.chat_bubble_outline, - color: SepotColors.textMuted, + trailing: IconButton( + tooltip: context.l10n.chatOpenProfile, + icon: const Icon(Icons.person_outline_rounded, + color: SepotColors.textSecondary), + onPressed: () => context.push('/contact/$id'), ), - onTap: () { - context.push('/conversation/${contact.publicIdentity.id}'); - }, + onTap: () => context.push('/conversation/$id'), onLongPress: () => _showContactOptions(context, contact.id), ); diff --git a/app/lib/features/conversation/chat_actions.dart b/app/lib/features/conversation/chat_actions.dart new file mode 100644 index 0000000..5380143 --- /dev/null +++ b/app/lib/features/conversation/chat_actions.dart @@ -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 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( + 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 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 reportAndBlock( + BuildContext context, WidgetRef ref, Contact contact) async { + final jmeno = contactLabel(context, contact); + final ok = await showDialog( + 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 = {'contact': contact.id, 'name': contact.displayName}; + context.push(Uri(path: '/report', queryParameters: q).toString()); +} + +Future renameGroup( + BuildContext context, WidgetRef ref, Group group) async { + final controller = TextEditingController(text: group.name); + final novy = await showDialog( + 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 removeGroupMember(BuildContext context, WidgetRef ref, + Group group, String fingerprint, String name) async { + final potvrzeno = await showDialog( + 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 setGroupAdmin(BuildContext context, WidgetRef ref, Group group, + String fingerprint, bool admin) => + _adminAction( + context, + () => ref + .read(groupsProvider.notifier) + .setAdmin(group, fingerprint, admin)); + +Future _adminAction( + BuildContext context, Future 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 leaveGroup( + BuildContext context, WidgetRef ref, Group group) async { + final potvrzeno = await showDialog( + 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; + } +} diff --git a/app/lib/features/conversation/conversation_screen.dart b/app/lib/features/conversation/conversation_screen.dart index 53352f4..de189ec 100644 --- a/app/lib/features/conversation/conversation_screen.dart +++ b/app/lib/features/conversation/conversation_screen.dart @@ -1,6 +1,6 @@ import 'dart:async'; import 'dart:io'; - +import 'dart:math' as math; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; @@ -10,33 +10,55 @@ 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:mime/mime.dart'; import 'package:intl/intl.dart'; +import 'package:sepot/core/calls/call_controller.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/services/image_processor.dart'; import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/core/utils/text_fold.dart'; +import 'package:sepot/features/calls/call_log_text.dart'; +import 'package:sepot/features/conversation/chat_actions.dart'; +import 'package:sepot/features/conversation/forward_sheet.dart'; import 'package:sepot/features/conversation/widgets/attachment_content.dart'; +import 'package:sepot/features/conversation/widgets/create_poll_sheet.dart'; +import 'package:sepot/features/conversation/widgets/poll_content.dart'; +import 'package:sepot/features/profile/profile_widgets.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/call_log.dart'; import 'package:sepot/models/message.dart'; import 'package:sepot/providers/contacts_provider.dart'; +import 'package:sepot/providers/drafts_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/messaging_provider.dart'; import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/widgets/avatar.dart'; import 'package:sepot/widgets/e2e_badge.dart'; +import 'package:sepot/widgets/glass.dart'; +import 'package:sepot/widgets/message_status_icon.dart'; -enum _AttachAction { gallery, camera, document } +enum _AttachAction { gallery, camera, document, poll } /// Chat obrazovka — zprávy s kontaktem class ConversationScreen extends ConsumerStatefulWidget { final String contactId; - const ConversationScreen({super.key, required this.contactId}); + const ConversationScreen({ + super.key, + required this.contactId, + this.startSearch = false, + }); + + /// Otevřít rovnou v režimu hledání (akce „Hledat“ z profilu). + final bool startSearch; @override ConsumerState createState() => _ConversationScreenState(); @@ -45,10 +67,29 @@ class ConversationScreen extends ConsumerStatefulWidget { class _ConversationScreenState extends ConsumerState { final _messageController = TextEditingController(); final _scrollController = ScrollController(); + final _searchController = TextEditingController(); bool _isSending = false; Message? _replyingTo; // aktivní odpověď (nad inputem) String? _editingId; // id právě upravované zprávy + /// Poznámky pro sebe: nic nejde do sítě, takže ani „píše…“, reakce, + /// ankety, fajfky nebo smazání pro všechny. + bool get _isNotes => ConversationsNotifier.isNotes(widget.contactId); + + // @zmínky ve skupině: kdo byl vložen z nabídky (fingerprint → jméno) + // a rozepsaný dotaz za „@“ před kurzorem (null = nabídka zavřená). + final _mentions = {}; + int? _mentionStart; + String _mentionQuery = ''; + static const _mentionSuggestionLimit = 5; + + // Rozepsaná zpráva: ukládá se s krátkým zpožděním, aby přežila i zabití + // appky. Při úpravě staré zprávy se koncept odloží a pak vrátí. + late final DraftsNotifier _drafts; + Timer? _draftTimer; + String? _draftBeforeEdit; + static const _draftSaveDelay = Duration(milliseconds: 800); + // Nahrávání hlasové zprávy final _recorder = AudioRecorder(); bool _isRecording = false; @@ -56,27 +97,116 @@ class _ConversationScreenState extends ConsumerState { Timer? _recordTimer; String? _recordPath; + // Hledání v konverzaci: shody od nejnovější, _matchIndex = aktuální. + bool _searching = false; + List _matches = const []; + int _matchIndex = 0; + + // Řádek „N nových zpráv“ — zachycený PŘED označením za přečtené. + String? _firstUnreadId; + int _unreadAtOpen = 0; + + // Tlačítko „dolů“: počet zpráv, které přišly, když jsem byl výš. + bool _showJump = false; + int _messageCount = 0; + int _awayFrom = 0; + + // Klíče bublin pro skok na zprávu (citace, hledání) a krátké zvýraznění. + final _keys = {}; + String? _flashId; + Timer? _flashTimer; + /// Malá sada emoji pro rychlé reakce. static const _quickReactions = ['👍', '❤️', '😂', '😮', '😢', '🙏']; + /// Vzdálenost od konce, od které se ukáže „dolů“. + static const _jumpThreshold = 320.0; + + /// Zprávy od stejného člověka do téhle pauzy tvoří jeden blok. + static const _clusterGap = Duration(minutes: 3); + @override void initState() { super.initState(); - // Označ zprávy jako přečtené - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(conversationsProvider.notifier).markAsRead(widget.contactId); + _scrollController.addListener(_onScroll); + _searching = widget.startSearch; + _drafts = ref.read(draftsProvider.notifier); + _restoreDraft(); + _messageController.addListener(_scheduleDraftSave); + WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark()); + } + + /// Zapamatuje si první nepřečtenou zprávu a teprve pak konverzaci označí + /// za přečtenou — jinak by řádek „nové zprávy“ nikdy nebylo kam dát. + Future _captureUnreadAndMark() async { + final myId = ref.read(identityProvider).valueOrNull?.id ?? ''; + final conv = + await ref.read(storageProvider).loadConversation(widget.contactId); + final unread = + conv?.messages.where((m) => !m.isRead && !m.isMine(myId)).toList() ?? + const []; + if (mounted && unread.isNotEmpty) { + setState(() { + _firstUnreadId = unread.first.id; + _unreadAtOpen = unread.length; + }); + } + await ref.read(conversationsProvider.notifier).markAsRead(widget.contactId); + } + + void _onScroll() { + if (!_scrollController.hasClients) return; + final away = _scrollController.offset > _jumpThreshold; + if (away == _showJump) return; + setState(() { + _showJump = away; + _awayFrom = _messageCount; }); } + Future _restoreDraft() async { + final draft = (await ref.read(draftsProvider.future))[widget.contactId]; + if (!mounted || draft == null || _messageController.text.isNotEmpty) { + return; + } + _messageController.text = draft; + } + + void _scheduleDraftSave() { + if (_editingId != null) return; + _draftTimer?.cancel(); + _draftTimer = Timer(_draftSaveDelay, _saveDraft); + } + + void _saveDraft() { + if (_editingId != null) return; + _drafts.set(widget.contactId, _messageController.text); + } + @override void dispose() { + _draftTimer?.cancel(); + // Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text + // upravované zprávy. + _drafts.set(widget.contactId, + _editingId != null ? (_draftBeforeEdit ?? '') : _messageController.text); _messageController.dispose(); _scrollController.dispose(); + _searchController.dispose(); _recordTimer?.cancel(); + _flashTimer?.cancel(); _recorder.dispose(); super.dispose(); } + void _goBack() { + if (context.canPop()) { + context.pop(); + } else { + context.go('/chats'); + } + } + @override Widget build(BuildContext context) { final contacts = ref.watch(contactsProvider).valueOrNull ?? []; @@ -88,199 +218,10 @@ class _ConversationScreenState extends ConsumerState { // 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( - 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 conversationAsync, - LocalIdentity? identity, { - required List 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) { + if (fingerprint == identity?.id) return context.l10n.groupsYou; final jmeno = contacts .where((c) => c.publicIdentity.id == fingerprint) .firstOrNull @@ -291,405 +232,803 @@ class _ConversationScreenState extends ConsumerState { 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, + final Widget bottom; + if (group?.left == true) { + bottom = _buildBanner(context.l10n.groupsLeftBanner); + } else if (contact?.blocked == true) { + bottom = _buildBanner( + context.l10n.chatBlockedBanner, + action: TextButton( + onPressed: () => setContactBlocked(context, ref, contact!, false), + child: Text(context.l10n.chatUnblock), + ), + ); + } else { + bottom = _buildComposer(); + } + + _updateMatches(conversationAsync.valueOrNull?.messages ?? const []); + final writers = + ref.watch(typingProvider).valueOrNull?[widget.contactId] ?? const {}; + final header = _searching + ? _buildSearchHeader() + : _isNotes + ? _buildNotesHeader() + : group != null + ? _buildGroupHeader(group, autorJmeno, writers) + : _buildContactHeader(contact, typing: writers.isNotEmpty); + + return AnnotatedRegion( + value: SystemUiOverlayStyle.light, + child: Scaffold( + backgroundColor: SepotColors.background, + body: ChatWallpaper( + child: Column( + children: [ + Expanded( + child: Stack( + children: [ + Positioned.fill( + child: conversationAsync.when( + data: (conversation) => _buildMessageList( + conversation?.messages ?? const [], + identity, + group: group, + autorJmeno: autorJmeno, + typing: writers.isNotEmpty, + ), + loading: () => const Center( + child: CircularProgressIndicator( + color: SepotColors.ember), + ), + error: (e, _) => + Center(child: Text(describeError(context.l10n, e))), ), ), - ), - ], - ), - ), - ], - ), - actions: [ - const E2EBadge(compact: true), - const SizedBox(width: 8), - PopupMenuButton( - 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)), + Positioned(top: 0, left: 0, right: 0, child: header), + if (_showJump) + Positioned( + right: 14, + bottom: 10, + child: _buildJumpButton(conversationAsync.valueOrNull, + identity?.id ?? ''), + ), + ], ), - ], + ), + bottom, ], ), - ], + ), ), - 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; + // ── Hlavička ────────────────────────────────────────────────────────── - // 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); + Widget _buildHeaderFrame({required List children}) { + return GlassSurface( + border: false, + child: DecoratedBox( + decoration: const BoxDecoration( + border: Border(bottom: BorderSide(color: SepotColors.glassBorder)), + ), + child: Padding( + padding: EdgeInsets.fromLTRB( + 4, MediaQuery.paddingOf(context).top + 6, 4, 8), + child: SizedBox(height: 50, child: Row(children: children)), + ), + ), + ); + } - 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))), + Widget _backButton() => IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back_rounded), + onPressed: _goBack, + ); + + Widget _searchButton() => IconButton( + tooltip: context.l10n.chatSearchHint, + icon: const Icon(Icons.search_rounded, color: SepotColors.textSecondary), + onPressed: () => setState(() { + _searching = true; + _searchController.clear(); + _matches = const []; + _matchIndex = 0; + }), + ); + + /// Klepnutí na jméno/avatar otevře profil (jako v ostatních messengerech). + Widget _headerIdentity({ + required Widget avatar, + required String title, + required Widget subtitle, + required VoidCallback onTap, + }) { + return Expanded( + child: Semantics( + button: true, + label: context.l10n.chatOpenProfile, + child: InkWell( + borderRadius: BorderRadius.circular(14), + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + children: [ + avatar, + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text( + title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700), + ), + const SizedBox(height: 1), + subtitle, + ], + ), + ), + ], ), ), - if (group.left) _buildLeftGroupBanner() else _buildInputBar(), + ), + ), + ); + } + + /// Podtitulek „píše…“ — přebíjí stav ověření, dokud protistrana píše. + Widget _typingLabel(String text) => Text( + text, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SepotColors.ember), + ); + + void _startCall(Contact contact, {required bool video}) { + final call = ref.read(callProvider); + if (call.busy) { + context.push('/call'); + return; + } + ref.read(callProvider.notifier).startCall(contact, video: video); + } + + Widget _buildNotesHeader() { + return _buildHeaderFrame(children: [ + _backButton(), + _headerIdentity( + avatar: const NotesAvatar(size: 40), + title: context.l10n.notesTitle, + subtitle: Text( + context.l10n.notesSubtitle, + style: const TextStyle( + fontSize: 12, color: SepotColors.textSecondary), + ), + onTap: () {}, + ), + _searchButton(), + ]); + } + + Widget _buildContactHeader(Contact? contact, {bool typing = false}) { + final name = (contact == null || contact.displayName.isEmpty) + ? context.l10n.commonUnknownContact + : contact.displayName; + final verified = contact?.verified ?? false; + return _buildHeaderFrame(children: [ + _backButton(), + _headerIdentity( + avatar: SepotAvatar( + name: name, seed: widget.contactId, size: 40, verified: verified), + title: name, + // Stav ověření místo syrového ID (UX-FLOWS §3.1) + subtitle: typing + ? _typingLabel(context.l10n.chatTyping) + : Text( + verified + ? context.l10n.verifyVerified + : context.l10n.verifyUnverified, + style: TextStyle( + fontSize: 12, + color: + verified ? SepotColors.sage : SepotColors.textSecondary, + ), + ), + onTap: () => _openProfile('/contact/${widget.contactId}'), + ), + if (contact != null && !contact.blocked) ...[ + IconButton( + tooltip: context.l10n.callVoice, + icon: const Icon(Icons.call_outlined, + color: SepotColors.textSecondary), + onPressed: () => _startCall(contact, video: false), + ), + IconButton( + tooltip: context.l10n.callVideo, + icon: const Icon(Icons.videocam_outlined, + color: SepotColors.textSecondary), + onPressed: () => _startCall(contact, video: true), + ), + ], + _searchButton(), + if (contact != null) + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: SepotColors.textSecondary), + onSelected: (value) { + switch (value) { + case 'profile': + _openProfile('/contact/${widget.contactId}'); + case 'disappearing': + configureDisappearing(context, ref, widget.contactId); + case 'block': + setContactBlocked(context, ref, contact, true); + case 'unblock': + setContactBlocked(context, ref, contact, false); + case 'report': + reportAndBlock(context, ref, contact); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'profile', child: Text(context.l10n.chatOpenProfile)), + 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)), + ), + ], + ], + ), + ]); + } + + /// Skupinová hlavička: jména členů místo stavu + /// ověření — ověřují se jednotliví členové, skupina safety number nemá. + Widget _buildGroupHeader(Group group, String Function(String) autorJmeno, + Set writers) { + final myId = ref.read(identityProvider).valueOrNull?.id; + final jmena = [ + if (group.memberIds.contains(myId)) context.l10n.groupsYou, + for (final f in group.memberIds) + if (f != myId) autorJmeno(f).split(' ').first, + ].join(', '); + return _buildHeaderFrame(children: [ + _backButton(), + _headerIdentity( + avatar: SepotGroupAvatar(seed: group.id, size: 40), + title: group.name, + subtitle: writers.isNotEmpty + ? _typingLabel(writers.length == 1 + ? context.l10n.chatTypingNamed( + autorJmeno(writers.first).split(' ').first) + : context.l10n.chatTypingSeveral) + : Text( + group.left ? context.l10n.groupsYouLeftGroup : jmena, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, color: SepotColors.textSecondary), + ), + onTap: () => _openProfile('/group/${group.id}'), + ), + _searchButton(), + PopupMenuButton( + icon: const Icon(Icons.more_vert, color: SepotColors.textSecondary), + onSelected: (value) async { + switch (value) { + case 'profile': + _openProfile('/group/${group.id}'); + case 'add': + context.push('/group/${group.id}/add'); + case 'disappearing': + configureDisappearing(context, ref, widget.contactId); + case 'leave': + if (await leaveGroup(context, ref, group) && mounted) _goBack(); + } + }, + itemBuilder: (context) => [ + PopupMenuItem( + value: 'profile', child: Text(context.l10n.groupsProfile)), + if (!group.left) ...[ + // Stejné pravidlo jako v profilu skupiny: přidává správce, ostatní + // jen dokud je ve skupině někdo s verzí bez správců. + if (group.isAdmin(myId ?? '') || !group.isEnforced(myId ?? '')) + PopupMenuItem( + value: 'add', child: Text(context.l10n.groupsAddMembers)), + PopupMenuItem( + value: 'disappearing', + child: Text(context.l10n.chatDisappearingMessages)), + const PopupMenuDivider(), + PopupMenuItem( + value: 'leave', + child: Text(context.l10n.groupsLeave, + style: const TextStyle(color: SepotColors.error)), + ), + ], ], ), - ); + ]); } - /// 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), + Widget _buildSearchHeader() { + final total = _matches.length; + final hasQuery = _searchController.text.trim().isNotEmpty; + return _buildHeaderFrame(children: [ + IconButton( + tooltip: context.l10n.commonCancel, + icon: const Icon(Icons.close_rounded), + onPressed: () => setState(() { + _searching = false; + _searchController.clear(); + _matches = const []; + }), ), + Expanded( + child: TextField( + controller: _searchController, + autofocus: true, + textInputAction: TextInputAction.search, + onChanged: (_) => setState(() => _matchIndex = 0), + onSubmitted: (_) { + if (_matches.isNotEmpty) _goToMatch(_matchIndex); + }, + decoration: InputDecoration( + hintText: context.l10n.chatSearchHint, + filled: true, + fillColor: SepotColors.surface, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 11), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(14), + borderSide: + const BorderSide(color: SepotColors.ember, width: 1.5), + ), + ), + ), + ), + if (hasQuery) + Padding( + padding: const EdgeInsets.only(left: 8), + child: Text( + total == 0 + ? context.l10n.chatSearchNoMatches + : context.l10n.chatSearchCounter(_matchIndex + 1, total), + style: + const TextStyle(fontSize: 12, color: SepotColors.textSecondary), + ), + ), + // Shody jsou od nejnovější: „nahoru“ = starší = vyšší index. + IconButton( + tooltip: context.l10n.chatSearchOlder, + icon: const Icon(Icons.keyboard_arrow_up_rounded), + onPressed: _matchIndex < total - 1 + ? () => _goToMatch(_matchIndex + 1) + : null, + ), + IconButton( + tooltip: context.l10n.chatSearchNewer, + icon: const Icon(Icons.keyboard_arrow_down_rounded), + onPressed: _matchIndex > 0 ? () => _goToMatch(_matchIndex - 1) : null, + ), + ]); + } + + /// Profil otevřený z chatu se „Zprávou“ jen vrací; „Hledat“ vrátí + /// požadavek zapnout tady hledání. + Future _openProfile(String path) async { + final result = await context.push('$path?from=chat'); + if (result == ProfileResult.openSearch && mounted) { + setState(() { + _searching = true; + _searchController.clear(); + _matchIndex = 0; + }); + } + } + + void _goToMatch(int index) { + if (index < 0 || index >= _matches.length) return; + setState(() => _matchIndex = index); + _scrollToMessage(_matches[index]); + } + + /// Doskroluje k zprávě a krátce ji zvýrazní. Seznam staví bubliny líně, + /// takže vzdálenou zprávu nejdřív „najde“ postupným posunem nahoru; + /// když dojde na začátek, zkusí to ještě jednou odspodu (cíl byl níž). + Future _scrollToMessage(String id) async { + for (var pass = 0; pass < 2; pass++) { + for (var step = 0; step < 120; step++) { + final ctx = _keys[id]?.currentContext; + if (ctx != null && ctx.mounted) { + await Scrollable.ensureVisible( + ctx, + alignment: 0.5, + duration: const Duration(milliseconds: 260), + curve: Curves.easeOutCubic, + ); + _flash(id); + return; + } + if (!_scrollController.hasClients) return; + final pos = _scrollController.position; + if (pos.pixels >= pos.maxScrollExtent) break; + _scrollController.jumpTo(math.min( + pos.pixels + pos.viewportDimension * 0.9, pos.maxScrollExtent)); + await WidgetsBinding.instance.endOfFrame; + } + if (!_scrollController.hasClients) return; + _scrollController.jumpTo(0); + await WidgetsBinding.instance.endOfFrame; + } + } + + void _flash(String id) { + _flashTimer?.cancel(); + setState(() => _flashId = id); + _flashTimer = Timer(const Duration(milliseconds: 1400), () { + if (mounted) setState(() => _flashId = null); + }); + } + + // ── Seznam zpráv ────────────────────────────────────────────────────── + + /// Shody hledání (text i popisek u fotky), od nejnovější. Počítá se + /// PŘED stavbou hlavičky, jinak by počítadlo ukazovalo předchozí dotaz. + void _updateMatches(List messages) { + final query = _searching ? _searchController.text.trim() : ''; + if (query.isEmpty) { + _matches = const []; + return; + } + final q = fold(query); + _matches = [ + for (final m in messages.reversed) + if (!m.isDeleted && m.content != null && fold(m.content!).contains(q)) + m.id, + ]; + if (_matchIndex >= _matches.length) _matchIndex = 0; + } + + bool _joins(Message? a, Message b) => + a != null && + a.senderId == b.senderId && + a.isDeleted == b.isDeleted && + _isSameDay(a.timestamp, b.timestamp) && + a.timestamp.difference(b.timestamp).abs() < _clusterGap; + + Widget _buildMessageList( + List messages, + LocalIdentity? identity, { + required Group? group, + required String Function(String) autorJmeno, + bool typing = false, + }) { + _messageCount = messages.length; + final myId = identity?.id ?? ''; + final top = MediaQuery.paddingOf(context).top + 72; + + final query = _searching ? _searchController.text.trim() : ''; + final currentMatch = _matches.isEmpty ? null : _matches[_matchIndex]; + + if (messages.isEmpty) { + return Padding(padding: EdgeInsets.only(top: top), child: _buildEmptyChat()); + } + + final lastMineId = + messages.lastWhere((m) => m.isMine(myId), orElse: () => messages.first).id; + + return ListView.builder( + controller: _scrollController, + padding: EdgeInsets.fromLTRB(12, 8, 12, top), + reverse: true, + itemCount: messages.length + (typing ? 1 : 0), + itemBuilder: (context, rawIndex) { + // Seznam je obrácený: položka 0 je dole — tam patří „píše…“. + if (typing && rawIndex == 0) return const _TypingBubble(); + final index = typing ? rawIndex - 1 : rawIndex; + final n = messages.length; + final msg = messages[n - 1 - index]; + final older = index == n - 1 ? null : messages[n - 2 - index]; + final newer = index == 0 ? null : messages[n - index]; + final isMine = msg.isMine(myId); + final showDate = + older == null || !_isSameDay(msg.timestamp, older.timestamp); + final first = showDate || !_joins(older, msg); + final last = newer == null || + !_isSameDay(msg.timestamp, newer.timestamp) || + !_joins(newer, msg); + final repliedTo = msg.replyToId == null + ? null + : messages.where((m) => m.id == msg.replyToId).firstOrNull; + + final callLog = msg.callLog; + if (callLog != null) { + return KeyedSubtree( + key: _keys.putIfAbsent(msg.id, GlobalKey.new), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDate) _buildDateDivider(msg.timestamp), + _buildCallLogRow(msg, callLog, isMine), + ], + ), + ); + } + + Widget bubble = _MessageBubble( + message: msg, + isMine: isMine, + contactId: widget.contactId, + repliedTo: repliedTo, + clusterFirst: first, + clusterLast: last, + query: query, + highlighted: msg.id == currentMatch || msg.id == _flashId, + onLongPress: () => _showMessageActions(msg, isMine), + showStatus: !_isNotes, + onToggleReaction: (emoji) => _toggleReaction(msg, emoji), + onTapQuote: repliedTo == null + ? null + : () => _scrollToMessage(repliedTo.id), + repliedToAuthor: + repliedTo == null ? null : autorJmeno(repliedTo.senderId), + myId: myId, + nameOf: autorJmeno, + onVote: group?.left == true ? null : (c) => _vote(msg, c), + ); + if (!msg.isDeleted) { + bubble = _SwipeToReply( + onReply: () => setState(() { + _replyingTo = msg; + _editingId = null; + }), + child: bubble, + ); + } + + // Ve skupině má cizí blok avatar autora u poslední bubliny. + final groupIncoming = group != null && !isMine; + if (groupIncoming) { + bubble = Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox( + width: 34, + child: last + ? SepotAvatar( + name: autorJmeno(msg.senderId), + seed: msg.senderId, + size: 28) + : null, + ), + Expanded(child: bubble), + ], + ); + } + + return KeyedSubtree( + key: _keys.putIfAbsent(msg.id, GlobalKey.new), + child: Padding( + padding: EdgeInsets.only(top: first ? 8 : 1), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (showDate) _buildDateDivider(msg.timestamp), + if (msg.id == _firstUnreadId) _buildUnreadDivider(), + if (groupIncoming && first) + Padding( + padding: const EdgeInsets.only(left: 46, bottom: 3), + child: Text( + autorJmeno(msg.senderId), + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: SepotTint.of(msg.senderId).ink, + ), + ), + ), + bubble, + if (group == null && + !_isNotes && + isMine && + msg.id == lastMineId) + _buildReceiptCaption(msg), + ], + ), + ), + ); + }, ); } - void _showGroupMembers(Group group, String Function(String) autorJmeno) { - final identity = ref.read(identityProvider).valueOrNull; - showModalBottomSheet( - 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, + /// Záznam hovoru: na straně, kdo volal, s časem; klepnutí volá zpět. + Widget _buildCallLogRow(Message msg, CallLog log, bool isMine) { + final label = callLogLabel(context.l10n, log); + final contact = ref + .read(contactsProvider) + .valueOrNull + ?.where((c) => c.publicIdentity.id == widget.contactId) + .firstOrNull; + final color = label.alert ? SepotColors.error : SepotColors.textSecondary; + return Align( + alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + child: Material( + color: SepotColors.surfaceVariant, + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: contact == null || contact.blocked + ? null + : () => _startCall(contact, video: log.video), + child: Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row( + mainAxisSize: MainAxisSize.min, 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'); - }, - ), + Icon(label.icon, size: 18, color: color), + const SizedBox(width: 8), + Text(label.text, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: label.alert + ? SepotColors.error + : SepotColors.textPrimary)), + const SizedBox(width: 10), + Text( + DateFormat.Hm(Localizations.localeOf(context).toString()) + .format(msg.timestamp), + style: const TextStyle( + fontSize: 11, color: SepotColors.textMuted), + ), ], ), ), - const SizedBox(height: 8), + ), + ), + ), + ); + } + + Widget _buildUnreadDivider() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: Row( + children: [ + Expanded( + child: Container( + height: 1, color: SepotColors.ember.withValues(alpha: 0.45))), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + child: Text( + context.l10n.chatUnreadDivider(_unreadAtOpen), + style: const TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: SepotColors.ember), + ), + ), + Expanded( + child: Container( + height: 1, color: SepotColors.ember.withValues(alpha: 0.45))), + ], + ), + ); + } + + Widget _buildJumpButton(Conversation? conv, String myId) { + final fresh = conv == null + ? 0 + : conv.messages.skip(_awayFrom).where((m) => !m.isMine(myId)).length; + return Semantics( + button: true, + label: context.l10n.chatJumpToLatest, + child: GestureDetector( + onTap: () { + HapticFeedback.selectionClick(); + _scrollController.animateTo(0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutCubic); + }, + child: Stack( + clipBehavior: Clip.none, + children: [ + GlassSurface( + borderRadius: BorderRadius.circular(24), + child: const SizedBox( + width: 48, + height: 48, + child: Icon(Icons.keyboard_arrow_down_rounded, size: 26), + ), + ), + if (fresh > 0) + Positioned( + top: -6, + right: -4, + child: Container( + constraints: const BoxConstraints(minWidth: 20), + height: 20, + padding: const EdgeInsets.symmetric(horizontal: 5), + alignment: Alignment.center, + decoration: BoxDecoration( + color: SepotColors.ember, + borderRadius: BorderRadius.circular(10), + ), + child: Text( + '$fresh', + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: SepotColors.emberInk), + ), + ), + ), ], ), ), ); } - Future _renameGroup(Group group) async { - final controller = TextEditingController(text: group.name); - final novy = await showDialog( - 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 _leaveGroup(Group group) async { - final potvrzeno = await showDialog( - 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, + /// Plovoucí lišta místo pole pro psaní (blokovaný kontakt, opuštěná + /// skupina). + Widget _buildBanner(String text, {Widget? action}) { + return SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(10, 6, 10, 10), + child: GlassSurface( + borderRadius: BorderRadius.circular(20), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 10), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( - context.l10n.chatBlockedBanner, + text, 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), + fontSize: 13, color: SepotColors.textSecondary), ), + ?action, ], ), ), ); } - Future _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 _reportAndBlock(Contact contact) async { - final jmeno = contact.displayName.isEmpty - ? context.l10n.commonUnknownContact - : contact.displayName; - final ok = await showDialog( - 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 = { - 'contact': contact.id, - 'name': contact.displayName, - }; - context.push(Uri(path: '/report', queryParameters: q).toString()); - } - Widget _buildEmptyChat() { return Center( child: Column( @@ -700,19 +1039,13 @@ class _ConversationScreenState extends ConsumerState { Text( context.l10n.chatEmptyEncrypted, style: const TextStyle( - fontFamily: kFontSans, - fontSize: 14, - color: SepotColors.textSecondary, - ), + fontSize: 14, color: SepotColors.textSecondary), ), const SizedBox(height: 4), Text( context.l10n.chatEmptyNoOneElse, style: const TextStyle( - fontFamily: kFontSans, - fontSize: 14, - color: SepotColors.textSecondary, - ), + fontSize: 14, color: SepotColors.textSecondary), ), ], ), @@ -733,19 +1066,17 @@ class _ConversationScreenState extends ConsumerState { } return Padding( - padding: const EdgeInsets.symmetric(vertical: 16), + padding: const EdgeInsets.symmetric(vertical: 12), child: Center( - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: SepotColors.surfaceVariant, - borderRadius: BorderRadius.circular(12), - ), + child: GlassSurface( + borderRadius: BorderRadius.circular(14), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), child: Text( text, style: const TextStyle( fontSize: 12, - color: SepotColors.textMuted, + fontWeight: FontWeight.w600, + color: SepotColors.textEmphasis, ), ), ), @@ -753,88 +1084,65 @@ class _ConversationScreenState extends ConsumerState { ); } - 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, + // ── Pole pro psaní ──────────────────────────────────────────────────── + + /// Najde „@dotaz“ těsně před kurzorem — jen na začátku slova, aby + /// e-mailová adresa nabídku neotevírala. + void _updateMentionQuery() { + final group = ref.read(groupProvider(widget.contactId)).valueOrNull; + final sel = _messageController.selection; + final text = _messageController.text; + int? start; + if (group != null && sel.isCollapsed && sel.baseOffset > 0) { + final before = text.substring(0, sel.baseOffset); + final at = before.lastIndexOf('@'); + final query = at < 0 ? '' : before.substring(at + 1); + final wordStart = at == 0 || (at > 0 && before[at - 1].trim().isEmpty); + if (at >= 0 && wordStart && !query.contains('\n') && query.length <= 30) { + start = at; + _mentionQuery = query; + } + } + _mentionStart = start; + } + + Widget _buildMentionSuggestions() { + final group = ref.read(groupProvider(widget.contactId)).valueOrNull; + final myId = ref.read(identityProvider).valueOrNull?.id; + final contacts = ref.read(contactsProvider).valueOrNull ?? const []; + if (group == null) return const SizedBox.shrink(); + final q = fold(_mentionQuery); + final candidates = <(String, String)>[ + for (final fp in group.memberIds) + if (fp != myId) + ( + fp, + contacts + .where((c) => c.publicIdentity.id == fp) + .firstOrNull + ?.displayName ?? + '' + ), + ] + .where((c) => c.$2.isNotEmpty && fold(c.$2).contains(q)) + .take(_mentionSuggestionLimit) + .toList(); + if (candidates.isEmpty) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: GlassSurface( + borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.symmetric(vertical: 4), 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), - ), - ], - ), + for (final (fp, name) in candidates) + ListTile( + dense: true, + leading: SepotAvatar(name: name, seed: fp, size: 30), + title: Text(name, + style: const TextStyle(color: SepotColors.textPrimary)), + onTap: () => _insertMention(fp, name), ), ], ), @@ -842,6 +1150,179 @@ class _ConversationScreenState extends ConsumerState { ); } + void _insertMention(String fingerprint, String name) { + final start = _mentionStart; + if (start == null) return; + final text = _messageController.text; + final end = _messageController.selection.baseOffset; + final inserted = '@$name '; + _messageController.value = TextEditingValue( + text: text.replaceRange(start, end, inserted), + selection: TextSelection.collapsed(offset: start + inserted.length), + ); + setState(() { + _mentions[fingerprint] = name; + _mentionStart = null; + }); + } + + /// Zmínky, které v odesílaném textu opravdu zůstaly (uživatel mohl + /// vloženou zmínku smazat nebo přepsat). + List _mentionsIn(String text) => [ + for (final e in _mentions.entries) + if (text.contains('@${e.value}')) e.key, + ]; + + Widget _buildComposer() { + final hasText = + _messageController.text.trim().isNotEmpty || _editingId != null; + return SafeArea( + top: false, + minimum: const EdgeInsets.fromLTRB(10, 6, 10, 10), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_mentionStart != null) _buildMentionSuggestions(), + if (_replyingTo != null || _editingId != null) + Padding( + padding: const EdgeInsets.only(bottom: 6), + child: GlassSurface( + borderRadius: BorderRadius.circular(16), + padding: const EdgeInsets.fromLTRB(12, 8, 4, 8), + child: _buildComposeBanner(), + ), + ), + if (_isRecording) + GlassSurface( + borderRadius: BorderRadius.circular(27), + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), + child: _buildRecordingBar(), + ) + else + Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Expanded( + child: GlassSurface( + borderRadius: BorderRadius.circular(27), + child: ConstrainedBox( + constraints: const BoxConstraints(minHeight: 54), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + IconButton( + tooltip: context.l10n.chatAttach, + icon: const Icon(Icons.add_rounded, + size: 24, color: SepotColors.textSecondary), + onPressed: (_isSending || _editingId != null) + ? null + : () => _pickAndSendAttachment(), + ), + Expanded( + // `filled: false` PŘEBÍJÍ globální + // InputDecorationTheme (jinak vnořené pole). + child: TextField( + controller: _messageController, + onChanged: (text) { + _updateMentionQuery(); + setState(() {}); + if (text.trim().isNotEmpty && + _editingId == null && + !_isNotes) { + ref + .read(messagingServiceProvider) + .sendTyping(widget.contactId); + } + }, + decoration: InputDecoration( + hintText: context.l10n.chatMessageHint, + filled: false, + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: + const EdgeInsets.symmetric(vertical: 10), + ), + style: const TextStyle(fontSize: 16), + maxLines: 5, + minLines: 1, + textCapitalization: + TextCapitalization.sentences, + ), + ), + if (!hasText) + IconButton( + tooltip: context.l10n.attachmentTakePhoto, + icon: const Icon(Icons.photo_camera_outlined, + size: 22, color: SepotColors.textSecondary), + onPressed: _isSending + ? null + : () => _pickAndSendAttachment( + preset: _AttachAction.camera), + ) + else + const SizedBox(width: 12), + ], + ), + ), + ), + ), + const SizedBox(width: 8), + _buildSendButton(hasText), + ], + ), + ], + ), + ); + } + + Widget _buildSendButton(bool hasText) { + final icon = hasText + ? (_editingId != null ? Icons.check_rounded : Icons.arrow_upward_rounded) + : Icons.mic_rounded; + final label = hasText + ? (_editingId != null ? context.l10n.commonSave : context.l10n.chatSend) + : context.l10n.chatRecordVoice; + return Semantics( + button: true, + label: label, + child: Material( + color: SepotColors.ember, + shape: const CircleBorder(), + elevation: 0, + child: InkWell( + customBorder: const CircleBorder(), + onTap: _isSending + ? null + : () { + HapticFeedback.lightImpact(); + hasText ? _sendMessage() : _startRecording(); + }, + child: SizedBox( + width: 54, + height: 54, + child: _isSending + ? const Padding( + padding: EdgeInsets.all(17), + child: CircularProgressIndicator( + strokeWidth: 2, color: SepotColors.emberInk), + ) + : AnimatedSwitcher( + duration: const Duration(milliseconds: 160), + transitionBuilder: (child, anim) => + ScaleTransition(scale: anim, child: child), + child: Icon(icon, + key: ValueKey(icon), + size: 24, + color: SepotColors.emberInk), + ), + ), + ), + ), + ); + } + String _fmtRec(Duration d) { final m = d.inMinutes.remainder(60).toString(); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); @@ -870,13 +1351,16 @@ class _ConversationScreenState extends ConsumerState { fontFeatures: [FontFeature.tabularFigures()])), const SizedBox(width: 12), Text(context.l10n.chatRecording, - style: const TextStyle(color: SepotColors.textMuted, fontSize: 13)), + style: const TextStyle( + color: SepotColors.textSecondary, fontSize: 13)), const Spacer(), - Container( - decoration: const BoxDecoration( - shape: BoxShape.circle, color: SepotColors.ember), + Material( + color: SepotColors.ember, + shape: const CircleBorder(), child: IconButton( - icon: const Icon(Icons.send_rounded, color: Colors.white), + tooltip: context.l10n.chatSend, + icon: const Icon(Icons.arrow_upward_rounded, + color: SepotColors.emberInk), onPressed: _stopAndSend, ), ), @@ -992,10 +1476,17 @@ class _ConversationScreenState extends ConsumerState { ? context.l10n.chatPhotoPreview : context.l10n.chatAttachmentPreview)))); return Padding( - padding: const EdgeInsets.only(bottom: 8), + padding: EdgeInsets.zero, child: Row( children: [ - Container(width: 3, height: 34, color: SepotColors.ember), + Container( + width: 3, + height: 34, + decoration: BoxDecoration( + color: SepotColors.ember, + borderRadius: BorderRadius.circular(2), + ), + ), const SizedBox(width: 10), Expanded( child: Column( @@ -1023,8 +1514,7 @@ class _ConversationScreenState extends ConsumerState { color: SepotColors.textMuted, onPressed: () => setState(() { _replyingTo = null; - _editingId = null; - if (editing) _messageController.clear(); + if (editing) _finishEditing(); }), ), ], @@ -1047,23 +1537,34 @@ class _ConversationScreenState extends ConsumerState { 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)), - ), - ], + if (!_isNotes) ...[ + 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), + ], + if (isMine && !_isNotes) + ListTile( + leading: const Icon(Icons.info_outline_rounded), + title: Text(context.l10n.chatActionInfo), + onTap: () { + Navigator.pop(ctx); + _showMessageInfo(msg); + }, ), - ), - const Divider(height: 1, color: SepotColors.border), ListTile( leading: const Icon(Icons.reply_rounded), title: Text(context.l10n.chatActionReply), @@ -1075,6 +1576,16 @@ class _ConversationScreenState extends ConsumerState { }); }, ), + // Anketa se nepřeposílá: hlasy patří lidem z původního chatu. + if (msg.poll == null) + ListTile( + leading: const Icon(Icons.shortcut_rounded), + title: Text(context.l10n.chatActionForward), + onTap: () { + Navigator.pop(ctx); + forwardMessage(context, ref, msg, fromId: widget.contactId); + }, + ), if (msg.type == MessageType.text && !msg.isDeleted) ListTile( leading: const Icon(Icons.copy_rounded), @@ -1084,13 +1595,16 @@ class _ConversationScreenState extends ConsumerState { Clipboard.setData(ClipboardData(text: msg.content ?? '')); }, ), - if (isMine && msg.type == MessageType.text) + if (isMine && msg.type == MessageType.text && msg.poll == null) ListTile( leading: const Icon(Icons.edit_rounded), title: Text(context.l10n.commonEdit), onTap: () { Navigator.pop(ctx); setState(() { + if (_editingId == null) { + _draftBeforeEdit = _messageController.text; + } _editingId = msg.id; _replyingTo = null; _messageController.text = msg.content ?? ''; @@ -1108,7 +1622,7 @@ class _ConversationScreenState extends ConsumerState { ref.invalidate(conversationProvider(widget.contactId)); }, ), - if (isMine && !msg.isDeleted) + if (isMine && !msg.isDeleted && !_isNotes) ListTile( leading: const Icon(Icons.delete_forever, color: SepotColors.error), @@ -1128,6 +1642,149 @@ class _ConversationScreenState extends ConsumerState { ); } + /// Čas doručení/přečtení: dnes jen hodina, jinak i datum. + String _formatWhen(DateTime t) { + final locale = Localizations.localeOf(context).toString(); + final time = DateFormat.Hm(locale).format(t); + return _isSameDay(t, DateTime.now()) + ? time + : '${DateFormat.MMMd(locale).format(t)} $time'; + } + + /// Drobný řádek pod mou poslední zprávou v 1:1 — kdy ji protistrana + /// dostala / přečetla, bez nutnosti otevírat Info. + Widget _buildReceiptCaption(Message msg) { + final readAt = msg.firstReadAt; + final deliveredAt = msg.firstDeliveredAt; + final text = readAt != null + ? context.l10n.chatStatusReadAt(_formatWhen(readAt)) + : deliveredAt != null + ? context.l10n.chatStatusDeliveredAt(_formatWhen(deliveredAt)) + : null; + if (text == null || msg.isDeleted) return const SizedBox.shrink(); + return Align( + alignment: Alignment.centerRight, + child: Padding( + padding: const EdgeInsets.only(right: 4, top: 1, bottom: 4), + child: Text( + text, + style: TextStyle( + fontSize: 11, + color: readAt != null ? SepotColors.mist : SepotColors.textMuted, + ), + ), + ), + ); + } + + /// Info o mé zprávě: kdy odešla a kdy ji kdo dostal / přečetl. Ve + /// skupině po členech — ✓✓ v bublině tam svítí až za všechny. + Future _showMessageInfo(Message msg) async { + final group = await ref.read(groupProvider(widget.contactId).future); + final contacts = ref.read(contactsProvider).valueOrNull ?? const []; + final myId = ref.read(identityProvider).valueOrNull?.id; + final receiptsOn = await ref + .read(storageProvider) + .getSetting('read_receipts_enabled') ?? + false; + if (!mounted) return; + + final recipients = group != null + ? group.memberIds.where((f) => f != myId).toList() + : [widget.contactId]; + String jmeno(String f) { + final c = contacts.where((c) => c.id == f).firstOrNull; + if (c == null) return 'SEP-${f.substring(0, f.length.clamp(0, 10))}'; + return c.displayName.isEmpty + ? context.l10n.commonUnknownContact + : c.displayName; + } + + Widget radek(IconData icon, String label, DateTime? t, {Color? color}) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Icon(icon, size: 16, color: color ?? SepotColors.textMuted), + const SizedBox(width: 10), + Text(label, + style: const TextStyle( + fontSize: 13, color: SepotColors.textSecondary)), + const Spacer(), + Text( + t == null ? context.l10n.messageInfoPending : _formatWhen(t), + style: TextStyle( + fontSize: 13, + color: t == null + ? SepotColors.textMuted + : SepotColors.textPrimary, + ), + ), + ], + ), + ); + } + + final odeslano = msg.status == MessageStatus.sending || + msg.status == MessageStatus.failed + ? null + : msg.timestamp; + + await showModalBottomSheet( + context: context, + backgroundColor: SepotColors.surface, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: ConstrainedBox( + constraints: + BoxConstraints(maxHeight: MediaQuery.of(ctx).size.height * 0.7), + child: ListView( + shrinkWrap: true, + padding: const EdgeInsets.fromLTRB(20, 16, 20, 16), + children: [ + Text(context.l10n.chatActionInfo, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w600)), + const SizedBox(height: 12), + radek(Icons.north_east_rounded, context.l10n.messageInfoSent, + odeslano), + for (final f in recipients) ...[ + if (group != null) ...[ + const SizedBox(height: 12), + Row( + children: [ + SepotAvatar(name: jmeno(f), size: 24), + const SizedBox(width: 8), + Text(jmeno(f), + style: const TextStyle( + fontSize: 14, fontWeight: FontWeight.w600)), + ], + ), + const SizedBox(height: 4), + ], + radek(Icons.done_all_rounded, + context.l10n.messageInfoDelivered, msg.deliveredTo[f]), + if (receiptsOn) + radek(Icons.visibility_outlined, context.l10n.messageInfoRead, + msg.readBy[f], + color: msg.readBy[f] != null ? SepotColors.mist : null), + ], + if (!receiptsOn) ...[ + const SizedBox(height: 12), + Text(context.l10n.messageInfoReadReceiptsOff, + style: const TextStyle( + fontSize: 12, color: SepotColors.textMuted)), + ], + ], + ), + ), + ), + ); + } + Future _toggleReaction(Message msg, String emoji) async { final mine = ref.read(identityProvider).valueOrNull?.fingerprint.display; final already = mine != null && msg.reactions[mine] == emoji; @@ -1137,77 +1794,8 @@ class _ConversationScreenState extends ConsumerState { 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 _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( - 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 _pickAndSendAttachment() async { - final action = await showModalBottomSheet<_AttachAction>( + Future _pickAndSendAttachment({_AttachAction? preset}) async { + final action = preset ?? await showModalBottomSheet<_AttachAction>( context: context, backgroundColor: SepotColors.surface, shape: const RoundedRectangleBorder( @@ -1234,6 +1822,12 @@ class _ConversationScreenState extends ConsumerState { context.l10n.attachmentDocument, _AttachAction.document ), + if (!_isNotes) + ( + Icons.bar_chart_rounded, + context.l10n.pollAttach, + _AttachAction.poll + ), ]) ListTile( leading: Icon(icon, color: SepotColors.ember), @@ -1246,6 +1840,10 @@ class _ConversationScreenState extends ConsumerState { ), ); if (action == null) return; + if (action == _AttachAction.poll) { + await _createPoll(); + return; + } Uint8List? bytes; var fileName = ''; @@ -1284,6 +1882,8 @@ class _ConversationScreenState extends ConsumerState { fileName = preview.fileName; mime = preview.mime; caption = preview.caption; + case _AttachAction.poll: + return; // obslouženo výš, jen kvůli úplnosti switche case _AttachAction.document: final result = await FilePicker.pickFiles(withData: true); final file = result?.files.firstOrNull; @@ -1295,6 +1895,13 @@ class _ConversationScreenState extends ConsumerState { } return; } + final mimeGuess = + lookupMimeType(file.name, headerBytes: file.bytes); + if (mimeGuess != null && + mimeGuess.startsWith('image/') && + !await ImageProcessor.canStripMetadata(file.bytes!)) { + if (!mounted || !await _confirmOriginalWithMetadata()) return; + } bytes = file.bytes; fileName = file.name; } @@ -1327,11 +1934,70 @@ class _ConversationScreenState extends ConsumerState { } } + Future _createPoll() async { + final poll = await showCreatePollSheet(context); + if (poll == null || !mounted) return; + // Záložní text pro starší verze appky — uvidí otázku i možnosti. + final fallback = [ + context.l10n.pollFallback(poll.question), + for (final o in poll.options) '• $o', + context.l10n.pollFallbackUpdate, + ].join('\n'); + try { + await ref + .read(conversationsProvider.notifier) + .sendPoll(widget.contactId, poll, fallback); + ref.invalidate(conversationProvider(widget.contactId)); + } catch (e) { + if (!mounted) return; + _showError(context.l10n.chatSendFailed(describeError(context.l10n, e))); + } + } + + Future _vote(Message msg, List choice) async { + try { + await ref + .read(conversationsProvider.notifier) + .votePoll(widget.contactId, msg.id, choice); + ref.invalidate(conversationProvider(widget.contactId)); + } catch (e) { + if (!mounted) return; + _showError(context.l10n.chatSendFailed(describeError(context.l10n, e))); + } + } + + Future _confirmOriginalWithMetadata() async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: Text(context.l10n.attachmentKeepsMetadataTitle), + content: Text(context.l10n.attachmentKeepsMetadataBody), + actions: [ + TextButton( + onPressed: () => Navigator.pop(ctx, false), + child: Text(context.l10n.commonCancel)), + TextButton( + onPressed: () => Navigator.pop(ctx, true), + child: Text(context.l10n.attachmentSendOriginal), + ), + ], + ), + ); + return ok == true; + } + void _showError(String text) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text))); } + /// Konec úpravy (odeslání i zrušení) vrátí do pole odložený koncept. + void _finishEditing() { + _editingId = null; + _messageController.text = _draftBeforeEdit ?? ''; + _draftBeforeEdit = null; + } + Future _sendMessage() async { final text = _messageController.text.trim(); if (text.isEmpty) return; @@ -1347,12 +2013,14 @@ class _ConversationScreenState extends ConsumerState { await notifier.editMessage(widget.contactId, editingId, text); } else { await notifier.sendMessage(widget.contactId, text, - replyToId: replyToId); + replyToId: replyToId, mentions: _mentionsIn(text)); } if (mounted) { setState(() { _replyingTo = null; - _editingId = null; + _mentions.clear(); + _mentionStart = null; + if (editingId != null) _finishEditing(); }); } ref.invalidate(conversationProvider(widget.contactId)); @@ -1390,26 +2058,233 @@ class _CompactWidth extends StatelessWidget { compact ? IntrinsicWidth(child: child) : child; } -/// Bublina se zprávou (text, obrázek, dokument) +/// Tři pulzující tečky dole v chatu, dokud protistrana píše. +class _TypingBubble extends StatefulWidget { + const _TypingBubble(); + + @override + State<_TypingBubble> createState() => _TypingBubbleState(); +} + +class _TypingBubbleState extends State<_TypingBubble> + with SingleTickerProviderStateMixin { + late final AnimationController _pulse = AnimationController( + vsync: this, duration: const Duration(milliseconds: 1200)) + ..repeat(); + + @override + void dispose() { + _pulse.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(top: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Semantics( + label: context.l10n.chatTyping, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: const BoxDecoration( + color: SepotColors.bubbleReceived, + borderRadius: BorderRadius.only( + topLeft: Radius.circular(20), + topRight: Radius.circular(20), + bottomRight: Radius.circular(20), + bottomLeft: Radius.circular(6), + ), + ), + child: AnimatedBuilder( + animation: _pulse, + builder: (context, _) => Row( + mainAxisSize: MainAxisSize.min, + children: [ + for (var i = 0; i < 3; i++) ...[ + if (i > 0) const SizedBox(width: 5), + Opacity( + // Vlna: každá tečka o třetinu periody později. + opacity: 0.35 + + 0.65 * + (0.5 + + 0.5 * + math.sin(2 * + math.pi * + (_pulse.value - i / 3))), + child: Container( + width: 7, + height: 7, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: SepotColors.textSecondary, + ), + ), + ), + ], + ], + ), + ), + ), + ), + ), + ); + } +} + +/// Odpověď tahem doprava: bublina se posune, objeví se šipka a po +/// překročení prahu jemně cvakne. Pustit = odpovědět. +class _SwipeToReply extends StatefulWidget { + const _SwipeToReply({required this.onReply, required this.child}); + + final VoidCallback onReply; + final Widget child; + + @override + State<_SwipeToReply> createState() => _SwipeToReplyState(); +} + +class _SwipeToReplyState extends State<_SwipeToReply> + with SingleTickerProviderStateMixin { + static const _max = 84.0; + static const _trigger = 58.0; + + late final AnimationController _back = AnimationController( + vsync: this, duration: const Duration(milliseconds: 180)) + ..addListener(() => setState(() => _dx = _from * (1 - _back.value))); + double _dx = 0; + double _from = 0; + bool _armed = false; + + @override + void dispose() { + _back.dispose(); + super.dispose(); + } + + void _update(DragUpdateDetails d) { + if (_back.isAnimating) _back.stop(); + setState(() => _dx = (_dx + d.delta.dx).clamp(0.0, _max)); + final armed = _dx >= _trigger; + if (armed && !_armed) HapticFeedback.selectionClick(); + _armed = armed; + } + + void _end(DragEndDetails _) { + if (_armed) widget.onReply(); + _armed = false; + _from = _dx; + _back.forward(from: 0); + } + + @override + Widget build(BuildContext context) { + final progress = (_dx / _trigger).clamp(0.0, 1.0); + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragUpdate: _update, + onHorizontalDragEnd: _end, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + if (_dx > 0) + Opacity( + opacity: progress, + child: Transform.scale( + scale: 0.6 + 0.4 * progress, + child: Container( + width: 34, + height: 34, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: SepotColors.ember.withValues(alpha: 0.18), + ), + child: const Icon(Icons.reply_rounded, + size: 19, color: SepotColors.ember), + ), + ), + ), + Transform.translate(offset: Offset(_dx, 0), child: widget.child), + ], + ), + ); + } +} + +/// Bublina se zprávou (text, obrázek, dokument, hlas). class _MessageBubble extends StatelessWidget { final Message message; final bool isMine; final String contactId; final Message? repliedTo; // zpráva, na kterou tahle odpovídá + final String? repliedToAuthor; + + /// Pozice v bloku zpráv od stejného člověka — určuje zaoblení rohů. + final bool clusterFirst; + final bool clusterLast; + + /// Hledaný text ke zvýraznění (prázdný = nic). + final String query; + + /// Aktuální shoda hledání / cíl skoku z citace. + final bool highlighted; final VoidCallback? onLongPress; + final VoidCallback? onTapQuote; final void Function(String emoji)? onToggleReaction; + /// Fajfky doručení — v poznámkách pro sebe nedávají smysl. + final bool showStatus; + + /// Pro anketu: můj otisk, jména hlasujících a hlasování. + final String myId; + final String Function(String fingerprint)? nameOf; + final void Function(List choice)? onVote; + const _MessageBubble({ required this.message, required this.isMine, required this.contactId, this.repliedTo, + this.repliedToAuthor, + this.clusterFirst = true, + this.clusterLast = true, + this.query = '', + this.highlighted = false, this.onLongPress, + this.onTapQuote, this.onToggleReaction, + this.showStatus = true, + this.myId = '', + this.nameOf, + this.onVote, }); /// Ztlumený inkoust pro metadata na ember bublině. - static const _inkSoft = Color(0x8C2B120A); + static const _inkSoft = Color(0x9E2B120A); + + static const _radius = Radius.circular(20); + static const _joined = Radius.circular(6); + + /// Zvýraznění shody hledání (jantar — odlišné od korálu i od textu). + static const _matchColor = SepotColors.warning; + + BorderRadius get _shape { + // Strana „ocásku“ (moje vpravo, cizí vlevo): uvnitř bloku malé rohy, + // navenek velké — blok pak čte jako jeden celek. + final top = clusterFirst ? _radius : _joined; + return isMine + ? BorderRadius.only( + topLeft: _radius, + bottomLeft: _radius, + topRight: top, + bottomRight: _joined) + : BorderRadius.only( + topRight: _radius, + bottomRight: _radius, + topLeft: top, + bottomLeft: _joined); + } @override Widget build(BuildContext context) { @@ -1424,7 +2299,9 @@ class _MessageBubble extends StatelessWidget { } final isImage = hasAttachment && message.type == MessageType.image; final isAudio = hasAttachment && message.attachment!.isAudio; + final poll = message.isDeleted ? null : message.poll; final ink = isMine ? SepotColors.emberInk : SepotColors.textPrimary; + final metaColor = isMine ? _inkSoft : SepotColors.textSecondary; return Align( alignment: isMine ? Alignment.centerRight : Alignment.centerLeft, @@ -1433,121 +2310,129 @@ class _MessageBubble extends StatelessWidget { isMine ? CrossAxisAlignment.end : CrossAxisAlignment.start, children: [ GestureDetector( - onLongPress: message.isDeleted ? null : onLongPress, - child: Container( - margin: const EdgeInsets.symmetric(vertical: 2), + onLongPress: message.isDeleted + ? null + : () { + HapticFeedback.mediumImpact(); + onLongPress?.call(); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 220), padding: isImage ? const EdgeInsets.all(4) - : const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + : const EdgeInsets.symmetric(horizontal: 13, vertical: 9), + // Anketa potřebuje pevnou šířku — pruhy výsledků se měří + // proti ní, podle obsahu by každá anketa byla jinak široká. + width: poll != null + ? MediaQuery.sizeOf(context).width * 0.76 + : null, constraints: BoxConstraints( - maxWidth: MediaQuery.of(context).size.width * 0.75, + maxWidth: MediaQuery.sizeOf(context).width * 0.78, ), 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), - ), + borderRadius: _shape, + boxShadow: highlighted + ? const [BoxShadow(color: _matchColor, spreadRadius: 2)] + : null, ), child: _CompactWidth( - compact: !isImage && !hasAttachment, + compact: !isImage && !hasAttachment && poll == null, 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, - ), + 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) + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: ImageAttachmentContent( + contactId: contactId, message: message), + ) + else if (isAudio) + AudioAttachmentContent( + contactId: contactId, + message: message, + isMine: isMine) + else if (hasAttachment) + DocumentAttachmentContent( + contactId: contactId, message: message), + if (poll != null) + PollContent( + poll: poll, + onEmber: isMine, + myId: myId, + nameOf: nameOf ?? (f) => f, + onVote: onVote, + ) + else if (message.content != null && + message.content!.isNotEmpty) + Padding( + padding: isImage + ? const EdgeInsets.fromLTRB(9, 7, 9, 0) + : hasAttachment + ? const EdgeInsets.only(top: 6, bottom: 2) + : EdgeInsets.zero, + child: Text.rich( + _highlight(message.content!, ink), + style: TextStyle( + fontSize: 15.5, color: ink, height: 1.38), + ), + ), ], - ), - ], - ), + const SizedBox(height: 3), + Padding( + padding: isImage + ? const EdgeInsets.fromLTRB(9, 0, 9, 4) + : EdgeInsets.zero, + child: 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: 11, color: metaColor)), + ), + if (message.isDisappearing) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Icon(Icons.timer_outlined, + size: 12, color: metaColor), + ), + Text( + DateFormat.Hm( + Localizations.localeOf(context).toString()) + .format(message.timestamp), + style: TextStyle(fontSize: 11, color: metaColor), + ), + if (isMine && showStatus && !message.isDeleted) ...[ + const SizedBox(width: 5), + MessageStatusIcon( + status: message.status, onEmber: true), + ], + ], + ), + ), + ], + ), ), ), ), @@ -1557,6 +2442,32 @@ class _MessageBubble extends StatelessWidget { ); } + /// Text s vyznačenými shodami hledání (bez ohledu na diakritiku). + TextSpan _highlight(String text, Color ink) { + if (query.isEmpty) return TextSpan(text: text); + final hits = foldedIndexes(text, query); + if (hits.isEmpty) return TextSpan(text: text); + final len = query.length; + final spans = []; + var pos = 0; + for (final h in hits) { + if (h < pos) continue; + if (h > pos) spans.add(TextSpan(text: text.substring(pos, h))); + final end = math.min(h + len, text.length); + spans.add(TextSpan( + text: text.substring(h, end), + style: const TextStyle( + backgroundColor: _matchColor, + color: SepotColors.background, + fontWeight: FontWeight.w600, + ), + )); + pos = end; + } + if (pos < text.length) spans.add(TextSpan(text: text.substring(pos))); + return TextSpan(children: spans); + } + Widget _buildQuote(BuildContext context, Color ink) { final r = repliedTo!; final preview = r.isDeleted @@ -1568,62 +2479,72 @@ class _MessageBubble extends StatelessWidget { : (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)), + final accent = isMine ? SepotColors.emberInk : SepotTint.of(r.senderId).ink; + return GestureDetector( + onTap: onTapQuote, + child: Container( + margin: const EdgeInsets.only(bottom: 6), + padding: const EdgeInsets.fromLTRB(9, 6, 10, 6), + decoration: BoxDecoration( + color: isMine + ? SepotColors.emberInk.withValues(alpha: 0.13) + : Colors.black.withValues(alpha: 0.22), + borderRadius: BorderRadius.circular(12), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (repliedToAuthor != null) + Text(repliedToAuthor!, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: accent)), + Text(preview, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: + TextStyle(fontSize: 13, color: ink.withValues(alpha: .8))), + ], + ), ), - child: Text(preview, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle(fontSize: 13, color: ink.withValues(alpha: .8))), ); } - /// Seskupené reakce (emoji × počet). + /// Seskupené reakce (emoji × počet) — přesahují spodní hranu bubliny. Widget _buildReactions() { final counts = {}; 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), + return Transform.translate( + offset: const Offset(0, -6), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 10), + 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.border, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: SepotColors.background, width: 2), + ), + 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, - }; diff --git a/app/lib/features/conversation/forward_sheet.dart b/app/lib/features/conversation/forward_sheet.dart new file mode 100644 index 0000000..edb2fc2 --- /dev/null +++ b/app/lib/features/conversation/forward_sheet.dart @@ -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 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), + ); + }, + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/conversation/image_viewer_screen.dart b/app/lib/features/conversation/image_viewer_screen.dart index 41fec32..b15f852 100644 --- a/app/lib/features/conversation/image_viewer_screen.dart +++ b/app/lib/features/conversation/image_viewer_screen.dart @@ -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 images; + final int initialIndex; + + /// Otevře fotku [message] a nabídne listování ostatními z [all]. + static Future open( + BuildContext context, Message message, List 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( + opaque: false, + pageBuilder: (_, _, _) => + ImageViewerScreen(images: images, initialIndex: index), + transitionsBuilder: (_, anim, _, child) => + FadeTransition(opacity: anim, child: child), + ), + ); + } @override ConsumerState createState() => _ImageViewerScreenState(); } class _ImageViewerScreenState extends ConsumerState { - late final Future _decrypted; + late final _pages = PageController(initialPage: widget.initialIndex); + late int _index = widget.initialIndex; + final _decrypted = >{}; + bool _chrome = true; @override - void initState() { - super.initState(); - _decrypted = ref - .read(messagingServiceProvider) - .loadDecryptedAttachment(widget.message); + void dispose() { + _pages.dispose(); + super.dispose(); } + Future _load(Message m) => _decrypted.putIfAbsent( + m.id, + () => ref.read(messagingServiceProvider).loadDecryptedAttachment(m)); + + Message get _current => widget.images[_index]; + Future _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( + 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( - 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 future; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + 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), + ), + ), ), ); } diff --git a/app/lib/features/conversation/widgets/attachment_content.dart b/app/lib/features/conversation/widgets/attachment_content.dart index 41595a9..08ede31 100644 --- a/app/lib/features/conversation/widgets/attachment_content.dart +++ b/app/lib/features/conversation/widgets/attachment_content.dart @@ -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( - 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 { + /// 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 = >[]; bool _loading = false; bool _playing = false; @@ -232,6 +240,7 @@ class _AudioState extends ConsumerState { 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 { super.dispose(); } + Future _loadRate() async { + final saved = + await ref.read(storageProvider).getSetting(rateSettingKey); + if (mounted && saved != null && _rates.contains(saved.toDouble())) { + setState(() => _rate = saved.toDouble()); + } + } + + Future _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 { 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 { 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) diff --git a/app/lib/features/conversation/widgets/create_poll_sheet.dart b/app/lib/features/conversation/widgets/create_poll_sheet.dart new file mode 100644 index 0000000..1710604 --- /dev/null +++ b/app/lib/features/conversation/widgets/create_poll_sheet.dart @@ -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 showCreatePollSheet(BuildContext context) { + return showModalBottomSheet( + 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 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), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/conversation/widgets/poll_content.dart b/app/lib/features/conversation/widgets/poll_content.dart new file mode 100644 index 0000000..8874e8c --- /dev/null +++ b/app/lib/features/conversation/widgets/poll_content.dart @@ -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 choice)? onVote; + + static const _maxFaces = 3; + + void _toggle(int index) { + final mine = poll.votes[myId] ?? const []; + final List 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 []; + 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 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 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), + ), + ), + ], + ), + ); + } +} diff --git a/app/lib/features/groups/create_group_screen.dart b/app/lib/features/groups/create_group_screen.dart index a45c8c8..df4b4c4 100644 --- a/app/lib/features/groups/create_group_screen.dart +++ b/app/lib/features/groups/create_group_screen.dart @@ -10,13 +10,17 @@ import 'package:sepot/providers/contacts_provider.dart'; import 'package:sepot/providers/groups_provider.dart'; import 'package:sepot/widgets/avatar.dart'; -/// Založení skupiny: jméno + výběr členů. +/// Založení skupiny: jméno + výběr členů. S `addToGroupId` +/// jde o přidání lidí do existující skupiny — bez jména, stávající členové +/// v nabídce nejsou a strop počítá s nimi. /// /// Vybrat jde jen kontakty s doručovací adresou (routing) — bez ní jim /// fanout nemá kam doručit. Kontakty bez routingu jsou v seznamu vidět, /// ale zašedlé s vysvětlením. class CreateGroupScreen extends ConsumerStatefulWidget { - const CreateGroupScreen({super.key}); + const CreateGroupScreen({super.key, this.addToGroupId}); + + final String? addToGroupId; @override ConsumerState createState() => _CreateGroupScreenState(); @@ -37,24 +41,32 @@ class _CreateGroupScreenState extends ConsumerState { c.publicIdentity.accountId != null && c.publicIdentity.deliveryAddress != null; - Future _create(List contacts) async { + Future _submit(List contacts, Group? existing) async { final name = _nameController.text.trim(); - if (name.isEmpty || _selected.isEmpty || _creating) return; + if ((existing == null && name.isEmpty) || _selected.isEmpty || _creating) { + return; + } setState(() => _creating = true); + final members = contacts.where((c) => _selected.contains(c.id)).toList(); + final groups = ref.read(groupsProvider.notifier); try { - final members = - contacts.where((c) => _selected.contains(c.id)).toList(); - final group = - await ref.read(groupsProvider.notifier).createGroup(name, members); - if (mounted) context.pushReplacement('/conversation/${group.id}'); + if (existing != null) { + await groups.addMembers(existing, members); + if (mounted) context.pop(); + } else { + final group = await groups.createGroup(name, members); + if (mounted) context.pushReplacement('/conversation/${group.id}'); + } } catch (e) { setState(() => _creating = false); if (mounted) { + final detail = describeError(context.l10n, e); ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(context.l10n - .groupsCreateFailed(describeError(context.l10n, e))), + content: Text(existing != null + ? context.l10n.groupsAddMembersFailed(detail) + : context.l10n.groupsCreateFailed(detail)), ), ); } @@ -63,20 +75,30 @@ class _CreateGroupScreenState extends ConsumerState { @override Widget build(BuildContext context) { + final adding = widget.addToGroupId != null; + final existing = adding + ? ref.watch(groupProvider(widget.addToGroupId!)).valueOrNull + : null; final contactsAsync = ref.watch(contactsProvider); final contacts = (contactsAsync.valueOrNull ?? []) .where((c) => !c.blocked) + .where((c) => existing == null || !existing.memberIds.contains(c.id)) .toList(); - final canCreate = _nameController.text.trim().isNotEmpty && - _selected.isNotEmpty && - !_creating; + // Já jsem v memberIds existující skupiny; nová skupina mě přidá sama. + final capacity = Group.maxMembers - (existing?.memberIds.length ?? 1); + final canSubmit = + (adding ? existing != null : _nameController.text.trim().isNotEmpty) && + _selected.isNotEmpty && + !_creating; return Scaffold( appBar: AppBar( - title: Text(context.l10n.groupsNewGroupTitle), + title: Text(adding + ? context.l10n.groupsAddMembers + : context.l10n.groupsNewGroupTitle), actions: [ TextButton( - onPressed: canCreate ? () => _create(contacts) : null, + onPressed: canSubmit ? () => _submit(contacts, existing) : null, child: _creating ? const SizedBox( width: 18, @@ -84,33 +106,37 @@ class _CreateGroupScreenState extends ConsumerState { child: CircularProgressIndicator( strokeWidth: 2, color: SepotColors.ember), ) - : Text(context.l10n.groupsCreateAction), + : Text(adding + ? context.l10n.groupsAddMembersAction + : context.l10n.groupsCreateAction), ), ], ), body: Column( children: [ - Padding( - padding: const EdgeInsets.all(16), - child: TextField( - controller: _nameController, - maxLength: 64, - decoration: InputDecoration( - labelText: context.l10n.groupsNameHint, - counterText: '', - prefixIcon: const Icon(Icons.group_rounded, - color: SepotColors.textMuted), + if (adding) + const SizedBox(height: 16) + else + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _nameController, + maxLength: 64, + decoration: InputDecoration( + labelText: context.l10n.groupsNameHint, + counterText: '', + prefixIcon: const Icon(Icons.group_rounded, + color: SepotColors.textMuted), + ), + onChanged: (_) => setState(() {}), ), - onChanged: (_) => setState(() {}), ), - ), Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: Row( children: [ Text( - context.l10n.groupsMembersCounter( - _selected.length, Group.maxMembers - 1), + context.l10n.groupsMembersCounter(_selected.length, capacity), style: const TextStyle( color: SepotColors.textSecondary, fontSize: 13), ), @@ -124,7 +150,9 @@ class _CreateGroupScreenState extends ConsumerState { child: Padding( padding: const EdgeInsets.all(32), child: Text( - context.l10n.groupsEmptyContactsHint, + adding + ? context.l10n.groupsNoContactsToAdd + : context.l10n.groupsEmptyContactsHint, textAlign: TextAlign.center, style: const TextStyle( color: SepotColors.textSecondary), @@ -142,8 +170,7 @@ class _CreateGroupScreenState extends ConsumerState { onChanged: routable ? (v) => setState(() { if (v == true) { - if (_selected.length < - Group.maxMembers - 1) { + if (_selected.length < capacity) { _selected.add(c.id); } } else { @@ -151,7 +178,8 @@ class _CreateGroupScreenState extends ConsumerState { } }) : null, - secondary: SepotAvatar(name: c.displayName, size: 40), + secondary: SepotAvatar( + name: c.displayName, seed: c.id, size: 40), title: Text( c.displayName, style: TextStyle( diff --git a/app/lib/features/profile/contact_profile_screen.dart b/app/lib/features/profile/contact_profile_screen.dart new file mode 100644 index 0000000..b389604 --- /dev/null +++ b/app/lib/features/profile/contact_profile_screen.dart @@ -0,0 +1,234 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:sepot/core/l10n/l10n.dart'; +import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/features/conversation/chat_actions.dart'; +import 'package:sepot/features/profile/profile_widgets.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'; + +/// Profil kontaktu: kdo to je, jestli je ověřený, co jsme si poslali, +/// ve kterých skupinách jsme spolu a nastavení konverzace. +class ContactProfileScreen extends ConsumerWidget { + const ContactProfileScreen({ + super.key, + required this.contactId, + this.fromChat = false, + }); + + final String contactId; + + /// Otevřeno z hlavičky chatu — „Zpráva“ se jen vrátí, nic nepřidá + /// do zásobníku obrazovek. + final bool fromChat; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final contact = (ref.watch(contactsProvider).valueOrNull ?? const []) + .where((c) => c.publicIdentity.id == contactId) + .firstOrNull; + final conv = ref.watch(conversationProvider(contactId)).valueOrNull; + final groups = (ref.watch(groupsProvider).valueOrNull ?? const []) + .where((g) => !g.left && g.memberIds.contains(contactId)) + .toList(); + + if (contact == null) { + return Scaffold( + appBar: AppBar(), + body: Center(child: Text(l10n.commonUnknownContact)), + ); + } + + final name = contactLabel(context, contact); + final muted = conv?.isMuted ?? false; + + void openChat({bool search = false}) { + if (fromChat) { + Navigator.of(context).pop(search ? ProfileResult.openSearch : null); + } else { + context.push('/conversation/$contactId${search ? '?search=1' : ''}'); + } + } + + return Scaffold( + body: ListView( + padding: EdgeInsets.zero, + children: [ + ProfileHero( + avatar: Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all( + color: contact.verified + ? SepotColors.sage + : SepotColors.border, + width: 2, + ), + ), + child: SepotAvatar( + name: name, + seed: contactId, + size: 108, + verified: contact.verified, + ), + ), + title: name, + lines: [ + SelectableText( + contact.publicIdentity.fingerprint.short, + style: const TextStyle( + fontFamily: kFontMono, + fontSize: 13, + color: SepotColors.textSecondary, + ), + ), + _VerificationLine( + verified: contact.verified, + onVerify: () => context.push('/verify/$contactId'), + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + ProfileAction( + icon: Icons.chat_bubble_outline_rounded, + label: l10n.profileMessage, + accent: true, + onTap: contact.blocked ? null : openChat, + ), + const SizedBox(width: 8), + ProfileAction( + icon: Icons.search_rounded, + label: l10n.profileSearch, + onTap: () => openChat(search: true), + ), + const SizedBox(width: 8), + ProfileAction( + icon: muted + ? Icons.notifications_active_outlined + : Icons.notifications_off_outlined, + label: muted ? l10n.profileUnmute : l10n.chatListMute, + onTap: conv == null + ? null + : () => ref + .read(conversationsProvider.notifier) + .setMuted(contactId, !muted) + .then((_) => ref + .invalidate(conversationProvider(contactId))), + ), + const SizedBox(width: 8), + ProfileAction( + icon: Icons.timer_outlined, + label: l10n.profileDisappearing, + onTap: () => + configureDisappearing(context, ref, contactId), + ), + ], + ), + const SizedBox(height: 14), + if (conv != null) ...[ + SharedMediaCard(messages: conv.messages), + const SizedBox(height: 14), + ], + if (groups.isNotEmpty) ...[ + ProfileCard( + title: l10n.profileCommonGroups, + trailing: '${groups.length}', + children: [ + for (final g in groups) + ProfileRow( + leading: SepotGroupAvatar(seed: g.id, size: 38), + label: g.name, + subtitle: l10n.groupsMemberCount(g.memberIds.length), + onTap: () => context.push('/conversation/${g.id}'), + ), + ], + ), + const SizedBox(height: 14), + ], + ProfileCard( + children: [ + ProfileRow( + label: l10n.chatDisappearingMessages, + value: + disappearingLabel(context, conv?.disappearAfterSeconds), + onTap: () => + configureDisappearing(context, ref, contactId), + ), + ProfileRow( + label: l10n.profileSafetyNumber, + subtitle: contact.verified + ? l10n.verifyVerified + : l10n.profileSafetyNumberHint, + onTap: () => context.push('/verify/$contactId'), + ), + ], + ), + const SizedBox(height: 14), + ProfileCard( + children: [ + ProfileRow( + label: contact.blocked + ? l10n.chatMenuUnblockContact + : l10n.chatMenuBlockContact, + danger: !contact.blocked, + onTap: () => setContactBlocked( + context, ref, contact, !contact.blocked), + ), + if (!contact.blocked) + ProfileRow( + label: l10n.reportAndBlock, + danger: true, + onTap: () => reportAndBlock(context, ref, contact), + ), + ], + ), + ], + ), + ), + ], + ), + ); + } +} + +class _VerificationLine extends StatelessWidget { + const _VerificationLine({required this.verified, required this.onVerify}); + + final bool verified; + final VoidCallback onVerify; + + @override + Widget build(BuildContext context) { + if (verified) { + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.verified_user_outlined, + size: 16, color: SepotColors.sage), + const SizedBox(width: 6), + Text(context.l10n.profileVerifiedInPerson, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: SepotColors.sage)), + ], + ); + } + return TextButton.icon( + onPressed: onVerify, + icon: const Icon(Icons.shield_outlined, size: 16), + label: Text(context.l10n.profileVerifyNow), + ); + } +} diff --git a/app/lib/features/profile/group_profile_screen.dart b/app/lib/features/profile/group_profile_screen.dart new file mode 100644 index 0000000..8dd7f7e --- /dev/null +++ b/app/lib/features/profile/group_profile_screen.dart @@ -0,0 +1,426 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import 'package:intl/intl.dart'; +import 'package:sepot/core/l10n/l10n.dart'; +import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/features/conversation/chat_actions.dart'; +import 'package:sepot/features/profile/profile_widgets.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/identity_provider.dart'; +import 'package:sepot/widgets/avatar.dart'; + +/// Profil skupiny: členové a jejich ověření, přidání lidí, sdílená média, +/// nastavení konverzace a odchod. +/// +/// Členství, role a jméno mění správci. Ne-správce +/// vidí akce neaktivní a pod členy vysvětlení; přidávat lidi smí i on, dokud +/// je ve skupině někdo se starší verzí (v1 to umí a skupina se nesmí +/// rozejít). +class GroupProfileScreen extends ConsumerWidget { + const GroupProfileScreen({ + super.key, + required this.groupId, + this.fromChat = false, + }); + + final String groupId; + final bool fromChat; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final group = ref.watch(groupProvider(groupId)).valueOrNull; + final conv = ref.watch(conversationProvider(groupId)).valueOrNull; + final contacts = ref.watch(contactsProvider).valueOrNull ?? const []; + final myId = ref.watch(identityProvider).valueOrNull?.id; + + if (group == null) { + return Scaffold( + appBar: AppBar(), + body: const Center( + child: CircularProgressIndicator(color: SepotColors.ember)), + ); + } + + final me = myId ?? ''; + final amAdmin = group.isAdmin(me); + final active = !group.left; + final canAdd = active && (amAdmin || !group.isEnforced(me)); + final canRename = active && amAdmin; + final rejectedAt = group.rejectedOpAt; + final rejectedRecently = rejectedAt != null && + DateTime.now().difference(rejectedAt) < _rejectedNoticeFor; + + final muted = conv?.isMuted ?? false; + final locale = Localizations.localeOf(context).toString(); + + void openChat({bool search = false}) { + if (fromChat) { + Navigator.of(context).pop(search ? ProfileResult.openSearch : null); + } else { + context.push('/conversation/$groupId${search ? '?search=1' : ''}'); + } + } + + // Já první, pak ověření, pak ostatní — kdo je důvěryhodný, je nahoře. + final members = [...group.memberIds]..sort((a, b) { + if (a == myId) return -1; + if (b == myId) return 1; + final va = contacts.any((c) => c.id == a && c.verified) ? 0 : 1; + final vb = contacts.any((c) => c.id == b && c.verified) ? 0 : 1; + return va.compareTo(vb); + }); + + return Scaffold( + body: ListView( + padding: EdgeInsets.zero, + children: [ + ProfileHero( + avatar: SepotGroupAvatar(seed: groupId, size: 104), + title: group.name, + onTitleTap: + canRename ? () => renameGroup(context, ref, group) : null, + lines: [ + Text( + group.left + ? l10n.groupsYouLeftGroup + : l10n.profileGroupSubtitle( + l10n.groupsMemberCount(group.memberIds.length), + DateFormat.yMMMd(locale).format(group.createdAt)), + style: const TextStyle( + fontSize: 13, color: SepotColors.textSecondary), + ), + ], + ), + Padding( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Row( + children: [ + ProfileAction( + icon: Icons.person_add_alt_1_outlined, + label: l10n.profileAdd, + accent: true, + onTap: canAdd + ? () => context.push('/group/$groupId/add') + : null, + ), + const SizedBox(width: 8), + ProfileAction( + icon: Icons.search_rounded, + label: l10n.profileSearch, + onTap: () => openChat(search: true), + ), + const SizedBox(width: 8), + ProfileAction( + icon: muted + ? Icons.notifications_active_outlined + : Icons.notifications_off_outlined, + label: muted ? l10n.profileUnmute : l10n.chatListMute, + onTap: conv == null + ? null + : () => ref + .read(conversationsProvider.notifier) + .setMuted(groupId, !muted) + .then((_) => ref + .invalidate(conversationProvider(groupId))), + ), + const SizedBox(width: 8), + ProfileAction( + icon: Icons.timer_outlined, + label: l10n.profileDisappearing, + onTap: group.left + ? null + : () => configureDisappearing(context, ref, groupId), + ), + ], + ), + const SizedBox(height: 14), + if (active && group.needsResync) ...[ + _GroupNotice( + icon: Icons.sync_rounded, + text: l10n.groupsResyncNotice, + color: SepotColors.mist, + ), + const SizedBox(height: 10), + ], + if (active && rejectedRecently) ...[ + _GroupNotice( + icon: Icons.gpp_maybe_outlined, + text: l10n.groupsRejectedChangeNotice, + color: SepotColors.warning, + ), + const SizedBox(height: 10), + ], + ProfileCard( + title: l10n.groupsMembers, + trailing: l10n.profileMembersOfMax( + group.memberIds.length, Group.maxMembers), + children: [ + for (final f in members) + _MemberRow( + group: group, + fingerprint: f, + isMe: f == myId, + canManage: active && amAdmin, + ), + ], + ), + if (active && !amAdmin) + Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 0), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Icon(Icons.info_outline_rounded, + size: 16, color: SepotColors.textMuted), + const SizedBox(width: 8), + Expanded( + child: Text( + group.isEnforced(me) + ? l10n.groupsAdminOnlyHint + : l10n.groupsAdminOnlyCompatHint, + style: const TextStyle( + fontSize: 13, + color: SepotColors.textSecondary), + ), + ), + ], + ), + ), + const SizedBox(height: 14), + if (conv != null) ...[ + SharedMediaCard(messages: conv.messages), + const SizedBox(height: 14), + ], + ProfileCard( + children: [ + ProfileRow( + label: l10n.chatDisappearingMessages, + value: disappearingLabel( + context, conv?.disappearAfterSeconds), + onTap: group.left + ? null + : () => configureDisappearing(context, ref, groupId), + ), + ], + ), + if (!group.left) ...[ + const SizedBox(height: 14), + ProfileCard( + children: [ + ProfileRow( + label: l10n.groupsLeave, + danger: true, + onTap: () async { + if (await leaveGroup(context, ref, group) && + context.mounted) { + context.go('/chats'); + } + }, + ), + ], + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +/// Jak dlouho po odmítnuté neoprávněné změně skupiny o ní profil mluví. +const _rejectedNoticeFor = Duration(days: 7); + +/// Upozornění nad seznamem členů (resync, odmítnutá změna). +class _GroupNotice extends StatelessWidget { + const _GroupNotice({ + required this.icon, + required this.text, + required this.color, + }); + + final IconData icon; + final String text; + final Color color; + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 12), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: color.withValues(alpha: 0.25)), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(icon, size: 18, color: color), + const SizedBox(width: 10), + Expanded( + child: Text(text, + style: const TextStyle( + fontSize: 13, color: SepotColors.textEmphasis)), + ), + ], + ), + ); + } +} + +class _MemberRow extends ConsumerWidget { + const _MemberRow({ + required this.group, + required this.fingerprint, + required this.isMe, + required this.canManage, + }); + + final Group group; + final String fingerprint; + final bool isMe; + + /// Dívám se jako správce — řádek otevře nabídku správcovských akcí. + final bool canManage; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = context.l10n; + final identity = ref.watch(identityProvider).valueOrNull; + final contact = (ref.watch(contactsProvider).valueOrNull ?? const []) + .where((c) => c.id == fingerprint) + .firstOrNull; + final name = isMe + ? l10n.groupsYou + : contact == null + ? 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}' + : contactLabel(context, contact); + final verified = contact?.verified ?? false; + final isAdmin = group.isAdmin(fingerprint); + final isFounder = fingerprint == group.createdBy; + // Správce má přednost: zakladatel, který roli předal, už nic neřídí. + final badge = isAdmin + ? l10n.groupsAdmin + : isFounder && !group.provisional + ? l10n.groupsFounder + : null; + + void openContact() => context.push('/contact/$fingerprint'); + + return ProfileRow( + leading: SepotAvatar( + name: isMe ? (identity?.displayName ?? name) : name, + seed: fingerprint, + size: 40, + verified: verified, + ), + label: name, + subtitle: isMe + ? null + : verified + ? l10n.verifyVerified + : l10n.verifyUnverified, + trailing: badge == null + ? null + : Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: isAdmin + ? SepotColors.ember.withValues(alpha: 0.12) + : SepotColors.border, + borderRadius: BorderRadius.circular(8), + ), + child: Text(badge, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isAdmin + ? SepotColors.ember + : SepotColors.textSecondary)), + ), + onTap: isMe + ? null + : canManage + ? () => _showMemberActions(context, ref, + name: name, + isAdmin: isAdmin, + onOpenContact: contact == null ? null : openContact) + : contact == null + ? null + : openContact, + ); + } + + void _showMemberActions( + BuildContext context, + WidgetRef ref, { + required String name, + required bool isAdmin, + required VoidCallback? onOpenContact, + }) { + final l10n = context.l10n; + HapticFeedback.mediumImpact(); + showModalBottomSheet( + context: context, + backgroundColor: SepotColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(20, 18, 20, 8), + child: Text(name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 16, fontWeight: FontWeight.w700)), + ), + if (onOpenContact != null) + ListTile( + leading: const Icon(Icons.person_outline_rounded), + title: Text(l10n.groupsViewContact), + onTap: () { + Navigator.pop(ctx); + onOpenContact(); + }, + ), + ListTile( + leading: Icon(isAdmin + ? Icons.remove_moderator_outlined + : Icons.add_moderator_outlined), + title: Text( + isAdmin ? l10n.groupsDismissAdmin : l10n.groupsMakeAdmin), + onTap: () { + Navigator.pop(ctx); + setGroupAdmin(context, ref, group, fingerprint, !isAdmin); + }, + ), + ListTile( + leading: const Icon(Icons.person_remove_outlined, + color: SepotColors.error), + title: Text(l10n.groupsRemoveMember, + style: const TextStyle(color: SepotColors.error)), + onTap: () { + Navigator.pop(ctx); + removeGroupMember(context, ref, group, fingerprint, name); + }, + ), + ], + ), + ), + ); + } +} diff --git a/app/lib/features/profile/profile_widgets.dart b/app/lib/features/profile/profile_widgets.dart new file mode 100644 index 0000000..17e87d8 --- /dev/null +++ b/app/lib/features/profile/profile_widgets.dart @@ -0,0 +1,316 @@ +/// Stavební prvky profilu kontaktu a skupiny — ať oba vypadají a chovají +/// se stejně. +library; + +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/features/conversation/image_viewer_screen.dart'; +import 'package:sepot/models/message.dart'; + +/// Výsledek profilu otevřeného z chatu: co má chat udělat po návratu. +enum ProfileResult { openSearch } + +/// Horní část profilu: zpět, velký avatar, jméno a řádky pod ním. +class ProfileHero extends StatelessWidget { + const ProfileHero({ + super.key, + required this.avatar, + required this.title, + required this.lines, + this.onTitleTap, + }); + + final Widget avatar; + final String title; + final List lines; + final VoidCallback? onTitleTap; + + @override + Widget build(BuildContext context) { + return Container( + color: SepotColors.hero, + padding: EdgeInsets.fromLTRB( + 4, MediaQuery.paddingOf(context).top + 4, 4, 22), + child: Column( + children: [ + Align( + alignment: Alignment.centerLeft, + child: IconButton( + tooltip: MaterialLocalizations.of(context).backButtonTooltip, + icon: const Icon(Icons.arrow_back_rounded), + onPressed: () => Navigator.of(context).maybePop(), + ), + ), + avatar, + const SizedBox(height: 14), + InkWell( + borderRadius: BorderRadius.circular(10), + onTap: onTitleTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 26, + fontWeight: FontWeight.w800, + letterSpacing: -0.3, + ), + ), + ), + if (onTitleTap != null) ...[ + const SizedBox(width: 6), + const Icon(Icons.edit_outlined, + size: 18, color: SepotColors.textSecondary), + ], + ], + ), + ), + ), + const SizedBox(height: 6), + for (final line in lines) + Padding(padding: const EdgeInsets.only(top: 4), child: line), + ], + ), + ); + } +} + +/// Čtveřice rychlých akcí pod hlavičkou profilu. +class ProfileAction extends StatelessWidget { + const ProfileAction({ + super.key, + required this.icon, + required this.label, + required this.onTap, + this.accent = false, + }); + + final IconData icon; + final String label; + final VoidCallback? onTap; + final bool accent; + + @override + Widget build(BuildContext context) { + final color = accent ? SepotColors.ember : SepotColors.textPrimary; + return Expanded( + child: Material( + color: SepotColors.surface, + borderRadius: BorderRadius.circular(18), + child: InkWell( + borderRadius: BorderRadius.circular(18), + onTap: onTap == null + ? null + : () { + HapticFeedback.selectionClick(); + onTap!(); + }, + child: SizedBox( + height: 70, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(icon, size: 22, color: color), + const SizedBox(height: 6), + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 12, fontWeight: FontWeight.w500, color: color), + ), + ], + ), + ), + ), + ), + ); + } +} + +/// Zaoblená karta se sekcí profilu; řádky odděluje tenká linka. +class ProfileCard extends StatelessWidget { + const ProfileCard({super.key, required this.children, this.title, this.trailing}); + + final String? title; + final String? trailing; + final List children; + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: SepotColors.surface, + borderRadius: BorderRadius.circular(20), + ), + clipBehavior: Clip.antiAlias, + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (title != null) + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), + child: Row( + children: [ + Expanded( + child: Text(title!, + style: const TextStyle( + fontSize: 15, fontWeight: FontWeight.w700)), + ), + if (trailing != null) + Text(trailing!, + style: const TextStyle( + fontSize: 13, color: SepotColors.textSecondary)), + ], + ), + ), + for (var i = 0; i < children.length; i++) ...[ + if (i > 0 && title == null) + const Divider(height: 1, indent: 16, color: SepotColors.divider), + children[i], + ], + ], + ), + ); + } +} + +/// Řádek karty: popisek vlevo, hodnota vpravo, volitelně šipka. +class ProfileRow extends StatelessWidget { + const ProfileRow({ + super.key, + required this.label, + this.value, + this.leading, + this.subtitle, + this.onTap, + this.danger = false, + this.trailing, + }); + + final String label; + final String? value; + final Widget? leading; + final String? subtitle; + final VoidCallback? onTap; + final bool danger; + final Widget? trailing; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13), + child: Row( + children: [ + if (leading != null) ...[leading!, const SizedBox(width: 12)], + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, + style: TextStyle( + fontSize: 15, + fontWeight: danger ? FontWeight.w600 : FontWeight.w400, + color: danger + ? SepotColors.error + : SepotColors.textPrimary)), + if (subtitle != null) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text(subtitle!, + style: const TextStyle( + fontSize: 13, color: SepotColors.textSecondary)), + ), + ], + ), + ), + if (value != null) + Text(value!, + style: const TextStyle( + fontSize: 14, color: SepotColors.textSecondary)), + ?trailing, + if (onTap != null && !danger && trailing == null) ...[ + const SizedBox(width: 6), + const Icon(Icons.chevron_right_rounded, + size: 20, color: SepotColors.textMuted), + ], + ], + ), + ), + ); + } +} + +/// Mřížka sdílených fotek (inline náhledy z E2E zpráv, nic se nestahuje). +class SharedMediaCard extends StatelessWidget { + const SharedMediaCard({super.key, required this.messages}); + + /// Všechny zprávy konverzace — fotky si vybere sama. + final List messages; + + static const _shown = 8; + + @override + Widget build(BuildContext context) { + final images = messages.where(isViewableImage).toList().reversed.toList(); + if (images.isEmpty) return const SizedBox.shrink(); + final extra = images.length - _shown; + final visible = images.take(_shown).toList(); + + return ProfileCard( + title: context.l10n.profileSharedMedia, + trailing: '${images.length}', + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(4, 4, 4, 4), + child: GridView.count( + crossAxisCount: 4, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + mainAxisSpacing: 3, + crossAxisSpacing: 3, + children: [ + for (var i = 0; i < visible.length; i++) + GestureDetector( + onTap: () => + ImageViewerScreen.open(context, visible[i], messages), + child: ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Stack( + fit: StackFit.expand, + children: [ + if (visible[i].attachment?.thumbnail != null) + Image.memory(visible[i].attachment!.thumbnail!, + fit: BoxFit.cover, gaplessPlayback: true) + else + const ColoredBox(color: SepotColors.surfaceVariant), + if (i == _shown - 1 && extra > 0) + ColoredBox( + color: Colors.black54, + child: Center( + child: Text('+$extra', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w700)), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ], + ); + } +} diff --git a/app/lib/features/settings/settings_screen.dart b/app/lib/features/settings/settings_screen.dart index 6b7f538..091ec77 100644 --- a/app/lib/features/settings/settings_screen.dart +++ b/app/lib/features/settings/settings_screen.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:path_provider/path_provider.dart'; import 'package:share_plus/share_plus.dart'; import 'package:url_launcher/url_launcher.dart'; @@ -15,6 +16,8 @@ import 'package:sepot/core/l10n/l10n.dart'; import 'package:sepot/core/storage/secure_storage.dart'; import 'package:sepot/models/identity.dart'; import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/core/services/avatar_image.dart'; +import 'package:sepot/widgets/avatar.dart'; import 'package:sepot/providers/contacts_provider.dart'; import 'package:sepot/providers/conversations_provider.dart'; import 'package:sepot/providers/identity_provider.dart'; @@ -38,7 +41,8 @@ class SettingsScreen extends ConsumerWidget { return Scaffold( appBar: AppBar( - title: Text(l10n.settingsTitle), + toolbarHeight: SepotTheme.largeTitleBarHeight, + title: Text(l10n.settingsTitle, style: SepotTheme.largeTitle), ), body: SingleChildScrollView( child: Column( @@ -47,7 +51,7 @@ class SettingsScreen extends ConsumerWidget { identityAsync.when( data: (identity) { if (identity == null) return const SizedBox.shrink(); - return _buildProfileCard(context, identity); + return _buildProfileCard(context, ref, identity); }, loading: () => const SizedBox.shrink(), error: (_, _) => const SizedBox.shrink(), @@ -122,6 +126,19 @@ class SettingsScreen extends ConsumerWidget { ), onTap: () {}, ), + _buildTile( + icon: Icons.more_horiz_rounded, + title: l10n.settingsTypingTitle, + subtitle: l10n.settingsTypingSubtitle, + trailing: Switch( + value: + ref.watch(typingIndicatorsProvider).valueOrNull ?? false, + onChanged: (v) => + ref.read(typingIndicatorsProvider.notifier).set(v), + activeTrackColor: SepotColors.ember, + ), + onTap: () {}, + ), _buildTile( icon: Icons.screenshot_monitor_outlined, title: l10n.settingsScreenSecurityTitle, @@ -298,7 +315,69 @@ class SettingsScreen extends ConsumerWidget { } } - Widget _buildProfileCard(BuildContext context, LocalIdentity identity) { + /// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží + /// v izolátu), nebo odebrat. Protistrany ji dostanou s další zprávou. + Future _editAvatar(BuildContext context, WidgetRef ref) async { + final service = ref.read(messagingServiceProvider); + final hasAvatar = service.myAvatar != null; + final action = await showModalBottomSheet( + context: context, + backgroundColor: SepotColors.surface, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (ctx) => SafeArea( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ListTile( + leading: const Icon(Icons.photo_library_outlined, + color: SepotColors.ember), + title: Text(context.l10n.profilePhotoPick), + onTap: () => Navigator.pop(ctx, 'pick'), + ), + if (hasAvatar) + ListTile( + leading: const Icon(Icons.delete_outline, + color: SepotColors.error), + title: Text(context.l10n.profilePhotoRemove, + style: const TextStyle(color: SepotColors.error)), + onTap: () => Navigator.pop(ctx, 'remove'), + ), + Padding( + padding: const EdgeInsets.fromLTRB(20, 4, 20, 16), + child: Text(context.l10n.profilePhotoHint, + style: const TextStyle( + fontSize: 12, color: SepotColors.textSecondary)), + ), + ], + ), + ), + ); + if (action == null || !context.mounted) return; + try { + if (action == 'remove') { + await service.removeMyAvatar(); + } else { + final picked = await ImagePicker() + .pickImage(source: ImageSource.gallery, maxWidth: 1600); + if (picked == null) return; + final jpeg = await prepareAvatarJpeg(await picked.readAsBytes()); + if (jpeg == null) throw const FormatException('unreadable image'); + await service.setMyAvatar(jpeg); + } + ref.invalidate(avatarProvider); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Text(context.l10n + .profilePhotoFailed(describeError(context.l10n, e))), + )); + } + } + + Widget _buildProfileCard( + BuildContext context, WidgetRef ref, LocalIdentity identity) { return Container( margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(20), @@ -309,18 +388,34 @@ class SettingsScreen extends ConsumerWidget { ), child: Column( children: [ - CircleAvatar( - radius: 36, - backgroundColor: SepotColors.ember.withValues(alpha: 0.1), - child: Text( - identity.displayName.isEmpty - ? '?' - : identity.displayName.characters.first.toUpperCase(), - style: const TextStyle( - fontFamily: kFontSans, - fontSize: 28, - fontWeight: FontWeight.w600, - color: SepotColors.ember, + // Stejný tón jako „já“ v seznamu chatů a ve skupinách. + Semantics( + button: true, + label: context.l10n.profilePhotoEdit, + child: GestureDetector( + onTap: () => _editAvatar(context, ref), + child: Stack( + clipBehavior: Clip.none, + children: [ + SepotAvatar( + name: identity.displayName, seed: identity.id, size: 84), + Positioned( + right: -2, + bottom: -2, + child: Container( + width: 30, + height: 30, + decoration: BoxDecoration( + color: SepotColors.ember, + shape: BoxShape.circle, + border: + Border.all(color: SepotColors.surface, width: 3), + ), + child: const Icon(Icons.photo_camera_rounded, + size: 15, color: SepotColors.emberInk), + ), + ), + ], ), ), ), diff --git a/app/lib/l10n/app_cs.arb b/app/lib/l10n/app_cs.arb index 43d6c0e..8cf4acf 100644 --- a/app/lib/l10n/app_cs.arb +++ b/app/lib/l10n/app_cs.arb @@ -82,15 +82,6 @@ "@groupsLeftBanner": { "description": "Banner místo input baru ve skupině, kterou uživatel opustil; POZOR gender" }, - "groupsMembersWithCount": "Členové ({count})", - "@groupsMembersWithCount": { - "description": "Nadpis bottom sheetu se seznamem členů skupiny", - "placeholders": { - "count": { - "type": "int" - } - } - }, "groupsYou": "Ty", "@groupsYou": { "description": "Fallback jména vlastního avataru v seznamu členů skupiny | Titulek ListTile vlastní osoby v seznamu členů skupiny — stejný klíč jako ř. 486" @@ -99,6 +90,76 @@ "@groupsFounder": { "description": "Subtitle člena, který skupinu založil, v seznamu členů" }, + "groupsAdmin": "Správce", + "@groupsAdmin": { + "description": "Odznak správce skupiny u člena v seznamu členů" + }, + "groupsMakeAdmin": "Udělat správcem", + "@groupsMakeAdmin": { + "description": "Akce v nabídce člena: udělit roli správce" + }, + "groupsDismissAdmin": "Odebrat roli správce", + "@groupsDismissAdmin": { + "description": "Akce v nabídce člena: odebrat roli správce" + }, + "groupsRemoveMember": "Odebrat ze skupiny", + "@groupsRemoveMember": { + "description": "Akce v nabídce člena: odebrat ze skupiny" + }, + "groupsRemoveMemberConfirmTitle": "Odebrat {name} ze skupiny?", + "@groupsRemoveMemberConfirmTitle": { + "description": "Titulek potvrzení odebrání člena", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "groupsRemoveMemberConfirmBody": "Přestanou mu chodit nové zprávy skupiny. Dosavadní historie mu zůstane.", + "@groupsRemoveMemberConfirmBody": { + "description": "Text potvrzení odebrání člena" + }, + "groupsRemoveAction": "Odebrat", + "@groupsRemoveAction": { + "description": "Potvrzovací tlačítko odebrání člena" + }, + "groupsViewContact": "Zobrazit kontakt", + "@groupsViewContact": { + "description": "Akce v nabídce člena: otevřít profil kontaktu" + }, + "groupsAdminOnlyHint": "Jméno, členy a správce skupiny mění jen správci.", + "@groupsAdminOnlyHint": { + "description": "Vysvětlení pod seznamem členů pro ne-správce ve vynucené skupině" + }, + "groupsAdminOnlyCompatHint": "Jméno a správce mění jen správci. Přidávat lidi zatím může každý, protože někdo ve skupině má starší verzi Šepotu.", + "@groupsAdminOnlyCompatHint": { + "description": "Vysvětlení pro ne-správce, dokud je ve skupině člen se starší verzí aplikace" + }, + "groupsAdminActionFailed": "Změna se nepovedla: {error}", + "@groupsAdminActionFailed": { + "description": "SnackBar při selhání správcovské akce", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "groupsResyncNotice": "Stav skupiny se srovnává se správcem. Změny členů se můžou chvíli opozdit.", + "@groupsResyncNotice": { + "description": "Upozornění v profilu skupiny, když chybí mezistav od správce" + }, + "groupsRejectedChangeNotice": "Někdo bez oprávnění zkusil skupinu změnit. Změna se neprovedla.", + "@groupsRejectedChangeNotice": { + "description": "Upozornění v profilu skupiny po odmítnuté neoprávněné změně" + }, + "errorGroupNotAdmin": "Tohle může udělat jen správce skupiny.", + "@errorGroupNotAdmin": { + "description": "Chyba: akci smí udělat jen správce skupiny" + }, + "errorGroupLastAdmin": "Jsi jediný správce. Nejdřív udělej správcem někoho dalšího.", + "@errorGroupLastAdmin": { + "description": "Chyba: poslední správce se nemůže vzdát role" + }, "groupsNameHint": "Název skupiny", "@groupsNameHint": { "description": "hintText TextFieldu v dialogu přejmenování skupiny | labelText textového pole pro jméno skupiny (max 64 znaků)" @@ -254,6 +315,40 @@ "@chatActionReply": { "description": "Položka akčního bottom sheetu zprávy (long-press)" }, + "chatActionForward": "Přeposlat", + "@chatActionForward": { + "description": "Akce v nabídce po podržení zprávy" + }, + "forwardTitle": "Přeposlat do…", + "@forwardTitle": { + "description": "Nadpis panelu výběru chatu pro přeposlání" + }, + "forwardSearchHint": "Hledat kontakt nebo skupinu", + "@forwardSearchHint": { + "description": "Nápověda v poli hledání panelu přeposlání" + }, + "forwardEmpty": "Nic tu není", + "@forwardEmpty": { + "description": "Prázdný výsledek hledání v panelu přeposlání" + }, + "forwardDone": "Přeposláno: {name}", + "@forwardDone": { + "description": "SnackBar po přeposlání zprávy", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "forwardFailed": "Přeposlání se nepovedlo: {error}", + "@forwardFailed": { + "description": "SnackBar při chybě přeposlání", + "placeholders": { + "error": { + "type": "String" + } + } + }, "commonCopy": "Kopírovat", "@commonCopy": { "description": "Položka akčního bottom sheetu zprávy — kopírování textu do schránky | Popisek OutlinedButton.icon pro zkopírování Šepot ID do schránky" @@ -332,6 +427,18 @@ } } }, + "attachmentKeepsMetadataTitle": "Fotka odejde i s polohou", + "@attachmentKeepsMetadataTitle": { + "description": "Titulek dialogu před odesláním obrázku jako dokumentu, když z něj nejde odstranit EXIF (HEIC, RAW)" + }, + "attachmentKeepsMetadataBody": "Tenhle formát fotky (třeba HEIC) neumím zbavit metadat. Odešla by v originále včetně polohy, kde vznikla, a údajů o telefonu. Bezpečnější je poslat ji přes Galerii, kde se metadata odstraní.", + "@attachmentKeepsMetadataBody": { + "description": "Text téhož dialogu" + }, + "attachmentSendOriginal": "Poslat originál", + "@attachmentSendOriginal": { + "description": "Potvrzovací tlačítko téhož dialogu" + }, "attachmentPickFailed": "Výběr přílohy selhal: {error}", "@attachmentPickFailed": { "description": "SnackBar (_showError) při selhání výběru přílohy", @@ -396,6 +503,10 @@ "@errorGroupNeedsMember": { "description": "StateError z createGroup při prázdném seznamu členů; zobrazuje UI zakládání skupiny." }, + "errorAttachmentUnavailable": "Příloha už v telefonu není", + "@errorAttachmentUnavailable": { + "description": "Chyba při přeposlání přílohy, která už byla smazána nebo nestažena" + }, "errorGroupMaxMembers": "Skupina může mít nejvýš {maxMembers} členů", "@errorGroupMaxMembers": { "description": "StateError z createGroup při překročení max. počtu členů; zobrazuje UI zakládání skupiny. Počet v textu + české skloňování (člen/členové/členů) → ICU plural. | StateError z addGroupMembers — identický text jako v createGroup (ř. 583), stejný klíč. ICU plural.", @@ -813,28 +924,136 @@ "@chatListNewConversation": { "description": "Náhled poslední zprávy v dlaždici seznamu chatů, když konverzace zatím nemá žádnou zprávu" }, - "chatListPhotoWithCaptionPreview": "📷 {caption}", - "@chatListPhotoWithCaptionPreview": { - "description": "Náhled poslední zprávy v dlaždici — fotka s popiskem; emoji je součást textu", - "placeholders": { - "caption": { - "type": "String" - } - } - }, - "chatListFilePreview": "📎 {fileName}", - "@chatListFilePreview": { - "description": "Náhled poslední zprávy v dlaždici — soubor; emoji je součást textu. Skládaný literál: obsahuje vnořený fallback \"Soubor\" (viz samostatný záznam) — při lokalizaci předat placeholderu už vyřešenou hodnotu msg.fileName ?? commonFile.", - "placeholders": { - "fileName": { - "type": "String" - } - } - }, "chatListReadReceiptPreview": "Přečteno", "@chatListReadReceiptPreview": { "description": "Náhled poslední zprávy v dlaždici pro zprávu typu readReceipt (potvrzení o přečtení)" }, + "chatListDraft": "Koncept", + "@chatListDraft": { + "description": "Předpona náhledu v seznamu chatů, když je v chatu rozepsaná zpráva" + }, + "notesTitle": "Poznámky pro sebe", + "@notesTitle": { + "description": "Název chatu se sebou samým (seznam chatů, hlavička, kontakty, přeposlání)" + }, + "notesSubtitle": "Jen v tomto telefonu, šifrovaně", + "@notesSubtitle": { + "description": "Podtitulek poznámek pro sebe — nic se neodesílá na server" + }, + "callVoice": "Zavolat", + "@callVoice": { + "description": "Tlačítko hlasového hovoru v hlavičce chatu" + }, + "callVideo": "Videohovor", + "@callVideo": { + "description": "Tlačítko videohovoru v hlavičce chatu" + }, + "callStatusRinging": "Vyzvání…", + "@callStatusRinging": { + "description": "Stav odchozího hovoru, dokud protistrana nezvedne" + }, + "callIncomingVoice": "Příchozí hovor", + "@callIncomingVoice": { + "description": "Stav příchozího hlasového hovoru" + }, + "callIncomingVideo": "Příchozí videohovor", + "@callIncomingVideo": { + "description": "Stav příchozího videohovoru" + }, + "callStatusConnecting": "Spojuji…", + "@callStatusConnecting": { + "description": "Stav po přijetí, než naběhne spojení" + }, + "callEnded": "Hovor skončil", + "@callEnded": { + "description": "Krátce po konci hovoru" + }, + "callEndedBusy": "Obsazeno", + "@callEndedBusy": { + "description": "Protistrana má jiný hovor" + }, + "callEndedRejected": "Hovor odmítnut", + "@callEndedRejected": { + "description": "Hovor byl odmítnut" + }, + "callEndedUnanswered": "Nikdo to nevzal", + "@callEndedUnanswered": { + "description": "Odchozí hovor vypršel bez odpovědi" + }, + "callEndedFailed": "Spojení se nepodařilo", + "@callEndedFailed": { + "description": "Hovor se nepodařilo spojit" + }, + "callEncrypted": "Šifrováno end-to-end", + "@callEncrypted": { + "description": "Podtitulek na obrazovce hovoru" + }, + "callAccept": "Přijmout", + "@callAccept": { + "description": "Přijetí příchozího hovoru" + }, + "callReject": "Odmítnout", + "@callReject": { + "description": "Odmítnutí příchozího hovoru" + }, + "callMute": "Ztlumit mikrofon", + "@callMute": { + "description": "Přepínač mikrofonu (popisek pro čtečku)" + }, + "callSpeaker": "Reproduktor", + "@callSpeaker": { + "description": "Přepínač hlasitého odposlechu" + }, + "callCamera": "Vypnout kameru", + "@callCamera": { + "description": "Přepínač kamery ve videohovoru" + }, + "callSwitchCamera": "Přepnout kameru", + "@callSwitchCamera": { + "description": "Přední/zadní kamera" + }, + "callHangUp": "Zavěsit", + "@callHangUp": { + "description": "Ukončení hovoru" + }, + "callLogMissed": "Zmeškaný hovor", + "@callLogMissed": { + "description": "Záznam v chatu" + }, + "callLogUnanswered": "Nepřijatý hovor", + "@callLogUnanswered": { + "description": "Odchozí hovor, který protistrana nevzala" + }, + "callLogRejected": "Odmítnutý hovor", + "@callLogRejected": { + "description": "Záznam v chatu" + }, + "callLogBusy": "Obsazeno", + "@callLogBusy": { + "description": "Záznam v chatu — protistrana měla jiný hovor" + }, + "callLogFailed": "Hovor se nespojil", + "@callLogFailed": { + "description": "Záznam v chatu" + }, + "callLogVoice": "Hovor · {duration}", + "@callLogVoice": { + "description": "Záznam proběhlého hlasového hovoru s délkou", + "placeholders": { + "duration": { + "type": "String" + } + } + }, + "callLogVideo": "Videohovor · {duration}", + "@callLogVideo": { + "description": "Záznam proběhlého videohovoru s délkou", + "placeholders": { + "duration": { + "type": "String" + } + } + }, "chatListTimeNow": "teď", "@chatListTimeNow": { "description": "Relativní čas poslední zprávy v dlaždici, méně než 1 minuta" @@ -1270,15 +1489,15 @@ "@pushModeOffDesc": { "description": "Popisek volby 'Vypnuto' v dialogu výběru režimu pushů" }, - "pushModeUp": "UnifiedPush (doporučeno)", + "pushModeUp": "UnifiedPush (bez Googlu)", "@pushModeUp": { - "description": "Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(doporučeno)')" + "description": "Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(bez Googlu)')" }, "pushModeUpDesc": "Degooglované — potřebuje aplikaci ntfy (F-Droid/Play).", "@pushModeUpDesc": { "description": "Popisek volby UnifiedPush v dialogu výběru režimu pushů ('ntfy', 'F-Droid', 'Play' jsou názvy — nepřekládat)" }, - "pushModeFcm": "Google (FCM)", + "pushModeFcm": "Google (FCM, výchozí)", "@pushModeFcm": { "description": "Label volby 'fcm' v dialogu výběru režimu pushů (vlastní název — pravděpodobně shodný ve všech jazycích, ale klíč mít)" }, @@ -1336,7 +1555,7 @@ "@pushNoDistributorFallback": { "description": "Vnořený fallback literál uvnitř subtitle 'UnifiedPush · …', když není vybraný žádný distributor — vyhodnotit před dosazením do pushSubtitleUnifiedPush" }, - "pushSubtitleFcm": "Google (FCM) — rychlé, opt-in", + "pushSubtitleFcm": "Google (FCM)", "@pushSubtitleFcm": { "description": "Subtitle ListTile v nastavení, když je aktivní režim FCM" }, @@ -1762,5 +1981,354 @@ "photoPreviewDecodeFailed": "Fotku se nepodařilo načíst", "@photoPreviewDecodeFailed": { "description": "SnackBar, když se vybraná fotka nedá dekódovat — náhled se zavře bez odeslání" + }, + "chatActionInfo": "Info o zprávě", + "@chatActionInfo": { + "description": "Položka v nabídce zprávy — detail doručení a přečtení." + }, + "messageInfoSent": "Odesláno", + "@messageInfoSent": { + "description": "Info o zprávě: řádek času odeslání." + }, + "messageInfoDelivered": "Doručeno", + "@messageInfoDelivered": { + "description": "Info o zprávě: řádek času doručení." + }, + "messageInfoRead": "Přečteno", + "@messageInfoRead": { + "description": "Info o zprávě: řádek času přečtení." + }, + "messageInfoPending": "zatím ne", + "@messageInfoPending": { + "description": "Info o zprávě: událost ještě nenastala." + }, + "messageInfoReadReceiptsOff": "Kdy si zprávu přečetli, uvidíš, jen když sám sdílíš potvrzení přečtení (Nastavení).", + "@messageInfoReadReceiptsOff": { + "description": "Info o zprávě: vysvětlení, proč chybí čas přečtení." + }, + "chatStatusReadAt": "Přečteno {time}", + "@chatStatusReadAt": { + "description": "Řádek pod poslední odeslanou zprávou — čas přečtení.", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "chatStatusDeliveredAt": "Doručeno {time}", + "@chatStatusDeliveredAt": { + "description": "Řádek pod poslední odeslanou zprávou — čas doručení.", + "placeholders": { + "time": { + "type": "String" + } + } + }, + "groupsAddMembers": "Přidat členy", + "@groupsAddMembers": { + "description": "Nabídka skupiny a titulek obrazovky pro přidání lidí." + }, + "groupsAddMembersAction": "Přidat", + "@groupsAddMembersAction": { + "description": "Potvrzovací tlačítko v AppBaru při přidávání členů." + }, + "groupsAddMembersFailed": "Přidání se nepovedlo: {error}", + "@groupsAddMembersFailed": { + "description": "Chyba při přidávání členů.", + "placeholders": { + "error": { + "type": "String" + } + } + }, + "groupsNoContactsToAdd": "Všechny tvoje kontakty už ve skupině jsou. Nového člověka nejdřív přidej do kontaktů přes QR kód.", + "@groupsNoContactsToAdd": { + "description": "Prázdný výběr — všechny kontakty už ve skupině jsou." + }, + "navChats": "Chaty", + "@navChats": { + "description": "Karta spodní navigace a nadpis seznamu chatů." + }, + "chatListNewChat": "Nový chat", + "@chatListNewChat": { + "description": "Tlačítko pro zahájení nového chatu." + }, + "chatListSearchHint": "Hledat v chatech a zprávách", + "@chatListSearchHint": { + "description": "Placeholder hledání v seznamu chatů." + }, + "chatFilterAll": "Vše", + "@chatFilterAll": { + "description": "Filtr seznamu chatů — vše." + }, + "chatFilterUnread": "Nepřečtené", + "@chatFilterUnread": { + "description": "Filtr seznamu chatů — nepřečtené." + }, + "chatFilterGroups": "Skupiny", + "@chatFilterGroups": { + "description": "Filtr seznamu chatů — skupiny." + }, + "chatFilterArchive": "Archiv", + "@chatFilterArchive": { + "description": "Odkaz na archiv v řádku filtrů." + }, + "chatListNoResults": "Nic nenalezeno.", + "@chatListNoResults": { + "description": "Hledání v chatech nic nenašlo." + }, + "chatListFilterEmpty": "Tady zatím nic není.", + "@chatListFilterEmpty": { + "description": "Vybraný filtr je prázdný." + }, + "chatListPin": "Připnout", + "@chatListPin": { + "description": "Akce: připnout chat nahoru." + }, + "chatListUnpin": "Odepnout", + "@chatListUnpin": { + "description": "Akce: odepnout chat." + }, + "chatOpenProfile": "Zobrazit profil", + "@chatOpenProfile": { + "description": "Hlavička chatu / menu: otevřít profil kontaktu." + }, + "groupsProfile": "Profil skupiny", + "@groupsProfile": { + "description": "Menu skupinového chatu: profil skupiny." + }, + "chatSearchHint": "Hledat v konverzaci", + "@chatSearchHint": { + "description": "Hledání v jedné konverzaci — placeholder a popisek tlačítka." + }, + "chatSearchNoMatches": "Nic", + "@chatSearchNoMatches": { + "description": "Hledání v konverzaci bez výsledku (krátce, vedle pole)." + }, + "chatSearchCounter": "{current} z {total}", + "@chatSearchCounter": { + "description": "Pořadí aktuální shody hledání, např. „2 z 5“.", + "placeholders": { + "current": { + "type": "int" + }, + "total": { + "type": "int" + } + } + }, + "chatSearchOlder": "Starší shoda", + "@chatSearchOlder": { + "description": "Tlačítko: předchozí (starší) shoda." + }, + "chatSearchNewer": "Novější shoda", + "@chatSearchNewer": { + "description": "Tlačítko: další (novější) shoda." + }, + "chatUnreadDivider": "{count, plural, one{1 nová zpráva} few{{count} nové zprávy} other{{count} nových zpráv}}", + "@chatUnreadDivider": { + "description": "Oddělovač nad první nepřečtenou zprávou.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "chatJumpToLatest": "Na nejnovější zprávy", + "@chatJumpToLatest": { + "description": "Tlačítko: skok na nejnovější zprávy." + }, + "chatAttach": "Přiložit", + "@chatAttach": { + "description": "Tlačítko přílohy v poli pro psaní." + }, + "chatSend": "Odeslat", + "@chatSend": { + "description": "Tlačítko odeslat." + }, + "chatRecordVoice": "Nahrát hlasovou zprávu", + "@chatRecordVoice": { + "description": "Tlačítko nahrát hlasovou zprávu." + }, + "profileSharedMedia": "Sdílená média", + "@profileSharedMedia": { + "description": "Karta sdílených fotek v profilu." + }, + "profileMessage": "Zpráva", + "@profileMessage": { + "description": "Akce profilu: napsat zprávu." + }, + "profileSearch": "Hledat", + "@profileSearch": { + "description": "Akce profilu: hledat v konverzaci." + }, + "profileUnmute": "Zvuk", + "@profileUnmute": { + "description": "Akce profilu: zrušit ztlumení (krátce)." + }, + "profileDisappearing": "Mizení", + "@profileDisappearing": { + "description": "Akce profilu: mizící zprávy (krátce)." + }, + "profileCommonGroups": "Společné skupiny", + "@profileCommonGroups": { + "description": "Karta společných skupin v profilu kontaktu." + }, + "profileSafetyNumber": "Bezpečnostní číslo", + "@profileSafetyNumber": { + "description": "Řádek profilu: bezpečnostní číslo." + }, + "profileSafetyNumberHint": "Porovnejte osobně nebo QR kódem", + "@profileSafetyNumberHint": { + "description": "Podtitulek bezpečnostního čísla u neověřeného kontaktu." + }, + "profileVerifiedInPerson": "Ověřeno osobně", + "@profileVerifiedInPerson": { + "description": "Stav ověřeného kontaktu v hlavičce profilu." + }, + "profileVerifyNow": "Ověřit kontakt", + "@profileVerifyNow": { + "description": "Tlačítko v profilu neověřeného kontaktu." + }, + "profileAdd": "Přidat", + "@profileAdd": { + "description": "Akce profilu skupiny: přidat členy (krátce)." + }, + "profileGroupSubtitle": "Skupina · {members} · založena {date}", + "@profileGroupSubtitle": { + "description": "Podtitulek profilu skupiny: počet členů · datum založení.", + "placeholders": { + "members": { + "type": "String" + }, + "date": { + "type": "String" + } + } + }, + "profileMembersOfMax": "{count} ze {max}", + "@profileMembersOfMax": { + "description": "Počet členů ze stropu skupiny, např. „4 ze 128“.", + "placeholders": { + "count": { + "type": "int" + }, + "max": { + "type": "int" + } + } + }, + "chatTyping": "píše…", + "@chatTyping": { + "description": "Podtitulek chatu / náhled v seznamu: protistrana píše." + }, + "chatTypingNamed": "{name} píše…", + "@chatTypingNamed": { + "description": "Skupina: kdo píše (křestní jméno).", + "placeholders": { + "name": { + "type": "String" + } + } + }, + "chatTypingSeveral": "Několik lidí píše…", + "@chatTypingSeveral": { + "description": "Skupina: píše víc lidí najednou." + }, + "settingsTypingTitle": "Ukazovat, že píšu", + "@settingsTypingTitle": { + "description": "Nastavení: přepínač „píše…“." + }, + "settingsTypingSubtitle": "Oboustranné — kdo nesdílí, nevidí (výchozí vypnuto)", + "@settingsTypingSubtitle": { + "description": "Popis přepínače „píše…“." + }, + "pollAttach": "Anketa", + "@pollAttach": { + "description": "Položka nabídky příloh: vytvořit anketu." + }, + "pollCreateTitle": "Nová anketa", + "@pollCreateTitle": { + "description": "Nadpis formuláře nové ankety." + }, + "pollQuestion": "Otázka", + "@pollQuestion": { + "description": "Pole otázky ankety." + }, + "pollOptionHint": "Možnost {number}", + "@pollOptionHint": { + "description": "Placeholder možnosti ankety s pořadím.", + "placeholders": { + "number": { + "type": "int" + } + } + }, + "pollAddOption": "Přidat možnost", + "@pollAddOption": { + "description": "Tlačítko: přidat možnost do ankety." + }, + "pollRemoveOption": "Odebrat možnost", + "@pollRemoveOption": { + "description": "Tlačítko: odebrat možnost." + }, + "pollAllowMultiple": "Povolit víc odpovědí", + "@pollAllowMultiple": { + "description": "Přepínač: víc odpovědí na osobu." + }, + "pollSingle": "Anketa", + "@pollSingle": { + "description": "Hlavička ankety s jednou volbou." + }, + "pollMulti": "Anketa, více voleb", + "@pollMulti": { + "description": "Hlavička ankety s více volbami." + }, + "pollVoters": "{count, plural, =0{zatím bez hlasů} one{1 hlas} few{{count} hlasy} other{{count} hlasů}}", + "@pollVoters": { + "description": "Počet hlasujících v anketě.", + "placeholders": { + "count": { + "type": "int" + } + } + }, + "pollFallback": "Anketa: {question}", + "@pollFallback": { + "description": "Záložní text ankety pro starší verze appky — první řádek.", + "placeholders": { + "question": { + "type": "String" + } + } + }, + "pollFallbackUpdate": "Hlasovat jde v aktuální verzi Šepotu.", + "@pollFallbackUpdate": { + "description": "Záložní text ankety — výzva k aktualizaci." + }, + "profilePhotoEdit": "Změnit profilovou fotku", + "@profilePhotoEdit": { + "description": "Popisek avataru v Nastavení (čtečka obrazovky)." + }, + "profilePhotoPick": "Vybrat fotku", + "@profilePhotoPick": { + "description": "Akce: vybrat profilovou fotku z galerie." + }, + "profilePhotoRemove": "Odebrat fotku", + "@profilePhotoRemove": { + "description": "Akce: odebrat profilovou fotku." + }, + "profilePhotoHint": "Fotku uvidí jen lidé, se kterými si píšeš — šifrovaně, stejně jako zprávy. Poloha ani údaje o fotce se neposílají.", + "@profilePhotoHint": { + "description": "Vysvětlení, kdo profilovou fotku uvidí." + }, + "profilePhotoFailed": "Fotku se nepodařilo nastavit: {error}", + "@profilePhotoFailed": { + "description": "Chyba nastavení profilové fotky.", + "placeholders": { + "error": { + "type": "String" + } + } } } diff --git a/app/lib/l10n/app_de.arb b/app/lib/l10n/app_de.arb index 9b98b41..4510dd1 100644 --- a/app/lib/l10n/app_de.arb +++ b/app/lib/l10n/app_de.arb @@ -18,9 +18,23 @@ "groupsRename": "Gruppe umbenennen", "groupsLeave": "Gruppe verlassen", "groupsLeftBanner": "Du bist nicht mehr in der Gruppe. Der Verlauf bleibt, neue Nachrichten kommen nicht mehr an.", - "groupsMembersWithCount": "Mitglieder ({count})", "groupsYou": "Du", "groupsFounder": "Hat die Gruppe gegründet", + "groupsAdmin": "Admin", + "groupsMakeAdmin": "Zum Admin machen", + "groupsDismissAdmin": "Adminrolle entziehen", + "groupsRemoveMember": "Aus der Gruppe entfernen", + "groupsRemoveMemberConfirmTitle": "{name} aus der Gruppe entfernen?", + "groupsRemoveMemberConfirmBody": "Neue Nachrichten der Gruppe kommen dann nicht mehr an. Der bisherige Verlauf bleibt erhalten.", + "groupsRemoveAction": "Entfernen", + "groupsViewContact": "Kontakt anzeigen", + "groupsAdminOnlyHint": "Nur Admins können Namen, Mitglieder und Admins der Gruppe ändern.", + "groupsAdminOnlyCompatHint": "Nur Admins können die Gruppe umbenennen oder Admins ändern. Personen hinzufügen kann vorerst jeder, weil jemand in der Gruppe eine ältere Šepot-Version hat.", + "groupsAdminActionFailed": "Änderung fehlgeschlagen: {error}", + "groupsResyncNotice": "Die Gruppe gleicht sich mit dem Admin ab. Änderungen an Mitgliedern können sich kurz verzögern.", + "groupsRejectedChangeNotice": "Jemand ohne Berechtigung hat versucht, die Gruppe zu ändern. Die Änderung wurde nicht übernommen.", + "errorGroupNotAdmin": "Das kann nur ein Admin der Gruppe.", + "errorGroupLastAdmin": "Du bist der einzige Admin. Mach zuerst jemand anderen zum Admin.", "groupsNameHint": "Gruppenname", "commonCancel": "Abbrechen", "commonSave": "Speichern", @@ -51,6 +65,12 @@ "chatEditingMessage": "Nachricht bearbeiten", "chatReply": "Antwort", "chatActionReply": "Antworten", + "chatActionForward": "Weiterleiten", + "forwardTitle": "Weiterleiten an…", + "forwardSearchHint": "Kontakt oder Gruppe suchen", + "forwardEmpty": "Nichts gefunden", + "forwardDone": "Weitergeleitet an {name}", + "forwardFailed": "Weiterleiten fehlgeschlagen: {error}", "commonCopy": "Kopieren", "commonEdit": "Bearbeiten", "chatDeleteForMe": "Für mich löschen", @@ -68,6 +88,9 @@ "attachmentTakePhoto": "Foto aufnehmen", "attachmentDocument": "Dokument", "attachmentTooLarge": "Datei ist zu groß (max. {maxMb} MB)", + "attachmentKeepsMetadataTitle": "Foto enthält den Standort", + "attachmentKeepsMetadataBody": "Aus diesem Fotoformat (z. B. HEIC) kann ich die Metadaten nicht entfernen. Es würde im Original gesendet, einschließlich Aufnahmeort und Angaben zum Telefon. Sicherer ist das Senden über die Galerie, dort werden die Metadaten entfernt.", + "attachmentSendOriginal": "Original senden", "attachmentPickFailed": "Auswahl des Anhangs fehlgeschlagen: {error}", "attachmentSendFailed": "Senden des Anhangs fehlgeschlagen: {error}", "chatSendFailed": "Senden fehlgeschlagen: {error}", @@ -79,6 +102,7 @@ "errorContactLinkIncomplete": "Dieser Link ist unvollständig — die andere Person hat ihn erstellt, bevor sie verbunden war. Bitte sie um einen neuen sepot://-Link.", "errorAttachmentTooLarge": "Anhang ist zu groß (max. {maxMb} MB)", "errorGroupNeedsMember": "Die Gruppe braucht mindestens 1 Mitglied", + "errorAttachmentUnavailable": "Der Anhang ist nicht mehr auf diesem Telefon", "errorGroupMaxMembers": "Die Gruppe kann höchstens {maxMembers} Mitglieder haben", "errorMemberNoDeliveryAddress": "{displayName} hat keine Zustelladresse — frag nach einem neuen Link", "errorContactNoDeliveryAddress": "Der Kontakt hat keine Zustelladresse — bitte ihn um einen neuen sepot://-Link", @@ -163,9 +187,36 @@ "attachmentOpenFailed": "Öffnen des Anhangs fehlgeschlagen: {error}", "commonFile": "Datei", "chatListNewConversation": "Neue Unterhaltung", - "chatListPhotoWithCaptionPreview": "📷 {caption}", - "chatListFilePreview": "📎 {fileName}", "chatListReadReceiptPreview": "Gelesen", + "chatListDraft": "Entwurf", + "notesTitle": "Notizen für mich", + "notesSubtitle": "Nur auf diesem Telefon, verschlüsselt", + "callVoice": "Anrufen", + "callVideo": "Videoanruf", + "callStatusRinging": "Klingelt…", + "callIncomingVoice": "Eingehender Anruf", + "callIncomingVideo": "Eingehender Videoanruf", + "callStatusConnecting": "Verbinde…", + "callEnded": "Anruf beendet", + "callEndedBusy": "Besetzt", + "callEndedRejected": "Anruf abgelehnt", + "callEndedUnanswered": "Keine Antwort", + "callEndedFailed": "Verbindung fehlgeschlagen", + "callEncrypted": "Ende-zu-Ende-verschlüsselt", + "callAccept": "Annehmen", + "callReject": "Ablehnen", + "callMute": "Stummschalten", + "callSpeaker": "Lautsprecher", + "callCamera": "Kamera aus", + "callSwitchCamera": "Kamera wechseln", + "callHangUp": "Auflegen", + "callLogMissed": "Verpasster Anruf", + "callLogUnanswered": "Unbeantworteter Anruf", + "callLogRejected": "Abgelehnter Anruf", + "callLogBusy": "Besetzt", + "callLogFailed": "Anruf fehlgeschlagen", + "callLogVoice": "Anruf · {duration}", + "callLogVideo": "Videoanruf · {duration}", "chatListTimeNow": "jetzt", "chatListTimeMinutes": "{minutes} Min.", "qrScanTitle": "QR-Code scannen", @@ -266,9 +317,9 @@ "pinUnlockError": "Fehler beim Entsperren", "pushTitle": "Push-Benachrichtigungen", "pushModeOffDesc": "Nachrichten werden erst beim Öffnen der App abgerufen.", - "pushModeUp": "UnifiedPush (empfohlen)", + "pushModeUp": "UnifiedPush (ohne Google)", "pushModeUpDesc": "Google-frei — braucht die App ntfy (F-Droid/Play).", - "pushModeFcm": "Google (FCM)", + "pushModeFcm": "Google (FCM, Standard)", "pushModeFcmDesc": "Zuverlässig ohne zusätzliche App. Der Push-Token des Geräts geht an Google; der Inhalt der Nachrichten nie.", "pushSetupFailed": "Einrichtung der Push-Benachrichtigungen fehlgeschlagen: {error}", "pushHintOff": "Nachrichten kommen erst an, wenn du Šepot öffnest — keine Weckrufe eingerichtet.", @@ -280,7 +331,7 @@ "pushPickDistributorTitle": "Distributor wählen", "pushSubtitleUnifiedPush": "UnifiedPush · {distributor}", "pushNoDistributorFallback": "kein Distributor", - "pushSubtitleFcm": "Google (FCM) — schnell, opt-in", + "pushSubtitleFcm": "Google (FCM)", "restoreErrorNameRequired": "Gib einen Anzeigenamen ein.", "restoreErrorWordCount": "{wordCount, plural, one{Die Phrase hat 1 Wort, benötigt werden {requiredWords}.} other{Die Phrase hat {wordCount} Wörter, benötigt werden {requiredWords}.}}", "restoreErrorInvalidPhrase": "Die Phrase ist ungültig — prüfe die Wörter und ihre Reihenfolge.", @@ -364,5 +415,75 @@ "photoPreviewSend": "Senden", "photoPreviewTextDialogTitle": "Text auf dem Foto", "photoPreviewRemoveText": "Entfernen", - "photoPreviewDecodeFailed": "Foto konnte nicht geladen werden" + "photoPreviewDecodeFailed": "Foto konnte nicht geladen werden", + "chatActionInfo": "Nachrichteninfo", + "messageInfoSent": "Gesendet", + "messageInfoDelivered": "Zugestellt", + "messageInfoRead": "Gelesen", + "messageInfoPending": "noch nicht", + "messageInfoReadReceiptsOff": "Wann deine Nachrichten gelesen wurden, siehst du nur, wenn du selbst Lesebestätigungen teilst (Einstellungen).", + "chatStatusReadAt": "Gelesen {time}", + "chatStatusDeliveredAt": "Zugestellt {time}", + "groupsAddMembers": "Mitglieder hinzufügen", + "groupsAddMembersAction": "Hinzufügen", + "groupsAddMembersFailed": "Hinzufügen fehlgeschlagen: {error}", + "groupsNoContactsToAdd": "Alle deine Kontakte sind schon in der Gruppe. Füge neue Personen zuerst per QR-Code zu deinen Kontakten hinzu.", + "navChats": "Chats", + "chatListNewChat": "Neuer Chat", + "chatListSearchHint": "Chats und Nachrichten durchsuchen", + "chatFilterAll": "Alle", + "chatFilterUnread": "Ungelesen", + "chatFilterGroups": "Gruppen", + "chatFilterArchive": "Archiv", + "chatListNoResults": "Nichts gefunden.", + "chatListFilterEmpty": "Hier ist noch nichts.", + "chatListPin": "Anheften", + "chatListUnpin": "Lösen", + "chatOpenProfile": "Profil anzeigen", + "groupsProfile": "Gruppeninfo", + "chatSearchHint": "Im Chat suchen", + "chatSearchNoMatches": "Keine", + "chatSearchCounter": "{current} von {total}", + "chatSearchOlder": "Ältere Treffer", + "chatSearchNewer": "Neuerer Treffer", + "chatUnreadDivider": "{count, plural, one{1 neue Nachricht} other{{count} neue Nachrichten}}", + "chatJumpToLatest": "Zu den neuesten", + "chatAttach": "Anhängen", + "chatSend": "Senden", + "chatRecordVoice": "Sprachnachricht aufnehmen", + "profileSharedMedia": "Geteilte Medien", + "profileMessage": "Nachricht", + "profileSearch": "Suchen", + "profileUnmute": "Ton an", + "profileDisappearing": "Verschwinden", + "profileCommonGroups": "Gemeinsame Gruppen", + "profileSafetyNumber": "Sicherheitsnummer", + "profileSafetyNumberHint": "Persönlich oder per QR-Code vergleichen", + "profileVerifiedInPerson": "Persönlich verifiziert", + "profileVerifyNow": "Kontakt verifizieren", + "profileAdd": "Hinzufügen", + "profileGroupSubtitle": "Gruppe · {members} · erstellt am {date}", + "profileMembersOfMax": "{count} von {max}", + "chatTyping": "schreibt…", + "chatTypingNamed": "{name} schreibt…", + "chatTypingSeveral": "Mehrere schreiben…", + "settingsTypingTitle": "Tippindikator", + "settingsTypingSubtitle": "Gegenseitig — wer nicht teilt, sieht nichts (standardmäßig aus)", + "pollAttach": "Umfrage", + "pollCreateTitle": "Neue Umfrage", + "pollQuestion": "Frage", + "pollOptionHint": "Option {number}", + "pollAddOption": "Option hinzufügen", + "pollRemoveOption": "Option entfernen", + "pollAllowMultiple": "Mehrere Antworten erlauben", + "pollSingle": "Umfrage", + "pollMulti": "Umfrage, Mehrfachauswahl", + "pollVoters": "{count, plural, =0{noch keine Stimmen} one{1 Stimme} other{{count} Stimmen}}", + "pollFallback": "Umfrage: {question}", + "pollFallbackUpdate": "Zum Abstimmen Šepot aktualisieren.", + "profilePhotoEdit": "Profilbild ändern", + "profilePhotoPick": "Foto auswählen", + "profilePhotoRemove": "Foto entfernen", + "profilePhotoHint": "Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.", + "profilePhotoFailed": "Foto konnte nicht gesetzt werden: {error}" } diff --git a/app/lib/l10n/app_en.arb b/app/lib/l10n/app_en.arb index da2ff93..893ac7f 100644 --- a/app/lib/l10n/app_en.arb +++ b/app/lib/l10n/app_en.arb @@ -18,9 +18,23 @@ "groupsRename": "Rename group", "groupsLeave": "Leave group", "groupsLeftBanner": "You're no longer in this group. Your history stays, but new messages won't arrive.", - "groupsMembersWithCount": "Members ({count})", "groupsYou": "You", "groupsFounder": "Founder", + "groupsAdmin": "Admin", + "groupsMakeAdmin": "Make admin", + "groupsDismissAdmin": "Remove admin role", + "groupsRemoveMember": "Remove from group", + "groupsRemoveMemberConfirmTitle": "Remove {name} from the group?", + "groupsRemoveMemberConfirmBody": "They will stop getting new messages from this group. Their existing history stays.", + "groupsRemoveAction": "Remove", + "groupsViewContact": "View contact", + "groupsAdminOnlyHint": "Only admins can change the group name, members and admins.", + "groupsAdminOnlyCompatHint": "Only admins can rename the group or change admins. Anyone can still add people, because someone in the group has an older version of Šepot.", + "groupsAdminActionFailed": "The change failed: {error}", + "groupsResyncNotice": "The group is catching up with its admin. Member changes may show up with a delay.", + "groupsRejectedChangeNotice": "Someone without permission tried to change the group. The change was not applied.", + "errorGroupNotAdmin": "Only a group admin can do this.", + "errorGroupLastAdmin": "You're the only admin. Make someone else an admin first.", "groupsNameHint": "Group name", "commonCancel": "Cancel", "commonSave": "Save", @@ -51,6 +65,12 @@ "chatEditingMessage": "Editing message", "chatReply": "Reply", "chatActionReply": "Reply", + "chatActionForward": "Forward", + "forwardTitle": "Forward to…", + "forwardSearchHint": "Search contact or group", + "forwardEmpty": "Nothing here", + "forwardDone": "Forwarded to {name}", + "forwardFailed": "Forwarding failed: {error}", "commonCopy": "Copy", "commonEdit": "Edit", "chatDeleteForMe": "Delete for me", @@ -68,6 +88,9 @@ "attachmentTakePhoto": "Take photo", "attachmentDocument": "Document", "attachmentTooLarge": "File is too large (max {maxMb} MB)", + "attachmentKeepsMetadataTitle": "Photo will include location", + "attachmentKeepsMetadataBody": "I can't remove metadata from this photo format (e.g. HEIC). It would be sent as the original, including where it was taken and details about the phone. Sending it via Gallery is safer, as metadata is removed there.", + "attachmentSendOriginal": "Send original", "attachmentPickFailed": "Picking attachment failed: {error}", "attachmentSendFailed": "Sending attachment failed: {error}", "chatSendFailed": "Sending failed: {error}", @@ -79,6 +102,7 @@ "errorContactLinkIncomplete": "This link is incomplete — the other person created it before connecting. Ask them for a new sepot:// link.", "errorAttachmentTooLarge": "Attachment is too large (max {maxMb} MB)", "errorGroupNeedsMember": "A group needs at least 1 member", + "errorAttachmentUnavailable": "The attachment is no longer on this phone", "errorGroupMaxMembers": "A group can have at most {maxMembers} members", "errorMemberNoDeliveryAddress": "{displayName} has no delivery address — ask them for a new link", "errorContactNoDeliveryAddress": "This contact has no delivery address — ask them for a new sepot:// link", @@ -163,9 +187,36 @@ "attachmentOpenFailed": "Opening attachment failed: {error}", "commonFile": "File", "chatListNewConversation": "New conversation", - "chatListPhotoWithCaptionPreview": "📷 {caption}", - "chatListFilePreview": "📎 {fileName}", "chatListReadReceiptPreview": "Read", + "chatListDraft": "Draft", + "notesTitle": "Note to self", + "notesSubtitle": "Only on this phone, encrypted", + "callVoice": "Call", + "callVideo": "Video call", + "callStatusRinging": "Ringing…", + "callIncomingVoice": "Incoming call", + "callIncomingVideo": "Incoming video call", + "callStatusConnecting": "Connecting…", + "callEnded": "Call ended", + "callEndedBusy": "Busy", + "callEndedRejected": "Call declined", + "callEndedUnanswered": "No answer", + "callEndedFailed": "Couldn't connect", + "callEncrypted": "End-to-end encrypted", + "callAccept": "Accept", + "callReject": "Decline", + "callMute": "Mute", + "callSpeaker": "Speaker", + "callCamera": "Turn off camera", + "callSwitchCamera": "Switch camera", + "callHangUp": "Hang up", + "callLogMissed": "Missed call", + "callLogUnanswered": "Unanswered call", + "callLogRejected": "Declined call", + "callLogBusy": "Busy", + "callLogFailed": "Call failed", + "callLogVoice": "Call · {duration}", + "callLogVideo": "Video call · {duration}", "chatListTimeNow": "now", "chatListTimeMinutes": "{minutes} min", "qrScanTitle": "Scan QR code", @@ -266,9 +317,9 @@ "pinUnlockError": "Unlock error", "pushTitle": "Push notifications", "pushModeOffDesc": "Messages arrive only after you open the app.", - "pushModeUp": "UnifiedPush (recommended)", + "pushModeUp": "UnifiedPush (no Google)", "pushModeUpDesc": "De-Googled — needs the ntfy app (F-Droid/Play).", - "pushModeFcm": "Google (FCM)", + "pushModeFcm": "Google (FCM, default)", "pushModeFcmDesc": "Reliable without an extra app. The device push token goes to Google; message content never does.", "pushSetupFailed": "Setting up push failed: {error}", "pushHintOff": "Messages arrive only when you open Šepot — wake-ups aren’t set up.", @@ -280,7 +331,7 @@ "pushPickDistributorTitle": "Choose a distributor", "pushSubtitleUnifiedPush": "UnifiedPush · {distributor}", "pushNoDistributorFallback": "no distributor", - "pushSubtitleFcm": "Google (FCM) — fast, opt-in", + "pushSubtitleFcm": "Google (FCM)", "restoreErrorNameRequired": "Enter a display name.", "restoreErrorWordCount": "{wordCount, plural, one{The phrase has 1 word, {requiredWords} are needed.} other{The phrase has {wordCount} words, {requiredWords} are needed.}}", "restoreErrorInvalidPhrase": "The phrase isn't valid — check the words and their order.", @@ -364,5 +415,75 @@ "photoPreviewSend": "Send", "photoPreviewTextDialogTitle": "Text on photo", "photoPreviewRemoveText": "Remove", - "photoPreviewDecodeFailed": "Couldn't load the photo" + "photoPreviewDecodeFailed": "Couldn't load the photo", + "chatActionInfo": "Message info", + "messageInfoSent": "Sent", + "messageInfoDelivered": "Delivered", + "messageInfoRead": "Read", + "messageInfoPending": "not yet", + "messageInfoReadReceiptsOff": "You only see when people read your messages if you share read receipts yourself (Settings).", + "chatStatusReadAt": "Read {time}", + "chatStatusDeliveredAt": "Delivered {time}", + "groupsAddMembers": "Add members", + "groupsAddMembersAction": "Add", + "groupsAddMembersFailed": "Could not add members: {error}", + "groupsNoContactsToAdd": "All your contacts are already in this group. Add someone new to your contacts via QR code first.", + "navChats": "Chats", + "chatListNewChat": "New chat", + "chatListSearchHint": "Search chats and messages", + "chatFilterAll": "All", + "chatFilterUnread": "Unread", + "chatFilterGroups": "Groups", + "chatFilterArchive": "Archive", + "chatListNoResults": "Nothing found.", + "chatListFilterEmpty": "Nothing here yet.", + "chatListPin": "Pin", + "chatListUnpin": "Unpin", + "chatOpenProfile": "View profile", + "groupsProfile": "Group info", + "chatSearchHint": "Search in chat", + "chatSearchNoMatches": "None", + "chatSearchCounter": "{current} of {total}", + "chatSearchOlder": "Older match", + "chatSearchNewer": "Newer match", + "chatUnreadDivider": "{count, plural, one{1 new message} other{{count} new messages}}", + "chatJumpToLatest": "Jump to latest", + "chatAttach": "Attach", + "chatSend": "Send", + "chatRecordVoice": "Record voice message", + "profileSharedMedia": "Shared media", + "profileMessage": "Message", + "profileSearch": "Search", + "profileUnmute": "Unmute", + "profileDisappearing": "Disappear", + "profileCommonGroups": "Groups in common", + "profileSafetyNumber": "Safety number", + "profileSafetyNumberHint": "Compare in person or by QR code", + "profileVerifiedInPerson": "Verified in person", + "profileVerifyNow": "Verify contact", + "profileAdd": "Add", + "profileGroupSubtitle": "Group · {members} · created {date}", + "profileMembersOfMax": "{count} of {max}", + "chatTyping": "typing…", + "chatTypingNamed": "{name} is typing…", + "chatTypingSeveral": "Several people are typing…", + "settingsTypingTitle": "Typing indicators", + "settingsTypingSubtitle": "Mutual — if you don't share it, you don't see it (off by default)", + "pollAttach": "Poll", + "pollCreateTitle": "New poll", + "pollQuestion": "Question", + "pollOptionHint": "Option {number}", + "pollAddOption": "Add option", + "pollRemoveOption": "Remove option", + "pollAllowMultiple": "Allow multiple answers", + "pollSingle": "Poll", + "pollMulti": "Poll, multiple choice", + "pollVoters": "{count, plural, =0{no votes yet} one{1 vote} other{{count} votes}}", + "pollFallback": "Poll: {question}", + "pollFallbackUpdate": "Update Šepot to vote.", + "profilePhotoEdit": "Change profile photo", + "profilePhotoPick": "Choose photo", + "profilePhotoRemove": "Remove photo", + "profilePhotoHint": "Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.", + "profilePhotoFailed": "Could not set photo: {error}" } diff --git a/app/lib/l10n/app_it.arb b/app/lib/l10n/app_it.arb index 4fc0143..0f0c9e0 100644 --- a/app/lib/l10n/app_it.arb +++ b/app/lib/l10n/app_it.arb @@ -18,9 +18,23 @@ "groupsRename": "Rinomina gruppo", "groupsLeave": "Abbandona il gruppo", "groupsLeftBanner": "Non sei più nel gruppo. La cronologia resta, i nuovi messaggi non arriveranno.", - "groupsMembersWithCount": "Membri ({count})", "groupsYou": "Tu", "groupsFounder": "Fondatore", + "groupsAdmin": "Amministratore", + "groupsMakeAdmin": "Rendi amministratore", + "groupsDismissAdmin": "Rimuovi ruolo di amministratore", + "groupsRemoveMember": "Rimuovi dal gruppo", + "groupsRemoveMemberConfirmTitle": "Rimuovere {name} dal gruppo?", + "groupsRemoveMemberConfirmBody": "Non riceverà più i nuovi messaggi del gruppo. La cronologia esistente resta.", + "groupsRemoveAction": "Rimuovi", + "groupsViewContact": "Vedi contatto", + "groupsAdminOnlyHint": "Solo gli amministratori possono cambiare nome, membri e amministratori del gruppo.", + "groupsAdminOnlyCompatHint": "Solo gli amministratori possono rinominare il gruppo o cambiare gli amministratori. Per ora chiunque può aggiungere persone, perché qualcuno nel gruppo ha una versione precedente di Šepot.", + "groupsAdminActionFailed": "Modifica non riuscita: {error}", + "groupsResyncNotice": "Il gruppo si sta allineando con l'amministratore. Le modifiche ai membri potrebbero arrivare in ritardo.", + "groupsRejectedChangeNotice": "Qualcuno senza permesso ha provato a modificare il gruppo. La modifica non è stata applicata.", + "errorGroupNotAdmin": "Solo un amministratore del gruppo può farlo.", + "errorGroupLastAdmin": "Sei l'unico amministratore. Prima rendi amministratore qualcun altro.", "groupsNameHint": "Nome del gruppo", "commonCancel": "Annulla", "commonSave": "Salva", @@ -51,6 +65,12 @@ "chatEditingMessage": "Modifica messaggio", "chatReply": "Risposta", "chatActionReply": "Rispondi", + "chatActionForward": "Inoltra", + "forwardTitle": "Inoltra a…", + "forwardSearchHint": "Cerca contatto o gruppo", + "forwardEmpty": "Nessun risultato", + "forwardDone": "Inoltrato a {name}", + "forwardFailed": "Inoltro non riuscito: {error}", "commonCopy": "Copia", "commonEdit": "Modifica", "chatDeleteForMe": "Elimina per me", @@ -68,6 +88,9 @@ "attachmentTakePhoto": "Scatta foto", "attachmentDocument": "Documento", "attachmentTooLarge": "Il file è troppo grande (max {maxMb} MB)", + "attachmentKeepsMetadataTitle": "La foto includerà la posizione", + "attachmentKeepsMetadataBody": "Non riesco a rimuovere i metadati da questo formato (ad es. HEIC). Verrebbe inviata in originale, compresi il luogo dello scatto e i dati del telefono. È più sicuro inviarla dalla Galleria, dove i metadati vengono rimossi.", + "attachmentSendOriginal": "Invia originale", "attachmentPickFailed": "Selezione dell'allegato non riuscita: {error}", "attachmentSendFailed": "Invio dell'allegato non riuscito: {error}", "chatSendFailed": "Invio non riuscito: {error}", @@ -79,6 +102,7 @@ "errorContactLinkIncomplete": "Questo link è incompleto — l'altra persona l'ha creato prima di connettersi. Chiedile un nuovo link sepot://.", "errorAttachmentTooLarge": "L'allegato è troppo grande (max {maxMb} MB)", "errorGroupNeedsMember": "Il gruppo ha bisogno di almeno 1 membro", + "errorAttachmentUnavailable": "L'allegato non è più su questo telefono", "errorGroupMaxMembers": "Il gruppo può avere al massimo {maxMembers} membri", "errorMemberNoDeliveryAddress": "{displayName} non ha un indirizzo di consegna — chiedi un nuovo link", "errorContactNoDeliveryAddress": "Il contatto non ha un indirizzo di consegna — chiedigli un nuovo link sepot://", @@ -163,9 +187,36 @@ "attachmentOpenFailed": "Impossibile aprire l'allegato: {error}", "commonFile": "File", "chatListNewConversation": "Nuova conversazione", - "chatListPhotoWithCaptionPreview": "📷 {caption}", - "chatListFilePreview": "📎 {fileName}", "chatListReadReceiptPreview": "Letto", + "chatListDraft": "Bozza", + "notesTitle": "Note per me", + "notesSubtitle": "Solo su questo telefono, cifrato", + "callVoice": "Chiama", + "callVideo": "Videochiamata", + "callStatusRinging": "Squilla…", + "callIncomingVoice": "Chiamata in arrivo", + "callIncomingVideo": "Videochiamata in arrivo", + "callStatusConnecting": "Connessione…", + "callEnded": "Chiamata terminata", + "callEndedBusy": "Occupato", + "callEndedRejected": "Chiamata rifiutata", + "callEndedUnanswered": "Nessuna risposta", + "callEndedFailed": "Connessione non riuscita", + "callEncrypted": "Crittografia end-to-end", + "callAccept": "Accetta", + "callReject": "Rifiuta", + "callMute": "Disattiva microfono", + "callSpeaker": "Altoparlante", + "callCamera": "Disattiva fotocamera", + "callSwitchCamera": "Cambia fotocamera", + "callHangUp": "Riaggancia", + "callLogMissed": "Chiamata persa", + "callLogUnanswered": "Chiamata senza risposta", + "callLogRejected": "Chiamata rifiutata", + "callLogBusy": "Occupato", + "callLogFailed": "Chiamata non riuscita", + "callLogVoice": "Chiamata · {duration}", + "callLogVideo": "Videochiamata · {duration}", "chatListTimeNow": "adesso", "chatListTimeMinutes": "{minutes} min", "qrScanTitle": "Scansiona codice QR", @@ -266,9 +317,9 @@ "pinUnlockError": "Errore di sblocco", "pushTitle": "Notifiche push", "pushModeOffDesc": "I messaggi verranno scaricati solo all'apertura dell'app.", - "pushModeUp": "UnifiedPush (consigliato)", + "pushModeUp": "UnifiedPush (senza Google)", "pushModeUpDesc": "Senza Google — richiede l'app ntfy (F-Droid/Play).", - "pushModeFcm": "Google (FCM)", + "pushModeFcm": "Google (FCM, predefinito)", "pushModeFcmDesc": "Affidabile senza altre app. Il token push del dispositivo va a Google; il contenuto dei messaggi mai.", "pushSetupFailed": "Configurazione delle notifiche push non riuscita: {error}", "pushHintOff": "I messaggi arrivano solo quando apri Šepot — le notifiche non sono configurate.", @@ -280,7 +331,7 @@ "pushPickDistributorTitle": "Scegli il distributore", "pushSubtitleUnifiedPush": "UnifiedPush · {distributor}", "pushNoDistributorFallback": "nessun distributore", - "pushSubtitleFcm": "Google (FCM) — veloce, opt-in", + "pushSubtitleFcm": "Google (FCM)", "restoreErrorNameRequired": "Inserisci il nome visualizzato.", "restoreErrorWordCount": "{wordCount, plural, one{La frase ha 1 parola, ne servono {requiredWords}.} other{La frase ha {wordCount} parole, ne servono {requiredWords}.}}", "restoreErrorInvalidPhrase": "La frase non è valida — controlla le parole e il loro ordine.", @@ -364,5 +415,75 @@ "photoPreviewSend": "Invia", "photoPreviewTextDialogTitle": "Testo sulla foto", "photoPreviewRemoveText": "Rimuovi", - "photoPreviewDecodeFailed": "Impossibile caricare la foto" + "photoPreviewDecodeFailed": "Impossibile caricare la foto", + "chatActionInfo": "Info messaggio", + "messageInfoSent": "Inviato", + "messageInfoDelivered": "Consegnato", + "messageInfoRead": "Letto", + "messageInfoPending": "non ancora", + "messageInfoReadReceiptsOff": "Vedi quando i tuoi messaggi vengono letti solo se condividi anche tu le conferme di lettura (Impostazioni).", + "chatStatusReadAt": "Letto {time}", + "chatStatusDeliveredAt": "Consegnato {time}", + "groupsAddMembers": "Aggiungi membri", + "groupsAddMembersAction": "Aggiungi", + "groupsAddMembersFailed": "Impossibile aggiungere: {error}", + "groupsNoContactsToAdd": "Tutti i tuoi contatti sono già nel gruppo. Aggiungi prima una nuova persona ai contatti tramite codice QR.", + "navChats": "Chat", + "chatListNewChat": "Nuova chat", + "chatListSearchHint": "Cerca in chat e messaggi", + "chatFilterAll": "Tutte", + "chatFilterUnread": "Non lette", + "chatFilterGroups": "Gruppi", + "chatFilterArchive": "Archivio", + "chatListNoResults": "Nessun risultato.", + "chatListFilterEmpty": "Ancora niente qui.", + "chatListPin": "Fissa", + "chatListUnpin": "Sblocca", + "chatOpenProfile": "Vedi profilo", + "groupsProfile": "Info gruppo", + "chatSearchHint": "Cerca nella chat", + "chatSearchNoMatches": "Nessuno", + "chatSearchCounter": "{current} di {total}", + "chatSearchOlder": "Risultato precedente", + "chatSearchNewer": "Risultato successivo", + "chatUnreadDivider": "{count, plural, one{1 nuovo messaggio} other{{count} nuovi messaggi}}", + "chatJumpToLatest": "Vai ai più recenti", + "chatAttach": "Allega", + "chatSend": "Invia", + "chatRecordVoice": "Registra messaggio vocale", + "profileSharedMedia": "Media condivisi", + "profileMessage": "Messaggio", + "profileSearch": "Cerca", + "profileUnmute": "Riattiva", + "profileDisappearing": "Effimeri", + "profileCommonGroups": "Gruppi in comune", + "profileSafetyNumber": "Numero di sicurezza", + "profileSafetyNumberHint": "Confronta di persona o con codice QR", + "profileVerifiedInPerson": "Verificato di persona", + "profileVerifyNow": "Verifica contatto", + "profileAdd": "Aggiungi", + "profileGroupSubtitle": "Gruppo · {members} · creato il {date}", + "profileMembersOfMax": "{count} su {max}", + "chatTyping": "sta scrivendo…", + "chatTypingNamed": "{name} sta scrivendo…", + "chatTypingSeveral": "Più persone stanno scrivendo…", + "settingsTypingTitle": "Indicatore di scrittura", + "settingsTypingSubtitle": "Reciproco — se non lo condividi, non lo vedi (disattivato di default)", + "pollAttach": "Sondaggio", + "pollCreateTitle": "Nuovo sondaggio", + "pollQuestion": "Domanda", + "pollOptionHint": "Opzione {number}", + "pollAddOption": "Aggiungi opzione", + "pollRemoveOption": "Rimuovi opzione", + "pollAllowMultiple": "Consenti più risposte", + "pollSingle": "Sondaggio", + "pollMulti": "Sondaggio, scelta multipla", + "pollVoters": "{count, plural, =0{ancora nessun voto} one{1 voto} other{{count} voti}}", + "pollFallback": "Sondaggio: {question}", + "pollFallbackUpdate": "Aggiorna Šepot per votare.", + "profilePhotoEdit": "Cambia foto profilo", + "profilePhotoPick": "Scegli foto", + "profilePhotoRemove": "Rimuovi foto", + "profilePhotoHint": "La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.", + "profilePhotoFailed": "Impossibile impostare la foto: {error}" } diff --git a/app/lib/l10n/app_ru.arb b/app/lib/l10n/app_ru.arb index 5d05175..eb80d78 100644 --- a/app/lib/l10n/app_ru.arb +++ b/app/lib/l10n/app_ru.arb @@ -18,9 +18,23 @@ "groupsRename": "Переименовать группу", "groupsLeave": "Покинуть группу", "groupsLeftBanner": "Вы больше не в группе. История сохранится, но новые сообщения приходить не будут.", - "groupsMembersWithCount": "Участники ({count})", "groupsYou": "Вы", "groupsFounder": "Создатель", + "groupsAdmin": "Администратор", + "groupsMakeAdmin": "Сделать администратором", + "groupsDismissAdmin": "Снять роль администратора", + "groupsRemoveMember": "Удалить из группы", + "groupsRemoveMemberConfirmTitle": "Удалить {name} из группы?", + "groupsRemoveMemberConfirmBody": "Новые сообщения группы перестанут приходить. Прежняя история сохранится.", + "groupsRemoveAction": "Удалить", + "groupsViewContact": "Открыть контакт", + "groupsAdminOnlyHint": "Название, участников и администраторов группы меняют только администраторы.", + "groupsAdminOnlyCompatHint": "Переименовать группу и менять администраторов могут только администраторы. Добавлять людей пока может любой, потому что у кого-то в группе старая версия Šepot.", + "groupsAdminActionFailed": "Не удалось внести изменение: {error}", + "groupsResyncNotice": "Группа сверяется с администратором. Изменения состава могут появиться с задержкой.", + "groupsRejectedChangeNotice": "Кто-то без прав попытался изменить группу. Изменение не применено.", + "errorGroupNotAdmin": "Это может сделать только администратор группы.", + "errorGroupLastAdmin": "Вы единственный администратор. Сначала назначьте администратором кого-то ещё.", "groupsNameHint": "Название группы", "commonCancel": "Отмена", "commonSave": "Сохранить", @@ -51,6 +65,12 @@ "chatEditingMessage": "Редактирование сообщения", "chatReply": "Ответ", "chatActionReply": "Ответить", + "chatActionForward": "Переслать", + "forwardTitle": "Переслать в…", + "forwardSearchHint": "Найти контакт или группу", + "forwardEmpty": "Ничего не найдено", + "forwardDone": "Переслано: {name}", + "forwardFailed": "Не удалось переслать: {error}", "commonCopy": "Копировать", "commonEdit": "Изменить", "chatDeleteForMe": "Удалить у меня", @@ -68,6 +88,9 @@ "attachmentTakePhoto": "Сделать фото", "attachmentDocument": "Документ", "attachmentTooLarge": "Файл слишком большой (макс. {maxMb} МБ)", + "attachmentKeepsMetadataTitle": "Фото будет с геолокацией", + "attachmentKeepsMetadataBody": "Из этого формата фото (например, HEIC) я не могу удалить метаданные. Оно будет отправлено в оригинале, включая место съёмки и данные о телефоне. Безопаснее отправить его через Галерею — там метаданные удаляются.", + "attachmentSendOriginal": "Отправить оригинал", "attachmentPickFailed": "Не удалось выбрать вложение: {error}", "attachmentSendFailed": "Не удалось отправить вложение: {error}", "chatSendFailed": "Не удалось отправить: {error}", @@ -79,6 +102,7 @@ "errorContactLinkIncomplete": "Ссылка неполная — собеседник создал её до подключения. Попросите у него новую ссылку sepot://.", "errorAttachmentTooLarge": "Вложение слишком большое (макс. {maxMb} МБ)", "errorGroupNeedsMember": "В группе должен быть хотя бы 1 участник", + "errorAttachmentUnavailable": "Вложения больше нет на этом телефоне", "errorGroupMaxMembers": "Максимальное число участников группы — {maxMembers}", "errorMemberNoDeliveryAddress": "У контакта {displayName} нет адреса доставки — запросите новую ссылку", "errorContactNoDeliveryAddress": "У контакта нет адреса доставки — попросите у него новую ссылку sepot://", @@ -163,9 +187,36 @@ "attachmentOpenFailed": "Не удалось открыть вложение: {error}", "commonFile": "Файл", "chatListNewConversation": "Новый чат", - "chatListPhotoWithCaptionPreview": "📷 {caption}", - "chatListFilePreview": "📎 {fileName}", "chatListReadReceiptPreview": "Прочитано", + "chatListDraft": "Черновик", + "notesTitle": "Заметки для себя", + "notesSubtitle": "Только на этом телефоне, в зашифрованном виде", + "callVoice": "Позвонить", + "callVideo": "Видеозвонок", + "callStatusRinging": "Вызов…", + "callIncomingVoice": "Входящий звонок", + "callIncomingVideo": "Входящий видеозвонок", + "callStatusConnecting": "Соединение…", + "callEnded": "Звонок завершён", + "callEndedBusy": "Занято", + "callEndedRejected": "Звонок отклонён", + "callEndedUnanswered": "Нет ответа", + "callEndedFailed": "Не удалось соединиться", + "callEncrypted": "Сквозное шифрование", + "callAccept": "Принять", + "callReject": "Отклонить", + "callMute": "Выключить микрофон", + "callSpeaker": "Динамик", + "callCamera": "Выключить камеру", + "callSwitchCamera": "Сменить камеру", + "callHangUp": "Завершить", + "callLogMissed": "Пропущенный звонок", + "callLogUnanswered": "Звонок без ответа", + "callLogRejected": "Отклонённый звонок", + "callLogBusy": "Занято", + "callLogFailed": "Звонок не удался", + "callLogVoice": "Звонок · {duration}", + "callLogVideo": "Видеозвонок · {duration}", "chatListTimeNow": "сейчас", "chatListTimeMinutes": "{minutes} мин", "qrScanTitle": "Сканировать QR-код", @@ -266,9 +317,9 @@ "pinUnlockError": "Ошибка разблокировки", "pushTitle": "Push-уведомления", "pushModeOffDesc": "Сообщения загрузятся только после открытия приложения.", - "pushModeUp": "UnifiedPush (рекомендуется)", + "pushModeUp": "UnifiedPush (без Google)", "pushModeUpDesc": "Без сервисов Google — требуется приложение ntfy (F-Droid/Play).", - "pushModeFcm": "Google (FCM)", + "pushModeFcm": "Google (FCM, по умолчанию)", "pushModeFcmDesc": "Надёжно и без дополнительных приложений. Push-токен устройства передаётся Google; содержимое сообщений — никогда.", "pushSetupFailed": "Не удалось настроить push: {error}", "pushHintOff": "Сообщения придут, только когда откроешь Šepot — сигналы не настроены.", @@ -280,7 +331,7 @@ "pushPickDistributorTitle": "Выберите дистрибьютора", "pushSubtitleUnifiedPush": "UnifiedPush · {distributor}", "pushNoDistributorFallback": "без дистрибьютора", - "pushSubtitleFcm": "Google (FCM) — быстро, только по вашему выбору", + "pushSubtitleFcm": "Google (FCM)", "restoreErrorNameRequired": "Введите отображаемое имя.", "restoreErrorWordCount": "{wordCount, plural, one{Во фразе {wordCount} слово, а нужно {requiredWords}.} few{Во фразе {wordCount} слова, а нужно {requiredWords}.} many{Во фразе {wordCount} слов, а нужно {requiredWords}.} other{Во фразе {wordCount} слова, а нужно {requiredWords}.}}", "restoreErrorInvalidPhrase": "Фраза недействительна — проверьте слова и их порядок.", @@ -364,5 +415,75 @@ "photoPreviewSend": "Отправить", "photoPreviewTextDialogTitle": "Текст на фото", "photoPreviewRemoveText": "Удалить", - "photoPreviewDecodeFailed": "Не удалось загрузить фото" + "photoPreviewDecodeFailed": "Не удалось загрузить фото", + "chatActionInfo": "Сведения о сообщении", + "messageInfoSent": "Отправлено", + "messageInfoDelivered": "Доставлено", + "messageInfoRead": "Прочитано", + "messageInfoPending": "ещё нет", + "messageInfoReadReceiptsOff": "Время прочтения видно, только если вы сами делитесь отчётами о прочтении (Настройки).", + "chatStatusReadAt": "Прочитано {time}", + "chatStatusDeliveredAt": "Доставлено {time}", + "groupsAddMembers": "Добавить участников", + "groupsAddMembersAction": "Добавить", + "groupsAddMembersFailed": "Не удалось добавить: {error}", + "groupsNoContactsToAdd": "Все ваши контакты уже в группе. Сначала добавьте нового человека в контакты по QR-коду.", + "navChats": "Чаты", + "chatListNewChat": "Новый чат", + "chatListSearchHint": "Поиск по чатам и сообщениям", + "chatFilterAll": "Все", + "chatFilterUnread": "Непрочитанные", + "chatFilterGroups": "Группы", + "chatFilterArchive": "Архив", + "chatListNoResults": "Ничего не найдено.", + "chatListFilterEmpty": "Здесь пока ничего нет.", + "chatListPin": "Закрепить", + "chatListUnpin": "Открепить", + "chatOpenProfile": "Открыть профиль", + "groupsProfile": "Информация о группе", + "chatSearchHint": "Поиск в чате", + "chatSearchNoMatches": "Нет", + "chatSearchCounter": "{current} из {total}", + "chatSearchOlder": "Предыдущее совпадение", + "chatSearchNewer": "Следующее совпадение", + "chatUnreadDivider": "{count, plural, one{{count} новое сообщение} few{{count} новых сообщения} other{{count} новых сообщений}}", + "chatJumpToLatest": "К последним сообщениям", + "chatAttach": "Прикрепить", + "chatSend": "Отправить", + "chatRecordVoice": "Записать голосовое", + "profileSharedMedia": "Общие медиа", + "profileMessage": "Написать", + "profileSearch": "Поиск", + "profileUnmute": "Включить звук", + "profileDisappearing": "Исчезание", + "profileCommonGroups": "Общие группы", + "profileSafetyNumber": "Код безопасности", + "profileSafetyNumberHint": "Сравните лично или по QR-коду", + "profileVerifiedInPerson": "Проверено лично", + "profileVerifyNow": "Проверить контакт", + "profileAdd": "Добавить", + "profileGroupSubtitle": "Группа · {members} · создана {date}", + "profileMembersOfMax": "{count} из {max}", + "chatTyping": "печатает…", + "chatTypingNamed": "{name} печатает…", + "chatTypingSeveral": "Несколько человек печатают…", + "settingsTypingTitle": "Индикатор набора", + "settingsTypingSubtitle": "Взаимно — не делитесь, не видите (по умолчанию выкл.)", + "pollAttach": "Опрос", + "pollCreateTitle": "Новый опрос", + "pollQuestion": "Вопрос", + "pollOptionHint": "Вариант {number}", + "pollAddOption": "Добавить вариант", + "pollRemoveOption": "Удалить вариант", + "pollAllowMultiple": "Разрешить несколько ответов", + "pollSingle": "Опрос", + "pollMulti": "Опрос, несколько ответов", + "pollVoters": "{count, plural, =0{пока нет голосов} one{{count} голос} few{{count} голоса} other{{count} голосов}}", + "pollFallback": "Опрос: {question}", + "pollFallbackUpdate": "Обновите Šepot, чтобы голосовать.", + "profilePhotoEdit": "Изменить фото профиля", + "profilePhotoPick": "Выбрать фото", + "profilePhotoRemove": "Удалить фото", + "profilePhotoHint": "Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.", + "profilePhotoFailed": "Не удалось установить фото: {error}" } diff --git a/app/lib/l10n/generated/app_localizations.dart b/app/lib/l10n/generated/app_localizations.dart index efe465e..6b33dda 100644 --- a/app/lib/l10n/generated/app_localizations.dart +++ b/app/lib/l10n/generated/app_localizations.dart @@ -212,12 +212,6 @@ abstract class AppLocalizations { /// **'Už nejsi ve skupině. Historie zůstává, nové zprávy nedorazí.'** String get groupsLeftBanner; - /// Nadpis bottom sheetu se seznamem členů skupiny - /// - /// In cs, this message translates to: - /// **'Členové ({count})'** - String groupsMembersWithCount(int count); - /// Fallback jména vlastního avataru v seznamu členů skupiny | Titulek ListTile vlastní osoby v seznamu členů skupiny — stejný klíč jako ř. 486 /// /// In cs, this message translates to: @@ -230,6 +224,96 @@ abstract class AppLocalizations { /// **'Zakladatel'** String get groupsFounder; + /// Odznak správce skupiny u člena v seznamu členů + /// + /// In cs, this message translates to: + /// **'Správce'** + String get groupsAdmin; + + /// Akce v nabídce člena: udělit roli správce + /// + /// In cs, this message translates to: + /// **'Udělat správcem'** + String get groupsMakeAdmin; + + /// Akce v nabídce člena: odebrat roli správce + /// + /// In cs, this message translates to: + /// **'Odebrat roli správce'** + String get groupsDismissAdmin; + + /// Akce v nabídce člena: odebrat ze skupiny + /// + /// In cs, this message translates to: + /// **'Odebrat ze skupiny'** + String get groupsRemoveMember; + + /// Titulek potvrzení odebrání člena + /// + /// In cs, this message translates to: + /// **'Odebrat {name} ze skupiny?'** + String groupsRemoveMemberConfirmTitle(String name); + + /// Text potvrzení odebrání člena + /// + /// In cs, this message translates to: + /// **'Přestanou mu chodit nové zprávy skupiny. Dosavadní historie mu zůstane.'** + String get groupsRemoveMemberConfirmBody; + + /// Potvrzovací tlačítko odebrání člena + /// + /// In cs, this message translates to: + /// **'Odebrat'** + String get groupsRemoveAction; + + /// Akce v nabídce člena: otevřít profil kontaktu + /// + /// In cs, this message translates to: + /// **'Zobrazit kontakt'** + String get groupsViewContact; + + /// Vysvětlení pod seznamem členů pro ne-správce ve vynucené skupině + /// + /// In cs, this message translates to: + /// **'Jméno, členy a správce skupiny mění jen správci.'** + String get groupsAdminOnlyHint; + + /// Vysvětlení pro ne-správce, dokud je ve skupině člen se starší verzí aplikace + /// + /// In cs, this message translates to: + /// **'Jméno a správce mění jen správci. Přidávat lidi zatím může každý, protože někdo ve skupině má starší verzi Šepotu.'** + String get groupsAdminOnlyCompatHint; + + /// SnackBar při selhání správcovské akce + /// + /// In cs, this message translates to: + /// **'Změna se nepovedla: {error}'** + String groupsAdminActionFailed(String error); + + /// Upozornění v profilu skupiny, když chybí mezistav od správce + /// + /// In cs, this message translates to: + /// **'Stav skupiny se srovnává se správcem. Změny členů se můžou chvíli opozdit.'** + String get groupsResyncNotice; + + /// Upozornění v profilu skupiny po odmítnuté neoprávněné změně + /// + /// In cs, this message translates to: + /// **'Někdo bez oprávnění zkusil skupinu změnit. Změna se neprovedla.'** + String get groupsRejectedChangeNotice; + + /// Chyba: akci smí udělat jen správce skupiny + /// + /// In cs, this message translates to: + /// **'Tohle může udělat jen správce skupiny.'** + String get errorGroupNotAdmin; + + /// Chyba: poslední správce se nemůže vzdát role + /// + /// In cs, this message translates to: + /// **'Jsi jediný správce. Nejdřív udělej správcem někoho dalšího.'** + String get errorGroupLastAdmin; + /// hintText TextFieldu v dialogu přejmenování skupiny | labelText textového pole pro jméno skupiny (max 64 znaků) /// /// In cs, this message translates to: @@ -410,6 +494,42 @@ abstract class AppLocalizations { /// **'Odpovědět'** String get chatActionReply; + /// Akce v nabídce po podržení zprávy + /// + /// In cs, this message translates to: + /// **'Přeposlat'** + String get chatActionForward; + + /// Nadpis panelu výběru chatu pro přeposlání + /// + /// In cs, this message translates to: + /// **'Přeposlat do…'** + String get forwardTitle; + + /// Nápověda v poli hledání panelu přeposlání + /// + /// In cs, this message translates to: + /// **'Hledat kontakt nebo skupinu'** + String get forwardSearchHint; + + /// Prázdný výsledek hledání v panelu přeposlání + /// + /// In cs, this message translates to: + /// **'Nic tu není'** + String get forwardEmpty; + + /// SnackBar po přeposlání zprávy + /// + /// In cs, this message translates to: + /// **'Přeposláno: {name}'** + String forwardDone(String name); + + /// SnackBar při chybě přeposlání + /// + /// In cs, this message translates to: + /// **'Přeposlání se nepovedlo: {error}'** + String forwardFailed(String error); + /// Položka akčního bottom sheetu zprávy — kopírování textu do schránky | Popisek OutlinedButton.icon pro zkopírování Šepot ID do schránky /// /// In cs, this message translates to: @@ -512,6 +632,24 @@ abstract class AppLocalizations { /// **'Soubor je příliš velký (max {maxMb} MB)'** String attachmentTooLarge(int maxMb); + /// Titulek dialogu před odesláním obrázku jako dokumentu, když z něj nejde odstranit EXIF (HEIC, RAW) + /// + /// In cs, this message translates to: + /// **'Fotka odejde i s polohou'** + String get attachmentKeepsMetadataTitle; + + /// Text téhož dialogu + /// + /// In cs, this message translates to: + /// **'Tenhle formát fotky (třeba HEIC) neumím zbavit metadat. Odešla by v originále včetně polohy, kde vznikla, a údajů o telefonu. Bezpečnější je poslat ji přes Galerii, kde se metadata odstraní.'** + String get attachmentKeepsMetadataBody; + + /// Potvrzovací tlačítko téhož dialogu + /// + /// In cs, this message translates to: + /// **'Poslat originál'** + String get attachmentSendOriginal; + /// SnackBar (_showError) při selhání výběru přílohy /// /// In cs, this message translates to: @@ -578,6 +716,12 @@ abstract class AppLocalizations { /// **'Skupina potřebuje aspoň 1 člena'** String get errorGroupNeedsMember; + /// Chyba při přeposlání přílohy, která už byla smazána nebo nestažena + /// + /// In cs, this message translates to: + /// **'Příloha už v telefonu není'** + String get errorAttachmentUnavailable; + /// StateError z createGroup při překročení max. počtu členů; zobrazuje UI zakládání skupiny. Počet v textu + české skloňování (člen/členové/členů) → ICU plural. | StateError z addGroupMembers — identický text jako v createGroup (ř. 583), stejný klíč. ICU plural. /// /// In cs, this message translates to: @@ -1082,24 +1226,186 @@ abstract class AppLocalizations { /// **'Nová konverzace'** String get chatListNewConversation; - /// Náhled poslední zprávy v dlaždici — fotka s popiskem; emoji je součást textu - /// - /// In cs, this message translates to: - /// **'📷 {caption}'** - String chatListPhotoWithCaptionPreview(String caption); - - /// Náhled poslední zprávy v dlaždici — soubor; emoji je součást textu. Skládaný literál: obsahuje vnořený fallback "Soubor" (viz samostatný záznam) — při lokalizaci předat placeholderu už vyřešenou hodnotu msg.fileName ?? commonFile. - /// - /// In cs, this message translates to: - /// **'📎 {fileName}'** - String chatListFilePreview(String fileName); - /// Náhled poslední zprávy v dlaždici pro zprávu typu readReceipt (potvrzení o přečtení) /// /// In cs, this message translates to: /// **'Přečteno'** String get chatListReadReceiptPreview; + /// Předpona náhledu v seznamu chatů, když je v chatu rozepsaná zpráva + /// + /// In cs, this message translates to: + /// **'Koncept'** + String get chatListDraft; + + /// Název chatu se sebou samým (seznam chatů, hlavička, kontakty, přeposlání) + /// + /// In cs, this message translates to: + /// **'Poznámky pro sebe'** + String get notesTitle; + + /// Podtitulek poznámek pro sebe — nic se neodesílá na server + /// + /// In cs, this message translates to: + /// **'Jen v tomto telefonu, šifrovaně'** + String get notesSubtitle; + + /// Tlačítko hlasového hovoru v hlavičce chatu + /// + /// In cs, this message translates to: + /// **'Zavolat'** + String get callVoice; + + /// Tlačítko videohovoru v hlavičce chatu + /// + /// In cs, this message translates to: + /// **'Videohovor'** + String get callVideo; + + /// Stav odchozího hovoru, dokud protistrana nezvedne + /// + /// In cs, this message translates to: + /// **'Vyzvání…'** + String get callStatusRinging; + + /// Stav příchozího hlasového hovoru + /// + /// In cs, this message translates to: + /// **'Příchozí hovor'** + String get callIncomingVoice; + + /// Stav příchozího videohovoru + /// + /// In cs, this message translates to: + /// **'Příchozí videohovor'** + String get callIncomingVideo; + + /// Stav po přijetí, než naběhne spojení + /// + /// In cs, this message translates to: + /// **'Spojuji…'** + String get callStatusConnecting; + + /// Krátce po konci hovoru + /// + /// In cs, this message translates to: + /// **'Hovor skončil'** + String get callEnded; + + /// Protistrana má jiný hovor + /// + /// In cs, this message translates to: + /// **'Obsazeno'** + String get callEndedBusy; + + /// Hovor byl odmítnut + /// + /// In cs, this message translates to: + /// **'Hovor odmítnut'** + String get callEndedRejected; + + /// Odchozí hovor vypršel bez odpovědi + /// + /// In cs, this message translates to: + /// **'Nikdo to nevzal'** + String get callEndedUnanswered; + + /// Hovor se nepodařilo spojit + /// + /// In cs, this message translates to: + /// **'Spojení se nepodařilo'** + String get callEndedFailed; + + /// Podtitulek na obrazovce hovoru + /// + /// In cs, this message translates to: + /// **'Šifrováno end-to-end'** + String get callEncrypted; + + /// Přijetí příchozího hovoru + /// + /// In cs, this message translates to: + /// **'Přijmout'** + String get callAccept; + + /// Odmítnutí příchozího hovoru + /// + /// In cs, this message translates to: + /// **'Odmítnout'** + String get callReject; + + /// Přepínač mikrofonu (popisek pro čtečku) + /// + /// In cs, this message translates to: + /// **'Ztlumit mikrofon'** + String get callMute; + + /// Přepínač hlasitého odposlechu + /// + /// In cs, this message translates to: + /// **'Reproduktor'** + String get callSpeaker; + + /// Přepínač kamery ve videohovoru + /// + /// In cs, this message translates to: + /// **'Vypnout kameru'** + String get callCamera; + + /// Přední/zadní kamera + /// + /// In cs, this message translates to: + /// **'Přepnout kameru'** + String get callSwitchCamera; + + /// Ukončení hovoru + /// + /// In cs, this message translates to: + /// **'Zavěsit'** + String get callHangUp; + + /// Záznam v chatu + /// + /// In cs, this message translates to: + /// **'Zmeškaný hovor'** + String get callLogMissed; + + /// Odchozí hovor, který protistrana nevzala + /// + /// In cs, this message translates to: + /// **'Nepřijatý hovor'** + String get callLogUnanswered; + + /// Záznam v chatu + /// + /// In cs, this message translates to: + /// **'Odmítnutý hovor'** + String get callLogRejected; + + /// Záznam v chatu — protistrana měla jiný hovor + /// + /// In cs, this message translates to: + /// **'Obsazeno'** + String get callLogBusy; + + /// Záznam v chatu + /// + /// In cs, this message translates to: + /// **'Hovor se nespojil'** + String get callLogFailed; + + /// Záznam proběhlého hlasového hovoru s délkou + /// + /// In cs, this message translates to: + /// **'Hovor · {duration}'** + String callLogVoice(String duration); + + /// Záznam proběhlého videohovoru s délkou + /// + /// In cs, this message translates to: + /// **'Videohovor · {duration}'** + String callLogVideo(String duration); + /// Relativní čas poslední zprávy v dlaždici, méně než 1 minuta /// /// In cs, this message translates to: @@ -1700,10 +2006,10 @@ abstract class AppLocalizations { /// **'Zprávy se stáhnou až po otevření appky.'** String get pushModeOffDesc; - /// Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(doporučeno)') + /// Label volby 'up' v dialogu výběru režimu pushů ('UnifiedPush' je název technologie — nepřekládat, přeložit jen '(bez Googlu)') /// /// In cs, this message translates to: - /// **'UnifiedPush (doporučeno)'** + /// **'UnifiedPush (bez Googlu)'** String get pushModeUp; /// Popisek volby UnifiedPush v dialogu výběru režimu pushů ('ntfy', 'F-Droid', 'Play' jsou názvy — nepřekládat) @@ -1715,7 +2021,7 @@ abstract class AppLocalizations { /// Label volby 'fcm' v dialogu výběru režimu pushů (vlastní název — pravděpodobně shodný ve všech jazycích, ale klíč mít) /// /// In cs, this message translates to: - /// **'Google (FCM)'** + /// **'Google (FCM, výchozí)'** String get pushModeFcm; /// Popisek volby Google (FCM) v dialogu výběru režimu pushů. POZOR: v kódu DVA adjacentní literály (ř. 51–52: '…jde ' + 'Googlu; obsah…') — sloučit do jednoho klíče. @@ -1787,7 +2093,7 @@ abstract class AppLocalizations { /// Subtitle ListTile v nastavení, když je aktivní režim FCM /// /// In cs, this message translates to: - /// **'Google (FCM) — rychlé, opt-in'** + /// **'Google (FCM)'** String get pushSubtitleFcm; /// Validační chyba pod formulářem obnovy účtu, když je jméno prázdné @@ -2293,6 +2599,426 @@ abstract class AppLocalizations { /// In cs, this message translates to: /// **'Fotku se nepodařilo načíst'** String get photoPreviewDecodeFailed; + + /// Položka v nabídce zprávy — detail doručení a přečtení. + /// + /// In cs, this message translates to: + /// **'Info o zprávě'** + String get chatActionInfo; + + /// Info o zprávě: řádek času odeslání. + /// + /// In cs, this message translates to: + /// **'Odesláno'** + String get messageInfoSent; + + /// Info o zprávě: řádek času doručení. + /// + /// In cs, this message translates to: + /// **'Doručeno'** + String get messageInfoDelivered; + + /// Info o zprávě: řádek času přečtení. + /// + /// In cs, this message translates to: + /// **'Přečteno'** + String get messageInfoRead; + + /// Info o zprávě: událost ještě nenastala. + /// + /// In cs, this message translates to: + /// **'zatím ne'** + String get messageInfoPending; + + /// Info o zprávě: vysvětlení, proč chybí čas přečtení. + /// + /// In cs, this message translates to: + /// **'Kdy si zprávu přečetli, uvidíš, jen když sám sdílíš potvrzení přečtení (Nastavení).'** + String get messageInfoReadReceiptsOff; + + /// Řádek pod poslední odeslanou zprávou — čas přečtení. + /// + /// In cs, this message translates to: + /// **'Přečteno {time}'** + String chatStatusReadAt(String time); + + /// Řádek pod poslední odeslanou zprávou — čas doručení. + /// + /// In cs, this message translates to: + /// **'Doručeno {time}'** + String chatStatusDeliveredAt(String time); + + /// Nabídka skupiny a titulek obrazovky pro přidání lidí. + /// + /// In cs, this message translates to: + /// **'Přidat členy'** + String get groupsAddMembers; + + /// Potvrzovací tlačítko v AppBaru při přidávání členů. + /// + /// In cs, this message translates to: + /// **'Přidat'** + String get groupsAddMembersAction; + + /// Chyba při přidávání členů. + /// + /// In cs, this message translates to: + /// **'Přidání se nepovedlo: {error}'** + String groupsAddMembersFailed(String error); + + /// Prázdný výběr — všechny kontakty už ve skupině jsou. + /// + /// In cs, this message translates to: + /// **'Všechny tvoje kontakty už ve skupině jsou. Nového člověka nejdřív přidej do kontaktů přes QR kód.'** + String get groupsNoContactsToAdd; + + /// Karta spodní navigace a nadpis seznamu chatů. + /// + /// In cs, this message translates to: + /// **'Chaty'** + String get navChats; + + /// Tlačítko pro zahájení nového chatu. + /// + /// In cs, this message translates to: + /// **'Nový chat'** + String get chatListNewChat; + + /// Placeholder hledání v seznamu chatů. + /// + /// In cs, this message translates to: + /// **'Hledat v chatech a zprávách'** + String get chatListSearchHint; + + /// Filtr seznamu chatů — vše. + /// + /// In cs, this message translates to: + /// **'Vše'** + String get chatFilterAll; + + /// Filtr seznamu chatů — nepřečtené. + /// + /// In cs, this message translates to: + /// **'Nepřečtené'** + String get chatFilterUnread; + + /// Filtr seznamu chatů — skupiny. + /// + /// In cs, this message translates to: + /// **'Skupiny'** + String get chatFilterGroups; + + /// Odkaz na archiv v řádku filtrů. + /// + /// In cs, this message translates to: + /// **'Archiv'** + String get chatFilterArchive; + + /// Hledání v chatech nic nenašlo. + /// + /// In cs, this message translates to: + /// **'Nic nenalezeno.'** + String get chatListNoResults; + + /// Vybraný filtr je prázdný. + /// + /// In cs, this message translates to: + /// **'Tady zatím nic není.'** + String get chatListFilterEmpty; + + /// Akce: připnout chat nahoru. + /// + /// In cs, this message translates to: + /// **'Připnout'** + String get chatListPin; + + /// Akce: odepnout chat. + /// + /// In cs, this message translates to: + /// **'Odepnout'** + String get chatListUnpin; + + /// Hlavička chatu / menu: otevřít profil kontaktu. + /// + /// In cs, this message translates to: + /// **'Zobrazit profil'** + String get chatOpenProfile; + + /// Menu skupinového chatu: profil skupiny. + /// + /// In cs, this message translates to: + /// **'Profil skupiny'** + String get groupsProfile; + + /// Hledání v jedné konverzaci — placeholder a popisek tlačítka. + /// + /// In cs, this message translates to: + /// **'Hledat v konverzaci'** + String get chatSearchHint; + + /// Hledání v konverzaci bez výsledku (krátce, vedle pole). + /// + /// In cs, this message translates to: + /// **'Nic'** + String get chatSearchNoMatches; + + /// Pořadí aktuální shody hledání, např. „2 z 5“. + /// + /// In cs, this message translates to: + /// **'{current} z {total}'** + String chatSearchCounter(int current, int total); + + /// Tlačítko: předchozí (starší) shoda. + /// + /// In cs, this message translates to: + /// **'Starší shoda'** + String get chatSearchOlder; + + /// Tlačítko: další (novější) shoda. + /// + /// In cs, this message translates to: + /// **'Novější shoda'** + String get chatSearchNewer; + + /// Oddělovač nad první nepřečtenou zprávou. + /// + /// In cs, this message translates to: + /// **'{count, plural, one{1 nová zpráva} few{{count} nové zprávy} other{{count} nových zpráv}}'** + String chatUnreadDivider(int count); + + /// Tlačítko: skok na nejnovější zprávy. + /// + /// In cs, this message translates to: + /// **'Na nejnovější zprávy'** + String get chatJumpToLatest; + + /// Tlačítko přílohy v poli pro psaní. + /// + /// In cs, this message translates to: + /// **'Přiložit'** + String get chatAttach; + + /// Tlačítko odeslat. + /// + /// In cs, this message translates to: + /// **'Odeslat'** + String get chatSend; + + /// Tlačítko nahrát hlasovou zprávu. + /// + /// In cs, this message translates to: + /// **'Nahrát hlasovou zprávu'** + String get chatRecordVoice; + + /// Karta sdílených fotek v profilu. + /// + /// In cs, this message translates to: + /// **'Sdílená média'** + String get profileSharedMedia; + + /// Akce profilu: napsat zprávu. + /// + /// In cs, this message translates to: + /// **'Zpráva'** + String get profileMessage; + + /// Akce profilu: hledat v konverzaci. + /// + /// In cs, this message translates to: + /// **'Hledat'** + String get profileSearch; + + /// Akce profilu: zrušit ztlumení (krátce). + /// + /// In cs, this message translates to: + /// **'Zvuk'** + String get profileUnmute; + + /// Akce profilu: mizící zprávy (krátce). + /// + /// In cs, this message translates to: + /// **'Mizení'** + String get profileDisappearing; + + /// Karta společných skupin v profilu kontaktu. + /// + /// In cs, this message translates to: + /// **'Společné skupiny'** + String get profileCommonGroups; + + /// Řádek profilu: bezpečnostní číslo. + /// + /// In cs, this message translates to: + /// **'Bezpečnostní číslo'** + String get profileSafetyNumber; + + /// Podtitulek bezpečnostního čísla u neověřeného kontaktu. + /// + /// In cs, this message translates to: + /// **'Porovnejte osobně nebo QR kódem'** + String get profileSafetyNumberHint; + + /// Stav ověřeného kontaktu v hlavičce profilu. + /// + /// In cs, this message translates to: + /// **'Ověřeno osobně'** + String get profileVerifiedInPerson; + + /// Tlačítko v profilu neověřeného kontaktu. + /// + /// In cs, this message translates to: + /// **'Ověřit kontakt'** + String get profileVerifyNow; + + /// Akce profilu skupiny: přidat členy (krátce). + /// + /// In cs, this message translates to: + /// **'Přidat'** + String get profileAdd; + + /// Podtitulek profilu skupiny: počet členů · datum založení. + /// + /// In cs, this message translates to: + /// **'Skupina · {members} · založena {date}'** + String profileGroupSubtitle(String members, String date); + + /// Počet členů ze stropu skupiny, např. „4 ze 128“. + /// + /// In cs, this message translates to: + /// **'{count} ze {max}'** + String profileMembersOfMax(int count, int max); + + /// Podtitulek chatu / náhled v seznamu: protistrana píše. + /// + /// In cs, this message translates to: + /// **'píše…'** + String get chatTyping; + + /// Skupina: kdo píše (křestní jméno). + /// + /// In cs, this message translates to: + /// **'{name} píše…'** + String chatTypingNamed(String name); + + /// Skupina: píše víc lidí najednou. + /// + /// In cs, this message translates to: + /// **'Několik lidí píše…'** + String get chatTypingSeveral; + + /// Nastavení: přepínač „píše…“. + /// + /// In cs, this message translates to: + /// **'Ukazovat, že píšu'** + String get settingsTypingTitle; + + /// Popis přepínače „píše…“. + /// + /// In cs, this message translates to: + /// **'Oboustranné — kdo nesdílí, nevidí (výchozí vypnuto)'** + String get settingsTypingSubtitle; + + /// Položka nabídky příloh: vytvořit anketu. + /// + /// In cs, this message translates to: + /// **'Anketa'** + String get pollAttach; + + /// Nadpis formuláře nové ankety. + /// + /// In cs, this message translates to: + /// **'Nová anketa'** + String get pollCreateTitle; + + /// Pole otázky ankety. + /// + /// In cs, this message translates to: + /// **'Otázka'** + String get pollQuestion; + + /// Placeholder možnosti ankety s pořadím. + /// + /// In cs, this message translates to: + /// **'Možnost {number}'** + String pollOptionHint(int number); + + /// Tlačítko: přidat možnost do ankety. + /// + /// In cs, this message translates to: + /// **'Přidat možnost'** + String get pollAddOption; + + /// Tlačítko: odebrat možnost. + /// + /// In cs, this message translates to: + /// **'Odebrat možnost'** + String get pollRemoveOption; + + /// Přepínač: víc odpovědí na osobu. + /// + /// In cs, this message translates to: + /// **'Povolit víc odpovědí'** + String get pollAllowMultiple; + + /// Hlavička ankety s jednou volbou. + /// + /// In cs, this message translates to: + /// **'Anketa'** + String get pollSingle; + + /// Hlavička ankety s více volbami. + /// + /// In cs, this message translates to: + /// **'Anketa, více voleb'** + String get pollMulti; + + /// Počet hlasujících v anketě. + /// + /// In cs, this message translates to: + /// **'{count, plural, =0{zatím bez hlasů} one{1 hlas} few{{count} hlasy} other{{count} hlasů}}'** + String pollVoters(int count); + + /// Záložní text ankety pro starší verze appky — první řádek. + /// + /// In cs, this message translates to: + /// **'Anketa: {question}'** + String pollFallback(String question); + + /// Záložní text ankety — výzva k aktualizaci. + /// + /// In cs, this message translates to: + /// **'Hlasovat jde v aktuální verzi Šepotu.'** + String get pollFallbackUpdate; + + /// Popisek avataru v Nastavení (čtečka obrazovky). + /// + /// In cs, this message translates to: + /// **'Změnit profilovou fotku'** + String get profilePhotoEdit; + + /// Akce: vybrat profilovou fotku z galerie. + /// + /// In cs, this message translates to: + /// **'Vybrat fotku'** + String get profilePhotoPick; + + /// Akce: odebrat profilovou fotku. + /// + /// In cs, this message translates to: + /// **'Odebrat fotku'** + String get profilePhotoRemove; + + /// Vysvětlení, kdo profilovou fotku uvidí. + /// + /// In cs, this message translates to: + /// **'Fotku uvidí jen lidé, se kterými si píšeš — šifrovaně, stejně jako zprávy. Poloha ani údaje o fotce se neposílají.'** + String get profilePhotoHint; + + /// Chyba nastavení profilové fotky. + /// + /// In cs, this message translates to: + /// **'Fotku se nepodařilo nastavit: {error}'** + String profilePhotoFailed(String error); } class _AppLocalizationsDelegate diff --git a/app/lib/l10n/generated/app_localizations_cs.dart b/app/lib/l10n/generated/app_localizations_cs.dart index 468b961..6529486 100644 --- a/app/lib/l10n/generated/app_localizations_cs.dart +++ b/app/lib/l10n/generated/app_localizations_cs.dart @@ -75,17 +75,67 @@ class AppLocalizationsCs extends AppLocalizations { String get groupsLeftBanner => 'Už nejsi ve skupině. Historie zůstává, nové zprávy nedorazí.'; - @override - String groupsMembersWithCount(int count) { - return 'Členové ($count)'; - } - @override String get groupsYou => 'Ty'; @override String get groupsFounder => 'Zakladatel'; + @override + String get groupsAdmin => 'Správce'; + + @override + String get groupsMakeAdmin => 'Udělat správcem'; + + @override + String get groupsDismissAdmin => 'Odebrat roli správce'; + + @override + String get groupsRemoveMember => 'Odebrat ze skupiny'; + + @override + String groupsRemoveMemberConfirmTitle(String name) { + return 'Odebrat $name ze skupiny?'; + } + + @override + String get groupsRemoveMemberConfirmBody => + 'Přestanou mu chodit nové zprávy skupiny. Dosavadní historie mu zůstane.'; + + @override + String get groupsRemoveAction => 'Odebrat'; + + @override + String get groupsViewContact => 'Zobrazit kontakt'; + + @override + String get groupsAdminOnlyHint => + 'Jméno, členy a správce skupiny mění jen správci.'; + + @override + String get groupsAdminOnlyCompatHint => + 'Jméno a správce mění jen správci. Přidávat lidi zatím může každý, protože někdo ve skupině má starší verzi Šepotu.'; + + @override + String groupsAdminActionFailed(String error) { + return 'Změna se nepovedla: $error'; + } + + @override + String get groupsResyncNotice => + 'Stav skupiny se srovnává se správcem. Změny členů se můžou chvíli opozdit.'; + + @override + String get groupsRejectedChangeNotice => + 'Někdo bez oprávnění zkusil skupinu změnit. Změna se neprovedla.'; + + @override + String get errorGroupNotAdmin => 'Tohle může udělat jen správce skupiny.'; + + @override + String get errorGroupLastAdmin => + 'Jsi jediný správce. Nejdřív udělej správcem někoho dalšího.'; + @override String get groupsNameHint => 'Název skupiny'; @@ -192,6 +242,28 @@ class AppLocalizationsCs extends AppLocalizations { @override String get chatActionReply => 'Odpovědět'; + @override + String get chatActionForward => 'Přeposlat'; + + @override + String get forwardTitle => 'Přeposlat do…'; + + @override + String get forwardSearchHint => 'Hledat kontakt nebo skupinu'; + + @override + String get forwardEmpty => 'Nic tu není'; + + @override + String forwardDone(String name) { + return 'Přeposláno: $name'; + } + + @override + String forwardFailed(String error) { + return 'Přeposlání se nepovedlo: $error'; + } + @override String get commonCopy => 'Kopírovat'; @@ -248,6 +320,16 @@ class AppLocalizationsCs extends AppLocalizations { return 'Soubor je příliš velký (max $maxMb MB)'; } + @override + String get attachmentKeepsMetadataTitle => 'Fotka odejde i s polohou'; + + @override + String get attachmentKeepsMetadataBody => + 'Tenhle formát fotky (třeba HEIC) neumím zbavit metadat. Odešla by v originále včetně polohy, kde vznikla, a údajů o telefonu. Bezpečnější je poslat ji přes Galerii, kde se metadata odstraní.'; + + @override + String get attachmentSendOriginal => 'Poslat originál'; + @override String attachmentPickFailed(String error) { return 'Výběr přílohy selhal: $error'; @@ -292,6 +374,9 @@ class AppLocalizationsCs extends AppLocalizations { @override String get errorGroupNeedsMember => 'Skupina potřebuje aspoň 1 člena'; + @override + String get errorAttachmentUnavailable => 'Příloha už v telefonu není'; + @override String errorGroupMaxMembers(int maxMembers) { return 'Skupina může mít nejvýš $maxMembers členů'; @@ -601,19 +686,100 @@ class AppLocalizationsCs extends AppLocalizations { @override String get chatListNewConversation => 'Nová konverzace'; - @override - String chatListPhotoWithCaptionPreview(String caption) { - return '📷 $caption'; - } - - @override - String chatListFilePreview(String fileName) { - return '📎 $fileName'; - } - @override String get chatListReadReceiptPreview => 'Přečteno'; + @override + String get chatListDraft => 'Koncept'; + + @override + String get notesTitle => 'Poznámky pro sebe'; + + @override + String get notesSubtitle => 'Jen v tomto telefonu, šifrovaně'; + + @override + String get callVoice => 'Zavolat'; + + @override + String get callVideo => 'Videohovor'; + + @override + String get callStatusRinging => 'Vyzvání…'; + + @override + String get callIncomingVoice => 'Příchozí hovor'; + + @override + String get callIncomingVideo => 'Příchozí videohovor'; + + @override + String get callStatusConnecting => 'Spojuji…'; + + @override + String get callEnded => 'Hovor skončil'; + + @override + String get callEndedBusy => 'Obsazeno'; + + @override + String get callEndedRejected => 'Hovor odmítnut'; + + @override + String get callEndedUnanswered => 'Nikdo to nevzal'; + + @override + String get callEndedFailed => 'Spojení se nepodařilo'; + + @override + String get callEncrypted => 'Šifrováno end-to-end'; + + @override + String get callAccept => 'Přijmout'; + + @override + String get callReject => 'Odmítnout'; + + @override + String get callMute => 'Ztlumit mikrofon'; + + @override + String get callSpeaker => 'Reproduktor'; + + @override + String get callCamera => 'Vypnout kameru'; + + @override + String get callSwitchCamera => 'Přepnout kameru'; + + @override + String get callHangUp => 'Zavěsit'; + + @override + String get callLogMissed => 'Zmeškaný hovor'; + + @override + String get callLogUnanswered => 'Nepřijatý hovor'; + + @override + String get callLogRejected => 'Odmítnutý hovor'; + + @override + String get callLogBusy => 'Obsazeno'; + + @override + String get callLogFailed => 'Hovor se nespojil'; + + @override + String callLogVoice(String duration) { + return 'Hovor · $duration'; + } + + @override + String callLogVideo(String duration) { + return 'Videohovor · $duration'; + } + @override String get chatListTimeNow => 'teď'; @@ -943,14 +1109,14 @@ class AppLocalizationsCs extends AppLocalizations { String get pushModeOffDesc => 'Zprávy se stáhnou až po otevření appky.'; @override - String get pushModeUp => 'UnifiedPush (doporučeno)'; + String get pushModeUp => 'UnifiedPush (bez Googlu)'; @override String get pushModeUpDesc => 'Degooglované — potřebuje aplikaci ntfy (F-Droid/Play).'; @override - String get pushModeFcm => 'Google (FCM)'; + String get pushModeFcm => 'Google (FCM, výchozí)'; @override String get pushModeFcmDesc => @@ -995,7 +1161,7 @@ class AppLocalizationsCs extends AppLocalizations { String get pushNoDistributorFallback => 'bez distributora'; @override - String get pushSubtitleFcm => 'Google (FCM) — rychlé, opt-in'; + String get pushSubtitleFcm => 'Google (FCM)'; @override String get restoreErrorNameRequired => 'Zadej zobrazované jméno.'; @@ -1302,4 +1468,257 @@ class AppLocalizationsCs extends AppLocalizations { @override String get photoPreviewDecodeFailed => 'Fotku se nepodařilo načíst'; + + @override + String get chatActionInfo => 'Info o zprávě'; + + @override + String get messageInfoSent => 'Odesláno'; + + @override + String get messageInfoDelivered => 'Doručeno'; + + @override + String get messageInfoRead => 'Přečteno'; + + @override + String get messageInfoPending => 'zatím ne'; + + @override + String get messageInfoReadReceiptsOff => + 'Kdy si zprávu přečetli, uvidíš, jen když sám sdílíš potvrzení přečtení (Nastavení).'; + + @override + String chatStatusReadAt(String time) { + return 'Přečteno $time'; + } + + @override + String chatStatusDeliveredAt(String time) { + return 'Doručeno $time'; + } + + @override + String get groupsAddMembers => 'Přidat členy'; + + @override + String get groupsAddMembersAction => 'Přidat'; + + @override + String groupsAddMembersFailed(String error) { + return 'Přidání se nepovedlo: $error'; + } + + @override + String get groupsNoContactsToAdd => + 'Všechny tvoje kontakty už ve skupině jsou. Nového člověka nejdřív přidej do kontaktů přes QR kód.'; + + @override + String get navChats => 'Chaty'; + + @override + String get chatListNewChat => 'Nový chat'; + + @override + String get chatListSearchHint => 'Hledat v chatech a zprávách'; + + @override + String get chatFilterAll => 'Vše'; + + @override + String get chatFilterUnread => 'Nepřečtené'; + + @override + String get chatFilterGroups => 'Skupiny'; + + @override + String get chatFilterArchive => 'Archiv'; + + @override + String get chatListNoResults => 'Nic nenalezeno.'; + + @override + String get chatListFilterEmpty => 'Tady zatím nic není.'; + + @override + String get chatListPin => 'Připnout'; + + @override + String get chatListUnpin => 'Odepnout'; + + @override + String get chatOpenProfile => 'Zobrazit profil'; + + @override + String get groupsProfile => 'Profil skupiny'; + + @override + String get chatSearchHint => 'Hledat v konverzaci'; + + @override + String get chatSearchNoMatches => 'Nic'; + + @override + String chatSearchCounter(int current, int total) { + return '$current z $total'; + } + + @override + String get chatSearchOlder => 'Starší shoda'; + + @override + String get chatSearchNewer => 'Novější shoda'; + + @override + String chatUnreadDivider(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count nových zpráv', + few: '$count nové zprávy', + one: '1 nová zpráva', + ); + return '$_temp0'; + } + + @override + String get chatJumpToLatest => 'Na nejnovější zprávy'; + + @override + String get chatAttach => 'Přiložit'; + + @override + String get chatSend => 'Odeslat'; + + @override + String get chatRecordVoice => 'Nahrát hlasovou zprávu'; + + @override + String get profileSharedMedia => 'Sdílená média'; + + @override + String get profileMessage => 'Zpráva'; + + @override + String get profileSearch => 'Hledat'; + + @override + String get profileUnmute => 'Zvuk'; + + @override + String get profileDisappearing => 'Mizení'; + + @override + String get profileCommonGroups => 'Společné skupiny'; + + @override + String get profileSafetyNumber => 'Bezpečnostní číslo'; + + @override + String get profileSafetyNumberHint => 'Porovnejte osobně nebo QR kódem'; + + @override + String get profileVerifiedInPerson => 'Ověřeno osobně'; + + @override + String get profileVerifyNow => 'Ověřit kontakt'; + + @override + String get profileAdd => 'Přidat'; + + @override + String profileGroupSubtitle(String members, String date) { + return 'Skupina · $members · založena $date'; + } + + @override + String profileMembersOfMax(int count, int max) { + return '$count ze $max'; + } + + @override + String get chatTyping => 'píše…'; + + @override + String chatTypingNamed(String name) { + return '$name píše…'; + } + + @override + String get chatTypingSeveral => 'Několik lidí píše…'; + + @override + String get settingsTypingTitle => 'Ukazovat, že píšu'; + + @override + String get settingsTypingSubtitle => + 'Oboustranné — kdo nesdílí, nevidí (výchozí vypnuto)'; + + @override + String get pollAttach => 'Anketa'; + + @override + String get pollCreateTitle => 'Nová anketa'; + + @override + String get pollQuestion => 'Otázka'; + + @override + String pollOptionHint(int number) { + return 'Možnost $number'; + } + + @override + String get pollAddOption => 'Přidat možnost'; + + @override + String get pollRemoveOption => 'Odebrat možnost'; + + @override + String get pollAllowMultiple => 'Povolit víc odpovědí'; + + @override + String get pollSingle => 'Anketa'; + + @override + String get pollMulti => 'Anketa, více voleb'; + + @override + String pollVoters(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count hlasů', + few: '$count hlasy', + one: '1 hlas', + zero: 'zatím bez hlasů', + ); + return '$_temp0'; + } + + @override + String pollFallback(String question) { + return 'Anketa: $question'; + } + + @override + String get pollFallbackUpdate => 'Hlasovat jde v aktuální verzi Šepotu.'; + + @override + String get profilePhotoEdit => 'Změnit profilovou fotku'; + + @override + String get profilePhotoPick => 'Vybrat fotku'; + + @override + String get profilePhotoRemove => 'Odebrat fotku'; + + @override + String get profilePhotoHint => + 'Fotku uvidí jen lidé, se kterými si píšeš — šifrovaně, stejně jako zprávy. Poloha ani údaje o fotce se neposílají.'; + + @override + String profilePhotoFailed(String error) { + return 'Fotku se nepodařilo nastavit: $error'; + } } diff --git a/app/lib/l10n/generated/app_localizations_de.dart b/app/lib/l10n/generated/app_localizations_de.dart index 3dfad4f..a91a2ab 100644 --- a/app/lib/l10n/generated/app_localizations_de.dart +++ b/app/lib/l10n/generated/app_localizations_de.dart @@ -73,17 +73,67 @@ class AppLocalizationsDe extends AppLocalizations { String get groupsLeftBanner => 'Du bist nicht mehr in der Gruppe. Der Verlauf bleibt, neue Nachrichten kommen nicht mehr an.'; - @override - String groupsMembersWithCount(int count) { - return 'Mitglieder ($count)'; - } - @override String get groupsYou => 'Du'; @override String get groupsFounder => 'Hat die Gruppe gegründet'; + @override + String get groupsAdmin => 'Admin'; + + @override + String get groupsMakeAdmin => 'Zum Admin machen'; + + @override + String get groupsDismissAdmin => 'Adminrolle entziehen'; + + @override + String get groupsRemoveMember => 'Aus der Gruppe entfernen'; + + @override + String groupsRemoveMemberConfirmTitle(String name) { + return '$name aus der Gruppe entfernen?'; + } + + @override + String get groupsRemoveMemberConfirmBody => + 'Neue Nachrichten der Gruppe kommen dann nicht mehr an. Der bisherige Verlauf bleibt erhalten.'; + + @override + String get groupsRemoveAction => 'Entfernen'; + + @override + String get groupsViewContact => 'Kontakt anzeigen'; + + @override + String get groupsAdminOnlyHint => + 'Nur Admins können Namen, Mitglieder und Admins der Gruppe ändern.'; + + @override + String get groupsAdminOnlyCompatHint => + 'Nur Admins können die Gruppe umbenennen oder Admins ändern. Personen hinzufügen kann vorerst jeder, weil jemand in der Gruppe eine ältere Šepot-Version hat.'; + + @override + String groupsAdminActionFailed(String error) { + return 'Änderung fehlgeschlagen: $error'; + } + + @override + String get groupsResyncNotice => + 'Die Gruppe gleicht sich mit dem Admin ab. Änderungen an Mitgliedern können sich kurz verzögern.'; + + @override + String get groupsRejectedChangeNotice => + 'Jemand ohne Berechtigung hat versucht, die Gruppe zu ändern. Die Änderung wurde nicht übernommen.'; + + @override + String get errorGroupNotAdmin => 'Das kann nur ein Admin der Gruppe.'; + + @override + String get errorGroupLastAdmin => + 'Du bist der einzige Admin. Mach zuerst jemand anderen zum Admin.'; + @override String get groupsNameHint => 'Gruppenname'; @@ -191,6 +241,28 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chatActionReply => 'Antworten'; + @override + String get chatActionForward => 'Weiterleiten'; + + @override + String get forwardTitle => 'Weiterleiten an…'; + + @override + String get forwardSearchHint => 'Kontakt oder Gruppe suchen'; + + @override + String get forwardEmpty => 'Nichts gefunden'; + + @override + String forwardDone(String name) { + return 'Weitergeleitet an $name'; + } + + @override + String forwardFailed(String error) { + return 'Weiterleiten fehlgeschlagen: $error'; + } + @override String get commonCopy => 'Kopieren'; @@ -248,6 +320,16 @@ class AppLocalizationsDe extends AppLocalizations { return 'Datei ist zu groß (max. $maxMb MB)'; } + @override + String get attachmentKeepsMetadataTitle => 'Foto enthält den Standort'; + + @override + String get attachmentKeepsMetadataBody => + 'Aus diesem Fotoformat (z. B. HEIC) kann ich die Metadaten nicht entfernen. Es würde im Original gesendet, einschließlich Aufnahmeort und Angaben zum Telefon. Sicherer ist das Senden über die Galerie, dort werden die Metadaten entfernt.'; + + @override + String get attachmentSendOriginal => 'Original senden'; + @override String attachmentPickFailed(String error) { return 'Auswahl des Anhangs fehlgeschlagen: $error'; @@ -294,6 +376,10 @@ class AppLocalizationsDe extends AppLocalizations { String get errorGroupNeedsMember => 'Die Gruppe braucht mindestens 1 Mitglied'; + @override + String get errorAttachmentUnavailable => + 'Der Anhang ist nicht mehr auf diesem Telefon'; + @override String errorGroupMaxMembers(int maxMembers) { return 'Die Gruppe kann höchstens $maxMembers Mitglieder haben'; @@ -604,19 +690,100 @@ class AppLocalizationsDe extends AppLocalizations { @override String get chatListNewConversation => 'Neue Unterhaltung'; - @override - String chatListPhotoWithCaptionPreview(String caption) { - return '📷 $caption'; - } - - @override - String chatListFilePreview(String fileName) { - return '📎 $fileName'; - } - @override String get chatListReadReceiptPreview => 'Gelesen'; + @override + String get chatListDraft => 'Entwurf'; + + @override + String get notesTitle => 'Notizen für mich'; + + @override + String get notesSubtitle => 'Nur auf diesem Telefon, verschlüsselt'; + + @override + String get callVoice => 'Anrufen'; + + @override + String get callVideo => 'Videoanruf'; + + @override + String get callStatusRinging => 'Klingelt…'; + + @override + String get callIncomingVoice => 'Eingehender Anruf'; + + @override + String get callIncomingVideo => 'Eingehender Videoanruf'; + + @override + String get callStatusConnecting => 'Verbinde…'; + + @override + String get callEnded => 'Anruf beendet'; + + @override + String get callEndedBusy => 'Besetzt'; + + @override + String get callEndedRejected => 'Anruf abgelehnt'; + + @override + String get callEndedUnanswered => 'Keine Antwort'; + + @override + String get callEndedFailed => 'Verbindung fehlgeschlagen'; + + @override + String get callEncrypted => 'Ende-zu-Ende-verschlüsselt'; + + @override + String get callAccept => 'Annehmen'; + + @override + String get callReject => 'Ablehnen'; + + @override + String get callMute => 'Stummschalten'; + + @override + String get callSpeaker => 'Lautsprecher'; + + @override + String get callCamera => 'Kamera aus'; + + @override + String get callSwitchCamera => 'Kamera wechseln'; + + @override + String get callHangUp => 'Auflegen'; + + @override + String get callLogMissed => 'Verpasster Anruf'; + + @override + String get callLogUnanswered => 'Unbeantworteter Anruf'; + + @override + String get callLogRejected => 'Abgelehnter Anruf'; + + @override + String get callLogBusy => 'Besetzt'; + + @override + String get callLogFailed => 'Anruf fehlgeschlagen'; + + @override + String callLogVoice(String duration) { + return 'Anruf · $duration'; + } + + @override + String callLogVideo(String duration) { + return 'Videoanruf · $duration'; + } + @override String get chatListTimeNow => 'jetzt'; @@ -962,14 +1129,14 @@ class AppLocalizationsDe extends AppLocalizations { 'Nachrichten werden erst beim Öffnen der App abgerufen.'; @override - String get pushModeUp => 'UnifiedPush (empfohlen)'; + String get pushModeUp => 'UnifiedPush (ohne Google)'; @override String get pushModeUpDesc => 'Google-frei — braucht die App ntfy (F-Droid/Play).'; @override - String get pushModeFcm => 'Google (FCM)'; + String get pushModeFcm => 'Google (FCM, Standard)'; @override String get pushModeFcmDesc => @@ -1014,7 +1181,7 @@ class AppLocalizationsDe extends AppLocalizations { String get pushNoDistributorFallback => 'kein Distributor'; @override - String get pushSubtitleFcm => 'Google (FCM) — schnell, opt-in'; + String get pushSubtitleFcm => 'Google (FCM)'; @override String get restoreErrorNameRequired => 'Gib einen Anzeigenamen ein.'; @@ -1324,4 +1491,256 @@ class AppLocalizationsDe extends AppLocalizations { @override String get photoPreviewDecodeFailed => 'Foto konnte nicht geladen werden'; + + @override + String get chatActionInfo => 'Nachrichteninfo'; + + @override + String get messageInfoSent => 'Gesendet'; + + @override + String get messageInfoDelivered => 'Zugestellt'; + + @override + String get messageInfoRead => 'Gelesen'; + + @override + String get messageInfoPending => 'noch nicht'; + + @override + String get messageInfoReadReceiptsOff => + 'Wann deine Nachrichten gelesen wurden, siehst du nur, wenn du selbst Lesebestätigungen teilst (Einstellungen).'; + + @override + String chatStatusReadAt(String time) { + return 'Gelesen $time'; + } + + @override + String chatStatusDeliveredAt(String time) { + return 'Zugestellt $time'; + } + + @override + String get groupsAddMembers => 'Mitglieder hinzufügen'; + + @override + String get groupsAddMembersAction => 'Hinzufügen'; + + @override + String groupsAddMembersFailed(String error) { + return 'Hinzufügen fehlgeschlagen: $error'; + } + + @override + String get groupsNoContactsToAdd => + 'Alle deine Kontakte sind schon in der Gruppe. Füge neue Personen zuerst per QR-Code zu deinen Kontakten hinzu.'; + + @override + String get navChats => 'Chats'; + + @override + String get chatListNewChat => 'Neuer Chat'; + + @override + String get chatListSearchHint => 'Chats und Nachrichten durchsuchen'; + + @override + String get chatFilterAll => 'Alle'; + + @override + String get chatFilterUnread => 'Ungelesen'; + + @override + String get chatFilterGroups => 'Gruppen'; + + @override + String get chatFilterArchive => 'Archiv'; + + @override + String get chatListNoResults => 'Nichts gefunden.'; + + @override + String get chatListFilterEmpty => 'Hier ist noch nichts.'; + + @override + String get chatListPin => 'Anheften'; + + @override + String get chatListUnpin => 'Lösen'; + + @override + String get chatOpenProfile => 'Profil anzeigen'; + + @override + String get groupsProfile => 'Gruppeninfo'; + + @override + String get chatSearchHint => 'Im Chat suchen'; + + @override + String get chatSearchNoMatches => 'Keine'; + + @override + String chatSearchCounter(int current, int total) { + return '$current von $total'; + } + + @override + String get chatSearchOlder => 'Ältere Treffer'; + + @override + String get chatSearchNewer => 'Neuerer Treffer'; + + @override + String chatUnreadDivider(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count neue Nachrichten', + one: '1 neue Nachricht', + ); + return '$_temp0'; + } + + @override + String get chatJumpToLatest => 'Zu den neuesten'; + + @override + String get chatAttach => 'Anhängen'; + + @override + String get chatSend => 'Senden'; + + @override + String get chatRecordVoice => 'Sprachnachricht aufnehmen'; + + @override + String get profileSharedMedia => 'Geteilte Medien'; + + @override + String get profileMessage => 'Nachricht'; + + @override + String get profileSearch => 'Suchen'; + + @override + String get profileUnmute => 'Ton an'; + + @override + String get profileDisappearing => 'Verschwinden'; + + @override + String get profileCommonGroups => 'Gemeinsame Gruppen'; + + @override + String get profileSafetyNumber => 'Sicherheitsnummer'; + + @override + String get profileSafetyNumberHint => + 'Persönlich oder per QR-Code vergleichen'; + + @override + String get profileVerifiedInPerson => 'Persönlich verifiziert'; + + @override + String get profileVerifyNow => 'Kontakt verifizieren'; + + @override + String get profileAdd => 'Hinzufügen'; + + @override + String profileGroupSubtitle(String members, String date) { + return 'Gruppe · $members · erstellt am $date'; + } + + @override + String profileMembersOfMax(int count, int max) { + return '$count von $max'; + } + + @override + String get chatTyping => 'schreibt…'; + + @override + String chatTypingNamed(String name) { + return '$name schreibt…'; + } + + @override + String get chatTypingSeveral => 'Mehrere schreiben…'; + + @override + String get settingsTypingTitle => 'Tippindikator'; + + @override + String get settingsTypingSubtitle => + 'Gegenseitig — wer nicht teilt, sieht nichts (standardmäßig aus)'; + + @override + String get pollAttach => 'Umfrage'; + + @override + String get pollCreateTitle => 'Neue Umfrage'; + + @override + String get pollQuestion => 'Frage'; + + @override + String pollOptionHint(int number) { + return 'Option $number'; + } + + @override + String get pollAddOption => 'Option hinzufügen'; + + @override + String get pollRemoveOption => 'Option entfernen'; + + @override + String get pollAllowMultiple => 'Mehrere Antworten erlauben'; + + @override + String get pollSingle => 'Umfrage'; + + @override + String get pollMulti => 'Umfrage, Mehrfachauswahl'; + + @override + String pollVoters(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count Stimmen', + one: '1 Stimme', + zero: 'noch keine Stimmen', + ); + return '$_temp0'; + } + + @override + String pollFallback(String question) { + return 'Umfrage: $question'; + } + + @override + String get pollFallbackUpdate => 'Zum Abstimmen Šepot aktualisieren.'; + + @override + String get profilePhotoEdit => 'Profilbild ändern'; + + @override + String get profilePhotoPick => 'Foto auswählen'; + + @override + String get profilePhotoRemove => 'Foto entfernen'; + + @override + String get profilePhotoHint => + 'Nur deine Chatpartner sehen es — Ende-zu-Ende-verschlüsselt wie Nachrichten. Standort und Fotodaten werden nicht gesendet.'; + + @override + String profilePhotoFailed(String error) { + return 'Foto konnte nicht gesetzt werden: $error'; + } } diff --git a/app/lib/l10n/generated/app_localizations_en.dart b/app/lib/l10n/generated/app_localizations_en.dart index 494658e..9914fce 100644 --- a/app/lib/l10n/generated/app_localizations_en.dart +++ b/app/lib/l10n/generated/app_localizations_en.dart @@ -73,17 +73,67 @@ class AppLocalizationsEn extends AppLocalizations { String get groupsLeftBanner => 'You\'re no longer in this group. Your history stays, but new messages won\'t arrive.'; - @override - String groupsMembersWithCount(int count) { - return 'Members ($count)'; - } - @override String get groupsYou => 'You'; @override String get groupsFounder => 'Founder'; + @override + String get groupsAdmin => 'Admin'; + + @override + String get groupsMakeAdmin => 'Make admin'; + + @override + String get groupsDismissAdmin => 'Remove admin role'; + + @override + String get groupsRemoveMember => 'Remove from group'; + + @override + String groupsRemoveMemberConfirmTitle(String name) { + return 'Remove $name from the group?'; + } + + @override + String get groupsRemoveMemberConfirmBody => + 'They will stop getting new messages from this group. Their existing history stays.'; + + @override + String get groupsRemoveAction => 'Remove'; + + @override + String get groupsViewContact => 'View contact'; + + @override + String get groupsAdminOnlyHint => + 'Only admins can change the group name, members and admins.'; + + @override + String get groupsAdminOnlyCompatHint => + 'Only admins can rename the group or change admins. Anyone can still add people, because someone in the group has an older version of Šepot.'; + + @override + String groupsAdminActionFailed(String error) { + return 'The change failed: $error'; + } + + @override + String get groupsResyncNotice => + 'The group is catching up with its admin. Member changes may show up with a delay.'; + + @override + String get groupsRejectedChangeNotice => + 'Someone without permission tried to change the group. The change was not applied.'; + + @override + String get errorGroupNotAdmin => 'Only a group admin can do this.'; + + @override + String get errorGroupLastAdmin => + 'You\'re the only admin. Make someone else an admin first.'; + @override String get groupsNameHint => 'Group name'; @@ -190,6 +240,28 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatActionReply => 'Reply'; + @override + String get chatActionForward => 'Forward'; + + @override + String get forwardTitle => 'Forward to…'; + + @override + String get forwardSearchHint => 'Search contact or group'; + + @override + String get forwardEmpty => 'Nothing here'; + + @override + String forwardDone(String name) { + return 'Forwarded to $name'; + } + + @override + String forwardFailed(String error) { + return 'Forwarding failed: $error'; + } + @override String get commonCopy => 'Copy'; @@ -246,6 +318,16 @@ class AppLocalizationsEn extends AppLocalizations { return 'File is too large (max $maxMb MB)'; } + @override + String get attachmentKeepsMetadataTitle => 'Photo will include location'; + + @override + String get attachmentKeepsMetadataBody => + 'I can\'t remove metadata from this photo format (e.g. HEIC). It would be sent as the original, including where it was taken and details about the phone. Sending it via Gallery is safer, as metadata is removed there.'; + + @override + String get attachmentSendOriginal => 'Send original'; + @override String attachmentPickFailed(String error) { return 'Picking attachment failed: $error'; @@ -290,6 +372,10 @@ class AppLocalizationsEn extends AppLocalizations { @override String get errorGroupNeedsMember => 'A group needs at least 1 member'; + @override + String get errorAttachmentUnavailable => + 'The attachment is no longer on this phone'; + @override String errorGroupMaxMembers(int maxMembers) { return 'A group can have at most $maxMembers members'; @@ -596,19 +682,100 @@ class AppLocalizationsEn extends AppLocalizations { @override String get chatListNewConversation => 'New conversation'; - @override - String chatListPhotoWithCaptionPreview(String caption) { - return '📷 $caption'; - } - - @override - String chatListFilePreview(String fileName) { - return '📎 $fileName'; - } - @override String get chatListReadReceiptPreview => 'Read'; + @override + String get chatListDraft => 'Draft'; + + @override + String get notesTitle => 'Note to self'; + + @override + String get notesSubtitle => 'Only on this phone, encrypted'; + + @override + String get callVoice => 'Call'; + + @override + String get callVideo => 'Video call'; + + @override + String get callStatusRinging => 'Ringing…'; + + @override + String get callIncomingVoice => 'Incoming call'; + + @override + String get callIncomingVideo => 'Incoming video call'; + + @override + String get callStatusConnecting => 'Connecting…'; + + @override + String get callEnded => 'Call ended'; + + @override + String get callEndedBusy => 'Busy'; + + @override + String get callEndedRejected => 'Call declined'; + + @override + String get callEndedUnanswered => 'No answer'; + + @override + String get callEndedFailed => 'Couldn\'t connect'; + + @override + String get callEncrypted => 'End-to-end encrypted'; + + @override + String get callAccept => 'Accept'; + + @override + String get callReject => 'Decline'; + + @override + String get callMute => 'Mute'; + + @override + String get callSpeaker => 'Speaker'; + + @override + String get callCamera => 'Turn off camera'; + + @override + String get callSwitchCamera => 'Switch camera'; + + @override + String get callHangUp => 'Hang up'; + + @override + String get callLogMissed => 'Missed call'; + + @override + String get callLogUnanswered => 'Unanswered call'; + + @override + String get callLogRejected => 'Declined call'; + + @override + String get callLogBusy => 'Busy'; + + @override + String get callLogFailed => 'Call failed'; + + @override + String callLogVoice(String duration) { + return 'Call · $duration'; + } + + @override + String callLogVideo(String duration) { + return 'Video call · $duration'; + } + @override String get chatListTimeNow => 'now'; @@ -941,14 +1108,14 @@ class AppLocalizationsEn extends AppLocalizations { String get pushModeOffDesc => 'Messages arrive only after you open the app.'; @override - String get pushModeUp => 'UnifiedPush (recommended)'; + String get pushModeUp => 'UnifiedPush (no Google)'; @override String get pushModeUpDesc => 'De-Googled — needs the ntfy app (F-Droid/Play).'; @override - String get pushModeFcm => 'Google (FCM)'; + String get pushModeFcm => 'Google (FCM, default)'; @override String get pushModeFcmDesc => @@ -993,7 +1160,7 @@ class AppLocalizationsEn extends AppLocalizations { String get pushNoDistributorFallback => 'no distributor'; @override - String get pushSubtitleFcm => 'Google (FCM) — fast, opt-in'; + String get pushSubtitleFcm => 'Google (FCM)'; @override String get restoreErrorNameRequired => 'Enter a display name.'; @@ -1297,4 +1464,255 @@ class AppLocalizationsEn extends AppLocalizations { @override String get photoPreviewDecodeFailed => 'Couldn\'t load the photo'; + + @override + String get chatActionInfo => 'Message info'; + + @override + String get messageInfoSent => 'Sent'; + + @override + String get messageInfoDelivered => 'Delivered'; + + @override + String get messageInfoRead => 'Read'; + + @override + String get messageInfoPending => 'not yet'; + + @override + String get messageInfoReadReceiptsOff => + 'You only see when people read your messages if you share read receipts yourself (Settings).'; + + @override + String chatStatusReadAt(String time) { + return 'Read $time'; + } + + @override + String chatStatusDeliveredAt(String time) { + return 'Delivered $time'; + } + + @override + String get groupsAddMembers => 'Add members'; + + @override + String get groupsAddMembersAction => 'Add'; + + @override + String groupsAddMembersFailed(String error) { + return 'Could not add members: $error'; + } + + @override + String get groupsNoContactsToAdd => + 'All your contacts are already in this group. Add someone new to your contacts via QR code first.'; + + @override + String get navChats => 'Chats'; + + @override + String get chatListNewChat => 'New chat'; + + @override + String get chatListSearchHint => 'Search chats and messages'; + + @override + String get chatFilterAll => 'All'; + + @override + String get chatFilterUnread => 'Unread'; + + @override + String get chatFilterGroups => 'Groups'; + + @override + String get chatFilterArchive => 'Archive'; + + @override + String get chatListNoResults => 'Nothing found.'; + + @override + String get chatListFilterEmpty => 'Nothing here yet.'; + + @override + String get chatListPin => 'Pin'; + + @override + String get chatListUnpin => 'Unpin'; + + @override + String get chatOpenProfile => 'View profile'; + + @override + String get groupsProfile => 'Group info'; + + @override + String get chatSearchHint => 'Search in chat'; + + @override + String get chatSearchNoMatches => 'None'; + + @override + String chatSearchCounter(int current, int total) { + return '$current of $total'; + } + + @override + String get chatSearchOlder => 'Older match'; + + @override + String get chatSearchNewer => 'Newer match'; + + @override + String chatUnreadDivider(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count new messages', + one: '1 new message', + ); + return '$_temp0'; + } + + @override + String get chatJumpToLatest => 'Jump to latest'; + + @override + String get chatAttach => 'Attach'; + + @override + String get chatSend => 'Send'; + + @override + String get chatRecordVoice => 'Record voice message'; + + @override + String get profileSharedMedia => 'Shared media'; + + @override + String get profileMessage => 'Message'; + + @override + String get profileSearch => 'Search'; + + @override + String get profileUnmute => 'Unmute'; + + @override + String get profileDisappearing => 'Disappear'; + + @override + String get profileCommonGroups => 'Groups in common'; + + @override + String get profileSafetyNumber => 'Safety number'; + + @override + String get profileSafetyNumberHint => 'Compare in person or by QR code'; + + @override + String get profileVerifiedInPerson => 'Verified in person'; + + @override + String get profileVerifyNow => 'Verify contact'; + + @override + String get profileAdd => 'Add'; + + @override + String profileGroupSubtitle(String members, String date) { + return 'Group · $members · created $date'; + } + + @override + String profileMembersOfMax(int count, int max) { + return '$count of $max'; + } + + @override + String get chatTyping => 'typing…'; + + @override + String chatTypingNamed(String name) { + return '$name is typing…'; + } + + @override + String get chatTypingSeveral => 'Several people are typing…'; + + @override + String get settingsTypingTitle => 'Typing indicators'; + + @override + String get settingsTypingSubtitle => + 'Mutual — if you don\'t share it, you don\'t see it (off by default)'; + + @override + String get pollAttach => 'Poll'; + + @override + String get pollCreateTitle => 'New poll'; + + @override + String get pollQuestion => 'Question'; + + @override + String pollOptionHint(int number) { + return 'Option $number'; + } + + @override + String get pollAddOption => 'Add option'; + + @override + String get pollRemoveOption => 'Remove option'; + + @override + String get pollAllowMultiple => 'Allow multiple answers'; + + @override + String get pollSingle => 'Poll'; + + @override + String get pollMulti => 'Poll, multiple choice'; + + @override + String pollVoters(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count votes', + one: '1 vote', + zero: 'no votes yet', + ); + return '$_temp0'; + } + + @override + String pollFallback(String question) { + return 'Poll: $question'; + } + + @override + String get pollFallbackUpdate => 'Update Šepot to vote.'; + + @override + String get profilePhotoEdit => 'Change profile photo'; + + @override + String get profilePhotoPick => 'Choose photo'; + + @override + String get profilePhotoRemove => 'Remove photo'; + + @override + String get profilePhotoHint => + 'Only people you chat with will see it — end-to-end encrypted like your messages. No location or photo metadata is sent.'; + + @override + String profilePhotoFailed(String error) { + return 'Could not set photo: $error'; + } } diff --git a/app/lib/l10n/generated/app_localizations_it.dart b/app/lib/l10n/generated/app_localizations_it.dart index 7123eb4..2514863 100644 --- a/app/lib/l10n/generated/app_localizations_it.dart +++ b/app/lib/l10n/generated/app_localizations_it.dart @@ -73,17 +73,68 @@ class AppLocalizationsIt extends AppLocalizations { String get groupsLeftBanner => 'Non sei più nel gruppo. La cronologia resta, i nuovi messaggi non arriveranno.'; - @override - String groupsMembersWithCount(int count) { - return 'Membri ($count)'; - } - @override String get groupsYou => 'Tu'; @override String get groupsFounder => 'Fondatore'; + @override + String get groupsAdmin => 'Amministratore'; + + @override + String get groupsMakeAdmin => 'Rendi amministratore'; + + @override + String get groupsDismissAdmin => 'Rimuovi ruolo di amministratore'; + + @override + String get groupsRemoveMember => 'Rimuovi dal gruppo'; + + @override + String groupsRemoveMemberConfirmTitle(String name) { + return 'Rimuovere $name dal gruppo?'; + } + + @override + String get groupsRemoveMemberConfirmBody => + 'Non riceverà più i nuovi messaggi del gruppo. La cronologia esistente resta.'; + + @override + String get groupsRemoveAction => 'Rimuovi'; + + @override + String get groupsViewContact => 'Vedi contatto'; + + @override + String get groupsAdminOnlyHint => + 'Solo gli amministratori possono cambiare nome, membri e amministratori del gruppo.'; + + @override + String get groupsAdminOnlyCompatHint => + 'Solo gli amministratori possono rinominare il gruppo o cambiare gli amministratori. Per ora chiunque può aggiungere persone, perché qualcuno nel gruppo ha una versione precedente di Šepot.'; + + @override + String groupsAdminActionFailed(String error) { + return 'Modifica non riuscita: $error'; + } + + @override + String get groupsResyncNotice => + 'Il gruppo si sta allineando con l\'amministratore. Le modifiche ai membri potrebbero arrivare in ritardo.'; + + @override + String get groupsRejectedChangeNotice => + 'Qualcuno senza permesso ha provato a modificare il gruppo. La modifica non è stata applicata.'; + + @override + String get errorGroupNotAdmin => + 'Solo un amministratore del gruppo può farlo.'; + + @override + String get errorGroupLastAdmin => + 'Sei l\'unico amministratore. Prima rendi amministratore qualcun altro.'; + @override String get groupsNameHint => 'Nome del gruppo'; @@ -191,6 +242,28 @@ class AppLocalizationsIt extends AppLocalizations { @override String get chatActionReply => 'Rispondi'; + @override + String get chatActionForward => 'Inoltra'; + + @override + String get forwardTitle => 'Inoltra a…'; + + @override + String get forwardSearchHint => 'Cerca contatto o gruppo'; + + @override + String get forwardEmpty => 'Nessun risultato'; + + @override + String forwardDone(String name) { + return 'Inoltrato a $name'; + } + + @override + String forwardFailed(String error) { + return 'Inoltro non riuscito: $error'; + } + @override String get commonCopy => 'Copia'; @@ -247,6 +320,16 @@ class AppLocalizationsIt extends AppLocalizations { return 'Il file è troppo grande (max $maxMb MB)'; } + @override + String get attachmentKeepsMetadataTitle => 'La foto includerà la posizione'; + + @override + String get attachmentKeepsMetadataBody => + 'Non riesco a rimuovere i metadati da questo formato (ad es. HEIC). Verrebbe inviata in originale, compresi il luogo dello scatto e i dati del telefono. È più sicuro inviarla dalla Galleria, dove i metadati vengono rimossi.'; + + @override + String get attachmentSendOriginal => 'Invia originale'; + @override String attachmentPickFailed(String error) { return 'Selezione dell\'allegato non riuscita: $error'; @@ -292,6 +375,10 @@ class AppLocalizationsIt extends AppLocalizations { @override String get errorGroupNeedsMember => 'Il gruppo ha bisogno di almeno 1 membro'; + @override + String get errorAttachmentUnavailable => + 'L\'allegato non è più su questo telefono'; + @override String errorGroupMaxMembers(int maxMembers) { return 'Il gruppo può avere al massimo $maxMembers membri'; @@ -600,19 +687,100 @@ class AppLocalizationsIt extends AppLocalizations { @override String get chatListNewConversation => 'Nuova conversazione'; - @override - String chatListPhotoWithCaptionPreview(String caption) { - return '📷 $caption'; - } - - @override - String chatListFilePreview(String fileName) { - return '📎 $fileName'; - } - @override String get chatListReadReceiptPreview => 'Letto'; + @override + String get chatListDraft => 'Bozza'; + + @override + String get notesTitle => 'Note per me'; + + @override + String get notesSubtitle => 'Solo su questo telefono, cifrato'; + + @override + String get callVoice => 'Chiama'; + + @override + String get callVideo => 'Videochiamata'; + + @override + String get callStatusRinging => 'Squilla…'; + + @override + String get callIncomingVoice => 'Chiamata in arrivo'; + + @override + String get callIncomingVideo => 'Videochiamata in arrivo'; + + @override + String get callStatusConnecting => 'Connessione…'; + + @override + String get callEnded => 'Chiamata terminata'; + + @override + String get callEndedBusy => 'Occupato'; + + @override + String get callEndedRejected => 'Chiamata rifiutata'; + + @override + String get callEndedUnanswered => 'Nessuna risposta'; + + @override + String get callEndedFailed => 'Connessione non riuscita'; + + @override + String get callEncrypted => 'Crittografia end-to-end'; + + @override + String get callAccept => 'Accetta'; + + @override + String get callReject => 'Rifiuta'; + + @override + String get callMute => 'Disattiva microfono'; + + @override + String get callSpeaker => 'Altoparlante'; + + @override + String get callCamera => 'Disattiva fotocamera'; + + @override + String get callSwitchCamera => 'Cambia fotocamera'; + + @override + String get callHangUp => 'Riaggancia'; + + @override + String get callLogMissed => 'Chiamata persa'; + + @override + String get callLogUnanswered => 'Chiamata senza risposta'; + + @override + String get callLogRejected => 'Chiamata rifiutata'; + + @override + String get callLogBusy => 'Occupato'; + + @override + String get callLogFailed => 'Chiamata non riuscita'; + + @override + String callLogVoice(String duration) { + return 'Chiamata · $duration'; + } + + @override + String callLogVideo(String duration) { + return 'Videochiamata · $duration'; + } + @override String get chatListTimeNow => 'adesso'; @@ -951,14 +1119,14 @@ class AppLocalizationsIt extends AppLocalizations { 'I messaggi verranno scaricati solo all\'apertura dell\'app.'; @override - String get pushModeUp => 'UnifiedPush (consigliato)'; + String get pushModeUp => 'UnifiedPush (senza Google)'; @override String get pushModeUpDesc => 'Senza Google — richiede l\'app ntfy (F-Droid/Play).'; @override - String get pushModeFcm => 'Google (FCM)'; + String get pushModeFcm => 'Google (FCM, predefinito)'; @override String get pushModeFcmDesc => @@ -1003,7 +1171,7 @@ class AppLocalizationsIt extends AppLocalizations { String get pushNoDistributorFallback => 'nessun distributore'; @override - String get pushSubtitleFcm => 'Google (FCM) — veloce, opt-in'; + String get pushSubtitleFcm => 'Google (FCM)'; @override String get restoreErrorNameRequired => 'Inserisci il nome visualizzato.'; @@ -1311,4 +1479,255 @@ class AppLocalizationsIt extends AppLocalizations { @override String get photoPreviewDecodeFailed => 'Impossibile caricare la foto'; + + @override + String get chatActionInfo => 'Info messaggio'; + + @override + String get messageInfoSent => 'Inviato'; + + @override + String get messageInfoDelivered => 'Consegnato'; + + @override + String get messageInfoRead => 'Letto'; + + @override + String get messageInfoPending => 'non ancora'; + + @override + String get messageInfoReadReceiptsOff => + 'Vedi quando i tuoi messaggi vengono letti solo se condividi anche tu le conferme di lettura (Impostazioni).'; + + @override + String chatStatusReadAt(String time) { + return 'Letto $time'; + } + + @override + String chatStatusDeliveredAt(String time) { + return 'Consegnato $time'; + } + + @override + String get groupsAddMembers => 'Aggiungi membri'; + + @override + String get groupsAddMembersAction => 'Aggiungi'; + + @override + String groupsAddMembersFailed(String error) { + return 'Impossibile aggiungere: $error'; + } + + @override + String get groupsNoContactsToAdd => + 'Tutti i tuoi contatti sono già nel gruppo. Aggiungi prima una nuova persona ai contatti tramite codice QR.'; + + @override + String get navChats => 'Chat'; + + @override + String get chatListNewChat => 'Nuova chat'; + + @override + String get chatListSearchHint => 'Cerca in chat e messaggi'; + + @override + String get chatFilterAll => 'Tutte'; + + @override + String get chatFilterUnread => 'Non lette'; + + @override + String get chatFilterGroups => 'Gruppi'; + + @override + String get chatFilterArchive => 'Archivio'; + + @override + String get chatListNoResults => 'Nessun risultato.'; + + @override + String get chatListFilterEmpty => 'Ancora niente qui.'; + + @override + String get chatListPin => 'Fissa'; + + @override + String get chatListUnpin => 'Sblocca'; + + @override + String get chatOpenProfile => 'Vedi profilo'; + + @override + String get groupsProfile => 'Info gruppo'; + + @override + String get chatSearchHint => 'Cerca nella chat'; + + @override + String get chatSearchNoMatches => 'Nessuno'; + + @override + String chatSearchCounter(int current, int total) { + return '$current di $total'; + } + + @override + String get chatSearchOlder => 'Risultato precedente'; + + @override + String get chatSearchNewer => 'Risultato successivo'; + + @override + String chatUnreadDivider(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count nuovi messaggi', + one: '1 nuovo messaggio', + ); + return '$_temp0'; + } + + @override + String get chatJumpToLatest => 'Vai ai più recenti'; + + @override + String get chatAttach => 'Allega'; + + @override + String get chatSend => 'Invia'; + + @override + String get chatRecordVoice => 'Registra messaggio vocale'; + + @override + String get profileSharedMedia => 'Media condivisi'; + + @override + String get profileMessage => 'Messaggio'; + + @override + String get profileSearch => 'Cerca'; + + @override + String get profileUnmute => 'Riattiva'; + + @override + String get profileDisappearing => 'Effimeri'; + + @override + String get profileCommonGroups => 'Gruppi in comune'; + + @override + String get profileSafetyNumber => 'Numero di sicurezza'; + + @override + String get profileSafetyNumberHint => 'Confronta di persona o con codice QR'; + + @override + String get profileVerifiedInPerson => 'Verificato di persona'; + + @override + String get profileVerifyNow => 'Verifica contatto'; + + @override + String get profileAdd => 'Aggiungi'; + + @override + String profileGroupSubtitle(String members, String date) { + return 'Gruppo · $members · creato il $date'; + } + + @override + String profileMembersOfMax(int count, int max) { + return '$count su $max'; + } + + @override + String get chatTyping => 'sta scrivendo…'; + + @override + String chatTypingNamed(String name) { + return '$name sta scrivendo…'; + } + + @override + String get chatTypingSeveral => 'Più persone stanno scrivendo…'; + + @override + String get settingsTypingTitle => 'Indicatore di scrittura'; + + @override + String get settingsTypingSubtitle => + 'Reciproco — se non lo condividi, non lo vedi (disattivato di default)'; + + @override + String get pollAttach => 'Sondaggio'; + + @override + String get pollCreateTitle => 'Nuovo sondaggio'; + + @override + String get pollQuestion => 'Domanda'; + + @override + String pollOptionHint(int number) { + return 'Opzione $number'; + } + + @override + String get pollAddOption => 'Aggiungi opzione'; + + @override + String get pollRemoveOption => 'Rimuovi opzione'; + + @override + String get pollAllowMultiple => 'Consenti più risposte'; + + @override + String get pollSingle => 'Sondaggio'; + + @override + String get pollMulti => 'Sondaggio, scelta multipla'; + + @override + String pollVoters(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count voti', + one: '1 voto', + zero: 'ancora nessun voto', + ); + return '$_temp0'; + } + + @override + String pollFallback(String question) { + return 'Sondaggio: $question'; + } + + @override + String get pollFallbackUpdate => 'Aggiorna Šepot per votare.'; + + @override + String get profilePhotoEdit => 'Cambia foto profilo'; + + @override + String get profilePhotoPick => 'Scegli foto'; + + @override + String get profilePhotoRemove => 'Rimuovi foto'; + + @override + String get profilePhotoHint => + 'La vedranno solo le persone con cui chatti — cifrata come i messaggi. Posizione e metadati non vengono inviati.'; + + @override + String profilePhotoFailed(String error) { + return 'Impossibile impostare la foto: $error'; + } } diff --git a/app/lib/l10n/generated/app_localizations_ru.dart b/app/lib/l10n/generated/app_localizations_ru.dart index 1352b85..445d9e3 100644 --- a/app/lib/l10n/generated/app_localizations_ru.dart +++ b/app/lib/l10n/generated/app_localizations_ru.dart @@ -75,17 +75,68 @@ class AppLocalizationsRu extends AppLocalizations { String get groupsLeftBanner => 'Вы больше не в группе. История сохранится, но новые сообщения приходить не будут.'; - @override - String groupsMembersWithCount(int count) { - return 'Участники ($count)'; - } - @override String get groupsYou => 'Вы'; @override String get groupsFounder => 'Создатель'; + @override + String get groupsAdmin => 'Администратор'; + + @override + String get groupsMakeAdmin => 'Сделать администратором'; + + @override + String get groupsDismissAdmin => 'Снять роль администратора'; + + @override + String get groupsRemoveMember => 'Удалить из группы'; + + @override + String groupsRemoveMemberConfirmTitle(String name) { + return 'Удалить $name из группы?'; + } + + @override + String get groupsRemoveMemberConfirmBody => + 'Новые сообщения группы перестанут приходить. Прежняя история сохранится.'; + + @override + String get groupsRemoveAction => 'Удалить'; + + @override + String get groupsViewContact => 'Открыть контакт'; + + @override + String get groupsAdminOnlyHint => + 'Название, участников и администраторов группы меняют только администраторы.'; + + @override + String get groupsAdminOnlyCompatHint => + 'Переименовать группу и менять администраторов могут только администраторы. Добавлять людей пока может любой, потому что у кого-то в группе старая версия Šepot.'; + + @override + String groupsAdminActionFailed(String error) { + return 'Не удалось внести изменение: $error'; + } + + @override + String get groupsResyncNotice => + 'Группа сверяется с администратором. Изменения состава могут появиться с задержкой.'; + + @override + String get groupsRejectedChangeNotice => + 'Кто-то без прав попытался изменить группу. Изменение не применено.'; + + @override + String get errorGroupNotAdmin => + 'Это может сделать только администратор группы.'; + + @override + String get errorGroupLastAdmin => + 'Вы единственный администратор. Сначала назначьте администратором кого-то ещё.'; + @override String get groupsNameHint => 'Название группы'; @@ -192,6 +243,28 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatActionReply => 'Ответить'; + @override + String get chatActionForward => 'Переслать'; + + @override + String get forwardTitle => 'Переслать в…'; + + @override + String get forwardSearchHint => 'Найти контакт или группу'; + + @override + String get forwardEmpty => 'Ничего не найдено'; + + @override + String forwardDone(String name) { + return 'Переслано: $name'; + } + + @override + String forwardFailed(String error) { + return 'Не удалось переслать: $error'; + } + @override String get commonCopy => 'Копировать'; @@ -248,6 +321,16 @@ class AppLocalizationsRu extends AppLocalizations { return 'Файл слишком большой (макс. $maxMb МБ)'; } + @override + String get attachmentKeepsMetadataTitle => 'Фото будет с геолокацией'; + + @override + String get attachmentKeepsMetadataBody => + 'Из этого формата фото (например, HEIC) я не могу удалить метаданные. Оно будет отправлено в оригинале, включая место съёмки и данные о телефоне. Безопаснее отправить его через Галерею — там метаданные удаляются.'; + + @override + String get attachmentSendOriginal => 'Отправить оригинал'; + @override String attachmentPickFailed(String error) { return 'Не удалось выбрать вложение: $error'; @@ -292,6 +375,10 @@ class AppLocalizationsRu extends AppLocalizations { @override String get errorGroupNeedsMember => 'В группе должен быть хотя бы 1 участник'; + @override + String get errorAttachmentUnavailable => + 'Вложения больше нет на этом телефоне'; + @override String errorGroupMaxMembers(int maxMembers) { return 'Максимальное число участников группы — $maxMembers'; @@ -603,19 +690,100 @@ class AppLocalizationsRu extends AppLocalizations { @override String get chatListNewConversation => 'Новый чат'; - @override - String chatListPhotoWithCaptionPreview(String caption) { - return '📷 $caption'; - } - - @override - String chatListFilePreview(String fileName) { - return '📎 $fileName'; - } - @override String get chatListReadReceiptPreview => 'Прочитано'; + @override + String get chatListDraft => 'Черновик'; + + @override + String get notesTitle => 'Заметки для себя'; + + @override + String get notesSubtitle => 'Только на этом телефоне, в зашифрованном виде'; + + @override + String get callVoice => 'Позвонить'; + + @override + String get callVideo => 'Видеозвонок'; + + @override + String get callStatusRinging => 'Вызов…'; + + @override + String get callIncomingVoice => 'Входящий звонок'; + + @override + String get callIncomingVideo => 'Входящий видеозвонок'; + + @override + String get callStatusConnecting => 'Соединение…'; + + @override + String get callEnded => 'Звонок завершён'; + + @override + String get callEndedBusy => 'Занято'; + + @override + String get callEndedRejected => 'Звонок отклонён'; + + @override + String get callEndedUnanswered => 'Нет ответа'; + + @override + String get callEndedFailed => 'Не удалось соединиться'; + + @override + String get callEncrypted => 'Сквозное шифрование'; + + @override + String get callAccept => 'Принять'; + + @override + String get callReject => 'Отклонить'; + + @override + String get callMute => 'Выключить микрофон'; + + @override + String get callSpeaker => 'Динамик'; + + @override + String get callCamera => 'Выключить камеру'; + + @override + String get callSwitchCamera => 'Сменить камеру'; + + @override + String get callHangUp => 'Завершить'; + + @override + String get callLogMissed => 'Пропущенный звонок'; + + @override + String get callLogUnanswered => 'Звонок без ответа'; + + @override + String get callLogRejected => 'Отклонённый звонок'; + + @override + String get callLogBusy => 'Занято'; + + @override + String get callLogFailed => 'Звонок не удался'; + + @override + String callLogVoice(String duration) { + return 'Звонок · $duration'; + } + + @override + String callLogVideo(String duration) { + return 'Видеозвонок · $duration'; + } + @override String get chatListTimeNow => 'сейчас'; @@ -958,14 +1126,14 @@ class AppLocalizationsRu extends AppLocalizations { 'Сообщения загрузятся только после открытия приложения.'; @override - String get pushModeUp => 'UnifiedPush (рекомендуется)'; + String get pushModeUp => 'UnifiedPush (без Google)'; @override String get pushModeUpDesc => 'Без сервисов Google — требуется приложение ntfy (F-Droid/Play).'; @override - String get pushModeFcm => 'Google (FCM)'; + String get pushModeFcm => 'Google (FCM, по умолчанию)'; @override String get pushModeFcmDesc => @@ -1010,8 +1178,7 @@ class AppLocalizationsRu extends AppLocalizations { String get pushNoDistributorFallback => 'без дистрибьютора'; @override - String get pushSubtitleFcm => - 'Google (FCM) — быстро, только по вашему выбору'; + String get pushSubtitleFcm => 'Google (FCM)'; @override String get restoreErrorNameRequired => 'Введите отображаемое имя.'; @@ -1336,4 +1503,257 @@ class AppLocalizationsRu extends AppLocalizations { @override String get photoPreviewDecodeFailed => 'Не удалось загрузить фото'; + + @override + String get chatActionInfo => 'Сведения о сообщении'; + + @override + String get messageInfoSent => 'Отправлено'; + + @override + String get messageInfoDelivered => 'Доставлено'; + + @override + String get messageInfoRead => 'Прочитано'; + + @override + String get messageInfoPending => 'ещё нет'; + + @override + String get messageInfoReadReceiptsOff => + 'Время прочтения видно, только если вы сами делитесь отчётами о прочтении (Настройки).'; + + @override + String chatStatusReadAt(String time) { + return 'Прочитано $time'; + } + + @override + String chatStatusDeliveredAt(String time) { + return 'Доставлено $time'; + } + + @override + String get groupsAddMembers => 'Добавить участников'; + + @override + String get groupsAddMembersAction => 'Добавить'; + + @override + String groupsAddMembersFailed(String error) { + return 'Не удалось добавить: $error'; + } + + @override + String get groupsNoContactsToAdd => + 'Все ваши контакты уже в группе. Сначала добавьте нового человека в контакты по QR-коду.'; + + @override + String get navChats => 'Чаты'; + + @override + String get chatListNewChat => 'Новый чат'; + + @override + String get chatListSearchHint => 'Поиск по чатам и сообщениям'; + + @override + String get chatFilterAll => 'Все'; + + @override + String get chatFilterUnread => 'Непрочитанные'; + + @override + String get chatFilterGroups => 'Группы'; + + @override + String get chatFilterArchive => 'Архив'; + + @override + String get chatListNoResults => 'Ничего не найдено.'; + + @override + String get chatListFilterEmpty => 'Здесь пока ничего нет.'; + + @override + String get chatListPin => 'Закрепить'; + + @override + String get chatListUnpin => 'Открепить'; + + @override + String get chatOpenProfile => 'Открыть профиль'; + + @override + String get groupsProfile => 'Информация о группе'; + + @override + String get chatSearchHint => 'Поиск в чате'; + + @override + String get chatSearchNoMatches => 'Нет'; + + @override + String chatSearchCounter(int current, int total) { + return '$current из $total'; + } + + @override + String get chatSearchOlder => 'Предыдущее совпадение'; + + @override + String get chatSearchNewer => 'Следующее совпадение'; + + @override + String chatUnreadDivider(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count новых сообщений', + few: '$count новых сообщения', + one: '$count новое сообщение', + ); + return '$_temp0'; + } + + @override + String get chatJumpToLatest => 'К последним сообщениям'; + + @override + String get chatAttach => 'Прикрепить'; + + @override + String get chatSend => 'Отправить'; + + @override + String get chatRecordVoice => 'Записать голосовое'; + + @override + String get profileSharedMedia => 'Общие медиа'; + + @override + String get profileMessage => 'Написать'; + + @override + String get profileSearch => 'Поиск'; + + @override + String get profileUnmute => 'Включить звук'; + + @override + String get profileDisappearing => 'Исчезание'; + + @override + String get profileCommonGroups => 'Общие группы'; + + @override + String get profileSafetyNumber => 'Код безопасности'; + + @override + String get profileSafetyNumberHint => 'Сравните лично или по QR-коду'; + + @override + String get profileVerifiedInPerson => 'Проверено лично'; + + @override + String get profileVerifyNow => 'Проверить контакт'; + + @override + String get profileAdd => 'Добавить'; + + @override + String profileGroupSubtitle(String members, String date) { + return 'Группа · $members · создана $date'; + } + + @override + String profileMembersOfMax(int count, int max) { + return '$count из $max'; + } + + @override + String get chatTyping => 'печатает…'; + + @override + String chatTypingNamed(String name) { + return '$name печатает…'; + } + + @override + String get chatTypingSeveral => 'Несколько человек печатают…'; + + @override + String get settingsTypingTitle => 'Индикатор набора'; + + @override + String get settingsTypingSubtitle => + 'Взаимно — не делитесь, не видите (по умолчанию выкл.)'; + + @override + String get pollAttach => 'Опрос'; + + @override + String get pollCreateTitle => 'Новый опрос'; + + @override + String get pollQuestion => 'Вопрос'; + + @override + String pollOptionHint(int number) { + return 'Вариант $number'; + } + + @override + String get pollAddOption => 'Добавить вариант'; + + @override + String get pollRemoveOption => 'Удалить вариант'; + + @override + String get pollAllowMultiple => 'Разрешить несколько ответов'; + + @override + String get pollSingle => 'Опрос'; + + @override + String get pollMulti => 'Опрос, несколько ответов'; + + @override + String pollVoters(int count) { + String _temp0 = intl.Intl.pluralLogic( + count, + locale: localeName, + other: '$count голосов', + few: '$count голоса', + one: '$count голос', + zero: 'пока нет голосов', + ); + return '$_temp0'; + } + + @override + String pollFallback(String question) { + return 'Опрос: $question'; + } + + @override + String get pollFallbackUpdate => 'Обновите Šepot, чтобы голосовать.'; + + @override + String get profilePhotoEdit => 'Изменить фото профиля'; + + @override + String get profilePhotoPick => 'Выбрать фото'; + + @override + String get profilePhotoRemove => 'Удалить фото'; + + @override + String get profilePhotoHint => + 'Фото увидят только ваши собеседники — зашифровано, как и сообщения. Геоданные и метаданные не отправляются.'; + + @override + String profilePhotoFailed(String error) { + return 'Не удалось установить фото: $error'; + } } diff --git a/app/lib/models/avatar_ref.dart b/app/lib/models/avatar_ref.dart new file mode 100644 index 0000000..de35124 --- /dev/null +++ b/app/lib/models/avatar_ref.dart @@ -0,0 +1,79 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:sepot/core/crypto/attachment_cipher.dart'; +import 'package:sepot/core/storage/attachment_store.dart'; + +/// Odkaz na profilovou fotku: šifrovaný blob na relayi + klíč k němu. +/// +/// Fotka sama nejde v každé zprávě — v profilu `p` cestuje jen tenhle +/// odkaz (pár set bajtů). Příjemce blob stáhne jednou, ověří digest +/// a drží si ho šifrovaný u sebe. +class AvatarRef { + const AvatarRef({ + required this.blobId, + required this.key, + required this.nonce, + required this.digest, + this.uploadedAt, + }); + + /// Profilová fotka je malá (256 px JPEG) — cokoli většího je podvrh. + static const maxBlobBytes = 256 * 1024; + + final String blobId; + final Uint8List key; + final Uint8List nonce; + final Uint8List digest; + + /// Kdy jsem blob nahrál (jen u vlastní fotky — kvůli obnově před + /// vypršením na relayi). + final DateTime? uploadedAt; + + Map toWire() => { + 'b': blobId, + 'k': base64Encode(key), + 'n': base64Encode(nonce), + 'd': base64Encode(digest), + }; + + /// Odkaz od protistrany; cokoli podezřelého = null (fotka se neukáže). + static AvatarRef? fromWire(Object? raw) { + if (raw is! Map) return null; + final b = raw['b']; + if (b is! String || !AttachmentStore.isValidBlobId(b)) return null; + try { + final key = base64Decode(raw['k'] as String); + final nonce = base64Decode(raw['n'] as String); + final digest = base64Decode(raw['d'] as String); + if (key.length != AttachmentCipher.keyLength || + nonce.length != AttachmentCipher.nonceLength || + digest.length != 32) { + return null; + } + return AvatarRef(blobId: b, key: key, nonce: nonce, digest: digest); + } on Object { + return null; + } + } + + Map toJson() => { + ...toWire(), + if (uploadedAt != null) 'u': uploadedAt!.millisecondsSinceEpoch, + }; + + static AvatarRef? fromJson(Object? raw) { + final ref = fromWire(raw); + if (ref == null || raw is! Map) return ref; + final u = raw['u']; + return u is int + ? AvatarRef( + blobId: ref.blobId, + key: ref.key, + nonce: ref.nonce, + digest: ref.digest, + uploadedAt: DateTime.fromMillisecondsSinceEpoch(u), + ) + : ref; + } +} diff --git a/app/lib/models/contact.dart b/app/lib/models/contact.dart index 6e311f9..32f4ccc 100644 --- a/app/lib/models/contact.dart +++ b/app/lib/models/contact.dart @@ -1,3 +1,4 @@ +import 'package:sepot/models/avatar_ref.dart'; import 'package:sepot/models/identity.dart'; /// Kontakt — osoba, se kterou můžeme chatovat @@ -14,6 +15,9 @@ class Contact { /// nezobrazí) a odesílání je v UI zablokované. bool blocked; + /// Profilová fotka, kterou kontakt sdílí (null = iniciály). + AvatarRef? avatar; + Contact({ required this.displayName, required this.publicIdentity, @@ -21,6 +25,7 @@ class Contact { this.verified = false, this.notes, this.blocked = false, + this.avatar, }); /// ID kontaktu = fingerprint identity klíče (identita = klíč). @@ -58,6 +63,7 @@ class Contact { 'verified': verified, 'notes': notes, 'blocked': blocked, + 'avatar': avatar?.toJson(), }; factory Contact.fromJson(Map json) { @@ -70,6 +76,7 @@ class Contact { verified: json['verified'] as bool? ?? false, notes: json['notes'] as String?, blocked: json['blocked'] as bool? ?? false, + avatar: AvatarRef.fromJson(json['avatar']), ); } } diff --git a/app/lib/models/conversation.dart b/app/lib/models/conversation.dart index f70e360..59eb236 100644 --- a/app/lib/models/conversation.dart +++ b/app/lib/models/conversation.dart @@ -9,10 +9,11 @@ class Conversation { DateTime lastActivity; int? disappearAfterSeconds; // Globální nastavení mizících zpráv - /// Ztlumení a archivace — čistě LOKÁLNÍ (neopouští zařízení). + /// Ztlumení, archivace a připnutí — čistě LOKÁLNÍ (neopouští zařízení). bool muted; DateTime? mutedUntil; // null + muted = navždy; jinak dočasně bool archived; + bool pinned; // připnutý nahoře v seznamu chatů (lokální) Conversation({ required this.contactId, @@ -22,6 +23,7 @@ class Conversation { this.muted = false, this.mutedUntil, this.archived = false, + this.pinned = false, }) : messages = messages ?? [], lastActivity = lastActivity ?? DateTime.now(); @@ -75,6 +77,7 @@ class Conversation { 'muted': muted, 'mutedUntil': mutedUntil?.toIso8601String(), 'archived': archived, + 'pinned': pinned, }; factory Conversation.fromJson(Map json) { @@ -92,6 +95,7 @@ class Conversation { ? null : DateTime.parse(json['mutedUntil'] as String), archived: json['archived'] as bool? ?? false, + pinned: json['pinned'] as bool? ?? false, ); } } diff --git a/app/lib/models/message.dart b/app/lib/models/message.dart index 6d75f92..de941c9 100644 --- a/app/lib/models/message.dart +++ b/app/lib/models/message.dart @@ -1,4 +1,7 @@ import 'package:sepot/models/attachment.dart'; +import 'package:sepot/models/call_log.dart'; +import 'package:sepot/models/group.dart'; +import 'package:sepot/models/poll.dart'; import 'package:uuid/uuid.dart'; /// Typy zpráv — odpovídá Rust: MessageType @@ -11,8 +14,8 @@ enum MessageType { keyExchange, } -/// Stav odeslané (mojí) zprávy — poctivá hranice: read receipts zatím -/// NEjsou (design: default OFF), takže „přečteno" se nezobrazuje. +/// Stav odeslané (mojí) zprávy. Potvrzení přečtení je volitelné (default +/// vypnuto) a reciproční — kdo ho nesdílí, „přečteno" neuvidí ani sám. /// sending — lokálně zařazeno, čeká na potvrzení relaye (⏱) /// sent — relay přijal, příjemce byl offline → v jeho frontě (✓) /// delivered — doručeno na zařízení příjemce (✓✓) @@ -43,6 +46,21 @@ class Message { final bool isDeleted; // smazáno pro všechny (tombstone) /// Reakce: fingerprint odesílatele reakce → emoji. Prázdné = žádné. final Map reactions; + /// Kdy mou zprávu dostal / přečetl který příjemce (fingerprint → čas). + /// 1:1 má nejvýš jeden záznam, skupina jeden na člena — z toho se + /// skládá „Info o zprávě". Čas je z hodin příjemce (pole `ts` doručenky). + final Map deliveredTo; + final Map readBy; + + /// Anketa (textová zpráva, `content` nese záložní text pro starší appky). + final Poll? poll; + + /// Fingerprinty lidí zmíněných přes @ ve skupině — zmínka projde i přes + /// ztlumení skupiny. + final List mentions; + + /// Lokální záznam hovoru — taková zpráva nemá text ani přílohu. + final CallLog? callLog; const Message({ required this.id, @@ -62,6 +80,11 @@ class Message { this.editedAt, this.isDeleted = false, this.reactions = const {}, + this.deliveredTo = const {}, + this.readBy = const {}, + this.poll, + this.mentions = const [], + this.callLog, }); /// Vytvoř textovou zprávu @@ -72,6 +95,8 @@ class Message { String? replyToId, int? disappearAfterSeconds, MessageStatus status = MessageStatus.sending, + Poll? poll, + List mentions = const [], }) { return Message( id: const Uuid().v4(), @@ -83,6 +108,8 @@ class Message { timestamp: DateTime.now(), status: status, disappearAfterSeconds: disappearAfterSeconds, + poll: poll, + mentions: mentions, ); } @@ -137,6 +164,9 @@ class Message { DateTime? editedAt, bool? isDeleted, Map? reactions, + Map? deliveredTo, + Map? readBy, + Poll? poll, }) { return Message( id: id, @@ -156,9 +186,22 @@ class Message { editedAt: editedAt ?? this.editedAt, isDeleted: isDeleted ?? this.isDeleted, reactions: reactions ?? this.reactions, + deliveredTo: deliveredTo ?? this.deliveredTo, + readBy: readBy ?? this.readBy, + poll: poll ?? this.poll, + mentions: mentions, + callLog: callLog, ); } + /// Nejdřívější doručení / přečtení napříč příjemci (null = zatím nic). + DateTime? get firstDeliveredAt => _earliest(deliveredTo.values); + DateTime? get firstReadAt => _earliest(readBy.values); + + static DateTime? _earliest(Iterable times) => times.isEmpty + ? null + : times.reduce((a, b) => a.isBefore(b) ? a : b); + /// Má zpráva mizet? bool get isDisappearing => disappearAfterSeconds != null; @@ -183,8 +226,27 @@ class Message { 'editedAt': editedAt?.toIso8601String(), 'isDeleted': isDeleted, 'reactions': reactions, + 'deliveredTo': _timesToJson(deliveredTo), + 'readBy': _timesToJson(readBy), + 'poll': poll?.toJson(), + if (mentions.isNotEmpty) 'mentions': mentions, + if (callLog != null) 'call': callLog!.toJson(), }; + static Map _timesToJson(Map times) => + times.map((k, v) => MapEntry(k, v.millisecondsSinceEpoch)); + + /// Tolerantní čtení: starší záznamy pole nemají, vadné položky se + /// přeskočí — kvůli nim nesmí spadnout načtení celé konverzace. + static Map _timesFromJson(Object? raw) { + if (raw is! Map) return const {}; + return { + for (final e in raw.entries) + if (e.key is String && e.value is int) + e.key as String: DateTime.fromMillisecondsSinceEpoch(e.value as int), + }; + } + factory Message.fromJson(Map json) { return Message( id: json['id'] as String, @@ -215,6 +277,18 @@ class Message { reactions: (json['reactions'] as Map?) ?.map((k, v) => MapEntry(k, v as String)) ?? const {}, + deliveredTo: _timesFromJson(json['deliveredTo']), + readBy: _timesFromJson(json['readBy']), + poll: Poll.fromJson(json['poll']), + mentions: mentionsFromWire(json['mentions']), + callLog: CallLog.fromJson(json['call']), ); } } + +/// Zmínky z drátu i z úložiště: jen řetězce a rozumný počet — obálka od +/// cizího klienta nesmí rozbít zprávu ani nafouknout úložiště. +List mentionsFromWire(Object? raw) { + if (raw is! List) return const []; + return raw.whereType().take(Group.maxMembers).toList(); +} diff --git a/app/lib/models/poll.dart b/app/lib/models/poll.dart new file mode 100644 index 0000000..8085bce --- /dev/null +++ b/app/lib/models/poll.dart @@ -0,0 +1,109 @@ +/// Anketa uvnitř zprávy. +/// +/// Na drátě jede jako běžná textová zpráva s polem `poll` — starší verze +/// appky pole neznají a ukážou záložní text (otázku a možnosti), nové +/// z něj vykreslí anketu. Hlasy jsou samostatné řídicí zprávy `vote`; +/// klíč hlasu je otisk odesílatele ověřený sealed senderem, nikdy údaj +/// z obsahu. +class Poll { + const Poll({ + required this.question, + required this.options, + this.multi = false, + this.votes = const {}, + }); + + static const minOptions = 2; + static const maxOptions = 12; + static const maxQuestionLength = 200; + static const maxOptionLength = 100; + + final String question; + final List options; + + /// Smí jeden člověk zvolit víc možností. + final bool multi; + + /// Otisk hlasujícího → zvolené indexy možností. + final Map> votes; + + /// Kolik lidí zvolilo možnost [index]. + int countFor(int index) => + votes.values.where((choice) => choice.contains(index)).length; + + /// Otisky lidí, kteří zvolili možnost [index]. + List votersFor(int index) => [ + for (final e in votes.entries) + if (e.value.contains(index)) e.key, + ]; + + int get voterCount => votes.length; + + /// Nový hlas [voter]. Neplatné indexy se zahodí, duplicity sloučí; + /// u ankety s jednou volbou platí jen první. Prázdná volba = hlas zrušen. + Poll withVote(String voter, List choice) { + final clean = [ + for (final i in choice.toSet()) + if (i >= 0 && i < options.length) i, + ]..sort(); + final picked = multi ? clean : clean.take(1).toList(); + final next = Map>.from(votes); + if (picked.isEmpty) { + next.remove(voter); + } else { + next[voter] = picked; + } + return Poll(question: question, options: options, multi: multi, votes: next); + } + + /// Co jde na drát (bez hlasů — ty cestují zvlášť). + Map toWire() => { + 'q': question, + 'o': options, + if (multi) 'm': true, + }; + + /// Anketa od protistrany. Cokoli mimo limity = null (zpráva se pak + /// ukáže jako obyčejný text), aby cizí data nemohla rozbít vykreslení. + static Poll? fromWire(Object? raw) { + if (raw is! Map) return null; + final q = raw['q']; + final o = raw['o']; + if (q is! String || o is! List) return null; + final question = q.trim(); + if (question.isEmpty || question.length > maxQuestionLength) return null; + final options = [ + for (final item in o) + if (item is String) item.trim(), + ]; + if (options.length != o.length || + options.length < minOptions || + options.length > maxOptions || + options.any((x) => x.isEmpty || x.length > maxOptionLength)) { + return null; + } + return Poll(question: question, options: options, multi: raw['m'] == true); + } + + Map toJson() => { + ...toWire(), + 'votes': votes, + }; + + static Poll? fromJson(Object? raw) { + final poll = fromWire(raw); + if (poll == null || raw is! Map) return poll; + final stored = raw['votes']; + var result = poll; + if (stored is Map) { + for (final e in stored.entries) { + final choice = e.value; + if (e.key is String && choice is List) { + result = result.withVote( + e.key as String, choice.whereType().toList()); + } + } + } + return result; + } +} diff --git a/app/lib/providers/conversations_provider.dart b/app/lib/providers/conversations_provider.dart index 8f711ee..fc4f96b 100644 --- a/app/lib/providers/conversations_provider.dart +++ b/app/lib/providers/conversations_provider.dart @@ -1,10 +1,13 @@ import 'dart:typed_data'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sepot/core/constants/app_constants.dart'; +import 'package:sepot/core/errors.dart'; import 'package:sepot/models/contact.dart'; import 'package:sepot/models/conversation.dart'; import 'package:sepot/models/group.dart'; import 'package:sepot/models/message.dart'; +import 'package:sepot/models/poll.dart'; import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/storage_provider.dart'; import 'package:sepot/providers/identity_provider.dart'; @@ -20,13 +23,23 @@ class ConversationsNotifier extends AsyncNotifier> { /// Odešli zprávu — E2E šifrovaně přes relay. `replyToId` = odpověď. /// `contactId` může být i gid skupiny (fanout členům). Future sendMessage(String contactId, String text, - {String? replyToId}) async { + {String? replyToId, List mentions = const []}) async { + if (isNotes(contactId)) { + return _addNote((me) => Message.text( + senderId: me, + recipientId: me, + content: text, + replyToId: replyToId, + status: MessageStatus.sent, + )); + } final storage = ref.read(storageProvider); final service = ref.read(messagingServiceProvider); final group = await _groupOrNull(contactId); if (group != null) { - await service.sendGroupText(group, text, replyToId: replyToId); + await service.sendGroupText(group, text, + replyToId: replyToId, mentions: mentions); } else { final contact = await _contact(contactId); // sendText zprávu po odeslání sám uloží (s reálným delivery stavem) @@ -37,6 +50,51 @@ class ConversationsNotifier extends AsyncNotifier> { state = AsyncData(conversations); } + /// Pošle anketu. [fallback] je text, který uvidí starší verze appky + /// (skládá ho UI, protože zná jazyk uživatele). + Future sendPoll(String convId, Poll poll, String fallback) async { + final service = ref.read(messagingServiceProvider); + final group = await _groupOrNull(convId); + if (group != null) { + await service.sendGroupText(group, fallback, poll: poll); + } else { + await service.sendText(await _contact(convId), fallback, poll: poll); + } + state = AsyncData(await ref.read(storageProvider).loadConversations()); + } + + /// Hlas v anketě (prázdná volba = zrušit). + Future votePoll(String convId, String pollId, List choice) async { + final service = ref.read(messagingServiceProvider); + final group = await _groupOrNull(convId); + await service.votePoll( + convId, + pollId, + choice, + group: group, + contact: group == null ? await _contact(convId) : null, + ); + } + + static bool isNotes(String id) => id == AppConstants.notesConversationId; + + /// Poznámka pro sebe: jen šifrované úložiště v telefonu, nic do sítě. + Future _addNote(Message Function(String myId) build) async { + final identity = ref.read(identityProvider).valueOrNull; + if (identity == null) return; + final storage = ref.read(storageProvider); + await storage.addMessage( + AppConstants.notesConversationId, build(identity.id)); + state = AsyncData(await storage.loadConversations()); + } + + /// Otevře poznámky; prázdné je založí, aby byly vidět i v seznamu chatů. + Future ensureNotes() async { + final storage = ref.read(storageProvider); + await storage.ensureConversation(AppConstants.notesConversationId); + state = AsyncData(await storage.loadConversations()); + } + Future _contact(String contactId) async { final contacts = await ref.read(storageProvider).loadContacts(); final contact = contacts.where((c) => c.id == contactId).firstOrNull; @@ -67,6 +125,17 @@ class ConversationsNotifier extends AsyncNotifier> { /// Úprava textu odeslané zprávy. Future editMessage( String contactId, String messageId, String newText) async { + if (isNotes(contactId)) { + final storage = ref.read(storageProvider); + final conv = await storage.loadConversation(contactId); + final old = conv?.messages.where((m) => m.id == messageId).firstOrNull; + if (conv == null || old == null) return; + conv.updateMessage( + old.copyWith(content: newText, editedAt: DateTime.now())); + await storage.saveConversation(conv); + state = AsyncData(await storage.loadConversations()); + return; + } final service = ref.read(messagingServiceProvider); final group = await _groupOrNull(contactId); if (group != null) { @@ -112,6 +181,18 @@ class ConversationsNotifier extends AsyncNotifier> { state = AsyncData(await storage.loadConversations()); } + /// Připnout / odepnout konverzaci (lokální). Archivovaná se připnutím + /// vrací mezi aktivní — připnutý chat v archivu by nikdo neviděl. + Future setPinned(String contactId, bool pinned) async { + final storage = ref.read(storageProvider); + final conv = await storage.loadConversation(contactId); + if (conv == null) return; + conv.pinned = pinned; + if (pinned) conv.archived = false; + await storage.saveConversation(conv); + state = AsyncData(await storage.loadConversations()); + } + /// Smazání zprávy jen u mě (lokálně) — protistraně se nic neposílá. Future deleteLocalMessage(String contactId, String messageId) async { final storage = ref.read(storageProvider); @@ -134,6 +215,18 @@ class ConversationsNotifier extends AsyncNotifier> { final storage = ref.read(storageProvider); final service = ref.read(messagingServiceProvider); + if (isNotes(contactId)) { + final meta = await service.prepareNoteAttachment( + bytes: bytes, fileName: fileName, mime: mime); + return _addNote((me) => Message.attachment( + senderId: me, + recipientId: me, + attachment: meta, + caption: caption, + status: MessageStatus.sent, + )); + } + final group = await _groupOrNull(contactId); if (group != null) { await service.sendGroupAttachment( @@ -167,6 +260,19 @@ class ConversationsNotifier extends AsyncNotifier> { }) async { final storage = ref.read(storageProvider); final service = ref.read(messagingServiceProvider); + if (isNotes(contactId)) { + final meta = await service.prepareNoteAttachment( + bytes: bytes, + fileName: 'voice-message.m4a', + mime: mime, + durationMs: durationMs); + return _addNote((me) => Message.attachment( + senderId: me, + recipientId: me, + attachment: meta, + status: MessageStatus.sent, + )); + } final group = await _groupOrNull(contactId); if (group != null) { await service.sendGroupAttachment( @@ -188,6 +294,33 @@ class ConversationsNotifier extends AsyncNotifier> { state = AsyncData(await storage.loadConversations()); } + /// Přepošle zprávu do jiné konverzace (kontakt i skupina). Příloha se + /// dešifruje a odešle znovu s novým klíčem: odkaz na původní blob by + /// vypršel s ním (relay drží bloby 30 dní) a cíl by ho neměl čím otevřít. + Future forwardMessage(Message message, String targetId) async { + final meta = message.attachment; + if (meta == null) { + await sendMessage(targetId, message.content ?? ''); + return; + } + final bytes = await ref + .read(messagingServiceProvider) + .loadDecryptedAttachment(message); + if (bytes == null) throw const AttachmentUnavailableError(); + if (meta.mime.startsWith('audio/') && meta.durationMs != null) { + await sendVoiceMessage(targetId, + bytes: bytes, durationMs: meta.durationMs!, mime: meta.mime); + return; + } + await sendAttachment( + targetId, + bytes: bytes, + fileName: meta.name ?? 'attachment', + mime: meta.mime, + caption: message.content, + ); + } + /// Ruční opakování downloadu blobu (stav failed). Future retryAttachmentDownload( String contactId, Message message) async { @@ -210,23 +343,26 @@ class ConversationsNotifier extends AsyncNotifier> { // přečtení (sendReadReceipts sám zkontroluje, jestli je funkce zapnutá). final justRead = conv.messages .where((m) => !m.isMine(identity.id) && !m.isRead) - .map((m) => m.id) .toList(); conv.markAllRead(identity.id); await storage.saveConversation(conv); state = AsyncData(await storage.loadConversations()); - // Potvrzení přečtení ve skupině v1 neposíláme (fanout N receipts - // za každé otevření chatu; per-member matice až v2). - if (justRead.isNotEmpty && await _groupOrNull(contactId) == null) { - final contacts = await storage.loadContacts(); - final contact = contacts.where((c) => c.id == contactId).firstOrNull; - if (contact != null) { - await ref - .read(messagingServiceProvider) - .sendReadReceipts(contact, justRead); - } + if (justRead.isEmpty) return; + // Ve skupině dostane potvrzení jen autor dané zprávy — ne celý roster. + // Otevření chatu tak stojí tolik obálek, kolik lidí mezitím psalo. + final group = await _groupOrNull(contactId); + final byAuthor = >{}; + for (final m in justRead) { + byAuthor.putIfAbsent(m.senderId, () => []).add(m.id); + } + final contacts = await storage.loadContacts(); + final service = ref.read(messagingServiceProvider); + for (final MapEntry(key: author, value: ids) in byAuthor.entries) { + final contact = contacts.where((c) => c.id == author).firstOrNull; + if (contact == null) continue; + await service.sendReadReceipts(contact, ids, gid: group?.id); } } diff --git a/app/lib/providers/drafts_provider.dart b/app/lib/providers/drafts_provider.dart new file mode 100644 index 0000000..c712eb9 --- /dev/null +++ b/app/lib/providers/drafts_provider.dart @@ -0,0 +1,38 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sepot/providers/storage_provider.dart'; + +/// Rozepsané zprávy (contactId → text). Jeden záznam v šifrovaném nastavení, +/// ne pole konverzace: konverzace se ukládá celá jako load → změna → save +/// a zápis konceptu při psaní by mohl přepsat zprávu, která mezitím dorazila. +class DraftsNotifier extends AsyncNotifier> { + static const key = 'drafts'; + + @override + Future> build() async { + final raw = + await ref.read(storageProvider).getSetting>(key); + return { + for (final e in (raw ?? const {}).entries) + if (e.value is String) e.key: e.value as String, + }; + } + + Future set(String contactId, String text) async { + // Počkat na načtení — jinak by zápis před dokončením build() smazal + // koncepty ostatních chatů. + final current = {...await future}; + final trimmed = text.trim(); + if ((current[contactId] ?? '') == trimmed) return; + if (trimmed.isEmpty) { + current.remove(contactId); + } else { + current[contactId] = trimmed; + } + state = AsyncData(current); + await ref.read(storageProvider).setSetting(key, current); + } +} + +final draftsProvider = + AsyncNotifierProvider>( + DraftsNotifier.new); diff --git a/app/lib/providers/groups_provider.dart b/app/lib/providers/groups_provider.dart index 7de4054..2fe3c1a 100644 --- a/app/lib/providers/groups_provider.dart +++ b/app/lib/providers/groups_provider.dart @@ -30,6 +30,20 @@ class GroupsNotifier extends AsyncNotifier> { await _refresh(); } + Future removeMember(Group group, String fingerprint) async { + await ref + .read(messagingServiceProvider) + .removeGroupMember(group, fingerprint); + await _refresh(); + } + + Future setAdmin(Group group, String fingerprint, bool admin) async { + await ref + .read(messagingServiceProvider) + .setGroupAdmin(group, fingerprint, admin); + await _refresh(); + } + Future leave(Group group) async { await ref.read(messagingServiceProvider).leaveGroup(group); await _refresh(); diff --git a/app/lib/providers/messaging_provider.dart b/app/lib/providers/messaging_provider.dart index 9ccc15e..497eb2f 100644 --- a/app/lib/providers/messaging_provider.dart +++ b/app/lib/providers/messaging_provider.dart @@ -1,5 +1,8 @@ +import 'dart:typed_data'; + import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:sepot/core/calls/call_controller.dart'; import 'package:sepot/core/network/relay_client.dart'; import 'package:sepot/core/push/push_notifier.dart'; import 'package:sepot/core/services/messaging_service.dart'; @@ -16,6 +19,28 @@ final messagingServiceProvider = Provider((ref) { return service; }); +/// Kdo právě píše: konverzace → otisky pisatelů. Prázdné, když je +/// „píše…“ vypnuté (služba pak nic nepřijímá). +final typingProvider = StreamProvider>>((ref) async* { + final tracker = ref.watch(messagingServiceProvider).typing; + yield tracker.current; + yield* tracker.changes; +}); + +/// Profilová fotka člověka podle otisku (null = iniciály). Přepočítá se, +/// když se změní kontakty — tam dorazí nový odkaz i stažení fotky. +final avatarProvider = + FutureProvider.family((ref, fingerprint) async { + final service = ref.watch(messagingServiceProvider); + final me = ref.watch(identityProvider).valueOrNull; + final contacts = ref.watch(contactsProvider).valueOrNull ?? const []; + if (!service.isStarted) return null; + final avatar = fingerprint == me?.id + ? service.myAvatar + : contacts.where((c) => c.id == fingerprint).firstOrNull?.avatar; + return avatar == null ? null : service.loadAvatar(avatar); +}); + /// Nastartuje službu, jakmile existuje identita. Watchovat z první /// obrazovky po odemčení (chat list) — do té doby se nic nespojuje. /// Předpokládá odemčený storage — na /chats se jde vždy až po unlocku. @@ -46,16 +71,21 @@ final messagingStartProvider = FutureProvider((ref) async { // Appka běží, ale není v popředí (jiná appka / zhasnutý displej se // živým procesem) → systémová notifikace. Jen jméno, žádný obsah. - // Ztlumená konverzace notifikaci potlačí (unread badge běží dál). - service.onMessageReceived = (contact, message) async { + // Ztlumená konverzace notifikaci potlačí (unread badge běží dál), + // zmínka mě ve skupině projde i tak. + service.onMessageReceived = (contact, message, convId) async { final visible = WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed; if (visible) return; - final conv = await ref.read(storageProvider).loadConversation(contact.id); - if (conv?.isMuted == true) return; + final conv = await ref.read(storageProvider).loadConversation(convId); + final mentionedMe = message.mentions.contains(identity.id); + if (conv?.isMuted == true && !mentionedMe) return; PushNotifier.showMessageNotification(contact.displayName); }; + service.onCallSignal = (contact, signal) => + ref.read(callProvider.notifier).onSignal(contact, signal); + await service.start(identity); }); diff --git a/app/lib/providers/settings_provider.dart b/app/lib/providers/settings_provider.dart index 58ce8ce..b947fc8 100644 --- a/app/lib/providers/settings_provider.dart +++ b/app/lib/providers/settings_provider.dart @@ -1,6 +1,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:screen_protector/screen_protector.dart'; import 'package:sepot/core/backup/backup_service.dart'; +import 'package:sepot/core/services/messaging_service.dart'; import 'package:sepot/providers/messaging_provider.dart'; import 'package:sepot/providers/storage_provider.dart'; @@ -32,6 +33,29 @@ class ReadReceiptsNotifier extends AsyncNotifier { final readReceiptsProvider = AsyncNotifierProvider(ReadReceiptsNotifier.new); +/// „píše…“ — stejná pravidla jako potvrzení přečtení: výchozí vypnuto, +/// reciproční (kdo nesdílí, nevidí). +class TypingIndicatorsNotifier extends AsyncNotifier { + @override + Future build() async { + final storage = ref.read(storageProvider); + return (await storage.getSetting( + MessagingService.typingSettingKey)) ?? + false; + } + + Future set(bool value) async { + await ref + .read(storageProvider) + .setSetting(MessagingService.typingSettingKey, value); + state = AsyncData(value); + } +} + +final typingIndicatorsProvider = + AsyncNotifierProvider( + TypingIndicatorsNotifier.new); + /// Zabezpečení obrazovky: blok screenshotů (Android FLAG_SECURE) + skrytí /// obsahu v přepínači appek (iOS). Default VYPNUTO — blokuje i vlastní /// screenshoty, tak ať se uživatel rozhodne sám. diff --git a/app/lib/widgets/avatar.dart b/app/lib/widgets/avatar.dart index 34d265d..20ffeda 100644 --- a/app/lib/widgets/avatar.dart +++ b/app/lib/widgets/avatar.dart @@ -1,73 +1,110 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/providers/messaging_provider.dart'; -/// Generovaný avatar z iniciál -class SepotAvatar extends StatelessWidget { +/// Avatar člověka: jeho profilová fotka, jinak iniciály na barevném tónu. +/// +/// Tón se odvozuje ze [seed] (otisk klíče), ne ze jména — přejmenovaný +/// kontakt si barvu nechá a dva lidé se stejným jménem se od sebe liší. +/// Podle téhož otisku se hledá i fotka. +class SepotAvatar extends ConsumerWidget { final String name; final double size; - final bool isOnline; + + /// Stabilní identifikátor pro tón (fingerprint). Bez něj se použije jméno. + final String? seed; + + /// Zelený odznak ověřeného kontaktu v pravém dolním rohu. + final bool verified; + + /// Kroužek kolem avataru (připnutý chat s nepřečtenou zprávou apod.). + final Color? ring; const SepotAvatar({ super.key, required this.name, this.size = 48, - this.isOnline = false, + this.seed, + this.verified = false, + this.ring, }); @override - Widget build(BuildContext context) { - final initials = _getInitials(name); - final color = _colorFromName(name); + Widget build(BuildContext context, WidgetRef ref) { + final tint = SepotTint.of(seed ?? name); + final badge = (size * 0.34).clamp(12.0, 30.0); + final photo = + seed == null ? null : ref.watch(avatarProvider(seed!)).valueOrNull; + final px = (size * MediaQuery.devicePixelRatioOf(context)).round(); + + Widget circle = Container( + width: size, + height: size, + decoration: BoxDecoration(shape: BoxShape.circle, color: tint.background), + alignment: Alignment.center, + clipBehavior: Clip.antiAlias, + child: photo != null + ? Image.memory( + photo, + width: size, + height: size, + fit: BoxFit.cover, + // Dekódovat jen v potřebné velikosti — seznam má desítky avatarů. + cacheWidth: px, + gaplessPlayback: true, + ) + : Text( + initialsOf(name), + style: TextStyle( + color: tint.ink, + fontSize: size * 0.34, + fontWeight: FontWeight.w700, + ), + ), + ); + + if (ring != null) { + circle = Container( + padding: const EdgeInsets.all(2.5), + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: ring!, width: 2), + ), + child: circle, + ); + } + if (!verified) return circle; return Stack( + clipBehavior: Clip.none, children: [ - Container( - width: size, - height: size, - decoration: BoxDecoration( - shape: BoxShape.circle, - // Nokturno: tichý tmavý podklad, barva jen v iniciálách - color: SepotColors.surfaceVariant, - border: Border.all(color: SepotColors.border), - ), - child: Center( - child: Text( - initials, - style: TextStyle( - color: color, - fontSize: size * 0.35, - fontWeight: FontWeight.w600, - ), + circle, + Positioned( + right: -1, + bottom: -1, + child: Container( + width: badge, + height: badge, + decoration: BoxDecoration( + color: SepotColors.sage, + shape: BoxShape.circle, + border: Border.all(color: SepotColors.background, width: 2), ), + child: Icon(Icons.check_rounded, + size: badge * 0.62, color: SepotColors.background), ), ), - if (isOnline) - Positioned( - right: 0, - bottom: 0, - child: Container( - width: size * 0.28, - height: size * 0.28, - decoration: BoxDecoration( - color: SepotColors.success, - shape: BoxShape.circle, - border: Border.all( - color: SepotColors.background, - width: 2, - ), - ), - ), - ), ], ); } - String _getInitials(String name) { + /// Iniciály pro avatar — veřejné, ať je sdílí i kolečka hlasujících apod. + static String initialsOf(String name) { // Prázdné díly odfiltrovat — dvojitá mezera ve jméně nesmí shodit render. // Grafémy (characters), ne code units — jméno začínající emoji by se // jinak rozpůlilo na rozbitý surrogate. - final parts = - name.trim().split(' ').where((p) => p.isNotEmpty).toList(); + final parts = name.trim().split(' ').where((p) => p.isNotEmpty).toList(); if (parts.length >= 2) { return '${parts[0].characters.first}${parts[1].characters.first}' .toUpperCase(); @@ -75,16 +112,48 @@ class SepotAvatar extends StatelessWidget { if (parts.isEmpty) return '?'; return parts[0].characters.take(2).toString().toUpperCase(); } +} - Color _colorFromName(String name) { - // Jen tlumené tóny palety — žádné syté cizí barvy - final colors = [ - SepotColors.ember, - SepotColors.mist, - SepotColors.sage, - SepotColors.warning, - SepotColors.textSecondary, - ]; - return colors[name.hashCode.abs() % colors.length]; +/// Avatar skupiny: zaoblený čtverec s ikonou, ať se na první pohled liší +/// od člověka. +/// Avatar poznámek pro sebe — vlastní tvar i barva, ať se neplete s lidmi. +class NotesAvatar extends StatelessWidget { + const NotesAvatar({super.key, this.size = 48}); + + final double size; + + @override + Widget build(BuildContext context) { + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: SepotColors.ember.withValues(alpha: 0.18), + borderRadius: BorderRadius.circular(size * 0.32), + ), + child: Icon(Icons.bookmark_rounded, + size: size * 0.5, color: SepotColors.ember), + ); + } +} + +class SepotGroupAvatar extends StatelessWidget { + const SepotGroupAvatar({super.key, required this.seed, this.size = 48}); + + final String seed; + final double size; + + @override + Widget build(BuildContext context) { + final tint = SepotTint.of(seed); + return Container( + width: size, + height: size, + decoration: BoxDecoration( + color: tint.background, + borderRadius: BorderRadius.circular(size * 0.32), + ), + child: Icon(Icons.groups_rounded, size: size * 0.5, color: tint.ink), + ); } } diff --git a/app/lib/widgets/glass.dart b/app/lib/widgets/glass.dart new file mode 100644 index 0000000..c5485c5 --- /dev/null +++ b/app/lib/widgets/glass.dart @@ -0,0 +1,83 @@ +import 'dart:ui'; + +import 'package:flutter/material.dart'; +import 'package:sepot/core/theme/app_theme.dart'; + +/// Průsvitná plocha s rozmazáním obsahu pod sebou (hlavička chatu, pole pro +/// psaní, spodní navigace). Blur je drahý, proto jen na pár plovoucích +/// lištách, nikdy na položkách seznamu. +class GlassSurface extends StatelessWidget { + const GlassSurface({ + super.key, + required this.child, + this.borderRadius = BorderRadius.zero, + this.padding = EdgeInsets.zero, + this.border = true, + }); + + final Widget child; + final BorderRadius borderRadius; + final EdgeInsets padding; + final bool border; + + @override + Widget build(BuildContext context) { + return ClipRRect( + borderRadius: borderRadius, + child: BackdropFilter( + filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18), + child: Container( + padding: padding, + decoration: BoxDecoration( + color: SepotColors.glass, + borderRadius: borderRadius, + border: border + ? Border.all(color: SepotColors.glassBorder) + : null, + ), + child: child, + ), + ), + ); + } +} + +/// Tečková tapeta pod konverzací — dává průsvitným lištám co rozmazat +/// a chatu hloubku, aniž by rušila text. +class ChatWallpaper extends StatelessWidget { + const ChatWallpaper({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: const _DotsPainter(), + isComplex: true, + willChange: false, + child: child, + ); + } +} + +class _DotsPainter extends CustomPainter { + const _DotsPainter(); + + static const _spacing = 18.0; + static const _radius = 1.0; + + @override + void paint(Canvas canvas, Size size) { + canvas.drawRect( + Offset.zero & size, Paint()..color = SepotColors.background); + final dot = Paint()..color = SepotColors.mist.withValues(alpha: 0.11); + for (var y = _spacing / 2; y < size.height; y += _spacing) { + for (var x = _spacing / 2; x < size.width; x += _spacing) { + canvas.drawCircle(Offset(x, y), _radius, dot); + } + } + } + + @override + bool shouldRepaint(covariant _DotsPainter oldDelegate) => false; +} diff --git a/app/lib/widgets/message_status_icon.dart b/app/lib/widgets/message_status_icon.dart new file mode 100644 index 0000000..1249f0f --- /dev/null +++ b/app/lib/widgets/message_status_icon.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:sepot/core/theme/app_theme.dart'; +import 'package:sepot/models/message.dart'; + +/// Stav mé zprávy: ⏱ odesílá se · ✓ u relaye · ✓✓ doručeno · pilulka +/// přečteno · ✕ selhalo. +/// +/// „Přečteno“ se liší TVAREM (plná pilulka), ne jen barvou — dřívější +/// modrošedé ✓✓ mělo na korálové bublině kontrast ~1,2 : 1 a nešlo ho +/// odlišit od „doručeno“. +class MessageStatusIcon extends StatelessWidget { + const MessageStatusIcon({ + super.key, + required this.status, + this.onEmber = false, + }); + + final MessageStatus status; + + /// Vykresluje se na korálové bublině (jinak na tmavém pozadí). + final bool onEmber; + + @override + Widget build(BuildContext context) { + final soft = onEmber + ? SepotColors.emberInk.withValues(alpha: 0.6) + : SepotColors.textSecondary; + return switch (status) { + MessageStatus.sending => Icon(Icons.schedule_rounded, size: 14, color: soft), + MessageStatus.sent => Icon(Icons.done_rounded, size: 15, color: soft), + MessageStatus.delivered => + Icon(Icons.done_all_rounded, size: 15, color: soft), + MessageStatus.read => Container( + width: 21, + height: 14, + decoration: BoxDecoration( + color: onEmber ? SepotColors.emberInk : SepotColors.ember, + borderRadius: BorderRadius.circular(7), + ), + child: Icon( + Icons.done_all_rounded, + size: 11, + color: onEmber ? SepotColors.ember : SepotColors.emberInk, + ), + ), + MessageStatus.failed => + const Icon(Icons.error_outline_rounded, size: 15, color: SepotColors.error), + }; + } +} diff --git a/app/test/avatar_test.dart b/app/test/avatar_test.dart new file mode 100644 index 0000000..3c311f7 --- /dev/null +++ b/app/test/avatar_test.dart @@ -0,0 +1,65 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:image/image.dart' as img; +import 'package:sepot/core/services/avatar_image.dart'; +import 'package:sepot/models/avatar_ref.dart'; +import 'package:sepot/models/contact.dart'; +import 'package:sepot/models/identity.dart'; + +/// Odkaz na fotku přichází od protistrany v profilu — nesmí projít nic, +/// co by šlo zneužít (cizí cesta k blobu, klíč špatné délky). +void main() { + final ref = AvatarRef( + blobId: 'a' * 32, + key: Uint8List(32), + nonce: Uint8List(12), + digest: Uint8List(32), + ); + + test('drát tam a zpět', () { + final back = AvatarRef.fromWire(ref.toWire())!; + expect(back.blobId, ref.blobId); + expect(back.key, ref.key); + }); + + test('podvržený odkaz = null', () { + expect(AvatarRef.fromWire(null), isNull); + expect(AvatarRef.fromWire({...ref.toWire(), 'b': '../../etc/passwd'}), + isNull); + expect(AvatarRef.fromWire({...ref.toWire(), 'k': 'AAAA'}), isNull); + expect(AvatarRef.fromWire({...ref.toWire(), 'd': 'nebase64!'}), isNull); + }); + + test('kontakt si fotku pamatuje přes uložení', () { + final contact = Contact( + displayName: 'Alice', + publicIdentity: PublicIdentity( + displayName: 'Alice', + identityKey: Uint8List.fromList(List.filled(33, 5)), + createdAt: DateTime(2026, 9, 1), + ), + addedAt: DateTime(2026, 9, 1), + avatar: ref, + ); + final back = Contact.fromJson(contact.toJson()); + expect(back.avatar?.blobId, ref.blobId); + }); + + test('fotka se ořízne na čtverec, zmenší a ztratí EXIF', () async { + final photo = img.Image(width: 800, height: 400) + ..exif.imageIfd['Make'] = 'TestPhone'; + final jpeg = Uint8List.fromList(img.encodeJpg(photo)); + expect(img.decodeJpgExif(jpeg)?.imageIfd['Make'], isNotNull); + + final out = await prepareAvatarJpeg(jpeg); + final decoded = img.decodeJpg(out!)!; + expect(decoded.width, avatarSide); + expect(decoded.height, avatarSide); + expect(img.decodeJpgExif(out)?.imageIfd['Make'], isNull); + }); + + test('nečitelný obrázek = null', () async { + expect(await prepareAvatarJpeg(Uint8List.fromList([1, 2, 3])), isNull); + }); +} diff --git a/app/test/groups_admin_flow_test.dart b/app/test/groups_admin_flow_test.dart new file mode 100644 index 0000000..b4e2039 --- /dev/null +++ b/app/test/groups_admin_flow_test.dart @@ -0,0 +1,412 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/core/crypto/sepot_crypto.dart'; +import 'package:sepot/core/errors.dart'; +import 'package:sepot/core/services/messaging_service.dart'; +import 'package:sepot/core/storage/crypto_db.dart'; +import 'package:sepot/core/storage/secure_storage.dart'; +import 'package:sepot/models/contact.dart'; +import 'package:sepot/models/group.dart'; +import 'package:sepot/models/identity.dart'; + +/// Správci skupin end-to-end přes MessagingService: skutečné XEdDSA +/// podpisy, skutečné úložiště, relay nahrazený přímým předáním obsahu mezi +/// „telefony" (debugSendTap). Pokrývá tvorbu a příjem `gop`, legacy stín +/// pro v1 klienty, kompatibilní režim a resync po mezeře / rozchodu. +/// +/// Potřebuje nativní libsignal i sqlcipher (jako groups_security_test). +void main() { + final rng = Random.secure(); + final tmpDirs = []; + final dbs = []; + + /// Odeslaný obsah čekající na doručení (kdo, komu, co). + final outbox = <({String from, String to, Map content})>[]; + + setUpAll(() async => SepotCrypto.init()); + + tearDown(() { + outbox.clear(); + for (final db in dbs) { + db.close(); + } + for (final d in tmpDirs) { + d.deleteSync(recursive: true); + } + dbs.clear(); + tmpDirs.clear(); + }); + + + Future<_Phone> phone(String name) async { + final tmp = Directory.systemTemp.createTempSync('sepot_admini'); + tmpDirs.add(tmp); + final dek = Uint8List.fromList(List.generate(32, (_) => rng.nextInt(256))); + final db = CryptoDb.open(path: '${tmp.path}/app.db', dek: dek); + dbs.add(db); + final storage = SecureStorageService(dbPath: '${tmp.path}/app.db') + ..debugAttachDb(db); + final service = MessagingService(storage: storage); + final identity = await LocalIdentity.create(name); + service.debugSetIdentity(identity); + final p = _Phone(name, identity, storage, service); + service.debugSendTap = (to, content) => outbox.add(( + from: p.fp, + to: to, + // Přes JSON jako na drátu — žádné sdílené objekty mezi telefony. + content: jsonDecode(jsonEncode(content)) as Map, + )); + return p; + } + + /// Každý zná každého jako kontakt s routingem (jako po výměně QR). + Future introduce(List<_Phone> phones) async { + for (final p in phones) { + for (final q in phones) { + if (p == q) continue; + await p.storage.saveContact(Contact.fromPublicIdentity( + q.identity.publicIdentity.withRouting( + accountId: 'a' * 32, + deliveryAddress: Uint8List(32), + ), + )); + } + } + } + + /// Doručí frontu (FIFO) příjemcům ze seznamu; ostatní zahodí. + Future deliver(List<_Phone> phones, + {bool Function(Map)? where}) async { + final byFp = {for (final p in phones) p.fp: p}; + final batch = [...outbox]; + outbox.clear(); + for (final m in batch) { + if (where != null && !where(m.content)) { + outbox.add(m); + continue; + } + await byFp[m.to]?.service.debugHandleGroupControl(m.from, m.content); + } + } + + List> sentTo(_Phone p, String type) => [ + for (final m in outbox) + if (m.to == p.fp && m.content['t'] == type) m.content, + ]; + + test('založení: v2 člen zná správce hned, v1 dostane obyčejný ginv', + () async { + final a = await phone('Alice'); + final b = await phone('Bob'); + await introduce([a, b]); + final contactB = (await a.storage.loadContacts()).single; + final g = await a.service.createGroup('Chata', [contactB]); + + final ginv = sentTo(b, 'ginv').single; + // Tvar, který zpracuje v1 klient: v:1, gid, gname, roster se mnou i s ním. + expect(ginv['v'], 1); + expect(ginv['gname'], 'Chata'); + expect((ginv['roster'] as List).map((e) => e['f']), + containsAll([a.fp, b.fp])); + expect(ginv['gop'], isA(), reason: 'vnořený podepsaný stav pro v2'); + expect(ginv['gv'], 2); + + await deliver([a, b]); + final gb = (await b.storage.loadGroup(g.id))!; + expect(gb.admins, [a.fp]); + expect(gb.epoch, 0); + expect(gb.provisional, isFalse, reason: 'stav je podepsaný'); + }); + + group('skupina A (správce), B, C', () { + late _Phone a, b, c; + late String gid; + + setUp(() async { + outbox.clear(); + a = await phone('Alice'); + b = await phone('Bob'); + c = await phone('Cyril'); + await introduce([a, b, c]); + final contacts = await a.storage.loadContacts(); + gid = (await a.service.createGroup('Chata', contacts)).id; + await deliver([a, b, c]); + }); + + Future load(_Phone p) async => (await p.storage.loadGroup(gid))!; + + test('odebrání: B aplikuje, C se dozví, že už není členem', () async { + await a.service.removeGroupMember(await load(a), c.fp); + expect(sentTo(c, 'ginv'), hasLength(1), + reason: 'odebraný dostane výsledný stav taky'); + await deliver([a, b, c]); + + expect((await load(b)).memberIds, isNot(contains(c.fp))); + expect((await load(b)).epoch, 1); + expect((await load(c)).left, isTrue); + }); + + test('povýšení a přejmenování novým správcem projde u ostatních', + () async { + await a.service.setGroupAdmin(await load(a), b.fp, true); + await deliver([a, b, c]); + expect((await load(c)).admins, containsAll([a.fp, b.fp])); + + await b.service.renameGroup(await load(b), 'Chata 2026'); + await deliver([a, b, c]); + expect((await load(a)).name, 'Chata 2026'); + expect((await load(c)).name, 'Chata 2026'); + expect((await load(c)).epoch, 2); + }); + + test('NE-SPRÁVCE: B podepsaný gop se nepostaví a podvržený se odmítne', + () async { + await expectLater(b.service.renameGroup(await load(b), 'Únos'), + throwsA(isA())); + + // B si vyrobí operaci s podpisem sebe sama (upravený klient). + await a.service.setGroupAdmin(await load(a), b.fp, true); + final gop = Map.from( + sentTo(c, 'ginv').single['gop'] as Map); + outbox.clear(); + // Stejné pole, ale autor B, který v C správce není → notAdmin. + await c.service.debugHandleGroupControl(b.fp, { + 'v': 1, + 't': 'gop', + ...gop, + 'by': b.fp, + }); + final gc = await load(c); + expect(gc.admins, [a.fp]); + expect(gc.rejectedOpAt, isNotNull); + }); + + test('SPOOF: operaci správce A přeposlanou od B C odmítne', () async { + await a.service.renameGroup(await load(a), 'Nové jméno'); + final gmeta = sentTo(c, 'gmeta').single; + outbox.clear(); + await c.service.debugHandleGroupControl(b.fp, gmeta); + final gc = await load(c); + expect(gc.epoch, 0); + expect(gc.rejectedOpAt, isNotNull); + }); + + test('ŠPATNÝ PODPIS: pozměněné jméno se stejným podpisem neprojde', + () async { + await a.service.renameGroup(await load(a), 'Poctivé'); + final gmeta = sentTo(c, 'gmeta').single; + outbox.clear(); + (gmeta['gop'] as Map)['gname'] = 'Podvržené'; + await c.service.debugHandleGroupControl(a.fp, gmeta); + final gc = await load(c); + expect(gc.epoch, 0); + expect(gc.rejectedOpAt, isNotNull); + expect(gc.name, isNot('Podvržené')); + }); + + test('MEZERA: epocha 2 před 1 se podrží, vyžádá stav a po 1 aplikuje', + () async { + await a.service.renameGroup(await load(a), 'Jedna'); + final e1 = sentTo(c, 'gmeta').single; + await a.service.renameGroup(await load(a), 'Dvě'); + final e2 = sentTo(c, 'gmeta').last; + outbox.clear(); + + await c.service.debugHandleGroupControl(a.fp, e2); + var gc = await load(c); + expect(gc.epoch, 0); + expect(gc.needsResync, isTrue); + expect(gc.pendingOps, hasLength(1)); + expect(sentTo(a, 'gsync'), hasLength(1), + reason: 'žádost o stav jde správci'); + + await c.service.debugHandleGroupControl(a.fp, e1); + gc = await load(c); + expect(gc.epoch, 2); + expect(gc.name, 'Dvě'); + expect(gc.needsResync, isFalse); + expect(gc.pendingOps, isEmpty); + }); + + test('ZASTARALÁ: opakované doručení téže operace nic nezmění', () async { + await a.service.renameGroup(await load(a), 'Jednou'); + final e1 = sentTo(c, 'gmeta').single; + await deliver([a, b, c]); + final before = jsonEncode((await load(c)).toJson()); + await c.service.debugHandleGroupControl(a.fp, e1); + expect(jsonEncode((await load(c)).toJson()), before); + }); + + test('FORK přes prev: resync stavem od správce a konec bez smyčky', + () async { + // C má kvůli rozdílné historii jiný stav na stejné epoše. + final gc0 = await load(c)..name = 'Rozešlá historie'; + await c.storage.saveGroup(gc0); + + await a.service.renameGroup(await load(a), 'Pravda'); + await deliver([c]); + var gc = await load(c); + expect(gc.needsResync, isTrue); + expect(gc.name, 'Rozešlá historie'); + + // gsync → A odpoví podepsaným stavem → C ho přijme (epocha 1 > 0). + await deliver([a, c]); + await deliver([a, c]); + gc = await load(c); + expect(gc.name, 'Pravda'); + expect(gc.epoch, 1); + expect(gc.needsResync, isFalse); + expect(outbox, isEmpty, reason: 'odpověď už nic dalšího nevyvolá'); + }); + + test('poslední správce se nemůže degradovat; při odchodu předá roli', + () async { + await expectLater(a.service.setGroupAdmin(await load(a), a.fp, false), + throwsA(isA())); + + await a.service.leaveGroup(await load(a)); + await deliver([a, b, c]); + final gb = await load(b); + final naslednik = ([b.fp, c.fp]..sort()).first; + expect(gb.memberIds, isNot(contains(a.fp))); + expect(gb.admins, [naslednik]); + expect((await load(c)).admins, [naslednik]); + expect((await load(a)).left, isTrue); + }); + }); + + group('kompatibilní režim v1 ↔ v2', () { + late _Phone a, b, c, d; + late String gid; + + setUp(() async { + outbox.clear(); + a = await phone('Alice'); + b = await phone('Bob'); + c = await phone('Cyril'); + d = await phone('Dana'); + await introduce([a, b, c, d]); + final contacts = (await a.storage.loadContacts()) + .where((x) => x.id != d.fp) + .toList(); + gid = (await a.service.createGroup('Chata', contacts)).id; + await deliver([a, b, c]); + }); + + /// Legacy `ginv` od B (bez `gop`), který přidává Danu — tak přidává v1. + Map legacyAddFromB() { + Map e(_Phone p) => { + 'f': p.fp, + 'n': p.name, + 'ik': base64Encode(p.identity.publicIdentity.identityKey), + 'aid': 'a' * 32, + 'da': base64Encode(Uint8List(32)), + }; + return { + 'v': 1, + 't': 'ginv', + 'gid': gid, + 'gname': 'Chata', + 'roster': [e(a), e(b), e(c), e(d)], + }; + } + + test('v1 člen ve skupině → ginv od ne-správce projde (jen přidání)', + () async { + // C inzeruje v2 (poslal skupinovou zprávu s gv), B je v1 — nic. + final gc = (await c.storage.loadGroup(gid))!..v2Members.add(a.fp); + await c.storage.saveGroup(gc); + + await c.service.debugHandleGroupControl(b.fp, legacyAddFromB()); + final after = (await c.storage.loadGroup(gid))!; + expect(after.memberIds, contains(d.fp)); + expect(after.admins, [a.fp], reason: 'role se legacy cestou nemění'); + }); + + test('všichni inzerují v2 → ginv od ne-správce neprojde', () async { + final gc = (await c.storage.loadGroup(gid))! + ..v2Members.addAll([a.fp, b.fp]); + await c.storage.saveGroup(gc); + + await c.service.debugHandleGroupControl(b.fp, legacyAddFromB()); + final after = (await c.storage.loadGroup(gid))!; + expect(after.memberIds, isNot(contains(d.fp))); + expect(after.rejectedOpAt, isNotNull); + }); + + test('ve vynucené skupině jde operace samostatně jako t:gop', () async { + final ga = (await a.storage.loadGroup(gid))! + ..v2Members.addAll([b.fp, c.fp]); + await a.storage.saveGroup(ga); + await a.service.renameGroup(ga, 'Jen v2'); + expect(sentTo(c, 'gop'), hasLength(1)); + expect(sentTo(c, 'gmeta'), isEmpty); + await deliver([a, b, c]); + expect((await c.storage.loadGroup(gid))!.name, 'Jen v2'); + }); + + test('ne-správce v kompatibilním režimu přidá legacy cestou, ve ' + 'vynucené skupině nesmí', () async { + final contactD = + (await b.storage.loadContacts()).firstWhere((x) => x.id == d.fp); + var gb = (await b.storage.loadGroup(gid))!; + await b.service.addGroupMembers(gb, [contactD]); + final inv = sentTo(c, 'ginv').single; + expect(inv.containsKey('gop'), isFalse); + + outbox.clear(); + gb = (await b.storage.loadGroup(gid))! + ..memberIds.remove(d.fp) + ..v2Members.addAll([a.fp, c.fp, d.fp]); + await b.storage.saveGroup(gb); + await expectLater(b.service.addGroupMembers(gb, [contactD]), + throwsA(isA())); + }); + + test('capability se učí z gv ve skupinové zprávě a nejde odebrat', + () async { + final ga = (await a.storage.loadGroup(gid))!; + expect(ga.v2Members, isEmpty); + // Legacy gmeta od B s gv:2 (v2 klient) — naučí; bez gv nic neodebere. + await a.service.debugHandleGroupControl( + b.fp, {'v': 1, 't': 'gmeta', 'gid': gid, 'gname': 'X', 'gv': 2}); + await a.service.debugHandleGroupControl( + b.fp, {'v': 1, 't': 'gmeta', 'gid': gid, 'gname': 'Y'}); + expect((await a.storage.loadGroup(gid))!.v2Members, {b.fp}); + }); + }); + + test('migrace uložené v1 skupiny proběhne při načtení bez sítě', () async { + final a = await phone('Alice'); + dbs.last.groupPut( + 'stara', + jsonEncode({ + 'id': 'stara', + 'name': 'Z verze 1.1', + 'memberIds': [a.fp, 'fpB'], + 'createdBy': a.fp, + 'createdAt': '2026-07-10T10:00:00.000', + 'left': false, + }), + ); + final g = (await a.storage.loadGroup('stara'))!; + expect(g.admins, [a.fp]); + expect(g.epoch, 0); + expect(outbox, isEmpty); + }); +} + +class _Phone { + _Phone(this.name, this.identity, this.storage, this.service); + + final String name; + final LocalIdentity identity; + final SecureStorageService storage; + final MessagingService service; + + String get fp => identity.fingerprint.display; +} diff --git a/app/test/mentions_test.dart b/app/test/mentions_test.dart new file mode 100644 index 0000000..70a6f8c --- /dev/null +++ b/app/test/mentions_test.dart @@ -0,0 +1,38 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/models/group.dart'; +import 'package:sepot/models/message.dart'; + +/// Zmínky přicházejí od cizích klientů — vadný tvar nesmí rozbít zprávu +/// a počet je shora omezený. +void main() { + test('platné zmínky projdou', () { + expect(mentionsFromWire(['aa11', 'bb22']), ['aa11', 'bb22']); + }); + + test('ne-seznam a ne-řetězce se zahodí', () { + expect(mentionsFromWire('aa11'), isEmpty); + expect(mentionsFromWire(null), isEmpty); + expect(mentionsFromWire(['aa11', 42, null, {'x': 1}]), ['aa11']); + }); + + test('počet je omezený stropem členů skupiny', () { + final many = List.generate(Group.maxMembers + 50, (i) => 'fp$i'); + expect(mentionsFromWire(many), hasLength(Group.maxMembers)); + }); + + test('zmínky přežijí uložení a načtení zprávy', () { + final m = Message.text( + senderId: 'me', + recipientId: 'gid', + content: 'ahoj @Alice', + mentions: const ['alice-fp'], + ); + expect(Message.fromJson(m.toJson()).mentions, ['alice-fp']); + }); + + test('zpráva bez zmínek je neukládá', () { + final m = Message.text(senderId: 'me', recipientId: 'x', content: 'a'); + expect(m.toJson().containsKey('mentions'), isFalse); + expect(Message.fromJson(m.toJson()).mentions, isEmpty); + }); +} diff --git a/app/test/message_receipts_test.dart b/app/test/message_receipts_test.dart new file mode 100644 index 0000000..5d7b87a --- /dev/null +++ b/app/test/message_receipts_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/core/services/messaging_service.dart'; +import 'package:sepot/models/message.dart'; + +/// Doručenky a potvrzení přečtení nesou čas od příjemce; stav mé zprávy +/// z nich roste, ale nikdy neklesá. +void main() { + final odeslano = DateTime(2026, 9, 28, 14, 0); + + Message moje({MessageStatus status = MessageStatus.sent}) => Message( + id: 'm1', + senderId: 'ja', + recipientId: 'bob', + type: MessageType.text, + content: 'ahoj', + timestamp: odeslano, + status: status, + ); + + Message receipt(Message m, String from, int minut, + {bool read = false, int recipients = 1}) => + MessagingService.applyReceipt( + m, + from: from, + at: odeslano.add(Duration(minutes: minut)), + read: read, + recipients: recipients, + ); + + test('doručenka zapíše čas a povýší na doručeno', () { + final m = receipt(moje(), 'bob', 1); + expect(m.status, MessageStatus.delivered); + expect(m.deliveredTo['bob'], odeslano.add(const Duration(minutes: 1))); + expect(m.firstReadAt, isNull); + }); + + test('přečtení zároveň znamená doručení', () { + final m = receipt(moje(), 'bob', 5, read: true); + expect(m.status, MessageStatus.read); + expect(m.firstDeliveredAt, m.firstReadAt); + }); + + test('pozdní doručenka po přečtení stav nesníží', () { + // Doručenka poslaná znovu po výpadku spojení dorazí až po „přečteno". + var m = receipt(moje(), 'bob', 5, read: true); + m = receipt(m, 'bob', 9); + expect(m.status, MessageStatus.read); + }); + + test('opakované potvrzení nepřepíše první čas', () { + var m = receipt(moje(), 'bob', 2); + m = receipt(m, 'bob', 30); + expect(m.deliveredTo['bob'], odeslano.add(const Duration(minutes: 2))); + }); + + test('doručenka zachrání zprávu označenou jako neodeslaná', () { + // Timeout před ACK relaye → failed, přitom zpráva dorazila. + final m = receipt(moje(status: MessageStatus.failed), 'bob', 1); + expect(m.status, MessageStatus.delivered); + }); + + test('opožděné hodiny příjemce nepřečtou zprávu před odesláním', () { + final m = receipt(moje(), 'bob', -10, read: true); + expect(m.firstReadAt, odeslano); + }); + + test('skupina: ✓✓ a přečteno až za všechny ostatní členy', () { + var m = receipt(moje(), 'bob', 1, recipients: 2); + expect(m.status, MessageStatus.sent); + m = receipt(m, 'cyril', 2, recipients: 2); + expect(m.status, MessageStatus.delivered); + m = receipt(m, 'bob', 3, read: true, recipients: 2); + expect(m.status, MessageStatus.delivered); + m = receipt(m, 'cyril', 4, read: true, recipients: 2); + expect(m.status, MessageStatus.read); + expect(m.firstReadAt, odeslano.add(const Duration(minutes: 3))); + }); + + test('časy přežijí uložení a načtení', () { + final m = receipt(receipt(moje(), 'bob', 1), 'bob', 4, read: true); + final zpet = Message.fromJson(m.toJson()); + expect(zpet.deliveredTo, m.deliveredTo); + expect(zpet.readBy, m.readBy); + }); + + test('starý záznam bez časů i vadné položky se načtou', () { + final json = moje().toJson() + ..remove('deliveredTo') + ..['readBy'] = {'bob': 'včera', 'cyril': 1790000000000}; + final m = Message.fromJson(json); + expect(m.deliveredTo, isEmpty); + expect(m.readBy.keys, ['cyril']); + }); +} diff --git a/app/test/poll_test.dart b/app/test/poll_test.dart new file mode 100644 index 0000000..b841494 --- /dev/null +++ b/app/test/poll_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/models/message.dart'; +import 'package:sepot/models/poll.dart'; + +/// Anketa přichází od protistrany — vadná data nesmí nic rozbít a hlas +/// nesmí jít mimo nabídnuté možnosti. +void main() { + const poll = Poll( + question: 'Kdy vyrazíme?', + options: ['Pátek', 'Sobota', 'Neděle'], + ); + + test('drát tam a zpět zachová otázku i možnosti', () { + final back = Poll.fromWire(poll.toWire())!; + expect(back.question, poll.question); + expect(back.options, poll.options); + expect(back.multi, isFalse); + }); + + test('vadná anketa z drátu = null (ukáže se záložní text)', () { + expect(Poll.fromWire(null), isNull); + expect(Poll.fromWire({'q': 'Jen jedna?', 'o': ['a']}), isNull); + expect(Poll.fromWire({'q': '', 'o': ['a', 'b']}), isNull); + expect(Poll.fromWire({'q': 'x', 'o': ['a', 42]}), isNull); + expect( + Poll.fromWire({'q': 'x', 'o': List.filled(Poll.maxOptions + 1, 'a')}), + isNull); + expect( + Poll.fromWire({'q': 'x' * (Poll.maxQuestionLength + 1), 'o': ['a', 'b']}), + isNull); + }); + + test('hlas mimo nabídku se zahodí', () { + final p = poll.withVote('bob', [7, -1]); + expect(p.votes, isEmpty); + }); + + test('jedna volba: platí jen první index', () { + final p = poll.withVote('bob', [2, 0]); + expect(p.votes['bob'], [0]); + }); + + test('více voleb: duplicity se sloučí', () { + const multi = Poll(question: 'Co vezmeme?', options: ['a', 'b', 'c'], multi: true); + final p = multi.withVote('bob', [2, 0, 2]); + expect(p.votes['bob'], [0, 2]); + expect(p.countFor(2), 1); + }); + + test('nový hlas přepíše starý, prázdný ho zruší', () { + var p = poll.withVote('bob', [0]).withVote('cyril', [0]); + expect(p.countFor(0), 2); + p = p.withVote('bob', [1]); + expect(p.countFor(0), 1); + expect(p.countFor(1), 1); + p = p.withVote('bob', const []); + expect(p.voterCount, 1); + }); + + test('hlasy přežijí uložení zprávy', () { + final msg = Message.text( + senderId: 'ja', + recipientId: 'chata', + content: 'Anketa: Kdy vyrazíme?', + poll: poll.withVote('bob', [1]), + ); + final back = Message.fromJson(msg.toJson()); + expect(back.poll?.question, poll.question); + expect(back.poll?.votes['bob'], [1]); + }); +} diff --git a/app/test/typing_tracker_test.dart b/app/test/typing_tracker_test.dart new file mode 100644 index 0000000..0759a51 --- /dev/null +++ b/app/test/typing_tracker_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/core/services/typing_tracker.dart'; + +/// „píše…“ nemá zprávu „dopsal jsem“ — indikátor musí sám vyhasnout, +/// jinak by po ztraceném spojení svítil navždy. +void main() { + late DateTime now; + late TypingTracker tracker; + + setUp(() { + now = DateTime(2026, 9, 29, 12); + tracker = TypingTracker(clock: () => now); + }); + + tearDown(() => tracker.dispose()); + + test('pisatel se ukáže v dané konverzaci', () { + tracker.mark('chat-a', 'alice'); + expect(tracker.current, { + 'chat-a': {'alice'}, + }); + }); + + test('bez dalšího „píšu“ indikátor vyprší', () { + tracker.mark('chat-a', 'alice'); + now = now.add(TypingTracker.showFor + const Duration(milliseconds: 1)); + expect(tracker.current, isEmpty); + tracker.prune(); + expect(tracker.current, isEmpty); + }); + + test('opakované „píšu“ prodlužuje', () { + tracker.mark('chat-a', 'alice'); + now = now.add(const Duration(seconds: 4)); + tracker.mark('chat-a', 'alice'); + now = now.add(const Duration(seconds: 4)); + expect(tracker.current['chat-a'], {'alice'}); + }); + + test('příchozí zpráva indikátor hned zhasne', () { + tracker + ..mark('skupina', 'alice') + ..mark('skupina', 'tomas') + ..clear('skupina', 'alice'); + expect(tracker.current, { + 'skupina': {'tomas'}, + }); + }); + + test('změny se hlásí posluchačům', () async { + final events = >>[]; + final sub = tracker.changes.listen(events.add); + tracker + ..mark('chat-a', 'alice') + ..clear('chat-a', 'alice'); + await Future.delayed(Duration.zero); + await sub.cancel(); + expect(events, [ + { + 'chat-a': {'alice'}, + }, + >{}, + ]); + }); +} diff --git a/app/test/widget/group_profile_admin_test.dart b/app/test/widget/group_profile_admin_test.dart new file mode 100644 index 0000000..d70709b --- /dev/null +++ b/app/test/widget/group_profile_admin_test.dart @@ -0,0 +1,121 @@ +import 'dart:typed_data'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sepot/features/profile/group_profile_screen.dart'; +import 'package:sepot/features/profile/profile_widgets.dart'; +import 'package:sepot/l10n/generated/app_localizations.dart'; +import 'package:sepot/models/contact.dart'; +import 'package:sepot/models/group.dart'; +import 'package:sepot/models/identity.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'; + +/// Profil skupiny podle role: ne-správce nesmí vidět správcovské akce +/// (odebrat, udělat správcem), jen vysvětlení; správce je dostane v nabídce +/// člena. +class _FakeIdentityNotifier extends IdentityNotifier { + _FakeIdentityNotifier(this._identity); + final LocalIdentity _identity; + + @override + Future build() async => _identity; +} + +class _NoContacts extends ContactsNotifier { + @override + Future> build() async => const []; +} + +final _ja = LocalIdentity( + displayName: 'Já', + identityPrivateKey: Uint8List(32), + identityPublicKey: Uint8List(33), + createdAt: DateTime.utc(2026, 1, 1), +); + +const _spravce = 'SPRAVCE0001'; +const _clen = 'CLENKA0002'; + +Group _group({required bool jaSpravce, required bool vsichniV2}) => Group( + id: 'g1', + name: 'Chata v srpnu', + memberIds: [_ja.id, _spravce, _clen], + createdBy: _spravce, + admins: [_spravce, if (jaSpravce) _ja.id], + v2Members: vsichniV2 ? {_spravce, _clen} : {_spravce}, + ); + +Widget _app(Group group) => ProviderScope( + overrides: [ + identityProvider.overrideWith(() => _FakeIdentityNotifier(_ja)), + contactsProvider.overrideWith(_NoContacts.new), + groupProvider.overrideWith((ref, id) async => group), + conversationProvider.overrideWith((ref, id) async => null), + ], + child: MaterialApp( + locale: const Locale('cs'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: GroupProfileScreen(groupId: group.id), + ), + ); + +Future _pump(WidgetTester tester, Group group) async { + tester.view.physicalSize = const Size(1080, 3200); + tester.view.devicePixelRatio = 2.0; + addTearDown(tester.view.reset); + await tester.pumpWidget(_app(group)); + await tester.pumpAndSettle(); +} + +ProfileAction _addAction(WidgetTester tester) => tester.widget( + find.widgetWithText(ProfileAction, 'Přidat')); + +void main() { + testWidgets('ne-správce ve vynucené skupině: akce skryté, vysvětlení', + (tester) async { + await _pump(tester, _group(jaSpravce: false, vsichniV2: true)); + expect(tester.takeException(), isNull); + + expect(find.text('Správce'), findsOneWidget, + reason: 'odznak u skutečného správce'); + expect(find.text('Jméno, členy a správce skupiny mění jen správci.'), + findsOneWidget); + expect(_addAction(tester).onTap, isNull, + reason: 'přidávat lidi smí jen správce'); + expect(find.byIcon(Icons.edit_outlined), findsNothing, + reason: 'přejmenování jen správcem'); + + await tester.tap(find.textContaining('SEP-CLENKA')); + await tester.pumpAndSettle(); + expect(find.text('Odebrat ze skupiny'), findsNothing); + expect(find.text('Udělat správcem'), findsNothing); + }); + + testWidgets('ne-správce s v1 členem ve skupině smí přidávat, ne spravovat', + (tester) async { + await _pump(tester, _group(jaSpravce: false, vsichniV2: false)); + expect(_addAction(tester).onTap, isNotNull); + expect(find.textContaining('Přidávat lidi zatím může každý'), + findsOneWidget); + expect(find.byIcon(Icons.edit_outlined), findsNothing); + }); + + testWidgets('správce dostane nabídku člena s odebráním a rolí', + (tester) async { + await _pump(tester, _group(jaSpravce: true, vsichniV2: true)); + expect(find.text('Správce'), findsNWidgets(2)); + expect(_addAction(tester).onTap, isNotNull); + expect(find.byIcon(Icons.edit_outlined), findsOneWidget); + expect(find.textContaining('mění jen správci'), findsNothing); + + await tester.tap(find.textContaining('SEP-CLENKA')); + await tester.pumpAndSettle(); + expect(find.text('Odebrat ze skupiny'), findsOneWidget); + expect(find.text('Udělat správcem'), findsOneWidget); + }); +}