mirror of
https://github.com/block/buzz.git
synced 2026-11-09 10:51:42 +00:00
## Summary - align message typography, avatars, metadata, and spacing across mobile surfaces - improve message follow behavior, touch feedback, and Activity popover motion - refine Search motion, gutters, and explicit recent-search history ## Snapshots ### Home  ### Activity  ### Search  ## Testing - `just mobile-check` - `just mobile-test` (749 passed, 1 skipped) --------- Signed-off-by: Taylor Ho <taylorkmho@gmail.com> Signed-off-by: kenny lopez <klopez4212@gmail.com> Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz> Signed-off-by: Wes <wesbillman@users.noreply.github.com> Signed-off-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz> Co-authored-by: npub1223z34hd7vtwc6qj4s7flsxkj644nlre2nthu7lrrmkumhu3xddsrx9r6w <52a228d6edf316ec6812ac3c9fc0d696ab59fc7954d77e7be31eedcddf91335b@buzz.block.builderlab.xyz> Co-authored-by: Taylor Ho <taylorkmho@gmail.com> Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz> Co-authored-by: Wes <wesbillman@users.noreply.github.com> Co-authored-by: Carl <c7ebe626f000404285d3686e1dc74cc07cc60a9754a150041ba132e14bd3e2ec@buzz.block.builderlab.xyz> Co-authored-by: npub15w828kxsxu2684ynste0uah2jwkgatd99flt7ds4523hzm8ju6cshdr8hh <a38ea3d8d03715a3d49382f2fe76ea93ac8eada52a7ebf3615a2a3716cf2e6b1@buzz.block.builderlab.xyz>
261 lines
9.6 KiB
Dart
261 lines
9.6 KiB
Dart
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/avatar_image.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.gutter),
|
||
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: [
|
||
AvatarImage(
|
||
imageUrl: profile?.avatarUrl,
|
||
radius: 18,
|
||
backgroundColor: context.colors.primaryContainer,
|
||
fallback: Text(
|
||
profile?.initial ?? '?',
|
||
style: context.textTheme.labelMedium?.copyWith(
|
||
color: context.colors.onPrimaryContainer,
|
||
),
|
||
),
|
||
),
|
||
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.gutter, Grid.xs, Grid.gutter, 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: [
|
||
AvatarImage(
|
||
imageUrl: profile?.avatarUrl,
|
||
radius: 18,
|
||
backgroundColor: context.colors.primaryContainer,
|
||
fallback: Text(
|
||
(profile?.initial ?? displayName[0]).toUpperCase(),
|
||
style: context.textTheme.labelMedium?.copyWith(
|
||
color: context.colors.onPrimaryContainer,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: Grid.xs),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
displayName,
|
||
maxLines: 1,
|
||
style: messageUsernameTextStyle,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
const SizedBox(width: Grid.half),
|
||
ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: Grid.xxl),
|
||
child: Text(
|
||
formatPulseRelativeTime(note.createdAt),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: messageTimestampTextStyle.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,
|
||
baseStyle: messageBodyTextStyle.copyWith(
|
||
color: context.colors.onSurface,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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;
|
||
}
|