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:
@@ -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<void>(
|
||||
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<AudioAttachmentContent> {
|
||||
/// 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 = <StreamSubscription<dynamic>>[];
|
||||
bool _loading = false;
|
||||
bool _playing = false;
|
||||
@@ -232,6 +240,7 @@ class _AudioState extends ConsumerState<AudioAttachmentContent> {
|
||||
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<AudioAttachmentContent> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _loadRate() async {
|
||||
final saved =
|
||||
await ref.read(storageProvider).getSetting<num>(rateSettingKey);
|
||||
if (mounted && saved != null && _rates.contains(saved.toDouble())) {
|
||||
setState(() => _rate = saved.toDouble());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _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<AudioAttachmentContent> {
|
||||
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<AudioAttachmentContent> {
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user