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:
@@ -1,173 +1,467 @@
|
||||
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: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/features/chat_list/chat_list_tile.dart';
|
||||
import 'package:sepot/features/chat_list/push_hint_banner.dart';
|
||||
import 'package:sepot/features/contacts/contacts_screen.dart';
|
||||
import 'package:sepot/features/settings/settings_screen.dart';
|
||||
import 'package:sepot/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/group.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/drafts_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/providers/messaging_provider.dart';
|
||||
import 'package:sepot/providers/push_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/widgets/e2e_badge.dart';
|
||||
import 'package:sepot/widgets/glass.dart';
|
||||
|
||||
/// Hlavní obrazovka — seznam konverzací
|
||||
class ChatListScreen extends ConsumerWidget {
|
||||
/// Výška plovoucí navigace včetně okraje — obsah karet pod ní končí.
|
||||
const _navHeight = 66.0 + 18.0;
|
||||
|
||||
/// Domovská obrazovka: karty Chaty / Kontakty / Nastavení pod průsvitnou
|
||||
/// plovoucí navigací.
|
||||
class ChatListScreen extends ConsumerStatefulWidget {
|
||||
const ChatListScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ChatListScreen> createState() => _ChatListScreenState();
|
||||
}
|
||||
|
||||
class _ChatListScreenState extends ConsumerState<ChatListScreen> {
|
||||
var _tab = 0;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Nastartuje krypto + spojení s relayem (idempotentní)
|
||||
ref.watch(messagingStartProvider);
|
||||
// Zaregistruje push callbacky (po startu messagingu) — obnoví registraci,
|
||||
// pokud už distributor byl dřív vybraný. Push zapíná uživatel v nastavení.
|
||||
// Zaregistruje push callbacky (po startu messagingu) a obnoví režim.
|
||||
ref.watch(pushInitProvider);
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final groupsAsync = ref.watch(groupsProvider);
|
||||
final identityAsync = ref.watch(identityProvider);
|
||||
|
||||
final l10n = context.l10n;
|
||||
// S klávesnicí (hledání) by navigace i „Nový chat“ plavaly nad ní.
|
||||
final keyboard = MediaQuery.viewInsetsOf(context).bottom > 0;
|
||||
// Pod plovoucí navigací je ještě systémová lišta telefonu.
|
||||
final tabBottom = _navHeight + MediaQuery.paddingOf(context).bottom;
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
context.l10n.commonAppName,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 22,
|
||||
extendBody: true,
|
||||
body: IndexedStack(
|
||||
index: _tab,
|
||||
children: [
|
||||
_ChatsTab(onOpenSettings: () => setState(() => _tab = 2)),
|
||||
// Kontakty a Nastavení mají vlastní Scaffold; spodní okraj, ať
|
||||
// poslední položka neskončí pod navigací.
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: tabBottom),
|
||||
child: const ContactsScreen(),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.search_rounded),
|
||||
onPressed: () {
|
||||
// TODO: Implementovat vyhledávání
|
||||
},
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => context.push('/settings'),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(bottom: tabBottom),
|
||||
child: const SettingsScreen(),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: conversationsAsync.when(
|
||||
data: (conversations) {
|
||||
final active = conversations.where((c) => !c.archived).toList();
|
||||
final archived = conversations.where((c) => c.archived).toList();
|
||||
if (active.isEmpty && archived.isEmpty) {
|
||||
return _buildEmptyState(context);
|
||||
}
|
||||
final hasArchive = archived.isNotEmpty;
|
||||
floatingActionButton: _tab == 0 && !keyboard
|
||||
? FloatingActionButton.extended(
|
||||
onPressed: () => context.push('/contacts'),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: Text(l10n.chatListNewChat,
|
||||
style: const TextStyle(fontWeight: FontWeight.w700)),
|
||||
)
|
||||
: null,
|
||||
bottomNavigationBar: keyboard
|
||||
? null
|
||||
: SafeArea(
|
||||
minimum: const EdgeInsets.fromLTRB(14, 0, 14, 12),
|
||||
child: GlassSurface(
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
child: SizedBox(
|
||||
height: 66,
|
||||
child: Row(
|
||||
children: [
|
||||
_NavItem(
|
||||
icon: Icons.chat_bubble_outline_rounded,
|
||||
activeIcon: Icons.chat_bubble_rounded,
|
||||
label: l10n.navChats,
|
||||
selected: _tab == 0,
|
||||
onTap: () => setState(() => _tab = 0),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.people_outline_rounded,
|
||||
activeIcon: Icons.people_rounded,
|
||||
label: l10n.contactsTitle,
|
||||
selected: _tab == 1,
|
||||
onTap: () => setState(() => _tab = 1),
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.settings_outlined,
|
||||
activeIcon: Icons.settings_rounded,
|
||||
label: l10n.settingsTitle,
|
||||
selected: _tab == 2,
|
||||
onTap: () => setState(() => _tab = 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return Column(
|
||||
class _NavItem extends StatelessWidget {
|
||||
const _NavItem({
|
||||
required this.icon,
|
||||
required this.activeIcon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final IconData activeIcon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = selected ? SepotColors.ember : SepotColors.textSecondary;
|
||||
return Expanded(
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
onTap: () {
|
||||
HapticFeedback.selectionClick();
|
||||
onTap();
|
||||
},
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// Bez budíčků appka na pozadí spí — ať uživatel ví, proč
|
||||
// mu zprávy chodí, až když Šepot otevře.
|
||||
const PushHintBanner(),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
itemCount: active.length + (hasArchive ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (hasArchive && index == 0) {
|
||||
return ListTile(
|
||||
leading: const Icon(Icons.archive_outlined,
|
||||
color: SepotColors.textMuted),
|
||||
title: Text(context.l10n.chatListArchivedCount(archived.length),
|
||||
style: const TextStyle(color: SepotColors.textSecondary)),
|
||||
onTap: () => context.push('/archived'),
|
||||
);
|
||||
}
|
||||
final conv = active[index - (hasArchive ? 1 : 0)];
|
||||
final group = groupsAsync.valueOrNull
|
||||
?.where((g) => g.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final contact = contactsAsync.valueOrNull
|
||||
?.where((c) => c.publicIdentity.id == conv.contactId)
|
||||
.firstOrNull;
|
||||
final myId = identityAsync.valueOrNull?.id ?? '';
|
||||
final name = group?.name ??
|
||||
contact?.displayName ??
|
||||
context.l10n.commonUnknownContact;
|
||||
|
||||
return ChatListTile(
|
||||
conversation: conv,
|
||||
contactName: name,
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
onTap: () => context.push('/conversation/${conv.contactId}'),
|
||||
onLongPress: () => _showChatActions(context, ref, conv, name),
|
||||
);
|
||||
},
|
||||
Icon(selected ? activeIcon : icon, size: 23, color: color),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum _Filter { all, unread, groups }
|
||||
|
||||
/// Karta Chaty: nadpis, hledání, filtry, připnuté a seznam.
|
||||
class _ChatsTab extends ConsumerStatefulWidget {
|
||||
const _ChatsTab({required this.onOpenSettings});
|
||||
|
||||
final VoidCallback onOpenSettings;
|
||||
|
||||
@override
|
||||
ConsumerState<_ChatsTab> createState() => _ChatsTabState();
|
||||
}
|
||||
|
||||
class _ChatsTabState extends ConsumerState<_ChatsTab> {
|
||||
final _search = TextEditingController();
|
||||
var _filter = _Filter.all;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_search.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
final conversationsAsync = ref.watch(conversationsProvider);
|
||||
final contacts = ref.watch(contactsProvider).valueOrNull ?? const [];
|
||||
final groups = ref.watch(groupsProvider).valueOrNull ?? const [];
|
||||
final identity = ref.watch(identityProvider).valueOrNull;
|
||||
final myId = identity?.id ?? '';
|
||||
final typing = ref.watch(typingProvider).valueOrNull ?? const {};
|
||||
final drafts = ref.watch(draftsProvider).valueOrNull ?? const {};
|
||||
|
||||
Contact? contactOf(String id) =>
|
||||
contacts.where((c) => c.publicIdentity.id == id).firstOrNull;
|
||||
Group? groupOf(String id) => groups.where((g) => g.id == id).firstOrNull;
|
||||
String nameOf(Conversation c) => ConversationsNotifier.isNotes(c.contactId)
|
||||
? l10n.notesTitle
|
||||
: groupOf(c.contactId)?.name ??
|
||||
contactOf(c.contactId)?.displayName ??
|
||||
l10n.commonUnknownContact;
|
||||
|
||||
return SafeArea(
|
||||
bottom: false,
|
||||
child: conversationsAsync.when(
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember)),
|
||||
error: (e, _) => Center(child: Text(describeError(l10n, e))),
|
||||
data: (conversations) {
|
||||
final active = conversations.where((c) => !c.archived).toList();
|
||||
final archived = conversations.where((c) => c.archived).length;
|
||||
final query = _search.text.trim();
|
||||
final searching = query.isNotEmpty;
|
||||
|
||||
// Hledání: jméno i obsah zpráv, bez diakritiky. U shody ve zprávě
|
||||
// se místo poslední zprávy ukáže ta nalezená.
|
||||
final hits = <Conversation, Message?>{};
|
||||
if (searching) {
|
||||
final q = fold(query);
|
||||
for (final c in conversations) {
|
||||
if (fold(nameOf(c)).contains(q)) {
|
||||
hits[c] = null;
|
||||
continue;
|
||||
}
|
||||
final m = c.messages.reversed
|
||||
.where((m) =>
|
||||
!m.isDeleted &&
|
||||
m.content != null &&
|
||||
fold(m.content!).contains(q))
|
||||
.firstOrNull;
|
||||
if (m != null) hits[c] = m;
|
||||
}
|
||||
}
|
||||
|
||||
final unreadCount =
|
||||
active.where((c) => c.unreadCount(myId) > 0).length;
|
||||
final pinned = active.where((c) => c.pinned).toList();
|
||||
final list = searching
|
||||
? hits.keys.toList()
|
||||
: active.where((c) {
|
||||
if (_filter == _Filter.all && c.pinned) return false;
|
||||
return switch (_filter) {
|
||||
_Filter.all => true,
|
||||
_Filter.unread => c.unreadCount(myId) > 0,
|
||||
_Filter.groups => groupOf(c.contactId) != null,
|
||||
};
|
||||
}).toList();
|
||||
|
||||
Widget tile(Conversation c) {
|
||||
final group = groupOf(c.contactId);
|
||||
final last = c.lastMessage;
|
||||
final writers = typing[c.contactId] ?? const <String>{};
|
||||
final typingLabel = writers.isEmpty
|
||||
? null
|
||||
: group == null
|
||||
? l10n.chatTyping
|
||||
: writers.length == 1
|
||||
? l10n.chatTypingNamed(
|
||||
(contactOf(writers.first)?.displayName ?? '?')
|
||||
.split(' ')
|
||||
.first)
|
||||
: l10n.chatTypingSeveral;
|
||||
return ChatListTile(
|
||||
conversation: c,
|
||||
contactName: nameOf(c),
|
||||
myId: myId,
|
||||
isGroup: group != null,
|
||||
verified: contactOf(c.contactId)?.verified ?? false,
|
||||
lastAuthor: group != null && last != null
|
||||
? contactOf(last.senderId)?.displayName
|
||||
: null,
|
||||
highlight: hits[c],
|
||||
typingLabel: typingLabel,
|
||||
draft: hits[c] == null ? drafts[c.contactId] : null,
|
||||
onTap: () => context.push('/conversation/${c.contactId}'),
|
||||
onLongPress: () => _showChatActions(c, nameOf(c)),
|
||||
);
|
||||
}
|
||||
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 12, 0),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(l10n.navChats,
|
||||
style: SepotTheme.largeTitle),
|
||||
),
|
||||
IconButton.filledTonal(
|
||||
tooltip: l10n.qrScanTitle,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: SepotColors.surface,
|
||||
foregroundColor: SepotColors.textPrimary),
|
||||
onPressed: () => context.push('/qr-scan'),
|
||||
icon: const Icon(Icons.qr_code_scanner_rounded,
|
||||
size: 21),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Semantics(
|
||||
button: true,
|
||||
label: l10n.settingsTitle,
|
||||
child: GestureDetector(
|
||||
onTap: widget.onOpenSettings,
|
||||
child: SepotAvatar(
|
||||
name: identity?.displayName ?? '?',
|
||||
seed: myId,
|
||||
size: 42,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: TextField(
|
||||
controller: _search,
|
||||
onChanged: (_) => setState(() {}),
|
||||
textInputAction: TextInputAction.search,
|
||||
decoration: InputDecoration(
|
||||
hintText: l10n.chatListSearchHint,
|
||||
prefixIcon: const Icon(Icons.search_rounded,
|
||||
color: SepotColors.textSecondary),
|
||||
suffixIcon: searching
|
||||
? IconButton(
|
||||
tooltip: l10n.commonCancel,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () =>
|
||||
setState(() => _search.clear()),
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: SepotColors.surface,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(
|
||||
color: SepotColors.ember, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
// Bez budíčků appka na pozadí spí — ať uživatel ví, proč
|
||||
// mu zprávy chodí, až když Šepot otevře.
|
||||
const SliverToBoxAdapter(child: PushHintBanner()),
|
||||
if (!searching)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 52,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
|
||||
children: [
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterAll,
|
||||
selected: _filter == _Filter.all,
|
||||
onTap: () => setState(() => _filter = _Filter.all),
|
||||
),
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterUnread,
|
||||
count: unreadCount,
|
||||
selected: _filter == _Filter.unread,
|
||||
onTap: () =>
|
||||
setState(() => _filter = _Filter.unread),
|
||||
),
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterGroups,
|
||||
selected: _filter == _Filter.groups,
|
||||
onTap: () =>
|
||||
setState(() => _filter = _Filter.groups),
|
||||
),
|
||||
if (archived > 0)
|
||||
_FilterChip(
|
||||
label: l10n.chatFilterArchive,
|
||||
count: archived,
|
||||
icon: Icons.archive_outlined,
|
||||
selected: false,
|
||||
onTap: () => context.push('/archived'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!searching && _filter == _Filter.all && pinned.isNotEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(
|
||||
height: 100,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 4),
|
||||
itemCount: pinned.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 14),
|
||||
itemBuilder: (context, i) {
|
||||
final c = pinned[i];
|
||||
return _PinnedChat(
|
||||
name: nameOf(c),
|
||||
seed: c.contactId,
|
||||
isGroup: groupOf(c.contactId) != null,
|
||||
unread: c.unreadCount(myId) > 0 && !c.isMuted,
|
||||
onTap: () =>
|
||||
context.push('/conversation/${c.contactId}'),
|
||||
onLongPress: () => _showChatActions(c, nameOf(c)),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
if (conversations.isEmpty)
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: _EmptyState(),
|
||||
)
|
||||
else if (list.isEmpty)
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Text(
|
||||
searching ? l10n.chatListNoResults : l10n.chatListFilterEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
SliverList.builder(
|
||||
itemCount: list.length,
|
||||
itemBuilder: (context, i) => tile(list[i]),
|
||||
),
|
||||
// Místo pro „Nový chat“ a plovoucí navigaci.
|
||||
const SliverToBoxAdapter(child: SizedBox(height: 170)),
|
||||
],
|
||||
);
|
||||
},
|
||||
loading: () => const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
),
|
||||
error: (e, _) =>
|
||||
Center(child: Text(describeError(context.l10n, e))),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => context.push('/contacts'),
|
||||
child: const Icon(Icons.chat_bubble_outline_rounded),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.chat_bubble_outline_rounded,
|
||||
size: 40,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.chatListEmptyTitle,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chatListEmptySubtitle,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const E2EBadge(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Akce nad konverzací (long-press): ztlumit / archivovat.
|
||||
void _showChatActions(
|
||||
BuildContext context, WidgetRef ref, Conversation conv, String name) {
|
||||
/// Akce nad konverzací (long-press): připnout / ztlumit / archivovat.
|
||||
void _showChatActions(Conversation conv, String name) {
|
||||
final notifier = ref.read(conversationsProvider.notifier);
|
||||
final l10n = context.l10n;
|
||||
HapticFeedback.mediumImpact();
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
@@ -178,13 +472,30 @@ class ChatListScreen extends ConsumerWidget {
|
||||
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)),
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(conv.pinned
|
||||
? Icons.push_pin_outlined
|
||||
: Icons.push_pin_rounded),
|
||||
title: Text(conv.pinned ? l10n.chatListUnpin : l10n.chatListPin),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setPinned(conv.contactId, !conv.pinned);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: Icon(conv.isMuted
|
||||
? Icons.notifications_active_outlined
|
||||
: Icons.notifications_off_outlined),
|
||||
title: Text(conv.isMuted
|
||||
? context.l10n.chatListUnmute
|
||||
: context.l10n.chatListMute),
|
||||
title: Text(
|
||||
conv.isMuted ? l10n.chatListUnmute : l10n.chatListMute),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setMuted(conv.contactId, !conv.isMuted);
|
||||
@@ -192,7 +503,7 @@ class ChatListScreen extends ConsumerWidget {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.archive_outlined),
|
||||
title: Text(context.l10n.chatListArchive),
|
||||
title: Text(l10n.chatListArchive),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
notifier.setArchived(conv.contactId, true);
|
||||
@@ -204,3 +515,173 @@ class ChatListScreen extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FilterChip extends StatelessWidget {
|
||||
const _FilterChip({
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.count,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
final int? count;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: Semantics(
|
||||
button: true,
|
||||
selected: selected,
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? SepotColors.ember : SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: selected ? SepotColors.ember : SepotColors.border),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, size: 15, color: SepotColors.textEmphasis),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: selected ? FontWeight.w700 : FontWeight.w500,
|
||||
color: selected
|
||||
? SepotColors.emberInk
|
||||
: SepotColors.textEmphasis,
|
||||
),
|
||||
),
|
||||
if (count != null && count! > 0) ...[
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'$count',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected
|
||||
? SepotColors.emberInk
|
||||
: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinnedChat extends StatelessWidget {
|
||||
const _PinnedChat({
|
||||
required this.name,
|
||||
required this.seed,
|
||||
required this.isGroup,
|
||||
required this.unread,
|
||||
required this.onTap,
|
||||
required this.onLongPress,
|
||||
});
|
||||
|
||||
final String name;
|
||||
final String seed;
|
||||
final bool isGroup;
|
||||
final bool unread;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onLongPress;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: SizedBox(
|
||||
width: 66,
|
||||
child: Column(
|
||||
children: [
|
||||
isGroup
|
||||
? Container(
|
||||
padding: const EdgeInsets.all(2.5),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: unread ? SepotColors.ember : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: SepotGroupAvatar(seed: seed, size: 54),
|
||||
)
|
||||
: SepotAvatar(
|
||||
name: name,
|
||||
seed: seed,
|
||||
size: 54,
|
||||
ring: unread ? SepotColors.ember : SepotColors.border,
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
name.split(' ').first,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.ember.withValues(alpha: 0.1),
|
||||
),
|
||||
child: const Icon(Icons.chat_bubble_outline_rounded,
|
||||
size: 40, color: SepotColors.ember),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
context.l10n.chatListEmptyTitle,
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.l10n.chatListEmptySubtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
const E2EBadge(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/features/calls/call_log_text.dart';
|
||||
import 'package:sepot/models/conversation.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/widgets/message_status_icon.dart';
|
||||
|
||||
/// Dlaždice konverzace v seznamu chatů. Sdílená hlavním i archivovaným
|
||||
/// seznamem, aby se logika (fajfky, náhled, ztlumení) nerozešla.
|
||||
@@ -13,6 +16,20 @@ class ChatListTile extends StatelessWidget {
|
||||
final String contactName;
|
||||
final String myId;
|
||||
final bool isGroup;
|
||||
final bool verified;
|
||||
|
||||
/// Jméno autora poslední zprávy ve skupině (null = 1:1 nebo neznámý).
|
||||
final String? lastAuthor;
|
||||
|
||||
/// Nalezená zpráva při hledání — ukáže se místo poslední.
|
||||
final Message? highlight;
|
||||
|
||||
/// Kdo právě píše (už složený text „píše…“ / „Alice píše…“), null = nikdo.
|
||||
final String? typingLabel;
|
||||
|
||||
/// Rozepsaná zpráva v tomhle chatu — v náhledu má přednost před poslední
|
||||
/// zprávou, jinak by se na ni snadno zapomnělo.
|
||||
final String? draft;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback? onLongPress;
|
||||
|
||||
@@ -22,37 +39,41 @@ class ChatListTile extends StatelessWidget {
|
||||
required this.contactName,
|
||||
required this.myId,
|
||||
this.isGroup = false,
|
||||
this.verified = false,
|
||||
this.lastAuthor,
|
||||
this.highlight,
|
||||
this.typingLabel,
|
||||
this.draft,
|
||||
required this.onTap,
|
||||
this.onLongPress,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final lastMsg = conversation.lastMessage;
|
||||
final lastMsg = highlight ?? conversation.lastMessage;
|
||||
final unread = conversation.unreadCount(myId);
|
||||
final muted = conversation.isMuted;
|
||||
final mine = lastMsg != null && lastMsg.isMine(myId);
|
||||
final preview = previewOf(context.l10n, lastMsg);
|
||||
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
onLongPress: onLongPress,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
isGroup
|
||||
? Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: SepotColors.surfaceVariant,
|
||||
border: Border.all(color: SepotColors.border),
|
||||
),
|
||||
child: const Icon(Icons.group_rounded,
|
||||
color: SepotColors.ember, size: 24),
|
||||
)
|
||||
: SepotAvatar(name: contactName),
|
||||
const SizedBox(width: 12),
|
||||
conversation.contactId == AppConstants.notesConversationId
|
||||
? const NotesAvatar(size: 54)
|
||||
: isGroup
|
||||
? SepotGroupAvatar(seed: conversation.contactId, size: 54)
|
||||
: SepotAvatar(
|
||||
name: contactName,
|
||||
seed: conversation.contactId,
|
||||
size: 54,
|
||||
verified: verified,
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -65,26 +86,28 @@ class ChatListTile extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight:
|
||||
unread > 0 ? FontWeight.w700 : FontWeight.w500,
|
||||
unread > 0 ? FontWeight.w700 : FontWeight.w600,
|
||||
color: SepotColors.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
if (muted)
|
||||
if (conversation.pinned)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 6),
|
||||
child: Icon(Icons.notifications_off,
|
||||
size: 15, color: SepotColors.textMuted),
|
||||
child: Icon(Icons.push_pin_rounded,
|
||||
size: 14, color: SepotColors.textMuted),
|
||||
),
|
||||
if (lastMsg != null)
|
||||
Text(
|
||||
_formatTime(context, lastMsg.timestamp),
|
||||
formatListTime(context, lastMsg.timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight:
|
||||
unread > 0 ? FontWeight.w600 : FontWeight.w400,
|
||||
color: unread > 0 && !muted
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -92,58 +115,106 @@ class ChatListTile extends StatelessWidget {
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
if (lastMsg != null && lastMsg.isMine(myId))
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: Icon(
|
||||
switch (lastMsg.status) {
|
||||
MessageStatus.sending => Icons.schedule,
|
||||
MessageStatus.sent => Icons.done,
|
||||
MessageStatus.delivered => Icons.done_all,
|
||||
MessageStatus.read => Icons.done_all,
|
||||
MessageStatus.failed => Icons.error_outline,
|
||||
},
|
||||
size: 16,
|
||||
color: lastMsg.status == MessageStatus.failed
|
||||
? SepotColors.error
|
||||
: lastMsg.status == MessageStatus.read
|
||||
? SepotColors.mist
|
||||
: SepotColors.textMuted,
|
||||
if (typingLabel != null)
|
||||
Expanded(
|
||||
child: Text(
|
||||
typingLabel!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (draft != null)
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(
|
||||
text: '${context.l10n.chatListDraft}: ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
),
|
||||
TextSpan(text: draft!.replaceAll('\n', ' ')),
|
||||
]),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
if (mine && !lastMsg.isDeleted) ...[
|
||||
MessageStatusIcon(status: lastMsg.status),
|
||||
const SizedBox(width: 6),
|
||||
],
|
||||
if (preview.icon != null) ...[
|
||||
Icon(preview.icon,
|
||||
size: 15, color: SepotColors.textSecondary),
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
Expanded(
|
||||
child: Text.rich(
|
||||
TextSpan(children: [
|
||||
if (isGroup && !mine && lastAuthor != null)
|
||||
TextSpan(
|
||||
text: '$lastAuthor: ',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.textEmphasis,
|
||||
),
|
||||
),
|
||||
TextSpan(text: preview.text),
|
||||
]),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: unread > 0
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_getLastMessageText(context.l10n, lastMsg),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: unread > 0
|
||||
? SepotColors.textPrimary
|
||||
: SepotColors.textSecondary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
],
|
||||
if (muted)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 6),
|
||||
child: Icon(Icons.notifications_off_outlined,
|
||||
size: 16, color: SepotColors.textMuted),
|
||||
),
|
||||
),
|
||||
if (unread > 0)
|
||||
if (unread > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8, vertical: 2),
|
||||
constraints: const BoxConstraints(minWidth: 22),
|
||||
height: 22,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 7),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
// Ztlumené = šedý badge (počet ano, důraz ne)
|
||||
color: muted
|
||||
? SepotColors.textMuted
|
||||
? SepotColors.surfaceVariant
|
||||
: SepotColors.ember,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
),
|
||||
child: Text(
|
||||
unread.toString(),
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
unread > 99 ? '99+' : unread.toString(),
|
||||
style: TextStyle(
|
||||
color: muted
|
||||
? SepotColors.textSecondary
|
||||
: SepotColors.emberInk,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -155,33 +226,60 @@ class ChatListTile extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _getLastMessageText(AppLocalizations l10n, Message? msg) {
|
||||
if (msg == null) return l10n.chatListNewConversation;
|
||||
if (msg.isDeleted) return l10n.chatMessageDeleted;
|
||||
if (msg.attachment?.isAudio == true) return l10n.chatVoiceMessagePreview;
|
||||
switch (msg.type) {
|
||||
case MessageType.text:
|
||||
return msg.content ?? '';
|
||||
case MessageType.image:
|
||||
return msg.content?.isNotEmpty == true
|
||||
? l10n.chatListPhotoWithCaptionPreview(msg.content!)
|
||||
: l10n.chatPhotoPreview;
|
||||
case MessageType.file:
|
||||
return l10n.chatListFilePreview(msg.fileName ?? l10n.commonFile);
|
||||
case MessageType.readReceipt:
|
||||
return l10n.chatListReadReceiptPreview;
|
||||
default:
|
||||
return '';
|
||||
/// Náhled zprávy: ikona typu (fotka, soubor, hlas) + text. Ikona místo
|
||||
/// emoji — emoji se na každém systému kreslí jinak a v náhledu ruší.
|
||||
static ({IconData? icon, String text}) previewOf(
|
||||
AppLocalizations l10n, Message? msg) {
|
||||
if (msg == null) return (icon: null, text: l10n.chatListNewConversation);
|
||||
if (msg.isDeleted) {
|
||||
return (icon: Icons.block_rounded, text: l10n.chatMessageDeleted);
|
||||
}
|
||||
if (msg.attachment?.isAudio == true) {
|
||||
return (icon: Icons.mic_none_rounded, text: l10n.chatVoiceMessagePreview);
|
||||
}
|
||||
if (msg.poll != null) {
|
||||
return (icon: Icons.bar_chart_rounded, text: msg.poll!.question);
|
||||
}
|
||||
final call = msg.callLog;
|
||||
if (call != null) {
|
||||
final label = callLogLabel(l10n, call);
|
||||
return (icon: label.icon, text: label.text);
|
||||
}
|
||||
return switch (msg.type) {
|
||||
MessageType.text => (icon: null, text: msg.content ?? ''),
|
||||
MessageType.image => (
|
||||
icon: Icons.photo_outlined,
|
||||
text: msg.content?.isNotEmpty == true
|
||||
? msg.content!
|
||||
: l10n.chatPhotoPreview,
|
||||
),
|
||||
MessageType.file => (
|
||||
icon: Icons.attach_file_rounded,
|
||||
text: msg.fileName ?? l10n.commonFile,
|
||||
),
|
||||
MessageType.readReceipt => (
|
||||
icon: null,
|
||||
text: l10n.chatListReadReceiptPreview,
|
||||
),
|
||||
_ => (icon: null, text: ''),
|
||||
};
|
||||
}
|
||||
|
||||
static String _formatTime(BuildContext context, DateTime time) {
|
||||
static String formatListTime(BuildContext context, DateTime time) {
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(time);
|
||||
if (diff.inMinutes < 1) return context.l10n.chatListTimeNow;
|
||||
if (diff.inHours < 1) return context.l10n.chatListTimeMinutes(diff.inMinutes);
|
||||
if (diff.inDays < 1) return DateFormat.Hm(locale).format(time);
|
||||
// Záporný rozdíl = hodiny odesílatele jdou napřed; „teď“ by lhalo.
|
||||
if (!diff.isNegative && diff.inMinutes < 1) {
|
||||
return context.l10n.chatListTimeNow;
|
||||
}
|
||||
if (!diff.isNegative && diff.inHours < 1) {
|
||||
return context.l10n.chatListTimeMinutes(diff.inMinutes);
|
||||
}
|
||||
final sameDay = now.year == time.year &&
|
||||
now.month == time.month &&
|
||||
now.day == time.day;
|
||||
if (sameDay) return DateFormat.Hm(locale).format(time);
|
||||
if (diff.inDays < 7) return DateFormat.E(locale).format(time);
|
||||
return DateFormat.Md(locale).format(time);
|
||||
}
|
||||
|
||||
@@ -27,44 +27,49 @@ class PushHintBanner extends ConsumerWidget {
|
||||
? context.l10n.pushHintFailed
|
||||
: context.l10n.pushHintOff;
|
||||
|
||||
return Material(
|
||||
color: SepotColors.surfaceVariant,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/settings'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 18,
|
||||
color: health == PushHealth.failed
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 2),
|
||||
child: Material(
|
||||
color: SepotColors.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: () => context.push('/settings'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 10, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.notifications_off_outlined,
|
||||
size: 18,
|
||||
color: health == PushHealth.failed
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 13,
|
||||
color: SepotColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
color: SepotColors.textMuted,
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(storageProvider)
|
||||
.setSetting('push_hint_dismissed', true);
|
||||
ref.invalidate(pushHintDismissedProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 18),
|
||||
color: SepotColors.textMuted,
|
||||
tooltip: context.l10n.commonClose,
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(storageProvider)
|
||||
.setSetting('push_hint_dismissed', true);
|
||||
ref.invalidate(pushHintDismissedProvider);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,8 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:sepot/core/l10n/error_messages.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/constants/app_constants.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
@@ -32,7 +34,8 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.contactsTitle),
|
||||
toolbarHeight: SepotTheme.largeTitleBarHeight,
|
||||
title: Text(context.l10n.contactsTitle, style: SepotTheme.largeTitle),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.group_add_outlined),
|
||||
@@ -72,6 +75,22 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
// Sdílet vlastní QR
|
||||
_buildShareCard(context),
|
||||
const Divider(height: 1),
|
||||
ListTile(
|
||||
leading: const NotesAvatar(size: 40),
|
||||
title: Text(context.l10n.notesTitle,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500)),
|
||||
subtitle: Text(context.l10n.notesSubtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: SepotColors.textSecondary)),
|
||||
onTap: () async {
|
||||
await ref.read(conversationsProvider.notifier).ensureNotes();
|
||||
if (context.mounted) {
|
||||
context.push(
|
||||
'/conversation/${AppConstants.notesConversationId}');
|
||||
}
|
||||
},
|
||||
),
|
||||
const Divider(height: 1),
|
||||
// Seznam kontaktů
|
||||
Expanded(
|
||||
child: contactsAsync.when(
|
||||
@@ -115,8 +134,16 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
final displayName = contact.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
final id = contact.publicIdentity.id;
|
||||
return ListTile(
|
||||
leading: SepotAvatar(name: displayName),
|
||||
leading: GestureDetector(
|
||||
onTap: () => context.push('/contact/$id'),
|
||||
child: SepotAvatar(
|
||||
name: displayName,
|
||||
seed: id,
|
||||
verified: contact.verified,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
displayName,
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
@@ -127,23 +154,15 @@ class _ContactsScreenState extends ConsumerState<ContactsScreen> {
|
||||
contact.sepotId,
|
||||
style: SepotTheme.cryptoText.copyWith(fontSize: 11),
|
||||
),
|
||||
if (contact.verified) ...[
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.verified,
|
||||
size: 14,
|
||||
color: SepotColors.ember,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
color: SepotColors.textMuted,
|
||||
trailing: IconButton(
|
||||
tooltip: context.l10n.chatOpenProfile,
|
||||
icon: const Icon(Icons.person_outline_rounded,
|
||||
color: SepotColors.textSecondary),
|
||||
onPressed: () => context.push('/contact/$id'),
|
||||
),
|
||||
onTap: () {
|
||||
context.push('/conversation/${contact.publicIdentity.id}');
|
||||
},
|
||||
onTap: () => context.push('/conversation/$id'),
|
||||
onLongPress: () =>
|
||||
_showContactOptions(context, contact.id),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
/// Akce nad konverzací, kontaktem a skupinou sdílené obrazovkou chatu
|
||||
/// a profily — ať se chování (potvrzení, hlášky, pořadí kroků) nerozejde.
|
||||
library;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.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/models/contact.dart';
|
||||
import 'package:sepot/models/conversation.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/storage_provider.dart';
|
||||
|
||||
String contactLabel(BuildContext context, Contact contact) =>
|
||||
contact.displayName.isEmpty
|
||||
? context.l10n.commonUnknownContact
|
||||
: contact.displayName;
|
||||
|
||||
void _snack(BuildContext context, String text) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(text)));
|
||||
}
|
||||
|
||||
/// Popisky voleb mizících zpráv (sekundy → text); -1 = vypnuto.
|
||||
List<(int, String)> disappearingOptions(BuildContext context) => [
|
||||
(-1, context.l10n.commonOff),
|
||||
(30, context.l10n.chatDisappearing30s),
|
||||
(300, context.l10n.chatDisappearing5m),
|
||||
(3600, context.l10n.chatDisappearing1h),
|
||||
(86400, context.l10n.chatDisappearing1d),
|
||||
(604800, context.l10n.chatDisappearing1w),
|
||||
];
|
||||
|
||||
/// Text aktuálního nastavení mizících zpráv („1 týden“ / „Vypnuto“).
|
||||
String disappearingLabel(BuildContext context, int? seconds) {
|
||||
final options = disappearingOptions(context);
|
||||
return options.where((o) => o.$1 == (seconds ?? -1)).firstOrNull?.$2 ??
|
||||
context.l10n.commonOff;
|
||||
}
|
||||
|
||||
/// Per-konverzace TTL mizících zpráv. Platí pro NOVĚ odeslané zprávy
|
||||
/// (TTL cestuje E2E kanálem, protistrana ho respektuje). Poctivá
|
||||
/// hranice: je to dohoda klientů, ne DRM — screenshot nezastavíš.
|
||||
Future<void> configureDisappearing(
|
||||
BuildContext context, WidgetRef ref, String convId) async {
|
||||
final storage = ref.read(storageProvider);
|
||||
final conv = await storage.loadConversation(convId) ??
|
||||
Conversation(contactId: convId);
|
||||
if (!context.mounted) return;
|
||||
|
||||
final options = disappearingOptions(context);
|
||||
final current = conv.disappearAfterSeconds ?? -1;
|
||||
|
||||
final picked = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SimpleDialog(
|
||||
title: Text(context.l10n.chatDisappearingMessages),
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 0, 24, 8),
|
||||
child: Text(
|
||||
context.l10n.chatDisappearingDescription,
|
||||
style:
|
||||
const TextStyle(fontSize: 13, color: SepotColors.textSecondary),
|
||||
),
|
||||
),
|
||||
for (final (value, label) in options)
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(ctx).pop(value),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
value == current
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_off,
|
||||
size: 18,
|
||||
color: value == current
|
||||
? SepotColors.ember
|
||||
: SepotColors.textMuted,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(label),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (picked == null) return;
|
||||
|
||||
conv.disappearAfterSeconds = picked == -1 ? null : picked;
|
||||
await storage.saveConversation(conv);
|
||||
ref.invalidate(conversationProvider(convId));
|
||||
ref.invalidate(conversationsProvider);
|
||||
if (context.mounted) {
|
||||
_snack(
|
||||
context,
|
||||
picked == -1
|
||||
? context.l10n.chatDisappearingDisabled
|
||||
: context.l10n.chatDisappearingEnabled(
|
||||
options.firstWhere((o) => o.$1 == picked).$2));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> setContactBlocked(
|
||||
BuildContext context, WidgetRef ref, Contact contact, bool blocked) async {
|
||||
await ref.read(contactsProvider.notifier).setBlocked(contact.id, blocked);
|
||||
if (!context.mounted) return;
|
||||
final jmeno = contactLabel(context, contact);
|
||||
_snack(
|
||||
context,
|
||||
blocked
|
||||
? context.l10n.chatContactBlocked(jmeno)
|
||||
: context.l10n.chatContactUnblocked(jmeno));
|
||||
}
|
||||
|
||||
/// Nahlásit a zablokovat: nejdřív blok (okamžitá ochrana), pak formulář.
|
||||
Future<void> reportAndBlock(
|
||||
BuildContext context, WidgetRef ref, Contact contact) async {
|
||||
final jmeno = contactLabel(context, contact);
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.reportAndBlock),
|
||||
content: Text(context.l10n.reportAndBlockBody(jmeno)),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: Text(context.l10n.reportAndBlock,
|
||||
style: const TextStyle(color: SepotColors.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true || !context.mounted) return;
|
||||
|
||||
await setContactBlocked(context, ref, contact, true);
|
||||
if (!context.mounted) return;
|
||||
final q = <String, String>{'contact': contact.id, 'name': contact.displayName};
|
||||
context.push(Uri(path: '/report', queryParameters: q).toString());
|
||||
}
|
||||
|
||||
Future<void> renameGroup(
|
||||
BuildContext context, WidgetRef ref, Group group) async {
|
||||
final controller = TextEditingController(text: group.name);
|
||||
final novy = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.groupsRename),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(hintText: context.l10n.groupsNameHint),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(controller.text.trim()),
|
||||
child: Text(context.l10n.commonSave),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
controller.dispose();
|
||||
if (novy == null || novy.isEmpty || novy == group.name) return;
|
||||
try {
|
||||
await ref.read(groupsProvider.notifier).rename(group, novy);
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_snack(context,
|
||||
context.l10n.groupsRenameFailed(describeError(context.l10n, e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Odebere člena skupiny (jen správce) po potvrzení.
|
||||
Future<void> removeGroupMember(BuildContext context, WidgetRef ref,
|
||||
Group group, String fingerprint, String name) async {
|
||||
final potvrzeno = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.groupsRemoveMemberConfirmTitle(name)),
|
||||
content: Text(
|
||||
context.l10n.groupsRemoveMemberConfirmBody,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: SepotColors.error),
|
||||
child: Text(context.l10n.groupsRemoveAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (potvrzeno != true || !context.mounted) return;
|
||||
await _adminAction(context,
|
||||
() => ref.read(groupsProvider.notifier).removeMember(group, fingerprint));
|
||||
}
|
||||
|
||||
/// Udělí / odebere roli správce (jen správce).
|
||||
Future<void> setGroupAdmin(BuildContext context, WidgetRef ref, Group group,
|
||||
String fingerprint, bool admin) =>
|
||||
_adminAction(
|
||||
context,
|
||||
() => ref
|
||||
.read(groupsProvider.notifier)
|
||||
.setAdmin(group, fingerprint, admin));
|
||||
|
||||
Future<void> _adminAction(
|
||||
BuildContext context, Future<void> Function() action) async {
|
||||
try {
|
||||
await action();
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_snack(context,
|
||||
context.l10n.groupsAdminActionFailed(describeError(context.l10n, e)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Vrací true, když uživatel skupinu opravdu opustil.
|
||||
Future<bool> leaveGroup(
|
||||
BuildContext context, WidgetRef ref, Group group) async {
|
||||
final potvrzeno = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: Text(context.l10n.groupsLeaveConfirmTitle),
|
||||
content: Text(
|
||||
context.l10n.groupsLeaveConfirmBody,
|
||||
style: const TextStyle(color: SepotColors.textSecondary),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(false),
|
||||
child: Text(context.l10n.commonCancel)),
|
||||
ElevatedButton(
|
||||
onPressed: () => Navigator.of(ctx).pop(true),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: SepotColors.error),
|
||||
child: Text(context.l10n.groupsLeaveAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (potvrzeno != true) return false;
|
||||
try {
|
||||
await ref.read(groupsProvider.notifier).leave(group);
|
||||
return true;
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
_snack(context,
|
||||
context.l10n.groupsLeaveFailed(describeError(context.l10n, e)));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,165 @@
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
/// Fullscreen prohlížeč obrázkové přílohy.
|
||||
/// Fotky konverzace přes celou obrazovku — listování, přiblížení, sdílení.
|
||||
///
|
||||
/// Plné rozlišení se dešifruje až tady, do PAMĚTI (na disk nikdy).
|
||||
/// FLAG_SECURE platí globálně, takže screenshot/škrcení obrazovky
|
||||
@@ -6,89 +6,302 @@
|
||||
/// (XFile.fromData — plaintext jen v cache sdílení).
|
||||
library;
|
||||
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/models/attachment.dart';
|
||||
import 'package:sepot/models/message.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
import 'package:sepot/providers/messaging_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/widgets/glass.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
class ImageViewerScreen extends ConsumerStatefulWidget {
|
||||
const ImageViewerScreen({super.key, required this.message});
|
||||
/// Fotky zprávy, které jde otevřít: stažené obrázky, ne smazané.
|
||||
bool isViewableImage(Message m) =>
|
||||
!m.isDeleted &&
|
||||
m.type == MessageType.image &&
|
||||
m.attachment?.state == AttachmentTransferState.ready;
|
||||
|
||||
final Message message;
|
||||
class ImageViewerScreen extends ConsumerStatefulWidget {
|
||||
const ImageViewerScreen({
|
||||
super.key,
|
||||
required this.images,
|
||||
this.initialIndex = 0,
|
||||
});
|
||||
|
||||
/// Fotky od nejstarší po nejnovější (pořadí jako v chatu).
|
||||
final List<Message> images;
|
||||
final int initialIndex;
|
||||
|
||||
/// Otevře fotku [message] a nabídne listování ostatními z [all].
|
||||
static Future<void> open(
|
||||
BuildContext context, Message message, List<Message> all) {
|
||||
final images = all.where(isViewableImage).toList();
|
||||
var index = images.indexWhere((m) => m.id == message.id);
|
||||
if (index < 0) {
|
||||
images
|
||||
..clear()
|
||||
..add(message);
|
||||
index = 0;
|
||||
}
|
||||
return Navigator.of(context).push(
|
||||
PageRouteBuilder<void>(
|
||||
opaque: false,
|
||||
pageBuilder: (_, _, _) =>
|
||||
ImageViewerScreen(images: images, initialIndex: index),
|
||||
transitionsBuilder: (_, anim, _, child) =>
|
||||
FadeTransition(opacity: anim, child: child),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
ConsumerState<ImageViewerScreen> createState() => _ImageViewerScreenState();
|
||||
}
|
||||
|
||||
class _ImageViewerScreenState extends ConsumerState<ImageViewerScreen> {
|
||||
late final Future<Uint8List?> _decrypted;
|
||||
late final _pages = PageController(initialPage: widget.initialIndex);
|
||||
late int _index = widget.initialIndex;
|
||||
final _decrypted = <String, Future<Uint8List?>>{};
|
||||
bool _chrome = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_decrypted = ref
|
||||
.read(messagingServiceProvider)
|
||||
.loadDecryptedAttachment(widget.message);
|
||||
void dispose() {
|
||||
_pages.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<Uint8List?> _load(Message m) => _decrypted.putIfAbsent(
|
||||
m.id,
|
||||
() => ref.read(messagingServiceProvider).loadDecryptedAttachment(m));
|
||||
|
||||
Message get _current => widget.images[_index];
|
||||
|
||||
Future<void> _share() async {
|
||||
final meta = widget.message.attachment!;
|
||||
final meta = _current.attachment!;
|
||||
final ext = meta.mime == 'image/png'
|
||||
? 'png'
|
||||
: meta.mime == 'image/gif'
|
||||
? 'gif'
|
||||
: 'jpg';
|
||||
final name = context.l10n.attachmentSharedImageFilename(ext);
|
||||
final bytes = await _decrypted;
|
||||
final bytes = await _load(_current);
|
||||
if (bytes == null) return;
|
||||
await Share.shareXFiles([
|
||||
XFile.fromData(bytes, name: name, mimeType: meta.mime),
|
||||
]);
|
||||
}
|
||||
|
||||
String _senderName(Message m) {
|
||||
final me = ref.read(identityProvider).valueOrNull;
|
||||
if (m.senderId == me?.id) return context.l10n.groupsYou;
|
||||
final contact = (ref.read(contactsProvider).valueOrNull ?? const [])
|
||||
.where((c) => c.publicIdentity.id == m.senderId)
|
||||
.firstOrNull;
|
||||
return contact?.displayName.isNotEmpty == true
|
||||
? contact!.displayName
|
||||
: context.l10n.commonUnknownContact;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
appBar: AppBar(
|
||||
final m = _current;
|
||||
final locale = Localizations.localeOf(context).toString();
|
||||
final when =
|
||||
'${DateFormat.MMMd(locale).format(m.timestamp)} ${DateFormat.Hm(locale).format(m.timestamp)}';
|
||||
final total = widget.images.length;
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle.light,
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.black,
|
||||
iconTheme: const IconThemeData(color: Colors.white),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share_outlined),
|
||||
onPressed: _share,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: FutureBuilder<Uint8List?>(
|
||||
future: _decrypted,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
);
|
||||
}
|
||||
final bytes = snapshot.data;
|
||||
if (snapshot.hasError || bytes == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
context.l10n.attachmentDecryptFailed,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _chrome = !_chrome),
|
||||
child: PageView.builder(
|
||||
controller: _pages,
|
||||
itemCount: total,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemBuilder: (context, i) => _Page(future: _load(widget.images[i])),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return InteractiveViewer(
|
||||
maxScale: 6,
|
||||
child: Center(
|
||||
child: Image.memory(bytes, fit: BoxFit.contain),
|
||||
),
|
||||
);
|
||||
},
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
top: _chrome ? 0 : -140,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: GlassSurface(
|
||||
border: false,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
4, MediaQuery.paddingOf(context).top + 6, 8, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: MaterialLocalizations.of(context)
|
||||
.closeButtonTooltip,
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
SepotAvatar(name: _senderName(m), seed: m.senderId, size: 34),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(_senderName(m),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
Text(
|
||||
total > 1
|
||||
? '$when · ${context.l10n.chatSearchCounter(_index + 1, total)}'
|
||||
: when,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: SepotColors.textEmphasis),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: context.l10n.commonShare,
|
||||
icon: const Icon(Icons.ios_share_rounded),
|
||||
onPressed: _share,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
AnimatedPositioned(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
bottom: _chrome ? 0 : -220,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: (m.content?.isNotEmpty == true || total > 1)
|
||||
? GlassSurface(
|
||||
border: false,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, 12, 16, MediaQuery.paddingOf(context).bottom + 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (m.content?.isNotEmpty == true)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(m.content!,
|
||||
style: const TextStyle(
|
||||
fontSize: 15, height: 1.4)),
|
||||
),
|
||||
if (total > 1)
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: total,
|
||||
separatorBuilder: (_, _) =>
|
||||
const SizedBox(width: 6),
|
||||
itemBuilder: (context, i) => _Thumb(
|
||||
message: widget.images[i],
|
||||
selected: i == _index,
|
||||
onTap: () => _pages.animateToPage(i,
|
||||
duration:
|
||||
const Duration(milliseconds: 250),
|
||||
curve: Curves.easeOutCubic),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Page extends StatelessWidget {
|
||||
const _Page({required this.future});
|
||||
|
||||
final Future<Uint8List?> future;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.connectionState != ConnectionState.done) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: SepotColors.ember),
|
||||
);
|
||||
}
|
||||
final bytes = snapshot.data;
|
||||
if (snapshot.hasError || bytes == null) {
|
||||
return Center(
|
||||
child: Text(
|
||||
context.l10n.attachmentDecryptFailed,
|
||||
style: const TextStyle(color: Colors.white70),
|
||||
),
|
||||
);
|
||||
}
|
||||
return InteractiveViewer(
|
||||
maxScale: 6,
|
||||
child: Center(child: Image.memory(bytes, fit: BoxFit.contain)),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Náhled v pásu dole — stačí malý náhled, který přišel uvnitř E2E zprávy.
|
||||
class _Thumb extends StatelessWidget {
|
||||
const _Thumb({
|
||||
required this.message,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
final Message message;
|
||||
final bool selected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thumb = message.attachment?.thumbnail;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 160),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: selected ? SepotColors.ember : Colors.transparent,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Opacity(
|
||||
opacity: selected ? 1 : 0.6,
|
||||
child: thumb != null
|
||||
? Image.memory(thumb, fit: BoxFit.cover, gaplessPlayback: true)
|
||||
: const ColoredBox(color: SepotColors.surfaceVariant),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,17 @@ import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/groups_provider.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
|
||||
/// Založení skupiny: jméno + výběr členů.
|
||||
/// Založení skupiny: jméno + výběr členů. S `addToGroupId`
|
||||
/// jde o přidání lidí do existující skupiny — bez jména, stávající členové
|
||||
/// v nabídce nejsou a strop počítá s nimi.
|
||||
///
|
||||
/// Vybrat jde jen kontakty s doručovací adresou (routing) — bez ní jim
|
||||
/// fanout nemá kam doručit. Kontakty bez routingu jsou v seznamu vidět,
|
||||
/// ale zašedlé s vysvětlením.
|
||||
class CreateGroupScreen extends ConsumerStatefulWidget {
|
||||
const CreateGroupScreen({super.key});
|
||||
const CreateGroupScreen({super.key, this.addToGroupId});
|
||||
|
||||
final String? addToGroupId;
|
||||
|
||||
@override
|
||||
ConsumerState<CreateGroupScreen> createState() => _CreateGroupScreenState();
|
||||
@@ -37,24 +41,32 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
c.publicIdentity.accountId != null &&
|
||||
c.publicIdentity.deliveryAddress != null;
|
||||
|
||||
Future<void> _create(List<Contact> contacts) async {
|
||||
Future<void> _submit(List<Contact> contacts, Group? existing) async {
|
||||
final name = _nameController.text.trim();
|
||||
if (name.isEmpty || _selected.isEmpty || _creating) return;
|
||||
if ((existing == null && name.isEmpty) || _selected.isEmpty || _creating) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _creating = true);
|
||||
final members = contacts.where((c) => _selected.contains(c.id)).toList();
|
||||
final groups = ref.read(groupsProvider.notifier);
|
||||
try {
|
||||
final members =
|
||||
contacts.where((c) => _selected.contains(c.id)).toList();
|
||||
final group =
|
||||
await ref.read(groupsProvider.notifier).createGroup(name, members);
|
||||
if (mounted) context.pushReplacement('/conversation/${group.id}');
|
||||
if (existing != null) {
|
||||
await groups.addMembers(existing, members);
|
||||
if (mounted) context.pop();
|
||||
} else {
|
||||
final group = await groups.createGroup(name, members);
|
||||
if (mounted) context.pushReplacement('/conversation/${group.id}');
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() => _creating = false);
|
||||
if (mounted) {
|
||||
final detail = describeError(context.l10n, e);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(context.l10n
|
||||
.groupsCreateFailed(describeError(context.l10n, e))),
|
||||
content: Text(existing != null
|
||||
? context.l10n.groupsAddMembersFailed(detail)
|
||||
: context.l10n.groupsCreateFailed(detail)),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -63,20 +75,30 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final adding = widget.addToGroupId != null;
|
||||
final existing = adding
|
||||
? ref.watch(groupProvider(widget.addToGroupId!)).valueOrNull
|
||||
: null;
|
||||
final contactsAsync = ref.watch(contactsProvider);
|
||||
final contacts = (contactsAsync.valueOrNull ?? [])
|
||||
.where((c) => !c.blocked)
|
||||
.where((c) => existing == null || !existing.memberIds.contains(c.id))
|
||||
.toList();
|
||||
final canCreate = _nameController.text.trim().isNotEmpty &&
|
||||
_selected.isNotEmpty &&
|
||||
!_creating;
|
||||
// Já jsem v memberIds existující skupiny; nová skupina mě přidá sama.
|
||||
final capacity = Group.maxMembers - (existing?.memberIds.length ?? 1);
|
||||
final canSubmit =
|
||||
(adding ? existing != null : _nameController.text.trim().isNotEmpty) &&
|
||||
_selected.isNotEmpty &&
|
||||
!_creating;
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.l10n.groupsNewGroupTitle),
|
||||
title: Text(adding
|
||||
? context.l10n.groupsAddMembers
|
||||
: context.l10n.groupsNewGroupTitle),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: canCreate ? () => _create(contacts) : null,
|
||||
onPressed: canSubmit ? () => _submit(contacts, existing) : null,
|
||||
child: _creating
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
@@ -84,33 +106,37 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: SepotColors.ember),
|
||||
)
|
||||
: Text(context.l10n.groupsCreateAction),
|
||||
: Text(adding
|
||||
? context.l10n.groupsAddMembersAction
|
||||
: context.l10n.groupsCreateAction),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.groupsNameHint,
|
||||
counterText: '',
|
||||
prefixIcon: const Icon(Icons.group_rounded,
|
||||
color: SepotColors.textMuted),
|
||||
if (adding)
|
||||
const SizedBox(height: 16)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _nameController,
|
||||
maxLength: 64,
|
||||
decoration: InputDecoration(
|
||||
labelText: context.l10n.groupsNameHint,
|
||||
counterText: '',
|
||||
prefixIcon: const Icon(Icons.group_rounded,
|
||||
color: SepotColors.textMuted),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
onChanged: (_) => setState(() {}),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
context.l10n.groupsMembersCounter(
|
||||
_selected.length, Group.maxMembers - 1),
|
||||
context.l10n.groupsMembersCounter(_selected.length, capacity),
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textSecondary, fontSize: 13),
|
||||
),
|
||||
@@ -124,7 +150,9 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Text(
|
||||
context.l10n.groupsEmptyContactsHint,
|
||||
adding
|
||||
? context.l10n.groupsNoContactsToAdd
|
||||
: context.l10n.groupsEmptyContactsHint,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: SepotColors.textSecondary),
|
||||
@@ -142,8 +170,7 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
onChanged: routable
|
||||
? (v) => setState(() {
|
||||
if (v == true) {
|
||||
if (_selected.length <
|
||||
Group.maxMembers - 1) {
|
||||
if (_selected.length < capacity) {
|
||||
_selected.add(c.id);
|
||||
}
|
||||
} else {
|
||||
@@ -151,7 +178,8 @@ class _CreateGroupScreenState extends ConsumerState<CreateGroupScreen> {
|
||||
}
|
||||
})
|
||||
: null,
|
||||
secondary: SepotAvatar(name: c.displayName, size: 40),
|
||||
secondary: SepotAvatar(
|
||||
name: c.displayName, seed: c.id, size: 40),
|
||||
title: Text(
|
||||
c.displayName,
|
||||
style: TextStyle(
|
||||
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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:image_picker/image_picker.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
@@ -15,6 +16,8 @@ import 'package:sepot/core/l10n/l10n.dart';
|
||||
import 'package:sepot/core/storage/secure_storage.dart';
|
||||
import 'package:sepot/models/identity.dart';
|
||||
import 'package:sepot/core/theme/app_theme.dart';
|
||||
import 'package:sepot/core/services/avatar_image.dart';
|
||||
import 'package:sepot/widgets/avatar.dart';
|
||||
import 'package:sepot/providers/contacts_provider.dart';
|
||||
import 'package:sepot/providers/conversations_provider.dart';
|
||||
import 'package:sepot/providers/identity_provider.dart';
|
||||
@@ -38,7 +41,8 @@ class SettingsScreen extends ConsumerWidget {
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(l10n.settingsTitle),
|
||||
toolbarHeight: SepotTheme.largeTitleBarHeight,
|
||||
title: Text(l10n.settingsTitle, style: SepotTheme.largeTitle),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
child: Column(
|
||||
@@ -47,7 +51,7 @@ class SettingsScreen extends ConsumerWidget {
|
||||
identityAsync.when(
|
||||
data: (identity) {
|
||||
if (identity == null) return const SizedBox.shrink();
|
||||
return _buildProfileCard(context, identity);
|
||||
return _buildProfileCard(context, ref, identity);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, _) => const SizedBox.shrink(),
|
||||
@@ -122,6 +126,19 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.more_horiz_rounded,
|
||||
title: l10n.settingsTypingTitle,
|
||||
subtitle: l10n.settingsTypingSubtitle,
|
||||
trailing: Switch(
|
||||
value:
|
||||
ref.watch(typingIndicatorsProvider).valueOrNull ?? false,
|
||||
onChanged: (v) =>
|
||||
ref.read(typingIndicatorsProvider.notifier).set(v),
|
||||
activeTrackColor: SepotColors.ember,
|
||||
),
|
||||
onTap: () {},
|
||||
),
|
||||
_buildTile(
|
||||
icon: Icons.screenshot_monitor_outlined,
|
||||
title: l10n.settingsScreenSecurityTitle,
|
||||
@@ -298,7 +315,69 @@ class SettingsScreen extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileCard(BuildContext context, LocalIdentity identity) {
|
||||
/// Moje profilová fotka: vybrat z galerie (oříznutí a zmenšení běží
|
||||
/// v izolátu), nebo odebrat. Protistrany ji dostanou s další zprávou.
|
||||
Future<void> _editAvatar(BuildContext context, WidgetRef ref) async {
|
||||
final service = ref.read(messagingServiceProvider);
|
||||
final hasAvatar = service.myAvatar != null;
|
||||
final action = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
backgroundColor: SepotColors.surface,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined,
|
||||
color: SepotColors.ember),
|
||||
title: Text(context.l10n.profilePhotoPick),
|
||||
onTap: () => Navigator.pop(ctx, 'pick'),
|
||||
),
|
||||
if (hasAvatar)
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_outline,
|
||||
color: SepotColors.error),
|
||||
title: Text(context.l10n.profilePhotoRemove,
|
||||
style: const TextStyle(color: SepotColors.error)),
|
||||
onTap: () => Navigator.pop(ctx, 'remove'),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 4, 20, 16),
|
||||
child: Text(context.l10n.profilePhotoHint,
|
||||
style: const TextStyle(
|
||||
fontSize: 12, color: SepotColors.textSecondary)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (action == null || !context.mounted) return;
|
||||
try {
|
||||
if (action == 'remove') {
|
||||
await service.removeMyAvatar();
|
||||
} else {
|
||||
final picked = await ImagePicker()
|
||||
.pickImage(source: ImageSource.gallery, maxWidth: 1600);
|
||||
if (picked == null) return;
|
||||
final jpeg = await prepareAvatarJpeg(await picked.readAsBytes());
|
||||
if (jpeg == null) throw const FormatException('unreadable image');
|
||||
await service.setMyAvatar(jpeg);
|
||||
}
|
||||
ref.invalidate(avatarProvider);
|
||||
} catch (e) {
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text(context.l10n
|
||||
.profilePhotoFailed(describeError(context.l10n, e))),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildProfileCard(
|
||||
BuildContext context, WidgetRef ref, LocalIdentity identity) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(20),
|
||||
@@ -309,18 +388,34 @@ class SettingsScreen extends ConsumerWidget {
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 36,
|
||||
backgroundColor: SepotColors.ember.withValues(alpha: 0.1),
|
||||
child: Text(
|
||||
identity.displayName.isEmpty
|
||||
? '?'
|
||||
: identity.displayName.characters.first.toUpperCase(),
|
||||
style: const TextStyle(
|
||||
fontFamily: kFontSans,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: SepotColors.ember,
|
||||
// Stejný tón jako „já“ v seznamu chatů a ve skupinách.
|
||||
Semantics(
|
||||
button: true,
|
||||
label: context.l10n.profilePhotoEdit,
|
||||
child: GestureDetector(
|
||||
onTap: () => _editAvatar(context, ref),
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
SepotAvatar(
|
||||
name: identity.displayName, seed: identity.id, size: 84),
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: SepotColors.ember,
|
||||
shape: BoxShape.circle,
|
||||
border:
|
||||
Border.all(color: SepotColors.surface, width: 3),
|
||||
),
|
||||
child: const Icon(Icons.photo_camera_rounded,
|
||||
size: 15, color: SepotColors.emberInk),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user