Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d431501b74 | |||
| 6f9b08e844 | |||
| 09ef54ef69 | |||
| bfea7201f7 | |||
| 05eeccd83b |
@@ -6,6 +6,21 @@ verzování dle [ajtak standardu](#verzování).
|
||||
|
||||
## [Nevydáno]
|
||||
|
||||
## [1.2.2] – 2026-10-11
|
||||
|
||||
### Opraveno
|
||||
- **Přečtený chat se dál ukazoval jako nepřečtený.** Zpráva, která přišla do
|
||||
otevřeného chatu, zůstala po návratu do seznamu tučně s odznakem; teď se
|
||||
přečte hned, když je chat na obrazovce (a appka v popředí). Stažení přílohy
|
||||
nebo dodatečné doručení už nevrátí přečtenou zprávu mezi nepřečtené.
|
||||
- Gesto „odpověď tahem“ při zavírání chatu v ladicím buildu padalo na
|
||||
vytvoření animace nad zrušeným prvkem.
|
||||
- Na iPhonu hlásila dlaždice Push v Nastavení chybu (volala UnifiedPush,
|
||||
který existuje jen na Androidu).
|
||||
|
||||
### Změněno
|
||||
- **Nová ikona aplikace:** Š, které se ve spodní části rozpadá do teček.
|
||||
|
||||
## [1.2.1] – 2026-10-09
|
||||
|
||||
### Opraveno
|
||||
|
||||
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 8.2 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 38 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 9.0 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 33 KiB After Width: | Height: | Size: 7.6 KiB |
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#0a0e17</color>
|
||||
<color name="ic_launcher_background">#0B0C12</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 575 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 153 KiB After Width: | Height: | Size: 27 KiB |
@@ -1,9 +1,9 @@
|
||||
# Launcher ikony — kandidát 1 „Š s tečkami" (nokturno-ikona-B2, ember paleta).
|
||||
# Zdroj: sepot-web/koncept/logo/nokturno-ikona-B2.png; regenerace:
|
||||
# Launcher icons: the "Š that fades into dots" mark (variant B, 2026-10).
|
||||
# Source: assets/icon/icon-1024.png (full) and maskable-fg-512.png (adaptive foreground); regenerate with:
|
||||
# dart run flutter_launcher_icons
|
||||
flutter_launcher_icons:
|
||||
android: true
|
||||
ios: true
|
||||
image_path: assets/icon/icon-1024.png
|
||||
adaptive_icon_background: "#0a0e17"
|
||||
adaptive_icon_background: "#0B0C12"
|
||||
adaptive_icon_foreground: assets/icon/maskable-fg-512.png
|
||||
|
||||
|
Before Width: | Height: | Size: 571 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 782 B After Width: | Height: | Size: 560 B |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 4.0 KiB After Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 817 B |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 1.6 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 2.1 KiB After Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 6.5 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 3.7 KiB |
|
Before Width: | Height: | Size: 3.7 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 3.4 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 5.5 KiB After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 5.7 KiB |
|
Before Width: | Height: | Size: 5.9 KiB After Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 5.9 KiB |
|
Before Width: | Height: | Size: 25 KiB After Width: | Height: | Size: 6.7 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
@@ -138,10 +138,14 @@ class PushService {
|
||||
}
|
||||
|
||||
/// Seznam nainstalovaných distributorů (pro výběr v nastavení).
|
||||
Future<List<String>> distributors() => UnifiedPush.getDistributors();
|
||||
/// UnifiedPush existuje jen na Androidu; jinde by volání pluginu vyhodilo
|
||||
/// UnimplementedError (iOS: dlaždice Push v Nastavení).
|
||||
Future<List<String>> distributors() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributors() : const [];
|
||||
|
||||
/// Aktuálně uložený distributor (null = push vypnutý / nevybraný).
|
||||
Future<String?> currentDistributor() => UnifiedPush.getDistributor();
|
||||
Future<String?> currentDistributor() async =>
|
||||
Platform.isAndroid ? UnifiedPush.getDistributor() : null;
|
||||
|
||||
/// Vybere konkrétního distributora a zaregistruje se u něj.
|
||||
Future<void> useDistributor(String distributor) async {
|
||||
@@ -158,7 +162,7 @@ class PushService {
|
||||
}
|
||||
|
||||
Future<bool> get isEnabled async =>
|
||||
(await UnifiedPush.getDistributor()) != null;
|
||||
Platform.isAndroid && (await UnifiedPush.getDistributor()) != null;
|
||||
|
||||
// ── Režimy (off / up / fcm) ─────────────────────────────────────────
|
||||
|
||||
@@ -167,6 +171,7 @@ class PushService {
|
||||
Future<String> currentMode() async {
|
||||
final mode = await PushBootstrap.readMode();
|
||||
if (mode != 'off') return mode;
|
||||
if (!Platform.isAndroid) return mode;
|
||||
return (await UnifiedPush.getDistributor()) != null ? 'up' : 'off';
|
||||
}
|
||||
|
||||
|
||||
@@ -64,7 +64,8 @@ class ConversationScreen extends ConsumerStatefulWidget {
|
||||
ConsumerState<ConversationScreen> createState() => _ConversationScreenState();
|
||||
}
|
||||
|
||||
class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
class _ConversationScreenState extends ConsumerState<ConversationScreen>
|
||||
with WidgetsBindingObserver {
|
||||
final _messageController = TextEditingController();
|
||||
final _scrollController = ScrollController();
|
||||
final _searchController = TextEditingController();
|
||||
@@ -133,9 +134,43 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
_drafts = ref.read(draftsProvider.notifier);
|
||||
_restoreDraft();
|
||||
_messageController.addListener(_scheduleDraftSave);
|
||||
_conversations = ref.read(conversationsProvider.notifier);
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _captureUnreadAndMark());
|
||||
}
|
||||
|
||||
/// Notifier zachycený v initState: v dispose už `ref` použít nejde.
|
||||
late final ConversationsNotifier _conversations;
|
||||
|
||||
/// Zpráva, která přijde do otevřeného chatu, je přečtená, jen když ji
|
||||
/// uživatel opravdu vidí: appka v popředí a tahle obrazovka navrchu
|
||||
/// (ne pod profilem kontaktu nebo náhledem fotky).
|
||||
bool get _isSeenNow =>
|
||||
WidgetsBinding.instance.lifecycleState == AppLifecycleState.resumed &&
|
||||
(_route?.isCurrent ?? false);
|
||||
|
||||
// Route uložená v didChangeDependencies: lifecycle callback může přijít
|
||||
// i ve chvíli, kdy je element už deaktivovaný a hledat předka nesmí.
|
||||
ModalRoute<Object?>? _route;
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_route = ModalRoute.of(context);
|
||||
}
|
||||
|
||||
void _markIfSeen() {
|
||||
if (!mounted || !_isSeenNow) return;
|
||||
_conversations.markAsRead(widget.contactId);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
// Zprávy, které přišly se zhasnutým displejem nad otevřeným chatem,
|
||||
// uživatel uvidí až po návratu do appky.
|
||||
if (state == AppLifecycleState.resumed) _markIfSeen();
|
||||
}
|
||||
|
||||
/// 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<void> _captureUnreadAndMark() async {
|
||||
@@ -185,6 +220,10 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
// Odchod z chatu: všechno, co bylo na obrazovce, je přečtené – i zprávy
|
||||
// dorazivší těsně před odchodem, které listener nestihl označit.
|
||||
_conversations.markAsRead(widget.contactId);
|
||||
_draftTimer?.cancel();
|
||||
// Odchod z chatu uprostřed úpravy = koncept je ten odložený, ne text
|
||||
// upravované zprávy.
|
||||
@@ -214,6 +253,17 @@ class _ConversationScreenState extends ConsumerState<ConversationScreen> {
|
||||
.where((c) => c.publicIdentity.id == widget.contactId)
|
||||
.firstOrNull;
|
||||
final conversationAsync = ref.watch(conversationProvider(widget.contactId));
|
||||
// Nová příchozí zpráva v otevřeném chatu se rovnou čte; jinak by po
|
||||
// návratu do seznamu svítila jako nepřečtená.
|
||||
// První načtení vynechá: to obslouží _captureUnreadAndMark, které si
|
||||
// nejdřív zapamatuje řádek „N nových zpráv“.
|
||||
ref.listen(conversationProvider(widget.contactId), (prev, next) {
|
||||
if (prev?.valueOrNull == null) return;
|
||||
final conv = next.valueOrNull;
|
||||
final myId = ref.read(identityProvider).valueOrNull?.id;
|
||||
if (conv == null || myId == null || conv.unreadCount(myId) == 0) return;
|
||||
_markIfSeen();
|
||||
});
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
// Skupinová konverzace: contactId == gid
|
||||
final group = ref.watch(groupProvider(widget.contactId)).valueOrNull;
|
||||
@@ -2150,7 +2200,11 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
static const _max = 84.0;
|
||||
static const _trigger = 58.0;
|
||||
|
||||
late final AnimationController _back = AnimationController(
|
||||
// Kontroler vzniká až při prvním tahu. Dřív ho `late final` vytvořil
|
||||
// teprve dispose() u bublin, které nikdo netáhl – nad deaktivovaným
|
||||
// elementem, což v debug buildu padá na hledání TickerMode.
|
||||
AnimationController? _backCtl;
|
||||
AnimationController get _back => _backCtl ??= AnimationController(
|
||||
vsync: this, duration: const Duration(milliseconds: 180))
|
||||
..addListener(() => setState(() => _dx = _from * (1 - _back.value)));
|
||||
double _dx = 0;
|
||||
@@ -2159,7 +2213,7 @@ class _SwipeToReplyState extends State<_SwipeToReply>
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_back.dispose();
|
||||
_backCtl?.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@@ -55,9 +55,17 @@ class Conversation {
|
||||
|
||||
/// Nahraď zprávu se stejným id (změna stavu přílohy, doručenka…).
|
||||
/// Neexistující id se tiše ignoruje (zpráva mohla být smazána).
|
||||
///
|
||||
/// Přečtení je jednosměrné: aktualizace nesmí vrátit už přečtenou zprávu
|
||||
/// mezi nepřečtené. Volající často drží kopii zprávy načtenou před
|
||||
/// otevřením chatu (stahování přílohy, dodatečné odeslání) a bez téhle
|
||||
/// pojistky by zápis po návratu ze stahování obnovil odznak nepřečtených.
|
||||
void updateMessage(Message message) {
|
||||
final index = messages.indexWhere((m) => m.id == message.id);
|
||||
if (index >= 0) messages[index] = message;
|
||||
if (index < 0) return;
|
||||
messages[index] = messages[index].isRead && !message.isRead
|
||||
? message.copyWith(isRead: true)
|
||||
: message;
|
||||
}
|
||||
|
||||
/// Označ všechny zprávy jako přečtené
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
name: sepot
|
||||
description: "Šepot — Soukromý messenger s E2E šifrováním"
|
||||
publish_to: 'none'
|
||||
version: 1.2.1+24
|
||||
version: 1.2.2+25
|
||||
|
||||
environment:
|
||||
sdk: ^3.10.8
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Read state only moves forward. Updates that carry an older copy of the
|
||||
/// message (attachment download finishing, delivery status) must not flip a
|
||||
/// message the user has already read back to unread.
|
||||
void main() {
|
||||
Message zprava({bool isRead = false}) => Message(
|
||||
id: 'm1',
|
||||
senderId: 'alice',
|
||||
recipientId: 'bob',
|
||||
type: MessageType.text,
|
||||
content: 'hi',
|
||||
timestamp: DateTime.parse('2026-10-10T09:00:00.000'),
|
||||
isRead: isRead,
|
||||
);
|
||||
|
||||
test('stale unread copy does not undo a read message', () {
|
||||
final c = Conversation(contactId: 'alice')..addMessage(zprava());
|
||||
c.markAllRead('bob');
|
||||
|
||||
c.updateMessage(zprava().copyWith(content: 'hi (edited)'));
|
||||
|
||||
expect(c.messages.single.isRead, isTrue);
|
||||
expect(c.messages.single.content, 'hi (edited)');
|
||||
expect(c.unreadCount('bob'), 0);
|
||||
});
|
||||
|
||||
test('update can still mark an unread message as read', () {
|
||||
final c = Conversation(contactId: 'alice')..addMessage(zprava());
|
||||
|
||||
c.updateMessage(zprava(isRead: true));
|
||||
|
||||
expect(c.unreadCount('bob'), 0);
|
||||
});
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// Needs the native libsignal library (FFI); skipped on builders without it (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Needs the native libsignal library (FFI); skipped on builders without it (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Needs the native libsignal library (FFI); skipped on builders without it (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Needs the native libsignal library (FFI); skipped on builders without it (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Needs the native libsignal library (FFI); skipped on builders without it (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
// Argon2id (64 MiB) ×6 pod zátěží celé sady: víc než výchozích 30 s.
|
||||
@Timeout(Duration(minutes: 3))
|
||||
library;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// Needs the native libsignal library (FFI); skipped on builders without it (--exclude-tags ffi).
|
||||
@Tags(['ffi'])
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
import 'dart:typed_data';
|
||||
|
||||
@@ -29,6 +29,9 @@
|
||||
|
||||
<title>sepot</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
|
||||
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
<style id="splash-screen-style">
|
||||
html {
|
||||
height: 100%
|
||||
@@ -100,7 +103,6 @@
|
||||
document.body.style.background = "transparent";
|
||||
}
|
||||
</script>
|
||||
<meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" name="viewport">
|
||||
</head>
|
||||
<body>
|
||||
<picture id="splash">
|
||||
@@ -108,6 +110,7 @@
|
||||
<source srcset="splash/img/dark-1x.png 1x, splash/img/dark-2x.png 2x, splash/img/dark-3x.png 3x, splash/img/dark-4x.png 4x" media="(prefers-color-scheme: dark)">
|
||||
<img class="center" aria-hidden="true" src="splash/img/light-1x.png" alt="">
|
||||
</picture>
|
||||
|
||||
<script src="flutter_bootstrap.js" async=""></script>
|
||||
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 8.5 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 98 KiB After Width: | Height: | Size: 26 KiB |
|
Before Width: | Height: | Size: 158 KiB After Width: | Height: | Size: 32 KiB |