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);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user