import 'dart:async'; import 'dart:io'; import 'dart:math' show max, min, pi; import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter/physics.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/auth/auth.dart'; import '../../shared/community/community_icon_provider.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; import '../../shared/widgets/avatar_image.dart'; import '../../shared/widgets/anchored_popover_menu.dart'; import '../../shared/widgets/bee_refresh_indicator.dart'; import '../../shared/widgets/buzz_loading_indicator.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../../shared/widgets/modal_presentation.dart'; import '../../shared/widgets/skeleton.dart'; import '../../shared/custom_emoji/custom_emoji.dart'; import '../../shared/custom_emoji/custom_emoji_provider.dart'; import '../../shared/custom_emoji/custom_emoji_render.dart'; import '../../shared/l10n/l10n.dart'; import '../profile/profile_avatar.dart'; import '../profile/profile_provider.dart'; import '../profile/presence_cache_provider.dart'; import '../profile/user_cache_provider.dart'; import '../pairing/pairing_page.dart'; import '../pairing/pairing_provider.dart'; import 'channel.dart'; import 'channel_actions_sheet.dart'; import 'channel_detail_page.dart'; import 'channel_management_provider.dart'; import 'dm_channel_labels.dart'; import 'ephemeral_channel_display.dart'; import 'channel_mutes/channel_mutes_provider.dart'; import 'channel_sections/channel_sections_provider.dart'; import 'channel_sections/channel_sections_storage.dart'; import 'channel_sort/channel_sort_provider.dart'; import 'channel_sort/channel_sort_storage.dart'; import 'channel_stars/channel_stars_provider.dart'; import 'channels_provider.dart'; import '../../shared/read_state/deferred_read_state_update.dart'; import '../../shared/read_state/read_state_format.dart'; import '../../shared/read_state/read_state_provider.dart'; import '../../shared/read_state/read_state_time.dart'; import 'unread_badge/observed_unread_event.dart'; part 'channels_page/body.dart'; part 'channels_page/sections.dart'; part 'channels_page/channel_tile.dart'; part 'channels_page/sheets.dart'; part 'channels_page/badges.dart'; part 'channels_page/skeleton.dart'; part 'channels_page/community.dart'; part 'channels_page/quick_actions.dart'; part 'channels_page/quick_actions_launcher.dart'; enum _QuickAction { createChannel, newDm } const double _kChannelSectionInset = Grid.gutter; const double _kChannelLeadingWidth = 22.0; const double _kChannelIconSize = 18.0; const double _kChannelLabelGap = Grid.xxs; const double _kChannelRowVerticalPadding = Grid.xxs + Grid.quarter; const double _kSectionSpacingTightening = Grid.half; const double _kSectionHeaderVerticalPadding = _kChannelRowVerticalPadding - _kSectionSpacingTightening; // Section headers include touch targets for their actions, so their visual // centre sits lower than a channel row's. This keeps an expanded section's // final row equally spaced from the following divider. const double _kExpandedSectionTrailingPadding = 11.0 - _kSectionSpacingTightening; const double _kChannelLabelInset = _kChannelSectionInset + _kChannelLeadingWidth + _kChannelLabelGap; /// DM avatars are circles, so they fill their box edge to edge where a channel /// glyph leaves 4dp of slack inside the same 22dp leading column. Sizing them to /// the glyph's ink width keeps the icon-to-label distance identical across both /// sections while the labels stay on [_kChannelLabelInset]. const double _kDmAvatarSize = _kChannelIconSize; const double _kTopSectionAvatarSize = 40.0; const double _kTopSectionBottomPadding = Grid.xxs; /// The top section's avatars are 40dp circles, which fill their box edge to /// edge; the channel rows below lead with an 18dp glyph left-aligned in a 22dp /// box at [_kChannelSectionInset]. Edge-aligning the two leaves the circles /// looking pushed outward, so the bar is pulled in to sit the avatar's centre /// near the channel-icon column. const double _kTopSectionInset = Grid.twelve; const Duration _kSectionExpandDuration = Duration(milliseconds: 220); const Duration _kSectionCollapseDuration = Duration(milliseconds: 170); const Curve _kSectionExpandCurve = Cubic(0.23, 1, 0.32, 1); const Curve _kSectionCollapseCurve = Curves.easeInCubic; const double _kSectionCollapsedScaleY = 0.98; const double _kHeaderFrostScrollDistance = Grid.xxl; const double _kHeaderFrostMaxBlurSigma = 23.12; class _UnreadChannelState { final Set ids; const _UnreadChannelState({required this.ids}); } _UnreadChannelState _computeUnreadChannelState({ required Iterable channels, required ReadStateState readState, required ChannelsNotifier channelsNotifier, }) { if (!readState.isReady) { return const _UnreadChannelState(ids: {}); } final latestObservedByChannel = channelsNotifier.latestObservedByChannel; final observedEventsByChannel = channelsNotifier.observedUnreadEventsByChannel; final ids = {}; for (final channel in channels) { if (readState.locallyForcedChannelIds.contains(channel.id)) { ids.add(channel.id); continue; } final latestObserved = latestObservedByChannel[channel.id]; if (latestObserved == null) continue; final channelReadAt = readState.effectiveTimestamp(channel.id); if (channelReadAt != null && latestObserved <= channelReadAt) continue; final observedEvents = observedEventsByChannel[channel.id]; int? readAtForObservedEvent(ObservedUnreadEvent event) => observedUnreadEventReadAt( event, channelReadAt, (rootId) => readState.effectiveTimestamp(threadContextKey(rootId)), (messageId) => readState.effectiveTimestamp(msgContextKey(messageId)), ); final unreadCount = countUnreadObservedEvents( observedEvents, readAtForObservedEvent, ); if (unreadCount == 0) continue; ids.add(channel.id); } return _UnreadChannelState(ids: ids); } class ChannelsPage extends HookConsumerWidget { const ChannelsPage({ required this.settingsPageBuilder, required this.onSettingsTransitionProgress, this.tabReselection, super.key, }); final WidgetBuilder settingsPageBuilder; /// Reports the Settings route's raw animation progress from 0 to 1. final ValueChanged onSettingsTransitionProgress; /// Notifies this page when its already-selected tab is tapped again. final ValueListenable? tabReselection; @override Widget build(BuildContext context, WidgetRef ref) { final channelsAsync = ref.watch(channelsProvider); final sessionState = ref.watch(relaySessionProvider); final currentPubkey = ref .watch(profileProvider) .whenData((value) => value?.pubkey) .value; final headerTitleStyle = context.textTheme.titleMedium?.copyWith( fontSize: 22, fontWeight: FontWeight.w600, color: navigationPrimaryForeground(context), ); final topSectionHeight = frostedAppBarHeight( context, titleStyle: headerTitleStyle, bottomHeight: _kTopSectionBottomPadding, ); final channelsScrollController = useScrollController(); final reducedMotion = MediaQuery.disableAnimationsOf(context); final headerFrostProgress = useState(0.0); useEffect(() { void updateHeaderTreatment() { final nextProgress = !channelsScrollController.hasClients ? 0.0 : (channelsScrollController.offset / _kHeaderFrostScrollDistance) .clamp(0.0, 1.0) .toDouble(); if ((headerFrostProgress.value - nextProgress).abs() > 0.001) { headerFrostProgress.value = nextProgress; } } channelsScrollController.addListener(updateHeaderTreatment); return () => channelsScrollController.removeListener(updateHeaderTreatment); }, [channelsScrollController]); useEffect(() { final tabReselection = this.tabReselection; if (tabReselection == null) return null; void scrollToTop() { if (!channelsScrollController.hasClients) return; final position = channelsScrollController.position; if (position.pixels <= position.minScrollExtent + 0.5) return; if (reducedMotion) { channelsScrollController.jumpTo(position.minScrollExtent); return; } unawaited( channelsScrollController.animateTo( position.minScrollExtent, duration: const Duration(milliseconds: 260), curve: Curves.easeOutCubic, ), ); } tabReselection.addListener(scrollToTop); return () => tabReselection.removeListener(scrollToTop); }, [tabReselection, channelsScrollController, reducedMotion]); // Cache the last successfully loaded channels so the UI never flashes // back to a loading state when the provider rebuilds (e.g. reconnect). // Clear the cache on community switch so we show a full loader instead of // stale channels from the previous community. unwrapPrevious() ensures the // selector sees null during loading (not the previous community's ID). final activeCommunityId = ref.watch( activeCommunityProvider.select((v) => v.unwrapPrevious().value?.id), ); final cachedChannels = useRef?>(null); final lastCommunityId = useRef(null); if (lastCommunityId.value != activeCommunityId) { cachedChannels.value = null; lastCommunityId.value = activeCommunityId; } if (channelsAsync.asData?.value case final data?) { cachedChannels.value = data; } final channels = cachedChannels.value; Future openChannel(Channel channel) async { if (!context.mounted) return; await Navigator.of(context).push( MaterialPageRoute( builder: (_) => ChannelDetailPage(channel: channel), ), ); } // Only surface fetch errors while the relay is stably connected. During a // reconnect the session owns recovery, so a cancelled in-flight query must // not turn into a manual Retry page. final showError = useState(false); final hasError = channelsAsync.hasError && channels == null; final canSurfaceError = hasError && sessionState.status != SessionStatus.connecting && sessionState.status != SessionStatus.reconnecting; useEffect(() { if (!canSurfaceError) { showError.value = false; return null; } final timer = Timer(const Duration(seconds: 2), () { showError.value = true; }); return timer.cancel; }, [canSurfaceError]); // Keep cached content steady through brief socket flaps. A sustained // reconnect swaps to element-shaped skeletons that match desktop. final showConnectionSkeleton = useState(false); final isReconnectingWithContent = channels != null && (sessionState.status == SessionStatus.connecting || sessionState.status == SessionStatus.reconnecting); useEffect(() { if (!isReconnectingWithContent) { showConnectionSkeleton.value = false; return null; } final timer = Timer(const Duration(seconds: 2), () { showConnectionSkeleton.value = true; }); return timer.cancel; }, [isReconnectingWithContent]); void openCommunitySwitcher() { unawaited(HapticFeedback.selectionClick()); ref.invalidate(communityIconProvider); showBuzzModalBottomSheet( context: context, showDragHandle: true, builder: (_) => const _CommunitySwitcherSheet(), ); } final topSectionGradient = context.appColors.topSectionGradient; final usesPinnedGradient = topSectionGradient != null; return FrostedScaffold( backgroundColor: usesPinnedGradient ? Colors.transparent : context.colors.surface, backgroundGradient: topSectionGradient, appBar: FrostedAppBar( horizontalInset: _kTopSectionInset, // Let the full Buzz gradient show at rest. Once the list begins to // move beneath this row, build up blur over the first 64dp of scroll // without adding the usual white frosted wash. The Buzz list is // transparent, so the blurred pixels remain a continuation of the // pinned gradient instead of turning into a white header. frosted: !usesPinnedGradient || headerFrostProgress.value > 0, frostedSurfaceOpacity: usesPinnedGradient ? 0 : 0.5, frostedBlurSigma: usesPinnedGradient ? _kHeaderFrostMaxBlurSigma * headerFrostProgress.value : 20, showBottomDivider: false, leading: _CommunityIndicator(onTap: openCommunitySwitcher), titleStyle: headerTitleStyle, title: _CommunityHeaderTitle( style: headerTitleStyle, onTap: openCommunitySwitcher, ), actions: [ SizedBox( width: Grid.xl, height: Grid.xl, child: Center( child: ProfileAvatar( size: _kTopSectionAvatarSize, onTap: () { unawaited(HapticFeedback.lightImpact()); Navigator.of(context).push( _SettingsPageRoute( builder: settingsPageBuilder, onTransitionProgress: onSettingsTransitionProgress, ), ); }, ), ), ), ], bottomHeight: _kTopSectionBottomPadding, bottom: const SizedBox.expand(), ), body: _ChannelsBody( channels: channels, channelsAsync: channelsAsync, showError: showError.value, sessionStatus: sessionState.status, showConnectionSkeleton: showConnectionSkeleton.value, currentPubkey: currentPubkey, topSectionHeight: topSectionHeight, usesPinnedGradient: usesPinnedGradient, scrollController: channelsScrollController, onRefresh: () => ref.read(channelsProvider.notifier).refresh(), onSelectChannel: openChannel, ), ); } } /// A custom route deliberately avoids [MaterialPageRoute]'s platform exit /// transition on Home. Settings has a centered scale-and-fade transition, not /// a lateral page push. class _SettingsPageRoute extends PageRouteBuilder { _SettingsPageRoute({ required WidgetBuilder builder, required this.onTransitionProgress, }) : super( pageBuilder: (context, animation, secondaryAnimation) => builder(context), transitionsBuilder: _buildSettingsTransition, opaque: false, transitionDuration: const Duration(milliseconds: 190), reverseTransitionDuration: const Duration(milliseconds: 190), ); final ValueChanged onTransitionProgress; Animation? _progressAnimation; @override void install() { super.install(); _progressAnimation = animation?..addListener(_reportProgress); _reportProgress(); } void _reportProgress() { onTransitionProgress(_progressAnimation?.value ?? 0); } @override void dispose() { _progressAnimation?.removeListener(_reportProgress); super.dispose(); } static Widget _buildSettingsTransition( BuildContext context, Animation animation, Animation secondaryAnimation, Widget child, ) { if (MediaQuery.disableAnimationsOf(context)) return child; final incoming = CurvedAnimation( parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeOutCubic, ); return FadeTransition( key: const ValueKey('settings-transition-opacity'), opacity: _SettingsOpacityAnimation(incoming), child: RepaintBoundary( key: const ValueKey('settings-transition-layer'), child: ScaleTransition( scale: Tween(begin: 1.04, end: 1).animate(incoming), alignment: Alignment.center, child: child, ), ), ); } } /// Keeps Settings already composed on entry while retaining a complete exit /// fade. Reading the parent live also keeps opacity synchronized with scale on /// the route's first frame. class _SettingsOpacityAnimation extends Animation with AnimationWithParentMixin { _SettingsOpacityAnimation(this.parent); @override final Animation parent; @override double get value { final progress = parent.value; return parent.status == AnimationStatus.reverse ? progress : 0.8 + (0.2 * progress); } }