From 61c2d0561c77cefb78677e61324e7f70d24d0788 Mon Sep 17 00:00:00 2001
From: RarDog
Date: Fri, 4 Sep 2026 18:19:58 +0300
Subject: [PATCH] feat: liquid glass bottom navigation bar with animated pill
and tap micro-animations (v3.0.7)
---
README.md | 2 +-
lib/app/app_version.dart | 4 +-
lib/app/changelog.dart | 10 +
lib/shared/widgets/app_shell.dart | 294 ++++++++++++++++++++++++++++--
pubspec.yaml | 2 +-
5 files changed, 292 insertions(+), 20 deletions(-)
diff --git a/README.md b/README.md
index 7f6bdde..6bd0b35 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@
-
+
diff --git a/lib/app/app_version.dart b/lib/app/app_version.dart
index 4ff7ae6..d2056c6 100644
--- a/lib/app/app_version.dart
+++ b/lib/app/app_version.dart
@@ -1,3 +1,3 @@
-const appDisplayVersion = '3.0.6';
-const appBuildNumber = 57;
+const appDisplayVersion = '3.0.7';
+const appBuildNumber = 58;
const appReleaseChannel = 'Stable';
diff --git a/lib/app/changelog.dart b/lib/app/changelog.dart
index d47c6b4..618ab99 100644
--- a/lib/app/changelog.dart
+++ b/lib/app/changelog.dart
@@ -1,4 +1,14 @@
const lunarisChangelog = [
+ LunarisChange(
+ version: '3.0.7',
+ title: 'Liquid Glass Navigation Bar — парящая стеклянная панель и анимированная пилюля',
+ bullets: [
+ 'Парящая панель Liquid Glass: нижняя панель навигации преобразована в парящий стеклянный док со скруглением, полупрозрачным градиентом и размытием фона (BackdropFilter blur).',
+ 'Плавающая пилюля-индикатор (Liquid Pill): плавный анимированный бегунок перетекает под активную вкладку со сглаженной пружинящей физикой (easeOutBack).',
+ 'Микроанимации нажатия и тактильный отклик: иконки мягко сжимаются при тапе и отпружинивают назад с легким тактильным виброоткликом (Haptic Feedback).',
+ 'Глубина контента: контент ленты, поиска и галереи плавно просвечивает и размывается под стеклянной панелью при прокрутке.',
+ ],
+ ),
LunarisChange(
version: '3.0.6',
title: 'Улучшение недавних запросов и отображения тегов',
diff --git a/lib/shared/widgets/app_shell.dart b/lib/shared/widgets/app_shell.dart
index bbce41c..a893372 100644
--- a/lib/shared/widgets/app_shell.dart
+++ b/lib/shared/widgets/app_shell.dart
@@ -1,3 +1,4 @@
+import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -281,29 +282,290 @@ class _MobileShell extends StatelessWidget {
? location == '/'
: location.startsWith(item.location),
);
+ final bottomInset = 76.0 + MediaQuery.paddingOf(context).bottom;
+
return Scaffold(
- body: child,
+ extendBody: true,
+ body: MediaQuery(
+ data: MediaQuery.of(context).copyWith(
+ padding: MediaQuery.of(context).padding.copyWith(
+ bottom: bottomInset,
+ ),
+ ),
+ child: child,
+ ),
floatingActionButton: location == '/settings'
? null
- : FloatingActionButton.small(
- heroTag: 'mobile-settings',
- tooltip: ru
- ? '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438'
- : 'Settings',
- onPressed: () => context.go('/settings'),
- child: const Icon(Icons.settings_rounded),
+ : Padding(
+ padding: const EdgeInsets.only(bottom: 64),
+ child: FloatingActionButton.small(
+ heroTag: 'mobile-settings',
+ tooltip: ru ? 'Настройки' : 'Settings',
+ onPressed: () => context.go('/settings'),
+ child: const Icon(Icons.settings_rounded),
+ ),
),
floatingActionButtonLocation: FloatingActionButtonLocation.endFloat,
- bottomNavigationBar: NavigationBar(
+ bottomNavigationBar: _LiquidGlassBottomBar(
selectedIndex: selected < 0 ? 0 : selected,
onDestinationSelected: (index) => context.go(items[index].location),
- destinations: [
- for (final item in items)
- NavigationDestination(
- icon: Icon(item.icon),
- label: item.mobileLabelFor(ru),
- ),
- ],
+ items: items,
+ ru: ru,
+ ),
+ );
+ }
+}
+
+class _LiquidGlassBottomBar extends StatefulWidget {
+ const _LiquidGlassBottomBar({
+ required this.selectedIndex,
+ required this.onDestinationSelected,
+ required this.items,
+ required this.ru,
+ });
+
+ final int selectedIndex;
+ final ValueChanged onDestinationSelected;
+ final List<_Destination> items;
+ final bool ru;
+
+ @override
+ State<_LiquidGlassBottomBar> createState() => _LiquidGlassBottomBarState();
+}
+
+class _LiquidGlassBottomBarState extends State<_LiquidGlassBottomBar> {
+ int? _pressedIndex;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final isDark = theme.brightness == Brightness.dark;
+ final scheme = theme.colorScheme;
+ final count = widget.items.length;
+ if (count == 0) return const SizedBox.shrink();
+
+ return SafeArea(
+ top: false,
+ left: false,
+ right: false,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(14, 0, 14, 10),
+ child: LayoutBuilder(
+ builder: (context, constraints) {
+ final barWidth = constraints.maxWidth;
+ final itemWidth = barWidth / count;
+ final pillWidth = (itemWidth - 6).clamp(36.0, 64.0);
+ final pillLeft =
+ widget.selectedIndex * itemWidth + (itemWidth - pillWidth) / 2;
+
+ return Container(
+ height: 64,
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(28),
+ boxShadow: [
+ BoxShadow(
+ color: Colors.black.withValues(alpha: isDark ? 0.38 : 0.09),
+ blurRadius: 22,
+ offset: const Offset(0, 8),
+ spreadRadius: -2,
+ ),
+ if (isDark)
+ BoxShadow(
+ color: scheme.primary.withValues(alpha: 0.07),
+ blurRadius: 18,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ child: ClipRRect(
+ borderRadius: BorderRadius.circular(28),
+ child: BackdropFilter(
+ filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
+ child: Container(
+ decoration: BoxDecoration(
+ borderRadius: BorderRadius.circular(28),
+ gradient: LinearGradient(
+ begin: Alignment.topCenter,
+ end: Alignment.bottomCenter,
+ colors: isDark
+ ? [
+ scheme.surfaceContainerHigh
+ .withValues(alpha: 0.70),
+ scheme.surface.withValues(alpha: 0.82),
+ ]
+ : [
+ Colors.white.withValues(alpha: 0.84),
+ Colors.white.withValues(alpha: 0.68),
+ ],
+ ),
+ border: Border.all(
+ color: isDark
+ ? Colors.white.withValues(alpha: 0.16)
+ : Colors.white.withValues(alpha: 0.75),
+ width: 1.2,
+ ),
+ ),
+ child: Stack(
+ alignment: Alignment.centerLeft,
+ children: [
+ // Animated sliding liquid pill indicator
+ AnimatedPositioned(
+ duration: const Duration(milliseconds: 300),
+ curve: Curves.easeOutBack,
+ left: pillLeft,
+ top: 7,
+ child: Container(
+ width: pillWidth,
+ height: 32,
+ decoration: BoxDecoration(
+ color: scheme.primaryContainer.withValues(
+ alpha: isDark ? 0.75 : 0.88,
+ ),
+ borderRadius: BorderRadius.circular(16),
+ border: Border.all(
+ color: scheme.primary.withValues(
+ alpha: isDark ? 0.35 : 0.22,
+ ),
+ width: 1,
+ ),
+ boxShadow: [
+ BoxShadow(
+ color: scheme.primary.withValues(
+ alpha: isDark ? 0.22 : 0.12,
+ ),
+ blurRadius: 10,
+ offset: const Offset(0, 2),
+ ),
+ ],
+ ),
+ ),
+ ),
+
+ // Navigation item buttons
+ Row(
+ children: [
+ for (var i = 0; i < count; i++)
+ Expanded(
+ child: _LiquidNavItem(
+ destination: widget.items[i],
+ label: widget.items[i]
+ .mobileLabelFor(widget.ru),
+ isSelected: widget.selectedIndex == i,
+ isPressed: _pressedIndex == i,
+ onTapDown: () =>
+ setState(() => _pressedIndex = i),
+ onTapUp: () =>
+ setState(() => _pressedIndex = null),
+ onTapCancel: () =>
+ setState(() => _pressedIndex = null),
+ onTap: () {
+ HapticFeedback.lightImpact();
+ widget.onDestinationSelected(i);
+ },
+ ),
+ ),
+ ],
+ ),
+ ],
+ ),
+ ),
+ ),
+ ),
+ );
+ },
+ ),
+ ),
+ );
+ }
+}
+
+class _LiquidNavItem extends StatelessWidget {
+ const _LiquidNavItem({
+ required this.destination,
+ required this.label,
+ required this.isSelected,
+ required this.isPressed,
+ required this.onTapDown,
+ required this.onTapUp,
+ required this.onTapCancel,
+ required this.onTap,
+ });
+
+ final _Destination destination;
+ final String label;
+ final bool isSelected;
+ final bool isPressed;
+ final VoidCallback onTapDown;
+ final VoidCallback onTapUp;
+ final VoidCallback onTapCancel;
+ final VoidCallback onTap;
+
+ @override
+ Widget build(BuildContext context) {
+ final theme = Theme.of(context);
+ final scheme = theme.colorScheme;
+ final isDark = theme.brightness == Brightness.dark;
+
+ final targetScale = isPressed ? 0.86 : (isSelected ? 1.05 : 1.0);
+
+ return GestureDetector(
+ behavior: HitTestBehavior.opaque,
+ onTapDown: (_) => onTapDown(),
+ onTapUp: (_) => onTapUp(),
+ onTapCancel: onTapCancel,
+ onTap: onTap,
+ child: Center(
+ child: AnimatedScale(
+ scale: targetScale,
+ duration: const Duration(milliseconds: 180),
+ curve: Curves.easeOutCubic,
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ mainAxisAlignment: MainAxisAlignment.center,
+ children: [
+ SizedBox(
+ height: 32,
+ child: Center(
+ child: AnimatedSwitcher(
+ duration: const Duration(milliseconds: 200),
+ transitionBuilder: (child, anim) => ScaleTransition(
+ scale: anim,
+ child: child,
+ ),
+ child: Icon(
+ destination.icon,
+ key: ValueKey('${destination.id}_$isSelected'),
+ size: isSelected ? 22 : 20,
+ color: isSelected
+ ? scheme.onPrimaryContainer
+ : (isDark
+ ? scheme.onSurfaceVariant
+ .withValues(alpha: 0.78)
+ : scheme.onSurfaceVariant
+ .withValues(alpha: 0.85)),
+ ),
+ ),
+ ),
+ ),
+ const SizedBox(height: 2),
+ AnimatedDefaultTextStyle(
+ duration: const Duration(milliseconds: 200),
+ style: TextStyle(
+ fontSize: 10.5,
+ fontWeight:
+ isSelected ? FontWeight.w700 : FontWeight.w500,
+ color: isSelected
+ ? (isDark ? scheme.primary : scheme.onSurface)
+ : scheme.onSurfaceVariant.withValues(alpha: 0.75),
+ letterSpacing: -0.2,
+ ),
+ maxLines: 1,
+ overflow: TextOverflow.ellipsis,
+ child: Text(label),
+ ),
+ ],
+ ),
+ ),
),
);
}
diff --git a/pubspec.yaml b/pubspec.yaml
index b6e510c..1299158 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
name: gel_rule_app
description: Local backend/core layer for a Flutter booru browser app.
publish_to: "none"
-version: 3.0.6+57
+version: 3.0.7+58
environment:
sdk: ">=3.3.0 <4.0.0"