diff --git a/mobile/lib/features/channels/channel_management_provider.dart b/mobile/lib/features/channels/channel_management_provider.dart index 97953653c7..eb1f2b4293 100644 --- a/mobile/lib/features/channels/channel_management_provider.dart +++ b/mobile/lib/features/channels/channel_management_provider.dart @@ -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((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( + '' + '' + '$emoji' + '', + mimeType: 'image/svg+xml', + encoding: utf8, + ).toString(); } +/// Local identities used to preview the new-DM picker without a relay. +final dmDirectoryPreviewUsers = List.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((ref) { // Prefer the explicitly-derived pubkey from nsec — this is the signing // identity used for events. @@ -110,6 +175,153 @@ final currentPubkeyProvider = Provider((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 relayMemberPubkeysFromEvents(List events) { + final pubkeys = {}; + 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 directoryUsersFromProfileEvents(List events) { + final latestByPubkey = {}; + 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>(( + 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, 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> buildDeleteMessageTags({ ]; } +/// Builds the signed kind:9007 tags used by mobile channel creation. +List> 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 = >[ - ['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 || diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 838f1f3554..674a498af8 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -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 openChannel(Channel channel) async { if (!context.mounted) return; await Navigator.of(context).push( @@ -167,39 +167,6 @@ class ChannelsPage extends HookConsumerWidget { ); } - Future selectQuickAction(_QuickAction action) async { - final reducedMotion = MediaQuery.of(context).disableAnimations; - quickActionsOpen.value = false; - if (!reducedMotion) { - await Future.delayed(_kMorphCloseDuration); - } - if (!context.mounted) return; - - switch (action) { - case _QuickAction.createChannel: - final created = await showModalBottomSheet( - 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( - 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, ), ); } diff --git a/mobile/lib/features/channels/channels_page/quick_actions.dart b/mobile/lib/features/channels/channels_page/quick_actions.dart index 80fc717ddb..8754842702 100644 --- a/mobile/lib/features/channels/channels_page/quick_actions.dart +++ b/mobile/lib/features/channels/channels_page/quick_actions.dart @@ -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,103 +116,113 @@ 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, + 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.translate( + offset: Offset( + _kMorphSlide * (1 - surfaceProgress), + 0, + ), + child: Transform.scale( + alignment: Alignment.bottomRight, + scale: + _kMorphScale + + ((1 - _kMorphScale) * + surfaceProgress), + child: _QuickActionsMenu( + onSelected: onSelected, + ), + ), + ), + ), + ), + ), + ), + ), + ), + Positioned( + right: 0, + bottom: 0, + width: _kMorphClosedSize, + height: _kMorphClosedSize, child: IgnorePointer( - ignoring: !open || fadeValue < 0.9, + ignoring: open, child: ExcludeSemantics( - excluding: !open, + excluding: open, child: Opacity( - opacity: fadeValue, + opacity: 1 - fadeValue, child: ImageFiltered( imageFilter: ImageFilter.blur( - sigmaX: _kMorphBlur * (1 - fadeValue), - sigmaY: _kMorphBlur * (1 - fadeValue), + sigmaX: _kMorphBlur * fadeValue, + sigmaY: _kMorphBlur * fadeValue, ), child: Transform.translate( offset: Offset( - _kMorphSlide * (1 - surfaceValue), + -_kMorphSlide * surfaceProgress, 0, ), - child: Transform.scale( - alignment: Alignment.bottomRight, - scale: - _kMorphScale + - ((1 - _kMorphScale) * surfaceValue), - child: _QuickActionsMenu( - onSelected: onSelected, - ), - ), - ), - ), - ), - ), - ), - ), - ), - 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, + 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, - ), - ), - const SizedBox(height: Grid.quarter), - Text( - subtitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodySmall?.copyWith( - color: foreground.withValues(alpha: 0.72), + 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, + ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), ), ), diff --git a/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart new file mode 100644 index 0000000000..3653932089 --- /dev/null +++ b/mobile/lib/features/channels/channels_page/quick_actions_launcher.dart @@ -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 openChannel(Channel channel) async { + if (!context.mounted) return; + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ChannelDetailPage(channel: channel), + ), + ); + } + + Future selectQuickAction(_QuickAction action) async { + quickActionsOpen.value = false; + if (!reducedMotion) { + await Future.delayed(_kMorphCloseDuration); + } + if (!context.mounted) return; + + switch (action) { + case _QuickAction.createChannel: + final created = await showModalBottomSheet( + 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( + 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( + 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, + ); +} diff --git a/mobile/lib/features/channels/channels_page/sheets.dart b/mobile/lib/features/channels/channels_page/sheets.dart index a4ff4d9ee6..51aead28c7 100644 --- a/mobile/lib/features/channels/channels_page/sheets.dart +++ b/mobile/lib/features/channels/channels_page/sheets.dart @@ -1,5 +1,34 @@ part of '../channels_page.dart'; +const int _defaultCreateChannelTtlSeconds = 7 * 24 * 60 * 60; + +class _CreateChannelMenuOption { + 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(null); + useListenable(nameController); + + final kindLabel = channelType == 'forum' ? 'forum' : 'channel'; + final canSubmit = + nameController.text.trim().isNotEmpty && !isSubmitting.value; Future 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', + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Create a new $kindLabel', + style: context.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: -0.3, + ), ), - 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', + 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(height: Grid.xs), + const _CreateChannelFieldLabel( + label: 'Description', + optional: true, ), - 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), - Text( - error, - style: context.textTheme.bodySmall?.copyWith( - color: context.colors.error, + _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( + 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( + 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( + 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, + ), + ), + ], ], - const SizedBox(height: Grid.xs), - Row( - mainAxisAlignment: MainAxisAlignment.end, + ), + ), + ), + ); + } +} + +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 extends StatelessWidget { + final bool enabled; + final String label; + final ValueChanged onSelected; + final List<_CreateChannelMenuOption> 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( + groupValue: value, + onChanged: (nextValue) { + if (enabled && nextValue != null) { + onSelected(nextValue); + } + }, + child: Column( children: [ - TextButton( - onPressed: isSubmitting.value - ? null - : () => Navigator.of(context).pop(), - child: const Text('Cancel'), + for (final (index, option) in options.indexed) ...[ + RadioListTile( + 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 extends StatelessWidget { + final Key controlKey; + final bool enabled; + final String label; + final ValueChanged onSelected; + final List<_CreateChannelMenuOption> 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( + enabled: enabled, + initialValue: value, + onSelected: onSelected, + tooltip: '$label: $valueLabel', + itemBuilder: (context) => [ + for (final option in options) + CheckedPopupMenuItem( + 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), - FilledButton( - onPressed: isSubmitting.value ? null : submit, - child: Text(isSubmitting.value ? 'Creating…' : 'Create'), - ), ], - ), - ], + 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>([]); final isSubmitting = useState(false); final submitError = useState(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 []); - } - 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 []; 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), + 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), + ), + 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), - SizedBox( - height: 280, - child: Builder( + const SizedBox(height: Grid.xs), + Builder( + key: const Key('new-dm-results'), builder: (context) { if (selectedUsers.value.length >= 8) { - return const Center( - child: Text( - 'Direct messages support up to 9 people including you.', + return const SizedBox( + height: 96, + child: Center( + child: Text( + 'DMs support up to nine people, including you.', + ), ), ); } - if (debouncedQuery.value.isEmpty) { - return const Center( - child: Text( - 'Search for someone to start a conversation.', - ), + if (directoryAsync.isLoading && + directoryAsync.asData == null || + isSearchTransitionPending) { + return const SizedBox( + height: 280, + child: Center(child: CircularProgressIndicator()), ); } - if (searchResults.connectionState == - ConnectionState.waiting) { - return const 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 const Center(child: Text('No matching users.')); + return SizedBox( + height: 280, + child: Center( + child: Text( + normalizedQuery.isEmpty + ? 'No people or agents available to message.' + : 'No matching users.', + ), + ), + ); } - return ListView( + return ListView.separated( + physics: const NeverScrollableScrollPhysics(), shrinkWrap: true, - children: [ - for (final user in availableResults) - ListTile( - leading: AvatarImage( - imageUrl: user.avatarUrl, - radius: 20, - fallback: Text( - user.label.substring(0, 1).toUpperCase(), + 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), - subtitle: Text(user.secondaryLabel), - onTap: () { - selectedUsers.value = [ - ...selectedUsers.value, - user, - ]; - queryController.clear(); - query.value = ''; - debouncedQuery.value = ''; - }, ), - ], + 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, + 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'), + ), + ), + ), + ); + } +} + +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, ), - const SizedBox(width: Grid.half), - FilledButton( - onPressed: canSubmit ? submit : null, - child: Text(isSubmitting.value ? 'Opening…' : 'Open DM'), + 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, + ), + ), + ), + ], ), - ], + ), ), - ], + ), ), ), ); diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index a7485fb402..e76d739ab2 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -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(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( + 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, - ), - ), - ], + ), ), ), ), diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 54bf9c50e3..f05c579a32 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -63,6 +63,7 @@ class AppTheme { return ThemeData( useMaterial3: true, colorScheme: scheme, + splashFactory: NoSplash.splashFactory, scaffoldBackgroundColor: scheme.surface, extensions: [appColors], fontFamily: 'Inter', diff --git a/mobile/test/features/channels/channel_management_provider_test.dart b/mobile/test/features/channels/channel_management_provider_test.dart index f525f49990..7bd0429a87 100644 --- a/mobile/test/features/channels/channel_management_provider_test.dart +++ b/mobile/test/features/channels/channel_management_provider_test.dart @@ -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( diff --git a/mobile/test/features/channels/channels_page_test.dart b/mobile/test/features/channels/channels_page_test.dart index 232cdeb912..0cb3383f3a 100644 --- a/mobile/test/features/channels/channels_page_test.dart +++ b/mobile/test/features/channels/channels_page_test.dart @@ -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 overrides}) { + Widget buildTestable({ + required List 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( + 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( + find.byKey(const Key('channel-quick-actions-opacity')), + ); + expect(hiddenOpacity.opacity, 0); + final hiddenPointerGate = tester.widget( + 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( + find.descendant(of: createCard, matching: find.byType(Material)).first, + ); + final dmMaterial = tester.widget( + 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(find.text('Create channel')).style?.fontSize, + 16, + ); + expect( + tester.widget(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( + find.byKey(const Key('create-channel-name')), + ); + final descriptionField = tester.widget( + 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>(find.byType(RadioGroup)) + .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(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( + find.descendant(of: aliceChip, matching: find.byType(Material)).first, + ); + expect(aliceChipMaterial.shape, isA()); + expect( + aliceChipMaterial.color, + Theme.of(tester.element(aliceChip)).colorScheme.surfaceContainerHighest, + ); + expect( + tester + .widget( + find.descendant(of: aliceChip, matching: find.byType(AvatarImage)), + ) + .radius, + 16, + ); + expect( + tester + .widget( + 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( + 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(inlineSearch).focusNode?.hasFocus, isTrue); + expect(find.text('Cancel'), findsNothing); + expect(find.text('Open DM'), findsNothing); + final searchField = tester.widget( + 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 { diff --git a/mobile/test/features/home/home_page_test.dart b/mobile/test/features/home/home_page_test.dart new file mode 100644 index 0000000000..a738c3dce4 --- /dev/null +++ b/mobile/test/features/home/home_page_test.dart @@ -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 = []; + 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(find.byKey(const Key('channel-quick-actions-scale'))) + .transform + .storage + .first; + double opacity() => tester + .widget(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)); + }); +} diff --git a/mobile/test/shared/theme/app_theme_test.dart b/mobile/test/shared/theme/app_theme_test.dart new file mode 100644 index 0000000000..6231befc69 --- /dev/null +++ b/mobile/test/shared/theme/app_theme_test.dart @@ -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); + }); +}