5246a97db2
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.
166 lines
5.5 KiB
Dart
166 lines
5.5 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:sepot/core/constants/app_constants.dart';
|
|
import 'package:sepot/core/l10n/error_messages.dart';
|
|
import 'package:sepot/core/l10n/l10n.dart';
|
|
import 'package:sepot/core/theme/app_theme.dart';
|
|
import 'package:sepot/core/utils/text_fold.dart';
|
|
import 'package:sepot/models/message.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/widgets/avatar.dart';
|
|
|
|
/// Cíl přeposlání — kontakt nebo skupina, kam jde psát.
|
|
class _Target {
|
|
const _Target(this.id, this.name,
|
|
{this.isGroup = false, this.verified = false, this.isNotes = false});
|
|
|
|
final String id;
|
|
final String name;
|
|
final bool isGroup;
|
|
final bool isNotes;
|
|
final bool verified;
|
|
}
|
|
|
|
/// Výběr chatu a přeposlání zprávy. Zdrojový chat v nabídce není —
|
|
/// přeposlat zprávu tam, kde už je, nikdo nechce.
|
|
Future<void> forwardMessage(
|
|
BuildContext context,
|
|
WidgetRef ref,
|
|
Message message, {
|
|
required String fromId,
|
|
}) async {
|
|
final contacts = ref.read(contactsProvider).valueOrNull ?? const [];
|
|
final groups = ref.read(groupsProvider).valueOrNull ?? const [];
|
|
final targets = [
|
|
for (final g in groups)
|
|
if (!g.left && g.id != fromId) _Target(g.id, g.name, isGroup: true),
|
|
for (final c in contacts)
|
|
if (!c.blocked && c.id != fromId)
|
|
_Target(c.id, c.displayName, verified: c.verified),
|
|
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
// Poznámky nahoře: přeposlat si něco „na později“ je nejčastější případ.
|
|
if (!ConversationsNotifier.isNotes(fromId)) {
|
|
targets.insert(
|
|
0,
|
|
_Target(AppConstants.notesConversationId,
|
|
context.l10n.notesTitle,
|
|
isNotes: true));
|
|
}
|
|
|
|
final target = await showModalBottomSheet<_Target>(
|
|
context: context,
|
|
isScrollControlled: true,
|
|
backgroundColor: SepotColors.surface,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (_) => _ForwardPicker(targets: targets),
|
|
);
|
|
if (target == null || !context.mounted) return;
|
|
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
final l10n = context.l10n;
|
|
try {
|
|
await ref
|
|
.read(conversationsProvider.notifier)
|
|
.forwardMessage(message, target.id);
|
|
messenger.showSnackBar(
|
|
SnackBar(content: Text(l10n.forwardDone(target.name))));
|
|
} catch (e) {
|
|
messenger.showSnackBar(SnackBar(
|
|
content: Text(l10n.forwardFailed(describeError(l10n, e)))));
|
|
}
|
|
}
|
|
|
|
class _ForwardPicker extends StatefulWidget {
|
|
const _ForwardPicker({required this.targets});
|
|
|
|
final List<_Target> targets;
|
|
|
|
@override
|
|
State<_ForwardPicker> createState() => _ForwardPickerState();
|
|
}
|
|
|
|
class _ForwardPickerState extends State<_ForwardPicker> {
|
|
String _query = '';
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final l10n = context.l10n;
|
|
final q = fold(_query);
|
|
final shown = q.isEmpty
|
|
? widget.targets
|
|
: widget.targets
|
|
.where((t) => fold(t.name).contains(q))
|
|
.toList();
|
|
|
|
return DraggableScrollableSheet(
|
|
expand: false,
|
|
initialChildSize: 0.7,
|
|
maxChildSize: 0.92,
|
|
builder: (context, scroll) => Column(
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 16, 20, 8),
|
|
child: Text(
|
|
l10n.forwardTitle,
|
|
style: const TextStyle(
|
|
fontSize: 18,
|
|
fontWeight: FontWeight.w700,
|
|
color: SepotColors.textPrimary,
|
|
),
|
|
),
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
child: TextField(
|
|
autofocus: false,
|
|
onChanged: (v) => setState(() => _query = v),
|
|
decoration: InputDecoration(
|
|
hintText: l10n.forwardSearchHint,
|
|
prefixIcon: const Icon(Icons.search_rounded),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Expanded(
|
|
child: shown.isEmpty
|
|
? Center(
|
|
child: Text(
|
|
l10n.forwardEmpty,
|
|
style: const TextStyle(color: SepotColors.textSecondary),
|
|
),
|
|
)
|
|
: ListView.builder(
|
|
controller: scroll,
|
|
itemCount: shown.length,
|
|
itemBuilder: (context, i) {
|
|
final t = shown[i];
|
|
return ListTile(
|
|
leading: t.isNotes
|
|
? const NotesAvatar(size: 40)
|
|
: t.isGroup
|
|
? SepotGroupAvatar(seed: t.id, size: 40)
|
|
: SepotAvatar(
|
|
name: t.name,
|
|
seed: t.id,
|
|
size: 40,
|
|
verified: t.verified,
|
|
),
|
|
title: Text(t.name,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
color: SepotColors.textPrimary)),
|
|
onTap: () => Navigator.pop(context, t),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|