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.
427 lines
15 KiB
Dart
427 lines
15 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:sepot/core/l10n/l10n.dart';
|
|
import 'package:sepot/core/theme/app_theme.dart';
|
|
import 'package:sepot/features/conversation/chat_actions.dart';
|
|
import 'package:sepot/features/profile/profile_widgets.dart';
|
|
import 'package:sepot/models/group.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';
|
|
import 'package:sepot/widgets/avatar.dart';
|
|
|
|
/// Profil skupiny: členové a jejich ověření, přidání lidí, sdílená média,
|
|
/// nastavení konverzace a odchod.
|
|
///
|
|
/// Členství, role a jméno mění správci. Ne-správce
|
|
/// vidí akce neaktivní a pod členy vysvětlení; přidávat lidi smí i on, dokud
|
|
/// je ve skupině někdo se starší verzí (v1 to umí a skupina se nesmí
|
|
/// rozejít).
|
|
class GroupProfileScreen extends ConsumerWidget {
|
|
const GroupProfileScreen({
|
|
super.key,
|
|
required this.groupId,
|
|
this.fromChat = false,
|
|
});
|
|
|
|
final String groupId;
|
|
final bool fromChat;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = context.l10n;
|
|
final group = ref.watch(groupProvider(groupId)).valueOrNull;
|
|
final conv = ref.watch(conversationProvider(groupId)).valueOrNull;
|
|
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
|
final myId = ref.watch(identityProvider).valueOrNull?.id;
|
|
|
|
if (group == null) {
|
|
return Scaffold(
|
|
appBar: AppBar(),
|
|
body: const Center(
|
|
child: CircularProgressIndicator(color: SepotColors.ember)),
|
|
);
|
|
}
|
|
|
|
final me = myId ?? '';
|
|
final amAdmin = group.isAdmin(me);
|
|
final active = !group.left;
|
|
final canAdd = active && (amAdmin || !group.isEnforced(me));
|
|
final canRename = active && amAdmin;
|
|
final rejectedAt = group.rejectedOpAt;
|
|
final rejectedRecently = rejectedAt != null &&
|
|
DateTime.now().difference(rejectedAt) < _rejectedNoticeFor;
|
|
|
|
final muted = conv?.isMuted ?? false;
|
|
final locale = Localizations.localeOf(context).toString();
|
|
|
|
void openChat({bool search = false}) {
|
|
if (fromChat) {
|
|
Navigator.of(context).pop(search ? ProfileResult.openSearch : null);
|
|
} else {
|
|
context.push('/conversation/$groupId${search ? '?search=1' : ''}');
|
|
}
|
|
}
|
|
|
|
// Já první, pak ověření, pak ostatní — kdo je důvěryhodný, je nahoře.
|
|
final members = [...group.memberIds]..sort((a, b) {
|
|
if (a == myId) return -1;
|
|
if (b == myId) return 1;
|
|
final va = contacts.any((c) => c.id == a && c.verified) ? 0 : 1;
|
|
final vb = contacts.any((c) => c.id == b && c.verified) ? 0 : 1;
|
|
return va.compareTo(vb);
|
|
});
|
|
|
|
return Scaffold(
|
|
body: ListView(
|
|
padding: EdgeInsets.zero,
|
|
children: [
|
|
ProfileHero(
|
|
avatar: SepotGroupAvatar(seed: groupId, size: 104),
|
|
title: group.name,
|
|
onTitleTap:
|
|
canRename ? () => renameGroup(context, ref, group) : null,
|
|
lines: [
|
|
Text(
|
|
group.left
|
|
? l10n.groupsYouLeftGroup
|
|
: l10n.profileGroupSubtitle(
|
|
l10n.groupsMemberCount(group.memberIds.length),
|
|
DateFormat.yMMMd(locale).format(group.createdAt)),
|
|
style: const TextStyle(
|
|
fontSize: 13, color: SepotColors.textSecondary),
|
|
),
|
|
],
|
|
),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
ProfileAction(
|
|
icon: Icons.person_add_alt_1_outlined,
|
|
label: l10n.profileAdd,
|
|
accent: true,
|
|
onTap: canAdd
|
|
? () => context.push('/group/$groupId/add')
|
|
: null,
|
|
),
|
|
const SizedBox(width: 8),
|
|
ProfileAction(
|
|
icon: Icons.search_rounded,
|
|
label: l10n.profileSearch,
|
|
onTap: () => openChat(search: true),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ProfileAction(
|
|
icon: muted
|
|
? Icons.notifications_active_outlined
|
|
: Icons.notifications_off_outlined,
|
|
label: muted ? l10n.profileUnmute : l10n.chatListMute,
|
|
onTap: conv == null
|
|
? null
|
|
: () => ref
|
|
.read(conversationsProvider.notifier)
|
|
.setMuted(groupId, !muted)
|
|
.then((_) => ref
|
|
.invalidate(conversationProvider(groupId))),
|
|
),
|
|
const SizedBox(width: 8),
|
|
ProfileAction(
|
|
icon: Icons.timer_outlined,
|
|
label: l10n.profileDisappearing,
|
|
onTap: group.left
|
|
? null
|
|
: () => configureDisappearing(context, ref, groupId),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (active && group.needsResync) ...[
|
|
_GroupNotice(
|
|
icon: Icons.sync_rounded,
|
|
text: l10n.groupsResyncNotice,
|
|
color: SepotColors.mist,
|
|
),
|
|
const SizedBox(height: 10),
|
|
],
|
|
if (active && rejectedRecently) ...[
|
|
_GroupNotice(
|
|
icon: Icons.gpp_maybe_outlined,
|
|
text: l10n.groupsRejectedChangeNotice,
|
|
color: SepotColors.warning,
|
|
),
|
|
const SizedBox(height: 10),
|
|
],
|
|
ProfileCard(
|
|
title: l10n.groupsMembers,
|
|
trailing: l10n.profileMembersOfMax(
|
|
group.memberIds.length, Group.maxMembers),
|
|
children: [
|
|
for (final f in members)
|
|
_MemberRow(
|
|
group: group,
|
|
fingerprint: f,
|
|
isMe: f == myId,
|
|
canManage: active && amAdmin,
|
|
),
|
|
],
|
|
),
|
|
if (active && !amAdmin)
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 0),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.info_outline_rounded,
|
|
size: 16, color: SepotColors.textMuted),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
group.isEnforced(me)
|
|
? l10n.groupsAdminOnlyHint
|
|
: l10n.groupsAdminOnlyCompatHint,
|
|
style: const TextStyle(
|
|
fontSize: 13,
|
|
color: SepotColors.textSecondary),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 14),
|
|
if (conv != null) ...[
|
|
SharedMediaCard(messages: conv.messages),
|
|
const SizedBox(height: 14),
|
|
],
|
|
ProfileCard(
|
|
children: [
|
|
ProfileRow(
|
|
label: l10n.chatDisappearingMessages,
|
|
value: disappearingLabel(
|
|
context, conv?.disappearAfterSeconds),
|
|
onTap: group.left
|
|
? null
|
|
: () => configureDisappearing(context, ref, groupId),
|
|
),
|
|
],
|
|
),
|
|
if (!group.left) ...[
|
|
const SizedBox(height: 14),
|
|
ProfileCard(
|
|
children: [
|
|
ProfileRow(
|
|
label: l10n.groupsLeave,
|
|
danger: true,
|
|
onTap: () async {
|
|
if (await leaveGroup(context, ref, group) &&
|
|
context.mounted) {
|
|
context.go('/chats');
|
|
}
|
|
},
|
|
),
|
|
],
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Jak dlouho po odmítnuté neoprávněné změně skupiny o ní profil mluví.
|
|
const _rejectedNoticeFor = Duration(days: 7);
|
|
|
|
/// Upozornění nad seznamem členů (resync, odmítnutá změna).
|
|
class _GroupNotice extends StatelessWidget {
|
|
const _GroupNotice({
|
|
required this.icon,
|
|
required this.text,
|
|
required this.color,
|
|
});
|
|
|
|
final IconData icon;
|
|
final String text;
|
|
final Color color;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 12),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: color.withValues(alpha: 0.25)),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(icon, size: 18, color: color),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Text(text,
|
|
style: const TextStyle(
|
|
fontSize: 13, color: SepotColors.textEmphasis)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _MemberRow extends ConsumerWidget {
|
|
const _MemberRow({
|
|
required this.group,
|
|
required this.fingerprint,
|
|
required this.isMe,
|
|
required this.canManage,
|
|
});
|
|
|
|
final Group group;
|
|
final String fingerprint;
|
|
final bool isMe;
|
|
|
|
/// Dívám se jako správce — řádek otevře nabídku správcovských akcí.
|
|
final bool canManage;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = context.l10n;
|
|
final identity = ref.watch(identityProvider).valueOrNull;
|
|
final contact = (ref.watch(contactsProvider).valueOrNull ?? const [])
|
|
.where((c) => c.id == fingerprint)
|
|
.firstOrNull;
|
|
final name = isMe
|
|
? l10n.groupsYou
|
|
: contact == null
|
|
? 'SEP-${fingerprint.substring(0, fingerprint.length.clamp(0, 10))}'
|
|
: contactLabel(context, contact);
|
|
final verified = contact?.verified ?? false;
|
|
final isAdmin = group.isAdmin(fingerprint);
|
|
final isFounder = fingerprint == group.createdBy;
|
|
// Správce má přednost: zakladatel, který roli předal, už nic neřídí.
|
|
final badge = isAdmin
|
|
? l10n.groupsAdmin
|
|
: isFounder && !group.provisional
|
|
? l10n.groupsFounder
|
|
: null;
|
|
|
|
void openContact() => context.push('/contact/$fingerprint');
|
|
|
|
return ProfileRow(
|
|
leading: SepotAvatar(
|
|
name: isMe ? (identity?.displayName ?? name) : name,
|
|
seed: fingerprint,
|
|
size: 40,
|
|
verified: verified,
|
|
),
|
|
label: name,
|
|
subtitle: isMe
|
|
? null
|
|
: verified
|
|
? l10n.verifyVerified
|
|
: l10n.verifyUnverified,
|
|
trailing: badge == null
|
|
? null
|
|
: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: isAdmin
|
|
? SepotColors.ember.withValues(alpha: 0.12)
|
|
: SepotColors.border,
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Text(badge,
|
|
style: TextStyle(
|
|
fontSize: 11,
|
|
fontWeight: FontWeight.w600,
|
|
color: isAdmin
|
|
? SepotColors.ember
|
|
: SepotColors.textSecondary)),
|
|
),
|
|
onTap: isMe
|
|
? null
|
|
: canManage
|
|
? () => _showMemberActions(context, ref,
|
|
name: name,
|
|
isAdmin: isAdmin,
|
|
onOpenContact: contact == null ? null : openContact)
|
|
: contact == null
|
|
? null
|
|
: openContact,
|
|
);
|
|
}
|
|
|
|
void _showMemberActions(
|
|
BuildContext context,
|
|
WidgetRef ref, {
|
|
required String name,
|
|
required bool isAdmin,
|
|
required VoidCallback? onOpenContact,
|
|
}) {
|
|
final l10n = context.l10n;
|
|
HapticFeedback.mediumImpact();
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: SepotColors.surface,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (ctx) => SafeArea(
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 18, 20, 8),
|
|
child: Text(name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: const TextStyle(
|
|
fontSize: 16, fontWeight: FontWeight.w700)),
|
|
),
|
|
if (onOpenContact != null)
|
|
ListTile(
|
|
leading: const Icon(Icons.person_outline_rounded),
|
|
title: Text(l10n.groupsViewContact),
|
|
onTap: () {
|
|
Navigator.pop(ctx);
|
|
onOpenContact();
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: Icon(isAdmin
|
|
? Icons.remove_moderator_outlined
|
|
: Icons.add_moderator_outlined),
|
|
title: Text(
|
|
isAdmin ? l10n.groupsDismissAdmin : l10n.groupsMakeAdmin),
|
|
onTap: () {
|
|
Navigator.pop(ctx);
|
|
setGroupAdmin(context, ref, group, fingerprint, !isAdmin);
|
|
},
|
|
),
|
|
ListTile(
|
|
leading: const Icon(Icons.person_remove_outlined,
|
|
color: SepotColors.error),
|
|
title: Text(l10n.groupsRemoveMember,
|
|
style: const TextStyle(color: SepotColors.error)),
|
|
onTap: () {
|
|
Navigator.pop(ctx);
|
|
removeGroupMember(context, ref, group, fingerprint, name);
|
|
},
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|