mirror of
https://github.com/block/buzz.git
synced 2026-10-28 04:51:40 +00:00
## 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>
297 lines
11 KiB
Dart
297 lines
11 KiB
Dart
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|