9dfa06ffee
Docker image / Build (linux/amd64) (push) Has been cancelled
Docker image / Build (linux/arm64) (push) Has been cancelled
Docker image / Merge release multi-arch manifest (push) Has been cancelled
Docker image / Merge debug multi-arch manifest (push) Has been cancelled
Docker image / Build public push gateway (linux/amd64) (push) Has been cancelled
Docker image / Build public push gateway (linux/arm64) (push) Has been cancelled
Docker image / Publish public push gateway image (push) Has been cancelled
Sprig image / Build (linux/amd64) (push) Has been cancelled
Sprig image / Build (linux/arm64) (push) Has been cancelled
Sprig image / Merge multi-arch manifest (push) Has been cancelled
Harbor Buzz Orchestra / Python tests and lint (push) Has been cancelled
CI / Detect Changed Paths (push) Has been cancelled
CI / Rust Lint (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Desktop Core (push) Has been cancelled
CI / Desktop Smoke E2E (1) (push) Has been cancelled
CI / Desktop Smoke E2E (2) (push) Has been cancelled
CI / Desktop Smoke E2E (3) (push) Has been cancelled
CI / Desktop Smoke E2E (4) (push) Has been cancelled
CI / Desktop (push) Has been cancelled
CI / Desktop E2E Relay (push) Has been cancelled
CI / Desktop E2E Integration (1/2) (push) Has been cancelled
CI / Desktop E2E Integration (2/2) (push) Has been cancelled
CI / Desktop E2E Integration (push) Has been cancelled
CI / Backend Integration (relay e2e) (push) Has been cancelled
CI / Relay E2E (push) Has been cancelled
CI / Web (push) Has been cancelled
CI / Mobile (push) Has been cancelled
CI / Security (push) Has been cancelled
CI / Dead Token Reference Guard (push) Has been cancelled
CI / Server Cross-Compile (aarch64-unknown-linux-musl) (push) Has been cancelled
CI / Server Cross-Compile (x86_64-unknown-linux-musl) (push) Has been cancelled
CI / Windows Rust (x86_64-pc-windows-msvc) (push) Has been cancelled
CI / Desktop Build (macOS) (push) Has been cancelled
helm chart / lint + unittest + render matrix (push) Has been cancelled
helm chart / install on kind (gated) (push) Has been cancelled
helm chart / publish chart to GHCR (push) Has been cancelled
Mesh Lifecycle / Relay-Driven Mesh Lifecycle Smoke (push) Has been cancelled
Sprig / Build (aarch64-unknown-linux-musl) (push) Has been cancelled
Sprig / Build (x86_64-unknown-linux-musl) (push) Has been cancelled
Sprig / Publish rolling release (push) Has been cancelled
Sprig / Publish tagged release (push) Has been cancelled
Signed-off-by: cls_宁波本机 <908705107@qq.com>
516 lines
19 KiB
Dart
516 lines
19 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
|
|
|
import '../../shared/theme/theme.dart';
|
|
import '../../shared/l10n/l10n.dart';
|
|
import '../../shared/widgets/directional_transition_scope.dart';
|
|
import '../../shared/widgets/mobile_tab_footer_backdrop.dart';
|
|
import '../activity/activity_page.dart';
|
|
import '../channels/channels_page.dart';
|
|
import '../search/search_page.dart';
|
|
|
|
class HomePage extends HookConsumerWidget {
|
|
const HomePage({
|
|
required this.settingsPageBuilder,
|
|
required this.hasUnreadInbox,
|
|
super.key,
|
|
});
|
|
|
|
final WidgetBuilder settingsPageBuilder;
|
|
final bool hasUnreadInbox;
|
|
|
|
static const double _tabBarHeight = mobileTabBarHeight;
|
|
static const double _tabBarRadius = _tabBarHeight / 2;
|
|
static const double _tabBarInnerInset = Grid.half;
|
|
static const double _selectedTabRadius =
|
|
(_tabBarHeight - (_tabBarInnerInset * 2)) / 2;
|
|
static const double _tabBarBottomGap = mobileTabBarBottomGap;
|
|
static const double _tabBarHorizontalMargin = Grid.gutter;
|
|
static const double _tabDestinationHorizontalPadding = Grid.sm;
|
|
static const double _tabIconSize = 22;
|
|
static const double _fabClearance = _tabBarHeight + _tabBarBottomGap;
|
|
static const Duration _tabIconWeightDuration = Duration(milliseconds: 120);
|
|
static const Duration _tabUnreadBadgeDuration = Duration(milliseconds: 220);
|
|
static const double _settingsBackgroundScale = 0.97;
|
|
static const Duration _tabContentTransitionDuration = Duration(
|
|
milliseconds: 240,
|
|
);
|
|
static const Curve _tabContentTransitionCurve = Cubic(0.22, 1, 0.36, 1);
|
|
static const double _tabContentTransitionDistance = 24;
|
|
|
|
@override
|
|
Widget build(BuildContext context, WidgetRef ref) {
|
|
final l10n = context.l10n;
|
|
final destinations = [
|
|
_HomeDestination(
|
|
icon: LucideIcons.house300,
|
|
selectedIcon: LucideIcons.house500,
|
|
label: l10n.navHome,
|
|
),
|
|
_HomeDestination(
|
|
icon: LucideIcons.inbox300,
|
|
selectedIcon: LucideIcons.inbox500,
|
|
label: l10n.navActivity,
|
|
),
|
|
_HomeDestination(
|
|
icon: LucideIcons.search300,
|
|
selectedIcon: LucideIcons.search500,
|
|
label: l10n.navSearch,
|
|
),
|
|
];
|
|
final tabIndex = useState(0);
|
|
final tabContentTransitionDirection = useRef(1.0);
|
|
final tabContentTransitionController = useAnimationController(
|
|
duration: _tabContentTransitionDuration,
|
|
initialValue: 1,
|
|
);
|
|
final tabContentTransitionValue = useAnimation(
|
|
tabContentTransitionController,
|
|
);
|
|
final homeReselection = useValueNotifier(0);
|
|
final activityReselection = useValueNotifier(0);
|
|
final searchReselection = useValueNotifier(0);
|
|
final settingsTransitionProgress = useValueNotifier(0.0);
|
|
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
|
final tabContentTransitionProgress = reducedMotion
|
|
? 1.0
|
|
: _tabContentTransitionCurve.transform(tabContentTransitionValue);
|
|
final systemBottomInset = MediaQuery.paddingOf(context).bottom;
|
|
final navigationBarWidth = _floatingTabBarWidth(
|
|
MediaQuery.sizeOf(context).width,
|
|
destinations.length,
|
|
);
|
|
|
|
final pages = [
|
|
ChannelsPage(
|
|
settingsPageBuilder: settingsPageBuilder,
|
|
tabReselection: homeReselection,
|
|
onSettingsTransitionProgress: (progress) {
|
|
if (settingsTransitionProgress.value != progress) {
|
|
settingsTransitionProgress.value = progress;
|
|
}
|
|
},
|
|
),
|
|
ActivityPage(tabReselection: activityReselection),
|
|
SearchPage(tabReselection: searchReselection),
|
|
];
|
|
|
|
final settingsTransitionGradient = tabIndex.value == 0
|
|
? context.appColors.topSectionGradient
|
|
: null;
|
|
|
|
return Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Positioned.fill(
|
|
child: DecoratedBox(
|
|
key: const ValueKey('home-settings-transition-backdrop'),
|
|
decoration: BoxDecoration(
|
|
color: settingsTransitionGradient == null
|
|
? context.colors.surface
|
|
: null,
|
|
gradient: settingsTransitionGradient,
|
|
),
|
|
),
|
|
),
|
|
ValueListenableBuilder<double>(
|
|
valueListenable: settingsTransitionProgress,
|
|
child: Scaffold(
|
|
backgroundColor: Colors.transparent,
|
|
// Keep the floating navigation and Home quick actions anchored while the
|
|
// keyboard is visible on any tab.
|
|
resizeToAvoidBottomInset: false,
|
|
extendBody: true,
|
|
body: SizedBox.expand(
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
Positioned.fill(
|
|
child: ColoredBox(color: context.colors.surface),
|
|
),
|
|
Positioned.fill(
|
|
child: MediaQuery(
|
|
data: _mediaQueryWithFloatingTabBarClearance(
|
|
context,
|
|
HomePage._fabClearance,
|
|
),
|
|
child: DirectionalTransitionScope(
|
|
horizontalOffset:
|
|
tabContentTransitionDirection.value *
|
|
_tabContentTransitionDistance *
|
|
(1 - tabContentTransitionProgress),
|
|
opacity: tabContentTransitionProgress,
|
|
child: ClipRect(
|
|
child: IndexedStack(
|
|
index: tabIndex.value,
|
|
children: pages,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Align(
|
|
alignment: Alignment.bottomCenter,
|
|
child: IgnorePointer(
|
|
child: MobileTabFooterBackdrop(
|
|
height: mobileTabFooterBackdropHeight(context),
|
|
tint: context.colors.primaryContainer,
|
|
),
|
|
),
|
|
),
|
|
Positioned.fill(
|
|
child: ChannelQuickActionsLauncher(
|
|
visible: tabIndex.value == 0,
|
|
navigationBarHeight: HomePage._tabBarHeight,
|
|
navigationBarBottomGap: HomePage._tabBarBottomGap,
|
|
navigationBarWidth: navigationBarWidth,
|
|
systemBottomInset: systemBottomInset,
|
|
rightInset: Grid.sm,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
bottomNavigationBar: _FloatingTabBar(
|
|
selectedIndex: tabIndex.value,
|
|
hasUnreadInbox: hasUnreadInbox,
|
|
onDestinationSelected: (i) {
|
|
if (i == tabIndex.value) {
|
|
switch (i) {
|
|
case 0:
|
|
homeReselection.value++;
|
|
case 1:
|
|
activityReselection.value++;
|
|
case 2:
|
|
searchReselection.value++;
|
|
}
|
|
return;
|
|
}
|
|
tabContentTransitionDirection.value = i > tabIndex.value
|
|
? 1
|
|
: -1;
|
|
unawaited(HapticFeedback.selectionClick());
|
|
tabIndex.value = i;
|
|
if (reducedMotion) {
|
|
tabContentTransitionController.value = 1;
|
|
} else {
|
|
unawaited(tabContentTransitionController.forward(from: 0));
|
|
}
|
|
},
|
|
destinations: destinations,
|
|
),
|
|
),
|
|
builder: (context, progress, child) {
|
|
final curvedProgress = reducedMotion
|
|
? 0.0
|
|
: Curves.easeOutCubic.transform(progress);
|
|
return Opacity(
|
|
key: const ValueKey('home-settings-transition-opacity'),
|
|
opacity: 1 - curvedProgress,
|
|
child: Transform.scale(
|
|
key: const ValueKey('home-settings-transition-scale'),
|
|
scale: lerpDouble(1, _settingsBackgroundScale, curvedProgress),
|
|
alignment: Alignment.center,
|
|
child: child,
|
|
),
|
|
);
|
|
},
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
double _floatingTabDestinationWidth(double screenWidth, int destinationCount) {
|
|
final preferredDestinationWidth =
|
|
HomePage._tabIconSize + (HomePage._tabDestinationHorizontalPadding * 2);
|
|
final availableInnerWidth =
|
|
screenWidth -
|
|
(HomePage._tabBarHorizontalMargin * 2) -
|
|
(HomePage._tabBarInnerInset * 2);
|
|
return preferredDestinationWidth
|
|
.clamp(0.0, availableInnerWidth / destinationCount)
|
|
.toDouble();
|
|
}
|
|
|
|
double _floatingTabBarWidth(double screenWidth, int destinationCount) {
|
|
if (destinationCount <= 0) return 0;
|
|
return (_floatingTabDestinationWidth(screenWidth, destinationCount) *
|
|
destinationCount) +
|
|
(HomePage._tabBarInnerInset * 2);
|
|
}
|
|
|
|
MediaQueryData _mediaQueryWithFloatingTabBarClearance(
|
|
BuildContext context,
|
|
double clearance,
|
|
) {
|
|
final mediaQuery = MediaQuery.of(context);
|
|
return mediaQuery.copyWith(
|
|
padding: mediaQuery.padding.copyWith(
|
|
bottom: mediaQuery.padding.bottom + clearance,
|
|
),
|
|
viewPadding: mediaQuery.viewPadding.copyWith(
|
|
bottom: mediaQuery.viewPadding.bottom + clearance,
|
|
),
|
|
);
|
|
}
|
|
|
|
class _HomeDestination {
|
|
final IconData icon;
|
|
final IconData selectedIcon;
|
|
final String label;
|
|
|
|
const _HomeDestination({
|
|
required this.icon,
|
|
required this.selectedIcon,
|
|
required this.label,
|
|
});
|
|
}
|
|
|
|
class _FloatingTabBar extends StatelessWidget {
|
|
final int selectedIndex;
|
|
final bool hasUnreadInbox;
|
|
final ValueChanged<int> onDestinationSelected;
|
|
final List<_HomeDestination> destinations;
|
|
|
|
const _FloatingTabBar({
|
|
required this.selectedIndex,
|
|
required this.hasUnreadInbox,
|
|
required this.onDestinationSelected,
|
|
required this.destinations,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = context.colors;
|
|
final isDark = context.theme.brightness == Brightness.dark;
|
|
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
|
if (destinations.isEmpty) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final destinationCount = destinations.length;
|
|
final safeSelectedIndex = selectedIndex
|
|
.clamp(0, destinationCount - 1)
|
|
.toInt();
|
|
final selectedAlignment = destinationCount <= 1
|
|
? Alignment.center
|
|
: Alignment(-1 + (2 * safeSelectedIndex / (destinationCount - 1)), 0);
|
|
|
|
final destinationWidth = _floatingTabDestinationWidth(
|
|
MediaQuery.sizeOf(context).width,
|
|
destinationCount,
|
|
);
|
|
|
|
return SafeArea(
|
|
minimum: const EdgeInsets.fromLTRB(
|
|
HomePage._tabBarHorizontalMargin,
|
|
0,
|
|
HomePage._tabBarHorizontalMargin,
|
|
HomePage._tabBarBottomGap,
|
|
),
|
|
child: Align(
|
|
alignment: Alignment.bottomCenter,
|
|
heightFactor: 1,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: colorScheme.shadow.withValues(alpha: 0.10),
|
|
blurRadius: 20,
|
|
offset: const Offset(0, 8),
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
|
|
child: BackdropFilter(
|
|
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
|
|
color: isDark
|
|
? colorScheme.surfaceContainerHighest.withValues(
|
|
alpha: 0.72,
|
|
)
|
|
: colorScheme.surface,
|
|
border: Border.all(
|
|
color: colorScheme.outlineVariant.withValues(
|
|
alpha: isDark ? 0.20 : 0.38,
|
|
),
|
|
),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(HomePage._tabBarInnerInset),
|
|
child: SizedBox(
|
|
height:
|
|
HomePage._tabBarHeight -
|
|
(HomePage._tabBarInnerInset * 2),
|
|
width: destinationWidth * destinationCount,
|
|
child: Stack(
|
|
children: [
|
|
AnimatedAlign(
|
|
alignment: selectedAlignment,
|
|
duration: reducedMotion
|
|
? Duration.zero
|
|
: const Duration(milliseconds: 180),
|
|
curve: Curves.easeOutCubic,
|
|
child: SizedBox(
|
|
width: destinationWidth,
|
|
height: double.infinity,
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primaryContainer,
|
|
borderRadius: BorderRadius.circular(
|
|
HomePage._selectedTabRadius,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
for (var i = 0; i < destinations.length; i++)
|
|
SizedBox(
|
|
width: destinationWidth,
|
|
child: _FloatingTabDestination(
|
|
destination: destinations[i],
|
|
selected: i == safeSelectedIndex,
|
|
showUnreadBadge: i == 1 && hasUnreadInbox,
|
|
onTap: () => onDestinationSelected(i),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _FloatingTabDestination extends StatelessWidget {
|
|
final _HomeDestination destination;
|
|
final bool selected;
|
|
final bool showUnreadBadge;
|
|
final VoidCallback onTap;
|
|
|
|
const _FloatingTabDestination({
|
|
required this.destination,
|
|
required this.selected,
|
|
required this.showUnreadBadge,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final colorScheme = context.colors;
|
|
final isDark = context.theme.brightness == Brightness.dark;
|
|
final reducedMotion = MediaQuery.of(context).disableAnimations;
|
|
final foregroundColor = selected
|
|
? colorScheme.onPrimaryContainer
|
|
: colorScheme.onSurfaceVariant;
|
|
final icon = selected ? destination.selectedIcon : destination.icon;
|
|
final badgeOutlineColor = selected
|
|
? colorScheme.primaryContainer
|
|
: (isDark ? colorScheme.surfaceContainerHighest : colorScheme.surface);
|
|
|
|
final showVisibleUnreadBadge = showUnreadBadge && !selected;
|
|
|
|
return Semantics(
|
|
button: true,
|
|
selected: selected,
|
|
label: showVisibleUnreadBadge
|
|
? context.l10n.navUnread(destination.label)
|
|
: destination.label,
|
|
child: Tooltip(
|
|
message: destination.label,
|
|
excludeFromSemantics: true,
|
|
child: Material(
|
|
color: Colors.transparent,
|
|
clipBehavior: Clip.antiAlias,
|
|
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
|
|
child: InkWell(
|
|
onTap: onTap,
|
|
overlayColor: const WidgetStatePropertyAll<Color>(
|
|
Colors.transparent,
|
|
),
|
|
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
|
|
child: Center(
|
|
child: SizedBox(
|
|
width: HomePage._tabIconSize + 8,
|
|
height: HomePage._tabIconSize + 8,
|
|
child: Stack(
|
|
clipBehavior: Clip.none,
|
|
children: [
|
|
Center(
|
|
child: AnimatedSwitcher(
|
|
duration: reducedMotion
|
|
? Duration.zero
|
|
: HomePage._tabIconWeightDuration,
|
|
switchInCurve: Curves.easeOutCubic,
|
|
switchOutCurve: Curves.easeOutCubic,
|
|
transitionBuilder: (child, animation) =>
|
|
FadeTransition(opacity: animation, child: child),
|
|
child: Icon(
|
|
icon,
|
|
key: ValueKey('${destination.label}-$icon'),
|
|
color: foregroundColor,
|
|
size: HomePage._tabIconSize,
|
|
),
|
|
),
|
|
),
|
|
if (showUnreadBadge)
|
|
Positioned(
|
|
top: 0,
|
|
right: 0,
|
|
child: AnimatedScale(
|
|
key: const ValueKey('activity-tab-unread-dot-scale'),
|
|
scale: selected ? 0 : 1,
|
|
alignment: const Alignment(-0.5, 0.5),
|
|
duration: reducedMotion
|
|
? Duration.zero
|
|
: HomePage._tabUnreadBadgeDuration,
|
|
curve: Curves.easeOutCubic,
|
|
child: Container(
|
|
key: const ValueKey('activity-tab-unread-dot'),
|
|
width: 12,
|
|
height: 12,
|
|
padding: const EdgeInsets.all(2),
|
|
decoration: BoxDecoration(
|
|
color: badgeOutlineColor,
|
|
shape: BoxShape.circle,
|
|
),
|
|
child: DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
color: colorScheme.primary,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|