mirror of
https://github.com/block/buzz.git
synced 2026-10-28 21:11:48 +00:00
## Summary Use UIKit navigation bars across iOS pages, with large titles that collapse into the toolbar on scroll and a light native blur. Home uses the active community name as its title and shows community and profile avatars with centered emoji and stable loading, without flashing back to icons. Channel headers retain the member count, tap-to-settings title, and Huddle action. Their ultra-thin native material has a soft lower edge and stays visible at the timeline bottom. The hidden title-layout scroll view has its automatic edge effects disabled so it cannot add a second backdrop. | Expanded Home | Scrolled Home | Settings | | --- | --- | --- | | <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--home.png" alt="home" width="240"> | <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--scrolled.png" alt="scrolled" width="240"> | <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--settings.png" alt="settings" width="240"> | Channel title, member count, and Huddle control: <img src="https://raw.githubusercontent.com/block/buzz/47a77d3ff1d30fc74f7488c1a4427e41bbb85c4e/pr-8052--channel.png" alt="Channel navigation" width="280"> ### Related issue No matching open issue found. ### Testing - Added causal regressions for Profile/Edit Photo native titles, metrics-only collapse changes, and native-header suppression throughout channel/thread reaction and action backdrops. Each regression fails when its corresponding fix is removed. - Mobile formatting and analysis passed. Full mobile test rerun passed: 2,622 tests, 4 skipped, plus 3 push-disabled build tests. - Native iPhone Air regression passed: repeated title expansion/collapse and scroll material behavior. - Navigation and blur were accepted on the connected iPhone using the existing profile. Simulator snapshots use real community data. The subsequent review fixes have widget coverage; fresh physical-device verification of Profile states, Dynamic Type changes, and message backdrops is pending. - Full repository `just ci` was blocked by local disk exhaustion while compiling Rust dependencies. --------- Signed-off-by: kenny lopez <klopez4212@gmail.com>
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.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;
|
||
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.colors.primaryContainer,
|
||
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|