mirror of
https://github.com/block/buzz.git
synced 2026-10-27 04:30:52 +00:00
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:
@@ -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%" />
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
@@ -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),
|
||||
|
||||
59
mobile/lib/shared/community/community_icon_provider.dart
Normal file
59
mobile/lib/shared/community/community_icon_provider.dart
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
149
mobile/lib/shared/widgets/tappable_flapping_bee.dart
Normal file
149
mobile/lib/shared/widgets/tappable_flapping_bee.dart
Normal 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;
|
||||
}
|
||||
@@ -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', () {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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() {}
|
||||
}
|
||||
|
||||
162
mobile/test/features/search/search_page_test.dart
Normal file
162
mobile/test/features/search/search_page_test.dart
Normal 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');
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
86
mobile/test/shared/widgets/frosted_app_bar_test.dart
Normal file
86
mobile/test/shared/widgets/frosted_app_bar_test.dart
Normal 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user