From d5b589c86a28d7ec1d6e0689c9f5d1ef49e8da1b Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 11:40:24 -0700 Subject: [PATCH 1/8] =?UTF-8?q?feat(mobile):=20polish=20Pulse=20=E2=80=94?= =?UTF-8?q?=20flat=20list,=20compose=20page,=20shared=20filter=20chips?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Flatten Pulse timeline (dividers instead of boxed cards) in agent_activity_card and note_card. - Replace the inline note composer with a full-screen ComposeNotePage reached via a pencil/+ FAB and the note reply button; retire note_composer.dart. - Make the Pulse FAB round (+) to match Home; switch the Pulse bottom-nav icon to activity to match desktop. - Extract a shared FilterChipBar widget and migrate Activity, Pulse, and Search onto it; delete the two hand-rolled chip rows (PulseTabBar and Search's _FilterChips). Fixes the glow under the Pulse title and unifies the chip style across tabs. flutter analyze clean; full suite 336 pass / 1 skipped. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../lib/features/activity/activity_page.dart | 142 ++-------- mobile/lib/features/home/home_page.dart | 4 +- .../features/pulse/agent_activity_card.dart | 264 +++++++++--------- .../lib/features/pulse/compose_note_page.dart | 197 +++++++++++++ mobile/lib/features/pulse/note_card.dart | 76 ++--- mobile/lib/features/pulse/note_composer.dart | 112 -------- mobile/lib/features/pulse/pulse_page.dart | 102 ++++--- mobile/lib/features/pulse/pulse_tab_bar.dart | 89 ------ mobile/lib/features/search/search_page.dart | 61 +--- .../lib/shared/widgets/filter_chip_bar.dart | 85 ++++++ 10 files changed, 545 insertions(+), 587 deletions(-) create mode 100644 mobile/lib/features/pulse/compose_note_page.dart delete mode 100644 mobile/lib/features/pulse/note_composer.dart delete mode 100644 mobile/lib/features/pulse/pulse_tab_bar.dart create mode 100644 mobile/lib/shared/widgets/filter_chip_bar.dart 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/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..fcd848c65a --- /dev/null +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -0,0 +1,197 @@ +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 '../profile/profile_provider.dart'; +import '../profile/user_cache_provider.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, + ), + ), + ), + ), + ], + ), + ), + ), + ], + ), + ), + ); + } +} + +/// Compact preview of the note being replied to, shown above the editor. +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 Container( + width: double.infinity, + 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.half), + Text( + note.content, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + const SizedBox(height: Grid.xxs), + 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..4c3a711bb7 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,8 @@ 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), - ), - ), + return Padding( + padding: const EdgeInsets.symmetric(vertical: Grid.twelve), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -151,7 +143,7 @@ class NoteCard extends HookConsumerWidget { icon: effectiveUpvoted ? LucideIcons.heart : LucideIcons.heart, - label: effectiveCount > 0 ? '$effectiveCount' : 'Like', + label: effectiveCount > 0 ? '$effectiveCount' : null, color: effectiveUpvoted ? Colors.redAccent : null, filled: effectiveUpvoted, onTap: () async { @@ -172,12 +164,14 @@ class NoteCard extends HookConsumerWidget { ), _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 +181,6 @@ class NoteCard extends HookConsumerWidget { ), _ActionButton( icon: LucideIcons.mail, - label: 'DM', onTap: () async { final channel = await ref .read(channelActionsProvider) @@ -202,14 +195,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,15 +238,15 @@ 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, }); @@ -269,30 +254,29 @@ class _ActionButton extends StatelessWidget { @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, fill: filled ? 1 : 0), + 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..c8f1f08bf8 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,47 @@ class PulsePage extends HookConsumerWidget { return FrostedScaffold( resizeToAvoidBottomInset: true, appBar: const FrostedAppBar(title: Text('Pulse')), + floatingActionButton: FloatingActionButton( + 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 +178,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 +195,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 +288,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/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart new file mode 100644 index 0000000000..cb245e2484 --- /dev/null +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -0,0 +1,85 @@ +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 text = item.count != null + ? '${item.label} (${item.count})' + : item.label; + return FilterChip( + selected: selected == item.id, + showCheckmark: false, + label: item.icon != null + ? Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(item.icon, size: 14), + const SizedBox(width: Grid.half), + Text(text, style: context.textTheme.labelSmall), + ], + ) + : Text(text, style: context.textTheme.labelSmall), + onSelected: (_) => onSelected(item.id), + visualDensity: VisualDensity.compact, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ); + } +} From 8463d813876f267172b3880ad9dd73536df5ae24 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 11:45:32 -0700 Subject: [PATCH 2/8] fix(mobile): unique FAB hero tags + rich reply preview on compose page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Give the Pulse, Channels, and Forum FloatingActionButtons explicit heroTags. HomePage keeps tabs alive in an IndexedStack, so the Channels and Pulse FABs coexisted with Flutter's default hero tag and collided ("multiple heroes share the same tag") on any Hero flight — e.g. pushing the compose page or a reply. - Render the replied-to note in ComposeNotePage as a read-only list-style row (avatar + name + relative time + formatted MessageContent) instead of raw text, capped at 140px height with clipping. flutter analyze clean; full suite 336 pass / 1 skipped. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../lib/features/channels/channels_page.dart | 1 + .../lib/features/forum/forum_posts_view.dart | 1 + .../lib/features/pulse/compose_note_page.dart | 71 ++++++++++++++++--- mobile/lib/features/pulse/pulse_page.dart | 1 + 4 files changed, 64 insertions(+), 10 deletions(-) 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/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index fcd848c65a..b72bd27b3d 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -5,8 +5,10 @@ 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'; @@ -147,7 +149,9 @@ class ComposeNotePage extends HookConsumerWidget { } } -/// Compact preview of the note being replied to, shown above the editor. +/// 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; @@ -161,8 +165,7 @@ class _ReplyContext extends ConsumerWidget { ref.read(userCacheProvider.notifier).get(pubkey); final displayName = profile?.label ?? _shortPubkey(pubkey); - return Container( - width: double.infinity, + return Padding( padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xs, Grid.xs, 0), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -174,15 +177,63 @@ class _ReplyContext extends ConsumerWidget { ), ), const SizedBox(height: Grid.half), - Text( - note.content, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + // Mirror the NoteCard list-row layout, read-only. + ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 140), + child: ClipRect( + 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 ?? 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), + MessageContent(content: note.content, tags: note.tags), + ], + ), + ), + ], + ), ), ), - const SizedBox(height: Grid.xxs), + const SizedBox(height: Grid.xs), Divider( height: 1, color: context.colors.outlineVariant.withValues(alpha: 0.5), diff --git a/mobile/lib/features/pulse/pulse_page.dart b/mobile/lib/features/pulse/pulse_page.dart index c8f1f08bf8..87ffe866d1 100644 --- a/mobile/lib/features/pulse/pulse_page.dart +++ b/mobile/lib/features/pulse/pulse_page.dart @@ -56,6 +56,7 @@ class PulsePage extends HookConsumerWidget { resizeToAvoidBottomInset: true, appBar: const FrostedAppBar(title: Text('Pulse')), floatingActionButton: FloatingActionButton( + heroTag: 'pulse-compose-fab', onPressed: () => Navigator.of(context).push( MaterialPageRoute(builder: (_) => const ComposeNotePage()), ), From af13a94966af14107c240be1a3498a33a00cd9a7 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 11:46:54 -0700 Subject: [PATCH 3/8] test(mobile): cover ComposeNotePage reply preview + new-note states Widget tests asserting the reply-mode preview renders the replied-to note (author name, "Replying to X", content) and pumps without throwing, and that new-note mode shows no preview. Guards the rich reply preview. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../pulse/compose_note_page_test.dart | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 mobile/test/features/pulse/compose_note_page_test.dart 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..2db4096b1b --- /dev/null +++ b/mobile/test/features/pulse/compose_note_page_test.dart @@ -0,0 +1,62 @@ +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); + }); +} From 28e5886170a30a077ec31bfbc65d945f04302a4b Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 11:50:43 -0700 Subject: [PATCH 4/8] fix(mobile): make reply preview compact and add spacing - The reply preview reserved a fixed ~140px height (ConstrainedBox), so even a one-line note rendered tall with a big gap before the divider. Render the row shrink-wrapped and cap the quoted content at 3 lines with ellipsis instead of the height-greedy markdown widget. - Add breathing room between the "Replying to " label and the row. - Add a widget test asserting the preview hugs its content for short notes (guards the "too tall" regression). Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../lib/features/pulse/compose_note_page.dart | 105 +++++++++--------- .../pulse/compose_note_page_test.dart | 20 ++++ 2 files changed, 74 insertions(+), 51 deletions(-) diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index b72bd27b3d..3bbeaf1e0e 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -5,7 +5,6 @@ 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'; @@ -176,62 +175,66 @@ class _ReplyContext extends ConsumerWidget { color: context.colors.onSurfaceVariant, ), ), - const SizedBox(height: Grid.half), - // Mirror the NoteCard list-row layout, read-only. - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 140), - child: ClipRect( - 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 ?? displayName[0]).toUpperCase(), - style: context.textTheme.labelMedium?.copyWith( - color: context.colors.onPrimaryContainer, - ), - ) - : null, - ), - const SizedBox(width: Grid.xs), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + 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: [ - 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, - ), + 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), - MessageContent(content: note.content, tags: note.tags), ], ), - ), - ], + const SizedBox(height: Grid.half), + Text( + note.content, + maxLines: 3, + overflow: TextOverflow.ellipsis, + style: context.textTheme.bodyMedium?.copyWith( + color: context.colors.onSurface, + ), + ), + ], + ), ), - ), + ], ), const SizedBox(height: Grid.xs), Divider( diff --git a/mobile/test/features/pulse/compose_note_page_test.dart b/mobile/test/features/pulse/compose_note_page_test.dart index 2db4096b1b..1eb5fd7a6b 100644 --- a/mobile/test/features/pulse/compose_note_page_test.dart +++ b/mobile/test/features/pulse/compose_note_page_test.dart @@ -59,4 +59,24 @@ void main() { 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', + ); + }); } From 43ae68442f35b783225cd75512f11455203eb59d Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 11:54:50 -0700 Subject: [PATCH 5/8] feat(mobile): render rich content (images/links) in reply preview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restore MessageContent in the compose reply preview so images, links and mentions render instead of raw markdown (e.g. "![image](https://…)"). Wrap it in a topLeft Align + ClipRect bounded to 132px so a tall image or long note is clipped rather than blowing up the page — MessageContent shrink-wraps for short content, so one-liners stay compact. Add a widget test asserting an image note renders rich content (no raw markdown) and the preview stays height-bounded. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- .../lib/features/pulse/compose_note_page.dart | 21 ++++++++++---- .../pulse/compose_note_page_test.dart | 29 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/pulse/compose_note_page.dart b/mobile/lib/features/pulse/compose_note_page.dart index 3bbeaf1e0e..8666272f9b 100644 --- a/mobile/lib/features/pulse/compose_note_page.dart +++ b/mobile/lib/features/pulse/compose_note_page.dart @@ -5,6 +5,7 @@ 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'; @@ -223,12 +224,20 @@ class _ReplyContext extends ConsumerWidget { ], ), const SizedBox(height: Grid.half), - Text( - note.content, - maxLines: 3, - overflow: TextOverflow.ellipsis, - style: context.textTheme.bodyMedium?.copyWith( - color: context.colors.onSurface, + // 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, + ), + ), ), ), ], diff --git a/mobile/test/features/pulse/compose_note_page_test.dart b/mobile/test/features/pulse/compose_note_page_test.dart index 1eb5fd7a6b..042550ef99 100644 --- a/mobile/test/features/pulse/compose_note_page_test.dart +++ b/mobile/test/features/pulse/compose_note_page_test.dart @@ -79,4 +79,33 @@ void main() { 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)); + }); } From ac2dc920c837e427cd55178fae36d8001b347a01 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 12:02:23 -0700 Subject: [PATCH 6/8] fix(mobile): filled like heart, no count flash, accent filter chips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Heart icon: Lucide is a stroke font and Icon.fill is a no-op for it, so the liked heart only ever showed a colored outline. Use Material's favorite/favorite_border pair so a liked note shows a filled red heart. - Like count flash: the optimistic flag was cleared in `finally` before the reactions provider refetched, so the count flickered 1 → (stale 0) → 1. Keep the optimistic value sticky and clear it via an effect only once the refetched server state matches it. - Filter chips: the shared chipTheme set no selectedColor, so selected tabs used Material's muted default. Use the accent (scheme.primary) for the selected chip with onPrimary label/icon, applied via chipTheme + FilterChipBar — Pulse, Search, and Activity all get the accent state. flutter analyze clean; full suite 340 pass / 1 skipped. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- mobile/lib/features/pulse/note_card.dart | 22 +++++++++++++------ mobile/lib/shared/theme/app_theme.dart | 7 ++++++ .../lib/shared/widgets/filter_chip_bar.dart | 13 +++++++---- 3 files changed, 31 insertions(+), 11 deletions(-) diff --git a/mobile/lib/features/pulse/note_card.dart b/mobile/lib/features/pulse/note_card.dart index 4c3a711bb7..ea8bd6c8f3 100644 --- a/mobile/lib/features/pulse/note_card.dart +++ b/mobile/lib/features/pulse/note_card.dart @@ -47,6 +47,16 @@ class NoteCard extends HookConsumerWidget { pendingUpvote.value ?? reaction.reactedByCurrentUser; final effectiveCount = _effectiveCount(reaction, pendingUpvote.value); + // 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( @@ -141,11 +151,10 @@ class NoteCard extends HookConsumerWidget { children: [ _ActionButton( icon: effectiveUpvoted - ? LucideIcons.heart - : LucideIcons.heart, + ? 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; @@ -157,7 +166,8 @@ class NoteCard extends HookConsumerWidget { reactionEventId: reaction.currentUserReactionId, ); onReactionChanged?.call(); - } finally { + } catch (_) { + // Revert the optimistic state on failure. pendingUpvote.value = null; } }, @@ -241,14 +251,12 @@ class _ActionButton extends StatelessWidget { final String? label; final VoidCallback onTap; final Color? color; - final bool filled; const _ActionButton({ required this.icon, required this.onTap, this.label, this.color, - this.filled = false, }); @override @@ -265,7 +273,7 @@ class _ActionButton extends StatelessWidget { child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 16, color: effectiveColor, fill: filled ? 1 : 0), + Icon(icon, size: 16, color: effectiveColor), if (label != null) ...[ const SizedBox(width: 3), Text( diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 05579deb16..4ddec79ab2 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -244,6 +244,13 @@ class AppTheme { // Chips: desktop uses rounded-sm (6px) chipTheme: ChipThemeData( labelStyle: textTheme.bodySmall?.copyWith(color: scheme.secondary), + // Selected filter chips (Pulse/Search/Activity tabs) use the accent. + selectedColor: scheme.primary, + secondarySelectedColor: scheme.primary, + secondaryLabelStyle: textTheme.bodySmall?.copyWith( + color: scheme.onPrimary, + ), + checkmarkColor: scheme.onPrimary, shape: RoundedRectangleBorder( side: BorderSide.none, borderRadius: BorderRadius.circular(Radii.sm), diff --git a/mobile/lib/shared/widgets/filter_chip_bar.dart b/mobile/lib/shared/widgets/filter_chip_bar.dart index cb245e2484..a4b25e902d 100644 --- a/mobile/lib/shared/widgets/filter_chip_bar.dart +++ b/mobile/lib/shared/widgets/filter_chip_bar.dart @@ -61,22 +61,27 @@ class FilterChipBar extends StatelessWidget { } 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: selected == item.id, + selected: isSelected, showCheckmark: false, label: item.icon != null ? Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(item.icon, size: 14), + Icon(item.icon, size: 14, color: fg), const SizedBox(width: Grid.half), - Text(text, style: context.textTheme.labelSmall), + Text(text, style: labelStyle), ], ) - : Text(text, style: context.textTheme.labelSmall), + : Text(text, style: labelStyle), onSelected: (_) => onSelected(item.id), visualDensity: VisualDensity.compact, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, From c84036d90c19151fb0f98b2909856f5514eeef63 Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 12:09:45 -0700 Subject: [PATCH 7/8] fix(mobile): selected filter chip uses accent via M3 color property MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous fix set ChipThemeData.selectedColor, which is the legacy Material-2 path that Material-3 FilterChips ignore — so selected chips fell back to the near-white default instead of the accent. Drive the chip container via the M3 `color` WidgetStateProperty (accent when selected, surfaceContainerHighest otherwise). The accent is scheme.primary, which already carries the user's chosen accent at runtime (same path the bottom nav uses). Add a widget test asserting the chip theme resolves the accent for the selected state under a non-default (Blue) accent. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- mobile/lib/shared/theme/app_theme.dart | 13 ++++--- .../shared/widgets/filter_chip_bar_test.dart | 39 +++++++++++++++++++ 2 files changed, 46 insertions(+), 6 deletions(-) create mode 100644 mobile/test/shared/widgets/filter_chip_bar_test.dart diff --git a/mobile/lib/shared/theme/app_theme.dart b/mobile/lib/shared/theme/app_theme.dart index 4ddec79ab2..0712e97097 100644 --- a/mobile/lib/shared/theme/app_theme.dart +++ b/mobile/lib/shared/theme/app_theme.dart @@ -244,12 +244,13 @@ class AppTheme { // Chips: desktop uses rounded-sm (6px) chipTheme: ChipThemeData( labelStyle: textTheme.bodySmall?.copyWith(color: scheme.secondary), - // Selected filter chips (Pulse/Search/Activity tabs) use the accent. - selectedColor: scheme.primary, - secondarySelectedColor: scheme.primary, - secondaryLabelStyle: textTheme.bodySmall?.copyWith( - color: scheme.onPrimary, - ), + // 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, 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); + }); +} From 18f160d662d619ec04e10d3b67572cb873715d6e Mon Sep 17 00:00:00 2001 From: Wes Date: Fri, 29 May 2026 14:09:01 -0700 Subject: [PATCH 8/8] fix(mobile): saturate default Mauve accent, drop duplicate Lilac MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dark-theme default accent (Macchiato Mauve #C6A0F6) was so desaturated it read as near-white — the default "accent" was indistinguishable from no accent. Bump the dark Mauve to a saturated #A875F5 (primary, surfaceTint, and the light scheme's inversePrimary), and update the Settings default swatch to match so preview == applied. Also remove the redundant "Lilac" accent (#C0A2F1), which was a near-duplicate of the old pale Mauve. Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co> Signed-off-by: Wes --- mobile/lib/features/settings/settings_page.dart | 2 +- mobile/lib/shared/theme/accent_colors.dart | 1 - mobile/lib/shared/theme/color_scheme.dart | 6 +++--- 3 files changed, 4 insertions(+), 5 deletions(-) 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/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 );