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

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

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

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

Fixes
- Muted groups no longer notify: muting was checked against the
  author's chat instead of the group.
- A blocked contact could unblock itself by changing its delivery
  address.
- Read receipts no longer regress to delivered when a late delivery
  receipt arrives.
- A message marked as failed corrects itself once it is delivered.
- Double ticks in groups now mean delivered to every member.
This commit is contained in:
Julius Joska
2026-10-03 22:44:53 +02:00
parent 933a74c900
commit 5246a97db2
56 changed files with 12028 additions and 1668 deletions
@@ -0,0 +1,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);
});
}