Files
buzz/mobile/lib/features/pulse/compose_note_page.dart
klopez4212 a567dfc2df Unify mobile channel details (#6113)
## Summary

- combine channel identity, actions, member previews, and lifecycle
controls in one navigable mobile details page
- add compact member management and desktop-aligned channel editing
- smooth the profile entrance transition and standardize settings-group
spacing
- align Huddle lifecycle rows with regular message author and body
spacing

## Testing

- `just mobile-check`
- `just mobile-test` — 1,477 tests
- Pixel 10 debug build, install, and interaction review

## Snapshots

### Channel header

![Channel header with channel name and member
count](https://raw.githubusercontent.com/block/buzz/c61c64a8db0f69fc71e0ac344d64384a85ad02b5/pr-6113--01-channel-header.png)

### Combined channel details

![Combined channel details with actions and member
preview](https://raw.githubusercontent.com/block/buzz/c61c64a8db0f69fc71e0ac344d64384a85ad02b5/pr-6113--02-channel-details-members.png)

### Manage channel

![Manage channel editing
controls](https://raw.githubusercontent.com/block/buzz/c61c64a8db0f69fc71e0ac344d64384a85ad02b5/pr-6113--03-manage-channel.png)

---------

Signed-off-by: kenny lopez <klopez4212@gmail.com>
Signed-off-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
Co-authored-by: Fast Fizz <2df81cb51f05a9d5387ef24d7b9ecb8fcdfcd1c70ffabc67061c9596e1b5b1c4@buzz.block.builderlab.xyz>
2026-08-19 17:48:09 +01:00

267 lines
9.8 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/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;
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
? 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!),
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;
}