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:
@@ -0,0 +1,234 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Profil kontaktu: kdo to je, jestli je ověřený, co jsme si poslali,
|
||||
/// ve kterých skupinách jsme spolu a nastavení konverzace.
|
||||
class ContactProfileScreen extends ConsumerWidget {
|
||||
const ContactProfileScreen({
|
||||
super.key,
|
||||
required this.contactId,
|
||||
this.fromChat = false,
|
||||
});
|
||||
|
||||
final String contactId;
|
||||
|
||||
/// Otevřeno z hlavičky chatu — „Zpráva“ se jen vrátí, nic nepřidá
|
||||
/// do zásobníku obrazovek.
|
||||
final bool fromChat;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final l10n = context.l10n;
|
||||
final contact = (ref.watch(contactsProvider).valueOrNull ?? const [])
|
||||
.where((c) => c.publicIdentity.id == contactId)
|
||||
.firstOrNull;
|
||||
final conv = ref.watch(conversationProvider(contactId)).valueOrNull;
|
||||
final groups = (ref.watch(groupsProvider).valueOrNull ?? const [])
|
||||
.where((g) => !g.left && g.memberIds.contains(contactId))
|
||||
.toList();
|
||||
|
||||
if (contact == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(),
|
||||
body: Center(child: Text(l10n.commonUnknownContact)),
|
||||
);
|
||||
}
|
||||
|
||||
final name = contactLabel(context, contact);
|
||||
final muted = conv?.isMuted ?? false;
|
||||
|
||||
void openChat({bool search = false}) {
|
||||
if (fromChat) {
|
||||
Navigator.of(context).pop(search ? ProfileResult.openSearch : null);
|
||||
} else {
|
||||
context.push('/conversation/$contactId${search ? '?search=1' : ''}');
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
body: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
children: [
|
||||
ProfileHero(
|
||||
avatar: Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: contact.verified
|
||||
? SepotColors.sage
|
||||
: SepotColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: SepotAvatar(
|
||||
name: name,
|
||||
seed: contactId,
|
||||
size: 108,
|
||||
verified: contact.verified,
|
||||
),
|
||||
),
|
||||
title: name,
|
||||
lines: [
|
||||
SelectableText(
|
||||
contact.publicIdentity.fingerprint.short,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontMono,
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
_VerificationLine(
|
||||
verified: contact.verified,
|
||||
onVerify: () => context.push('/verify/$contactId'),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
ProfileAction(
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
label: l10n.profileMessage,
|
||||
accent: true,
|
||||
onTap: contact.blocked ? null : openChat,
|
||||
),
|
||||
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(contactId, !muted)
|
||||
.then((_) => ref
|
||||
.invalidate(conversationProvider(contactId))),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
ProfileAction(
|
||||
icon: Icons.timer_outlined,
|
||||
label: l10n.profileDisappearing,
|
||||
onTap: () =>
|
||||
configureDisappearing(context, ref, contactId),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
if (conv != null) ...[
|
||||
SharedMediaCard(messages: conv.messages),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
if (groups.isNotEmpty) ...[
|
||||
ProfileCard(
|
||||
title: l10n.profileCommonGroups,
|
||||
trailing: '${groups.length}',
|
||||
children: [
|
||||
for (final g in groups)
|
||||
ProfileRow(
|
||||
leading: SepotGroupAvatar(seed: g.id, size: 38),
|
||||
label: g.name,
|
||||
subtitle: l10n.groupsMemberCount(g.memberIds.length),
|
||||
onTap: () => context.push('/conversation/${g.id}'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
ProfileCard(
|
||||
children: [
|
||||
ProfileRow(
|
||||
label: l10n.chatDisappearingMessages,
|
||||
value:
|
||||
disappearingLabel(context, conv?.disappearAfterSeconds),
|
||||
onTap: () =>
|
||||
configureDisappearing(context, ref, contactId),
|
||||
),
|
||||
ProfileRow(
|
||||
label: l10n.profileSafetyNumber,
|
||||
subtitle: contact.verified
|
||||
? l10n.verifyVerified
|
||||
: l10n.profileSafetyNumberHint,
|
||||
onTap: () => context.push('/verify/$contactId'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
ProfileCard(
|
||||
children: [
|
||||
ProfileRow(
|
||||
label: contact.blocked
|
||||
? l10n.chatMenuUnblockContact
|
||||
: l10n.chatMenuBlockContact,
|
||||
danger: !contact.blocked,
|
||||
onTap: () => setContactBlocked(
|
||||
context, ref, contact, !contact.blocked),
|
||||
),
|
||||
if (!contact.blocked)
|
||||
ProfileRow(
|
||||
label: l10n.reportAndBlock,
|
||||
danger: true,
|
||||
onTap: () => reportAndBlock(context, ref, contact),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _VerificationLine extends StatelessWidget {
|
||||
const _VerificationLine({required this.verified, required this.onVerify});
|
||||
|
||||
final bool verified;
|
||||
final VoidCallback onVerify;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (verified) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.verified_user_outlined,
|
||||
size: 16, color: SepotColors.sage),
|
||||
const SizedBox(width: 6),
|
||||
Text(context.l10n.profileVerifiedInPerson,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.sage)),
|
||||
],
|
||||
);
|
||||
}
|
||||
return TextButton.icon(
|
||||
onPressed: onVerify,
|
||||
icon: const Icon(Icons.shield_outlined, size: 16),
|
||||
label: Text(context.l10n.profileVerifyNow),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,426 @@
|
||||
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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
/// Stavební prvky profilu kontaktu a skupiny — ať oba vypadají a chovají
|
||||
/// se stejně.
|
||||
library;
|
||||
|
||||
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/features/conversation/image_viewer_screen.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
|
||||
/// Výsledek profilu otevřeného z chatu: co má chat udělat po návratu.
|
||||
enum ProfileResult { openSearch }
|
||||
|
||||
/// Horní část profilu: zpět, velký avatar, jméno a řádky pod ním.
|
||||
class ProfileHero extends StatelessWidget {
|
||||
const ProfileHero({
|
||||
super.key,
|
||||
required this.avatar,
|
||||
required this.title,
|
||||
required this.lines,
|
||||
this.onTitleTap,
|
||||
});
|
||||
|
||||
final Widget avatar;
|
||||
final String title;
|
||||
final List<Widget> lines;
|
||||
final VoidCallback? onTitleTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: SepotColors.hero,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
4, MediaQuery.paddingOf(context).top + 4, 4, 22),
|
||||
child: Column(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: IconButton(
|
||||
tooltip: MaterialLocalizations.of(context).backButtonTooltip,
|
||||
icon: const Icon(Icons.arrow_back_rounded),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
),
|
||||
avatar,
|
||||
const SizedBox(height: 14),
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: onTitleTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800,
|
||||
letterSpacing: -0.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (onTitleTap != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.edit_outlined,
|
||||
size: 18, color: SepotColors.textSecondary),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
for (final line in lines)
|
||||
Padding(padding: const EdgeInsets.only(top: 4), child: line),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Čtveřice rychlých akcí pod hlavičkou profilu.
|
||||
class ProfileAction extends StatelessWidget {
|
||||
const ProfileAction({
|
||||
super.key,
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.accent = false,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
final bool accent;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = accent ? SepotColors.ember : SepotColors.textPrimary;
|
||||
return Expanded(
|
||||
child: Material(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: onTap == null
|
||||
? null
|
||||
: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap!();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: 70,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: color),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12, fontWeight: FontWeight.w500, color: color),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Zaoblená karta se sekcí profilu; řádky odděluje tenká linka.
|
||||
class ProfileCard extends StatelessWidget {
|
||||
const ProfileCard({super.key, required this.children, this.title, this.trailing});
|
||||
|
||||
final String? title;
|
||||
final String? trailing;
|
||||
final List<Widget> children;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title!,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w700)),
|
||||
),
|
||||
if (trailing != null)
|
||||
Text(trailing!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textSecondary)),
|
||||
],
|
||||
),
|
||||
),
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
if (i > 0 && title == null)
|
||||
const Divider(height: 1, indent: 16, color: SepotColors.divider),
|
||||
children[i],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Řádek karty: popisek vlevo, hodnota vpravo, volitelně šipka.
|
||||
class ProfileRow extends StatelessWidget {
|
||||
const ProfileRow({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.value,
|
||||
this.leading,
|
||||
this.subtitle,
|
||||
this.onTap,
|
||||
this.danger = false,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final String? value;
|
||||
final Widget? leading;
|
||||
final String? subtitle;
|
||||
final VoidCallback? onTap;
|
||||
final bool danger;
|
||||
final Widget? trailing;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
if (leading != null) ...[leading!, const SizedBox(width: 12)],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: danger ? FontWeight.w600 : FontWeight.w400,
|
||||
color: danger
|
||||
? SepotColors.error
|
||||
: SepotColors.textPrimary)),
|
||||
if (subtitle != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13, color: SepotColors.textSecondary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (value != null)
|
||||
Text(value!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14, color: SepotColors.textSecondary)),
|
||||
?trailing,
|
||||
if (onTap != null && !danger && trailing == null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.chevron_right_rounded,
|
||||
size: 20, color: SepotColors.textMuted),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mřížka sdílených fotek (inline náhledy z E2E zpráv, nic se nestahuje).
|
||||
class SharedMediaCard extends StatelessWidget {
|
||||
const SharedMediaCard({super.key, required this.messages});
|
||||
|
||||
/// Všechny zprávy konverzace — fotky si vybere sama.
|
||||
final List<Message> messages;
|
||||
|
||||
static const _shown = 8;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final images = messages.where(isViewableImage).toList().reversed.toList();
|
||||
if (images.isEmpty) return const SizedBox.shrink();
|
||||
final extra = images.length - _shown;
|
||||
final visible = images.take(_shown).toList();
|
||||
|
||||
return ProfileCard(
|
||||
title: context.l10n.profileSharedMedia,
|
||||
trailing: '${images.length}',
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 4, 4, 4),
|
||||
child: GridView.count(
|
||||
crossAxisCount: 4,
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
mainAxisSpacing: 3,
|
||||
crossAxisSpacing: 3,
|
||||
children: [
|
||||
for (var i = 0; i < visible.length; i++)
|
||||
GestureDetector(
|
||||
onTap: () =>
|
||||
ImageViewerScreen.open(context, visible[i], messages),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (visible[i].attachment?.thumbnail != null)
|
||||
Image.memory(visible[i].attachment!.thumbnail!,
|
||||
fit: BoxFit.cover, gaplessPlayback: true)
|
||||
else
|
||||
const ColoredBox(color: SepotColors.surfaceVariant),
|
||||
if (i == _shown - 1 && extra > 0)
|
||||
ColoredBox(
|
||||
color: Colors.black54,
|
||||
child: Center(
|
||||
child: Text('+$extra',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user