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,148 @@
import 'package:flutter/material.dart';
import 'package:sepot/core/l10n/l10n.dart';
import 'package:sepot/core/theme/app_theme.dart';
import 'package:sepot/models/poll.dart';
/// Vytvoření ankety: otázka, 2–12 možností, jedna nebo víc voleb.
/// Vrací hotovou [Poll], nebo null při zrušení.
Future<Poll?> showCreatePollSheet(BuildContext context) {
return showModalBottomSheet<Poll>(
context: context,
isScrollControlled: true,
backgroundColor: SepotColors.surface,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _CreatePollSheet(),
);
}
class _CreatePollSheet extends StatefulWidget {
const _CreatePollSheet();
@override
State<_CreatePollSheet> createState() => _CreatePollSheetState();
}
class _CreatePollSheetState extends State<_CreatePollSheet> {
final _question = TextEditingController();
final _options = [TextEditingController(), TextEditingController()];
var _multi = false;
@override
void dispose() {
_question.dispose();
for (final c in _options) {
c.dispose();
}
super.dispose();
}
List<String> get _filled => [
for (final c in _options)
if (c.text.trim().isNotEmpty) c.text.trim(),
];
bool get _valid =>
_question.text.trim().isNotEmpty && _filled.length >= Poll.minOptions;
void _addOption() {
if (_options.length >= Poll.maxOptions) return;
setState(() => _options.add(TextEditingController()));
}
void _removeOption(int i) {
if (_options.length <= Poll.minOptions) return;
setState(() => _options.removeAt(i).dispose());
}
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SafeArea(
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.sizeOf(context).height * 0.85),
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(20, 18, 20, 20),
children: [
Row(
children: [
Expanded(
child: Text(l10n.pollCreateTitle,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.w700)),
),
FilledButton(
onPressed: _valid
? () => Navigator.of(context).pop(Poll(
question: _question.text.trim(),
options: _filled,
multi: _multi,
))
: null,
child: Text(l10n.chatSend),
),
],
),
const SizedBox(height: 16),
TextField(
controller: _question,
autofocus: true,
maxLength: Poll.maxQuestionLength,
textCapitalization: TextCapitalization.sentences,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
labelText: l10n.pollQuestion,
counterText: '',
),
),
const SizedBox(height: 14),
for (var i = 0; i < _options.length; i++)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TextField(
controller: _options[i],
maxLength: Poll.maxOptionLength,
textCapitalization: TextCapitalization.sentences,
onChanged: (_) => setState(() {}),
decoration: InputDecoration(
hintText: l10n.pollOptionHint(i + 1),
counterText: '',
suffixIcon: _options.length > Poll.minOptions
? IconButton(
tooltip: l10n.pollRemoveOption,
icon: const Icon(Icons.close_rounded, size: 18),
onPressed: () => _removeOption(i),
)
: null,
),
),
),
if (_options.length < Poll.maxOptions)
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: _addOption,
icon: const Icon(Icons.add_rounded),
label: Text(l10n.pollAddOption),
),
),
const SizedBox(height: 6),
SwitchListTile(
contentPadding: EdgeInsets.zero,
value: _multi,
onChanged: (v) => setState(() => _multi = v),
activeTrackColor: SepotColors.ember,
title: Text(l10n.pollAllowMultiple),
),
],
),
),
),
);
}
}