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:
Julius Joska
2026-10-03 22:44:53 +02:00
parent 933a74c900
commit 5246a97db2
56 changed files with 12028 additions and 1668 deletions
+614 -133
View File
@@ -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(),
],
),
);
}
}
+179 -81
View File
@@ -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);
},
),
],
),
),
),
),