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)
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/poll.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Anketa v bublině: otázka, možnosti s pruhem výsledků a kolečky
|
||||
/// hlasujících. Klepnutí na možnost hlasuje (znovu = zrušit).
|
||||
class PollContent extends StatelessWidget {
|
||||
const PollContent({
|
||||
super.key,
|
||||
required this.poll,
|
||||
required this.onEmber,
|
||||
required this.myId,
|
||||
required this.nameOf,
|
||||
required this.onVote,
|
||||
});
|
||||
|
||||
final Poll poll;
|
||||
|
||||
/// Kreslí se na korálové bublině (moje anketa).
|
||||
final bool onEmber;
|
||||
final String myId;
|
||||
final String Function(String fingerprint) nameOf;
|
||||
final void Function(List<int> choice)? onVote;
|
||||
|
||||
static const _maxFaces = 3;
|
||||
|
||||
void _toggle(int index) {
|
||||
final mine = poll.votes[myId] ?? const <int>[];
|
||||
final List<int> next;
|
||||
if (poll.multi) {
|
||||
next = mine.contains(index)
|
||||
? (mine.toList()..remove(index))
|
||||
: [...mine, index];
|
||||
} else {
|
||||
next = mine.contains(index) ? const [] : [index];
|
||||
}
|
||||
HapticFeedback.selectionClick();
|
||||
onVote?.call(next);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ink = onEmber ? SepotColors.emberInk : SepotColors.textPrimary;
|
||||
final soft = onEmber
|
||||
? SepotColors.emberInk.withValues(alpha: 0.65)
|
||||
: SepotColors.textSecondary;
|
||||
final track = onEmber
|
||||
? SepotColors.emberInk.withValues(alpha: 0.12)
|
||||
: SepotColors.background.withValues(alpha: 0.55);
|
||||
final fill = onEmber
|
||||
? SepotColors.emberInk.withValues(alpha: 0.22)
|
||||
: SepotColors.ember.withValues(alpha: 0.24);
|
||||
final mine = poll.votes[myId] ?? const <int>[];
|
||||
final total = poll.voterCount;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.bar_chart_rounded, size: 15, color: soft),
|
||||
const SizedBox(width: 5),
|
||||
Text(
|
||||
'${poll.multi ? context.l10n.pollMulti : context.l10n.pollSingle}'
|
||||
' · ${context.l10n.pollVoters(total)}',
|
||||
style: TextStyle(fontSize: 12, color: soft),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(poll.question,
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.w700, color: ink)),
|
||||
const SizedBox(height: 10),
|
||||
for (var i = 0; i < poll.options.length; i++) ...[
|
||||
if (i > 0) const SizedBox(height: 6),
|
||||
_OptionRow(
|
||||
label: poll.options[i],
|
||||
count: poll.countFor(i),
|
||||
share: total == 0 ? 0 : poll.countFor(i) / total,
|
||||
chosen: mine.contains(i),
|
||||
voters: poll.votersFor(i),
|
||||
nameOf: nameOf,
|
||||
ink: ink,
|
||||
track: track,
|
||||
fill: fill,
|
||||
onEmber: onEmber,
|
||||
onTap: onVote == null ? null : () => _toggle(i),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _OptionRow extends StatelessWidget {
|
||||
const _OptionRow({
|
||||
required this.label,
|
||||
required this.count,
|
||||
required this.share,
|
||||
required this.chosen,
|
||||
required this.voters,
|
||||
required this.nameOf,
|
||||
required this.ink,
|
||||
required this.track,
|
||||
required this.fill,
|
||||
required this.onEmber,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final int count;
|
||||
final double share;
|
||||
final bool chosen;
|
||||
final List<String> voters;
|
||||
final String Function(String) nameOf;
|
||||
final Color ink;
|
||||
final Color track;
|
||||
final Color fill;
|
||||
final bool onEmber;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = onEmber ? SepotColors.emberInk : SepotColors.ember;
|
||||
return Semantics(
|
||||
button: onTap != null,
|
||||
selected: chosen,
|
||||
label: '$label, $count',
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 44),
|
||||
decoration: BoxDecoration(
|
||||
color: track,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: chosen ? accent : Colors.transparent,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: AnimatedFractionallySizedBox(
|
||||
duration: const Duration(milliseconds: 280),
|
||||
curve: Curves.easeOutCubic,
|
||||
widthFactor: share.clamp(0.0, 1.0),
|
||||
heightFactor: 1,
|
||||
child: ColoredBox(color: fill),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 12, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
chosen
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.radio_button_unchecked_rounded,
|
||||
size: 18,
|
||||
color: chosen ? accent : ink.withValues(alpha: 0.45),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: TextStyle(fontSize: 14, color: ink)),
|
||||
),
|
||||
if (voters.isNotEmpty) ...[
|
||||
const SizedBox(width: 6),
|
||||
_Faces(voters: voters, nameOf: nameOf),
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 14, fontWeight: FontWeight.w700, color: ink),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Překrývající se kolečka prvních hlasujících.
|
||||
class _Faces extends StatelessWidget {
|
||||
const _Faces({required this.voters, required this.nameOf});
|
||||
|
||||
final List<String> voters;
|
||||
final String Function(String) nameOf;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final shown = voters.take(PollContent._maxFaces).toList();
|
||||
return SizedBox(
|
||||
width: 20.0 + (shown.length - 1) * 13,
|
||||
height: 20,
|
||||
child: Stack(
|
||||
children: [
|
||||
for (var i = 0; i < shown.length; i++)
|
||||
Positioned(
|
||||
left: i * 13.0,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: SepotColors.surface, width: 1.5),
|
||||
),
|
||||
child: SepotAvatar(
|
||||
name: nameOf(shown[i]), seed: shown[i], size: 17),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user