import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/l10n/l10n.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 '../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 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: Text(context.l10n.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 ? context.l10n.pulseSendingReply : context.l10n.pulsePublishingPost, ), ) : Text( _isReply ? context.l10n.pulseReply : context.l10n.pulsePost, ), ), ), ], ), 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 ? context.l10n.pulsePostYourReply : context.l10n.pulseWhatOnMind, 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( context.l10n.pulseReplyingTo(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, l10n: context.l10n, ), 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; }