mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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 ||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user