feat(app): redesigned interface, polls, drafts, forwarding and mentions

A new look across the app and a set of everyday messaging features.

Interface
- Home screen with Chats, Contacts and Settings tabs, a large title,
  search across chats and message text (diacritics-insensitive), filters
  and pinned chats.
- Conversation view with a translucent header and composer, grouped
  bubbles, author avatars and colours in groups, swipe to reply, an
  "N new messages" marker, a jump-to-latest button and in-chat search.
- Contact and group profiles: verification, shared photos, groups in
  common, disappearing messages, mute, block; members, admin badges,
  adding people, rename and leave for groups.
- Full-screen photo viewer that pages through the whole conversation.
- A stable avatar colour per person and a clearly visible read state.

Messaging
- Polls in chats and groups, single or multiple choice, with votes that
  can be changed or withdrawn; older clients see them as text.
- Drafts survive leaving a chat and show in the chat list.
- Forwarding of messages, photos, files and voice messages.
- Notes to self: a local-only, encrypted chat that never reaches the
  relay.
- @mentions in groups that notify even when the group is muted.
- Typing indicators sent as ephemeral messages, opt-in and mutual like
  read receipts.
- Delivery and read times per message and per group member.
- Profile photos, cropped, resized and stripped of metadata, delivered
  encrypted to contacts.
- Voice message playback at 1.5x and 2x.
- Call buttons in 1:1 chats and call log entries.

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
This commit is contained in:
Julius Joska
2026-10-03 22:44:53 +02:00
parent 933a74c900
commit 5246a97db2
56 changed files with 12028 additions and 1668 deletions
+65
View File
@@ -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);
});
}
+412
View File
@@ -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 = <Directory>[];
final dbs = <CryptoDb>[];
/// Odeslaný obsah čekající na doručení (kdo, komu, co).
final outbox = <({String from, String to, Map<String, dynamic> 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<String, dynamic>,
));
return p;
}
/// Každý zná každého jako kontakt s routingem (jako po výměně QR).
Future<void> 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<void> deliver(List<_Phone> phones,
{bool Function(Map<String, dynamic>)? 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<Map<String, dynamic>> 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<Map>(), 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<Group> 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<NotGroupAdminError>()));
// B si vyrobí operaci s podpisem sebe sama (upravený klient).
await a.service.setGroupAdmin(await load(a), b.fp, true);
final gop = Map<String, dynamic>.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<LastGroupAdminError>()));
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<String, dynamic> legacyAddFromB() {
Map<String, dynamic> 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<NotGroupAdminError>()));
});
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;
}
+38
View File
@@ -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);
});
}
+94
View File
@@ -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']);
});
}
+71
View File
@@ -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]);
});
}
+65
View File
@@ -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 = <Map<String, Set<String>>>[];
final sub = tracker.changes.listen(events.add);
tracker
..mark('chat-a', 'alice')
..clear('chat-a', 'alice');
await Future<void>.delayed(Duration.zero);
await sub.cancel();
expect(events, [
{
'chat-a': {'alice'},
},
<String, Set<String>>{},
]);
});
}
@@ -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<LocalIdentity?> build() async => _identity;
}
class _NoContacts extends ContactsNotifier {
@override
Future<List<Contact>> 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<void> _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<ProfileAction>(
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);
});
}