mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
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:
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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'
|
||||
: 'What’s on your mind?',
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
vertical: Grid.half,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A read-only preview of the note being replied to, laid out like a list
|
||||
/// row (avatar + name + time + content) but non-interactive. Capped in
|
||||
/// height so a long note doesn't push the editor off-screen.
|
||||
class _ReplyContext extends ConsumerWidget {
|
||||
final UserNote note;
|
||||
|
||||
const _ReplyContext({required this.note});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final pubkey = note.pubkey.toLowerCase();
|
||||
final profile =
|
||||
ref.watch(userCacheProvider.select((cache) => cache[pubkey])) ??
|
||||
ref.read(userCacheProvider.notifier).get(pubkey);
|
||||
final displayName = profile?.label ?? _shortPubkey(pubkey);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(Grid.xs, Grid.xs, Grid.xs, 0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Replying to $displayName',
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
// Mirror the NoteCard list-row layout, read-only. Shrink-wraps to
|
||||
// the content; long notes are capped to ~3 lines via the parent
|
||||
// page scroll + ellipsis on the content.
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 18,
|
||||
backgroundColor: context.colors.primaryContainer,
|
||||
backgroundImage: profile?.avatarUrl != null
|
||||
? NetworkImage(profile!.avatarUrl!)
|
||||
: null,
|
||||
child: profile?.avatarUrl == null
|
||||
? Text(
|
||||
(profile?.initial ?? displayName[0]).toUpperCase(),
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.colors.onPrimaryContainer,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
const SizedBox(width: Grid.xs),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayName,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: Grid.half),
|
||||
Text(
|
||||
formatPulseRelativeTime(note.createdAt),
|
||||
style: context.textTheme.labelSmall?.copyWith(
|
||||
color: context.colors.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.half),
|
||||
// Rich content (images, links, mentions) like the list
|
||||
// row, but clipped to a compact max height so a tall
|
||||
// image or long note can't blow up the page.
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 132),
|
||||
child: ClipRect(
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
heightFactor: 1,
|
||||
child: MessageContent(
|
||||
content: note.content,
|
||||
tags: note.tags,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: Grid.xs),
|
||||
Divider(
|
||||
height: 1,
|
||||
color: context.colors.outlineVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _shortPubkey(String pubkey) =>
|
||||
pubkey.length >= 8 ? '${pubkey.substring(0, 8)}...' : pubkey;
|
||||
}
|
||||
@@ -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 = '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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
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 "" 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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user