Files
buzz/mobile/lib/features/pulse/compose_note_page.dart
klopez4212 c8ea4fb4eb feat(mobile): polish Home navigation and Solo workspace (#8393)
## Summary
Polish mobile Home and Solo: group Search/Inbox beside a separate
profile avatar, move Inbox and Settings to bottom-up pages with Close
controls, and place tabs opposite the plus/compose action. Morph plus to
compose clockwise when entering Solo and reverse counterclockwise when
returning Home, with a directional quarter-turn of the icon inside a
level button and a subtle scale pulse, respecting reduced motion. Align
Search’s Close control with Inbox, simplify its placeholder, and
initialize selected filter contrast correctly. Collapse the plus menu
when leaving Home. Center Solo’s native-glass New chat empty state and
keep it stationary behind the keyboard.

Standardize Tabler icons and neutral backgrounds, retain SF Back/Close
on iOS, use native Liquid Glass headers, tab container, and actions
(solid grey selected tab, slightly darker on iOS) on iOS and black
actions on Android, and smooth the expanded menu corners. Repair native
header safe-area/return layout and compose movement after cancellation.

### Related issue
None found for this UI refinement; follows #8337.

### Testing
- Formatting, analysis, full mobile suite: 3,487 passed, 4 skipped; 3
push-configuration tests passed. File-size checks passed.
- Base UI built and installed on Pixel and iPhone in place. Pixel
navigation/New chat cancellation checked. Subsequent glass and rotation
refinements are previewed on iPhone; motion feel awaits human
confirmation.
- Draft pending human confirmation of the final Settings entrance/return
behavior on iPhone.

### Pixel snapshots
| Solo | Expanded actions |
| --- | --- |
| <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-solo.png"
width="280" alt="Solo empty state with New chat" /> | <img
src="https://raw.githubusercontent.com/block/buzz/16dc0f8af971e63d788fee20974997fab925dec9/docs/screenshots/mobile-home-solo/pixel-actions.png"
width="280" alt="Expanded channel actions with smooth corners" /> |

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Co-authored-by: Terp <5856f6900e8e0a4e0ea1f4f8d2ee33e4753d2119bc122f94ecafc660b46c0511@buzz.block.builderlab.xyz>
2026-10-16 12:28:02 -04:00

297 lines
11 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../../shared/identity_names/identity_names.dart';
import '../../shared/identity_names/identity_names_provider.dart';
import '../../shared/mentions/mention_tags.dart';
import '../../shared/theme/theme.dart';
import '../../shared/widgets/avatar_image.dart';
import '../../shared/widgets/buzz_loading_indicator.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 '../../shared/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;
/// The comparison context of the surface that opened the reply, so the
/// reply context names people as they were shown there. Its labels are
/// resolved against live naming facts while this page is open.
final IdentityNames? names;
const ComposeNotePage({super.key, this.replyTo, this.names});
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(
nativeTitle: _isReply ? 'Reply' : 'New note',
nativeActions: [
IosNavigationAction(
label: isSending.value
? 'Sending…'
: _isReply
? 'Reply'
: 'Post',
onPressed: hasText && !isSending.value ? submit : null,
),
],
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
? SizedBox(
width: 16,
height: 16,
child: BuzzLoadingIndicator(
size: 16,
semanticLabel: _isReply
? 'Sending reply'
: 'Publishing post',
),
)
: Text(_isReply ? 'Reply' : 'Post'),
),
),
],
),
body: SafeArea(
top: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(height: frostedAppBarHeight(context)),
if (_isReply) _ReplyContext(note: replyTo!, names: names),
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(Grid.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AvatarImage(
imageUrl: profile?.avatarUrl,
radius: 18,
backgroundColor: context.controlSurface,
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;
final IdentityNames? names;
const _ReplyContext({required this.note, this.names});
@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 mentionPubkeys = mentionedPubkeysFromTags(note.tags);
final labels =
names?.withSources(ref.watch(identityNameSourcesProvider)) ??
watchIdentityNames(ref, {pubkey, ...mentionPubkeys});
final displayName = labels.labelFor(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.controlSurface,
fallback: Text(
// Name-derived when the profile is cached; keyed to the
// hex public key when it isn't, so the compact-npub
// fallback label doesn't render `N` for every unnamed
// author.
profile?.initial ??
(pubkey.isNotEmpty ? pubkey[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,
mentionLabels: {
for (final key in mentionPubkeys)
key: labels.labelFor(key),
},
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),
),
],
),
);
}
}