Refine mobile community switching and discovery (#2967)

## Summary

- Redesign community switching with relay-provided avatars,
selected-state checks, edit-to-remove controls, and confirmation.
- Bring desktop-inspired pairing onboarding to mobile with QR scanning
as the primary action, an expandable pairing-code flow, and a tappable
flapping bee.
- Polish Search and Activity headers, filter chips, typography, keyboard
stability, and Android adaptive-icon sizing.

## Validation

- `just mobile-check`
- `just mobile-test` — 661 passed, 1 skipped
- Pixel 10 snapshot review
This commit is contained in:
klopez4212
2026-07-26 23:05:57 +01:00
committed by GitHub
parent 5d8ede446f
commit 871a3b3772
26 changed files with 2351 additions and 538 deletions

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<inset xmlns:android="http://schemas.android.com/apk/res/android"
android:drawable="@mipmap/ic_launcher_foreground"
android:inset="10%" />

View File

@@ -1,5 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<foreground android:drawable="@drawable/ic_launcher_foreground_inset"/>
<background android:drawable="@color/ic_launcher_background"/>
</adaptive-icon>
</adaptive-icon>

View File

@@ -26,6 +26,10 @@ class ActivityPage extends HookConsumerWidget {
final feedAsync = ref.watch(activityProvider);
final channelsAsync = ref.watch(channelsProvider);
final filter = useState(_Filter.all);
final headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
);
// Cache last successful feed so the UI doesn't flash on rebuild.
final cachedFeed = useRef<HomeFeedResponse?>(null);
@@ -34,7 +38,7 @@ class ActivityPage extends HookConsumerWidget {
}
final feed = cachedFeed.value;
final Widget body;
final Widget content;
if (feed != null && !feed.isEmpty) {
final items = _filteredItems(feed, filter.value);
final channels = channelsAsync.asData?.value ?? [];
@@ -43,79 +47,85 @@ class ActivityPage extends HookConsumerWidget {
final pubkeys = items.map((i) => i.pubkey).toSet().toList();
ref.read(userCacheProvider.notifier).preload(pubkeys);
body = Column(
children: [
FilterChipBar<_Filter>(
selected: filter.value,
onSelected: (f) => filter.value = f,
items: [
const FilterChipItem(id: _Filter.all, label: 'All'),
FilterChipItem(
id: _Filter.mentions,
label: 'Mentions',
icon: LucideIcons.atSign,
count: feed.mentions.length,
content = items.isEmpty
? _EmptyFilterState(filter: filter.value)
: RefreshIndicator(
onRefresh: () => ref.read(activityProvider.notifier).refresh(),
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: items.length,
separatorBuilder: (_, _) => const SizedBox(height: Grid.half),
itemBuilder: (context, index) {
final item = items[index];
return _FeedItemTile(
item: item,
onTap: () => _openItem(context, item, channels),
);
},
),
FilterChipItem(
id: _Filter.needsAction,
label: 'Action',
icon: LucideIcons.circleAlert,
count: feed.needsAction.length,
),
FilterChipItem(
id: _Filter.activity,
label: 'Activity',
icon: LucideIcons.activity,
count: feed.activity.length,
),
FilterChipItem(
id: _Filter.agents,
label: 'Agents',
icon: LucideIcons.bot,
count: feed.agentActivity.length,
),
],
),
Expanded(
child: items.isEmpty
? _EmptyFilterState(filter: filter.value)
: RefreshIndicator(
onRefresh: () =>
ref.read(activityProvider.notifier).refresh(),
child: ListView.separated(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
itemCount: items.length,
separatorBuilder: (_, _) =>
const SizedBox(height: Grid.half),
itemBuilder: (context, index) {
final item = items[index];
return _FeedItemTile(
item: item,
onTap: () => _openItem(context, item, channels),
);
},
),
),
),
],
);
);
} else if (feedAsync.hasError) {
body = _ErrorView(
content = _ErrorView(
onRetry: () => ref.read(activityProvider.notifier).refresh(),
);
} else if (feedAsync.hasValue) {
body = const _EmptyState();
content = const _EmptyState();
} else {
body = const _LoadingSkeleton();
content = const _LoadingSkeleton();
}
return FrostedScaffold(
appBar: const FrostedAppBar(title: Text('Activity')),
appBar: FrostedAppBar(
gradient: context.appColors.topSectionGradient,
title: const Text('Activity'),
titleStyle: headerTitleStyle,
),
body: SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.only(top: frostedAppBarHeight(context)),
child: body,
padding: EdgeInsets.only(
top: frostedAppBarHeight(context, titleStyle: headerTitleStyle),
),
child: Column(
children: [
FilterChipBar<_Filter>(
expandItems: false,
visualDensity: const VisualDensity(horizontal: -2),
chipVerticalPadding: Grid.xxs,
barVerticalPadding: Grid.twelve,
selected: filter.value,
onSelected: (f) => filter.value = f,
items: [
const FilterChipItem(id: _Filter.all, label: 'All'),
FilterChipItem(
id: _Filter.mentions,
label: 'Mentions',
icon: LucideIcons.atSign,
count: feed?.mentions.length,
),
FilterChipItem(
id: _Filter.needsAction,
label: 'Action',
icon: LucideIcons.circleAlert,
count: feed?.needsAction.length,
),
FilterChipItem(
id: _Filter.activity,
label: 'Activity',
icon: LucideIcons.activity,
count: feed?.activity.length,
),
FilterChipItem(
id: _Filter.agents,
label: 'Agents',
icon: LucideIcons.bot,
count: feed?.agentActivity.length,
),
],
),
Expanded(child: content),
],
),
),
),
);

View File

@@ -148,6 +148,9 @@ class ChannelDetailPage extends HookConsumerWidget {
channel;
final resolvedChannel =
detailsAsync.whenData(baseChannel.mergeDetails).value ?? baseChannel;
final appBarTitleContentHeight = resolvedChannel.isDm
? _dmAppBarTitleContentHeight(context)
: 0.0;
final readTimestamp = _channelReadTimestamp(
channel: resolvedChannel,
messagesState: messagesState,
@@ -185,6 +188,7 @@ class ChannelDetailPage extends HookConsumerWidget {
return FrostedScaffold(
appBar: FrostedAppBar(
iconColor: context.colors.primary,
titleContentHeight: appBarTitleContentHeight,
title: resolvedChannel.isDm
? _DmAppBarTitle(
channel: resolvedChannel,
@@ -262,13 +266,19 @@ class ChannelDetailPage extends HookConsumerWidget {
: messagesState.when(
loading: () => Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
),
child: const Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: EdgeInsets.only(
top: frostedAppBarHeight(context),
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
),
child: Center(
child: Text(
@@ -300,6 +310,7 @@ class ChannelDetailPage extends HookConsumerWidget {
currentPubkey: currentPubkey,
isMember: resolvedChannel.isMember,
isArchived: resolvedChannel.isArchived,
appBarTitleContentHeight: appBarTitleContentHeight,
);
},
),

View File

@@ -1,5 +1,24 @@
part of '../channel_detail_page.dart';
double _scaledTextHeight(BuildContext context, TextStyle style) {
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 0);
return scaledFontSize * (style.height ?? 1);
}
double _dmAppBarTitleContentHeight(BuildContext context) {
final titleStyle = context.textTheme.titleSmall;
final presenceStyle = context.textTheme.bodySmall;
if (titleStyle == null || presenceStyle == null) {
return 30;
}
final textHeight =
_scaledTextHeight(context, titleStyle) +
_scaledTextHeight(context, presenceStyle);
return textHeight > 30 ? textHeight : 30;
}
class _TypingIndicator extends ConsumerWidget {
final List<TypingEntry> entries;

View File

@@ -9,6 +9,7 @@ class _MessageList extends HookConsumerWidget {
final String? currentPubkey;
final bool isMember;
final bool isArchived;
final double appBarTitleContentHeight;
const _MessageList({
required this.entries,
@@ -19,6 +20,7 @@ class _MessageList extends HookConsumerWidget {
required this.currentPubkey,
required this.isMember,
required this.isArchived,
required this.appBarTitleContentHeight,
});
@override
@@ -199,7 +201,10 @@ class _MessageList extends HookConsumerWidget {
padding: EdgeInsets.only(
left: Grid.gutter,
right: Grid.gutter,
top: frostedAppBarHeight(context),
top: frostedAppBarHeight(
context,
titleContentHeight: appBarTitleContentHeight,
),
bottom: Grid.xxs,
),
itemCount: displayEntries.length + (isLoadingOlder.value ? 1 : 0),

View File

@@ -10,6 +10,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../../shared/auth/auth.dart';
import '../../shared/community/community_icon_provider.dart';
import '../../shared/relay/relay.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
@@ -228,11 +229,14 @@ class ChannelsPage extends HookConsumerWidget {
// under every other theme, leaving the default frosted fill.
gradient: context.appColors.topSectionGradient,
leading: _CommunityIndicator(
onTap: () => showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
),
onTap: () {
ref.invalidate(communityIconProvider);
showModalBottomSheet<void>(
context: context,
showDragHandle: true,
builder: (_) => const _CommunitySwitcherSheet(),
);
},
),
title: const SizedBox.shrink(),
actions: [

View File

@@ -1,134 +1,187 @@
part of '../channels_page.dart';
class _CommunitySwitcherSheet extends ConsumerWidget {
class _CommunitySwitcherSheet extends HookConsumerWidget {
const _CommunitySwitcherSheet();
@override
Widget build(BuildContext context, WidgetRef ref) {
final communitiesAsync = ref.watch(communityListProvider);
final activeAsync = ref.watch(activeCommunityProvider);
final sessionState = ref.watch(relaySessionProvider);
final isEditing = useState(false);
return SafeArea(
child: communitiesAsync.when(
loading: () => const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text('Error loading communities: $e'),
),
data: (communities) {
final activeId = activeAsync.value?.id;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
for (final community in communities)
_CommunitySwitcherTile(
community: community,
isActive: community.id == activeId,
sessionStatus: community.id == activeId
? sessionState.status
: null,
onTap: () async {
if (community.id != activeId) {
await ref
.read(communityListProvider.notifier)
.switchCommunity(community.id);
}
if (context.mounted) Navigator.of(context).pop();
},
onRename: () async {
final nav = Navigator.of(context, rootNavigator: true);
final notifier = ref.read(communityListProvider.notifier);
Navigator.of(context).pop();
final name = await showDialog<String>(
context: nav.context,
useRootNavigator: true,
builder: (_) =>
_RenameCommunityDialog(currentName: community.name),
);
if (name != null && name.isNotEmpty) {
await notifier.renameCommunity(community.id, name);
}
},
onRemove: () async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('Remove Community'),
content: Text(
'Remove "${community.name}"? You can re-pair later.',
),
actions: [
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
child: Column(
key: const Key('community-switcher-sheet'),
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.xxs,
Grid.gutter,
Grid.xxs,
),
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 32),
child: Row(
children: [
Expanded(
child: Text(
'Switch Community',
key: const Key('community-switcher-title'),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
Padding(
padding: const EdgeInsets.only(right: Grid.half),
child: ConstrainedBox(
key: const Key('community-switcher-edit'),
constraints: const BoxConstraints(
minWidth: 48,
minHeight: 32,
),
child: TextButton(
onPressed: () => isEditing.value = !isEditing.value,
style: TextButton.styleFrom(
minimumSize: Size.zero,
padding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
TextButton(
onPressed: () =>
Navigator.of(dialogContext).pop(true),
child: const Text('Remove'),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(isEditing.value ? 'Done' : 'Edit'),
),
),
),
],
),
),
),
Flexible(
child: communitiesAsync.when(
loading: () => const SizedBox(
height: 120,
child: Center(child: CircularProgressIndicator()),
),
error: (e, _) => Padding(
padding: const EdgeInsets.all(Grid.xs),
child: Text('Error loading communities: $e'),
),
data: (communities) {
final activeId = activeAsync.value?.id;
return SingleChildScrollView(
padding: const EdgeInsets.only(bottom: Grid.xs),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.gutter,
),
child: Material(
key: const Key('community-switcher-options'),
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.card),
clipBehavior: Clip.antiAlias,
child: Column(
children: [
for (
var index = 0;
index < communities.length;
index++
) ...[
if (index > 0) const _CommunitySwitcherDivider(),
_CommunitySwitcherTile(
community: communities[index],
isActive: communities[index].id == activeId,
isEditing: isEditing.value,
onTap: isEditing.value
? null
: () async {
final community = communities[index];
if (community.id != activeId) {
await ref
.read(
communityListProvider.notifier,
)
.switchCommunity(community.id);
}
if (context.mounted) {
Navigator.of(context).pop();
}
},
onRemove: () => _confirmRemoveCommunity(
context,
ref,
communities[index],
closeSheetAfterRemoval:
communities[index].id == activeId,
),
),
],
if (communities.isNotEmpty)
const _CommunitySwitcherDivider(),
_AddCommunityTile(
onTap: () {
final nav = Navigator.of(
context,
rootNavigator: true,
);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) =>
const PairingPage(addingCommunity: true),
),
);
},
),
],
),
);
if (confirmed == true && context.mounted) {
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(communityListProvider.notifier)
.removeCommunity(community.id);
if (context.mounted) Navigator.of(context).pop();
} catch (e) {
messenger.showSnackBar(
SnackBar(
content: Text('Failed to remove community: $e'),
),
);
}
}
},
),
const Divider(height: 1),
ListTile(
leading: const Icon(LucideIcons.plus),
title: const Text('Add Community'),
onTap: () {
final nav = Navigator.of(context, rootNavigator: true);
ref.read(pairingProvider.notifier).reset();
Navigator.of(context).pop();
nav.push(
MaterialPageRoute<void>(
builder: (_) => const PairingPage(addingCommunity: true),
),
);
},
),
],
);
},
),
);
},
),
),
],
),
);
}
}
const double _communitySwitcherAvatarSize = 36;
class _CommunitySwitcherDivider extends StatelessWidget {
const _CommunitySwitcherDivider();
@override
Widget build(BuildContext context) {
return Divider(
height: 1,
thickness: 1,
indent: Grid.xs + _communitySwitcherAvatarSize + Grid.xs,
endIndent: 0,
color: context.colors.onSurface.withValues(alpha: 0.12),
);
}
}
class _CommunitySwitcherTile extends StatelessWidget {
final Community community;
final bool isActive;
final SessionStatus? sessionStatus;
final VoidCallback onTap;
final VoidCallback onRename;
final bool isEditing;
final VoidCallback? onTap;
final VoidCallback onRemove;
const _CommunitySwitcherTile({
required this.community,
required this.isActive,
required this.sessionStatus,
required this.isEditing,
required this.onTap,
required this.onRename,
required this.onRemove,
});
@@ -136,111 +189,269 @@ class _CommunitySwitcherTile extends StatelessWidget {
Widget build(BuildContext context) {
final host = Uri.tryParse(community.relayUrl)?.host ?? community.relayUrl;
return ListTile(
leading: _StatusDot(isActive: isActive, sessionStatus: sessionStatus),
title: Text(
community.name,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: isActive ? FontWeight.w600 : FontWeight.normal,
),
),
subtitle: Text(
host,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
trailing: PopupMenuButton<String>(
icon: Icon(
LucideIcons.ellipsisVertical,
size: 18,
color: context.colors.onSurfaceVariant,
),
onSelected: (value) {
switch (value) {
case 'rename':
onRename();
case 'remove':
onRemove();
}
},
itemBuilder: (_) => [
const PopupMenuItem(value: 'rename', child: Text('Rename')),
const PopupMenuItem(value: 'remove', child: Text('Remove')),
],
),
return InkWell(
key: Key('community-switcher-row-${community.id}'),
onTap: onTap,
);
}
}
class _StatusDot extends StatelessWidget {
final bool isActive;
final SessionStatus? sessionStatus;
const _StatusDot({required this.isActive, required this.sessionStatus});
@override
Widget build(BuildContext context) {
if (!isActive) {
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: context.colors.outline.withValues(alpha: 0.3),
child: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.xs,
Grid.twelve,
Grid.xxs,
Grid.twelve,
),
);
}
final color = switch (sessionStatus) {
SessionStatus.connected => context.appColors.success,
SessionStatus.connecting ||
SessionStatus.reconnecting => context.appColors.warning,
_ => context.colors.outline,
};
return Container(
width: 10,
height: 10,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
child: Row(
children: [
_CommunityAvatar(
key: Key('community-switcher-avatar-${community.id}'),
name: community.name,
relayUrl: community.relayUrl,
size: _communitySwitcherAvatarSize,
),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
community.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: isActive
? FontWeight.w600
: FontWeight.normal,
),
),
const SizedBox(height: Grid.quarter),
Text(
host,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
const SizedBox(width: Grid.xxs),
_CommunityActionSwap(
key: Key('community-switcher-action-${community.id}'),
communityId: community.id,
communityName: community.name,
isActive: isActive,
isEditing: isEditing,
onRemove: onRemove,
),
],
),
),
);
}
}
class _RenameCommunityDialog extends HookWidget {
final String currentName;
const _communityActionSwapDuration = Duration(milliseconds: 250);
const double _communityActionSwapBlur = 2;
const double _communityActionSwapStartScale = 0.25;
const _RenameCommunityDialog({required this.currentName});
class _CommunityActionSwap extends StatelessWidget {
const _CommunityActionSwap({
super.key,
required this.communityId,
required this.communityName,
required this.isActive,
required this.isEditing,
required this.onRemove,
});
final String communityId;
final String communityName;
final bool isActive;
final bool isEditing;
final VoidCallback onRemove;
@override
Widget build(BuildContext context) {
final controller = useTextEditingController(text: currentName);
final reduceMotion =
MediaQuery.maybeOf(context)?.disableAnimations ?? false;
return AlertDialog(
title: const Text('Rename Community'),
content: TextField(
controller: controller,
autofocus: true,
decoration: const InputDecoration(labelText: 'Name'),
onSubmitted: (value) {
final name = value.trim();
if (name.isNotEmpty) Navigator.of(context).pop(name);
return SizedBox.square(
dimension: 40,
child: AnimatedSwitcher(
duration: reduceMotion ? Duration.zero : _communityActionSwapDuration,
reverseDuration: reduceMotion
? Duration.zero
: _communityActionSwapDuration,
transitionBuilder: (child, animation) {
final eased = CurvedAnimation(
parent: animation,
curve: Curves.easeInOut,
);
return AnimatedBuilder(
animation: eased,
child: child,
builder: (context, child) {
final progress = eased.value;
final blur = _communityActionSwapBlur * (1 - progress);
final scale =
_communityActionSwapStartScale +
((1 - _communityActionSwapStartScale) * progress);
return Opacity(
opacity: progress,
child: ImageFiltered(
enabled: blur > 0.01,
imageFilter: ImageFilter.blur(sigmaX: blur, sigmaY: blur),
child: Transform.scale(scale: scale, child: child),
),
);
},
);
},
child: isEditing
? IconButton(
key: ValueKey('community-switcher-remove-$communityId'),
tooltip: 'Remove $communityName',
visualDensity: VisualDensity.compact,
onPressed: onRemove,
icon: Icon(
LucideIcons.trash2,
size: 18,
color: context.colors.error,
),
)
: Center(
key: ValueKey('community-switcher-selection-$communityId'),
child: _CommunitySelectionIndicator(
key: ValueKey('community-switcher-circle-$communityId'),
communityName: communityName,
isActive: isActive,
),
),
),
);
}
}
class _CommunitySelectionIndicator extends StatelessWidget {
const _CommunitySelectionIndicator({
super.key,
required this.communityName,
required this.isActive,
});
final String communityName;
final bool isActive;
@override
Widget build(BuildContext context) {
return Semantics(
label: isActive
? '$communityName is the current community'
: 'Switch to $communityName',
child: Container(
width: 24,
height: 24,
decoration: BoxDecoration(
color: isActive ? context.appColors.success : Colors.transparent,
border: isActive
? null
: Border.all(color: context.colors.outlineVariant, width: 2),
shape: BoxShape.circle,
),
child: isActive
? const Icon(LucideIcons.check, color: Colors.white, size: 16)
: null,
),
);
}
}
class _AddCommunityTile extends StatelessWidget {
const _AddCommunityTile({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return InkWell(
key: const Key('community-switcher-add'),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.twelve,
),
child: Row(
children: [
Container(
width: _communitySwitcherAvatarSize,
height: _communitySwitcherAvatarSize,
decoration: BoxDecoration(
color: context.colors.primaryContainer,
shape: BoxShape.circle,
),
child: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onPrimaryContainer,
),
),
const SizedBox(width: Grid.xs),
Text(
'Add Community',
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
],
),
),
);
}
}
Future<void> _confirmRemoveCommunity(
BuildContext context,
WidgetRef ref,
Community community, {
required bool closeSheetAfterRemoval,
}) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog.adaptive(
title: const Text('Remove community?'),
content: Text(
'Are you sure you want to remove “${community.name}”? '
'You can pair with it again later.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () => Navigator.of(dialogContext).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
final name = controller.text.trim();
if (name.isNotEmpty) Navigator.of(context).pop(name);
},
child: const Text('Rename'),
FilledButton(
onPressed: () => Navigator.of(dialogContext).pop(true),
style: FilledButton.styleFrom(backgroundColor: context.colors.error),
child: const Text('Remove'),
),
],
),
);
if (confirmed != true || !context.mounted) return;
final messenger = ScaffoldMessenger.of(context);
try {
await ref
.read(communityListProvider.notifier)
.removeCommunity(community.id);
if (closeSheetAfterRemoval && context.mounted) {
Navigator.of(context).pop();
}
} catch (e) {
messenger.showSnackBar(
SnackBar(content: Text('Failed to remove community: $e')),
);
}
}
@@ -254,7 +465,8 @@ class _CommunityIndicator extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final activeAsync = ref.watch(activeCommunityProvider);
final name = activeAsync.value?.name;
final activeCommunity = activeAsync.value;
final name = activeCommunity?.name;
return GestureDetector(
onTap: onTap,
@@ -262,7 +474,7 @@ class _CommunityIndicator extends ConsumerWidget {
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_CommunityAvatar(name: name),
_CommunityAvatar(name: name, relayUrl: activeCommunity?.relayUrl),
const SizedBox(width: Grid.xxs),
if (name != null)
Flexible(
@@ -290,22 +502,34 @@ class _CommunityIndicator extends ConsumerWidget {
}
}
class _CommunityAvatar extends StatelessWidget {
class _CommunityAvatar extends ConsumerWidget {
final String? name;
final String? relayUrl;
final double size;
const _CommunityAvatar({required this.name});
const _CommunityAvatar({
super.key,
required this.name,
this.relayUrl,
this.size = 32,
});
@override
Widget build(BuildContext context) {
Widget build(BuildContext context, WidgetRef ref) {
final trimmedName = name?.trim();
final initial = trimmedName != null && trimmedName.isNotEmpty
? trimmedName.substring(0, 1).toUpperCase()
: '?';
final relay = relayUrl;
final iconUrl = relay == null
? null
: ref.watch(communityIconProvider(relay)).value;
return CircleAvatar(
radius: 16,
return AvatarImage(
imageUrl: iconUrl,
radius: size / 2,
backgroundColor: context.colors.primaryContainer,
child: Text(
fallback: Text(
initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,

View File

@@ -63,6 +63,9 @@ class HomePage extends HookConsumerWidget {
];
return Scaffold(
// 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(

View File

@@ -1,14 +1,25 @@
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';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/tappable_flapping_bee.dart';
import 'pairing_provider.dart';
import 'pairing_qr_scanner.dart';
part 'pairing_page/onboarding_background.dart';
part 'pairing_page/pairing_welcome_view.dart';
const _onboardingChartreuse = Color(0xFFD7D72E);
const _onboardingShellBottom = Color(0xFFD7E7F6);
const _onboardingCtaLabel = Color(0xFFD7E6F0);
const _onboardingInk = Color(0xFF111111);
const _onboardingMutedInk = Color(0xB3111111);
class PairingPage extends HookConsumerWidget {
/// When true, the pairing page is being used to add a new community
/// (user is already authenticated with at least one community).
@@ -21,6 +32,7 @@ class PairingPage extends HookConsumerWidget {
final pairingState = ref.watch(pairingProvider);
final codeController = useTextEditingController();
final fallbackScannerVisible = useState(false);
final pairingCodeExpanded = useState(false);
final isBusy =
pairingState.status == PairingStatus.connecting ||
pairingState.status == PairingStatus.transferring ||
@@ -57,168 +69,98 @@ class PairingPage extends HookConsumerWidget {
await handleScannerResult(code);
}
final isVerifyingSas = pairingState.status == PairingStatus.confirmingSas;
final themedSystemOverlayStyle =
(context.theme.brightness == Brightness.dark
? SystemUiOverlayStyle.light
: SystemUiOverlayStyle.dark)
.copyWith(statusBarColor: Colors.transparent);
final pairingAppBar = addingCommunity
? AppBar(
foregroundColor: isVerifyingSas
? context.colors.onSurface
: _onboardingInk,
systemOverlayStyle: isVerifyingSas
? themedSystemOverlayStyle
: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
),
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.of(context).pop(),
),
title: Text(
'Add Community',
style: isVerifyingSas
? null
: context.textTheme.titleMedium?.copyWith(
color: _onboardingInk,
),
),
)
: null;
final pairingScaffold = isVerifyingSas
? AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('pairing-sas-system-overlay'),
value: themedSystemOverlayStyle,
child: Scaffold(
backgroundColor: context.colors.surface,
appBar: pairingAppBar,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: _SasVerificationView(
sasCode: pairingState.sasCode ?? '------',
confirmed: pairingState.userConfirmedSas,
onConfirm: () =>
ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
),
),
),
),
)
: AnnotatedRegion<SystemUiOverlayStyle>(
key: const Key('pairing-onboarding-system-overlay'),
value: SystemUiOverlayStyle.dark.copyWith(
statusBarColor: Colors.transparent,
),
child: _OnboardingBackground(
child: Scaffold(
backgroundColor: Colors.transparent,
appBar: pairingAppBar,
body: SafeArea(
child: _PairingWelcomeView(
codeController: codeController,
isBusy: isBusy,
pairingCodeExpanded: pairingCodeExpanded.value,
errorMessage: pairingState.status == PairingStatus.error
? pairingState.errorMessage
: null,
onScan: openScanner,
onTogglePairingCode: () {
pairingCodeExpanded.value = !pairingCodeExpanded.value;
},
onConnect: () {
final code = codeController.text.trim();
if (code.isNotEmpty) {
ref.read(pairingProvider.notifier).pair(code);
}
},
),
),
),
),
);
final appSurface = PopScope(
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
ref.read(pairingProvider.notifier).reset();
}
},
child: Scaffold(
appBar: addingCommunity
? AppBar(
leading: IconButton(
icon: const Icon(LucideIcons.arrowLeft),
onPressed: () => Navigator.of(context).pop(),
),
title: const Text('Add Community'),
)
: null,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.sm),
child: pairingState.status == PairingStatus.confirmingSas
? _SasVerificationView(
sasCode: pairingState.sasCode ?? '------',
confirmed: pairingState.userConfirmedSas,
onConfirm: () =>
ref.read(pairingProvider.notifier).confirmSas(),
onDeny: () => ref.read(pairingProvider.notifier).denySas(),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Spacer(flex: 2),
Image.asset('assets/images/buzz-icon.png', height: 64),
const SizedBox(height: Grid.xs),
Text(
'Welcome to Buzz',
style: context.textTheme.headlineSmall,
),
const SizedBox(height: Grid.xxs),
Text(
'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
const SizedBox(height: Grid.lg),
// Scan QR button
FilledButton.icon(
onPressed: isBusy ? null : openScanner,
icon: const Icon(LucideIcons.scanLine),
label: const Text('Scan QR Code'),
),
const SizedBox(height: Grid.sm),
Row(
children: [
const Expanded(child: Divider()),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
),
child: Text(
'or paste pairing code',
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
const Expanded(child: Divider()),
],
),
const SizedBox(height: Grid.sm),
// Paste field
TextField(
controller: codeController,
decoration: const InputDecoration(
hintText: 'nostrpair://... or buzz://...',
prefixIcon: Icon(LucideIcons.link),
isDense: true,
),
autocorrect: false,
enableSuggestions: false,
enabled: !isBusy,
contextMenuBuilder: (context, editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState: editableTextState,
);
},
),
const SizedBox(height: Grid.twelve),
// Connect button
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: isBusy
? null
: () {
final code = codeController.text.trim();
if (code.isNotEmpty) {
ref
.read(pairingProvider.notifier)
.pair(code);
}
},
child: isBusy
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: const Text('Connect'),
),
),
// Error message
if (pairingState.status == PairingStatus.error &&
pairingState.errorMessage != null) ...[
const SizedBox(height: Grid.twelve),
Container(
padding: const EdgeInsets.all(Grid.twelve),
decoration: BoxDecoration(
color: context.colors.errorContainer,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Icon(
LucideIcons.triangleAlert,
size: 16,
color: context.colors.onErrorContainer,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
pairingState.errorMessage!,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.onErrorContainer,
),
),
),
],
),
),
],
const Spacer(flex: 3),
],
),
),
),
),
child: pairingScaffold,
);
if (!fallbackScannerVisible.value) {

View File

@@ -0,0 +1,41 @@
part of '../pairing_page.dart';
class _OnboardingBackground extends StatelessWidget {
final Widget child;
const _OnboardingBackground({required this.child});
@override
Widget build(BuildContext context) {
return DecoratedBox(
key: const Key('pairing-onboarding-background'),
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [_onboardingChartreuse, _onboardingShellBottom],
),
),
child: CustomPaint(painter: const _DotGridPainter(), child: child),
);
}
}
class _DotGridPainter extends CustomPainter {
const _DotGridPainter();
@override
void paint(Canvas canvas, Size size) {
final dotPaint = Paint()..color = _onboardingInk.withValues(alpha: 0.08);
const spacing = 24.0;
for (var x = 0.0; x <= size.width; x += spacing) {
for (var y = 0.0; y <= size.height; y += spacing) {
canvas.drawCircle(Offset(x, y), 1, dotPaint);
}
}
}
@override
bool shouldRepaint(_DotGridPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,261 @@
part of '../pairing_page.dart';
class _PairingWelcomeView extends StatelessWidget {
final TextEditingController codeController;
final bool isBusy;
final bool pairingCodeExpanded;
final String? errorMessage;
final VoidCallback onScan;
final VoidCallback onTogglePairingCode;
final VoidCallback onConnect;
const _PairingWelcomeView({
required this.codeController,
required this.isBusy,
required this.pairingCodeExpanded,
required this.errorMessage,
required this.onScan,
required this.onTogglePairingCode,
required this.onConnect,
});
@override
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.disableAnimationsOf(context);
final revealDuration = reducedMotion
? Duration.zero
: const Duration(milliseconds: 220);
return LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
Grid.sm,
Grid.gutter,
Grid.sm,
),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight - (Grid.sm * 2),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 136,
height: 136,
alignment: Alignment.center,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0x4DFFFFFF),
),
child: const TappableFlappingBee(
width: 76,
color: _onboardingInk,
),
),
const SizedBox(height: Grid.sm),
Text(
'Welcome to Buzz',
textAlign: TextAlign.center,
style: context.textTheme.headlineSmall?.copyWith(
color: _onboardingInk,
fontWeight: FontWeight.w600,
letterSpacing: -0.4,
),
),
const SizedBox(height: Grid.xxs),
Text(
'Scan the QR code from your desktop app\nor paste a pairing code to connect.',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: _onboardingMutedInk,
),
),
const SizedBox(height: Grid.md),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: SizedBox(
width: double.infinity,
child: Column(
children: [
FilledButton(
style: _onboardingButtonStyle,
onPressed: isBusy ? null : onScan,
child: isBusy && !pairingCodeExpanded
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
color: _onboardingCtaLabel,
),
)
: const Text('Scan a QR code'),
),
const SizedBox(height: Grid.xxs),
TextButton(
style: _onboardingSecondaryButtonStyle,
onPressed: isBusy ? null : onTogglePairingCode,
child: Text(
pairingCodeExpanded
? 'Hide pairing code'
: 'Use pairing code',
),
),
AnimatedSwitcher(
duration: revealDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
transitionBuilder: (child, animation) {
return SizeTransition(
sizeFactor: animation,
axisAlignment: -1,
child: FadeTransition(
opacity: animation,
child: child,
),
);
},
child: pairingCodeExpanded
? Column(
key: const ValueKey('pairing-code-fields'),
children: [
const SizedBox(height: Grid.twelve),
TextField(
controller: codeController,
style: context.textTheme.bodyMedium
?.copyWith(color: _onboardingInk),
cursorColor: _onboardingInk,
decoration: InputDecoration(
filled: true,
fillColor: Colors.white.withValues(
alpha: 0.7,
),
hintText:
'nostrpair://... or buzz://...',
hintStyle: context.textTheme.bodyMedium
?.copyWith(
color: _onboardingMutedInk,
),
prefixIcon: const Icon(
LucideIcons.link,
color: _onboardingInk,
),
enabledBorder: _inputBorder,
disabledBorder: _inputBorder,
focusedBorder: _inputBorder.copyWith(
borderSide: const BorderSide(
color: _onboardingInk,
),
),
isDense: true,
),
autocorrect: false,
enableSuggestions: false,
enabled: !isBusy,
contextMenuBuilder:
(context, editableTextState) {
return AdaptiveTextSelectionToolbar.editableText(
editableTextState:
editableTextState,
);
},
),
const SizedBox(height: Grid.twelve),
SizedBox(
width: double.infinity,
child: FilledButton(
style: _onboardingButtonStyle,
onPressed: isBusy ? null : onConnect,
child: isBusy
? const SizedBox(
width: 20,
height: 20,
child:
CircularProgressIndicator(
strokeWidth: 2,
color:
_onboardingCtaLabel,
),
)
: const Text('Connect'),
),
),
],
)
: const SizedBox(
key: ValueKey('pairing-code-fields-hidden'),
),
),
if (errorMessage != null) ...[
const SizedBox(height: Grid.twelve),
Container(
padding: const EdgeInsets.all(Grid.twelve),
decoration: BoxDecoration(
color: context.colors.errorContainer,
borderRadius: BorderRadius.circular(Radii.md),
),
child: Row(
children: [
Icon(
LucideIcons.triangleAlert,
size: 16,
color: context.colors.onErrorContainer,
),
const SizedBox(width: Grid.xxs),
Expanded(
child: Text(
errorMessage!,
style: context.textTheme.bodySmall
?.copyWith(
color:
context.colors.onErrorContainer,
),
),
),
],
),
),
],
],
),
),
),
],
),
),
);
},
);
}
}
final _inputBorder = OutlineInputBorder(
borderRadius: BorderRadius.circular(Radii.md),
borderSide: BorderSide(color: _onboardingInk.withValues(alpha: 0.18)),
);
final _onboardingButtonStyle = FilledButton.styleFrom(
minimumSize: const Size(0, 48),
padding: const EdgeInsets.symmetric(
horizontal: Grid.lg,
vertical: Grid.twelve,
),
backgroundColor: _onboardingInk,
foregroundColor: _onboardingCtaLabel,
disabledBackgroundColor: _onboardingInk.withValues(alpha: 0.38),
disabledForegroundColor: _onboardingCtaLabel.withValues(alpha: 0.7),
shape: const StadiumBorder(),
);
final _onboardingSecondaryButtonStyle = TextButton.styleFrom(
minimumSize: const Size(0, 44),
padding: const EdgeInsets.symmetric(horizontal: Grid.md, vertical: Grid.xxs),
backgroundColor: _onboardingInk.withValues(alpha: 0.1),
foregroundColor: _onboardingInk,
disabledBackgroundColor: _onboardingInk.withValues(alpha: 0.05),
disabledForegroundColor: _onboardingInk.withValues(alpha: 0.45),
shape: const StadiumBorder(),
);

View File

@@ -23,6 +23,23 @@ import 'search_provider.dart';
enum _SearchFilter { all, messages, channels, people }
const _searchFieldMinHeight = 36.0;
const _searchFieldVerticalPadding = Grid.xxs;
double _searchFieldHeight(BuildContext context) {
final style =
context.textTheme.bodyMedium ??
const TextStyle(fontSize: 14, height: 1.3);
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 14);
final contentHeight =
scaledFontSize * (style.height ?? 1) + _searchFieldVerticalPadding * 2;
return contentHeight > _searchFieldMinHeight
? contentHeight
: _searchFieldMinHeight;
}
class SearchPage extends HookConsumerWidget {
const SearchPage({super.key});
@@ -39,35 +56,72 @@ class SearchPage extends HookConsumerWidget {
textController,
() => textController.text.isNotEmpty,
);
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 headerTitleStyle = context.textTheme.titleMedium?.copyWith(
fontSize: 22,
fontWeight: FontWeight.w600,
);
final searchFieldHeight = _searchFieldHeight(context);
final searchHeaderBottomHeight = searchFieldHeight + Grid.twelve;
return FrostedScaffold(
resizeToAvoidBottomInset: true,
// Keep the empty state centered in the page rather than the portion left
// above the keyboard.
resizeToAvoidBottomInset: false,
appBar: FrostedAppBar(
title: Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: Grid.half),
decoration: BoxDecoration(
color: context.colors.surfaceContainerHighest,
borderRadius: BorderRadius.circular(Radii.lg),
gradient: context.appColors.topSectionGradient,
title: const Text('Search'),
titleStyle: headerTitleStyle,
bottomHeight: searchHeaderBottomHeight,
bottom: Padding(
padding: const EdgeInsets.fromLTRB(
Grid.gutter,
0,
Grid.gutter,
Grid.twelve,
),
child: TextField(
controller: textController,
decoration: InputDecoration(
hintText: 'Search messages, channels, people\u2026',
hintStyle: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
prefixIcon: const Icon(LucideIcons.search, size: 16),
prefixIconConstraints: const BoxConstraints(minWidth: 32),
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding: const EdgeInsets.symmetric(vertical: Grid.xxs),
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(
controller: textController,
decoration: InputDecoration(
hintText: 'Search messages, channels, people\u2026',
hintStyle: context.textTheme.bodyMedium?.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: context.textTheme.bodyMedium,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
),
style: context.textTheme.bodyMedium,
onChanged: (value) =>
ref.read(searchProvider.notifier).search(value),
),
),
actions: [
@@ -82,9 +136,20 @@ class SearchPage extends HookConsumerWidget {
],
),
body: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(height: frostedAppBarHeight(context)),
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: [
@@ -120,10 +185,26 @@ class _SearchBody extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
if (state.query.isEmpty) {
return Center(
child: Text(
'Search messages, channels, and people',
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
child: Padding(
key: const Key('search-empty-state'),
padding: const EdgeInsets.symmetric(horizontal: Grid.gutter),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
LucideIcons.search,
size: 64,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Grid.xs),
Text(
'Search messages, channels, and people',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
],
),
),
);
@@ -142,18 +223,27 @@ class _SearchBody extends ConsumerWidget {
state.messageResults.isNotEmpty;
if (!state.isLoading && !hasAnyResults) {
return Center(
child: Text(
"No results for '${state.query}'",
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
return Padding(
key: const Key('search-no-results-state'),
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: Center(
child: Text(
"No results for '${state.query}'",
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
),
);
}
return ListView(
padding: const EdgeInsets.only(bottom: Grid.xl),
key: const Key('search-results-list'),
padding: EdgeInsets.only(
bottom: Grid.xl + MediaQuery.viewInsetsOf(context).bottom,
),
children: [
if (showChannels && state.channelResults.isNotEmpty)
_ChannelsSection(channels: state.channelResults),

View File

@@ -0,0 +1,59 @@
import 'dart:convert';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
/// Supplies the HTTP client used for NIP-11 community icon lookups.
///
/// Tests can override this provider to return deterministic relay responses.
final communityIconHttpClientProvider = Provider<http.Client>((ref) {
final client = http.Client();
ref.onDispose(client.close);
return client;
});
/// Reads a community's icon from its public NIP-11 relay information document.
///
/// The lookup does not depend on the active relay session, so icons can render
/// for every paired community in the switcher. Callers explicitly invalidate
/// this family when opening the switcher so transient failures and relay
/// metadata updates can be retried without background polling.
final communityIconProvider = FutureProvider.autoDispose
.family<String?, String>((ref, relayUrl) async {
final uri = _relayInfoUri(relayUrl);
if (uri == null) return null;
try {
final response = await ref
.read(communityIconHttpClientProvider)
.get(uri, headers: const {'Accept': 'application/nostr+json'})
.timeout(const Duration(seconds: 5));
if (response.statusCode < 200 || response.statusCode >= 300) {
return null;
}
final document = jsonDecode(response.body);
if (document is! Map<String, dynamic>) return null;
final icon = document['icon'];
if (icon is! String || icon.trim().isEmpty) return null;
return icon.trim();
} catch (_) {
return null;
}
});
Uri? _relayInfoUri(String relayUrl) {
try {
final uri = Uri.parse(relayUrl.trim());
final scheme = switch (uri.scheme) {
'wss' => 'https',
'ws' => 'http',
'https' || 'http' => uri.scheme,
_ => null,
};
return scheme == null ? null : uri.replace(scheme: scheme);
} on FormatException {
return null;
}
}

View File

@@ -3,6 +3,15 @@ import 'package:flutter/material.dart';
const _fontFamily = 'Inter';
const _chatLineHeight = 22 / 16;
/// Optional 12sp body style for compact secondary metadata.
const bodyExtraSmallTextStyle = TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,
);
const textTheme = TextTheme(
displayLarge: TextStyle(
fontFamily: _fontFamily,
@@ -104,7 +113,7 @@ const textTheme = TextTheme(
),
bodySmall: TextStyle(
fontFamily: _fontFamily,
fontSize: 12,
fontSize: 14,
fontWeight: FontWeight.w400,
height: 1.25,
letterSpacing: 0,

View File

@@ -9,3 +9,4 @@ export 'theme_catalog.dart';
export 'theme_extensions.dart';
export 'theme_pairs.dart';
export 'theme_provider.dart';
export 'text_theme.dart' show bodyExtraSmallTextStyle;

View File

@@ -34,33 +34,66 @@ class FilterChipBar<T> extends StatelessWidget {
final T selected;
final ValueChanged<T> onSelected;
/// When enabled, every chip shares the width rather than scrolling.
final bool expandItems;
/// Material density applied to each chip.
final VisualDensity visualDensity;
/// Vertical padding around the content inside each chip.
final double chipVerticalPadding;
/// Vertical spacing around the chip row.
final double barVerticalPadding;
const FilterChipBar({
super.key,
required this.items,
required this.selected,
required this.onSelected,
this.expandItems = false,
this.visualDensity = VisualDensity.compact,
this.chipVerticalPadding = Grid.quarter,
this.barVerticalPadding = Grid.xxs,
});
@override
Widget build(BuildContext context) {
final chips = [
for (var index = 0; index < items.length; index++) ...[
if (index > 0) const SizedBox(width: Grid.xxs),
if (expandItems)
Expanded(child: _chip(context, items[index], fillWidth: true))
else
_chip(context, items[index]),
],
];
if (expandItems) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: barVerticalPadding,
),
child: Row(children: chips),
);
}
return SingleChildScrollView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(
padding: EdgeInsets.symmetric(
horizontal: Grid.gutter,
vertical: Grid.xxs,
),
child: Row(
children: [
for (final item in items) ...[
if (item != items.first) const SizedBox(width: Grid.xxs),
_chip(context, item),
],
],
vertical: barVerticalPadding,
),
child: Row(children: chips),
);
}
Widget _chip(BuildContext context, FilterChipItem<T> item) {
Widget _chip(
BuildContext context,
FilterChipItem<T> item, {
bool fillWidth = false,
}) {
final isSelected = selected == item.id;
final text = item.count != null
? '${item.label} (${item.count})'
@@ -68,23 +101,52 @@ class FilterChipBar<T> extends StatelessWidget {
final fg = isSelected
? context.colors.onPrimary
: context.colors.onSurfaceVariant;
final labelStyle = context.textTheme.labelSmall?.copyWith(color: fg);
return FilterChip(
final labelStyle = context.textTheme.bodyLarge?.copyWith(
color: fg,
fontWeight: isSelected ? FontWeight.w500 : FontWeight.w400,
);
final label = item.icon != null
? Row(
mainAxisSize: fillWidth ? MainAxisSize.max : MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(item.icon, size: 14, color: fg),
const SizedBox(width: Grid.half),
if (fillWidth)
Flexible(
child: Text(
text,
textAlign: TextAlign.center,
style: labelStyle,
),
)
else
Text(text, style: labelStyle),
],
)
: Text(
text,
textAlign: fillWidth ? TextAlign.center : TextAlign.start,
style: labelStyle,
);
final chip = FilterChip(
selected: isSelected,
showCheckmark: false,
label: item.icon != null
? Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(item.icon, size: 14, color: fg),
const SizedBox(width: Grid.half),
Text(text, style: labelStyle),
],
label: fillWidth
? SizedBox(
width: double.infinity,
child: Center(child: label),
)
: Text(text, style: labelStyle),
: label,
onSelected: (_) => onSelected(item.id),
visualDensity: VisualDensity.compact,
padding: EdgeInsets.symmetric(
horizontal: fillWidth ? Grid.quarter : Grid.twelve,
vertical: chipVerticalPadding,
),
visualDensity: visualDensity,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
);
return fillWidth ? SizedBox(width: double.infinity, child: chip) : chip;
}
}

View File

@@ -5,14 +5,51 @@ import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../theme/theme.dart';
/// Height of the frosted app bar content area below the safe area.
const _kBarContentHeight = Grid.xxs + 32 + Grid.xxs; // 48
/// Minimum height of the frosted app bar content area below the safe area.
const _kBarContentMinHeight = Grid.xxs + 32 + Grid.xxs; // 48
const _kBottomBorderWidth = 1.0;
TextStyle _effectiveTitleStyle(BuildContext context, TextStyle? titleStyle) {
final baseStyle =
context.textTheme.titleMedium ??
const TextStyle(fontSize: 20, height: 1.3);
return baseStyle.copyWith(fontWeight: FontWeight.w600).merge(titleStyle);
}
double _barContentHeight(
BuildContext context,
TextStyle? titleStyle,
double titleContentHeight,
) {
final style = _effectiveTitleStyle(context, titleStyle);
final scaledFontSize = MediaQuery.textScalerOf(
context,
).scale(style.fontSize ?? 20);
final scaledTitleHeight = scaledFontSize * (style.height ?? 1);
final effectiveTitleHeight = titleContentHeight > scaledTitleHeight
? titleContentHeight
: scaledTitleHeight;
final accessibleHeight = Grid.xxs + effectiveTitleHeight + Grid.xxs;
return accessibleHeight > _kBarContentMinHeight
? accessibleHeight
: _kBarContentMinHeight;
}
/// 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.
double frostedAppBarHeight(BuildContext context) {
return MediaQuery.paddingOf(context).top + _kBarContentHeight;
/// Pass the same [titleStyle] and [titleContentHeight] to the bar and this
/// helper when customizing them.
double frostedAppBarHeight(
BuildContext context, {
double bottomHeight = 0,
TextStyle? titleStyle,
double titleContentHeight = 0,
}) {
return MediaQuery.paddingOf(context).top +
_barContentHeight(context, titleStyle, titleContentHeight) +
bottomHeight +
_kBottomBorderWidth;
}
/// A frosted-glass floating app bar designed to sit inside a [Stack].
@@ -27,6 +64,20 @@ class FrostedAppBar extends StatelessWidget {
/// Widget displayed in the center/title area.
final Widget? title;
/// Optional style merged over the default title style.
final TextStyle? titleStyle;
/// Scaled height needed by a custom title with multiple text lines.
///
/// Pass the same value to [frostedAppBarHeight] when spacing body content.
final double titleContentHeight;
/// Optional content displayed below the title row in the same surface.
final Widget? bottom;
/// Height reserved for [bottom].
final double bottomHeight;
/// Widgets displayed on the trailing (right) side.
final List<Widget> actions;
@@ -45,16 +96,26 @@ class FrostedAppBar extends StatelessWidget {
super.key,
this.leading,
this.title,
this.titleStyle,
this.titleContentHeight = 0,
this.bottom,
this.bottomHeight = 0,
this.actions = const [],
this.horizontalInset = Grid.quarter,
this.iconColor,
this.gradient,
});
}) : assert(bottom == null || bottomHeight > 0);
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.paddingOf(context).top;
final canPop = Navigator.canPop(context);
final effectiveTitleStyle = _effectiveTitleStyle(context, titleStyle);
final barContentHeight = _barContentHeight(
context,
titleStyle,
titleContentHeight,
);
final effectiveLeading =
leading ??
@@ -90,46 +151,52 @@ class FrostedAppBar extends StatelessWidget {
border: Border(
bottom: BorderSide(
color: context.colors.outlineVariant.withValues(alpha: 0.3),
width: _kBottomBorderWidth,
),
),
),
child: SizedBox(
height: _kBarContentHeight,
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: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
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!,
),
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
child: title!,
),
),
)
else
const Spacer(),
...actions,
],
)
else
const Spacer(),
...actions,
],
),
),
),
),
),
if (bottom != null)
SizedBox(height: bottomHeight, child: bottom),
],
),
),
),

View File

@@ -0,0 +1,149 @@
import 'dart:math' show cos, min, pi;
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
/// The Buzz mark with wings that flutter twice when the user taps it.
///
/// The geometry and wing tuck match the desktop loading bee. When reduced
/// motion is enabled, the mark stays static.
class TappableFlappingBee extends HookConsumerWidget {
/// The rendered width of the complete bee mark.
final double width;
/// The color used for the bee silhouette.
final Color color;
const TappableFlappingBee({
required this.width,
required this.color,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final animation = useAnimationController(
duration: const Duration(milliseconds: 480),
);
final reducedMotion = MediaQuery.disableAnimationsOf(context);
void flutterWings() {
if (reducedMotion) return;
animation.forward(from: 0);
}
return Semantics(
button: true,
label: 'Buzz bee',
hint: 'Tap to make its wings flutter',
onTap: flutterWings,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
excludeFromSemantics: true,
onTap: flutterWings,
child: RepaintBoundary(
child: AnimatedBuilder(
animation: animation,
builder: (context, _) {
final flapAmount = 0.5 - (0.5 * cos(animation.value * 4 * pi));
return CustomPaint(
size: Size(width, width * 309 / 466),
painter: _FlappingBeePainter(
color: color,
flapAmount: flapAmount,
),
);
},
),
),
),
);
}
}
class _FlappingBeePainter extends CustomPainter {
final Color color;
final double flapAmount;
const _FlappingBeePainter({required this.color, required this.flapAmount});
@override
void paint(Canvas canvas, Size size) {
final scale = min(size.width / 466, size.height / 309);
final renderedWidth = 466 * scale;
final renderedHeight = 309 * scale;
canvas
..save()
..translate(
(size.width - renderedWidth) / 2,
(size.height - renderedHeight) / 2,
)
..scale(scale);
final wingRadiusX = 91.7 * (1 - (0.38 * flapAmount));
final wingTranslation = 30 * flapAmount;
final leftWing = Path()
..addOval(
Rect.fromCenter(
center: Offset(91.7 + wingTranslation, 154.5),
width: wingRadiusX * 2,
height: 183.4,
),
);
final rightWing = Path()
..addOval(
Rect.fromCenter(
center: Offset(374.3 - wingTranslation, 154.5),
width: wingRadiusX * 2,
height: 183.4,
),
);
final body = Path()
..addRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(128, 0, 210, 309),
const Radius.circular(34),
),
);
final cutouts = Path()
..addOval(
Rect.fromCenter(
center: const Offset(193.3, 84.4),
width: 54,
height: 54,
),
)
..addOval(
Rect.fromCenter(center: const Offset(276, 84.4), width: 54, height: 54),
)
..addRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(166.3, 157.2, 136.9, 38.3),
const Radius.circular(5),
),
)
..addRRect(
RRect.fromRectAndRadius(
const Rect.fromLTWH(166.9, 235.1, 136.2, 37.6),
const Radius.circular(5),
),
);
final wings = Path.combine(PathOperation.union, leftWing, rightWing);
final silhouette = Path.combine(PathOperation.union, wings, body);
final finishedMark = Path.combine(
PathOperation.difference,
silhouette,
cutouts,
);
canvas
..drawPath(finishedMark, Paint()..color = color)
..restore();
}
@override
bool shouldRepaint(_FlappingBeePainter oldDelegate) =>
color != oldDelegate.color || flapAmount != oldDelegate.flapAmount;
}

View File

@@ -25,6 +25,7 @@ import 'package:buzz/features/profile/user_cache_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:buzz/shared/widgets/frosted_app_bar.dart';
const _channelId = 'test-channel';
@@ -144,6 +145,7 @@ Widget _buildTestable({
_FakeMessagesNotifier? messagesNotifier,
String? canvasContent,
List<NostrEvent>? threadReplies,
TextScaler textScaler = TextScaler.noScaling,
}) {
final resolvedChannel = channel ?? _testChannel;
final fakeChannelsNotifier =
@@ -190,7 +192,12 @@ Widget _buildTestable({
child: MaterialApp(
theme: AppTheme.light(),
navigatorObservers: navigatorObservers,
home: ChannelDetailPage(channel: resolvedChannel),
home: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(textScaler: textScaler),
child: ChannelDetailPage(channel: resolvedChannel),
),
),
),
);
}
@@ -1327,6 +1334,50 @@ void main() {
expect(find.text('secret'), findsOneWidget);
expect(find.byIcon(LucideIcons.lock), findsOneWidget);
});
testWidgets('grows for a scaled two-line DM title', (tester) async {
final dmChannel = Channel(
id: _channelId,
name: 'dm',
channelType: 'dm',
visibility: 'private',
description: '',
createdBy: 'alice',
createdAt: DateTime(2025),
memberCount: 2,
participants: const ['Alice'],
participantPubkeys: const ['alice'],
isMember: true,
);
await tester.pumpWidget(
_buildTestable(
messages: [],
channel: dmChannel,
users: const {
'alice': UserProfile(pubkey: 'alice', displayName: 'Alice'),
},
textScaler: const TextScaler.linear(1.25),
),
);
await tester.pumpAndSettle();
final appBar = find.byType(FrostedAppBar);
final clip = find.descendant(of: appBar, matching: find.byType(ClipRect));
final title = find.descendant(of: appBar, matching: find.text('Alice'));
final presence = find.descendant(
of: appBar,
matching: find.text('Offline'),
);
expect(
tester.getSize(clip).height,
greaterThan(
tester.getSize(title).height + tester.getSize(presence).height,
),
);
expect(tester.takeException(), isNull);
});
});
group('Error and loading states', () {

View File

@@ -14,6 +14,8 @@ import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_avatar.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/auth/auth.dart';
import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
@@ -22,12 +24,20 @@ void main() {
required List<Override> overrides,
bool previewDirectory = false,
double keyboardInset = 0,
bool disableAnimations = false,
Map<String, String?> communityIcons = const {},
ValueChanged<String>? onCommunityIconLoad,
TextScaler textScaler = TextScaler.noScaling,
}) {
return ProviderScope(
overrides: [
// Provide a fake profile and presence so the avatar doesn't hit the network.
profileProvider.overrideWith(() => _FakeProfileNotifier()),
presenceProvider.overrideWith(() => _FakePresenceNotifier()),
communityIconProvider.overrideWith((ref, relayUrl) async {
onCommunityIconLoad?.call(relayUrl);
return communityIcons[relayUrl];
}),
dmDirectoryPreviewEnabledProvider.overrideWith(
(ref) => previewDirectory,
),
@@ -36,9 +46,11 @@ void main() {
child: MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(viewInsets: EdgeInsets.only(bottom: keyboardInset)),
data: MediaQuery.of(context).copyWith(
disableAnimations: disableAnimations,
textScaler: textScaler,
viewInsets: EdgeInsets.only(bottom: keyboardInset),
),
child: child!,
),
home: const Stack(
@@ -136,6 +148,295 @@ void main() {
expect(find.text('Injected settings'), findsOneWidget);
});
testWidgets('community switcher separates selection from edit removal', (
tester,
) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final communities = [
Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
),
Community(
id: 'bravo',
name: 'Bravo',
relayUrl: 'wss://bravo.example.com',
addedAt: DateTime(2025),
),
];
final communityNotifier = _FakeCommunityListNotifier(communities);
await tester.pumpWidget(
buildTestable(
communityIcons: const {
'wss://alpha.example.com':
'data:image/png;base64,'
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
},
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(() => communityNotifier),
activeCommunityProvider.overrideWith(
(ref) async => communities.first,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(find.text('Switch Community'), findsOneWidget);
final options = find.byKey(const Key('community-switcher-options'));
expect(options, findsOneWidget);
final editButton = find.byKey(const Key('community-switcher-edit'));
expect(
tester.getRect(options).top - tester.getRect(editButton).bottom,
closeTo(8, 0.01),
);
expect(tester.getSize(editButton).height, 32);
expect(find.text('alpha.example.com'), findsOneWidget);
expect(find.text('bravo.example.com'), findsOneWidget);
expect(find.text('Rename'), findsNothing);
expect(find.byIcon(LucideIcons.ellipsisVertical), findsNothing);
expect(find.text('Edit'), findsOneWidget);
expect(find.byIcon(LucideIcons.trash2), findsNothing);
expect(
tester.getSize(find.byKey(const Key('community-switcher-avatar-alpha'))),
const Size.square(36),
);
final alphaAvatar = tester.widget<AvatarImage>(
find.descendant(
of: find.byKey(const Key('community-switcher-avatar-alpha')),
matching: find.byType(AvatarImage),
),
);
expect(alphaAvatar.imageUrl, startsWith('data:image/png;base64,'));
final activeSelection = find.byKey(
const Key('community-switcher-selection-alpha'),
);
final inactiveSelection = find.byKey(
const Key('community-switcher-selection-bravo'),
);
expect(tester.getSize(activeSelection), const Size.square(40));
expect(
tester.getSize(find.byKey(const Key('community-switcher-circle-alpha'))),
const Size.square(24),
);
expect(
find.descendant(
of: activeSelection,
matching: find.byIcon(LucideIcons.check),
),
findsOneWidget,
);
expect(
find.descendant(
of: inactiveSelection,
matching: find.byIcon(LucideIcons.check),
),
findsNothing,
);
expect(
find.descendant(of: options, matching: find.byType(Divider)),
findsNWidgets(2),
);
await tester.tap(find.text('Edit'));
await tester.pump();
final activeAction = find.byKey(
const Key('community-switcher-action-alpha'),
);
final actionSwitcher = tester.widget<AnimatedSwitcher>(
find.descendant(
of: activeAction,
matching: find.byType(AnimatedSwitcher),
),
);
expect(actionSwitcher.duration, const Duration(milliseconds: 250));
expect(activeSelection, findsOneWidget);
expect(
find.byKey(const Key('community-switcher-remove-alpha')),
findsOneWidget,
);
await tester.pump(const Duration(milliseconds: 125));
final transitioningOpacities = tester
.widgetList<Opacity>(
find.descendant(of: activeAction, matching: find.byType(Opacity)),
)
.map((opacity) => opacity.opacity);
expect(
transitioningOpacities.any((opacity) => opacity > 0 && opacity < 1),
isTrue,
);
expect(
find.descendant(of: activeAction, matching: find.byType(ImageFiltered)),
findsWidgets,
);
await tester.pumpAndSettle();
expect(find.text('Done'), findsOneWidget);
expect(find.byIcon(LucideIcons.trash2), findsNWidgets(2));
expect(activeSelection, findsNothing);
expect(inactiveSelection, findsNothing);
await tester.tap(find.byTooltip('Remove Bravo'));
await tester.pumpAndSettle();
expect(find.text('Remove community?'), findsOneWidget);
expect(
find.text(
'Are you sure you want to remove “Bravo”? '
'You can pair with it again later.',
),
findsOneWidget,
);
await tester.tap(find.widgetWithText(FilledButton, 'Remove'));
await tester.pumpAndSettle();
expect(communityNotifier.removedIds, ['bravo']);
expect(find.text('Switch Community'), findsOneWidget);
expect(find.text('Bravo'), findsNothing);
expect(find.text('Done'), findsOneWidget);
await tester.tap(find.text('Done'));
await tester.pumpAndSettle();
expect(find.text('Edit'), findsOneWidget);
expect(
find.byKey(const Key('community-switcher-selection-alpha')),
findsOneWidget,
);
});
testWidgets('opening the community switcher refreshes visible icons', (
tester,
) async {
final community = Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
);
var iconLoads = 0;
await tester.pumpWidget(
buildTestable(
onCommunityIconLoad: (_) => iconLoads++,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier([community]),
),
activeCommunityProvider.overrideWith((ref) async => community),
],
),
);
await tester.pumpAndSettle();
final initialIconLoads = iconLoads;
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(iconLoads, greaterThan(initialIconLoads));
});
testWidgets('community switcher header grows with accessible text', (
tester,
) async {
final community = Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
);
await tester.pumpWidget(
buildTestable(
textScaler: TextScaler.linear(2),
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier([community]),
),
activeCommunityProvider.overrideWith((ref) async => community),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
expect(
tester.getSize(find.byKey(const Key('community-switcher-title'))).height,
greaterThan(32),
);
});
testWidgets('community switcher disables icon motion when requested', (
tester,
) async {
final communities = [
Community(
id: 'alpha',
name: 'Alpha',
relayUrl: 'wss://alpha.example.com',
addedAt: DateTime(2025),
),
];
await tester.pumpWidget(
buildTestable(
disableAnimations: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
communityListProvider.overrideWith(
() => _FakeCommunityListNotifier(communities),
),
activeCommunityProvider.overrideWith(
(ref) async => communities.first,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.text('Alpha'));
await tester.pumpAndSettle();
await tester.tap(find.text('Edit'));
await tester.pump();
final actionSwitcher = tester.widget<AnimatedSwitcher>(
find.descendant(
of: find.byKey(const Key('community-switcher-action-alpha')),
matching: find.byType(AnimatedSwitcher),
),
);
expect(actionSwitcher.duration, Duration.zero);
expect(
find.byKey(const Key('community-switcher-selection-alpha')),
findsNothing,
);
expect(
find.byKey(const Key('community-switcher-remove-alpha')),
findsOneWidget,
);
});
testWidgets('quick actions slide behind navigation when leaving home', (
tester,
) async {
@@ -936,6 +1237,25 @@ class _FakeNotifier extends ChannelsNotifier {
get observedUnreadEventsByChannel => _observedEventsByChannel;
}
class _FakeCommunityListNotifier extends CommunityListNotifier {
_FakeCommunityListNotifier(this._communities);
List<Community> _communities;
final List<String> removedIds = [];
@override
Future<List<Community>> build() async => _communities;
@override
Future<void> removeCommunity(String id) async {
removedIds.add(id);
_communities = _communities
.where((community) => community.id != id)
.toList();
state = AsyncData(_communities);
}
}
class _ErrorNotifier extends ChannelsNotifier {
@override
Future<List<Channel>> build() => Future.error('Connection refused');

View File

@@ -1,27 +1,96 @@
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:buzz/features/pairing/pairing_page.dart';
import 'package:buzz/features/pairing/pairing_provider.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/tappable_flapping_bee.dart';
import '../../helpers/widget_helpers.dart';
void main() {
group('PairingPage', () {
testWidgets('renders branding, scan button, divider, and text field', (
testWidgets('renders branding and progressive pairing actions', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
expect(
find.image(const AssetImage('assets/images/buzz-icon.png')),
findsOneWidget,
);
expect(find.byType(TappableFlappingBee), findsOneWidget);
expect(find.text('Welcome to Buzz'), findsOneWidget);
expect(find.text('Scan QR Code'), findsOneWidget);
expect(find.text('or paste pairing code'), findsOneWidget);
expect(find.text('Scan a QR code'), findsOneWidget);
expect(find.text('Use pairing code'), findsOneWidget);
expect(find.text('Connect'), findsNothing);
expect(find.byType(TextField), findsNothing);
});
testWidgets('uses compact desktop-style onboarding actions', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
final scanButton = tester.getSize(
find.widgetWithText(FilledButton, 'Scan a QR code'),
);
final pairingCodeButton = tester.getSize(
find.widgetWithText(TextButton, 'Use pairing code'),
);
expect(scanButton.width, lessThan(440));
expect(pairingCodeButton.width, lessThan(440));
expect(find.byType(OutlinedButton), findsNothing);
});
testWidgets('uses dark status-bar icons on the onboarding surface', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('pairing-onboarding-system-overlay')),
);
expect(overlay.value.statusBarIconBrightness, Brightness.dark);
expect(overlay.value.statusBarColor, Colors.transparent);
});
testWidgets('uses light status-bar icons for dark-theme SAS verification', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
overrides: [
pairingProvider.overrideWith(() => _ConfirmingSasPairingNotifier()),
],
child: MaterialApp(theme: AppTheme.dark(), home: const PairingPage()),
),
);
final overlay = tester.widget<AnnotatedRegion<SystemUiOverlayStyle>>(
find.byKey(const Key('pairing-sas-system-overlay')),
);
expect(overlay.value.statusBarIconBrightness, Brightness.light);
expect(overlay.value.statusBarColor, Colors.transparent);
expect(find.text('Verify Security Code'), findsOneWidget);
});
testWidgets('reveals pairing code field and connect action', (
tester,
) async {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
await _expandPairingCode(tester);
expect(find.text('Hide pairing code'), findsOneWidget);
expect(find.text('Connect'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
});
@@ -32,6 +101,7 @@ void main() {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
await _expandPairingCode(tester);
final textField = tester.getBottomLeft(find.byType(TextField));
final connectButton = tester.getTopLeft(
@@ -46,6 +116,7 @@ void main() {
await tester.pumpWidget(
WidgetHelpers.testable(child: const PairingPage()),
);
await _expandPairingCode(tester);
final connectButton = tester.getSize(
find.widgetWithText(FilledButton, 'Connect'),
@@ -88,9 +159,7 @@ void main() {
expect(find.text('Connect'), findsNothing);
});
testWidgets('text field and buttons disabled when connecting', (
tester,
) async {
testWidgets('pairing actions are disabled when connecting', (tester) async {
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
@@ -101,12 +170,22 @@ void main() {
);
await tester.pump();
final textField = tester.widget<TextField>(find.byType(TextField));
expect(textField.enabled, isFalse);
final scanButton = tester.widget<FilledButton>(find.byType(FilledButton));
final pairingCodeButton = tester.widget<TextButton>(
find.widgetWithText(TextButton, 'Use pairing code'),
);
expect(scanButton.onPressed, isNull);
expect(pairingCodeButton.onPressed, isNull);
});
});
}
Future<void> _expandPairingCode(WidgetTester tester) async {
await tester.tap(find.text('Use pairing code'));
await tester.pumpAndSettle();
}
class _ErrorPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
final String error;
@@ -146,3 +225,24 @@ class _ConnectingPairingNotifier extends Notifier<PairingState>
@override
void denySas() {}
}
class _ConfirmingSasPairingNotifier extends Notifier<PairingState>
implements PairingNotifier {
@override
PairingState build() => const PairingState(
status: PairingStatus.confirmingSas,
sasCode: '123456',
);
@override
Future<void> pair(String rawInput) async {}
@override
void reset() {}
@override
void confirmSas() {}
@override
void denySas() {}
}

View File

@@ -0,0 +1,162 @@
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
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:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../helpers/widget_helpers.dart';
void main() {
testWidgets('empty state preserves large accessible text scaling', (
tester,
) async {
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState.initial()),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(textScaler: const TextScaler.linear(2)),
child: const SearchPage(),
),
),
),
);
await tester.pumpAndSettle();
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!;
final scaledLineHeight =
MediaQuery.textScalerOf(searchFieldContext).scale(bodyStyle.fontSize!) *
bodyStyle.height!;
expect(
find.descendant(of: emptyState, matching: find.byType(FittedBox)),
findsNothing,
);
expect(
tester.getSize(searchField).height,
greaterThanOrEqualTo(scaledLineHeight + Grid.xxs * 2),
);
expect(tester.getSize(message).height, greaterThan(32));
expect(tester.takeException(), isNull);
});
testWidgets('keeps search results scrollable above the keyboard', (
tester,
) async {
const keyboardInset = 300.0;
final state = SearchState(
query: 'general',
channelResults: [
Channel(
id: 'general',
name: 'general',
channelType: 'stream',
visibility: 'open',
description: 'General discussion',
createdBy: 'test',
createdAt: DateTime(2025),
memberCount: 1,
isMember: true,
),
],
);
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(() => _FakeSearchNotifier(state)),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(
viewInsets: const EdgeInsets.only(bottom: keyboardInset),
),
child: const SearchPage(),
),
),
),
);
await tester.pumpAndSettle();
final results = tester.widget<ListView>(
find.byKey(const Key('search-results-list')),
);
final padding = results.padding! as EdgeInsets;
expect(padding.bottom, Grid.xl + keyboardInset);
});
testWidgets('keeps no-results feedback above the keyboard', (tester) async {
const keyboardInset = 300.0;
tester.view.physicalSize = const Size(320, 640);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const query = 'missing';
await tester.pumpWidget(
WidgetHelpers.testable(
overrides: [
searchProvider.overrideWith(
() => _FakeSearchNotifier(const SearchState(query: query)),
),
profileProvider.overrideWith(() => _FakeProfileNotifier()),
],
child: Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(context).copyWith(
viewInsets: const EdgeInsets.only(bottom: keyboardInset),
),
child: const SearchPage(),
),
),
),
);
await tester.pumpAndSettle();
final noResults = find.byKey(const Key('search-no-results-state'));
final message = find.text("No results for '$query'");
final keyboardTop =
tester.view.physicalSize.height / tester.view.devicePixelRatio -
keyboardInset;
expect(noResults, findsOneWidget);
expect(tester.getBottomLeft(message).dy, lessThan(keyboardTop));
expect(tester.takeException(), isNull);
});
}
class _FakeSearchNotifier extends SearchNotifier {
_FakeSearchNotifier(this.initialState);
final SearchState initialState;
@override
SearchState build() => initialState;
}
class _FakeProfileNotifier extends ProfileNotifier {
@override
Future<UserProfile?> build() async =>
const UserProfile(pubkey: 'test', displayName: 'Test');
}

View File

@@ -0,0 +1,93 @@
import 'package:buzz/shared/community/community_icon_provider.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart' as http_testing;
void main() {
test('fetches the NIP-11 icon over the relay HTTP URL', () async {
late http.Request capturedRequest;
final client = http_testing.MockClient((request) async {
capturedRequest = request;
return http.Response('{"icon":"data:image/png;base64,icon-bytes"}', 200);
});
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('wss://relay.example.com').future,
);
expect(capturedRequest.url, Uri.parse('https://relay.example.com'));
expect(capturedRequest.headers['Accept'], 'application/nostr+json');
expect(icon, 'data:image/png;base64,icon-bytes');
});
test('returns null when the relay has no usable icon', () async {
final client = http_testing.MockClient(
(_) async => http.Response('{"icon":""}', 200),
);
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('https://relay.example.com').future,
);
expect(icon, isNull);
});
test('returns null when relay information cannot be loaded', () async {
final client = http_testing.MockClient(
(_) async => http.Response('unavailable', 503),
);
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
addTearDown(container.dispose);
final icon = await container.read(
communityIconProvider('https://relay.example.com').future,
);
expect(icon, isNull);
});
test('refreshes a transient failure when explicitly invalidated', () async {
var requestCount = 0;
final client = http_testing.MockClient((_) async {
requestCount++;
if (requestCount == 1) {
return http.Response('unavailable', 503);
}
return http.Response(
'{"icon":"https://relay.example.com/icon.png"}',
200,
);
});
final container = ProviderContainer(
overrides: [communityIconHttpClientProvider.overrideWithValue(client)],
);
final provider = communityIconProvider('https://relay.example.com');
final subscription = container.listen(provider, (_, _) {});
addTearDown(() {
subscription.close();
container.dispose();
});
expect(await container.read(provider.future), isNull);
expect(requestCount, 1);
container.invalidate(provider);
expect(
await container.read(provider.future),
'https://relay.example.com/icon.png',
);
expect(requestCount, 2);
});
}

View File

@@ -36,4 +36,44 @@ void main() {
final resolved = chipTheme.color?.resolve({WidgetState.selected});
expect(resolved, accent);
});
testWidgets('expanded chips preserve large accessible text scaling', (
tester,
) async {
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: Scaffold(
body: SizedBox(
width: 240,
child: FilterChipBar<int>(
expandItems: true,
selected: 0,
onSelected: (_) {},
items: const [
FilterChipItem(id: 0, label: 'All'),
FilterChipItem(id: 1, label: 'Messages'),
FilterChipItem(id: 2, label: 'Channels'),
FilterChipItem(id: 3, label: 'People'),
],
),
),
),
),
),
);
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(FilterChipBar<int>),
matching: find.byType(FittedBox),
),
findsNothing,
);
expect(tester.getSize(find.text('Messages')).height, greaterThan(32));
expect(tester.takeException(), isNull);
});
}

View File

@@ -0,0 +1,86 @@
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/frosted_app_bar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('title row and reported height grow with accessible text', (
tester,
) async {
const titleStyle = TextStyle(fontSize: 22, height: 1.3);
late double reportedHeight;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: MediaQuery(
data: const MediaQueryData(textScaler: TextScaler.linear(2)),
child: Builder(
builder: (context) {
reportedHeight = frostedAppBarHeight(
context,
titleStyle: titleStyle,
);
return const Stack(
children: [
FrostedAppBar(title: Text('Search'), titleStyle: titleStyle),
],
);
},
),
),
),
);
await tester.pumpAndSettle();
final clip = find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
);
expect(reportedHeight, greaterThan(48));
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
expect(tester.getSize(find.text('Search')).height, greaterThan(48));
expect(tester.takeException(), isNull);
});
testWidgets('custom multi-line title height matches reported height', (
tester,
) async {
const titleContentHeight = 64.0;
late double reportedHeight;
await tester.pumpWidget(
MaterialApp(
theme: AppTheme.light(),
home: Builder(
builder: (context) {
reportedHeight = frostedAppBarHeight(
context,
titleContentHeight: titleContentHeight,
);
return const Stack(
children: [
FrostedAppBar(
titleContentHeight: titleContentHeight,
title: Column(
mainAxisSize: MainAxisSize.min,
children: [Text('Title'), Text('Subtitle')],
),
),
],
);
},
),
),
);
await tester.pumpAndSettle();
final clip = find.descendant(
of: find.byType(FrostedAppBar),
matching: find.byType(ClipRect),
);
expect(tester.getSize(clip).height, closeTo(reportedHeight, 0.01));
expect(reportedHeight, closeTo(titleContentHeight + Grid.xs + 1, 0.01));
expect(tester.takeException(), isNull);
});
}