Release IKAR 1.0.7: port navigation and sharing improvements, harden calls
Tests / test (push) Canceled after 0s
Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Native crypto / native-crypto (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s

This commit is contained in:
sevenhill
2026-10-08 20:38:54 +03:00
parent 68f7957043
commit 317cfdae38
37 changed files with 1483 additions and 282 deletions
+5 -1
View File
@@ -322,7 +322,11 @@ class _CallScreenState extends State<CallScreen> with TickerProviderStateMixin {
void _onState(CallSessionState state) {
if (!mounted) return;
setState(() => _state = state);
if (state == CallSessionState.ended) _close();
if (state == CallSessionState.ended) {
final failure = _session?.failureReason;
if (failure != null) showCustomNotification(context, failure);
_close();
}
}
Future<void> _accept() async {
@@ -202,12 +202,13 @@ class ChatController extends ChangeNotifier {
Future<bool> loadMessageWindow({
required String targetId,
required int targetTime,
bool Function()? stillWanted,
}) async {
if (myId == 0 || targetTime <= 0) return false;
final onlyVisible = !QlyraSettings.viewDeleted.value;
var window = await loadWindowFromDb(targetTime, onlyVisible);
if (!isMounted()) return false;
if (!isMounted() || stillWanted?.call() == false) return false;
if (!window.any((m) => m.id == targetId)) {
final fetched = await messagesModule.fetchHistory(
@@ -217,12 +218,12 @@ class ChatController extends ChangeNotifier {
forward: jumpWindowAfter,
backward: jumpWindowBefore + 1,
);
if (!isMounted()) return false;
if (!isMounted() || stillWanted?.call() == false) return false;
if (fetched.isNotEmpty && QlyraSettings.viewDeleted.value) {
await chats.reconcileDeletedFromFetch(myId, chatId, fetched);
}
window = await loadWindowFromDb(targetTime, onlyVisible);
if (!isMounted()) return false;
if (!isMounted() || stillWanted?.call() == false) return false;
}
if (window.isEmpty) return false;
+117 -84
View File
@@ -31,6 +31,8 @@ import '../../widgets/sheet_helpers.dart';
import '../../widgets/swipe_route.dart';
import '../../widgets/toast_placement.dart';
import '../../widgets/sliding_pill_nav.dart';
import '../../widgets/compact_bottom_navigation_bar.dart';
import '../../../core/config/app_bottom_navigation_style.dart';
import '../../widgets/springy_tap.dart';
import '../../widgets/visible_page_tickers.dart';
import '../../widgets/informer_banner_tile.dart';
@@ -2472,92 +2474,123 @@ class _ChatListScreenState extends State<ChatListScreen>
return best;
}
return AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: 8,
right: 8,
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
child: RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (_) {
if (_isSelectionMode) return;
_navPageAnimController.stop();
_navPageAnimController.value = 1.0;
_navDragDx.value = 0;
setState(() {
_navDragging = true;
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
});
},
onHorizontalDragUpdate: (details) {
if (!_navDragging) return;
_navDragDx.value += details.delta.dx;
},
onHorizontalDragEnd: (_) {
if (!_navDragging) return;
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
minBubbleLeft,
maxBubbleLeft,
);
final next = indexForBubbleLeft(left);
_navDragDx.value = 0;
setState(() {
_currentNavIndex = next;
_navDragging = false;
});
activeNavTab.value = next;
if (next == 0) _scheduleInformerPresentation();
},
onHorizontalDragCancel: () {
if (!_navDragging) return;
_navDragDx.value = 0;
setState(() {
_navDragging = false;
});
},
child: ValueListenableBuilder<double>(
valueListenable: _navDragDx,
builder: (context, navDragDx, _) {
final position = _navDragging
? ((_navDragBaseLeft + navDragDx).clamp(
minBubbleLeft,
maxBubbleLeft,
) -
4) /
inactiveWidth
: _currentNavIndex.toDouble();
return SlidingPillNav(
items: [
PillNavItem(
icon: _chatsNavItems(
AppLocalizations.of(context)!,
).first.icon,
label: _chatsNavItems(
AppLocalizations.of(context)!,
).first.label,
unreadCount: countUnreadChats(_folderSourceChats),
),
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
],
position: position,
animationDuration: _navDragging
? Duration.zero
: const Duration(milliseconds: 350),
geometry: geometry,
iconSize: 20,
labelGap: 4,
backdropKey: _frostBackdrop,
onTap: _onNavTabSelected,
onItemLongPress: (index, pos) {
if (index == 3) _openAccountSwitcher(pos);
return ValueListenableBuilder<BottomNavigationStyle>(
valueListenable: AppBottomNavigationStyle.current,
builder: (context, style, _) {
if (style == BottomNavigationStyle.compact) {
return Positioned(
left: 0,
right: 0,
bottom: _isSelectionMode ? -150 : 0,
child: CompactBottomNavigationBar(
items: [
PillNavItem(
icon: _chatsNavItems(
AppLocalizations.of(context)!,
).first.icon,
label: _chatsNavItems(
AppLocalizations.of(context)!,
).first.label,
unreadCount: countUnreadChats(_folderSourceChats),
),
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
],
currentIndex: _currentNavIndex,
onTap: _onNavTabSelected,
onItemLongPress: (index, position) {
if (index == 3) _openAccountSwitcher(position);
},
),
);
}
return AnimatedPositioned(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOutCubic,
left: 8,
right: 8,
bottom: _isSelectionMode ? -100 : bottomInset + 10.0,
child: RepaintBoundary(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (_) {
if (_isSelectionMode) return;
_navPageAnimController.stop();
_navPageAnimController.value = 1.0;
_navDragDx.value = 0;
setState(() {
_navDragging = true;
_navDragBaseLeft = bubbleLeftForIndex(_currentNavIndex);
});
},
onHorizontalDragUpdate: (details) {
if (!_navDragging) return;
_navDragDx.value += details.delta.dx;
},
onHorizontalDragEnd: (_) {
if (!_navDragging) return;
final left = (_navDragBaseLeft + _navDragDx.value).clamp(
minBubbleLeft,
maxBubbleLeft,
);
final next = indexForBubbleLeft(left);
_navDragDx.value = 0;
setState(() {
_currentNavIndex = next;
_navDragging = false;
});
activeNavTab.value = next;
if (next == 0) _scheduleInformerPresentation();
},
onHorizontalDragCancel: () {
if (!_navDragging) return;
_navDragDx.value = 0;
setState(() {
_navDragging = false;
});
},
child: ValueListenableBuilder<double>(
valueListenable: _navDragDx,
builder: (context, navDragDx, _) {
final position = _navDragging
? ((_navDragBaseLeft + navDragDx).clamp(
minBubbleLeft,
maxBubbleLeft,
) -
4) /
inactiveWidth
: _currentNavIndex.toDouble();
return SlidingPillNav(
items: [
PillNavItem(
icon: _chatsNavItems(
AppLocalizations.of(context)!,
).first.icon,
label: _chatsNavItems(
AppLocalizations.of(context)!,
).first.label,
unreadCount: countUnreadChats(_folderSourceChats),
),
..._chatsNavItems(AppLocalizations.of(context)!).skip(1),
],
position: position,
animationDuration: _navDragging
? Duration.zero
: const Duration(milliseconds: 350),
geometry: geometry,
iconSize: 20,
labelGap: 4,
backdropKey: _frostBackdrop,
onTap: _onNavTabSelected,
onItemLongPress: (index, pos) {
if (index == 3) _openAccountSwitcher(pos);
},
);
},
);
},
),
),
),
),
),
);
},
);
}
+137 -68
View File
@@ -1,3 +1,4 @@
import '../../../backend/modules/pending_message_deletions.dart';
import 'package:ikar/frontend/widgets/toast_placement.dart';
import 'package:ikar/frontend/widgets/hint_bubble.dart';
import 'chat/view/message_row_widgets.dart' show SelectableMessageRow;
@@ -916,6 +917,7 @@ class _ChatScreenState extends State<ChatScreen>
unawaited(_loadPeerKind());
unawaited(_loadWallpaper());
unawaited(_loadReactionSettings());
PendingMessageDeletions.instance.addListener(_pendingRemovalsChanged);
_forwardedSub = ForwardSender.delivered.listen(_onForwardedHere);
_departedSub = chats.departedChats.listen((id) {
if (!mounted || id != widget.chatId) return;
@@ -2016,11 +2018,17 @@ class _ChatScreenState extends State<ChatScreen>
return true;
}
Future<void> _loadMessageWindow(String messageId, int targetTime) async {
Future<void> _loadMessageWindow(
String messageId,
int targetTime, {
bool Function()? stillWanted,
}) async {
if (targetTime <= 0) {
await _walkHistoryBack(
reached: () => _messages.any((m) => m.id == messageId),
maxPages: 10,
reached: () =>
stillWanted?.call() == false ||
_messages.any((m) => m.id == messageId),
maxPages: 100,
);
return;
}
@@ -2030,6 +2038,7 @@ class _ChatScreenState extends State<ChatScreen>
await _chatController.loadMessageWindow(
targetId: messageId,
targetTime: targetTime,
stillWanted: stillWanted,
);
} finally {
_historyAutoloadSuppressCount--;
@@ -2424,6 +2433,7 @@ class _ChatScreenState extends State<ChatScreen>
_detachUploadStatus();
_pushSub?.cancel();
_reactionUpdatesSub?.cancel();
PendingMessageDeletions.instance.removeListener(_pendingRemovalsChanged);
_forwardedSub?.cancel();
_departedSub?.cancel();
_messageEventSub?.cancel();
@@ -2762,39 +2772,70 @@ class _ChatScreenState extends State<ChatScreen>
if (forEveryone == null || !mounted) return;
_clearSelection();
for (final message in msgs.where((m) => m.id.startsWith('temp_'))) {
_startDeleteAnimation(message.id);
}
_removeMessagesWithUndo(serverMsgs, forEveryone: forEveryone);
}
void _pendingRemovalsChanged() {
if (mounted) _bumpMessages();
}
void _removeMessagesWithUndo(
List<CachedMessage> messages, {
required bool forEveryone,
}) {
final accountId = _myId;
final chatId = widget.chatId;
final ids = messages.map((message) => message.id).toList();
final pending = PendingMessageDeletions.instance;
if (ids.any((id) => pending.contains(accountId, chatId, id))) return;
pending.hold(accountId, chatId, ids);
final overlay = Overlay.of(context, rootOverlay: true);
void restore() => pending.release(accountId, chatId, ids);
showUndoNotification(
context,
'Удаление сообщений через 5 секунд',
onUndo: () {},
messages.length == 1 ? 'Сообщение удалено' : 'Сообщения удалены',
onUndo: restore,
onCommit: () async {
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
final ok = await messagesModule.deleteMessages(
widget.chatId,
serverMsgs.map((m) => m.id).toList(),
forEveryone: forEveryone,
);
if (ok) {
for (final message in serverMsgs) {
await AppDatabase.deleteMessage(
accountId,
widget.chatId,
message.id,
try {
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
final ok = await messagesModule.deleteMessages(
chatId,
ids,
forEveryone: forEveryone,
);
if (!ok) {
if (overlay.mounted) {
showCustomNotificationOnOverlay(
overlay,
'Не удалось удалить сообщения',
);
}
return;
}
for (final id in ids) {
await AppDatabase.deleteMessage(accountId, chatId, id);
}
MessageSessionCache.remove(accountId, chatId);
await chats.reconcileLastMessage(accountId, chatId);
if (mounted && _myId == accountId) {
_messages.removeWhere((message) => ids.contains(message.id));
for (final id in ids) {
_reactionNotifiers.remove(id)?.dispose();
}
}
} catch (e) {
if (overlay.mounted) {
showCustomNotificationOnOverlay(
overlay,
'Не удалось удалить сообщения',
);
}
MessageSessionCache.remove(accountId, widget.chatId);
await chats.reconcileLastMessage(accountId, widget.chatId);
} finally {
restore();
}
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(context, 'Не удалось удалить сообщения');
return;
}
for (final m in msgs) {
_startDeleteAnimation(m.id);
}
_clearSelection();
},
);
}
@@ -3240,6 +3281,11 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<void> _confirmDeleteMessage(String messageId, bool isMe) async {
final messageIndex = _messages.indexWhere(
(message) => message.id == messageId,
);
if (messageIndex == -1) return;
final message = _messages[messageIndex];
final isLocalOnly = messageId.startsWith('temp_');
final canForEveryone = (isMe || _canDeleteOthers) && !isLocalOnly;
@@ -3251,30 +3297,7 @@ class _ChatScreenState extends State<ChatScreen>
final forEveryone = await _showDeleteMessageDialog(canForEveryone);
if (forEveryone == null || !mounted) return;
final accountId = _myId;
showUndoNotification(
context,
'Удаление сообщения через 5 секунд',
onUndo: () {},
onCommit: () async {
if ((await AppDatabase.loadActiveProfile())?.id != accountId) return;
final ok = await messagesModule.deleteMessages(widget.chatId, [
messageId,
], forEveryone: forEveryone);
if (ok) {
await AppDatabase.deleteMessage(accountId, widget.chatId, messageId);
MessageSessionCache.remove(accountId, widget.chatId);
await chats.reconcileLastMessage(accountId, widget.chatId);
}
if (!mounted) return;
if (!ok) {
Haptics.error();
showCustomNotification(context, 'Не удалось удалить сообщение');
return;
}
_startDeleteAnimation(messageId);
},
);
_removeMessagesWithUndo([message], forEveryone: forEveryone);
}
void _startDeleteAnimation(String messageId) {
@@ -4915,18 +4938,45 @@ class _ChatScreenState extends State<ChatScreen>
);
}
void _jumpToMessage(String messageId, {String? fromId}) {
final index = _messages.indexWhere((m) => m.id == messageId);
if (index == -1) {
showCustomNotification(context, 'Сообщение не загружено');
return;
}
int _messageNavigationToken = 0;
Future<void> _jumpToMessage(String messageId, {String? fromId}) async {
final token = ++_messageNavigationToken;
final gesture = _userGestureEpoch;
bool current() =>
mounted &&
token == _messageNavigationToken &&
gesture == _userGestureEpoch;
if (fromId != null) _pushReturnAnchor(fromId);
if (!_messages.any((message) => message.id == messageId)) {
try {
final target = await messagesModule.fetchMessage(
_myId,
widget.chatId,
messageId,
);
if (!mounted || !current()) return;
if (target == null) {
showCustomNotification(context, 'Сообщение не найдено');
return;
}
await _loadMessageWindow(messageId, target.time, stillWanted: current);
} catch (e) {
if (mounted && current()) {
showCustomNotification(context, 'Не удалось загрузить сообщение');
}
return;
}
if (!mounted || !current()) return;
if (!_messages.any((message) => message.id == messageId)) {
showCustomNotification(context, 'Сообщение не найдено');
return;
}
}
final key = _keyForMessage(messageId);
final ctx = key.currentContext;
if (ctx != null) {
if (ctx != null && ctx.mounted) {
Scrollable.ensureVisible(
ctx,
duration: const Duration(milliseconds: 300),
@@ -4934,7 +4984,12 @@ class _ChatScreenState extends State<ChatScreen>
alignment: 0.4,
);
} else {
unawaited(_scrollToMessagePrecise(messageId, alignment: 0.4));
await _scrollToMessagePrecise(
messageId,
alignment: 0.4,
stillWanted: current,
);
if (!mounted || !current()) return;
}
_highlightTimer?.cancel();
@@ -4986,14 +5041,20 @@ class _ChatScreenState extends State<ChatScreen>
}
Future<void> _runGoToMessage(String id, int targetTime) async {
final token = ++_messageNavigationToken;
final epoch = _userGestureEpoch;
bool current() =>
mounted &&
token == _messageNavigationToken &&
epoch == _userGestureEpoch;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
if (!current()) return;
if (!_messages.any((m) => m.id == id)) {
await _loadMessageWindow(id, targetTime);
if (!mounted) return;
await _loadMessageWindow(id, targetTime, stillWanted: current);
if (!current()) return;
await WidgetsBinding.instance.endOfFrame;
if (!mounted) return;
if (!current()) return;
}
if (!_messages.any((m) => m.id == id)) {
@@ -5009,8 +5070,8 @@ class _ChatScreenState extends State<ChatScreen>
if (_highlightMessageId.value == id) _highlightMessageId.value = null;
});
await _scrollToMessagePrecise(id);
_finishTargetNavigation();
await _scrollToMessagePrecise(id, stillWanted: current);
if (token == _messageNavigationToken && mounted) _finishTargetNavigation();
}
({int min, int max})? _laidOutMessageRange(List<Object> items) {
@@ -5034,6 +5095,7 @@ class _ChatScreenState extends State<ChatScreen>
Future<void> _scrollToMessagePrecise(
String id, {
double alignment = 0.32,
bool Function()? stillWanted,
}) async {
if (!mounted || !_scrollController.hasClients) return;
if (_messages.indexWhere((m) => m.id == id) == -1) return;
@@ -5050,7 +5112,7 @@ class _ChatScreenState extends State<ChatScreen>
var stable = 0;
for (var iter = 0; iter < 120; iter++) {
if (!mounted || !_scrollController.hasClients) return;
if (_userGestureEpoch != epoch) return;
if (_userGestureEpoch != epoch || stillWanted?.call() == false) return;
final listObj = _listKey.currentContext?.findRenderObject();
final boxObj = _keyForMessage(id).currentContext?.findRenderObject();
final p = _scrollController.position;
@@ -5382,6 +5444,13 @@ class _ChatScreenState extends State<ChatScreen>
for (int i = 0; i < visible; i++) {
final msg = _messages[i];
if (PendingMessageDeletions.instance.contains(
_myId,
widget.chatId,
msg.id,
)) {
continue;
}
final msgDate = DateTime.fromMillisecondsSinceEpoch(msg.time);
final dayMillis = DateTime(
msgDate.year,
@@ -21,6 +21,7 @@ import '../../../l10n/app_localizations.dart';
import '../../../main.dart';
import '../../widgets/liquid_glass.dart';
import '../../widgets/settings_card.dart';
import '../../widgets/bottom_navigation_style_card.dart';
import '../../../core/config/app_shape.dart';
import '../../../core/utils/seed_scheme.dart';
@@ -124,6 +125,8 @@ class _AppearanceScreenState extends State<AppearanceScreen> {
const SizedBox(height: 12),
const _VisualStyleCard(),
const SizedBox(height: 12),
const BottomNavigationStyleCard(),
const SizedBox(height: 12),
const _ChatChromeCard(),
const SizedBox(height: 12),
const _ComposerBarCard(),
@@ -27,21 +27,52 @@ import '../../share_unopenable_file.dart';
import '../../upload_progress_ring.dart';
import 'bubble_context.dart';
class FileBubble extends StatelessWidget {
static const double _previewWidth = 240;
static const double _previewHeight = 160;
typedef FileBubbleOpener =
Future<FileDownloadResult> Function(
String cacheName,
Future<String?> Function() resolveUrl, {
void Function(double)? onProgress,
void Function()? onReady,
DownloadMetadata? download,
bool downloadOnlyIfMissing,
});
class FileBubble extends StatefulWidget {
final BubbleContext ctx;
final FileAttachment file;
final bool fill;
final FileBubbleOpener openFile;
const FileBubble({
super.key,
required this.ctx,
required this.file,
this.fill = false,
this.openFile = openCachedFile,
});
@override
State<FileBubble> createState() => _FileBubbleState();
}
class _FileBubbleState extends State<FileBubble> {
static const double _previewWidth = 240;
static const double _previewHeight = 160;
BubbleContext get ctx => widget.ctx;
FileAttachment get file => widget.file;
bool get fill => widget.fill;
bool _opening = false;
Future<void> _openOnce(Future<void> Function() open) async {
if (_opening || ctx.uploadProgress != null) return;
setState(() => _opening = true);
try {
await open();
} finally {
if (mounted) setState(() => _opening = false);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
@@ -124,7 +155,11 @@ class FileBubble extends StatelessWidget {
builder: (context, progress, _) => Text(
progress != null
? '${(progress * 100).round()}% · $sizeStr'
: sizeStr,
: '${_opening
? 'Открытие…'
: MediaCache.presence(cacheName).value
? 'Открыть'
: 'Скачать'} · $sizeStr',
style: TextStyle(
color: ctx.dim,
fontSize: 12,
@@ -144,7 +179,7 @@ class FileBubble extends StatelessWidget {
: ctx.cs.primary;
Widget circle(Widget child, VoidCallback? onTap) {
return GestureDetector(
onTap: onTap,
onTap: _opening ? null : onTap,
child: Container(
width: 34,
height: 34,
@@ -159,13 +194,15 @@ class FileBubble extends StatelessWidget {
);
}
if (progress != null) {
if (progress != null || _opening) {
return circle(
Padding(
padding: const EdgeInsets.all(8),
child: CircularProgressIndicator(
strokeWidth: 2,
value: progress > 0 ? progress : null,
value: progress != null && progress > 0
? progress
: null,
color: iconColor,
),
),
@@ -389,7 +426,7 @@ class FileBubble extends StatelessWidget {
);
}
Future<void> _openInViewer(
Future<void> _openInViewerInner(
BuildContext context,
String name,
String cacheName,
@@ -498,6 +535,22 @@ class FileBubble extends StatelessWidget {
BuildContext context,
FileAttachment file,
String name,
) => _openOnce(() => _downloadFileInner(context, file, name));
Future<void> _playAudioFile(
BuildContext context,
FileAttachment file,
String name,
) => _openOnce(() => _playAudioFileInner(context, file, name));
Future<void> _openInViewer(
BuildContext context,
String name,
String cacheName,
) => _openOnce(() => _openInViewerInner(context, name, cacheName));
Future<void> _downloadFileInner(
BuildContext context,
FileAttachment file,
String name,
) async {
final fileId = file.fileId;
if (fileId == null) {
@@ -514,7 +567,7 @@ class FileBubble extends StatelessWidget {
final kind = downloadKindForName(name);
MediaDownloadProgress.set(cacheName, 0);
final result = await openCachedFile(
final result = await widget.openFile(
cacheName,
_fileUrl,
downloadOnlyIfMissing: true,
@@ -553,7 +606,7 @@ class FileBubble extends StatelessWidget {
}
}
Future<void> _playAudioFile(
Future<void> _playAudioFileInner(
BuildContext context,
FileAttachment file,
String name,
@@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import '../../core/config/app_bottom_navigation_style.dart';
import '../../core/utils/haptics.dart';
import '../../l10n/app_localizations.dart';
import 'settings_card.dart';
class BottomNavigationStyleCard extends StatelessWidget {
const BottomNavigationStyleCard({super.key});
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final l10n = AppLocalizations.of(context)!;
return SettingsPanel(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.appearanceBottomNavigationTitle,
style: TextStyle(
color: cs.onSurface,
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
l10n.appearanceBottomNavigationSubtitle,
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 13),
),
const SizedBox(height: 16),
ValueListenableBuilder<BottomNavigationStyle>(
valueListenable: AppBottomNavigationStyle.current,
builder: (context, current, _) =>
SegmentedButton<BottomNavigationStyle>(
showSelectedIcon: false,
segments: [
ButtonSegment(
value: BottomNavigationStyle.floating,
label: Text(l10n.appearanceBottomNavigationFloating),
),
ButtonSegment(
value: BottomNavigationStyle.compact,
label: Text(l10n.appearanceBottomNavigationCompact),
),
],
selected: {current},
onSelectionChanged: (selection) {
Haptics.selection();
AppBottomNavigationStyle.save(selection.first);
},
),
),
],
),
);
}
}
@@ -0,0 +1,152 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'sliding_pill_nav.dart';
class CompactBottomNavigationBar extends StatelessWidget {
const CompactBottomNavigationBar({
super.key,
required this.items,
required this.currentIndex,
required this.onTap,
this.onItemLongPress,
});
final List<PillNavItem> items;
final int currentIndex;
final ValueChanged<int> onTap;
final void Function(int index, Offset position)? onItemLongPress;
static double contentHeight(BuildContext context) =>
math.max(50, 34 + MediaQuery.textScalerOf(context).scale(10) * 1.2);
@override
Widget build(BuildContext context) {
if (MediaQuery.viewInsetsOf(context).bottom > 0) {
return const SizedBox.shrink();
}
final cs = Theme.of(context).colorScheme;
return DecoratedBox(
decoration: BoxDecoration(
color: cs.surface,
border: Border(top: BorderSide(color: cs.outlineVariant, width: 0.5)),
),
child: SafeArea(
top: false,
child: SizedBox(
height: contentHeight(context),
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: _CompactTab(
item: items[index],
selected: index == currentIndex,
onTap: () => onTap(index),
onLongPress:
items[index].longPressable && onItemLongPress != null
? (position) => onItemLongPress!(index, position)
: null,
),
),
],
),
),
),
);
}
}
class _CompactTab extends StatefulWidget {
const _CompactTab({
required this.item,
required this.selected,
required this.onTap,
this.onLongPress,
});
final PillNavItem item;
final bool selected;
final VoidCallback onTap;
final ValueChanged<Offset>? onLongPress;
@override
State<_CompactTab> createState() => _CompactTabState();
}
class _CompactTabState extends State<_CompactTab> {
bool _pressed = false;
void _setPressed(bool value) {
if (_pressed != value) setState(() => _pressed = value);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
final color = widget.selected ? cs.primary : cs.onSurfaceVariant;
return Semantics(
button: true,
selected: widget.selected,
label: widget.item.label,
onTap: widget.onTap,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTapDown: (_) => _setPressed(true),
onTapUp: (_) => _setPressed(false),
onTapCancel: () => _setPressed(false),
onTap: widget.onTap,
onLongPressStart: widget.onLongPress == null
? null
: (details) {
_setPressed(false);
widget.onLongPress!(details.globalPosition);
},
child: ExcludeSemantics(
child: AnimatedOpacity(
opacity: _pressed ? 0.55 : 1,
duration: const Duration(milliseconds: 100),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Badge(
isLabelVisible: widget.item.unreadCount > 0,
label: Text(
widget.item.unreadCount > 99
? '99+'
: '${widget.item.unreadCount}',
),
child: Icon(
widget.item.icon,
color: color,
size: 24,
fill: widget.selected ? 1 : 0,
weight: widget.selected ? 500 : 400,
),
),
const SizedBox(height: 2),
Text(
widget.item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: color,
fontSize: 10,
height: 1.2,
fontWeight: FontWeight.w500,
),
),
],
),
),
),
),
),
);
}
}