mirror of
https://github.com/block/buzz.git
synced 2026-10-27 12:40:30 +00:00
Polish mobile top navigation (#4778)
## Summary - Polish mobile Home, Activity, Search, and Settings navigation chrome. - Add progressive Buzz gradients/frost, aligned theme colors, dividers, typography, and section spacing. - Refine Search and Settings motion, including automatic keyboard focus on search activation. <img width="630" height="1368" alt="C78FA3CE-F2B3-45F2-B9F5-7EA7500778CC" src="https://github.com/user-attachments/assets/5935514b-d894-4010-80dd-a938363fee93" /> <img width="630" height="1368" alt="5C058A73-1879-476A-881C-531ACC256D84" src="https://github.com/user-attachments/assets/5266c841-17ee-49e8-9841-b06d84f4195f" /> <img width="630" height="1368" alt="3F50ADB7-9BDA-4A8D-A81E-20560C3B9EA6" src="https://github.com/user-attachments/assets/f615f61e-9e96-4bce-b261-ae5ec54db872" /> <img width="630" height="1368" alt="35ECE741-01F3-4B79-80C5-1DDD447121A7" src="https://github.com/user-attachments/assets/b5145186-9884-44eb-8ebc-f3303831c0a4" /> ## Validation - `flutter analyze` - Focused Home, Activity, Channels, Search, theme, and footer widget tests - Full pre-push checks, including mobile tests, desktop checks, and Tauri checks - On-device iPhone review during the visual polish pass --------- Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz> Signed-off-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz> Signed-off-by: Kenny Lopez <klopez4212@gmail.com> Co-authored-by: npub1glqcqfjxdens59scl477pmejh8lht4hqkhx0y4w38jxr6e6w6y2sm29y4e <47c18026466e670a1618fd7de0ef32b9ff75d6e0b5ccf255d13c8c3d674ed115@buzz.block.builderlab.xyz> Co-authored-by: Code Reviewer <037593536284cf40e221c96c931e9877d4166d54f6bb84e5341a86d7fd5d05a4@buzz.block.builderlab.xyz>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
@@ -59,7 +60,10 @@ EdgeInsets _activityScrollPadding(
|
||||
/// navigation. Row taps deep-link to the represented message (oldest unread
|
||||
/// for grouped conversations) rather than just opening the channel.
|
||||
class ActivityPage extends HookConsumerWidget {
|
||||
const ActivityPage({super.key});
|
||||
const ActivityPage({this.tabReselection, super.key});
|
||||
|
||||
/// Notifies this page when its already-selected tab is tapped again.
|
||||
final ValueListenable<int>? tabReselection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -67,9 +71,41 @@ class ActivityPage extends HookConsumerWidget {
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
final filter = useState(InboxFilter.all);
|
||||
final unreadOnly = useState(false);
|
||||
final scrollController = useScrollController();
|
||||
final reducedMotion = MediaQuery.disableAnimationsOf(context);
|
||||
useEffect(() {
|
||||
final tabReselection = this.tabReselection;
|
||||
if (tabReselection == null) return null;
|
||||
|
||||
void scrollToTop() {
|
||||
if (!scrollController.hasClients) return;
|
||||
final position = scrollController.position;
|
||||
if (position.pixels <= position.minScrollExtent + 0.5) return;
|
||||
if (reducedMotion) {
|
||||
scrollController.jumpTo(position.minScrollExtent);
|
||||
return;
|
||||
}
|
||||
unawaited(
|
||||
scrollController.animateTo(
|
||||
position.minScrollExtent,
|
||||
duration: const Duration(milliseconds: 260),
|
||||
curve: Curves.easeOutCubic,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
tabReselection.addListener(scrollToTop);
|
||||
return () => tabReselection.removeListener(scrollToTop);
|
||||
}, [tabReselection, scrollController, reducedMotion]);
|
||||
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: navigationPrimaryForeground(context),
|
||||
);
|
||||
final topSectionHeight = frostedAppBarHeight(
|
||||
context,
|
||||
titleStyle: headerTitleStyle,
|
||||
bottomHeight: Grid.xxs,
|
||||
);
|
||||
|
||||
final readState = ref.watch(readStateProvider);
|
||||
@@ -244,12 +280,18 @@ class ActivityPage extends HookConsumerWidget {
|
||||
]);
|
||||
}
|
||||
|
||||
final Widget body;
|
||||
late final Widget body;
|
||||
var bodyRidesOverTopSection = false;
|
||||
if (filter.value == InboxFilter.reminders) {
|
||||
body = _RemindersList(onOpen: openReminder, onRefresh: refresh);
|
||||
body = _RemindersList(
|
||||
scrollController: scrollController,
|
||||
onOpen: openReminder,
|
||||
onRefresh: refresh,
|
||||
);
|
||||
} else if (filter.value == InboxFilter.drafts) {
|
||||
body = _DraftsList(
|
||||
drafts: drafts,
|
||||
scrollController: scrollController,
|
||||
channelById: channelById,
|
||||
myPubkey: myPk,
|
||||
onOpen: openDraft,
|
||||
@@ -259,7 +301,7 @@ class ActivityPage extends HookConsumerWidget {
|
||||
} else if (feedAsync.hasError && allItems.isEmpty) {
|
||||
body = _ErrorView(onRetry: refresh);
|
||||
} else if (!hasLoadedOnce.value && allItems.isEmpty) {
|
||||
body = const _LoadingSkeleton();
|
||||
body = _LoadingSkeleton(scrollController: scrollController);
|
||||
} else if (visibleItems.isEmpty) {
|
||||
body = _EmptyFilterState(
|
||||
filter: filter.value,
|
||||
@@ -274,54 +316,73 @@ class ActivityPage extends HookConsumerWidget {
|
||||
? firstReadIndex
|
||||
: -1;
|
||||
|
||||
bodyRidesOverTopSection = true;
|
||||
body = RefreshIndicator(
|
||||
edgeOffset: topSectionHeight,
|
||||
onRefresh: refresh,
|
||||
child: ListView.builder(
|
||||
padding: _activityScrollPadding(context),
|
||||
itemCount: visibleItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = visibleItems[index];
|
||||
final channel = item.item.channelId != null
|
||||
? channelById[item.item.channelId]
|
||||
: null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (index == newBoundaryIndex) const _NewBoundaryDivider(),
|
||||
_InboxRow(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
channel: channel,
|
||||
currentPubkey: myPk,
|
||||
isDone: isDone(item),
|
||||
onTap: () => openItem(item),
|
||||
onMarkRead: () => markItemRead(item),
|
||||
onMarkUnread: () => markItemUnread(item),
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: SizedBox(height: topSectionHeight)),
|
||||
DecoratedSliver(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(Radii.dialog),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
sliver: SliverPadding(
|
||||
padding: _activityScrollPadding(context),
|
||||
sliver: SliverList.builder(
|
||||
itemCount: visibleItems.length,
|
||||
itemBuilder: (context, index) {
|
||||
final item = visibleItems[index];
|
||||
final channel = item.item.channelId != null
|
||||
? channelById[item.item.channelId]
|
||||
: null;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (index == newBoundaryIndex)
|
||||
const _NewBoundaryDivider(),
|
||||
_InboxRow(
|
||||
key: ValueKey(item.id),
|
||||
item: item,
|
||||
channel: channel,
|
||||
currentPubkey: myPk,
|
||||
isDone: isDone(item),
|
||||
onTap: () => openItem(item),
|
||||
onMarkRead: () => markItemRead(item),
|
||||
onMarkUnread: () => markItemUnread(item),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return FrostedScaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: context.colors.surface,
|
||||
appBar: FrostedAppBar(
|
||||
gradient: context.appColors.topSectionGradient,
|
||||
automaticallyImplyLeading: false,
|
||||
title: const Text('Activity'),
|
||||
horizontalInset: Grid.gutter,
|
||||
showBottomDivider: true,
|
||||
bottomDividerOpacity: 0.06,
|
||||
title: Text('Activity', style: headerTitleStyle),
|
||||
titleStyle: headerTitleStyle,
|
||||
actions: [
|
||||
_FilterMenuButton(
|
||||
_ActivityActionsPill(
|
||||
filter: filter.value,
|
||||
dueReminderCount: dueReminderCount,
|
||||
draftCount: drafts.length,
|
||||
onChanged: (f) => filter.value = f,
|
||||
),
|
||||
_InboxOptionsButton(
|
||||
unreadOnly: unreadOnly.value,
|
||||
unreadCount: unreadVisibleCount,
|
||||
onFilterChanged: (f) => filter.value = f,
|
||||
onUnreadOnlyChanged: (v) => unreadOnly.value = v,
|
||||
onMarkAllRead: () {
|
||||
for (final item in visibleItems) {
|
||||
@@ -330,17 +391,19 @@ class ActivityPage extends HookConsumerWidget {
|
||||
},
|
||||
),
|
||||
],
|
||||
bottomHeight: Grid.xxs,
|
||||
bottom: const SizedBox.expand(),
|
||||
),
|
||||
body: SafeArea(
|
||||
key: const ValueKey('activity-content-safe-area'),
|
||||
top: false,
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
child: bodyRidesOverTopSection
|
||||
? body
|
||||
: Padding(
|
||||
padding: EdgeInsets.only(top: topSectionHeight),
|
||||
child: body,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -11,6 +11,59 @@ const _filterLabels = <InboxFilter, String>{
|
||||
InboxFilter.drafts: 'Drafts',
|
||||
};
|
||||
|
||||
class _ActivityActionsPill extends StatelessWidget {
|
||||
final InboxFilter filter;
|
||||
final int dueReminderCount;
|
||||
final int draftCount;
|
||||
final bool unreadOnly;
|
||||
final int unreadCount;
|
||||
final ValueChanged<InboxFilter> onFilterChanged;
|
||||
final ValueChanged<bool> onUnreadOnlyChanged;
|
||||
final VoidCallback onMarkAllRead;
|
||||
|
||||
const _ActivityActionsPill({
|
||||
required this.filter,
|
||||
required this.dueReminderCount,
|
||||
required this.draftCount,
|
||||
required this.unreadOnly,
|
||||
required this.unreadCount,
|
||||
required this.onFilterChanged,
|
||||
required this.onUnreadOnlyChanged,
|
||||
required this.onMarkAllRead,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => ClipRRect(
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_FilterMenuButton(
|
||||
filter: filter,
|
||||
dueReminderCount: dueReminderCount,
|
||||
draftCount: draftCount,
|
||||
onChanged: onFilterChanged,
|
||||
),
|
||||
_InboxOptionsButton(
|
||||
unreadOnly: unreadOnly,
|
||||
unreadCount: unreadCount,
|
||||
onUnreadOnlyChanged: onUnreadOnlyChanged,
|
||||
onMarkAllRead: onMarkAllRead,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Compact filter dropdown replacing the old chip rail — mirrors desktop's
|
||||
/// inbox filter menu (`FILTER_OPTIONS`).
|
||||
class _FilterMenuButton extends StatelessWidget {
|
||||
@@ -91,6 +144,7 @@ class _FilterMenuButton extends StatelessWidget {
|
||||
Text(
|
||||
_filterLabels[filter]!,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: navigationPrimaryForeground(context),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
@@ -98,7 +152,7 @@ class _FilterMenuButton extends StatelessWidget {
|
||||
Icon(
|
||||
LucideIcons.chevronDown,
|
||||
size: 16,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
color: navigationPrimaryForeground(context),
|
||||
),
|
||||
if (dueReminderCount > 0 || draftCount > 0) ...[
|
||||
const SizedBox(width: Grid.quarter),
|
||||
@@ -133,7 +187,7 @@ class _CountBadge extends StatelessWidget {
|
||||
vertical: Grid.quarter,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.primary,
|
||||
color: navigationPrimaryForeground(context),
|
||||
borderRadius: BorderRadius.circular(Grid.xxs),
|
||||
),
|
||||
child: Text(
|
||||
@@ -168,6 +222,12 @@ class _InboxOptionsButton extends StatelessWidget {
|
||||
builder: (buttonContext) => IconButton(
|
||||
key: const ValueKey('activity-options-menu'),
|
||||
tooltip: 'Activity options',
|
||||
color: navigationPrimaryForeground(context),
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.xxs),
|
||||
constraints: const BoxConstraints.tightFor(
|
||||
width: Grid.xl,
|
||||
height: Grid.xl,
|
||||
),
|
||||
icon: const Icon(LucideIcons.ellipsis, size: 20),
|
||||
onPressed: () async {
|
||||
final selected = await showAnchoredPopover<String>(
|
||||
|
||||
@@ -3,10 +3,15 @@ part of '../activity_page.dart';
|
||||
/// Reminders surface for the Reminders filter — due/pending NIP-ER
|
||||
/// reminders that deep-link to their target message.
|
||||
class _RemindersList extends ConsumerWidget {
|
||||
final ScrollController scrollController;
|
||||
final void Function(Reminder reminder) onOpen;
|
||||
final Future<void> Function() onRefresh;
|
||||
|
||||
const _RemindersList({required this.onOpen, required this.onRefresh});
|
||||
const _RemindersList({
|
||||
required this.scrollController,
|
||||
required this.onOpen,
|
||||
required this.onRefresh,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -36,6 +41,7 @@ class _RemindersList extends ConsumerWidget {
|
||||
return RefreshIndicator(
|
||||
onRefresh: onRefresh,
|
||||
child: ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: _activityScrollPadding(context),
|
||||
itemCount: reminders.length,
|
||||
itemBuilder: (context, index) {
|
||||
@@ -73,6 +79,7 @@ class _RemindersList extends ConsumerWidget {
|
||||
/// text that reopens the target composer.
|
||||
class _DraftsList extends StatelessWidget {
|
||||
final List<ComposeDraft> drafts;
|
||||
final ScrollController scrollController;
|
||||
final Map<String, Channel> channelById;
|
||||
final String? myPubkey;
|
||||
final void Function(ComposeDraft draft) onOpen;
|
||||
@@ -80,6 +87,7 @@ class _DraftsList extends StatelessWidget {
|
||||
|
||||
const _DraftsList({
|
||||
required this.drafts,
|
||||
required this.scrollController,
|
||||
required this.channelById,
|
||||
required this.myPubkey,
|
||||
required this.onOpen,
|
||||
@@ -97,6 +105,7 @@ class _DraftsList extends StatelessWidget {
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
controller: scrollController,
|
||||
padding: _activityScrollPadding(context),
|
||||
itemCount: drafts.length,
|
||||
itemBuilder: (context, index) {
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
part of '../activity_page.dart';
|
||||
|
||||
class _LoadingSkeleton extends StatelessWidget {
|
||||
const _LoadingSkeleton();
|
||||
final ScrollController scrollController;
|
||||
|
||||
const _LoadingSkeleton({required this.scrollController});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
controller: scrollController,
|
||||
padding: _activityScrollPadding(
|
||||
context,
|
||||
horizontal: Grid.gutter,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -65,6 +66,14 @@ 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;
|
||||
|
||||
@@ -74,22 +83,22 @@ const double _kChannelLabelInset =
|
||||
/// sections while the labels stay on [_kChannelLabelInset].
|
||||
const double _kDmAvatarSize = _kChannelIconSize;
|
||||
|
||||
const double _kTopSectionAvatarSize = 32.0;
|
||||
const double _kTopSectionAvatarSize = 40.0;
|
||||
const double _kTopSectionBottomPadding = Grid.xxs;
|
||||
|
||||
/// The top section's avatars are 32dp circles, which fill their box edge to
|
||||
/// 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
|
||||
/// on the channel-icon column (12 + 16 = 28dp against the glyph's ~29dp). Its
|
||||
/// label gap is derived separately so both labels land on the same 50dp column.
|
||||
/// near the channel-icon column.
|
||||
const double _kTopSectionInset = Grid.twelve;
|
||||
const double _kTopSectionLabelGap =
|
||||
_kChannelLabelInset - _kTopSectionInset - _kTopSectionAvatarSize;
|
||||
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<String> ids;
|
||||
@@ -145,10 +154,21 @@ _UnreadChannelState _computeUnreadChannelState({
|
||||
}
|
||||
|
||||
class ChannelsPage extends HookConsumerWidget {
|
||||
const ChannelsPage({required this.settingsPageBuilder, super.key});
|
||||
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<double> onSettingsTransitionProgress;
|
||||
|
||||
/// Notifies this page when its already-selected tab is tapped again.
|
||||
final ValueListenable<int>? tabReselection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final channelsAsync = ref.watch(channelsProvider);
|
||||
@@ -157,6 +177,59 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
.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).
|
||||
@@ -223,32 +296,65 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
return timer.cancel;
|
||||
}, [isReconnectingWithContent]);
|
||||
|
||||
void openCommunitySwitcher() {
|
||||
unawaited(HapticFeedback.selectionClick());
|
||||
ref.invalidate(communityIconProvider);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _CommunitySwitcherSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
final topSectionGradient = context.appColors.topSectionGradient;
|
||||
final usesPinnedGradient = topSectionGradient != null;
|
||||
|
||||
return FrostedScaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: usesPinnedGradient
|
||||
? Colors.transparent
|
||||
: context.colors.surface,
|
||||
backgroundGradient: topSectionGradient,
|
||||
appBar: FrostedAppBar(
|
||||
horizontalInset: _kTopSectionInset,
|
||||
// Under a Buzz theme the community + account avatar strip carries the
|
||||
// branded gradient, the way desktop paints it across the sidebar. Null
|
||||
// under every other theme, leaving the default frosted fill.
|
||||
gradient: context.appColors.topSectionGradient,
|
||||
leading: _CommunityIndicator(
|
||||
onTap: () {
|
||||
ref.invalidate(communityIconProvider);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
showDragHandle: true,
|
||||
builder: (_) => const _CommunitySwitcherSheet(),
|
||||
);
|
||||
},
|
||||
// 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,
|
||||
),
|
||||
title: const SizedBox.shrink(),
|
||||
actions: [
|
||||
ProfileAvatar(
|
||||
onTap: () => Navigator.of(
|
||||
context,
|
||||
).push(MaterialPageRoute<void>(builder: settingsPageBuilder)),
|
||||
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,
|
||||
@@ -257,9 +363,96 @@ class ChannelsPage extends HookConsumerWidget {
|
||||
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<void> {
|
||||
_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<double> onTransitionProgress;
|
||||
|
||||
Animation<double>? _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<double> animation,
|
||||
Animation<double> 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<double>(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<double>
|
||||
with AnimationWithParentMixin<double> {
|
||||
_SettingsOpacityAnimation(this.parent);
|
||||
|
||||
@override
|
||||
final Animation<double> parent;
|
||||
|
||||
@override
|
||||
double get value {
|
||||
final progress = parent.value;
|
||||
return parent.status == AnimationStatus.reverse
|
||||
? progress
|
||||
: 0.8 + (0.2 * progress);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,9 @@ class _ChannelsBody extends StatelessWidget {
|
||||
final SessionStatus sessionStatus;
|
||||
final bool showConnectionSkeleton;
|
||||
final String? currentPubkey;
|
||||
final double topSectionHeight;
|
||||
final bool usesPinnedGradient;
|
||||
final ScrollController scrollController;
|
||||
final Future<void> Function() onRefresh;
|
||||
final Future<void> Function(Channel channel) onSelectChannel;
|
||||
|
||||
@@ -17,13 +20,16 @@ class _ChannelsBody extends StatelessWidget {
|
||||
required this.sessionStatus,
|
||||
required this.showConnectionSkeleton,
|
||||
required this.currentPubkey,
|
||||
required this.topSectionHeight,
|
||||
required this.usesPinnedGradient,
|
||||
required this.scrollController,
|
||||
required this.onRefresh,
|
||||
required this.onSelectChannel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final barHeight = frostedAppBarHeight(context);
|
||||
final barHeight = topSectionHeight;
|
||||
final loadedChannels = channels;
|
||||
final loading =
|
||||
showConnectionSkeleton || (loadedChannels == null && !showError);
|
||||
@@ -38,13 +44,32 @@ class _ChannelsBody extends StatelessWidget {
|
||||
edgeOffset: barHeight,
|
||||
onRefresh: onRefresh,
|
||||
child: CustomScrollView(
|
||||
controller: scrollController,
|
||||
// The transparent gap shows the top section and must not absorb
|
||||
// taps meant for the community or profile controls beneath it.
|
||||
hitTestBehavior: HitTestBehavior.deferToChild,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: SizedBox(height: barHeight)),
|
||||
_SliverChannelsList(
|
||||
channels: loadedChannels,
|
||||
currentPubkey: currentPubkey,
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
if (usesPinnedGradient)
|
||||
_SliverChannelsList(
|
||||
channels: loadedChannels,
|
||||
currentPubkey: currentPubkey,
|
||||
onSelectChannel: onSelectChannel,
|
||||
)
|
||||
else
|
||||
DecoratedSliver(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colors.surface,
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(Radii.dialog),
|
||||
),
|
||||
),
|
||||
sliver: _SliverChannelsList(
|
||||
channels: loadedChannels,
|
||||
currentPubkey: currentPubkey,
|
||||
onSelectChannel: onSelectChannel,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -26,6 +26,12 @@ class _ChannelTile extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final contentColor = isMuted
|
||||
? navigationSecondaryForeground(context)
|
||||
: navigationPrimaryForeground(
|
||||
context,
|
||||
).withValues(alpha: isUnread ? 1 : 0.8);
|
||||
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(Radii.md),
|
||||
onTap: onTap,
|
||||
@@ -47,8 +53,9 @@ class _ChannelTile extends ConsumerWidget {
|
||||
? _DmAvatar(channel: channel, currentPubkey: currentPubkey)
|
||||
: Icon(
|
||||
channelIcon(channel),
|
||||
key: ValueKey('channel-icon-${channel.id}'),
|
||||
size: _kChannelIconSize,
|
||||
color: context.colors.onSurface,
|
||||
color: contentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -65,7 +72,7 @@ class _ChannelTile extends ConsumerWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: contentListTitleTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
color: contentColor,
|
||||
fontWeight: isUnread ? FontWeight.w700 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
@@ -81,7 +88,7 @@ class _ChannelTile extends ConsumerWidget {
|
||||
Icon(
|
||||
LucideIcons.bellOff,
|
||||
size: 12,
|
||||
color: context.colors.onSurfaceVariant,
|
||||
color: context.colors.onSurface.withValues(alpha: 0.4),
|
||||
),
|
||||
],
|
||||
if (!channel.isMember && !channel.isDm)
|
||||
|
||||
@@ -471,37 +471,44 @@ class _CommunityIndicator extends ConsumerWidget {
|
||||
final activeAsync = ref.watch(activeCommunityProvider);
|
||||
|
||||
final activeCommunity = activeAsync.value;
|
||||
final name = activeCommunity?.name;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_CommunityAvatar(name: name, relayUrl: activeCommunity?.relayUrl),
|
||||
const SizedBox(width: _kTopSectionLabelGap),
|
||||
if (name != null)
|
||||
Flexible(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Community',
|
||||
child: _CommunityAvatar(
|
||||
name: activeCommunity?.name,
|
||||
relayUrl: activeCommunity?.relayUrl,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CommunityHeaderTitle extends ConsumerWidget {
|
||||
final TextStyle? style;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CommunityHeaderTitle({required this.onTap, this.style});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final name = ref.watch(activeCommunityProvider).value?.name;
|
||||
final title = name?.trim();
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: SizedBox.expand(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: Grid.xxs),
|
||||
child: Text(
|
||||
title == null || title.isEmpty ? 'Community' : title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
style: style,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,6 +77,7 @@ class _CustomChannelSection extends StatelessWidget {
|
||||
onMarkRead: () => onMarkChannelRead(channel),
|
||||
sectionId: section.id,
|
||||
),
|
||||
const SizedBox(height: _kExpandedSectionTrailingPadding),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -114,7 +115,7 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final sectionColor = context.colors.primary;
|
||||
final sectionColor = navigationSectionForeground(context);
|
||||
final icon = section.icon;
|
||||
final customEmoji = icon == null
|
||||
? null
|
||||
@@ -126,9 +127,9 @@ class _CustomSectionHeader extends ConsumerWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.twelve,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
Grid.gutter,
|
||||
_kChannelRowVerticalPadding,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -445,6 +446,7 @@ class _ChannelSection extends StatelessWidget {
|
||||
onMarkRead: null,
|
||||
sectionId: null,
|
||||
),
|
||||
const SizedBox(height: _kExpandedSectionTrailingPadding),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -495,7 +497,7 @@ class _SectionDivider extends StatelessWidget {
|
||||
thickness: 1,
|
||||
indent: _kChannelSectionInset,
|
||||
endIndent: _kChannelSectionInset,
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.72),
|
||||
color: context.colors.primary.withValues(alpha: 0.15),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -520,7 +522,7 @@ class _SectionHeader extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sectionColor = context.colors.primary;
|
||||
final sectionColor = navigationSectionForeground(context);
|
||||
|
||||
return GestureDetector(
|
||||
onTap: onToggle,
|
||||
@@ -528,9 +530,9 @@ class _SectionHeader extends StatelessWidget {
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
Grid.twelve,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
Grid.gutter,
|
||||
_kChannelRowVerticalPadding,
|
||||
_kSectionHeaderVerticalPadding,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
|
||||
@@ -36,6 +36,7 @@ class HomePage extends HookConsumerWidget {
|
||||
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,
|
||||
);
|
||||
@@ -71,6 +72,10 @@ class HomePage extends HookConsumerWidget {
|
||||
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
|
||||
@@ -82,77 +87,141 @@ class HomePage extends HookConsumerWidget {
|
||||
);
|
||||
|
||||
final pages = [
|
||||
ChannelsPage(settingsPageBuilder: settingsPageBuilder),
|
||||
const ActivityPage(),
|
||||
const SearchPage(),
|
||||
];
|
||||
|
||||
return 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
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) 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));
|
||||
ChannelsPage(
|
||||
settingsPageBuilder: settingsPageBuilder,
|
||||
tabReselection: homeReselection,
|
||||
onSettingsTransitionProgress: (progress) {
|
||||
if (settingsTransitionProgress.value != progress) {
|
||||
settingsTransitionProgress.value = progress;
|
||||
}
|
||||
},
|
||||
destinations: _destinations,
|
||||
),
|
||||
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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ import 'profile_provider.dart';
|
||||
import 'user_profile.dart';
|
||||
|
||||
/// Matches desktop's sidebar profile card, whose avatar is 32px.
|
||||
const _avatarSize = 32.0;
|
||||
const _defaultAvatarSize = 32.0;
|
||||
|
||||
/// The visible dot is smaller than the notch it sits in, so a ring of
|
||||
/// background separates it from the avatar. Desktop's `h-2 w-2` dot inside a
|
||||
@@ -24,7 +24,15 @@ class ProfileAvatar extends ConsumerWidget {
|
||||
final VoidCallback? onTap;
|
||||
final bool showPresence;
|
||||
|
||||
const ProfileAvatar({super.key, this.onTap, this.showPresence = true});
|
||||
/// The avatar diameter in logical pixels; defaults to the 32px desktop match.
|
||||
final double size;
|
||||
|
||||
const ProfileAvatar({
|
||||
super.key,
|
||||
this.onTap,
|
||||
this.showPresence = true,
|
||||
this.size = _defaultAvatarSize,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -45,7 +53,7 @@ class ProfileAvatar extends ConsumerWidget {
|
||||
|
||||
Widget _buildPlaceholder(BuildContext context) {
|
||||
return CircleAvatar(
|
||||
radius: _avatarSize / 2,
|
||||
radius: size / 2,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
);
|
||||
}
|
||||
@@ -58,7 +66,7 @@ class ProfileAvatar extends ConsumerWidget {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: MaskedAvatarBadge(
|
||||
size: _avatarSize,
|
||||
size: size,
|
||||
geometry: AvatarBadgeMaskGeometry.presenceDot,
|
||||
avatar: ClipOval(
|
||||
child: ColoredBox(
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
import '../../shared/relay/relay.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import 'user_profile.dart';
|
||||
|
||||
/// The current user's profile (kind:0 metadata) loaded over the relay
|
||||
@@ -50,13 +51,26 @@ final profileProvider = AsyncNotifierProvider<ProfileNotifier, UserProfile?>(
|
||||
/// [appLifecycleProvider] to send "away" when backgrounded.
|
||||
class PresenceNotifier extends AsyncNotifier<String> {
|
||||
static const _heartbeatInterval = Duration(seconds: 60);
|
||||
static const _preferenceKeyPrefix = 'buzz_presence_preference_';
|
||||
|
||||
Timer? _heartbeatTimer;
|
||||
String? _preferencePubkey;
|
||||
String? _manualPresence;
|
||||
|
||||
@override
|
||||
Future<String> build() {
|
||||
ref.watch(relaySessionProvider);
|
||||
ref.watch(profileProvider);
|
||||
final pubkey = ref.watch(myPubkeyProvider)?.toLowerCase();
|
||||
|
||||
if (_preferencePubkey != pubkey) {
|
||||
_preferencePubkey = pubkey;
|
||||
final stored = pubkey == null
|
||||
? null
|
||||
: ref
|
||||
.read(savedPrefsProvider)
|
||||
.getString('$_preferenceKeyPrefix$pubkey');
|
||||
_manualPresence = stored == 'away' || stored == 'offline' ? stored : null;
|
||||
}
|
||||
|
||||
final lifecycle = ref.watch(appLifecycleProvider);
|
||||
|
||||
@@ -65,6 +79,13 @@ class PresenceNotifier extends AsyncNotifier<String> {
|
||||
_heartbeatTimer = null;
|
||||
});
|
||||
|
||||
final manualPresence = _manualPresence;
|
||||
if (manualPresence != null) {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
return _setPresence(manualPresence);
|
||||
}
|
||||
|
||||
if (lifecycle == AppLifecycleState.resumed) {
|
||||
_startHeartbeat();
|
||||
return _setPresence('online');
|
||||
@@ -87,6 +108,33 @@ class PresenceNotifier extends AsyncNotifier<String> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Updates the current user's presence preference and publishes it.
|
||||
///
|
||||
/// Online restores automatic lifecycle-driven presence. Away and Offline
|
||||
/// remain selected until the user chooses another value.
|
||||
Future<void> setPresence(String status) async {
|
||||
if (status != 'online' && status != 'away' && status != 'offline') return;
|
||||
|
||||
_manualPresence = status == 'online' ? null : status;
|
||||
final pubkey = ref.read(myPubkeyProvider)?.toLowerCase();
|
||||
if (pubkey != null) {
|
||||
await ref
|
||||
.read(savedPrefsProvider)
|
||||
.setString('$_preferenceKeyPrefix$pubkey', _manualPresence ?? 'auto');
|
||||
}
|
||||
|
||||
if (_manualPresence == null &&
|
||||
ref.read(appLifecycleProvider) == AppLifecycleState.resumed) {
|
||||
_startHeartbeat();
|
||||
} else {
|
||||
_heartbeatTimer?.cancel();
|
||||
_heartbeatTimer = null;
|
||||
}
|
||||
|
||||
state = AsyncData(status);
|
||||
await _setPresence(status);
|
||||
}
|
||||
|
||||
/// Publish a kind:20001 presence event. Returns the requested status
|
||||
/// optimistically — failures are silently absorbed and the next heartbeat
|
||||
/// will retry.
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -6,6 +8,7 @@ import '../../shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import '../../shared/custom_emoji/custom_emoji_render.dart';
|
||||
import '../../shared/theme/theme.dart';
|
||||
import '../../shared/widgets/avatar_image.dart';
|
||||
import '../../shared/widgets/anchored_popover_menu.dart';
|
||||
import '../../shared/widgets/masked_avatar_badge.dart';
|
||||
import 'profile_provider.dart';
|
||||
import 'set_status_sheet.dart';
|
||||
@@ -26,12 +29,13 @@ class SettingsProfileHeader extends ConsumerWidget {
|
||||
final profile = ref.watch(profileProvider).asData?.value;
|
||||
final status = ref.watch(userStatusProvider).asData?.value;
|
||||
final hasStatus = status != null && !status.isEmpty;
|
||||
final presence = ref.watch(presenceProvider).value ?? 'offline';
|
||||
|
||||
void openStatusSheet() =>
|
||||
showSetStatusSheet(context, currentStatus: status);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: Grid.xxs, bottom: Grid.sm),
|
||||
padding: const EdgeInsets.only(top: Grid.sm, bottom: Grid.sm),
|
||||
child: Column(
|
||||
children: [
|
||||
MaskedAvatarBadge(
|
||||
@@ -59,8 +63,8 @@ class SettingsProfileHeader extends ConsumerWidget {
|
||||
style: context.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
// No placeholder copy — the badge is the affordance, so this line
|
||||
// appears only once there is an actual status to show.
|
||||
// Keep the status text visible even when no emoji is set. NIP-38
|
||||
// permits text-only statuses, which the avatar badge cannot represent.
|
||||
if (hasStatus)
|
||||
GestureDetector(
|
||||
onTap: openStatusSheet,
|
||||
@@ -82,12 +86,154 @@ class SettingsProfileHeader extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
_PresencePill(
|
||||
presence: presence,
|
||||
onSelected: (nextPresence) => unawaited(
|
||||
ref.read(presenceProvider.notifier).setPresence(nextPresence),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PresencePill extends StatelessWidget {
|
||||
const _PresencePill({required this.presence, required this.onSelected});
|
||||
|
||||
final String presence;
|
||||
final ValueChanged<String> onSelected;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectivePresence = switch (presence) {
|
||||
'online' || 'away' => presence,
|
||||
_ => 'offline',
|
||||
};
|
||||
final (backgroundColor, foregroundColor) = switch (effectivePresence) {
|
||||
'online' => (
|
||||
context.appColors.success.withValues(alpha: 0.15),
|
||||
context.appColors.success,
|
||||
),
|
||||
'away' => (
|
||||
context.appColors.warning.withValues(alpha: 0.15),
|
||||
context.appColors.warning,
|
||||
),
|
||||
_ => (
|
||||
context.colors.onSurfaceVariant.withValues(alpha: 0.15),
|
||||
context.colors.onSurfaceVariant,
|
||||
),
|
||||
};
|
||||
final label = _presenceLabel(effectivePresence);
|
||||
|
||||
return Builder(
|
||||
builder: (buttonContext) => Semantics(
|
||||
button: true,
|
||||
label: 'Presence: $label',
|
||||
child: SizedBox(
|
||||
key: const ValueKey('settings-presence-target'),
|
||||
height: Grid.xl,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
key: const ValueKey('settings-presence-menu'),
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
onTap: () async {
|
||||
final selected = await showAnchoredPopover<String>(
|
||||
context: buttonContext,
|
||||
width: 176,
|
||||
alignment: AnchoredPopoverAlignment.center,
|
||||
offset: const Offset(0, Grid.half),
|
||||
menuPadding: const EdgeInsets.symmetric(vertical: Grid.half),
|
||||
surfaceKey: const ValueKey('settings-presence-popover'),
|
||||
items: [
|
||||
for (final option in const ['online', 'away', 'offline'])
|
||||
PopupMenuItem<String>(
|
||||
key: ValueKey('settings-presence-$option'),
|
||||
value: option,
|
||||
height: Grid.xl,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.twelve,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: _presenceColor(context, option),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.xxs),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_presenceLabel(option),
|
||||
style: filterChipTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
fontWeight: option == effectivePresence
|
||||
? FontWeight.w500
|
||||
: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (option == effectivePresence)
|
||||
Icon(
|
||||
LucideIcons.check,
|
||||
size: 16,
|
||||
color: context.colors.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
if (buttonContext.mounted && selected != null) {
|
||||
onSelected(selected);
|
||||
}
|
||||
},
|
||||
child: Center(
|
||||
child: Material(
|
||||
key: const ValueKey('settings-presence-pill'),
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.circular(Radii.full),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.xs,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
key: const ValueKey('settings-presence-label'),
|
||||
style: filterChipTextStyle.copyWith(
|
||||
color: foregroundColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _presenceLabel(String presence) => switch (presence) {
|
||||
'online' => 'Online',
|
||||
'away' => 'Away',
|
||||
_ => 'Offline',
|
||||
};
|
||||
|
||||
Color _presenceColor(BuildContext context, String presence) =>
|
||||
switch (presence) {
|
||||
'online' => context.appColors.success,
|
||||
'away' => context.appColors.warning,
|
||||
_ => context.colors.outline,
|
||||
};
|
||||
|
||||
/// Fills the notch left by [MaskedAvatarBadge], so its size comes from the mask
|
||||
/// geometry rather than being set here.
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
@@ -26,13 +27,42 @@ import '../profile/user_profile.dart';
|
||||
import 'recent_searches_provider.dart';
|
||||
import 'search_provider.dart';
|
||||
|
||||
part 'search_page/motion_field.dart';
|
||||
|
||||
enum _SearchFilter { all, messages, channels, people }
|
||||
|
||||
const _searchFieldMinHeight = 36.0;
|
||||
const _searchIdleFieldHeight = 45.0;
|
||||
const _searchIdleTextSize = 15.0;
|
||||
const _searchFieldVerticalPadding = Grid.xxs;
|
||||
const _searchFieldHint = 'Search messages, channels, people\u2026';
|
||||
const _searchCancelEnterDuration = Duration(milliseconds: 160);
|
||||
const _searchCancelExitDuration = Duration(milliseconds: 120);
|
||||
const _searchFieldMoveDuration = Duration(milliseconds: 160);
|
||||
const _searchTitleReturnDuration = Duration(milliseconds: 80);
|
||||
const _searchCancelEnterDuration = Duration(milliseconds: 80);
|
||||
const _searchCancelExitDuration = Duration(milliseconds: 60);
|
||||
const _searchIdleFieldTopInset = Grid.half;
|
||||
const _searchActiveFieldTopOffset = 42.0;
|
||||
const _searchBottomOverlap =
|
||||
_searchActiveFieldTopOffset + _searchIdleFieldTopInset;
|
||||
const _searchFilterChipVerticalPadding = Grid.xxs;
|
||||
const _searchFilterBarVerticalPadding = Grid.xxs;
|
||||
const _searchHeaderFiltersMinHeight = Grid.xl;
|
||||
const _searchActiveFieldRightInsetMin = 72.0;
|
||||
|
||||
/// Reserves the Cancel action's scaled label, padding, and app-bar edge inset.
|
||||
double _searchActiveFieldRightInset(BuildContext context) {
|
||||
final textPainter = TextPainter(
|
||||
text: TextSpan(
|
||||
text: 'Cancel',
|
||||
style: filterChipTextStyle.copyWith(fontWeight: FontWeight.w500),
|
||||
),
|
||||
textScaler: MediaQuery.textScalerOf(context),
|
||||
textDirection: Directionality.of(context),
|
||||
)..layout();
|
||||
final cancelWidth = textPainter.width + Grid.half * 2 + Grid.twelve;
|
||||
return cancelWidth > _searchActiveFieldRightInsetMin
|
||||
? cancelWidth
|
||||
: _searchActiveFieldRightInsetMin;
|
||||
}
|
||||
|
||||
double _searchFieldHeight(BuildContext context) {
|
||||
const style = searchInputTextStyle;
|
||||
@@ -46,8 +76,35 @@ double _searchFieldHeight(BuildContext context) {
|
||||
: _searchFieldMinHeight;
|
||||
}
|
||||
|
||||
double _idleSearchFieldHeight(BuildContext context) {
|
||||
final scaledFontSize = MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(_searchIdleTextSize);
|
||||
final contentHeight =
|
||||
scaledFontSize * (20 / _searchIdleTextSize) +
|
||||
_searchFieldVerticalPadding * 2;
|
||||
return contentHeight > _searchIdleFieldHeight
|
||||
? contentHeight
|
||||
: _searchIdleFieldHeight;
|
||||
}
|
||||
|
||||
double _searchHeaderFiltersHeight(BuildContext context) {
|
||||
const style = filterChipTextStyle;
|
||||
final scaledLabelHeight =
|
||||
MediaQuery.textScalerOf(context).scale(style.fontSize ?? 15) *
|
||||
(style.height ?? 1);
|
||||
final chipHeight = scaledLabelHeight + _searchFilterChipVerticalPadding * 2;
|
||||
final contentHeight = chipHeight + _searchFilterBarVerticalPadding * 2;
|
||||
return contentHeight > _searchHeaderFiltersMinHeight
|
||||
? contentHeight
|
||||
: _searchHeaderFiltersMinHeight;
|
||||
}
|
||||
|
||||
class SearchPage extends HookConsumerWidget {
|
||||
const SearchPage({super.key});
|
||||
const SearchPage({this.tabReselection, super.key});
|
||||
|
||||
/// Notifies this page when its already-selected tab is tapped again.
|
||||
final ValueListenable<int>? tabReselection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
@@ -59,202 +116,306 @@ class SearchPage extends HookConsumerWidget {
|
||||
final activeFilter = useState(_SearchFilter.all);
|
||||
final textController = useTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final isSearchFocused = useListenableSelector(
|
||||
focusNode,
|
||||
() => focusNode.hasFocus,
|
||||
);
|
||||
final isSearchEditing = useState(false);
|
||||
final showSearchTitle = useState(true);
|
||||
final isTabActivationInFlight = useRef(false);
|
||||
final reduceMotion = MediaQuery.disableAnimationsOf(context);
|
||||
final isBuzzTheme = context.appColors.topSectionGradient != null;
|
||||
final buzzSearchColor = context.theme.brightness == Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black;
|
||||
final searchSurfaceColor = isBuzzTheme
|
||||
? buzzSearchColor.withValues(alpha: 0.04)
|
||||
: context.colors.surfaceContainerHighest;
|
||||
final searchMutedColor = isBuzzTheme
|
||||
? buzzSearchColor.withValues(alpha: 0.4)
|
||||
: context.colors.onSurfaceVariant;
|
||||
final searchSurfaceColor = navigationSearchSurface(context);
|
||||
final searchPrimaryColor = navigationPrimaryForeground(context);
|
||||
final searchPlaceholderColor = navigationSecondaryForeground(context);
|
||||
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w600,
|
||||
);
|
||||
final searchFieldHeight = _searchFieldHeight(context);
|
||||
final searchControlHeight = searchFieldHeight > Grid.xl
|
||||
? searchFieldHeight
|
||||
final compactSearchFieldHeight = _searchFieldHeight(context);
|
||||
final idleSearchFieldHeight = _idleSearchFieldHeight(context);
|
||||
final searchHeaderFiltersHeight = _searchHeaderFiltersHeight(context);
|
||||
final searchActiveFieldRightInset = _searchActiveFieldRightInset(context);
|
||||
// Cancel remains an accessible target without giving the text action a
|
||||
// visual button treatment.
|
||||
final searchControlHeight = compactSearchFieldHeight > Grid.xl
|
||||
? compactSearchFieldHeight
|
||||
: Grid.xl;
|
||||
final searchHeaderBottomHeight = searchControlHeight + Grid.twelve;
|
||||
final searchHeaderBottomHeight = isSearchEditing.value
|
||||
? _searchIdleFieldTopInset +
|
||||
compactSearchFieldHeight +
|
||||
searchHeaderFiltersHeight
|
||||
: idleSearchFieldHeight + _searchIdleFieldTopInset + Grid.xxs;
|
||||
final topSectionHeight = frostedAppBarHeight(
|
||||
context,
|
||||
titleStyle: headerTitleStyle,
|
||||
bottomHeight: searchHeaderBottomHeight,
|
||||
);
|
||||
|
||||
void activateSearch() {
|
||||
showSearchTitle.value = false;
|
||||
isSearchEditing.value = true;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (context.mounted && isSearchEditing.value) {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void deactivateSearch() {
|
||||
if (!isSearchEditing.value) return;
|
||||
// The field is painted above the title while it returns to its idle
|
||||
// position. Start this fade now so the title is already there beneath it
|
||||
// and is revealed by the field's motion instead of arriving afterward.
|
||||
showSearchTitle.value = true;
|
||||
isSearchEditing.value = false;
|
||||
}
|
||||
|
||||
useEffect(() {
|
||||
final tabReselection = this.tabReselection;
|
||||
if (tabReselection == null) return null;
|
||||
|
||||
void reactivateSearch() {
|
||||
// The same tab gesture can report focus loss after this callback. Keep
|
||||
// that notification from starting a competing return animation while
|
||||
// the normal field activation path restores focus.
|
||||
isTabActivationInFlight.value = true;
|
||||
activateSearch();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
isTabActivationInFlight.value = false;
|
||||
});
|
||||
}
|
||||
|
||||
tabReselection.addListener(reactivateSearch);
|
||||
return () => tabReselection.removeListener(reactivateSearch);
|
||||
}, [tabReselection, focusNode]);
|
||||
|
||||
useEffect(() {
|
||||
void resetIdlePromptWhenFocusLeaves() {
|
||||
if (!focusNode.hasFocus && !isTabActivationInFlight.value) {
|
||||
deactivateSearch();
|
||||
}
|
||||
}
|
||||
|
||||
focusNode.addListener(resetIdlePromptWhenFocusLeaves);
|
||||
return () => focusNode.removeListener(resetIdlePromptWhenFocusLeaves);
|
||||
}, [focusNode]);
|
||||
|
||||
void runRecentSearch(String query) {
|
||||
textController.value = TextEditingValue(
|
||||
text: query,
|
||||
selection: TextSelection.collapsed(offset: query.length),
|
||||
);
|
||||
focusNode.requestFocus();
|
||||
activateSearch();
|
||||
ref.read(recentSearchesProvider.notifier).record(query);
|
||||
ref.read(searchProvider.notifier).search(query);
|
||||
}
|
||||
|
||||
return FrostedScaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
backgroundColor: context.colors.surface,
|
||||
// Keep the empty state centered in the page rather than the portion left
|
||||
// above the keyboard.
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: FrostedAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
gradient: context.appColors.topSectionGradient,
|
||||
title: const Text('Search'),
|
||||
horizontalInset: Grid.twelve,
|
||||
showBottomDivider: true,
|
||||
bottomDividerOpacity: 0.06,
|
||||
titleStyle: headerTitleStyle,
|
||||
bottomHeight: searchHeaderBottomHeight,
|
||||
bottom: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
Grid.gutter,
|
||||
0,
|
||||
Grid.gutter,
|
||||
Grid.twelve,
|
||||
// Keep this mounted through the search-field morph so it can fade in
|
||||
// beneath the returning field rather than popping in afterward.
|
||||
title: IgnorePointer(
|
||||
child: AnimatedOpacity(
|
||||
key: const Key('search-header-title-opacity'),
|
||||
duration: reduceMotion ? Duration.zero : _searchTitleReturnDuration,
|
||||
curve: Curves.easeOutCubic,
|
||||
opacity: showSearchTitle.value ? 1 : 0,
|
||||
child: Text(
|
||||
'Search',
|
||||
key: const ValueKey('search-header-title'),
|
||||
style: headerTitleStyle,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
key: const Key('search-field-container'),
|
||||
height: searchFieldHeight,
|
||||
padding: const EdgeInsets.symmetric(horizontal: Grid.half),
|
||||
decoration: BoxDecoration(
|
||||
color: searchSurfaceColor,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: TextField(
|
||||
key: const Key('search-field'),
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: isSearchFocused ? null : _searchFieldHint,
|
||||
hintStyle: searchInputTextStyle.copyWith(
|
||||
color: searchMutedColor,
|
||||
),
|
||||
prefixIcon: Icon(
|
||||
LucideIcons.search,
|
||||
size: 16,
|
||||
color: searchMutedColor,
|
||||
),
|
||||
prefixIconConstraints: const BoxConstraints(minWidth: 32),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: _searchFieldVerticalPadding,
|
||||
),
|
||||
),
|
||||
style: searchInputTextStyle.copyWith(
|
||||
color: context.colors.onSurface,
|
||||
),
|
||||
textInputAction: TextInputAction.search,
|
||||
onChanged: (value) =>
|
||||
ref.read(searchProvider.notifier).search(value),
|
||||
onSubmitted: (value) {
|
||||
final query = value.trim();
|
||||
if (query.isEmpty) return;
|
||||
ref.read(recentSearchesProvider.notifier).record(query);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
AnimatedSwitcher(
|
||||
duration: reduceMotion ? Duration.zero : _searchCancelEnterDuration,
|
||||
reverseDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: _searchCancelExitDuration,
|
||||
transitionBuilder: (child, animation) {
|
||||
final curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return SizeTransition(
|
||||
sizeFactor: curvedAnimation,
|
||||
axis: Axis.horizontal,
|
||||
axisAlignment: 1,
|
||||
child: FadeTransition(
|
||||
opacity: curvedAnimation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.35, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: isSearchEditing.value
|
||||
? Semantics(
|
||||
key: const Key('search-cancel'),
|
||||
button: true,
|
||||
label: 'Cancel search',
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () {
|
||||
textController.clear();
|
||||
ref.read(searchProvider.notifier).clear();
|
||||
deactivateSearch();
|
||||
focusNode.unfocus();
|
||||
},
|
||||
child: SizedBox(
|
||||
height: searchControlHeight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half,
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: filterChipTextStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(key: ValueKey('search-cancel-hidden')),
|
||||
),
|
||||
],
|
||||
bottomHeight: searchHeaderBottomHeight,
|
||||
bottomOverlap: _searchBottomOverlap,
|
||||
bottom: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
AnimatedPositioned(
|
||||
duration: reduceMotion ? Duration.zero : _searchFieldMoveDuration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
left: Grid.gutter,
|
||||
right: isSearchEditing.value
|
||||
? searchActiveFieldRightInset
|
||||
: Grid.gutter,
|
||||
top: isSearchEditing.value
|
||||
? _searchIdleFieldTopInset
|
||||
: _searchBottomOverlap + _searchIdleFieldTopInset,
|
||||
height: isSearchEditing.value
|
||||
? compactSearchFieldHeight
|
||||
: idleSearchFieldHeight,
|
||||
// Do not key this subtree by the visual state: replacing the
|
||||
// TextField immediately after its first tap can detach its
|
||||
// native input connection before the keyboard is shown.
|
||||
child: SizedBox(
|
||||
key: const Key('search-field-container'),
|
||||
child: _SearchMotionField(
|
||||
controller: textController,
|
||||
focusNode: focusNode,
|
||||
iconColor: searchPrimaryColor,
|
||||
inputColor: searchPrimaryColor,
|
||||
placeholderColor: searchPlaceholderColor,
|
||||
surfaceColor: searchSurfaceColor,
|
||||
isSearchEditing: isSearchEditing.value,
|
||||
reduceMotion: reduceMotion,
|
||||
motionDuration: _searchFieldMoveDuration,
|
||||
onTap: activateSearch,
|
||||
onChanged: (value) =>
|
||||
ref.read(searchProvider.notifier).search(value),
|
||||
onSubmitted: (value) {
|
||||
final query = value.trim();
|
||||
if (query.isEmpty) return;
|
||||
ref.read(recentSearchesProvider.notifier).record(query);
|
||||
},
|
||||
),
|
||||
),
|
||||
AnimatedSwitcher(
|
||||
),
|
||||
Positioned.fill(
|
||||
child: AnimatedSwitcher(
|
||||
duration: reduceMotion
|
||||
? Duration.zero
|
||||
: _searchCancelEnterDuration,
|
||||
reverseDuration: reduceMotion
|
||||
? Duration.zero
|
||||
: _searchCancelExitDuration,
|
||||
transitionBuilder: (child, animation) {
|
||||
final curvedAnimation = CurvedAnimation(
|
||||
parent: animation,
|
||||
curve: Curves.easeOutCubic,
|
||||
reverseCurve: Curves.easeInCubic,
|
||||
);
|
||||
return SizeTransition(
|
||||
sizeFactor: curvedAnimation,
|
||||
axis: Axis.horizontal,
|
||||
axisAlignment: 1,
|
||||
child: FadeTransition(
|
||||
opacity: curvedAnimation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0.35, 0),
|
||||
end: Offset.zero,
|
||||
).animate(curvedAnimation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: isSearchFocused
|
||||
? Padding(
|
||||
key: const ValueKey('search-cancel-visible'),
|
||||
padding: const EdgeInsets.only(left: Grid.xxs),
|
||||
child: TextButton(
|
||||
key: const Key('search-cancel'),
|
||||
onPressed: () {
|
||||
textController.clear();
|
||||
ref.read(searchProvider.notifier).clear();
|
||||
focusNode.unfocus();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: context.colors.primary,
|
||||
minimumSize: Size(0, searchControlHeight),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: Grid.half,
|
||||
vertical: Grid.xxs,
|
||||
),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
),
|
||||
child: Text(
|
||||
'Cancel',
|
||||
style: filterChipTextStyle.copyWith(
|
||||
color: context.colors.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
position: Tween<Offset>(
|
||||
begin: const Offset(0, 0.2),
|
||||
end: Offset.zero,
|
||||
).animate(animation),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: isSearchEditing.value
|
||||
? Align(
|
||||
alignment: Alignment.topCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: _searchBottomOverlap),
|
||||
child: SizedBox(
|
||||
key: const ValueKey('search-header-filters'),
|
||||
height: searchHeaderFiltersHeight,
|
||||
child: FilterChipBar<_SearchFilter>(
|
||||
expandItems: true,
|
||||
visualDensity: const VisualDensity(
|
||||
horizontal: -2,
|
||||
),
|
||||
chipVerticalPadding:
|
||||
_searchFilterChipVerticalPadding,
|
||||
barVerticalPadding:
|
||||
_searchFilterBarVerticalPadding,
|
||||
selected: activeFilter.value,
|
||||
onSelected: (f) => activeFilter.value = f,
|
||||
items: [
|
||||
for (final f in _SearchFilter.values)
|
||||
FilterChipItem(id: f, label: f.label),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(
|
||||
key: ValueKey('search-cancel-hidden'),
|
||||
key: ValueKey('search-header-filters-hidden'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: const [],
|
||||
),
|
||||
body: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: frostedAppBarHeight(
|
||||
context,
|
||||
bottomHeight: searchHeaderBottomHeight,
|
||||
titleStyle: headerTitleStyle,
|
||||
),
|
||||
),
|
||||
FilterChipBar<_SearchFilter>(
|
||||
expandItems: true,
|
||||
visualDensity: const VisualDensity(horizontal: -2),
|
||||
chipVerticalPadding: Grid.xxs,
|
||||
barVerticalPadding: Grid.twelve,
|
||||
selected: activeFilter.value,
|
||||
onSelected: (f) => activeFilter.value = f,
|
||||
items: [
|
||||
for (final f in _SearchFilter.values)
|
||||
FilterChipItem(id: f, label: f.label),
|
||||
],
|
||||
),
|
||||
SizedBox(height: topSectionHeight),
|
||||
Expanded(
|
||||
child: _SearchBody(
|
||||
state: searchState,
|
||||
filter: activeFilter.value,
|
||||
currentPubkey: currentPubkey,
|
||||
onRecentSearchSelected: runRecentSearch,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.vertical(
|
||||
top: Radius.circular(Radii.dialog),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color: context.colors.surface,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SearchBody(
|
||||
state: searchState,
|
||||
filter: activeFilter.value,
|
||||
currentPubkey: currentPubkey,
|
||||
onRecentSearchSelected: runRecentSearch,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
113
mobile/lib/features/search/search_page/motion_field.dart
Normal file
113
mobile/lib/features/search/search_page/motion_field.dart
Normal file
@@ -0,0 +1,113 @@
|
||||
part of '../search_page.dart';
|
||||
|
||||
const _searchIdleIconSize = 26.0;
|
||||
const _searchCompactIconSize = 18.0;
|
||||
const _searchFieldHint = 'Search messages, channels, and people';
|
||||
const _searchIdleIconInset = Grid.xxs;
|
||||
const _searchIdleTextInset =
|
||||
_searchIdleIconInset + _searchIdleIconSize + Grid.xxs;
|
||||
const _searchCompactTextInset =
|
||||
_searchIdleIconInset + _searchCompactIconSize + Grid.xxs;
|
||||
|
||||
class _SearchMotionField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final Color iconColor;
|
||||
final Color inputColor;
|
||||
final Color placeholderColor;
|
||||
final Color surfaceColor;
|
||||
final bool isSearchEditing;
|
||||
final bool reduceMotion;
|
||||
final Duration motionDuration;
|
||||
final VoidCallback onTap;
|
||||
final ValueChanged<String> onChanged;
|
||||
final ValueChanged<String> onSubmitted;
|
||||
|
||||
const _SearchMotionField({
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
required this.iconColor,
|
||||
required this.inputColor,
|
||||
required this.placeholderColor,
|
||||
required this.surfaceColor,
|
||||
required this.isSearchEditing,
|
||||
required this.reduceMotion,
|
||||
required this.motionDuration,
|
||||
required this.onTap,
|
||||
required this.onChanged,
|
||||
required this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: surfaceColor,
|
||||
borderRadius: BorderRadius.circular(Radii.lg),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: TextField(
|
||||
key: const Key('search-field'),
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
decoration: InputDecoration(
|
||||
hintText: isSearchEditing ? null : _searchFieldHint,
|
||||
hintStyle: searchInputTextStyle.copyWith(
|
||||
color: placeholderColor,
|
||||
fontSize: _searchIdleTextSize,
|
||||
height: 20 / _searchIdleTextSize,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.only(
|
||||
left: isSearchEditing
|
||||
? _searchCompactTextInset
|
||||
: _searchIdleTextInset,
|
||||
right: Grid.xxs,
|
||||
top: isSearchEditing ? _searchFieldVerticalPadding : 0,
|
||||
bottom: isSearchEditing ? _searchFieldVerticalPadding : 0,
|
||||
),
|
||||
),
|
||||
style: searchInputTextStyle.copyWith(color: inputColor),
|
||||
textAlignVertical: TextAlignVertical.center,
|
||||
textAlign: TextAlign.start,
|
||||
textInputAction: TextInputAction.search,
|
||||
onTap: onTap,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: _searchIdleIconInset),
|
||||
child: AnimatedScale(
|
||||
duration: reduceMotion ? Duration.zero : motionDuration,
|
||||
curve: Curves.easeInOutCubic,
|
||||
scale: isSearchEditing
|
||||
? _searchCompactIconSize / _searchIdleIconSize
|
||||
: 1,
|
||||
child: Icon(
|
||||
LucideIcons.search,
|
||||
key: const Key('search-moving-icon'),
|
||||
size: _searchIdleIconSize,
|
||||
color: iconColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
@@ -28,17 +31,38 @@ class SettingsPage extends HookConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final packageInfoFuture = useMemoized(() => PackageInfo.fromPlatform());
|
||||
final packageInfo = useFuture(packageInfoFuture);
|
||||
final topSectionHeight = frostedAppBarHeight(
|
||||
context,
|
||||
bottomHeight: Grid.xxs,
|
||||
);
|
||||
|
||||
return FrostedScaffold(
|
||||
appBar: const FrostedAppBar(title: Text('Settings')),
|
||||
backgroundColor: context.colors.surface,
|
||||
appBar: FrostedAppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
horizontalInset: Grid.gutter,
|
||||
showBottomDivider: false,
|
||||
leading: SizedBox(
|
||||
width: Grid.xl,
|
||||
height: Grid.xl,
|
||||
child: IconButton(
|
||||
tooltip: 'Close settings',
|
||||
onPressed: () {
|
||||
unawaited(HapticFeedback.lightImpact());
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
color: navigationPrimaryForeground(context),
|
||||
icon: const Icon(LucideIcons.x),
|
||||
),
|
||||
),
|
||||
bottomHeight: Grid.xxs,
|
||||
bottom: const SizedBox.expand(),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.only(
|
||||
top: frostedAppBarHeight(context),
|
||||
bottom: Grid.xs,
|
||||
),
|
||||
padding: EdgeInsets.only(top: topSectionHeight, bottom: Grid.xs),
|
||||
children: [
|
||||
profileHeader,
|
||||
const _AppearanceSection(),
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// Name of the first-party Buzz theme. Buzz reuses the GitHub Light palette for
|
||||
/// every base color; the one thing that sets it apart is a branded gradient
|
||||
/// painted across the app's top section. Mirrors desktop, where the same
|
||||
@@ -17,6 +19,50 @@ const buzzDarkThemeName = 'buzz-dark';
|
||||
bool isBuzzTheme(String themeName) =>
|
||||
themeName == buzzThemeName || themeName == buzzDarkThemeName;
|
||||
|
||||
/// Whether the current widget tree is using the first-party Buzz treatment.
|
||||
bool isBuzzThemeContext(BuildContext context) =>
|
||||
Theme.of(context).extension<AppColors>()?.topSectionGradient != null;
|
||||
|
||||
/// Primary foreground for the mobile top navigation.
|
||||
///
|
||||
/// Every theme uses its own [ColorScheme.onSurface]. Buzz is the exception:
|
||||
/// its desktop-matching top gradient needs a neutral black or white foreground
|
||||
/// rather than the accent-derived color scheme foreground.
|
||||
Color navigationPrimaryForeground(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
if (!isBuzzThemeContext(context)) return scheme.onSurface;
|
||||
return scheme.brightness == Brightness.dark ? Colors.white : Colors.black;
|
||||
}
|
||||
|
||||
/// Secondary label and placeholder foreground for the mobile top navigation.
|
||||
Color navigationSecondaryForeground(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
|
||||
return navigationPrimaryForeground(context).withValues(alpha: 0.4);
|
||||
}
|
||||
|
||||
/// Channel-section label and icon foreground for the mobile side navigation.
|
||||
///
|
||||
/// Section labels need more hierarchy than a placeholder. Buzz therefore uses
|
||||
/// a stronger neutral over its gradient, while all other themes preserve their
|
||||
/// established secondary foreground token.
|
||||
Color navigationSectionForeground(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
if (!isBuzzThemeContext(context)) return scheme.onSurfaceVariant;
|
||||
return navigationPrimaryForeground(context).withValues(alpha: 0.8);
|
||||
}
|
||||
|
||||
/// Search-field surface for the mobile top navigation.
|
||||
Color navigationSearchSurface(BuildContext context) {
|
||||
final scheme = Theme.of(context).colorScheme;
|
||||
if (!isBuzzThemeContext(context)) return scheme.surfaceContainerHighest;
|
||||
return navigationPrimaryForeground(context).withValues(alpha: 0.04);
|
||||
}
|
||||
|
||||
/// A low-contrast navigation divider derived from the active theme foreground.
|
||||
Color navigationDivider(BuildContext context, double opacity) =>
|
||||
navigationPrimaryForeground(context).withValues(alpha: opacity);
|
||||
|
||||
/// Gradient stops, matching desktop's `--buzz-gradient-*` custom properties.
|
||||
const _lightTop = Color(0xFFE6E6B6);
|
||||
const _lightBottom = Color(0xFFC4D0DA);
|
||||
|
||||
@@ -32,6 +32,9 @@ enum AnchoredPopoverAlignment {
|
||||
/// Aligns the popover's leading edge with the trigger's leading edge.
|
||||
start,
|
||||
|
||||
/// Centers the popover horizontally on the trigger.
|
||||
center,
|
||||
|
||||
/// Aligns the popover's trailing edge with the trigger's trailing edge.
|
||||
end,
|
||||
}
|
||||
@@ -160,6 +163,7 @@ class _AnchoredPopoverRoute<T> extends PopupRoute<T> {
|
||||
).animate(curvedAnimation);
|
||||
final transformOrigin = switch (alignment) {
|
||||
AnchoredPopoverAlignment.start => Alignment.topLeft,
|
||||
AnchoredPopoverAlignment.center => Alignment.topCenter,
|
||||
AnchoredPopoverAlignment.end => Alignment.topRight,
|
||||
};
|
||||
|
||||
@@ -234,6 +238,11 @@ class _AnchoredPopoverLayoutDelegate extends SingleChildLayoutDelegate {
|
||||
final anchorBottom = size.height - position.bottom;
|
||||
final desiredX = switch (alignment) {
|
||||
AnchoredPopoverAlignment.start => position.left + offset.dx,
|
||||
AnchoredPopoverAlignment.center =>
|
||||
position.left +
|
||||
(size.width - position.left - position.right - childSize.width) /
|
||||
2 +
|
||||
offset.dx,
|
||||
AnchoredPopoverAlignment.end =>
|
||||
size.width - position.right - childSize.width + offset.dx,
|
||||
};
|
||||
|
||||
@@ -36,6 +36,22 @@ double _barContentHeight(
|
||||
: _kBarContentMinHeight;
|
||||
}
|
||||
|
||||
/// Height for a compact title rail below the app bar's action row.
|
||||
///
|
||||
/// The rail normally stays at 40dp, but grows with an accessible title rather
|
||||
/// than clipping text at larger system text sizes.
|
||||
double frostedAppBarLowerTitleHeight(
|
||||
BuildContext context, {
|
||||
TextStyle? titleStyle,
|
||||
}) {
|
||||
final style = _effectiveTitleStyle(context, titleStyle);
|
||||
final scaledFontSize = MediaQuery.textScalerOf(
|
||||
context,
|
||||
).scale(style.fontSize ?? 20);
|
||||
final titleHeight = scaledFontSize * (style.height ?? 1);
|
||||
return titleHeight > 40 ? titleHeight : 40;
|
||||
}
|
||||
|
||||
/// Returns the total height of the [FrostedAppBar] including safe area padding.
|
||||
///
|
||||
/// Use this to add top spacing to body content so it starts below the bar.
|
||||
@@ -82,6 +98,11 @@ class FrostedAppBar extends StatelessWidget {
|
||||
/// Height reserved for [bottom].
|
||||
final double bottomHeight;
|
||||
|
||||
/// Extends [bottom] upward into the title row without moving the app bar's
|
||||
/// outer bounds. This keeps overlapping controls inside the app bar's hit
|
||||
/// test region as well as its paint region.
|
||||
final double bottomOverlap;
|
||||
|
||||
/// Widgets displayed on the trailing (right) side.
|
||||
final List<Widget> actions;
|
||||
|
||||
@@ -96,6 +117,24 @@ class FrostedAppBar extends StatelessWidget {
|
||||
/// top section — see [buzzTopSectionGradient].
|
||||
final Gradient? gradient;
|
||||
|
||||
/// Whether to apply the translucent blur treatment behind the app bar.
|
||||
///
|
||||
/// A page can leave its painted backdrop exposed at rest, then turn this on
|
||||
/// when scrolling moves content beneath the controls.
|
||||
final bool frosted;
|
||||
|
||||
/// Opacity of the frosted surface above the blurred backdrop.
|
||||
final double frostedSurfaceOpacity;
|
||||
|
||||
/// Blur strength of the frosted backdrop.
|
||||
final double frostedBlurSigma;
|
||||
|
||||
/// Whether to draw a divider below the app bar.
|
||||
final bool showBottomDivider;
|
||||
|
||||
/// Opacity of the divider below the app bar.
|
||||
final double bottomDividerOpacity;
|
||||
|
||||
const FrostedAppBar({
|
||||
super.key,
|
||||
this.leading,
|
||||
@@ -105,11 +144,21 @@ class FrostedAppBar extends StatelessWidget {
|
||||
this.titleContentHeight = 0,
|
||||
this.bottom,
|
||||
this.bottomHeight = 0,
|
||||
this.bottomOverlap = 0,
|
||||
this.actions = const [],
|
||||
this.horizontalInset = Grid.quarter,
|
||||
this.iconColor,
|
||||
this.gradient,
|
||||
}) : assert(bottom == null || bottomHeight > 0);
|
||||
this.frosted = true,
|
||||
this.frostedSurfaceOpacity = 0.5,
|
||||
this.frostedBlurSigma = 20,
|
||||
this.showBottomDivider = true,
|
||||
this.bottomDividerOpacity = 0.15,
|
||||
}) : assert(bottom == null || bottomHeight > 0),
|
||||
assert(bottomOverlap >= 0),
|
||||
assert(bottom != null || bottomOverlap == 0),
|
||||
assert(frostedBlurSigma >= 0),
|
||||
assert(bottomDividerOpacity >= 0 && bottomDividerOpacity <= 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -137,86 +186,112 @@ class FrostedAppBar extends StatelessWidget {
|
||||
)
|
||||
: null);
|
||||
|
||||
return Positioned(
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: ClipRect(
|
||||
child: BackdropFilter(
|
||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||
child: Container(
|
||||
key: const ValueKey('frosted-app-bar-background'),
|
||||
padding: EdgeInsets.only(top: topPadding),
|
||||
decoration: BoxDecoration(
|
||||
// A gradient and a color cannot both paint, so the gradient
|
||||
// replaces the frosted surface fill when one is supplied.
|
||||
color: gradient == null
|
||||
? context.colors.surface.withValues(alpha: 0.5)
|
||||
: null,
|
||||
gradient: gradient,
|
||||
border: Border(
|
||||
bottom: BorderSide(
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.3),
|
||||
width: _kBottomBorderWidth,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: DirectionalTransitionMotion(
|
||||
transformKey: const ValueKey(
|
||||
'frosted-app-bar-content-transition-transform',
|
||||
),
|
||||
opacityKey: const ValueKey(
|
||||
'frosted-app-bar-content-transition-opacity',
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: barContentHeight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalInset,
|
||||
),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(color: iconColor),
|
||||
child: Row(
|
||||
children: [
|
||||
?effectiveLeading,
|
||||
if (title != null)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: effectiveLeading != null
|
||||
? 0
|
||||
: Grid.gutter - Grid.quarter,
|
||||
right: actions.isEmpty
|
||||
? Grid.gutter - Grid.quarter
|
||||
: 0,
|
||||
),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: effectiveTitleStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
child: title!,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
final titleRow = SizedBox(
|
||||
height: barContentHeight,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalInset),
|
||||
child: IconTheme.merge(
|
||||
data: IconThemeData(color: iconColor),
|
||||
child: Row(
|
||||
children: [
|
||||
?effectiveLeading,
|
||||
if (title != null)
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
left: effectiveLeading != null
|
||||
? 0
|
||||
: horizontalInset < Grid.gutter
|
||||
? Grid.gutter - horizontalInset
|
||||
: 0,
|
||||
right: actions.isEmpty
|
||||
? horizontalInset < Grid.gutter
|
||||
? Grid.gutter - horizontalInset
|
||||
: 0
|
||||
: 0,
|
||||
),
|
||||
child: DefaultTextStyle.merge(
|
||||
style: effectiveTitleStyle,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
maxLines: 1,
|
||||
child: title!,
|
||||
),
|
||||
),
|
||||
if (bottom != null)
|
||||
SizedBox(height: bottomHeight, child: bottom),
|
||||
],
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
const Spacer(),
|
||||
...actions,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final contentBody = bottom != null && bottomOverlap > 0
|
||||
? SizedBox(
|
||||
height: barContentHeight + bottomHeight,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned(top: 0, left: 0, right: 0, child: titleRow),
|
||||
Positioned(
|
||||
top: barContentHeight - bottomOverlap,
|
||||
left: 0,
|
||||
right: 0,
|
||||
height: bottomHeight + bottomOverlap,
|
||||
child: bottom!,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
titleRow,
|
||||
if (bottom != null) SizedBox(height: bottomHeight, child: bottom),
|
||||
],
|
||||
);
|
||||
|
||||
final content = DirectionalTransitionMotion(
|
||||
transformKey: const ValueKey(
|
||||
'frosted-app-bar-content-transition-transform',
|
||||
),
|
||||
opacityKey: const ValueKey('frosted-app-bar-content-transition-opacity'),
|
||||
child: contentBody,
|
||||
);
|
||||
|
||||
final background = Container(
|
||||
key: const ValueKey('frosted-app-bar-background'),
|
||||
padding: EdgeInsets.only(top: topPadding),
|
||||
decoration: BoxDecoration(
|
||||
color: !frosted
|
||||
? Colors.transparent
|
||||
: gradient == null
|
||||
? context.colors.surface.withValues(alpha: frostedSurfaceOpacity)
|
||||
: null,
|
||||
gradient: gradient,
|
||||
border: showBottomDivider
|
||||
? Border(
|
||||
bottom: BorderSide(
|
||||
color: navigationDivider(context, bottomDividerOpacity),
|
||||
width: _kBottomBorderWidth,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: content,
|
||||
);
|
||||
|
||||
final child = ClipRect(
|
||||
child: frosted
|
||||
? BackdropFilter(
|
||||
filter: ImageFilter.blur(
|
||||
sigmaX: frostedBlurSigma,
|
||||
sigmaY: frostedBlurSigma,
|
||||
),
|
||||
child: background,
|
||||
)
|
||||
: background,
|
||||
);
|
||||
|
||||
return Positioned(top: 0, left: 0, right: 0, child: child);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,9 @@ class FrostedScaffold extends StatelessWidget {
|
||||
/// surface behind this page.
|
||||
final Color? backgroundColor;
|
||||
|
||||
/// A fixed gradient painted behind the app bar and scrolling body.
|
||||
final Gradient? backgroundGradient;
|
||||
|
||||
const FrostedScaffold({
|
||||
super.key,
|
||||
required this.appBar,
|
||||
@@ -33,6 +36,7 @@ class FrostedScaffold extends StatelessWidget {
|
||||
this.floatingActionButton,
|
||||
this.resizeToAvoidBottomInset,
|
||||
this.backgroundColor,
|
||||
this.backgroundGradient,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -41,20 +45,41 @@ class FrostedScaffold extends StatelessWidget {
|
||||
backgroundColor: backgroundColor,
|
||||
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
||||
floatingActionButton: floatingActionButton,
|
||||
body: Stack(
|
||||
children: [
|
||||
DirectionalTransitionMotion(
|
||||
transformKey: const ValueKey(
|
||||
'frosted-scaffold-body-transition-transform',
|
||||
),
|
||||
opacityKey: const ValueKey(
|
||||
'frosted-scaffold-body-transition-opacity',
|
||||
),
|
||||
child: body,
|
||||
),
|
||||
appBar,
|
||||
],
|
||||
),
|
||||
body: Stack(children: _stackChildren()),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _stackChildren() {
|
||||
final backdrop = backgroundGradient == null
|
||||
? const <Widget>[]
|
||||
: [
|
||||
Positioned.fill(
|
||||
child: _PinnedGradientBackground(gradient: backgroundGradient!),
|
||||
),
|
||||
];
|
||||
final bodyMotion = DirectionalTransitionMotion(
|
||||
transformKey: const ValueKey(
|
||||
'frosted-scaffold-body-transition-transform',
|
||||
),
|
||||
opacityKey: const ValueKey('frosted-scaffold-body-transition-opacity'),
|
||||
child: body,
|
||||
);
|
||||
// The bar must be painted after the scrollable sheet: [BackdropFilter]
|
||||
// only samples pixels that were already painted behind it. This is the
|
||||
// same composition as channel navigation, so top-level headers blur their
|
||||
// content rather than only the fixed gradient.
|
||||
return [...backdrop, bodyMotion, appBar];
|
||||
}
|
||||
}
|
||||
|
||||
class _PinnedGradientBackground extends StatelessWidget {
|
||||
final Gradient gradient;
|
||||
|
||||
const _PinnedGradientBackground({required this.gradient});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => DecoratedBox(
|
||||
key: const ValueKey('frosted-scaffold-pinned-gradient'),
|
||||
decoration: BoxDecoration(gradient: gradient),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,13 +8,8 @@ const mobileTabBarHeight = 56.0;
|
||||
/// Gap between the floating mobile tab bar and the bottom safe area.
|
||||
const mobileTabBarBottomGap = Grid.twelve;
|
||||
|
||||
/// Returns the shared footer backdrop height, including the logical safe area.
|
||||
double mobileTabFooterBackdropHeight(BuildContext context) =>
|
||||
mobileTabBarHeight +
|
||||
mobileTabBarBottomGap +
|
||||
MediaQuery.paddingOf(context).bottom +
|
||||
Grid.xl +
|
||||
Grid.gutter;
|
||||
/// Fixed visual height of the shared footer fade behind the floating tab bar.
|
||||
double mobileTabFooterBackdropHeight(BuildContext _) => 180;
|
||||
|
||||
/// Builds the shared transparent-to-surface footer fade.
|
||||
///
|
||||
@@ -22,11 +17,14 @@ double mobileTabFooterBackdropHeight(BuildContext context) =>
|
||||
/// the channel composer can paint the exact same fade behind their own content.
|
||||
LinearGradient mobileTabFooterBackdropGradient(
|
||||
BuildContext context, {
|
||||
List<double> stops = const [0, 0.5, 1],
|
||||
List<double> opacities = const [0, 0.75, 1],
|
||||
List<double> stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
|
||||
List<double> opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
|
||||
Color? tint,
|
||||
double tintBlend = 0,
|
||||
}) {
|
||||
assert(stops.length == opacities.length);
|
||||
final surface = context.colors.surface;
|
||||
assert(tintBlend >= 0 && tintBlend <= 1);
|
||||
final surface = Color.lerp(context.colors.surface, tint, tintBlend)!;
|
||||
return LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
@@ -48,15 +46,24 @@ class MobileTabFooterBackdrop extends StatelessWidget {
|
||||
/// Surface-color alpha values paired with [stops].
|
||||
final List<double> opacities;
|
||||
|
||||
/// Optional color blended into the surface before opacity is applied.
|
||||
final Color? tint;
|
||||
|
||||
/// Amount of [tint] mixed into the surface, from 0 to 1.
|
||||
final double tintBlend;
|
||||
|
||||
/// Creates a footer backdrop with the required [height].
|
||||
///
|
||||
/// Override [stops] and [opacities] together to customize the gradient.
|
||||
const MobileTabFooterBackdrop({
|
||||
super.key,
|
||||
required this.height,
|
||||
this.stops = const [0, 0.5, 1],
|
||||
this.opacities = const [0, 0.75, 1],
|
||||
}) : assert(stops.length == opacities.length);
|
||||
this.stops = const [0, 0.18, 0.38, 0.6, 0.8, 1],
|
||||
this.opacities = const [0, 0.03, 0.12, 0.34, 0.7, 1],
|
||||
this.tint,
|
||||
this.tintBlend = 0,
|
||||
}) : assert(stops.length == opacities.length),
|
||||
assert(tintBlend >= 0 && tintBlend <= 1);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -69,6 +76,8 @@ class MobileTabFooterBackdrop extends StatelessWidget {
|
||||
context,
|
||||
stops: stops,
|
||||
opacities: opacities,
|
||||
tint: tint,
|
||||
tintBlend: tintBlend,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:buzz/features/activity/activity_page.dart';
|
||||
import 'package:buzz/features/activity/activity_provider.dart';
|
||||
import 'package:buzz/features/activity/feed_item.dart';
|
||||
@@ -115,6 +116,7 @@ void main() {
|
||||
List<Channel>? channels,
|
||||
TextScaler? textScaler,
|
||||
EdgeInsets mediaPadding = EdgeInsets.zero,
|
||||
ValueListenable<int>? tabReselection,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
@@ -143,7 +145,7 @@ void main() {
|
||||
).copyWith(textScaler: textScaler, padding: mediaPadding),
|
||||
child: child!,
|
||||
),
|
||||
home: const ActivityPage(),
|
||||
home: ActivityPage(tabReselection: tabReselection),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -181,11 +183,43 @@ void main() {
|
||||
await tester.pumpWidget(await buildTestable());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
|
||||
final appBar = tester.widget<FrostedAppBar>(
|
||||
find.byType(FrostedAppBar).last,
|
||||
);
|
||||
expect(appBar.automaticallyImplyLeading, isFalse);
|
||||
expect(appBar.gradient, isNull);
|
||||
expect(appBar.frosted, isTrue);
|
||||
expect(appBar.showBottomDivider, isTrue);
|
||||
expect(appBar.bottomHeight, Grid.xxs);
|
||||
expect(find.byTooltip('Back'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('sizes the Activity app bar for its custom title style', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(textScaler: const TextScaler.linear(2)),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(
|
||||
find.byType(FrostedAppBar).last,
|
||||
);
|
||||
final titleStyle = appBar.titleStyle!;
|
||||
expect(titleStyle.fontSize, 22);
|
||||
expect(
|
||||
tester.getSize(find.byType(ClipRect).last).height,
|
||||
closeTo(
|
||||
frostedAppBarHeight(
|
||||
tester.element(find.byType(FrostedAppBar).last),
|
||||
titleStyle: titleStyle,
|
||||
bottomHeight: Grid.xxs,
|
||||
),
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps footer clearance inside the scrollable content', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -200,8 +234,48 @@ void main() {
|
||||
expect(safeArea.top, isFalse);
|
||||
expect(safeArea.bottom, isFalse);
|
||||
|
||||
final list = tester.widget<ListView>(find.byType(ListView));
|
||||
expect(list.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96));
|
||||
final padding = tester.widget<SliverPadding>(
|
||||
find.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(SliverPadding),
|
||||
),
|
||||
);
|
||||
expect(padding.padding, const EdgeInsets.fromLTRB(0, Grid.xxs, 0, 96));
|
||||
});
|
||||
|
||||
testWidgets('scrolls Activity to the top when its tab is selected again', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 180);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final tabReselection = ValueNotifier(0);
|
||||
addTearDown(tabReselection.dispose);
|
||||
await tester.pumpWidget(
|
||||
await buildTestable(tabReselection: tabReselection),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(scrollable.position.maxScrollExtent, greaterThan(0));
|
||||
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
|
||||
tabReselection.value++;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
|
||||
expect(
|
||||
scrollable.position.pixels,
|
||||
lessThan(scrollable.position.maxScrollExtent),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(scrollable.position.pixels, scrollable.position.minScrollExtent);
|
||||
});
|
||||
|
||||
testWidgets('shows error view with retry button', (tester) async {
|
||||
@@ -285,7 +359,13 @@ void main() {
|
||||
|
||||
await tester.tap(find.descendant(of: surface, matching: find.text('All')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const ValueKey('activity-options-menu')));
|
||||
final optionsTrigger = find.byKey(const ValueKey('activity-options-menu'));
|
||||
expect(
|
||||
tester.getSize(optionsTrigger),
|
||||
const Size(Grid.xl, Grid.xl),
|
||||
reason: 'Activity options must retain a 48dp touch target.',
|
||||
);
|
||||
await tester.tap(optionsTrigger);
|
||||
await tester.pump();
|
||||
|
||||
final optionsSurface = find.byKey(
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:hooks_riverpod/misc.dart';
|
||||
@@ -22,6 +24,8 @@ import 'package:buzz/shared/community/community_icon_provider.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/avatar_image.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
|
||||
import 'package:buzz/shared/widgets/skeleton.dart';
|
||||
|
||||
void main() {
|
||||
@@ -34,6 +38,9 @@ void main() {
|
||||
Map<String, String?> communityIcons = const {},
|
||||
ValueChanged<String>? onCommunityIconLoad,
|
||||
TextScaler textScaler = TextScaler.noScaling,
|
||||
Gradient? topSectionGradient,
|
||||
ValueChanged<double>? onSettingsTransitionProgress,
|
||||
ValueListenable<int>? tabReselection,
|
||||
}) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
@@ -50,7 +57,7 @@ void main() {
|
||||
...overrides,
|
||||
],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
theme: AppTheme.light(topSectionGradient: topSectionGradient),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(context).copyWith(
|
||||
disableAnimations: disableAnimations,
|
||||
@@ -60,10 +67,15 @@ void main() {
|
||||
),
|
||||
child: child!,
|
||||
),
|
||||
home: const Stack(
|
||||
home: Stack(
|
||||
children: [
|
||||
ChannelsPage(settingsPageBuilder: _buildSettingsPage),
|
||||
Positioned.fill(
|
||||
ChannelsPage(
|
||||
settingsPageBuilder: _buildSettingsPage,
|
||||
onSettingsTransitionProgress:
|
||||
onSettingsTransitionProgress ?? (_) {},
|
||||
tabReselection: tabReselection,
|
||||
),
|
||||
const Positioned.fill(
|
||||
child: ChannelQuickActionsLauncher(
|
||||
visible: true,
|
||||
navigationBarHeight: 60,
|
||||
@@ -161,12 +173,65 @@ void main() {
|
||||
final text = tester.widget<Text>(find.text(label));
|
||||
expect(text.style?.fontSize, contentListTitleTextStyle.fontSize);
|
||||
expect(text.style?.height, contentListTitleTextStyle.height);
|
||||
expect(
|
||||
text.style?.color,
|
||||
Theme.of(
|
||||
tester.element(find.text(label)),
|
||||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
);
|
||||
}
|
||||
final channelIcon = tester.widget<Icon>(
|
||||
find.byKey(const ValueKey('channel-icon-1')),
|
||||
);
|
||||
expect(
|
||||
channelIcon.color,
|
||||
Theme.of(
|
||||
tester.element(find.byKey(const ValueKey('channel-icon-1'))),
|
||||
).colorScheme.onSurface.withValues(alpha: 0.8),
|
||||
);
|
||||
final sectionTitle = tester.widget<Text>(find.text('Channels'));
|
||||
expect(sectionTitle.style?.fontSize, contentListTitleTextStyle.fontSize);
|
||||
expect(sectionTitle.style?.fontWeight, FontWeight.w600);
|
||||
});
|
||||
|
||||
testWidgets('sizes the community header for accessible text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
textScaler: const TextScaler.linear(2),
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(
|
||||
find.byType(FrostedAppBar).last,
|
||||
);
|
||||
final titleStyle = appBar.titleStyle!;
|
||||
expect(titleStyle.fontSize, 22);
|
||||
expect(
|
||||
tester
|
||||
.getSize(
|
||||
find.descendant(
|
||||
of: find.byType(FrostedAppBar).last,
|
||||
matching: find.byType(ClipRect),
|
||||
),
|
||||
)
|
||||
.height,
|
||||
closeTo(
|
||||
frostedAppBarHeight(
|
||||
tester.element(find.byType(FrostedAppBar).last),
|
||||
titleStyle: titleStyle,
|
||||
bottomHeight: appBar.bottomHeight,
|
||||
) -
|
||||
1,
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('keeps the last channel above the floating tab bar', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -190,6 +255,149 @@ void main() {
|
||||
expect((padding.padding as EdgeInsets).bottom, footerClearance);
|
||||
});
|
||||
|
||||
testWidgets('balances an expanded section around its following divider', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final lastChannel = tester.getRect(find.text('general'));
|
||||
final divider = tester.getRect(find.byType(Divider).last);
|
||||
final nextSectionHeader = tester.getRect(find.text('DMs'));
|
||||
|
||||
expect(
|
||||
divider.top - lastChannel.bottom,
|
||||
closeTo(nextSectionHeader.top - divider.bottom, 0.01),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps the Buzz background fixed behind the channels list', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
topSectionGradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.yellow, Colors.blue],
|
||||
),
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(DecoratedSliver), findsNothing);
|
||||
final gradientBackground = tester.widget<DecoratedBox>(
|
||||
find.byKey(const ValueKey('frosted-scaffold-pinned-gradient')),
|
||||
);
|
||||
final gradient =
|
||||
(gradientBackground.decoration as BoxDecoration).gradient
|
||||
as LinearGradient;
|
||||
expect(gradient.end, Alignment.bottomCenter);
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(
|
||||
find.byType(FrostedAppBar).last,
|
||||
);
|
||||
expect(appBar.frosted, isFalse);
|
||||
expect(appBar.frostedSurfaceOpacity, 0);
|
||||
expect(appBar.frostedBlurSigma, 0);
|
||||
expect(appBar.showBottomDivider, isFalse);
|
||||
expect(appBar.bottomHeight, Grid.xxs);
|
||||
});
|
||||
|
||||
testWidgets('builds Home header frost progressively while scrolling', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 160);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
topSectionGradient: const LinearGradient(
|
||||
colors: [Colors.yellow, Colors.blue],
|
||||
),
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
expect(scrollable.position.maxScrollExtent, greaterThanOrEqualTo(Grid.xxl));
|
||||
|
||||
scrollable.position.jumpTo(Grid.xl / 2);
|
||||
await tester.pump();
|
||||
var appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
|
||||
expect(appBar.frosted, isTrue);
|
||||
expect(appBar.frostedSurfaceOpacity, 0);
|
||||
expect(appBar.frostedBlurSigma, closeTo(8.67, 0.001));
|
||||
|
||||
scrollable.position.jumpTo(Grid.xxl);
|
||||
await tester.pump();
|
||||
appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar).last);
|
||||
expect(appBar.frostedSurfaceOpacity, 0);
|
||||
expect(appBar.frostedBlurSigma, 23.12);
|
||||
});
|
||||
|
||||
testWidgets('scrolls Home to the top when its tab is selected again', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 160);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.reset);
|
||||
final tabReselection = ValueNotifier(0);
|
||||
addTearDown(tabReselection.dispose);
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
tabReselection: tabReselection,
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final scrollable = tester.state<ScrollableState>(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(CustomScrollView),
|
||||
matching: find.byType(Scrollable),
|
||||
)
|
||||
.first,
|
||||
);
|
||||
scrollable.position.jumpTo(scrollable.position.maxScrollExtent);
|
||||
tabReselection.value++;
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 130));
|
||||
|
||||
expect(
|
||||
scrollable.position.pixels,
|
||||
lessThan(scrollable.position.maxScrollExtent),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(scrollable.position.pixels, scrollable.position.minScrollExtent);
|
||||
});
|
||||
|
||||
testWidgets('truncates long custom section names beside the menu', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -322,7 +530,9 @@ void main() {
|
||||
final topLabelX = tester.getTopLeft(find.text('Community')).dx;
|
||||
final sectionLabelX = tester.getTopLeft(find.text('Channels')).dx;
|
||||
final rowLabelX = tester.getTopLeft(find.text('general')).dx;
|
||||
expect(topLabelX, sectionLabelX);
|
||||
// The community title shares the leading row with its avatar. Channel
|
||||
// labels stay aligned below it.
|
||||
expect(topLabelX, Grid.twelve + 40 + Grid.xxs);
|
||||
expect(sectionLabelX, rowLabelX);
|
||||
|
||||
relaySession.setReconnecting();
|
||||
@@ -344,6 +554,32 @@ void main() {
|
||||
expect(skeletonSectionLabelX, sectionLabelX);
|
||||
});
|
||||
|
||||
testWidgets('matches the community and profile avatar circle sizes', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = find.byType(FrostedAppBar).last;
|
||||
final communityAvatar = find.descendant(
|
||||
of: appBar,
|
||||
matching: find.byType(AvatarImage),
|
||||
);
|
||||
final profileAvatar = find.descendant(
|
||||
of: appBar,
|
||||
matching: find.byType(MaskedAvatarBadge),
|
||||
);
|
||||
|
||||
expect(tester.getSize(communityAvatar), const Size.square(40));
|
||||
expect(tester.getSize(profileAvatar), const Size.square(40));
|
||||
});
|
||||
|
||||
testWidgets('reveals channel content from same-slot reconnect skeletons', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -451,10 +687,128 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Hero), findsNothing);
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Injected settings'), findsOneWidget);
|
||||
final route = ModalRoute.of(tester.element(find.text('Injected settings')));
|
||||
expect(route, isNot(isA<MaterialPageRoute<void>>()));
|
||||
expect(route?.opaque, isFalse);
|
||||
});
|
||||
|
||||
testWidgets('reports Settings progress in both directions', (tester) async {
|
||||
final progress = <double>[];
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
onSettingsTransitionProgress: progress.add,
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
await tester.pumpAndSettle();
|
||||
expect(progress.any((value) => value > 0 && value < 1), isTrue);
|
||||
expect(progress.last, 1);
|
||||
|
||||
final reverseStart = progress.length;
|
||||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
progress.skip(reverseStart).any((value) => value > 0 && value < 1),
|
||||
isTrue,
|
||||
);
|
||||
expect(progress.last, 0);
|
||||
});
|
||||
|
||||
testWidgets('paints Settings content with its surface from the first frame', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
await tester.pump();
|
||||
|
||||
final transition = find.byKey(
|
||||
const ValueKey('settings-transition-opacity'),
|
||||
skipOffstage: false,
|
||||
);
|
||||
expect(transition, findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: transition,
|
||||
matching: find.byKey(
|
||||
const ValueKey('settings-transition-layer'),
|
||||
skipOffstage: false,
|
||||
),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(tester.widget<FadeTransition>(transition).opacity.value, 0.8);
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
expect(
|
||||
tester.widget<FadeTransition>(transition).opacity.value,
|
||||
inExclusiveRange(0.8, 1),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.widget<FadeTransition>(transition).opacity.value, 1);
|
||||
|
||||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 95));
|
||||
expect(
|
||||
tester.widget<FadeTransition>(transition).opacity.value,
|
||||
inExclusiveRange(0, 1),
|
||||
reason: 'The complete Settings layer still fades out on exit.',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('gives feedback for the profile and community controls', (
|
||||
tester,
|
||||
) async {
|
||||
final hapticCalls = <MethodCall>[];
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
|
||||
if (call.method == 'HapticFeedback.vibrate') hapticCalls.add(call);
|
||||
return null;
|
||||
});
|
||||
addTearDown(
|
||||
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(SystemChannels.platform, null),
|
||||
);
|
||||
await tester.pumpWidget(
|
||||
buildTestable(
|
||||
overrides: [
|
||||
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
|
||||
],
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byType(ProfileAvatar));
|
||||
await tester.pumpAndSettle();
|
||||
expect(hapticCalls.single.arguments, 'HapticFeedbackType.lightImpact');
|
||||
|
||||
Navigator.of(tester.element(find.text('Injected settings'))).pop();
|
||||
await tester.pumpAndSettle();
|
||||
final communityAvatar = find.descendant(
|
||||
of: find.byType(FrostedAppBar).last,
|
||||
matching: find.byType(AvatarImage),
|
||||
);
|
||||
await tester.tap(communityAvatar);
|
||||
await tester.pump();
|
||||
expect(hapticCalls.last.arguments, 'HapticFeedbackType.selectionClick');
|
||||
});
|
||||
|
||||
testWidgets('community switcher separates selection from edit removal', (
|
||||
@@ -1360,6 +1714,10 @@ void main() {
|
||||
tester.widget<Text>(find.text('general')).style?.fontWeight,
|
||||
FontWeight.w700,
|
||||
);
|
||||
expect(
|
||||
tester.widget<Text>(find.text('general')).style?.color,
|
||||
Theme.of(tester.element(find.text('general'))).colorScheme.onSurface,
|
||||
);
|
||||
|
||||
readState.markContextRead('1', 20);
|
||||
await tester.pump();
|
||||
|
||||
@@ -11,13 +11,14 @@ void main() {
|
||||
Future<Widget> buildHome({
|
||||
int unreadInboxCount = 0,
|
||||
bool disableAnimations = false,
|
||||
Gradient? topSectionGradient,
|
||||
}) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return ProviderScope(
|
||||
overrides: [savedPrefsProvider.overrideWithValue(prefs)],
|
||||
child: MaterialApp(
|
||||
theme: AppTheme.light(),
|
||||
theme: AppTheme.light(topSectionGradient: topSectionGradient),
|
||||
builder: (context, child) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
@@ -70,6 +71,46 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps the Buzz backdrop behind the scalable Home screen', (
|
||||
tester,
|
||||
) async {
|
||||
const gradient = LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Colors.yellow, Colors.blue],
|
||||
);
|
||||
await tester.pumpWidget(await buildHome(topSectionGradient: gradient));
|
||||
await tester.pump();
|
||||
|
||||
final backdrop = find.byKey(
|
||||
const ValueKey('home-settings-transition-backdrop'),
|
||||
);
|
||||
final decoration =
|
||||
tester.widget<DecoratedBox>(backdrop).decoration as BoxDecoration;
|
||||
expect(decoration.gradient, gradient);
|
||||
expect(
|
||||
find.byKey(const ValueKey('home-settings-transition-scale')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Transform>(
|
||||
find.byKey(const ValueKey('home-settings-transition-scale')),
|
||||
)
|
||||
.transform
|
||||
.getMaxScaleOnAxis(),
|
||||
1,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.widget<Opacity>(
|
||||
find.byKey(const ValueKey('home-settings-transition-opacity')),
|
||||
)
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('gives selection haptics only when the tab changes', (
|
||||
tester,
|
||||
) async {
|
||||
|
||||
90
mobile/test/features/profile/profile_provider_test.dart
Normal file
90
mobile/test/features/profile/profile_provider_test.dart
Normal file
@@ -0,0 +1,90 @@
|
||||
import 'package:buzz/features/profile/profile_provider.dart';
|
||||
import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/shared/relay/relay.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'manual presence persists until Online restores automatic mode',
|
||||
() async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
var container = _buildContainer(prefs);
|
||||
|
||||
expect(
|
||||
await container
|
||||
.read(presenceProvider.future)
|
||||
.timeout(
|
||||
const Duration(seconds: 2),
|
||||
onTimeout: () =>
|
||||
throw StateError('initial presence did not resolve'),
|
||||
),
|
||||
'online',
|
||||
);
|
||||
await container
|
||||
.read(presenceProvider.notifier)
|
||||
.setPresence('away')
|
||||
.timeout(
|
||||
const Duration(seconds: 2),
|
||||
onTimeout: () => throw StateError('setting Away did not resolve'),
|
||||
);
|
||||
expect(container.read(presenceProvider).value, 'away');
|
||||
expect(prefs.getString('buzz_presence_preference_aabb'), 'away');
|
||||
|
||||
container.dispose();
|
||||
container = _buildContainer(prefs);
|
||||
addTearDown(container.dispose);
|
||||
expect(
|
||||
await container
|
||||
.read(presenceProvider.future)
|
||||
.timeout(
|
||||
const Duration(seconds: 2),
|
||||
onTimeout: () =>
|
||||
throw StateError('stored presence did not resolve'),
|
||||
),
|
||||
'away',
|
||||
);
|
||||
|
||||
await container
|
||||
.read(presenceProvider.notifier)
|
||||
.setPresence('online')
|
||||
.timeout(
|
||||
const Duration(seconds: 2),
|
||||
onTimeout: () => throw StateError('setting Online did not resolve'),
|
||||
);
|
||||
expect(container.read(presenceProvider).value, 'online');
|
||||
expect(prefs.getString('buzz_presence_preference_aabb'), 'auto');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ProviderContainer _buildContainer(SharedPreferences prefs) => ProviderContainer(
|
||||
overrides: [
|
||||
savedPrefsProvider.overrideWithValue(prefs),
|
||||
myPubkeyProvider.overrideWithValue('aabb'),
|
||||
profileProvider.overrideWith(_FakeProfileNotifier.new),
|
||||
relaySessionProvider.overrideWith(_DisconnectedRelaySession.new),
|
||||
appLifecycleProvider.overrideWith(_ResumedLifecycle.new),
|
||||
],
|
||||
);
|
||||
|
||||
class _FakeProfileNotifier extends ProfileNotifier {
|
||||
@override
|
||||
Future<UserProfile?> build() async =>
|
||||
const UserProfile(pubkey: 'aabb', displayName: 'Test');
|
||||
}
|
||||
|
||||
class _DisconnectedRelaySession extends RelaySessionNotifier {
|
||||
@override
|
||||
SessionState build() =>
|
||||
const SessionState(status: SessionStatus.disconnected);
|
||||
}
|
||||
|
||||
class _ResumedLifecycle extends AppLifecycleNotifier {
|
||||
@override
|
||||
AppLifecycleState build() => AppLifecycleState.resumed;
|
||||
}
|
||||
@@ -4,7 +4,9 @@ import 'package:buzz/features/profile/user_profile.dart';
|
||||
import 'package:buzz/features/profile/user_status.dart';
|
||||
import 'package:buzz/features/profile/user_status_provider.dart';
|
||||
import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/widgets/masked_avatar_badge.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
|
||||
@@ -19,6 +21,7 @@ void main() {
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
profileProvider.overrideWith(_FakeProfileNotifier.new),
|
||||
presenceProvider.overrideWith(() => _FakePresenceNotifier('online')),
|
||||
userStatusProvider.overrideWith(
|
||||
() => _FakeUserStatusNotifier(
|
||||
const UserStatus(
|
||||
@@ -35,6 +38,7 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byType(Hero), findsNothing);
|
||||
final badge = find.byType(MaskedAvatarBadge);
|
||||
expect(
|
||||
find.descendant(of: badge, matching: find.text(missingShortcode)),
|
||||
@@ -45,6 +49,99 @@ void main() {
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'keeps text-only status visible beside a changeable presence pill',
|
||||
(tester) async {
|
||||
final presenceNotifier = _FakePresenceNotifier('away');
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
profileProvider.overrideWith(_FakeProfileNotifier.new),
|
||||
presenceProvider.overrideWith(() => presenceNotifier),
|
||||
userStatusProvider.overrideWith(
|
||||
() => _FakeUserStatusNotifier(
|
||||
const UserStatus(text: 'Focusing', emoji: '', updatedAt: 1),
|
||||
),
|
||||
),
|
||||
customEmojiListProvider.overrideWithValue(const []),
|
||||
],
|
||||
child: const SettingsProfileHeader(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Focusing'), findsOneWidget);
|
||||
await tester.tap(find.text('Focusing'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Set a status'), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).controller?.text,
|
||||
'Focusing',
|
||||
);
|
||||
await tester.binding.handlePopRoute();
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-presence-label')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('Away'), findsOneWidget);
|
||||
expect(
|
||||
tester
|
||||
.widget<Text>(find.byKey(const ValueKey('settings-presence-label')))
|
||||
.style
|
||||
?.fontSize,
|
||||
filterChipTextStyle.fontSize,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('settings-presence-target')))
|
||||
.height,
|
||||
48,
|
||||
);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.byKey(const ValueKey('settings-presence-pill')))
|
||||
.height,
|
||||
greaterThanOrEqualTo(31),
|
||||
);
|
||||
|
||||
final presenceTarget = find.byKey(
|
||||
const ValueKey('settings-presence-target'),
|
||||
);
|
||||
final targetRect = tester.getRect(presenceTarget);
|
||||
await tester.tapAt(Offset(targetRect.center.dx, targetRect.bottom - 1));
|
||||
await tester.pump();
|
||||
|
||||
final scale = tester.widget<ScaleTransition>(
|
||||
find.byKey(const ValueKey('activity-popover-scale')),
|
||||
);
|
||||
expect(scale.alignment, Alignment.topCenter);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-presence-popover')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-presence-online')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-presence-away')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const ValueKey('settings-presence-offline')),
|
||||
findsOneWidget,
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const ValueKey('settings-presence-offline')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(presenceNotifier.selected, ['offline']);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeProfileNotifier extends ProfileNotifier {
|
||||
@@ -61,3 +158,18 @@ class _FakeUserStatusNotifier extends UserStatusNotifier {
|
||||
@override
|
||||
Future<UserStatus?> build() async => _status;
|
||||
}
|
||||
|
||||
class _FakePresenceNotifier extends PresenceNotifier {
|
||||
_FakePresenceNotifier(this._presence);
|
||||
|
||||
final String _presence;
|
||||
final List<String> selected = [];
|
||||
|
||||
@override
|
||||
Future<String> build() async => _presence;
|
||||
|
||||
@override
|
||||
Future<void> setPresence(String status) async {
|
||||
selected.add(status);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import 'package:buzz/features/search/search_page.dart';
|
||||
import 'package:buzz/features/search/search_provider.dart';
|
||||
import 'package:buzz/shared/theme/theme.dart';
|
||||
import 'package:buzz/shared/mentions/agent_identity_provider.dart';
|
||||
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
@@ -18,6 +19,110 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import '../../helpers/widget_helpers.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('reselecting Search uses the field activation path', (
|
||||
tester,
|
||||
) async {
|
||||
final tabReselection = ValueNotifier(0);
|
||||
addTearDown(tabReselection.dispose);
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(
|
||||
() => _FakeSearchNotifier(const SearchState.initial()),
|
||||
),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
],
|
||||
child: SearchPage(tabReselection: tabReselection),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('search-cancel')), findsNothing);
|
||||
tabReselection.value++;
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
|
||||
expect(
|
||||
tester.widget<TextField>(find.byType(TextField)).focusNode?.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
final focusNode = tester
|
||||
.widget<TextField>(find.byType(TextField))
|
||||
.focusNode!;
|
||||
// Reproduce the real tab-tap ordering where the destination callback can
|
||||
// run immediately before the same pointer gesture dismisses the field.
|
||||
tabReselection.value++;
|
||||
focusNode.unfocus();
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
|
||||
expect(find.byKey(const Key('search-cancel')), findsOneWidget);
|
||||
expect(focusNode.hasFocus, isTrue);
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedOpacity>(
|
||||
find.byKey(const Key('search-header-title-opacity')),
|
||||
)
|
||||
.opacity,
|
||||
0,
|
||||
reason: 'The tab gesture must not paint a close-and-reopen flicker.',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('uses the shared frosted navigation surface', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(
|
||||
() => _FakeSearchNotifier(const SearchState.initial()),
|
||||
),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
],
|
||||
child: const SearchPage(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
|
||||
expect(appBar.gradient, isNull);
|
||||
expect(appBar.frosted, isTrue);
|
||||
expect(appBar.showBottomDivider, isTrue);
|
||||
expect(appBar.bottomDividerOpacity, 0.06);
|
||||
expect(appBar.bottomHeight, 57);
|
||||
expect(appBar.leading, isNull);
|
||||
expect(find.text('Search'), findsOneWidget);
|
||||
final promptText = find.descendant(
|
||||
of: find.byKey(const Key('search-field-container')),
|
||||
matching: find.byType(Text),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(promptText).left,
|
||||
closeTo(
|
||||
tester.getRect(find.byKey(const Key('search-moving-icon'))).right +
|
||||
Grid.xxs,
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
expect(
|
||||
tester.getRect(promptText).center.dy,
|
||||
closeTo(
|
||||
tester
|
||||
.getRect(find.byKey(const Key('search-field-container')))
|
||||
.center
|
||||
.dy,
|
||||
0.5,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('empty state preserves large accessible text scaling', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -49,8 +154,30 @@ void main() {
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final appBar = tester.widget<FrostedAppBar>(find.byType(FrostedAppBar));
|
||||
final titleStyle = appBar.titleStyle!;
|
||||
expect(titleStyle.fontSize, 22);
|
||||
expect(
|
||||
tester
|
||||
.getSize(
|
||||
find
|
||||
.descendant(
|
||||
of: find.byType(FrostedAppBar),
|
||||
matching: find.byType(ClipRect),
|
||||
)
|
||||
.first,
|
||||
)
|
||||
.height,
|
||||
closeTo(
|
||||
frostedAppBarHeight(
|
||||
tester.element(find.byType(FrostedAppBar)),
|
||||
titleStyle: titleStyle,
|
||||
bottomHeight: appBar.bottomHeight,
|
||||
),
|
||||
0.01,
|
||||
),
|
||||
);
|
||||
final emptyState = find.byKey(const Key('search-empty-state'));
|
||||
final message = find.text('Search messages, channels, and people');
|
||||
final searchField = find.byKey(const Key('search-field-container'));
|
||||
final searchFieldContext = tester.element(searchField);
|
||||
final bodyStyle = Theme.of(searchFieldContext).textTheme.bodyMedium!;
|
||||
@@ -66,12 +193,68 @@ void main() {
|
||||
tester.getSize(searchField).height,
|
||||
greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2),
|
||||
);
|
||||
final input = tester.widget<TextField>(
|
||||
find.byKey(const Key('search-field')),
|
||||
final prompt = tester.widget<Text>(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('search-field-container')),
|
||||
matching: find.byType(Text),
|
||||
),
|
||||
);
|
||||
expect(prompt.style?.fontSize, 15);
|
||||
expect(prompt.maxLines, 1);
|
||||
expect(prompt.overflow, TextOverflow.ellipsis);
|
||||
expect(
|
||||
tester
|
||||
.getSize(find.descendant(of: emptyState, matching: find.byType(Text)))
|
||||
.height,
|
||||
greaterThan(32),
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
testWidgets('search filters grow with accessible text', (tester) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(
|
||||
() => _FakeSearchNotifier(const SearchState.initial()),
|
||||
),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
],
|
||||
child: Builder(
|
||||
builder: (context) => MediaQuery(
|
||||
data: MediaQuery.of(
|
||||
context,
|
||||
).copyWith(textScaler: const TextScaler.linear(2)),
|
||||
child: const SearchPage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await tester.tap(find.byKey(const Key('search-field')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final filters = find.byKey(const Key('search-header-filters'));
|
||||
final activeField = find.byKey(const Key('search-field-container'));
|
||||
final cancel = find.byKey(const Key('search-cancel'));
|
||||
expect(filters, findsOneWidget);
|
||||
expect(cancel, findsOneWidget);
|
||||
expect(
|
||||
tester.getRect(activeField).right,
|
||||
lessThanOrEqualTo(tester.getRect(cancel).left),
|
||||
reason: 'Scaled Cancel must not overlap the active search field.',
|
||||
);
|
||||
expect(tester.getSize(filters).height, greaterThan(Grid.xl));
|
||||
expect(
|
||||
tester.getSize(filters).height,
|
||||
greaterThanOrEqualTo(
|
||||
tester.getSize(find.text('Messages')).height + Grid.xs * 2,
|
||||
),
|
||||
);
|
||||
expect(input.style?.fontSize, searchInputTextStyle.fontSize);
|
||||
expect(input.style?.height, searchInputTextStyle.height);
|
||||
expect(tester.getSize(message).height, greaterThan(32));
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
@@ -93,20 +276,13 @@ void main() {
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byKey(const Key('search-field'));
|
||||
final editingField = find.byType(TextField);
|
||||
final searchFieldContainer = find.byKey(
|
||||
const Key('search-field-container'),
|
||||
);
|
||||
final unfocusedWidth = tester.getSize(searchFieldContainer).width;
|
||||
final unfocusedTop = tester.getRect(searchFieldContainer).top;
|
||||
expect(find.byKey(const Key('search-cancel')), findsNothing);
|
||||
expect(
|
||||
tester.widget<TextField>(searchField).decoration?.hintText,
|
||||
'Search messages, channels, people\u2026',
|
||||
);
|
||||
expect(
|
||||
tester.widget<TextField>(searchField).textInputAction,
|
||||
TextInputAction.search,
|
||||
);
|
||||
|
||||
await tester.tap(searchField);
|
||||
await tester.pump();
|
||||
|
||||
@@ -117,7 +293,14 @@ void main() {
|
||||
greaterThanOrEqualTo(Grid.xl),
|
||||
reason: 'Cancel must keep a 48dp touch target.',
|
||||
);
|
||||
expect(tester.widget<TextField>(searchField).decoration?.hintText, isNull);
|
||||
final input = tester.widget<TextField>(editingField);
|
||||
expect(input.decoration?.hintText, isNull);
|
||||
expect(input.textInputAction, TextInputAction.search);
|
||||
expect(
|
||||
input.focusNode?.hasFocus,
|
||||
isTrue,
|
||||
reason: 'Tapping the idle search field opens the native keyboard.',
|
||||
);
|
||||
final enteringSlide = tester.widget<SlideTransition>(
|
||||
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
|
||||
);
|
||||
@@ -125,28 +308,58 @@ void main() {
|
||||
|
||||
await tester.pump(const Duration(milliseconds: 160));
|
||||
final focusedWidth = tester.getSize(searchFieldContainer).width;
|
||||
final focusedRect = tester.getRect(searchFieldContainer);
|
||||
expect(focusedWidth, lessThan(unfocusedWidth));
|
||||
expect(
|
||||
focusedRect.top,
|
||||
lessThan(unfocusedTop),
|
||||
reason: 'The active field translates upward into the title row.',
|
||||
);
|
||||
expect(find.byKey(const Key('search-header-filters')), findsOneWidget);
|
||||
final settledSlide = tester.widget<SlideTransition>(
|
||||
find.ancestor(of: cancel, matching: find.byType(SlideTransition)).first,
|
||||
);
|
||||
expect(settledSlide.position.value, Offset.zero);
|
||||
final movingIcon = find.byKey(const Key('search-moving-icon'));
|
||||
final iconScale = tester.widget<AnimatedScale>(
|
||||
find.ancestor(of: movingIcon, matching: find.byType(AnimatedScale)),
|
||||
);
|
||||
final movingField = tester.widget<AnimatedPositioned>(
|
||||
find.ancestor(of: movingIcon, matching: find.byType(AnimatedPositioned)),
|
||||
);
|
||||
expect(iconScale.scale, lessThan(1));
|
||||
expect(movingField.top, Grid.half);
|
||||
final appBarRect = tester.getRect(find.byType(FrostedAppBar));
|
||||
expect(
|
||||
appBarRect.contains(focusedRect.center),
|
||||
isTrue,
|
||||
reason: 'The translated field remains inside the app bar hit-test box.',
|
||||
);
|
||||
|
||||
await tester.enterText(searchField, 'design');
|
||||
await tester.enterText(editingField, 'design');
|
||||
await tester.tap(cancel);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 60));
|
||||
final exitingWidth = tester.getSize(searchFieldContainer).width;
|
||||
expect(exitingWidth, greaterThan(focusedWidth));
|
||||
expect(exitingWidth, lessThan(unfocusedWidth));
|
||||
|
||||
final titleOpacity = tester.widget<AnimatedOpacity>(
|
||||
find.byKey(const Key('search-header-title-opacity')),
|
||||
);
|
||||
expect(
|
||||
titleOpacity.opacity,
|
||||
1,
|
||||
reason:
|
||||
'The title fades beneath the returning field instead of appearing after it.',
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 159));
|
||||
expect(
|
||||
tester
|
||||
.widget<AnimatedOpacity>(
|
||||
find.byKey(const Key('search-header-title-opacity')),
|
||||
)
|
||||
.opacity,
|
||||
1,
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final input = tester.widget<TextField>(searchField);
|
||||
expect(input.controller?.text, isEmpty);
|
||||
expect(input.focusNode?.hasFocus, isFalse);
|
||||
expect(
|
||||
input.decoration?.hintText,
|
||||
'Search messages, channels, people\u2026',
|
||||
);
|
||||
expect(find.byKey(const Key('search-cancel')), findsNothing);
|
||||
expect(
|
||||
tester.getSize(searchFieldContainer).width,
|
||||
@@ -154,6 +367,42 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps the search prompt calm until it is focused', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
WidgetHelpers.testable(
|
||||
overrides: [
|
||||
searchProvider.overrideWith(
|
||||
() => _FakeSearchNotifier(const SearchState.initial()),
|
||||
),
|
||||
recentSearchesProvider.overrideWith(
|
||||
() => _FakeRecentSearchesNotifier(const []),
|
||||
),
|
||||
profileProvider.overrideWith(() => _FakeProfileNotifier()),
|
||||
],
|
||||
child: const SearchPage(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final searchField = find.byKey(const Key('search-field'));
|
||||
final searchFieldContainer = find.byKey(
|
||||
const Key('search-field-container'),
|
||||
);
|
||||
expect(find.text('Messages'), findsNothing);
|
||||
expect(tester.getSize(searchFieldContainer).height, greaterThan(36));
|
||||
|
||||
await tester.tap(searchField);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Messages'), findsOneWidget);
|
||||
expect(
|
||||
tester.getSize(searchFieldContainer).height,
|
||||
greaterThanOrEqualTo(36),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('only submitted queries are added to recent searches', (
|
||||
tester,
|
||||
) async {
|
||||
@@ -243,7 +492,13 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('clear-recent-searches')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('recent-searches-list')), findsNothing);
|
||||
expect(find.text('Search messages, channels, and people'), findsOneWidget);
|
||||
expect(
|
||||
find.descendant(
|
||||
of: find.byKey(const Key('search-empty-state')),
|
||||
matching: find.text('Search messages, channels, and people'),
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('keeps recent searches scrollable above the keyboard', (
|
||||
|
||||
@@ -183,6 +183,59 @@ void main() {
|
||||
expect(decoration.gradient, isNull);
|
||||
expect(decoration.color, isNotNull);
|
||||
});
|
||||
|
||||
testWidgets('Buzz section labels use 80% neutral foreground', (
|
||||
tester,
|
||||
) async {
|
||||
await tester.pumpWidget(
|
||||
harness(
|
||||
AppTheme.light(
|
||||
topSectionGradient: buzzTopSectionGradient(
|
||||
buzzThemeName,
|
||||
Brightness.light,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
final context = tester.element(find.text('Home'));
|
||||
expect(
|
||||
navigationSectionForeground(context),
|
||||
Colors.black.withValues(alpha: 0.8),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('navigation roles inherit non-Buzz theme tokens', (
|
||||
tester,
|
||||
) async {
|
||||
const primaryForeground = Color(0xFF123456);
|
||||
const secondaryForeground = Color(0xFF789ABC);
|
||||
const searchSurface = Color(0xFFDEF012);
|
||||
final theme = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: Colors.purple).copyWith(
|
||||
onSurface: primaryForeground,
|
||||
onSurfaceVariant: secondaryForeground,
|
||||
surfaceContainerHighest: searchSurface,
|
||||
),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: const Scaffold(body: SizedBox()),
|
||||
),
|
||||
);
|
||||
|
||||
final context = tester.element(find.byType(SizedBox));
|
||||
expect(navigationPrimaryForeground(context), primaryForeground);
|
||||
expect(navigationSecondaryForeground(context), secondaryForeground);
|
||||
expect(navigationSectionForeground(context), secondaryForeground);
|
||||
expect(navigationSearchSurface(context), searchSurface);
|
||||
expect(
|
||||
navigationDivider(context, 0.15),
|
||||
primaryForeground.withValues(alpha: 0.15),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
group('isBuzzTheme', () {
|
||||
|
||||
@@ -19,27 +19,25 @@ void main() {
|
||||
),
|
||||
);
|
||||
|
||||
expect(gradient?.stops, [0, 0.5, 1]);
|
||||
expect(gradient?.stops, [0, 0.18, 0.38, 0.6, 0.8, 1]);
|
||||
expect(gradient?.colors.first.a, 0);
|
||||
expect(gradient?.colors[1].a, 0.75);
|
||||
expect(gradient?.colors[1].a, closeTo(0.03, 0.01));
|
||||
expect(gradient?.colors[3].a, closeTo(0.34, 0.01));
|
||||
expect(gradient?.colors.last.a, 1);
|
||||
});
|
||||
|
||||
testWidgets('uses the logical bottom safe-area inset', (tester) async {
|
||||
testWidgets('uses a fixed 180px footer backdrop height', (tester) async {
|
||||
double? height;
|
||||
|
||||
await tester.pumpWidget(
|
||||
MediaQuery(
|
||||
data: const MediaQueryData(padding: EdgeInsets.only(bottom: 34)),
|
||||
child: Builder(
|
||||
builder: (context) {
|
||||
height = mobileTabFooterBackdropHeight(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
Builder(
|
||||
builder: (context) {
|
||||
height = mobileTabFooterBackdropHeight(context);
|
||||
return const SizedBox();
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
expect(height, 170);
|
||||
expect(height, 180);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user