diff --git a/mobile/lib/features/activity/activity_page.dart b/mobile/lib/features/activity/activity_page.dart index 9a3322fde6..2186da1112 100644 --- a/mobile/lib/features/activity/activity_page.dart +++ b/mobile/lib/features/activity/activity_page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/filter_chip_bar.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/channel.dart'; @@ -43,15 +44,36 @@ class ActivityPage extends HookConsumerWidget { body = Column( children: [ - _FilterBar( + FilterChipBar<_Filter>( selected: filter.value, onSelected: (f) => filter.value = f, - counts: _FilterCounts( - mentions: feed.mentions.length, - needsAction: feed.needsAction.length, - activity: feed.activity.length, - agents: feed.agentActivity.length, - ), + items: [ + const FilterChipItem(id: _Filter.all, label: 'All'), + FilterChipItem( + id: _Filter.mentions, + label: 'Mentions', + icon: LucideIcons.atSign, + count: feed.mentions.length, + ), + FilterChipItem( + id: _Filter.needsAction, + label: 'Action', + icon: LucideIcons.circleAlert, + count: feed.needsAction.length, + ), + FilterChipItem( + id: _Filter.activity, + label: 'Activity', + icon: LucideIcons.activity, + count: feed.activity.length, + ), + FilterChipItem( + id: _Filter.agents, + label: 'Agents', + icon: LucideIcons.bot, + count: feed.agentActivity.length, + ), + ], ), Expanded( child: items.isEmpty @@ -123,112 +145,6 @@ class ActivityPage extends HookConsumerWidget { } } -// --------------------------------------------------------------------------- -// Filter bar -// --------------------------------------------------------------------------- - -class _FilterCounts { - final int mentions; - final int needsAction; - final int activity; - final int agents; - - const _FilterCounts({ - required this.mentions, - required this.needsAction, - required this.activity, - required this.agents, - }); -} - -class _FilterBar extends StatelessWidget { - final _Filter selected; - final ValueChanged<_Filter> onSelected; - final _FilterCounts counts; - - const _FilterBar({ - required this.selected, - required this.onSelected, - required this.counts, - }); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric( - horizontal: Grid.xs, - vertical: Grid.xxs, - ), - child: Row( - children: [ - _chip(context, _Filter.all, 'All', null, null), - const SizedBox(width: Grid.xxs), - _chip( - context, - _Filter.mentions, - 'Mentions', - LucideIcons.atSign, - counts.mentions, - ), - const SizedBox(width: Grid.xxs), - _chip( - context, - _Filter.needsAction, - 'Action', - LucideIcons.circleAlert, - counts.needsAction, - ), - const SizedBox(width: Grid.xxs), - _chip( - context, - _Filter.activity, - 'Activity', - LucideIcons.activity, - counts.activity, - ), - const SizedBox(width: Grid.xxs), - _chip( - context, - _Filter.agents, - 'Agents', - LucideIcons.bot, - counts.agents, - ), - ], - ), - ); - } - - Widget _chip( - BuildContext context, - _Filter filter, - String label, - IconData? icon, - int? count, - ) { - final isSelected = selected == filter; - final text = count != null ? '$label ($count)' : label; - return FilterChip( - selected: isSelected, - showCheckmark: false, - label: icon != null - ? Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 14), - const SizedBox(width: Grid.half), - Text(text, style: context.textTheme.labelSmall), - ], - ) - : Text(text, style: context.textTheme.labelSmall), - onSelected: (_) => onSelected(filter), - visualDensity: VisualDensity.compact, - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - ); - } -} - // --------------------------------------------------------------------------- // Feed item tile // --------------------------------------------------------------------------- diff --git a/mobile/lib/features/channels/channels_page.dart b/mobile/lib/features/channels/channels_page.dart index 478b624a93..36eb7ed6b3 100644 --- a/mobile/lib/features/channels/channels_page.dart +++ b/mobile/lib/features/channels/channels_page.dart @@ -161,6 +161,7 @@ class ChannelsPage extends HookConsumerWidget { ], ), floatingActionButton: FloatingActionButton( + heroTag: 'channels-fab', onPressed: openQuickActions, tooltip: 'Create or start conversation', shape: const CircleBorder(), diff --git a/mobile/lib/features/forum/forum_posts_view.dart b/mobile/lib/features/forum/forum_posts_view.dart index aea2e1fe39..76dd50959b 100644 --- a/mobile/lib/features/forum/forum_posts_view.dart +++ b/mobile/lib/features/forum/forum_posts_view.dart @@ -51,6 +51,7 @@ class ForumPostsView extends HookConsumerWidget { backgroundColor: Colors.transparent, floatingActionButton: canPost && !isComposing.value ? FloatingActionButton( + heroTag: 'forum-fab', onPressed: () => isComposing.value = true, tooltip: 'New post', shape: const CircleBorder(), diff --git a/mobile/lib/features/home/home_page.dart b/mobile/lib/features/home/home_page.dart index 2223da6819..9dab1f428b 100644 --- a/mobile/lib/features/home/home_page.dart +++ b/mobile/lib/features/home/home_page.dart @@ -29,8 +29,8 @@ class HomePage extends HookConsumerWidget { label: 'Home', ), NavigationDestination( - icon: Icon(LucideIcons.radio), - selectedIcon: Icon(LucideIcons.radio), + icon: Icon(LucideIcons.activity), + selectedIcon: Icon(LucideIcons.activity), label: 'Pulse', ), NavigationDestination( diff --git a/mobile/lib/features/pulse/agent_activity_card.dart b/mobile/lib/features/pulse/agent_activity_card.dart index e17cd4fadd..cbaed133de 100644 --- a/mobile/lib/features/pulse/agent_activity_card.dart +++ b/mobile/lib/features/pulse/agent_activity_card.dart @@ -28,160 +28,150 @@ class AgentActivityCard extends HookConsumerWidget { ref.read(userCacheProvider.notifier).get(group.pubkey); final name = profile?.label ?? _shortPubkey(group.pubkey); - return Container( - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues(alpha: 0.6), - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all( - color: context.colors.primary.withValues(alpha: 0.22), - ), - ), - child: Column( - children: [ - InkWell( - onTap: group.notes.length > 1 - ? () => expanded.value = !expanded.value - : null, - borderRadius: const BorderRadius.vertical( - top: Radius.circular(Radii.lg), - ), - child: Padding( - padding: const EdgeInsets.all(Grid.twelve), - child: Row( - children: [ - Stack( - children: [ - CircleAvatar( - radius: 18, - backgroundColor: context.colors.primaryContainer, - backgroundImage: profile?.avatarUrl != null - ? NetworkImage(profile!.avatarUrl!) - : null, - child: profile?.avatarUrl == null - ? const Icon(LucideIcons.bot, size: 18) - : null, - ), - Positioned( - right: 0, - bottom: 0, - child: Container( - width: 10, - height: 10, - decoration: BoxDecoration( - color: context.appColors.success, - shape: BoxShape.circle, - border: Border.all( - color: context.colors.surfaceContainerHighest, - width: 1.5, - ), + return Column( + children: [ + InkWell( + onTap: group.notes.length > 1 + ? () => expanded.value = !expanded.value + : null, + borderRadius: BorderRadius.circular(Radii.md), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.twelve), + child: Row( + children: [ + Stack( + children: [ + CircleAvatar( + radius: 18, + backgroundColor: context.colors.primaryContainer, + backgroundImage: profile?.avatarUrl != null + ? NetworkImage(profile!.avatarUrl!) + : null, + child: profile?.avatarUrl == null + ? const Icon(LucideIcons.bot, size: 18) + : null, + ), + Positioned( + right: 0, + bottom: 0, + child: Container( + width: 10, + height: 10, + decoration: BoxDecoration( + color: context.appColors.success, + shape: BoxShape.circle, + border: Border.all( + color: context.colors.surface, + width: 1.5, ), ), ), - ], - ), - const SizedBox(width: Grid.xs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: Text( - name, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelLarge?.copyWith( - fontWeight: FontWeight.w700, - ), + ), + ], + ), + const SizedBox(width: Grid.xs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + name, + overflow: TextOverflow.ellipsis, + style: context.textTheme.labelLarge?.copyWith( + fontWeight: FontWeight.w700, ), ), - const SizedBox(width: Grid.half), - Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.half, - vertical: 2, - ), - decoration: BoxDecoration( - color: context.colors.primary.withValues( - alpha: 0.12, - ), - borderRadius: BorderRadius.circular(Radii.sm), + ), + const SizedBox(width: Grid.half), + Container( + padding: const EdgeInsets.symmetric( + horizontal: Grid.half, + vertical: 2, + ), + decoration: BoxDecoration( + color: context.colors.primary.withValues( + alpha: 0.12, ), - child: Text( - 'BOT', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.primary, - fontWeight: FontWeight.w800, - fontSize: 10, - ), + borderRadius: BorderRadius.circular(Radii.sm), + ), + child: Text( + 'BOT', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.primary, + fontWeight: FontWeight.w800, + fontSize: 10, ), ), - ], - ), - Text( - '${group.notes.length} update${group.notes.length == 1 ? '' : 's'} · ${formatPulseRelativeTime(group.latestAt)}', - style: context.textTheme.labelSmall?.copyWith( - color: context.colors.onSurfaceVariant, ), + ], + ), + Text( + '${group.notes.length} update${group.notes.length == 1 ? '' : 's'} · ${formatPulseRelativeTime(group.latestAt)}', + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, ), - ], - ), + ), + ], ), - if (group.notes.length > 1) - Icon( - expanded.value - ? LucideIcons.chevronUp - : LucideIcons.chevronDown, - size: 18, - color: context.colors.onSurfaceVariant, - ), - ], - ), + ), + if (group.notes.length > 1) + Icon( + expanded.value + ? LucideIcons.chevronUp + : LucideIcons.chevronDown, + size: 18, + color: context.colors.onSurfaceVariant, + ), + ], ), ), - if (expanded.value) - Padding( - padding: const EdgeInsets.fromLTRB(Grid.xs, 0, Grid.xs, Grid.xs), - child: Column( - children: [ - for (final note in group.notes) ...[ - NoteCard( - note: note, - reaction: - reactions[note.id] ?? - const PulseReactionState( - count: 0, - reactedByCurrentUser: false, - ), - isAgent: true, - onReactionChanged: onReactionChanged, + ), + if (expanded.value) + Padding( + padding: const EdgeInsets.only(left: Grid.xl), + child: Column( + children: [ + for (final note in group.notes) ...[ + NoteCard( + note: note, + reaction: + reactions[note.id] ?? + const PulseReactionState( + count: 0, + reactedByCurrentUser: false, + ), + isAgent: true, + onReactionChanged: onReactionChanged, + ), + if (note != group.notes.last) + Divider( + height: 1, + thickness: 1, + color: context.colors.outlineVariant.withValues( + alpha: 0.4, + ), ), - if (note != group.notes.last) - const SizedBox(height: Grid.xxs), - ], ], - ), - ) - else - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.twelve, - 0, - Grid.twelve, - Grid.twelve, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Text( - group.notes.first.content, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium, - ), + ], + ), + ) + else + Padding( + padding: const EdgeInsets.only(left: Grid.xl, bottom: Grid.xxs), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + group.notes.first.content, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium, ), ), - ], - ), + ), + ], ); } } diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart new file mode 100644 index 0000000000..8666272f9b --- /dev/null +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -0,0 +1,260 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../shared/theme/theme.dart'; +import '../../shared/widgets/frosted_app_bar.dart'; +import '../../shared/widgets/frosted_scaffold.dart'; +import '../channels/message_content.dart'; +import '../profile/profile_provider.dart'; +import '../profile/user_cache_provider.dart'; +import 'note_card.dart'; +import 'pulse_actions.dart'; +import 'pulse_models.dart'; + +/// Full-screen page for composing a new note or replying to one. +/// +/// When [replyTo] is null this composes a new top-level note; when set the +/// page shows the note being replied to as context and the action button +/// reads "Reply". The text field auto-focuses on open so the keyboard is +/// ready immediately. +class ComposeNotePage extends HookConsumerWidget { + final UserNote? replyTo; + + const ComposeNotePage({super.key, this.replyTo}); + + bool get _isReply => replyTo != null; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final controller = useTextEditingController(); + final focusNode = useFocusNode(); + final isSending = useState(false); + final hasText = useListenableSelector( + controller, + () => controller.text.trim().isNotEmpty, + ); + final profile = ref.watch(profileProvider).asData?.value; + + // Auto-focus the field once the page has mounted. + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + focusNode.requestFocus(); + }); + return null; + }, const []); + + Future submit() async { + if (!hasText || isSending.value) return; + isSending.value = true; + try { + await publishNote(ref, content: controller.text, replyTo: replyTo); + if (context.mounted) Navigator.of(context).pop(true); + } catch (_) { + // Keep the page open so the draft isn't lost; re-enable the button. + isSending.value = false; + rethrow; + } + } + + return FrostedScaffold( + resizeToAvoidBottomInset: true, + appBar: FrostedAppBar( + leading: TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + actions: [ + Padding( + padding: const EdgeInsets.only(right: Grid.xxs), + child: FilledButton( + onPressed: hasText && !isSending.value ? submit : null, + style: FilledButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: Grid.xs), + minimumSize: const Size(0, 36), + shape: const StadiumBorder(), + ), + child: isSending.value + ? const SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : Text(_isReply ? 'Reply' : 'Post'), + ), + ), + ], + ), + body: SafeArea( + top: false, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: frostedAppBarHeight(context)), + if (_isReply) _ReplyContext(note: replyTo!), + Expanded( + child: SingleChildScrollView( + padding: const EdgeInsets.all(Grid.xs), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 18, + backgroundColor: context.colors.primaryContainer, + backgroundImage: profile?.avatarUrl != null + ? NetworkImage(profile!.avatarUrl!) + : null, + child: profile?.avatarUrl == null + ? Text( + profile?.initial ?? '?', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ) + : null, + ), + const SizedBox(width: Grid.xxs), + Expanded( + child: TextField( + controller: controller, + focusNode: focusNode, + minLines: 3, + maxLines: null, + autofocus: true, + keyboardType: TextInputType.multiline, + textInputAction: TextInputAction.newline, + style: context.textTheme.bodyLarge, + decoration: InputDecoration( + hintText: _isReply + ? 'Post your reply' + : 'What’s on your mind?', + border: InputBorder.none, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + isDense: true, + contentPadding: const EdgeInsets.symmetric( + vertical: Grid.half, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// A read-only preview of the note being replied to, laid out like a list +/// row (avatar + name + time + content) but non-interactive. Capped in +/// height so a long note doesn't push the editor off-screen. +class _ReplyContext extends ConsumerWidget { + final UserNote note; + + const _ReplyContext({required this.note}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final pubkey = note.pubkey.toLowerCase(); + final profile = + ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ?? + ref.read(userCacheProvider.notifier).get(pubkey); + final displayName = profile?.label ?? _shortPubkey(pubkey); + + return Padding( + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xs, Grid.xs, 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Replying to $displayName', + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xs), + // Mirror the NoteCard list-row layout, read-only. Shrink-wraps to + // the content; long notes are capped to ~3 lines via the parent + // page scroll + ellipsis on the content. + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CircleAvatar( + radius: 18, + backgroundColor: context.colors.primaryContainer, + backgroundImage: profile?.avatarUrl != null + ? NetworkImage(profile!.avatarUrl!) + : null, + child: profile?.avatarUrl == null + ? Text( + (profile?.initial ?? displayName[0]).toUpperCase(), + style: context.textTheme.labelMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ) + : null, + ), + const SizedBox(width: Grid.xs), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + displayName, + style: context.textTheme.labelMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: Grid.half), + Text( + formatPulseRelativeTime(note.createdAt), + style: context.textTheme.labelSmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + const SizedBox(height: Grid.half), + // Rich content (images, links, mentions) like the list + // row, but clipped to a compact max height so a tall + // image or long note can't blow up the page. + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 132), + child: ClipRect( + child: Align( + alignment: Alignment.topLeft, + heightFactor: 1, + child: MessageContent( + content: note.content, + tags: note.tags, + ), + ), + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: Grid.xs), + Divider( + height: 1, + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ), + ], + ), + ); + } + + String _shortPubkey(String pubkey) => + pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey; +} diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 739b87b170..ea8bd6c8f3 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -11,7 +11,7 @@ import '../channels/channel_management_provider.dart'; import '../channels/message_content.dart'; import '../profile/user_cache_provider.dart'; import '../profile/user_profile_sheet.dart'; -import 'note_composer.dart'; +import 'compose_note_page.dart'; import 'pulse_actions.dart'; import 'pulse_models.dart'; @@ -37,7 +37,6 @@ class NoteCard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final showReply = useState(false); final pendingUpvote = useState(null); final pubkey = note.pubkey.toLowerCase(); final profile = @@ -48,15 +47,18 @@ class NoteCard extends HookConsumerWidget { pendingUpvote.value ?? reaction.reactedByCurrentUser; final effectiveCount = _effectiveCount(reaction, pendingUpvote.value); - return Container( - padding: const EdgeInsets.all(Grid.twelve), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.5), - ), - ), + // Clear the optimistic flag only once the refetched server state matches + // it, so the count never flickers back through the stale value mid-refetch. + useEffect(() { + if (pendingUpvote.value != null && + reaction.reactedByCurrentUser == pendingUpvote.value) { + pendingUpvote.value = null; + } + return null; + }, [reaction.reactedByCurrentUser]); + + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.twelve), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -149,11 +151,10 @@ class NoteCard extends HookConsumerWidget { children: [ _ActionButton( icon: effectiveUpvoted - ? LucideIcons.heart - : LucideIcons.heart, - label: effectiveCount > 0 ? '$effectiveCount' : 'Like', + ? Icons.favorite + : Icons.favorite_border, + label: effectiveCount > 0 ? '$effectiveCount' : null, color: effectiveUpvoted ? Colors.redAccent : null, - filled: effectiveUpvoted, onTap: () async { final next = !effectiveUpvoted; pendingUpvote.value = next; @@ -165,19 +166,22 @@ class NoteCard extends HookConsumerWidget { reactionEventId: reaction.currentUserReactionId, ); onReactionChanged?.call(); - } finally { + } catch (_) { + // Revert the optimistic state on failure. pendingUpvote.value = null; } }, ), _ActionButton( icon: LucideIcons.messageCircle, - label: 'Reply', - onTap: () => showReply.value = !showReply.value, + onTap: () => Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => ComposeNotePage(replyTo: note), + ), + ), ), _ActionButton( icon: LucideIcons.share, - label: 'Share', onTap: () { Clipboard.setData(ClipboardData(text: _shareUri(note))); ScaffoldMessenger.of(context).showSnackBar( @@ -187,7 +191,6 @@ class NoteCard extends HookConsumerWidget { ), _ActionButton( icon: LucideIcons.mail, - label: 'DM', onTap: () async { final channel = await ref .read(channelActionsProvider) @@ -202,14 +205,6 @@ class NoteCard extends HookConsumerWidget { ), ], ), - if (showReply.value) ...[ - const SizedBox(height: Grid.xxs), - NoteComposer( - replyTo: note, - hintText: 'Reply to $displayName', - onSent: () => showReply.value = false, - ), - ], ], ), ), @@ -253,46 +248,43 @@ class _FollowButton extends StatelessWidget { class _ActionButton extends StatelessWidget { final IconData icon; - final String label; + final String? label; final VoidCallback onTap; final Color? color; - final bool filled; const _ActionButton({ required this.icon, - required this.label, required this.onTap, + this.label, this.color, - this.filled = false, }); @override Widget build(BuildContext context) { final effectiveColor = color ?? context.colors.onSurfaceVariant; - return Expanded( - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(Radii.md), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: Grid.half), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 16, color: effectiveColor, fill: filled ? 1 : 0), + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(Radii.md), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.half, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 16, color: effectiveColor), + if (label != null) ...[ const SizedBox(width: 3), - Flexible( - child: Text( - label, - overflow: TextOverflow.ellipsis, - style: context.textTheme.labelSmall?.copyWith( - color: effectiveColor, - fontWeight: FontWeight.w600, - ), + Text( + label!, + style: context.textTheme.labelSmall?.copyWith( + color: effectiveColor, + fontWeight: FontWeight.w600, ), ), ], - ), + ], ), ), ); diff --git a/mobile/lib/features/pulse/note_composer.dart b/mobile/lib/features/pulse/note_composer.dart deleted file mode 100644 index 2509790155..0000000000 --- a/mobile/lib/features/pulse/note_composer.dart +++ /dev/null @@ -1,112 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:lucide_icons_flutter/lucide_icons.dart'; - -import '../../shared/theme/theme.dart'; -import '../profile/profile_provider.dart'; -import 'pulse_actions.dart'; -import 'pulse_models.dart'; - -class NoteComposer extends HookConsumerWidget { - final UserNote? replyTo; - final VoidCallback? onSent; - final String hintText; - - const NoteComposer({ - super.key, - this.replyTo, - this.onSent, - this.hintText = 'What’s on your mind?', - }); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final controller = useTextEditingController(); - final isSending = useState(false); - final hasText = useListenableSelector( - controller, - () => controller.text.trim().isNotEmpty, - ); - final profile = ref.watch(profileProvider).asData?.value; - - return Container( - padding: const EdgeInsets.all(Grid.xs), - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues(alpha: 0.82), - borderRadius: BorderRadius.circular(Radii.lg), - border: Border.all( - color: context.colors.outlineVariant.withValues(alpha: 0.55), - ), - ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CircleAvatar( - radius: 16, - backgroundColor: context.colors.primaryContainer, - backgroundImage: profile?.avatarUrl != null - ? NetworkImage(profile!.avatarUrl!) - : null, - child: profile?.avatarUrl == null - ? Text( - profile?.initial ?? '?', - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ) - : null, - ), - const SizedBox(width: Grid.xs), - Expanded( - child: TextField( - controller: controller, - minLines: 1, - maxLines: 5, - textInputAction: TextInputAction.newline, - decoration: InputDecoration( - hintText: hintText, - border: InputBorder.none, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: Grid.half), - ), - ), - ), - const SizedBox(width: Grid.half), - SizedBox( - width: 38, - height: 38, - child: FilledButton( - onPressed: hasText && !isSending.value - ? () async { - isSending.value = true; - try { - await publishNote( - ref, - content: controller.text, - replyTo: replyTo, - ); - controller.clear(); - onSent?.call(); - } finally { - isSending.value = false; - } - } - : null, - style: FilledButton.styleFrom(padding: EdgeInsets.zero), - child: isSending.value - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(LucideIcons.send, size: 16), - ), - ), - ], - ), - ); - } -} diff --git a/mobile/lib/features/pulse/pulse_page.dart b/mobile/lib/features/pulse/pulse_page.dart index b9512f9f97..87ffe866d1 100644 --- a/mobile/lib/features/pulse/pulse_page.dart +++ b/mobile/lib/features/pulse/pulse_page.dart @@ -5,28 +5,20 @@ import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/relay/relay.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/filter_chip_bar.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import 'agent_activity_card.dart'; +import 'compose_note_page.dart'; import 'note_card.dart'; -import 'note_composer.dart'; import 'pulse_models.dart'; import 'pulse_provider.dart'; -import 'pulse_tab_bar.dart'; enum PulseTab { everyone, following, liked, agents, mine } class PulsePage extends HookConsumerWidget { const PulsePage({super.key}); - static const _tabs = [ - PulseTabSpec(id: 'everyone', label: 'Everyone', icon: LucideIcons.radio), - PulseTabSpec(id: 'following', label: 'Following', icon: LucideIcons.users), - PulseTabSpec(id: 'liked', label: 'Liked', icon: LucideIcons.heart), - PulseTabSpec(id: 'agents', label: 'Agents', icon: LucideIcons.bot), - PulseTabSpec(id: 'mine', label: 'Mine', icon: LucideIcons.user), - ]; - @override Widget build(BuildContext context, WidgetRef ref) { final active = useState(PulseTab.everyone); @@ -63,25 +55,48 @@ class PulsePage extends HookConsumerWidget { return FrostedScaffold( resizeToAvoidBottomInset: true, appBar: const FrostedAppBar(title: Text('Pulse')), + floatingActionButton: FloatingActionButton( + heroTag: 'pulse-compose-fab', + onPressed: () => Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const ComposeNotePage()), + ), + tooltip: 'New note', + shape: const CircleBorder(), + child: const Icon(LucideIcons.plus), + ), body: Column( children: [ SizedBox(height: frostedAppBarHeight(context)), - PulseTabBar( - tabs: _tabs, - selected: active.value.name, - onSelected: (id) => active.value = PulseTab.values.firstWhere( - (tab) => tab.name == id, - orElse: () => PulseTab.everyone, - ), - ), - Padding( - padding: const EdgeInsets.fromLTRB( - Grid.xs, - Grid.xxs, - Grid.xs, - Grid.xxs, - ), - child: const NoteComposer(), + FilterChipBar( + selected: active.value, + onSelected: (tab) => active.value = tab, + items: const [ + FilterChipItem( + id: PulseTab.everyone, + label: 'Everyone', + icon: LucideIcons.radio, + ), + FilterChipItem( + id: PulseTab.following, + label: 'Following', + icon: LucideIcons.users, + ), + FilterChipItem( + id: PulseTab.liked, + label: 'Liked', + icon: LucideIcons.heart, + ), + FilterChipItem( + id: PulseTab.agents, + label: 'Agents', + icon: LucideIcons.bot, + ), + FilterChipItem( + id: PulseTab.mine, + label: 'Mine', + icon: LucideIcons.user, + ), + ], ), Expanded( child: RefreshIndicator( @@ -164,7 +179,7 @@ class _PulseBody extends ConsumerWidget { Grid.xs, ), itemCount: groups.length, - separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs), + separatorBuilder: (_, _) => const _NoteDivider(), itemBuilder: (context, index) => AgentActivityCard( group: groups[index], reactions: reactions, @@ -181,7 +196,7 @@ class _PulseBody extends ConsumerWidget { Grid.xs, ), itemCount: notes.length, - separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs), + separatorBuilder: (_, _) => const _NoteDivider(), itemBuilder: (context, index) { final note = notes[index]; return NoteCard( @@ -274,16 +289,34 @@ class _TimelineSkeleton extends StatelessWidget { @override Widget build(BuildContext context) { return ListView.separated( - padding: const EdgeInsets.all(Grid.xs), + padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xxs, Grid.xs, Grid.xs), itemCount: 5, - separatorBuilder: (_, _) => const SizedBox(height: Grid.xxs), - itemBuilder: (_, _) => Container( - height: 112, - decoration: BoxDecoration( - color: context.colors.surfaceContainerHighest.withValues(alpha: 0.55), - borderRadius: BorderRadius.circular(Radii.lg), + separatorBuilder: (_, _) => const _NoteDivider(), + itemBuilder: (_, _) => Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.twelve), + child: Container( + height: 64, + decoration: BoxDecoration( + color: context.colors.surfaceContainerHighest.withValues( + alpha: 0.55, + ), + borderRadius: BorderRadius.circular(Radii.md), + ), ), ), ); } } + +class _NoteDivider extends StatelessWidget { + const _NoteDivider(); + + @override + Widget build(BuildContext context) { + return Divider( + height: 1, + thickness: 1, + color: context.colors.outlineVariant.withValues(alpha: 0.5), + ); + } +} diff --git a/mobile/lib/features/pulse/pulse_tab_bar.dart b/mobile/lib/features/pulse/pulse_tab_bar.dart deleted file mode 100644 index 7025babe43..0000000000 --- a/mobile/lib/features/pulse/pulse_tab_bar.dart +++ /dev/null @@ -1,89 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../../shared/theme/theme.dart'; - -class PulseTabSpec { - final String id; - final String label; - final IconData icon; - - const PulseTabSpec({ - required this.id, - required this.label, - required this.icon, - }); -} - -class PulseTabBar extends StatelessWidget { - final List tabs; - final String selected; - final ValueChanged onSelected; - - const PulseTabBar({ - super.key, - required this.tabs, - required this.selected, - required this.onSelected, - }); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB( - Grid.xs, - Grid.twelve, - Grid.xs, - Grid.xxs, - ), - child: Row( - children: [ - for (final tab in tabs) ...[ - if (tab != tabs.first) const SizedBox(width: Grid.xxs), - GestureDetector( - onTap: () => onSelected(tab.id), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.half + 2, - ), - decoration: BoxDecoration( - color: selected == tab.id - ? context.colors.primary - : context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: selected == tab.id - ? null - : Border.all(color: context.colors.outlineVariant), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - tab.icon, - size: 14, - color: selected == tab.id - ? context.colors.onPrimary - : context.colors.onSurfaceVariant, - ), - const SizedBox(width: Grid.half), - Text( - tab.label, - style: context.textTheme.labelMedium?.copyWith( - color: selected == tab.id - ? context.colors.onPrimary - : context.colors.onSurfaceVariant, - fontWeight: FontWeight.w600, - ), - ), - ], - ), - ), - ), - ], - ], - ), - ); - } -} diff --git a/mobile/lib/features/search/search_page.dart b/mobile/lib/features/search/search_page.dart index 9818d128b7..6382ad6f1a 100644 --- a/mobile/lib/features/search/search_page.dart +++ b/mobile/lib/features/search/search_page.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:lucide_icons_flutter/lucide_icons.dart'; import '../../shared/theme/theme.dart'; +import '../../shared/widgets/filter_chip_bar.dart'; import '../../shared/widgets/frosted_app_bar.dart'; import '../../shared/widgets/frosted_scaffold.dart'; import '../channels/channel.dart'; @@ -81,9 +82,13 @@ class SearchPage extends HookConsumerWidget { body: Column( children: [ SizedBox(height: frostedAppBarHeight(context)), - _FilterChips( - active: activeFilter.value, - onChanged: (f) => activeFilter.value = f, + FilterChipBar<_SearchFilter>( + selected: activeFilter.value, + onSelected: (f) => activeFilter.value = f, + items: [ + for (final f in _SearchFilter.values) + FilterChipItem(id: f, label: f.label), + ], ), Expanded( child: _SearchBody( @@ -98,56 +103,6 @@ class SearchPage extends HookConsumerWidget { } } -class _FilterChips extends StatelessWidget { - final _SearchFilter active; - final ValueChanged<_SearchFilter> onChanged; - - const _FilterChips({required this.active, required this.onChanged}); - - @override - Widget build(BuildContext context) { - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.half, Grid.xs, Grid.xxs), - child: Row( - children: [ - for (final filter in _SearchFilter.values) ...[ - if (filter != _SearchFilter.values.first) - const SizedBox(width: Grid.xxs), - GestureDetector( - onTap: () => onChanged(filter), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: Grid.twelve, - vertical: Grid.half + 2, - ), - decoration: BoxDecoration( - color: active == filter - ? context.colors.primary - : context.colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(Radii.lg), - border: active == filter - ? null - : Border.all(color: context.colors.outlineVariant), - ), - child: Text( - filter.label, - style: context.textTheme.labelMedium?.copyWith( - color: active == filter - ? context.colors.onPrimary - : context.colors.onSurfaceVariant, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ], - ], - ), - ); - } -} - class _SearchBody extends ConsumerWidget { final SearchState state; final _SearchFilter filter; diff --git a/mobile/lib/features/settings/settings_page.dart b/mobile/lib/features/settings/settings_page.dart index 6d50dd3360..4f0ed942a1 100644 --- a/mobile/lib/features/settings/settings_page.dart +++ b/mobile/lib/features/settings/settings_page.dart @@ -146,7 +146,7 @@ class SettingsPage extends HookConsumerWidget { _AccentSwatch( color: context.colors.brightness == Brightness.light ? const Color(0xFF8839EF) - : const Color(0xFFC6A0F6), + : const Color(0xFFA875F5), label: 'Mauve', selected: selectedAccent == defaultAccentIndex, onTap: () => ref diff --git a/mobile/lib/shared/theme/accent_colors.dart b/mobile/lib/shared/theme/accent_colors.dart index cab417dfc3..9a2e740792 100644 --- a/mobile/lib/shared/theme/accent_colors.dart +++ b/mobile/lib/shared/theme/accent_colors.dart @@ -34,7 +34,6 @@ const accentColors = [ light: Color(0xFF6366F1), dark: Color(0xFF818CF8), ), - AccentColor(name: 'Lilac', light: Color(0xFFC0A2F1), dark: Color(0xFFC0A2F1)), ]; /// Default: Catppuccin Mauve (the current primary). diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 05579deb16..0712e97097 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -244,6 +244,14 @@ class AppTheme { // Chips: desktop uses rounded-sm (6px) chipTheme: ChipThemeData( labelStyle: textTheme.bodySmall?.copyWith(color: scheme.secondary), + // M3 resolves the chip container via `color` (WidgetStateProperty); + // `selectedColor` is the legacy M2 path and is ignored here. Selected + // filter chips (Pulse/Search/Activity tabs) use the accent. + color: WidgetStateProperty.resolveWith((states) { + if (states.contains(WidgetState.selected)) return scheme.primary; + return scheme.surfaceContainerHighest; + }), + checkmarkColor: scheme.onPrimary, shape: RoundedRectangleBorder( side: BorderSide.none, borderRadius: BorderRadius.circular(Radii.sm), diff --git a/mobile/lib/shared/theme/color_scheme.dart b/mobile/lib/shared/theme/color_scheme.dart index fae051f339..f9f7c80041 100644 --- a/mobile/lib/shared/theme/color_scheme.dart +++ b/mobile/lib/shared/theme/color_scheme.dart @@ -28,7 +28,7 @@ const lightColorScheme = ColorScheme( outlineVariant: Color(0xFFCCD0DA), // Latte Surface1 inverseSurface: Color(0xFF4C4F69), // Latte Text onInverseSurface: Color(0xFFEFF1F5), // Latte Base - inversePrimary: Color(0xFFC6A0F6), // Macchiato Mauve + inversePrimary: Color(0xFFA875F5), // Macchiato Mauve (saturated) shadow: Color(0xFF000000), scrim: Color(0xFF000000), surfaceTint: Color(0xFF8839EF), // Latte Mauve @@ -38,7 +38,7 @@ const lightColorScheme = ColorScheme( // Catppuccin Macchiato (mauve accent) — matches Sprout desktop dark theme const darkColorScheme = ColorScheme( brightness: Brightness.dark, - primary: Color(0xFFC6A0F6), // Macchiato Mauve + primary: Color(0xFFA875F5), // Macchiato Mauve (saturated) onPrimary: Color(0xFF24273A), // Macchiato Base primaryContainer: Color(0xFF363A4F), // Macchiato Surface0 onPrimaryContainer: Color(0xFFCAD3F5), // Macchiato Text @@ -64,7 +64,7 @@ const darkColorScheme = ColorScheme( inversePrimary: Color(0xFF8839EF), // Latte Mauve shadow: Color(0xFF000000), scrim: Color(0xFF000000), - surfaceTint: Color(0xFFC6A0F6), // Macchiato Mauve + surfaceTint: Color(0xFFA875F5), // Macchiato Mauve (saturated) surfaceContainerHighest: Color(0xFF1E2030), // Macchiato Mantle ); diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart new file mode 100644 index 0000000000..a4b25e902d --- /dev/null +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +import '../theme/theme.dart'; + +/// A single entry in a [FilterChipBar]. +class FilterChipItem { + /// Value identifying this chip; compared against the bar's `selected`. + final T id; + + /// Visible label. + final String label; + + /// Optional leading icon. + final IconData? icon; + + /// Optional count appended to the label as " (n)". + final int? count; + + const FilterChipItem({ + required this.id, + required this.label, + this.icon, + this.count, + }); +} + +/// A horizontally-scrolling row of Material [FilterChip]s. +/// +/// This is the single shared filter/segment selector used across Pulse, +/// Search, and Activity. It relies on the app's `chipTheme` for styling so +/// every screen looks identical — do not hand-roll chip rows elsewhere. +class FilterChipBar extends StatelessWidget { + final List> items; + final T selected; + final ValueChanged onSelected; + + const FilterChipBar({ + super.key, + required this.items, + required this.selected, + required this.onSelected, + }); + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric( + horizontal: Grid.xs, + vertical: Grid.xxs, + ), + child: Row( + children: [ + for (final item in items) ...[ + if (item != items.first) const SizedBox(width: Grid.xxs), + _chip(context, item), + ], + ], + ), + ); + } + + Widget _chip(BuildContext context, FilterChipItem item) { + final isSelected = selected == item.id; + final text = item.count != null + ? '${item.label} (${item.count})' + : item.label; + final fg = isSelected + ? context.colors.onPrimary + : context.colors.onSurfaceVariant; + final labelStyle = context.textTheme.labelSmall?.copyWith(color: fg); + return FilterChip( + selected: isSelected, + showCheckmark: false, + label: item.icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(item.icon, size: 14, color: fg), + const SizedBox(width: Grid.half), + Text(text, style: labelStyle), + ], + ) + : Text(text, style: labelStyle), + onSelected: (_) => onSelected(item.id), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} diff --git a/mobile/test/features/pulse/compose_note_page_test.dart b/mobile/test/features/pulse/compose_note_page_test.dart new file mode 100644 index 0000000000..042550ef99 --- /dev/null +++ b/mobile/test/features/pulse/compose_note_page_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:sprout_mobile/features/profile/user_cache_provider.dart'; +import 'package:sprout_mobile/features/profile/user_profile.dart'; +import 'package:sprout_mobile/features/pulse/compose_note_page.dart'; +import 'package:sprout_mobile/features/pulse/pulse_models.dart'; +import 'package:sprout_mobile/shared/theme/theme.dart'; + +class _FakeUserCacheNotifier extends UserCacheNotifier { + final Map _users; + _FakeUserCacheNotifier(this._users); + + @override + Map build() => _users; +} + +void main() { + final replyNote = UserNote( + id: 'note1', + pubkey: 'alice_pk', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 120, + content: 'The original note being replied to', + tags: const [], + ); + + Widget buildTestable(Widget home) { + return ProviderScope( + overrides: [ + userCacheProvider.overrideWith( + () => _FakeUserCacheNotifier({ + 'alice_pk': const UserProfile( + pubkey: 'alice_pk', + displayName: 'Alice', + ), + }), + ), + ], + child: MaterialApp(theme: AppTheme.light(), home: home), + ); + } + + testWidgets('reply mode shows a rich preview of the replied-to note', ( + tester, + ) async { + await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: replyNote))); + await tester.pump(); + + expect(find.text('Replying to Alice'), findsOneWidget); + expect(find.text('Alice'), findsOneWidget); // author name in the row + expect(find.textContaining('original note being replied to'), findsWidgets); + expect(find.text('Reply'), findsOneWidget); // action button label + }); + + testWidgets('new-note mode shows no reply preview', (tester) async { + await tester.pumpWidget(buildTestable(const ComposeNotePage())); + await tester.pump(); + + expect(find.textContaining('Replying to'), findsNothing); + expect(find.text('Post'), findsOneWidget); + }); + + testWidgets('reply preview stays compact for a short note', (tester) async { + await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: replyNote))); + await tester.pump(); + + // The preview content sits just above the divider; for a one-line note + // the gap between the content text and the divider must be small (this + // guards the earlier "preview row way too tall" regression). + final contentBottom = tester + .getRect(find.text('The original note being replied to')) + .bottom; + final divider = find.byType(Divider); + expect(divider, findsOneWidget); + final dividerTop = tester.getRect(divider).top; + expect( + dividerTop - contentBottom, + lessThan(24), + reason: 'reply preview should hug its content, not reserve tall space', + ); + }); + + testWidgets('reply preview with an image note clips to a bounded height', ( + tester, + ) async { + final imageNote = UserNote( + id: 'note2', + pubkey: 'alice_pk', + createdAt: DateTime.now().millisecondsSinceEpoch ~/ 1000 - 60, + content: '#sprout\n![image](https://example.com/big.png)', + tags: const [ + [ + 'imeta', + 'url https://example.com/big.png', + 'm image/png', + 'dim 800x1600', + ], + ], + ); + await tester.pumpWidget(buildTestable(ComposeNotePage(replyTo: imageNote))); + await tester.pump(); + + // Renders the rich content (not the raw "![image](...)" markdown). + expect(find.textContaining('![image]'), findsNothing); + // The whole reply context stays bounded: divider sits within a screen of + // the "Replying to" label (guards a tall image blowing up the page). + final labelTop = tester.getRect(find.text('Replying to Alice')).top; + final dividerTop = tester.getRect(find.byType(Divider)).top; + expect(dividerTop - labelTop, lessThan(220)); + }); +} diff --git a/mobile/test/shared/widgets/filter_chip_bar_test.dart b/mobile/test/shared/widgets/filter_chip_bar_test.dart new file mode 100644 index 0000000000..2d185aebbc --- /dev/null +++ b/mobile/test/shared/widgets/filter_chip_bar_test.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sprout_mobile/shared/theme/theme.dart'; +import 'package:sprout_mobile/shared/widgets/filter_chip_bar.dart'; + +void main() { + testWidgets('selected chip resolves the accent (scheme.primary) as fill', ( + tester, + ) async { + final accented = applyAccent(darkColorScheme, 0); // Blue accent + final accent = accented.primary; + + await tester.pumpWidget( + MaterialApp( + theme: AppTheme.dark(colorScheme: accented), + home: Scaffold( + body: FilterChipBar( + selected: 0, + onSelected: (_) {}, + items: const [ + FilterChipItem(id: 0, label: 'Everyone'), + FilterChipItem(id: 1, label: 'Following'), + ], + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // The chip's container color comes from ChipThemeData.color resolved for + // the selected state. Resolve it the same way the chip does and assert + // it's the accent. + final chipTheme = ChipTheme.of( + tester.element(find.widgetWithText(RawChip, 'Everyone')), + ); + final resolved = chipTheme.color?.resolve({WidgetState.selected}); + expect(resolved, accent); + }); +}