Refine mobile navigation and creation flows (#2810)

## Summary

- Refine mobile navigation with icon-only tabs, haptics, a solid active
state, and spring quick actions.
- Bring Create channel and New message closer to desktop with radio
settings, keyboard submission, relay people, and wrapped recipient
chips.
- Keep both sheets draggable below the status area and prevent keyboard
overflow with many recipients.

## Testing

- `just mobile-check`
- `just mobile-test`
- Pixel 10 manual verification
This commit is contained in:
klopez4212
2026-07-25 06:31:18 -07:00
committed by GitHub
parent ab3af82871
commit 50fadaa7a3
11 changed files with 2296 additions and 586 deletions
@@ -1,3 +1,4 @@
import 'dart:convert';
import 'dart:math';
import 'package:flutter/foundation.dart';
@@ -87,8 +88,72 @@ class DirectoryUser {
}
return pubkey.length > 16 ? '${pubkey.substring(0, 16)}' : pubkey;
}
/// First visible character used when no avatar image is available.
String get initial => label.isNotEmpty ? label[0].toUpperCase() : '?';
}
/// Whether the mobile DM directory should show local preview identities.
const bool mockDmDirectoryEnabled =
kDebugMode && bool.fromEnvironment('BUZZ_MOCK_DM_DIRECTORY');
/// Whether the new-DM picker should use local preview identities.
///
/// Debug builds fall back automatically while disconnected. Production builds
/// always use the active relay directory.
final dmDirectoryPreviewEnabledProvider = Provider<bool>((ref) {
if (mockDmDirectoryEnabled) {
return true;
}
final relayStatus = ref.watch(
relaySessionProvider.select((session) => session.status),
);
return kDebugMode && relayStatus != SessionStatus.connected;
});
String _mockEmojiAvatar(String emoji, String color) {
return Uri.dataFromString(
'<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128">'
'<rect width="128" height="128" fill="$color"/>'
'<text x="64" y="80" text-anchor="middle" font-size="58">$emoji</text>'
'</svg>',
mimeType: 'image/svg+xml',
encoding: utf8,
).toString();
}
/// Local identities used to preview the new-DM picker without a relay.
final dmDirectoryPreviewUsers = List<DirectoryUser>.unmodifiable([
DirectoryUser(
pubkey: '1111111111111111111111111111111111111111111111111111111111111111',
displayName: 'Maya Chen',
avatarUrl: _mockEmojiAvatar('🎨', '#8AADF4'),
nip05Handle: 'maya@demo.buzz',
),
DirectoryUser(
pubkey: '2222222222222222222222222222222222222222222222222222222222222222',
displayName: 'Jordan Brooks',
avatarUrl: _mockEmojiAvatar('🌱', '#A6DA95'),
nip05Handle: 'jordan@demo.buzz',
),
const DirectoryUser(
pubkey: '3333333333333333333333333333333333333333333333333333333333333333',
displayName: 'Priya Shah',
nip05Handle: 'priya@demo.buzz',
),
DirectoryUser(
pubkey: '4444444444444444444444444444444444444444444444444444444444444444',
displayName: 'Theo Martin',
avatarUrl: _mockEmojiAvatar('💻', '#C6A0F6'),
nip05Handle: 'theo@demo.buzz',
),
const DirectoryUser(
pubkey: '5555555555555555555555555555555555555555555555555555555555555555',
displayName: 'Sam Rivera',
nip05Handle: 'sam@demo.buzz',
),
]);
final currentPubkeyProvider = Provider<String?>((ref) {
// Prefer the explicitly-derived pubkey from nsec — this is the signing
// identity used for events.
@@ -110,6 +175,153 @@ final currentPubkeyProvider = Provider<String?>((ref) {
return null;
});
/// Extracts the unique member pubkeys advertised by relay membership events.
///
/// Buzz relays use `member` tags, while older NIP-29-compatible relays may
/// still expose the same directory through `p` tags.
@visibleForTesting
List<String> relayMemberPubkeysFromEvents(List<NostrEvent> events) {
final pubkeys = <String>{};
for (final event in events) {
for (final tag in event.tags) {
if (tag.length < 2 || (tag[0] != 'member' && tag[0] != 'p')) {
continue;
}
final pubkey = tag[1].trim().toLowerCase();
if (pubkey.isNotEmpty) {
pubkeys.add(pubkey);
}
}
}
return pubkeys.toList();
}
/// Converts kind:0 events into a deduplicated, alphabetized people directory.
@visibleForTesting
List<DirectoryUser> directoryUsersFromProfileEvents(List<NostrEvent> events) {
final latestByPubkey = <String, NostrEvent>{};
for (final event in events) {
if (event.kind != 0) {
continue;
}
final pubkey = event.pubkey.toLowerCase();
final current = latestByPubkey[pubkey];
if (current == null || event.createdAt > current.createdAt) {
latestByPubkey[pubkey] = event;
}
}
return [
for (final event in latestByPubkey.values)
if (ProfileData.fromEvent(event) case final profile)
DirectoryUser(
pubkey: profile.pubkey.toLowerCase(),
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
}
/// People and agents discoverable on the active relay.
///
/// This mirrors desktop's empty-query people directory by listing kind:0
/// profiles through the HTTP bridge. The relay membership snapshot remains a
/// fallback for older relays that do not support directory listing.
final relayDirectoryUsersProvider = FutureProvider<List<DirectoryUser>>((
ref,
) async {
if (mockDmDirectoryEnabled) {
return dmDirectoryPreviewUsers;
}
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final directoryEvents = await session.queryRelay([
const NostrFilter(kinds: [0], limit: 50, extensions: {'page': 1}),
]);
var users = directoryUsersFromProfileEvents(directoryEvents);
if (users.isNotEmpty) {
return users
.where((user) => user.pubkey.toLowerCase() != currentPubkey)
.toList();
}
final membershipEvents = await session.fetchHistory(
NostrFilters.relayMembers(),
);
final memberPubkeys = relayMemberPubkeysFromEvents(
membershipEvents,
).where((pubkey) => pubkey != currentPubkey).toList();
if (memberPubkeys.isEmpty) {
return const [];
}
final profileEvents = await session.queryRelay([
NostrFilters.profilesBatch(memberPubkeys),
]);
final profilesByPubkey = {
for (final event in profileEvents)
event.pubkey.toLowerCase(): ProfileData.fromEvent(event),
};
users =
[
for (final pubkey in memberPubkeys)
if (profilesByPubkey[pubkey] case final profile?)
DirectoryUser(
pubkey: pubkey,
displayName: profile.displayName,
avatarUrl: profile.avatarUrl,
nip05Handle: profile.nip05,
)
else
DirectoryUser(pubkey: pubkey),
]..sort((a, b) {
final labelComparison = a.label.toLowerCase().compareTo(
b.label.toLowerCase(),
);
return labelComparison != 0
? labelComparison
: a.pubkey.compareTo(b.pubkey);
});
return users;
});
/// Prefix-searches the active relay's kind:0 people directory.
final relayDirectorySearchProvider =
FutureProvider.family<List<DirectoryUser>, String>((ref, query) async {
final trimmed = query.trim();
if (mockDmDirectoryEnabled) {
final normalizedQuery = trimmed.toLowerCase();
return dmDirectoryPreviewUsers
.where(
(user) =>
user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery),
)
.toList();
}
if (trimmed.isEmpty) {
return ref.watch(relayDirectoryUsersProvider.future);
}
final session = ref.watch(relaySessionProvider.notifier);
final currentPubkey = ref.watch(currentPubkeyProvider)?.toLowerCase();
final events = await session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: 50),
]);
return directoryUsersFromProfileEvents(
events,
).where((user) => user.pubkey.toLowerCase() != currentPubkey).toList();
});
/// Build [ChannelDetails] from a kind:39000 metadata event.
///
/// Exposed as a pure function so the mapping can be unit-tested without
@@ -227,6 +439,30 @@ List<List<String>> buildDeleteMessageTags({
];
}
/// Builds the signed kind:9007 tags used by mobile channel creation.
List<List<String>> buildCreateChannelTags({
required String channelId,
required String name,
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) {
if (ttlSeconds != null && ttlSeconds <= 0) {
throw ArgumentError.value(ttlSeconds, 'ttlSeconds', 'must be positive');
}
return [
['h', channelId],
['name', name],
['visibility', visibility],
['channel_type', channelType],
if (description case final about? when about.trim().isNotEmpty)
['about', about.trim()],
if (ttlSeconds != null) ['ttl', ttlSeconds.toString()],
];
}
class ChannelActions {
final Ref _ref;
final RelaySessionNotifier _session;
@@ -248,16 +484,17 @@ class ChannelActions {
required String channelType,
required String visibility,
String? description,
int? ttlSeconds,
}) async {
final channelId = _newUuidV4();
final tags = <List<String>>[
['h', channelId],
['name', name],
['visibility', visibility],
['channel_type', channelType],
if (description case final about? when about.trim().isNotEmpty)
['about', about.trim()],
];
final tags = buildCreateChannelTags(
channelId: channelId,
name: name,
channelType: channelType,
visibility: visibility,
description: description,
ttlSeconds: ttlSeconds,
);
await _signedEventRelay.submit(kind: 9007, content: '', tags: tags);
return _refreshChannelsAndRead(channelId);
}
@@ -349,19 +586,10 @@ class ChannelActions {
final trimmed = query.trim();
if (trimmed.isEmpty) return const [];
final events = await _session.fetchHistory(
NostrFilter(kinds: [0], search: trimmed, limit: limit),
);
return events
.map((event) {
final data = ProfileData.fromEvent(event);
return DirectoryUser(
pubkey: data.pubkey,
displayName: data.displayName,
avatarUrl: data.avatarUrl,
nip05Handle: data.nip05,
);
})
final events = await _session.queryRelay([
NostrFilters.searchUsers(trimmed, limit: limit),
]);
return directoryUsersFromProfileEvents(events)
.where(
(user) =>
_currentPubkey == null ||
+11 -64
View File
@@ -5,6 +5,7 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter/physics.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -47,6 +48,7 @@ part 'channels_page/sheets.dart';
part 'channels_page/badges.dart';
part 'channels_page/community.dart';
part 'channels_page/quick_actions.dart';
part 'channels_page/quick_actions_launcher.dart';
enum _QuickAction { createChannel, newDm }
@@ -156,8 +158,6 @@ class ChannelsPage extends HookConsumerWidget {
cachedChannels.value = data;
}
final channels = cachedChannels.value;
final quickActionsOpen = useState(false);
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
@@ -167,39 +167,6 @@ class ChannelsPage extends HookConsumerWidget {
);
}
Future<void> selectQuickAction(_QuickAction action) async {
final reducedMotion = MediaQuery.of(context).disableAnimations;
quickActionsOpen.value = false;
if (!reducedMotion) {
await Future<void>.delayed(_kMorphCloseDuration);
}
if (!context.mounted) return;
switch (action) {
case _QuickAction.createChannel:
final created = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showModalBottomSheet<Channel>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
_NewDirectMessageSheet(currentPubkey: currentPubkey),
);
if (opened != null && context.mounted) {
await openChannel(opened);
}
}
}
// Only surface fetch errors while the relay is stably connected. During a
// reconnect the session owns recovery, so a cancelled in-flight query must
// not turn into a manual Retry page.
@@ -258,35 +225,15 @@ class ChannelsPage extends HookConsumerWidget {
),
],
),
floatingActionButton: _MorphingQuickActionsButton(
open: quickActionsOpen.value,
onToggle: () => quickActionsOpen.value = !quickActionsOpen.value,
onSelected: (action) => unawaited(selectQuickAction(action)),
),
body: Stack(
children: [
_ChannelsBody(
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
),
if (quickActionsOpen.value)
Positioned.fill(
child: Semantics(
button: true,
label: 'Close quick actions',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => quickActionsOpen.value = false,
),
),
),
],
body: _ChannelsBody(
channels: channels,
channelsAsync: channelsAsync,
showError: showError.value,
sessionStatus: sessionState.status,
showConnectionBanner: showConnectionBanner.value,
currentPubkey: currentPubkey,
onRefresh: () => ref.read(channelsProvider.notifier).refresh(),
onSelectChannel: openChannel,
),
);
}
@@ -1,24 +1,29 @@
part of '../channels_page.dart';
const _kMorphOpenDuration = Duration(milliseconds: 350);
const _kMorphCloseDuration = Duration(milliseconds: 250);
const _kMorphOpenDuration = Duration(milliseconds: 280);
const _kMorphCloseDuration = Duration(milliseconds: 240);
const _kMorphFadeDuration = Duration(milliseconds: 200);
const _kMorphOpenCurve = Cubic(0.34, 1.25, 0.64, 1);
const _kMorphCloseCurve = Cubic(0.22, 1, 0.36, 1);
const double _kMorphOpenBounce = 0.14;
const double _kMorphCloseBounce = 0.06;
const double _kMorphClosedSize = 56;
const double _kMorphOpenHeight = 160;
const double _kMorphOpenRadius = 20;
const double _kMorphSlide = 40;
const double _kMorphScale = 0.97;
const double _kMorphBlur = 2;
const double _kQuickActionCardOverlayOpacity = 0.1;
const double _kQuickActionCardRadius = _kMorphOpenRadius - Grid.xxs;
class _MorphingQuickActionsButton extends HookWidget {
final bool open;
final double openEdgeOffset;
final VoidCallback onToggle;
final ValueChanged<_QuickAction> onSelected;
const _MorphingQuickActionsButton({
required this.open,
required this.openEdgeOffset,
required this.onToggle,
required this.onSelected,
});
@@ -27,28 +32,18 @@ class _MorphingQuickActionsButton extends HookWidget {
Widget build(BuildContext context) {
final reducedMotion = MediaQuery.of(context).disableAnimations;
final mediaQuery = MediaQuery.of(context);
final openWidth =
(mediaQuery.size.width - mediaQuery.padding.horizontal - (Grid.xs * 2))
.clamp(_kMorphClosedSize, double.infinity)
.toDouble();
final openWidth = (mediaQuery.size.width - (Grid.gutter * 2))
.clamp(_kMorphClosedSize, double.infinity)
.toDouble();
final surfaceController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphOpenDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphCloseDuration,
initialValue: open ? 1 : 0,
upperBound: 1.02,
);
final fadeController = useAnimationController(
duration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
reverseDuration: reducedMotion ? Duration.zero : _kMorphFadeDuration,
initialValue: open ? 1 : 0,
);
final surfaceAnimation = useMemoized(
() => CurvedAnimation(
parent: surfaceController,
curve: _kMorphOpenCurve,
reverseCurve: _kMorphCloseCurve,
),
[surfaceController],
);
final fadeAnimation = useMemoized(
() => CurvedAnimation(
parent: fadeController,
@@ -58,20 +53,13 @@ class _MorphingQuickActionsButton extends HookWidget {
[fadeController],
);
final animation = useMemoized(
() => Listenable.merge([surfaceAnimation, fadeAnimation]),
[surfaceAnimation, fadeAnimation],
() => Listenable.merge([surfaceController, fadeAnimation]),
[surfaceController, fadeAnimation],
);
useEffect(() => surfaceAnimation.dispose, [surfaceAnimation]);
useEffect(() => fadeAnimation.dispose, [fadeAnimation]);
useEffect(() {
surfaceController.duration = reducedMotion
? Duration.zero
: _kMorphOpenDuration;
surfaceController.reverseDuration = reducedMotion
? Duration.zero
: _kMorphCloseDuration;
fadeController.duration = reducedMotion
? Duration.zero
: _kMorphFadeDuration;
@@ -82,12 +70,29 @@ class _MorphingQuickActionsButton extends HookWidget {
if (reducedMotion) {
surfaceController.value = open ? 1 : 0;
fadeController.value = open ? 1 : 0;
} else if (open) {
unawaited(surfaceController.forward());
unawaited(fadeController.forward());
} else {
unawaited(surfaceController.reverse());
unawaited(fadeController.reverse());
final target = open ? 1.0 : 0.0;
if ((surfaceController.value - target).abs() > 0.001) {
unawaited(
surfaceController.animateWith(
SpringSimulation(
SpringDescription.withDurationAndBounce(
duration: open ? _kMorphOpenDuration : _kMorphCloseDuration,
bounce: open ? _kMorphOpenBounce : _kMorphCloseBounce,
),
surfaceController.value,
target,
surfaceController.velocity,
snapToEnd: true,
),
),
);
}
if (open) {
unawaited(fadeController.forward());
} else {
unawaited(fadeController.reverse());
}
}
return null;
}, [fadeController, open, reducedMotion, surfaceController]);
@@ -95,7 +100,8 @@ class _MorphingQuickActionsButton extends HookWidget {
return AnimatedBuilder(
animation: animation,
builder: (context, _) {
final surfaceValue = surfaceAnimation.value;
final surfaceValue = surfaceController.value;
final surfaceProgress = surfaceValue.clamp(0.0, 1.0);
final fadeValue = fadeAnimation.value.clamp(0.0, 1.0);
final width = lerpDouble(_kMorphClosedSize, openWidth, surfaceValue)!;
final height = lerpDouble(
@@ -110,58 +116,63 @@ class _MorphingQuickActionsButton extends HookWidget {
)!;
final borderRadius = BorderRadius.circular(radius);
return SizedBox(
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.24),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: Material(
type: MaterialType.transparency,
child: Stack(
children: [
Positioned.fill(
child: OverflowBox(
alignment: Alignment.bottomRight,
minWidth: openWidth,
maxWidth: openWidth,
minHeight: _kMorphOpenHeight,
maxHeight: _kMorphOpenHeight,
child: IgnorePointer(
ignoring: !open || fadeValue < 0.9,
child: ExcludeSemantics(
excluding: !open,
child: Opacity(
opacity: fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * (1 - fadeValue),
sigmaY: _kMorphBlur * (1 - fadeValue),
),
child: Transform.translate(
offset: Offset(
_kMorphSlide * (1 - surfaceValue),
0,
return Transform.translate(
offset: Offset(openEdgeOffset * surfaceProgress, 0),
child: SizedBox(
key: const Key('quick-actions-surface'),
width: width,
height: height,
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primary,
borderRadius: borderRadius,
boxShadow: [
BoxShadow(
color: context.colors.shadow.withValues(alpha: 0.24),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: ClipRRect(
borderRadius: borderRadius,
clipBehavior: Clip.antiAlias,
child: Material(
type: MaterialType.transparency,
child: Stack(
children: [
Positioned.fill(
child: OverflowBox(
alignment: Alignment.bottomRight,
minWidth: openWidth,
maxWidth: openWidth,
minHeight: _kMorphOpenHeight,
maxHeight: _kMorphOpenHeight,
child: IgnorePointer(
ignoring: !open || fadeValue < 0.9,
child: ExcludeSemantics(
excluding: !open,
child: Opacity(
opacity: fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * (1 - fadeValue),
sigmaY: _kMorphBlur * (1 - fadeValue),
),
child: Transform.scale(
alignment: Alignment.bottomRight,
scale:
_kMorphScale +
((1 - _kMorphScale) * surfaceValue),
child: _QuickActionsMenu(
onSelected: onSelected,
child: Transform.translate(
offset: Offset(
_kMorphSlide * (1 - surfaceProgress),
0,
),
child: Transform.scale(
alignment: Alignment.bottomRight,
scale:
_kMorphScale +
((1 - _kMorphScale) *
surfaceProgress),
child: _QuickActionsMenu(
onSelected: onSelected,
),
),
),
),
@@ -170,43 +181,48 @@ class _MorphingQuickActionsButton extends HookWidget {
),
),
),
),
Positioned(
right: 0,
bottom: 0,
width: _kMorphClosedSize,
height: _kMorphClosedSize,
child: IgnorePointer(
ignoring: open,
child: ExcludeSemantics(
excluding: open,
child: Opacity(
opacity: 1 - fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * fadeValue,
sigmaY: _kMorphBlur * fadeValue,
),
child: Transform.translate(
offset: Offset(-_kMorphSlide * surfaceValue, 0),
child: Transform.rotate(
angle: (pi / 4) * surfaceValue,
child: Transform.scale(
scale:
1 - ((1 - _kMorphScale) * surfaceValue),
child: Tooltip(
message: 'Create or start conversation',
child: Semantics(
button: true,
label: 'Create or start conversation',
expanded: open,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onToggle,
child: Center(
child: Icon(
LucideIcons.plus,
color: context.colors.onPrimary,
Positioned(
right: 0,
bottom: 0,
width: _kMorphClosedSize,
height: _kMorphClosedSize,
child: IgnorePointer(
ignoring: open,
child: ExcludeSemantics(
excluding: open,
child: Opacity(
opacity: 1 - fadeValue,
child: ImageFiltered(
imageFilter: ImageFilter.blur(
sigmaX: _kMorphBlur * fadeValue,
sigmaY: _kMorphBlur * fadeValue,
),
child: Transform.translate(
offset: Offset(
-_kMorphSlide * surfaceProgress,
0,
),
child: Transform.rotate(
angle: (pi / 4) * surfaceProgress,
child: Transform.scale(
scale:
1 -
((1 - _kMorphScale) *
surfaceProgress),
child: Tooltip(
message: 'Create or start conversation',
child: Semantics(
button: true,
label: 'Create or start conversation',
expanded: open,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onToggle,
child: Center(
child: Icon(
LucideIcons.plus,
color: context.colors.onPrimary,
),
),
),
),
@@ -219,8 +235,8 @@ class _MorphingQuickActionsButton extends HookWidget {
),
),
),
),
],
],
),
),
),
),
@@ -239,20 +255,23 @@ class _QuickActionsMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: Grid.xxs),
key: const Key('quick-actions-menu'),
padding: const EdgeInsets.all(Grid.xxs),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_QuickActionItem(
icon: LucideIcons.hash,
title: 'Create channel',
subtitle: 'Start a new stream channel',
key: const Key('quick-action-create-channel-card'),
onTap: () => onSelected(_QuickAction.createChannel),
),
const SizedBox(height: Grid.xxs),
_QuickActionItem(
icon: LucideIcons.messagesSquare,
title: 'New direct message',
subtitle: 'Message one or more people',
key: const Key('quick-action-new-dm-card'),
onTap: () => onSelected(_QuickAction.newDm),
),
],
@@ -264,56 +283,55 @@ class _QuickActionsMenu extends StatelessWidget {
class _QuickActionItem extends StatelessWidget {
final IconData icon;
final String title;
final String subtitle;
final VoidCallback onTap;
const _QuickActionItem({
super.key,
required this.icon,
required this.title,
required this.subtitle,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onPrimary;
final background = Color.alphaBlend(
foreground.withValues(alpha: _kQuickActionCardOverlayOpacity),
context.colors.primary,
);
return Expanded(
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Row(
children: [
Icon(icon, size: 22, color: foreground),
const SizedBox(width: Grid.twelve),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
child: Material(
color: background,
borderRadius: BorderRadius.circular(_kQuickActionCardRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.xs),
child: Row(
children: [
Icon(icon, size: 24, color: foreground),
const SizedBox(width: Grid.xs),
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: Grid.quarter),
Text(
subtitle,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodySmall?.copyWith(
color: foreground.withValues(alpha: 0.72),
),
),
],
],
),
),
),
],
],
),
),
),
),
@@ -0,0 +1,182 @@
part of '../channels_page.dart';
const _kQuickActionsTabMotionDuration = Duration(milliseconds: 220);
const _kQuickActionsTabMotionCurve = Cubic(0.77, 0, 0.175, 1);
const _kQuickActionsHiddenOverlap = Grid.half;
const _kQuickActionsHiddenScale = 0.8;
/// Places the channel quick-actions button beside mobile navigation.
///
/// The button remains available only on Home and moves behind the navigation
/// bar when another destination is selected.
class ChannelQuickActionsLauncher extends HookConsumerWidget {
/// Whether the launcher should be visible beside the navigation bar.
final bool visible;
/// Height of the navigation bar used to vertically center the closed button.
final double navigationBarHeight;
/// Space between the navigation bar and the bottom safe area.
final double navigationBarBottomGap;
/// Full width of the navigation bar, including its inner edge padding.
final double navigationBarWidth;
/// Bottom system inset used by the navigation bar's [SafeArea].
final double systemBottomInset;
/// Distance between the launcher and the right edge of the screen.
final double rightInset;
/// Creates a launcher aligned with the supplied navigation geometry.
const ChannelQuickActionsLauncher({
super.key,
required this.visible,
required this.navigationBarHeight,
required this.navigationBarBottomGap,
required this.navigationBarWidth,
required this.systemBottomInset,
required this.rightInset,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentPubkey = ref.watch(currentPubkeyProvider);
final quickActionsOpen = useState(false);
final reducedMotion = MediaQuery.of(context).disableAnimations;
final navigationBottomInset = systemBottomInset > navigationBarBottomGap
? systemBottomInset
: navigationBarBottomGap;
final closedBottomInset =
navigationBottomInset + ((navigationBarHeight - _kMorphClosedSize) / 2);
final openLift =
navigationBarHeight +
Grid.xxs -
((navigationBarHeight - _kMorphClosedSize) / 2);
final effectiveOpen = visible && quickActionsOpen.value;
final screenWidth = MediaQuery.sizeOf(context).width;
final navigationBarRight = (screenWidth + navigationBarWidth) / 2;
final launcherRight = screenWidth - rightInset;
final hiddenHorizontalOffset =
navigationBarRight - launcherRight - _kQuickActionsHiddenOverlap;
useEffect(() {
if (!visible && quickActionsOpen.value) {
quickActionsOpen.value = false;
}
return null;
}, [visible]);
Future<void> openChannel(Channel channel) async {
if (!context.mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => ChannelDetailPage(channel: channel),
),
);
}
Future<void> selectQuickAction(_QuickAction action) async {
quickActionsOpen.value = false;
if (!reducedMotion) {
await Future<void>.delayed(_kMorphCloseDuration);
}
if (!context.mounted) return;
switch (action) {
case _QuickAction.createChannel:
final created = await showModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) => const _CreateChannelSheet(channelType: 'stream'),
);
if (created != null && context.mounted) {
await openChannel(created);
}
case _QuickAction.newDm:
final opened = await showModalBottomSheet<Channel>(
context: context,
constraints: _quickActionSheetConstraints(context),
isScrollControlled: true,
showDragHandle: true,
builder: (_) =>
_NewDirectMessageSheet(currentPubkey: currentPubkey),
);
if (opened != null && context.mounted) {
await openChannel(opened);
}
}
}
return Stack(
fit: StackFit.expand,
clipBehavior: Clip.none,
children: [
if (quickActionsOpen.value)
Positioned.fill(
child: Semantics(
button: true,
label: 'Close quick actions',
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => quickActionsOpen.value = false,
),
),
),
AnimatedPositioned(
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
right: rightInset,
bottom: closedBottomInset + (effectiveOpen ? openLift : 0),
child: IgnorePointer(
ignoring: !visible,
child: ExcludeSemantics(
excluding: !visible,
child: TweenAnimationBuilder<double>(
key: const Key('channel-quick-actions-motion'),
tween: Tween(end: visible ? 0 : 1),
duration: reducedMotion
? Duration.zero
: _kQuickActionsTabMotionDuration,
curve: _kQuickActionsTabMotionCurve,
builder: (context, hiddenProgress, child) => Opacity(
key: const Key('channel-quick-actions-opacity'),
opacity: 1 - hiddenProgress,
child: Transform.translate(
key: const Key('channel-quick-actions-transform'),
offset: Offset(hiddenHorizontalOffset * hiddenProgress, 0),
child: Transform.scale(
key: const Key('channel-quick-actions-scale'),
scale:
1 -
((1 - _kQuickActionsHiddenScale) * hiddenProgress),
child: child,
),
),
),
child: _MorphingQuickActionsButton(
open: effectiveOpen,
openEdgeOffset: rightInset - Grid.gutter,
onToggle: () =>
quickActionsOpen.value = !quickActionsOpen.value,
onSelected: (action) => unawaited(selectQuickAction(action)),
),
),
),
),
),
],
);
}
}
BoxConstraints _quickActionSheetConstraints(BuildContext context) {
final mediaQuery = MediaQuery.of(context);
return BoxConstraints(
maxHeight: mediaQuery.size.height - mediaQuery.viewPadding.top - Grid.sm,
);
}
@@ -1,5 +1,34 @@
part of '../channels_page.dart';
const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60;
class _CreateChannelMenuOption<T> {
final Key? key;
final String label;
final T value;
const _CreateChannelMenuOption({
this.key,
required this.label,
required this.value,
});
}
const _createChannelTtlOptions = [
_CreateChannelMenuOption(label: '30 minutes', value: 30 * 60),
_CreateChannelMenuOption(label: '1 hour', value: 60 * 60),
_CreateChannelMenuOption(label: '6 hours', value: 6 * 60 * 60),
_CreateChannelMenuOption(label: '12 hours', value: 12 * 60 * 60),
_CreateChannelMenuOption(label: '1 day', value: 24 * 60 * 60),
_CreateChannelMenuOption(label: '3 days', value: 3 * 24 * 60 * 60),
_CreateChannelMenuOption(
label: '7 days',
value: _defaultCreateChannelTtlSeconds,
),
_CreateChannelMenuOption(label: '14 days', value: 14 * 24 * 60 * 60),
_CreateChannelMenuOption(label: '30 days', value: 30 * 24 * 60 * 60),
];
class _CreateChannelSheet extends HookConsumerWidget {
final String channelType;
@@ -10,8 +39,15 @@ class _CreateChannelSheet extends HookConsumerWidget {
final nameController = useTextEditingController();
final descriptionController = useTextEditingController();
final visibility = useState('open');
final temporary = useState(false);
final ttlSeconds = useState(_defaultCreateChannelTtlSeconds);
final isSubmitting = useState(false);
final errorMessage = useState<String?>(null);
useListenable(nameController);
final kindLabel = channelType == 'forum' ? 'forum' : 'channel';
final canSubmit =
nameController.text.trim().isNotEmpty && !isSubmitting.value;
Future<void> submit() async {
final name = nameController.text.trim();
@@ -29,6 +65,7 @@ class _CreateChannelSheet extends HookConsumerWidget {
channelType: channelType,
visibility: visibility.value,
description: descriptionController.text.trim(),
ttlSeconds: temporary.value ? ttlSeconds.value : null,
);
if (context.mounted) {
Navigator.of(context).pop(created);
@@ -49,68 +86,355 @@ class _CreateChannelSheet extends HookConsumerWidget {
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: nameController,
enabled: !isSubmitting.value,
decoration: InputDecoration(
labelText: 'Name',
hintText: channelType == 'forum'
? 'design-discussions'
: 'release-notes',
),
textInputAction: TextInputAction.next,
),
const SizedBox(height: Grid.xxs),
TextField(
controller: descriptionController,
enabled: !isSubmitting.value,
decoration: const InputDecoration(
labelText: 'Description',
hintText: 'What this space is for',
),
minLines: 2,
maxLines: 3,
),
const SizedBox(height: Grid.xxs),
SwitchListTile(
title: const Text('Private'),
contentPadding: EdgeInsets.zero,
value: visibility.value == 'private',
onChanged: isSubmitting.value
? null
: (on) => visibility.value = on ? 'private' : 'open',
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xxs),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
'Create a new $kindLabel',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
],
const SizedBox(height: Grid.xs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isSubmitting.value
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
const SizedBox(height: Grid.sm),
_CreateChannelFieldLabel(label: 'Name'),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
child: TextField(
key: const Key('create-channel-name'),
controller: nameController,
enabled: !isSubmitting.value,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
textCapitalization: TextCapitalization.none,
decoration: InputDecoration(
hintText: channelType == 'forum'
? 'design-discussions'
: 'release-notes',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: isSubmitting.value ? null : submit,
child: Text(isSubmitting.value ? 'Creating…' : 'Create'),
),
const SizedBox(height: Grid.xs),
const _CreateChannelFieldLabel(
label: 'Description',
optional: true,
),
const SizedBox(height: Grid.xxs),
_CreateChannelFieldShell(
child: TextField(
key: const Key('create-channel-description'),
controller: descriptionController,
enabled: !isSubmitting.value,
decoration: InputDecoration(
hintText: 'What this $kindLabel is for',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
),
minLines: 2,
maxLines: 3,
textInputAction: TextInputAction.done,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
),
),
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<bool>(
enabled: !isSubmitting.value,
label: 'Channel type',
onSelected: (value) => temporary.value = value,
options: const [
_CreateChannelMenuOption(
key: Key('create-channel-type-ongoing'),
label: 'Ongoing',
value: false,
),
_CreateChannelMenuOption(
key: Key('create-channel-type-temporary'),
label: 'Temporary',
value: true,
),
],
value: temporary.value,
),
if (temporary.value) ...[
const SizedBox(height: Grid.xs),
_CreateChannelSettingMenu<int>(
controlKey: const Key('create-channel-ttl'),
enabled: !isSubmitting.value,
label: 'Expires after',
onSelected: (value) => ttlSeconds.value = value,
options: _createChannelTtlOptions,
value: ttlSeconds.value,
valueLabel: _createChannelTtlOptions
.firstWhere((option) => option.value == ttlSeconds.value)
.label,
),
],
const SizedBox(height: Grid.sm),
_CreateChannelRadioGroup<String>(
enabled: !isSubmitting.value,
label: 'Visibility',
onSelected: (value) => visibility.value = value,
options: const [
_CreateChannelMenuOption(
key: Key('create-channel-visibility-public'),
label: 'Public',
value: 'open',
),
_CreateChannelMenuOption(
key: Key('create-channel-visibility-private'),
label: 'Private',
value: 'private',
),
],
value: visibility.value,
),
if (errorMessage.value case final error?) ...[
const SizedBox(height: Grid.xs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
),
),
);
}
}
class _CreateChannelFieldLabel extends StatelessWidget {
final String label;
final bool optional;
const _CreateChannelFieldLabel({required this.label, this.optional = false});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
TextSpan(text: label),
if (optional)
TextSpan(
text: ' Optional',
style: context.textTheme.labelSmall?.copyWith(
color: context.colors.onSurfaceVariant.withValues(alpha: 0.6),
fontWeight: FontWeight.w400,
),
),
],
],
),
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
);
}
}
class _CreateChannelFieldShell extends StatelessWidget {
final Widget child;
const _CreateChannelFieldShell({required this.child});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.primaryContainer.withValues(alpha: 0.55),
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: child,
);
}
}
class _CreateChannelRadioGroup<T> extends StatelessWidget {
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
const _CreateChannelRadioGroup({
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CreateChannelFieldLabel(label: label),
const SizedBox(height: Grid.xxs),
DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: RadioGroup<T>(
groupValue: value,
onChanged: (nextValue) {
if (enabled && nextValue != null) {
onSelected(nextValue);
}
},
child: Column(
children: [
for (final (index, option) in options.indexed) ...[
RadioListTile<T>(
key: option.key,
value: option.value,
enabled: enabled,
selected: option.value == value,
controlAffinity: ListTileControlAffinity.leading,
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.xs,
vertical: Grid.half,
),
visualDensity: VisualDensity.standard,
activeColor: context.colors.primary,
title: Text(
option.label,
style: context.textTheme.bodyMedium?.copyWith(
color: enabled
? context.colors.onSurface
: context.colors.onSurface.withValues(alpha: 0.38),
fontWeight: option.value == value
? FontWeight.w600
: FontWeight.w400,
),
),
),
if (index < options.length - 1)
Divider(
height: 1,
indent: Grid.xs,
endIndent: Grid.xs,
color: context.colors.outlineVariant,
),
],
],
),
),
),
],
);
}
}
class _CreateChannelSettingMenu<T> extends StatelessWidget {
final Key controlKey;
final bool enabled;
final String label;
final ValueChanged<T> onSelected;
final List<_CreateChannelMenuOption<T>> options;
final T value;
final IconData? valueIcon;
final String valueLabel;
const _CreateChannelSettingMenu({
required this.controlKey,
required this.enabled,
required this.label,
required this.onSelected,
required this.options,
required this.value,
this.valueIcon,
required this.valueLabel,
});
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(color: context.colors.outlineVariant),
borderRadius: BorderRadius.circular(Radii.lg),
),
child: PopupMenuButton<T>(
enabled: enabled,
initialValue: value,
onSelected: onSelected,
tooltip: '$label: $valueLabel',
itemBuilder: (context) => [
for (final option in options)
CheckedPopupMenuItem<T>(
checked: option.value == value,
value: option.value,
child: Text(option.label),
),
],
child: Padding(
key: controlKey,
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xs,
),
child: Row(
children: [
Expanded(
child: Text(
label,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w600,
),
),
),
if (valueIcon case final icon?) ...[
Icon(icon, size: 16, color: context.colors.onSurfaceVariant),
const SizedBox(width: Grid.half),
],
Text(
valueLabel,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colors.onSurface,
fontWeight: FontWeight.w500,
),
),
const SizedBox(width: Grid.half),
Icon(
LucideIcons.chevronDown,
size: 16,
color: context.colors.onSurfaceVariant,
),
],
),
),
),
);
@@ -125,40 +449,50 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final queryController = useTextEditingController();
final queryFocusNode = useFocusNode();
final query = useState('');
final debouncedQuery = useState('');
final selectedUsers = useState<List<DirectoryUser>>([]);
final isSubmitting = useState(false);
final submitError = useState<String?>(null);
final previewDirectoryActive = ref.watch(dmDirectoryPreviewEnabledProvider);
useEffect(() {
final timer = Timer(const Duration(milliseconds: 250), () {
debouncedQuery.value = query.value.trim();
debouncedQuery.value = query.value.trim().toLowerCase();
});
return timer.cancel;
}, [query.value]);
final searchFuture = useMemoized(() {
if (debouncedQuery.value.isEmpty || selectedUsers.value.length >= 8) {
return Future.value(const <DirectoryUser>[]);
}
return ref
.read(channelActionsProvider)
.searchUsers(debouncedQuery.value, limit: 8);
}, [debouncedQuery.value, selectedUsers.value.length]);
final searchResults = useFuture(searchFuture);
final normalizedQuery = previewDirectoryActive
? query.value.trim().toLowerCase()
: debouncedQuery.value;
final isSearchTransitionPending =
!previewDirectoryActive &&
query.value.trim().toLowerCase() != normalizedQuery;
final directoryAsync = previewDirectoryActive
? AsyncValue.data(dmDirectoryPreviewUsers)
: normalizedQuery.isEmpty
? ref.watch(relayDirectoryUsersProvider)
: ref.watch(relayDirectorySearchProvider(normalizedQuery));
final selectedPubkeys = selectedUsers.value
.map((user) => user.pubkey.toLowerCase())
.toSet();
final availableResults =
searchResults.data
?.where(
(user) =>
!selectedPubkeys.contains(user.pubkey.toLowerCase()) &&
user.pubkey.toLowerCase() != currentPubkey?.toLowerCase(),
)
.toList() ??
directoryAsync.asData?.value.where((user) {
final normalizedPubkey = user.pubkey.toLowerCase();
if (selectedPubkeys.contains(normalizedPubkey) ||
normalizedPubkey == currentPubkey?.toLowerCase()) {
return false;
}
if (normalizedQuery.isEmpty) {
return true;
}
return user.label.toLowerCase().contains(normalizedQuery) ||
user.secondaryLabel.toLowerCase().contains(normalizedQuery) ||
normalizedPubkey.contains(normalizedQuery);
}).toList() ??
const <DirectoryUser>[];
final canSubmit = !isSubmitting.value && selectedUsers.value.isNotEmpty;
@@ -166,6 +500,10 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
if (selectedUsers.value.isEmpty || isSubmitting.value) {
return;
}
if (previewDirectoryActive) {
submitError.value = 'Preview only — mock people cannot be messaged.';
return;
}
isSubmitting.value = true;
submitError.value = null;
@@ -194,123 +532,349 @@ class _NewDirectMessageSheet extends HookConsumerWidget {
),
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: queryController,
decoration: const InputDecoration(
prefixIcon: Icon(LucideIcons.search),
hintText: 'Search by name, NIP-05, or pubkey',
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'New message',
style: context.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
letterSpacing: -0.3,
),
),
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
),
if (selectedUsers.value.isNotEmpty) ...[
const SizedBox(height: Grid.xxs),
Wrap(
spacing: Grid.half,
runSpacing: Grid.half,
children: [
for (final user in selectedUsers.value)
InputChip(
label: Text(user.label),
onDeleted: isSubmitting.value
? null
: () {
selectedUsers.value = [
for (final candidate in selectedUsers.value)
if (candidate.pubkey != user.pubkey)
candidate,
];
},
),
],
),
],
const SizedBox(height: Grid.xs),
SizedBox(
height: 280,
child: Builder(
builder: (context) {
if (selectedUsers.value.length >= 8) {
return const Center(
child: Text(
'Direct messages support up to 9 people including you.',
const SizedBox(height: Grid.xs),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: isSubmitting.value ? null : queryFocusNode.requestFocus,
child: SizedBox(
width: double.infinity,
child: ConstrainedBox(
key: const Key('new-dm-recipient-field'),
constraints: const BoxConstraints(minHeight: Grid.xl),
child: DecoratedBox(
decoration: BoxDecoration(
color: context.colors.surface,
border: Border.all(
color: context.colors.outlineVariant,
),
borderRadius: BorderRadius.circular(Radii.lg),
),
);
}
if (debouncedQuery.value.isEmpty) {
return const Center(
child: Text(
'Search for someone to start a conversation.',
),
);
}
if (searchResults.connectionState ==
ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator());
}
if (availableResults.isEmpty) {
return const Center(child: Text('No matching users.'));
}
return ListView(
shrinkWrap: true,
children: [
for (final user in availableResults)
ListTile(
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
fallback: Text(
user.label.substring(0, 1).toUpperCase(),
),
),
title: Text(user.label),
subtitle: Text(user.secondaryLabel),
onTap: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.twelve,
vertical: Grid.xxs,
),
child: LayoutBuilder(
builder: (context, constraints) {
final hasSelectedUsers =
selectedUsers.value.isNotEmpty;
final searchFieldWidth = hasSelectedUsers
? 96.0
: (constraints.maxWidth - 32).clamp(
96.0,
constraints.maxWidth,
);
return Wrap(
key: const Key('new-dm-recipient-wrap'),
spacing: 6,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
Padding(
padding: const EdgeInsets.symmetric(
vertical: Grid.half,
),
child: Text(
'To:',
style: context.textTheme.bodyLarge
?.copyWith(fontWeight: FontWeight.w600),
),
),
for (final user in selectedUsers.value)
_SelectedDmRecipientChip(
user: user,
enabled: !isSubmitting.value,
onDeleted: () {
selectedUsers.value = [
for (final candidate
in selectedUsers.value)
if (candidate.pubkey != user.pubkey)
candidate,
];
queryFocusNode.requestFocus();
},
),
SizedBox(
width: searchFieldWidth,
child: TextField(
key: const Key('new-dm-search'),
controller: queryController,
focusNode: queryFocusNode,
autofocus: true,
autocorrect: false,
enableSuggestions: false,
enabled: !isSubmitting.value,
onChanged: (value) => query.value = value,
onSubmitted: (_) {
if (canSubmit) {
unawaited(submit());
}
},
decoration: InputDecoration(
hintText: hasSelectedUsers
? null
: 'Search for a person',
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
isDense: true,
contentPadding:
const EdgeInsets.symmetric(
vertical: Grid.half,
),
suffixIcon: isSubmitting.value
? const Padding(
padding: EdgeInsets.all(
Grid.half,
),
child: SizedBox.square(
dimension: 16,
child:
CircularProgressIndicator(
strokeWidth: 2,
),
),
)
: null,
suffixIconConstraints:
const BoxConstraints(
minHeight: 32,
minWidth: 32,
),
),
textInputAction: TextInputAction.done,
),
),
],
);
},
),
],
),
),
),
),
),
const SizedBox(height: Grid.xs),
Builder(
key: const Key('new-dm-results'),
builder: (context) {
if (selectedUsers.value.length >= 8) {
return const SizedBox(
height: 96,
child: Center(
child: Text(
'DMs support up to nine people, including you.',
),
),
);
}
if (directoryAsync.isLoading &&
directoryAsync.asData == null ||
isSearchTransitionPending) {
return const SizedBox(
height: 280,
child: Center(child: CircularProgressIndicator()),
);
}
if (directoryAsync.hasError) {
return SizedBox(
height: 280,
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text(
'Could not load people from this relay.',
),
const SizedBox(height: Grid.half),
TextButton(
onPressed: () {
if (normalizedQuery.isEmpty) {
ref.invalidate(relayDirectoryUsersProvider);
} else {
ref.invalidate(
relayDirectorySearchProvider(
normalizedQuery,
),
);
}
},
child: const Text('Retry'),
),
],
),
),
);
}
if (availableResults.isEmpty) {
return SizedBox(
height: 280,
child: Center(
child: Text(
normalizedQuery.isEmpty
? 'No people or agents available to message.'
: 'No matching users.',
),
),
);
}
return ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
itemCount: availableResults.length,
separatorBuilder: (_, _) =>
const Divider(height: 1, indent: 56),
itemBuilder: (context, index) {
final user = availableResults[index];
return ListTile(
key: Key('new-dm-person-${user.pubkey}'),
contentPadding: const EdgeInsets.symmetric(
horizontal: Grid.half,
),
leading: AvatarImage(
imageUrl: user.avatarUrl,
radius: 20,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelLarge?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
title: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
user.secondaryLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Icon(
LucideIcons.plus,
size: 18,
color: context.colors.onSurfaceVariant,
),
onTap: isSubmitting.value
? null
: () {
selectedUsers.value = [
...selectedUsers.value,
user,
];
queryController.clear();
query.value = '';
debouncedQuery.value = '';
submitError.value = null;
queryFocusNode.requestFocus();
},
);
},
);
},
),
),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
const SizedBox(height: Grid.xs),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: isSubmitting.value
? null
: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
const SizedBox(width: Grid.half),
FilledButton(
onPressed: canSubmit ? submit : null,
child: Text(isSubmitting.value ? 'Opening…' : 'Open DM'),
if (submitError.value case final error?) ...[
const SizedBox(height: Grid.xxs),
Text(
error,
style: context.textTheme.bodySmall?.copyWith(
color: context.colors.error,
),
),
],
],
),
),
),
);
}
}
class _SelectedDmRecipientChip extends StatelessWidget {
final DirectoryUser user;
final bool enabled;
final VoidCallback onDeleted;
const _SelectedDmRecipientChip({
required this.user,
required this.enabled,
required this.onDeleted,
});
@override
Widget build(BuildContext context) {
final foreground = context.colors.onSurfaceVariant;
return ConstrainedBox(
key: Key('new-dm-selected-${user.pubkey}'),
constraints: const BoxConstraints(maxWidth: 224),
child: SizedBox(
height: 40,
child: Material(
color: context.colors.surfaceContainerHighest,
shape: const StadiumBorder(),
clipBehavior: Clip.antiAlias,
child: Semantics(
button: true,
enabled: enabled,
excludeSemantics: true,
label: 'Remove ${user.label}',
child: InkWell(
customBorder: const StadiumBorder(),
onTap: enabled ? onDeleted : null,
child: Padding(
padding: const EdgeInsets.only(
left: Grid.half,
right: Grid.twelve,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
AvatarImage(
imageUrl: user.avatarUrl,
radius: 16,
backgroundColor: context.colors.primaryContainer,
fallback: Text(
user.initial,
style: context.textTheme.labelMedium?.copyWith(
color: context.colors.onPrimaryContainer,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(width: Grid.xxs),
Flexible(
child: Text(
user.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyLarge?.copyWith(
color: foreground,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
],
),
),
),
);
+162 -160
View File
@@ -1,6 +1,8 @@
import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
@@ -13,13 +15,15 @@ import '../search/search_page.dart';
class HomePage extends HookConsumerWidget {
const HomePage({super.key});
static const double _tabBarHeight = 60;
static const double _tabBarHeight = 56;
static const double _tabBarRadius = _tabBarHeight / 2;
static const double _tabBarInnerInset = 5;
static const double _tabBarInnerInset = Grid.half;
static const double _selectedTabRadius =
(_tabBarHeight - (_tabBarInnerInset * 2)) / 2;
static const double _tabBarBottomGap = Grid.twelve;
static const double _tabBarHorizontalMargin = Grid.gutter;
static const double _tabDestinationHorizontalPadding = Grid.sm;
static const double _tabIconSize = 22;
static const double _fabClearance = _tabBarHeight + _tabBarBottomGap;
static const Duration _tabIconWeightDuration = Duration(milliseconds: 120);
@@ -30,8 +34,8 @@ class HomePage extends HookConsumerWidget {
label: 'Home',
),
_HomeDestination(
icon: LucideIcons.bell300,
selectedIcon: LucideIcons.bell400,
icon: LucideIcons.inbox300,
selectedIcon: LucideIcons.inbox500,
label: 'Activity',
),
_HomeDestination(
@@ -44,27 +48,74 @@ class HomePage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final tabIndex = useState(0);
final systemBottomInset = MediaQuery.paddingOf(context).bottom;
final navigationBarWidth = _floatingTabBarWidth(
MediaQuery.sizeOf(context).width,
_destinations.length,
);
const pages = [ChannelsPage(), ActivityPage(), SearchPage()];
return Scaffold(
extendBody: true,
body: MediaQuery(
data: _mediaQueryWithFloatingTabBarClearance(
context,
HomePage._fabClearance,
body: SizedBox.expand(
child: Stack(
fit: StackFit.expand,
children: [
Positioned.fill(
child: MediaQuery(
data: _mediaQueryWithFloatingTabBarClearance(
context,
HomePage._fabClearance,
),
child: IndexedStack(index: tabIndex.value, children: pages),
),
),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: tabIndex.value == 0,
navigationBarHeight: HomePage._tabBarHeight,
navigationBarBottomGap: HomePage._tabBarBottomGap,
navigationBarWidth: navigationBarWidth,
systemBottomInset: systemBottomInset,
rightInset: Grid.sm,
),
),
],
),
child: IndexedStack(index: tabIndex.value, children: pages),
),
bottomNavigationBar: _FloatingTabBar(
selectedIndex: tabIndex.value,
onDestinationSelected: (i) => tabIndex.value = i,
onDestinationSelected: (i) {
if (i == tabIndex.value) return;
unawaited(HapticFeedback.selectionClick());
tabIndex.value = i;
},
destinations: _destinations,
),
);
}
}
double _floatingTabDestinationWidth(double screenWidth, int destinationCount) {
final preferredDestinationWidth =
HomePage._tabIconSize + (HomePage._tabDestinationHorizontalPadding * 2);
final availableInnerWidth =
screenWidth -
(HomePage._tabBarHorizontalMargin * 2) -
(HomePage._tabBarInnerInset * 2);
return preferredDestinationWidth
.clamp(0.0, availableInnerWidth / destinationCount)
.toDouble();
}
double _floatingTabBarWidth(double screenWidth, int destinationCount) {
if (destinationCount <= 0) return 0;
return (_floatingTabDestinationWidth(screenWidth, destinationCount) *
destinationCount) +
(HomePage._tabBarInnerInset * 2);
}
MediaQueryData _mediaQueryWithFloatingTabBarClearance(
BuildContext context,
double clearance,
@@ -119,6 +170,11 @@ class _FloatingTabBar extends StatelessWidget {
? Alignment.center
: Alignment(-1 + (2 * safeSelectedIndex / (destinationCount - 1)), 0);
final destinationWidth = _floatingTabDestinationWidth(
MediaQuery.sizeOf(context).width,
destinationCount,
);
return SafeArea(
minimum: const EdgeInsets.fromLTRB(
HomePage._tabBarHorizontalMargin,
@@ -128,117 +184,80 @@ class _FloatingTabBar extends StatelessWidget {
),
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
width: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.18),
blurRadius: 28,
offset: const Offset(0, 12),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
color: isDark
? colorScheme.surfaceContainerHighest.withValues(
alpha: 0.72,
)
: null,
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: isDark ? 0.20 : 0.38,
),
heightFactor: 1,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
boxShadow: [
BoxShadow(
color: colorScheme.shadow.withValues(alpha: 0.10),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 18, sigmaY: 18),
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(HomePage._tabBarRadius),
color: isDark
? colorScheme.surfaceContainerHighest.withValues(
alpha: 0.72,
)
: colorScheme.surface,
border: Border.all(
color: colorScheme.outlineVariant.withValues(
alpha: isDark ? 0.20 : 0.38,
),
gradient: isDark
? null
: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
colorScheme.surface.withValues(alpha: 0.90),
colorScheme.surfaceContainerHighest.withValues(
alpha: 0.78,
),
],
),
),
child: Stack(
children: [
if (!isDark)
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.center,
colors: [
Colors.white.withValues(alpha: 0.22),
Colors.white.withValues(alpha: 0.02),
],
),
child: Padding(
padding: const EdgeInsets.all(HomePage._tabBarInnerInset),
child: SizedBox(
height:
HomePage._tabBarHeight -
(HomePage._tabBarInnerInset * 2),
width: destinationWidth * destinationCount,
child: Stack(
children: [
AnimatedAlign(
alignment: selectedAlignment,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: SizedBox(
width: destinationWidth,
height: double.infinity,
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.primaryContainer,
borderRadius: BorderRadius.circular(
HomePage._selectedTabRadius,
),
),
),
),
),
Padding(
padding: const EdgeInsets.all(
HomePage._tabBarInnerInset,
),
child: SizedBox(
height:
HomePage._tabBarHeight -
(HomePage._tabBarInnerInset * 2),
child: Stack(
children: [
AnimatedAlign(
alignment: selectedAlignment,
duration: reducedMotion
? Duration.zero
: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
child: FractionallySizedBox(
widthFactor: 1 / destinationCount,
heightFactor: 1,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.quarter,
),
child: DecoratedBox(
decoration: BoxDecoration(
color: colorScheme.secondaryContainer,
borderRadius: BorderRadius.circular(
HomePage._selectedTabRadius,
),
),
),
),
Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
for (var i = 0; i < destinations.length; i++)
SizedBox(
width: destinationWidth,
child: _FloatingTabDestination(
destination: destinations[i],
selected: i == safeSelectedIndex,
onTap: () => onDestinationSelected(i),
),
),
Row(
children: [
for (var i = 0; i < destinations.length; i++)
Expanded(
child: _FloatingTabDestination(
destination: destinations[i],
selected: i == selectedIndex,
onTap: () => onDestinationSelected(i),
),
),
],
),
],
),
],
),
),
],
],
),
),
),
),
@@ -264,62 +283,45 @@ class _FloatingTabDestination extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = context.colors;
final textStyle = context.textTheme.labelSmall;
final reducedMotion = MediaQuery.of(context).disableAnimations;
final foregroundColor = selected
? colorScheme.onSecondaryContainer
? colorScheme.onPrimaryContainer
: colorScheme.onSurfaceVariant;
final icon = selected ? destination.selectedIcon : destination.icon;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Grid.quarter),
child: Material(
color: Colors.transparent,
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: onTap,
splashFactory: NoSplash.splashFactory,
overlayColor: const WidgetStatePropertyAll<Color>(Colors.transparent),
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: Tooltip(
message: destination.label,
excludeFromSemantics: true,
child: Material(
color: Colors.transparent,
clipBehavior: Clip.antiAlias,
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Grid.xxs,
vertical: Grid.xxs,
child: InkWell(
onTap: onTap,
overlayColor: const WidgetStatePropertyAll<Color>(
Colors.transparent,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: reducedMotion
? Duration.zero
: HomePage._tabIconWeightDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: Icon(
icon,
key: ValueKey('${destination.label}-$icon'),
color: foregroundColor,
size: 20,
),
borderRadius: BorderRadius.circular(HomePage._selectedTabRadius),
child: Center(
child: AnimatedSwitcher(
duration: reducedMotion
? Duration.zero
: HomePage._tabIconWeightDuration,
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeOutCubic,
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: Icon(
icon,
key: ValueKey('${destination.label}-$icon'),
color: foregroundColor,
size: HomePage._tabIconSize,
),
const SizedBox(height: 1),
Text(
destination.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textStyle?.copyWith(
color: foregroundColor,
fontSize: 10.5,
fontWeight: selected ? FontWeight.w700 : FontWeight.w400,
height: 1.15,
letterSpacing: 0,
),
),
],
),
),
),
),
+1
View File
@@ -63,6 +63,7 @@ class AppTheme {
return ThemeData(
useMaterial3: true,
colorScheme: scheme,
splashFactory: NoSplash.splashFactory,
scaffoldBackgroundColor: scheme.surface,
extensions: [appColors],
fontFamily: 'Inter',
@@ -10,6 +10,74 @@ import 'package:buzz/shared/relay/relay.dart';
/// also exposed on `ChannelDetails` MUST be propagated here — otherwise
/// `mergeDetails` silently clears that state on the merged Channel.
void main() {
test('extracts unique relay members from current and legacy tags', () {
final pubkeys = relayMemberPubkeysFromEvents([
NostrEvent(
id: 'members-1',
pubkey: 'relay',
createdAt: 1700000000,
kind: 13534,
tags: const [
['member', 'ALICE', 'member'],
['member', 'bob', 'admin'],
['p', 'alice', '', 'member'],
['name', 'not-a-member'],
['member', ''],
],
content: '',
sig: 'sig',
),
]);
expect(pubkeys, ['alice', 'bob']);
});
test('builds an alphabetized directory from the latest profile events', () {
final users = directoryUsersFromProfileEvents([
NostrEvent(
id: 'alice-old',
pubkey: 'alice',
createdAt: 10,
kind: 0,
tags: const [],
content: '{"display_name":"Zoe"}',
sig: 'sig',
),
NostrEvent(
id: 'bob',
pubkey: 'bob',
createdAt: 20,
kind: 0,
tags: const [],
content: '{"display_name":"Bob"}',
sig: 'sig',
),
NostrEvent(
id: 'alice-new',
pubkey: 'ALICE',
createdAt: 30,
kind: 0,
tags: const [],
content:
'{"display_name":"Alice","picture":"https://example.com/alice.png"}',
sig: 'sig',
),
NostrEvent(
id: 'not-a-profile',
pubkey: 'charlie',
createdAt: 40,
kind: 1,
tags: const [],
content: '{}',
sig: 'sig',
),
]);
expect(users.map((user) => user.label), ['Alice', 'Bob']);
expect(users.first.pubkey, 'alice');
expect(users.first.avatarUrl, 'https://example.com/alice.png');
});
test('propagates archived state from kind:39000 archived tag', () {
// Regression: previously this mapping ignored the `archived` tag, so
// `Channel.mergeDetails` would clear the archived flag the list provider
@@ -85,6 +153,38 @@ void main() {
expect(details.ttlDeadline!.isUtc, isTrue);
});
group('buildCreateChannelTags', () {
test('builds an ongoing channel without a ttl tag', () {
final tags = buildCreateChannelTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
name: 'release-notes',
channelType: 'stream',
visibility: 'open',
description: ' Ship updates ',
);
expect(tags, [
['h', 'c8c629ae-d35c-44fa-bc39-f6c1816756cc'],
['name', 'release-notes'],
['visibility', 'open'],
['channel_type', 'stream'],
['about', 'Ship updates'],
]);
});
test('adds the selected ttl for a temporary channel', () {
final tags = buildCreateChannelTags(
channelId: 'c8c629ae-d35c-44fa-bc39-f6c1816756cc',
name: 'incident-room',
channelType: 'stream',
visibility: 'private',
ttlSeconds: 604800,
);
expect(tags, contains(equals(['ttl', '604800'])));
});
});
group('buildDeleteMessageTags', () {
test('emits both channel h tag and target e tag', () {
final tags = buildDeleteMessageTags(
@@ -1,8 +1,12 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:hooks_riverpod/misc.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import 'package:buzz/features/channels/channel.dart';
import 'package:buzz/features/channels/channel_management_provider.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/features/channels/channels_provider.dart';
import 'package:buzz/features/channels/read_state/read_state_provider.dart';
@@ -10,17 +14,48 @@ import 'package:buzz/features/channels/unread_badge/observed_unread_event.dart';
import 'package:buzz/features/profile/profile_provider.dart';
import 'package:buzz/features/profile/user_profile.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:buzz/shared/widgets/avatar_image.dart';
void main() {
Widget buildTestable({required List<Override> overrides}) {
Widget buildTestable({
required List<Override> overrides,
bool previewDirectory = false,
double keyboardInset = 0,
}) {
return ProviderScope(
overrides: [
// Provide a fake profile and presence so the avatar doesn't hit the network.
profileProvider.overrideWith(() => _FakeProfileNotifier()),
presenceProvider.overrideWith(() => _FakePresenceNotifier()),
dmDirectoryPreviewEnabledProvider.overrideWith(
(ref) => previewDirectory,
),
...overrides,
],
child: MaterialApp(theme: AppTheme.light(), home: const ChannelsPage()),
child: MaterialApp(
theme: AppTheme.light(),
builder: (context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(viewInsets: EdgeInsets.only(bottom: keyboardInset)),
child: child!,
),
home: const Stack(
children: [
ChannelsPage(),
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: true,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
);
}
@@ -82,6 +117,492 @@ void main() {
expect(find.byTooltip('Create or start conversation'), findsOneWidget);
});
testWidgets('quick actions slide behind navigation when leaving home', (
tester,
) async {
Widget buildLauncher({required bool visible}) {
return ProviderScope(
overrides: [profileProvider.overrideWith(() => _FakeProfileNotifier())],
child: MaterialApp(
theme: AppTheme.light(),
home: Stack(
children: [
Positioned.fill(
child: ChannelQuickActionsLauncher(
visible: visible,
navigationBarHeight: 60,
navigationBarBottomGap: 12,
navigationBarWidth: 218,
systemBottomInset: 0,
rightInset: 16,
),
),
],
),
),
);
}
await tester.pumpWidget(buildLauncher(visible: true));
await tester.pumpAndSettle();
Transform motionTransform() => tester.widget<Transform>(
find.byKey(const Key('channel-quick-actions-transform')),
);
expect(motionTransform().transform.getTranslation().x, 0);
await tester.pumpWidget(buildLauncher(visible: false));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
final midpoint = motionTransform().transform.getTranslation().x;
expect(midpoint, lessThan(0));
expect(midpoint, greaterThan(-279));
await tester.pumpAndSettle();
expect(motionTransform().transform.getTranslation().x, closeTo(-279, 0.01));
final hiddenOpacity = tester.widget<Opacity>(
find.byKey(const Key('channel-quick-actions-opacity')),
);
expect(hiddenOpacity.opacity, 0);
final hiddenPointerGate = tester.widget<IgnorePointer>(
find
.ancestor(
of: find.byKey(const Key('channel-quick-actions-transform')),
matching: find.byType(IgnorePointer),
)
.first,
);
expect(hiddenPointerGate.ignoring, isTrue);
});
testWidgets('quick actions spring into spaced muted cards', (tester) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
final surface = find.byKey(const Key('quick-actions-surface'));
expect(tester.getSize(surface), const Size.square(56));
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pump();
var largestHeight = tester.getSize(surface).height;
for (var frame = 0; frame < 20; frame++) {
await tester.pump(const Duration(milliseconds: 16));
largestHeight = max(largestHeight, tester.getSize(surface).height);
}
await tester.pumpAndSettle();
expect(largestHeight, greaterThan(160));
expect(tester.getSize(surface).height, closeTo(160, 0.01));
final screenWidth = MediaQuery.sizeOf(tester.element(surface)).width;
final surfaceRect = tester.getRect(surface);
expect(surfaceRect.left, closeTo(20, 0.01));
expect(surfaceRect.right, closeTo(screenWidth - 20, 0.01));
final menuRect = tester.getRect(
find.byKey(const Key('quick-actions-menu')),
);
final createCard = find.byKey(
const Key('quick-action-create-channel-card'),
);
final dmCard = find.byKey(const Key('quick-action-new-dm-card'));
final createRect = tester.getRect(createCard);
final dmRect = tester.getRect(dmCard);
expect(createRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - createRect.right, closeTo(8, 0.01));
expect(dmRect.left - menuRect.left, closeTo(8, 0.01));
expect(menuRect.right - dmRect.right, closeTo(8, 0.01));
expect(dmRect.top - createRect.bottom, closeTo(8, 0.01));
expect(dmRect.width, createRect.width);
expect(dmRect.width, closeTo(menuRect.width - 16, 0.01));
final cardScheme = Theme.of(tester.element(createCard)).colorScheme;
final expectedCardColor = Color.alphaBlend(
cardScheme.onPrimary.withValues(alpha: 0.1),
cardScheme.primary,
);
final createMaterial = tester.widget<Material>(
find.descendant(of: createCard, matching: find.byType(Material)).first,
);
final dmMaterial = tester.widget<Material>(
find.descendant(of: dmCard, matching: find.byType(Material)).first,
);
expect(createMaterial.color, expectedCardColor);
expect(dmMaterial.color, expectedCardColor);
expect(
(createMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(
(dmMaterial.borderRadius as BorderRadius).topLeft.x,
closeTo(12, 0.01),
);
expect(find.text('Start a new stream channel'), findsNothing);
expect(
tester.widget<Text>(find.text('Create channel')).style?.fontSize,
16,
);
expect(
tester.widget<Text>(find.text('New direct message')).style?.fontSize,
16,
);
expect(find.text('Message one or more people'), findsNothing);
});
testWidgets('create channel sheet lists type and visibility radio options', (
tester,
) async {
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('Create channel'));
await tester.pumpAndSettle();
final createSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(createSheetRect.top, greaterThanOrEqualTo(24));
tester.testTextInput.hide();
await tester.pumpAndSettle();
expect(find.text('Create a new channel'), findsOneWidget);
expect(find.text('Name'), findsOneWidget);
expect(find.text('Description Optional'), findsOneWidget);
expect(find.text('Channel type'), findsOneWidget);
expect(find.text('Ongoing'), findsOneWidget);
expect(find.text('Temporary'), findsOneWidget);
expect(find.text('Visibility'), findsOneWidget);
expect(find.text('Public'), findsOneWidget);
expect(find.text('Private'), findsOneWidget);
expect(find.byKey(const Key('create-channel-submit')), findsNothing);
final nameField = tester.widget<TextField>(
find.byKey(const Key('create-channel-name')),
);
final descriptionField = tester.widget<TextField>(
find.byKey(const Key('create-channel-description')),
);
expect(nameField.textInputAction, TextInputAction.done);
expect(nameField.onSubmitted, isNotNull);
expect(descriptionField.textInputAction, TextInputAction.done);
expect(descriptionField.onSubmitted, isNotNull);
expect(
tester.getSize(find.byKey(const Key('create-channel-name'))).height,
greaterThanOrEqualTo(52),
);
expect(
tester
.getSize(find.byKey(const Key('create-channel-type-ongoing')))
.height,
greaterThanOrEqualTo(56),
);
await tester.tap(find.byKey(const Key('create-channel-type-temporary')));
await tester.pumpAndSettle();
expect(find.text('Expires after'), findsOneWidget);
expect(find.text('7 days'), findsOneWidget);
expect(
tester.getSize(find.byKey(const Key('create-channel-ttl'))).height,
greaterThanOrEqualTo(51),
);
final privateVisibility = find.byKey(
const Key('create-channel-visibility-private'),
);
await tester.ensureVisible(privateVisibility);
await tester.pumpAndSettle();
await tester.tap(privateVisibility);
await tester.pumpAndSettle();
expect(
tester
.widget<RadioGroup<String>>(find.byType(RadioGroup<String>))
.groupValue,
'private',
);
});
testWidgets('new message sheet lists and selects relay members', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
const directoryUsers = [
DirectoryUser(
pubkey: 'alice',
displayName: 'Alice',
nip05Handle: 'alice@example.com',
),
DirectoryUser(pubkey: 'bob', displayName: 'Bob'),
DirectoryUser(pubkey: 'charlie', displayName: 'Charlie'),
DirectoryUser(pubkey: 'danielle', displayName: 'Danielle'),
];
await tester.pumpWidget(
buildTestable(
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
relayDirectorySearchProvider.overrideWith((ref, query) async {
return directoryUsers
.where(
(user) =>
user.label.toLowerCase().contains(query.toLowerCase()),
)
.toList();
}),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
final dmSheetRect = tester.getRect(find.byType(BottomSheet).last);
expect(dmSheetRect.top, greaterThanOrEqualTo(24));
expect(find.text('New message'), findsOneWidget);
expect(find.text('To:'), findsOneWidget);
expect(find.text('Search for a person'), findsOneWidget);
final recipientField = find.byKey(const Key('new-dm-recipient-field'));
final initialRecipientWidth = tester.getSize(recipientField).width;
expect(tester.getSize(recipientField).height, greaterThanOrEqualTo(48));
expect(find.byKey(const Key('new-dm-person-alice')), findsOneWidget);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(
find.descendant(
of: find.byKey(const Key('new-dm-person-alice')),
matching: find.byType(AvatarImage),
),
findsOneWidget,
);
await tester.enterText(find.byKey(const Key('new-dm-search')), 'bob');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
expect(find.byKey(const Key('new-dm-person-alice')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
await tester.enterText(find.byKey(const Key('new-dm-search')), '');
await tester.pump(const Duration(milliseconds: 300));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('new-dm-person-alice')));
await tester.pump();
final aliceChip = find.byKey(const Key('new-dm-selected-alice'));
final inlineSearch = find.byKey(const Key('new-dm-search'));
final aliceRect = tester.getRect(aliceChip);
final inlineSearchRect = tester.getRect(inlineSearch);
expect(inlineSearchRect.left, greaterThan(aliceRect.right));
expect(
(inlineSearchRect.center.dy - aliceRect.center.dy).abs(),
lessThan(1),
);
expect(find.text('Search for a person'), findsNothing);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(tester.getSize(aliceChip).height, 40);
expect(tester.getSize(aliceChip).width, lessThanOrEqualTo(224));
final aliceChipMaterial = tester.widget<Material>(
find.descendant(of: aliceChip, matching: find.byType(Material)).first,
);
expect(aliceChipMaterial.shape, isA<StadiumBorder>());
expect(
aliceChipMaterial.color,
Theme.of(tester.element(aliceChip)).colorScheme.surfaceContainerHighest,
);
expect(
tester
.widget<AvatarImage>(
find.descendant(of: aliceChip, matching: find.byType(AvatarImage)),
)
.radius,
16,
);
expect(
tester
.widget<Text>(
find.descendant(of: aliceChip, matching: find.text('Alice')),
)
.style
?.fontSize,
16,
);
expect(
find.descendant(of: aliceChip, matching: find.byIcon(LucideIcons.x)),
findsNothing,
);
expect(find.bySemanticsLabel('Remove Alice'), findsOneWidget);
await tester.tap(find.byKey(const Key('new-dm-person-bob')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-charlie')));
await tester.pump();
await tester.tap(find.byKey(const Key('new-dm-person-danielle')));
await tester.pump();
expect(aliceChip, findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-bob')), findsOneWidget);
expect(find.byKey(const Key('new-dm-selected-charlie')), findsOneWidget);
final danielleChip = find.byKey(const Key('new-dm-selected-danielle'));
expect(danielleChip, findsOneWidget);
expect(tester.getSize(recipientField).width, initialRecipientWidth);
expect(
tester.getRect(danielleChip).top,
greaterThan(tester.getRect(aliceChip).top),
);
final recipientScrollViews = tester.widgetList<SingleChildScrollView>(
find.ancestor(
of: aliceChip,
matching: find.byType(SingleChildScrollView),
),
);
expect(
recipientScrollViews.every(
(scrollView) => scrollView.scrollDirection == Axis.vertical,
),
isTrue,
);
expect(
find.ancestor(
of: aliceChip,
matching: find.byKey(const Key('new-dm-recipient-wrap')),
),
findsOneWidget,
);
await tester.tap(find.byKey(const Key('new-dm-selected-bob')));
await tester.pump();
expect(find.byKey(const Key('new-dm-selected-bob')), findsNothing);
expect(find.byKey(const Key('new-dm-person-bob')), findsOneWidget);
expect(tester.widget<TextField>(inlineSearch).focusNode?.hasFocus, isTrue);
expect(find.text('Cancel'), findsNothing);
expect(find.text('Open DM'), findsNothing);
final searchField = tester.widget<TextField>(
find.byKey(const Key('new-dm-search')),
);
expect(searchField.textInputAction, TextInputAction.done);
expect(searchField.onSubmitted, isNotNull);
});
testWidgets('wraps many recipients without overflowing above the keyboard', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final directoryUsers = [
for (var index = 0; index < 8; index++)
DirectoryUser(
pubkey: 'person-$index',
displayName: 'Long recipient name number $index',
),
];
await tester.pumpWidget(
buildTestable(
keyboardInset: 300,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
relayDirectoryUsersProvider.overrideWith(
(ref) async => directoryUsers,
),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
for (final user in directoryUsers) {
final result = find.byKey(Key('new-dm-person-${user.pubkey}'));
await tester.ensureVisible(result);
await tester.pumpAndSettle();
await tester.tap(result);
await tester.pumpAndSettle();
}
expect(find.byKey(const Key('new-dm-recipient-wrap')), findsOneWidget);
expect(
find.bySemanticsLabel(RegExp(r'^Remove Long recipient')),
findsNWidgets(8),
);
expect(
find.text('DMs support up to nine people, including you.'),
findsOneWidget,
);
expect(tester.takeException(), isNull);
});
testWidgets('shows local preview people when the relay is offline', (
tester,
) async {
tester.view.physicalSize = const Size(390, 800);
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(
buildTestable(
previewDirectory: true,
overrides: [
channelsProvider.overrideWith(() => _FakeNotifier(testChannels)),
],
),
);
await tester.pumpAndSettle();
await tester.tap(find.byTooltip('Create or start conversation'));
await tester.pumpAndSettle();
await tester.tap(find.text('New direct message'));
await tester.pumpAndSettle();
expect(find.text('Could not load people from this relay.'), findsNothing);
expect(find.text('Maya Chen'), findsOneWidget);
expect(find.text('Jordan Brooks'), findsOneWidget);
expect(find.text('Priya Shah'), findsOneWidget);
await tester.tap(
find.byKey(
const Key(
'new-dm-person-'
'1111111111111111111111111111111111111111111111111111111111111111',
),
),
);
await tester.pump();
expect(find.text('Maya Chen'), findsWidgets);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pump();
expect(
find.text('Preview only — mock people cannot be messaged.'),
findsOneWidget,
);
});
testWidgets('hides unjoined and archived channels from the main list', (
tester,
) async {
@@ -0,0 +1,137 @@
import 'package:buzz/features/home/home_page.dart';
import 'package:buzz/features/channels/channels_page.dart';
import 'package:buzz/shared/theme/theme.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
void main() {
testWidgets('shows icon-only navigation and an aligned quick action', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
),
);
await tester.pump();
expect(find.text('Home'), findsNothing);
expect(find.text('Activity'), findsNothing);
expect(find.text('Search'), findsNothing);
expect(find.bySemanticsLabel('Home'), findsOneWidget);
expect(find.bySemanticsLabel('Activity'), findsOneWidget);
expect(find.bySemanticsLabel('Search'), findsOneWidget);
final quickAction = find.byTooltip('Create or start conversation');
expect(quickAction, findsOneWidget);
final launcherSize = tester.getSize(
find.byType(ChannelQuickActionsLauncher),
);
expect(launcherSize.width, 800);
expect(launcherSize.height, greaterThan(0));
final motionRect = tester.getRect(
find.byKey(const Key('channel-quick-actions-motion')),
);
expect(motionRect.width, const Size.square(56).width);
expect(motionRect.left, greaterThanOrEqualTo(0));
expect(tester.getSize(quickAction), const Size.square(56));
final quickActionRect = tester.getRect(quickAction);
expect(quickActionRect.left, greaterThanOrEqualTo(0));
expect(quickActionRect.top, greaterThanOrEqualTo(0));
expect(quickActionRect.right, lessThanOrEqualTo(800));
expect(quickActionRect.bottom, lessThanOrEqualTo(600));
final homeDestinationRect = tester.getRect(find.bySemanticsLabel('Home'));
expect(
quickActionRect.center.dy,
closeTo(homeDestinationRect.center.dy, 0.01),
);
});
testWidgets('gives selection haptics only when the tab changes', (
tester,
) async {
final hapticCalls = <MethodCall>[];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, (call) async {
if (call.method == 'HapticFeedback.vibrate') {
hapticCalls.add(call);
}
return null;
});
addTearDown(
() => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null),
);
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
),
);
await tester.pump();
await tester.tap(find.byTooltip('Home'));
await tester.pump();
expect(hapticCalls, isEmpty);
await tester.tap(find.byTooltip('Activity'));
await tester.pump();
expect(hapticCalls, hasLength(1));
expect(hapticCalls.single.arguments, 'HapticFeedbackType.selectionClick');
await tester.tap(find.byTooltip('Activity'));
await tester.pump();
expect(hapticCalls, hasLength(1));
await tester.tap(find.byTooltip('Search'));
await tester.pump();
expect(hapticCalls, hasLength(2));
});
testWidgets('scales and fades the quick action as tabs change', (
tester,
) async {
await tester.pumpWidget(
ProviderScope(
child: MaterialApp(theme: AppTheme.light(), home: const HomePage()),
),
);
await tester.pump();
double scale() => tester
.widget<Transform>(find.byKey(const Key('channel-quick-actions-scale')))
.transform
.storage
.first;
double opacity() => tester
.widget<Opacity>(find.byKey(const Key('channel-quick-actions-opacity')))
.opacity;
expect(scale(), closeTo(1, 0.001));
expect(opacity(), closeTo(1, 0.001));
await tester.tap(find.byTooltip('Activity'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
expect(scale(), inExclusiveRange(0.8, 1));
expect(opacity(), inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(scale(), closeTo(0.8, 0.001));
expect(opacity(), closeTo(0, 0.001));
await tester.tap(find.byTooltip('Home'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 110));
expect(scale(), inExclusiveRange(0.8, 1));
expect(opacity(), inExclusiveRange(0, 1));
await tester.pumpAndSettle();
expect(scale(), closeTo(1, 0.001));
expect(opacity(), closeTo(1, 0.001));
});
}
@@ -0,0 +1,10 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:buzz/shared/theme/app_theme.dart';
void main() {
test('disables Material touch ripples in every app theme', () {
expect(AppTheme.light().splashFactory, NoSplash.splashFactory);
expect(AppTheme.dark().splashFactory, NoSplash.splashFactory);
});
}