feat(mobile): Pulse polish — flat feed, compose page, shared filter chips, like + accent fixes (#796)

Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Wes
2026-05-29 14:33:45 -07:00
committed by GitHub
co-authored by Brain
parent 10f37e4cab
commit b820420909
18 changed files with 796 additions and 602 deletions
+29 -113
View File
@@ -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
// ---------------------------------------------------------------------------
@@ -161,6 +161,7 @@ class ChannelsPage extends HookConsumerWidget {
],
),
floatingActionButton: FloatingActionButton(
heroTag: 'channels-fab',
onPressed: openQuickActions,
tooltip: 'Create or start conversation',
shape: const CircleBorder(),
@@ -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(),
+2 -2
View File
@@ -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(
+131 -141
View File
@@ -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,
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,
),
),
),
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,
),
],
),
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),
),
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,
),
),
],
),
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),
),
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,
),
),
],
),
),
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.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,
),
),
],
],
),
)
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,
),
),
),
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 (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,
),
),
),
],
),
],
);
}
}
@@ -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<void> 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'
: 'Whats 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;
}
+44 -52
View File
@@ -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<bool?>(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<void>(
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,
),
),
],
),
],
),
),
);
@@ -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 = 'Whats 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),
),
),
],
),
);
}
}
+68 -35
View File
@@ -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<void>(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<PulseTab>(
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),
);
}
}
@@ -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<PulseTabSpec> tabs;
final String selected;
final ValueChanged<String> 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,
),
),
],
),
),
),
],
],
),
);
}
}
+8 -53
View File
@@ -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;
@@ -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
@@ -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).
+8
View File
@@ -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),
+3 -3
View File
@@ -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
);
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import '../theme/theme.dart';
/// A single entry in a [FilterChipBar].
class FilterChipItem<T> {
/// 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<T> extends StatelessWidget {
final List<FilterChipItem<T>> items;
final T selected;
final ValueChanged<T> 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<T> 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,
);
}
}
@@ -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<String, UserProfile> _users;
_FakeUserCacheNotifier(this._users);
@override
Map<String, UserProfile> 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));
});
}
@@ -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<int>(
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);
});
}