Files
cls 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
feat: import Chinese-localized Buzz source snapshot
Signed-off-by: cls_宁波本机 <908705107@qq.com>
2026-08-13 18:34:25 +08:00

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,
),
),
),
),
),
],
),
),
),
),
),
),
);
}
}