import 'dart:async'; import 'dart:io' show Platform; import 'dart:ui' show ImageFilter; import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:intl/intl.dart'; import 'package:conduit/l10n/app_localizations.dart'; import '../../../core/models/note.dart'; import '../../../core/providers/app_providers.dart'; import '../../../core/widgets/error_boundary.dart'; import '../../../shared/theme/theme_extensions.dart'; import '../../../shared/utils/ui_utils.dart'; import '../../../shared/widgets/improved_loading_states.dart'; import '../../../shared/widgets/middle_ellipsis_text.dart'; import '../../../shared/widgets/themed_dialogs.dart'; import '../../chat/services/voice_input_service.dart'; import '../providers/notes_providers.dart'; /// Page for editing a note with OpenWebUI-style layout. class NoteEditorPage extends ConsumerStatefulWidget { final String noteId; const NoteEditorPage({super.key, required this.noteId}); @override ConsumerState createState() => _NoteEditorPageState(); } class _NoteEditorPageState extends ConsumerState { final TextEditingController _titleController = TextEditingController(); final TextEditingController _contentController = TextEditingController(); final FocusNode _titleFocusNode = FocusNode(debugLabel: 'note_title'); final FocusNode _contentFocusNode = FocusNode(debugLabel: 'note_content'); final ScrollController _scrollController = ScrollController(); Timer? _saveDebounce; bool _isLoading = true; bool _isSaving = false; bool _hasChanges = false; bool _isGeneratingTitle = false; bool _isEnhancing = false; bool _isRecording = false; Note? _note; // Voice input VoiceInputService? _voiceService; StreamSubscription? _voiceSub; String _voiceBaseText = ''; int get _wordCount { final text = _contentController.text.trim(); if (text.isEmpty) return 0; return text.split(RegExp(r'\s+')).length; } int get _charCount => _contentController.text.length; @override void initState() { super.initState(); _loadNote(); _titleController.addListener(_onContentChanged); _contentController.addListener(_onContentChanged); // Rebuild when title focus changes to show/hide the generate title button _titleFocusNode.addListener(_onTitleFocusChanged); } void _onTitleFocusChanged() { if (mounted) setState(() {}); } @override void dispose() { _saveDebounce?.cancel(); _voiceSub?.cancel(); _voiceService?.stopListening(); _titleController.dispose(); _contentController.dispose(); _titleFocusNode.removeListener(_onTitleFocusChanged); _titleFocusNode.dispose(); _contentFocusNode.dispose(); _scrollController.dispose(); super.dispose(); } Future _loadNote() async { setState(() => _isLoading = true); final api = ref.read(apiServiceProvider); if (api == null) { setState(() => _isLoading = false); return; } try { final json = await api.getNoteById(widget.noteId); final note = Note.fromJson(json); if (mounted) { setState(() { _note = note; _titleController.text = note.title; _contentController.text = note.markdownContent; _isLoading = false; _hasChanges = false; }); } } catch (e) { if (mounted) { setState(() => _isLoading = false); _showError(e.toString()); } } } void _onContentChanged() { if (!mounted || _isLoading) return; // Check if content actually changed from the saved note final titleChanged = _note != null && _titleController.text != _note!.title; final contentChanged = _note != null && _contentController.text != _note!.markdownContent; final hasRealChanges = titleChanged || contentChanged; if (hasRealChanges != _hasChanges) { setState(() => _hasChanges = hasRealChanges); } if (hasRealChanges) { _debounceSave(); } } void _debounceSave() { _saveDebounce?.cancel(); _saveDebounce = Timer(const Duration(milliseconds: 800), _autoSave); } Future _autoSave() async { if (_note == null || !_hasChanges) return; await _saveNote(showFeedback: false); } Future _saveNote({bool showFeedback = true}) async { if (_note == null) return; setState(() => _isSaving = true); final api = ref.read(apiServiceProvider); if (api == null) { setState(() => _isSaving = false); return; } try { final title = _titleController.text.trim(); final content = _contentController.text; final data = { 'content': { 'json': null, 'html': _markdownToHtml(content), 'md': content, }, }; // Use the server's response to get authoritative data (including updated_at) final json = await api.updateNote( widget.noteId, title: title.isEmpty ? AppLocalizations.of(context)!.untitled : title, data: data, ); final updatedNote = Note.fromJson(json); ref.read(notesListProvider.notifier).updateNote(updatedNote); if (mounted) { setState(() { _note = updatedNote; _isSaving = false; _hasChanges = false; }); if (showFeedback) { HapticFeedback.lightImpact(); } } } catch (e) { if (mounted) { setState(() => _isSaving = false); _showError(e.toString()); } } } String _markdownToHtml(String markdown) { final paragraphs = markdown.split('\n\n'); final html = paragraphs .map((p) { if (p.trim().isEmpty) return ''; if (p.startsWith('# ')) { return '

${_escapeHtml(p.substring(2))}

'; } if (p.startsWith('## ')) { return '

${_escapeHtml(p.substring(3))}

'; } if (p.startsWith('### ')) { return '

${_escapeHtml(p.substring(4))}

'; } // Escape entire paragraph first to prevent XSS, then apply // markdown formatting replacements on the escaped text. var text = _escapeHtml(p); text = text.replaceAllMapped( RegExp(r'\*\*(.+?)\*\*'), (m) => '${m.group(1)!}', ); text = text.replaceAllMapped( RegExp(r'\*(.+?)\*'), (m) => '${m.group(1)!}', ); return '

$text

'; }) .join('\n'); return html; } String _escapeHtml(String text) { return text .replaceAll('&', '&') .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll("'", '''); } void _showError(String message) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(message), backgroundColor: context.conduitTheme.error, ), ); } Future _onWillPop() async { if (_hasChanges) { await _saveNote(showFeedback: false); } return true; } Future _deleteNote() async { if (_note == null) return; final l10n = AppLocalizations.of(context)!; final confirmed = await ThemedDialogs.confirm( context, title: l10n.deleteNoteTitle, message: l10n.deleteNoteMessage( _note!.title.isEmpty ? l10n.untitled : _note!.title, ), confirmText: l10n.delete, isDestructive: true, ); if (confirmed && mounted) { HapticFeedback.mediumImpact(); final success = await ref .read(noteDeleterProvider.notifier) .deleteNote(widget.noteId); if (success && mounted) { Navigator.of(context).pop(); } } } // Get the selected model ID for AI operations String? _getSelectedModelId() { final selectedModel = ref.read(selectedModelProvider); return selectedModel?.id; } // AI title generation Future _generateTitle() async { if (_note == null || _isGeneratingTitle) return; final content = _contentController.text.trim(); if (content.isEmpty) { _showError(AppLocalizations.of(context)!.noContentToGenerateTitle); return; } final modelId = _getSelectedModelId(); if (modelId == null) { _showError(AppLocalizations.of(context)!.noModelSelected); return; } setState(() => _isGeneratingTitle = true); HapticFeedback.lightImpact(); final api = ref.read(apiServiceProvider); if (api == null) { setState(() => _isGeneratingTitle = false); return; } try { final generatedTitle = await api.generateNoteTitle( content, modelId: modelId, ); if (mounted && generatedTitle != null && generatedTitle.isNotEmpty) { _titleController.text = generatedTitle; HapticFeedback.mediumImpact(); } } catch (e) { if (mounted) { _showError(AppLocalizations.of(context)!.failedToGenerateTitle); } } finally { if (mounted) { setState(() => _isGeneratingTitle = false); } } } // AI content enhancement Future _enhanceContent() async { if (_note == null || _isEnhancing) return; final content = _contentController.text.trim(); if (content.isEmpty) { _showError(AppLocalizations.of(context)!.noContentToEnhance); return; } final modelId = _getSelectedModelId(); if (modelId == null) { _showError(AppLocalizations.of(context)!.noModelSelected); return; } setState(() => _isEnhancing = true); HapticFeedback.lightImpact(); final api = ref.read(apiServiceProvider); if (api == null) { setState(() => _isEnhancing = false); return; } try { final enhancedContent = await api.enhanceNoteContent( content, modelId: modelId, ); if (mounted && enhancedContent != null && enhancedContent.isNotEmpty) { _contentController.text = enhancedContent; HapticFeedback.mediumImpact(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context)!.noteEnhanced), duration: const Duration(seconds: 2), ), ); } } catch (e) { if (mounted) { _showError(AppLocalizations.of(context)!.failedToEnhanceNote); } } finally { if (mounted) { setState(() => _isEnhancing = false); } } } // Voice dictation Future _toggleDictation() async { if (_isRecording) { await _stopDictation(); } else { await _startDictation(); } } Future _startDictation() async { _voiceService ??= VoiceInputService(api: ref.read(apiServiceProvider)); try { final ok = await _voiceService!.initialize(); if (!mounted) return; if (!ok) { _showError(AppLocalizations.of(context)!.voiceInputUnavailable); return; } final stream = await _voiceService!.beginListening(); if (!mounted) return; setState(() { _isRecording = true; _voiceBaseText = _contentController.text; }); HapticFeedback.lightImpact(); _voiceSub?.cancel(); _voiceSub = stream.listen( (text) { if (!mounted) return; final updated = _voiceBaseText.isEmpty ? text : '${_voiceBaseText.trimRight()} $text'; _contentController.value = TextEditingValue( text: updated, selection: TextSelection.collapsed(offset: updated.length), ); }, onDone: () { if (!mounted) return; setState(() => _isRecording = false); }, onError: (_) { if (!mounted) return; setState(() => _isRecording = false); }, ); } catch (e) { _showError(AppLocalizations.of(context)!.failedToStartDictation); if (mounted) { setState(() => _isRecording = false); } } } Future _stopDictation() async { await _voiceService?.stopListening(); _voiceSub?.cancel(); if (mounted) { setState(() => _isRecording = false); HapticFeedback.selectionClick(); } } void _copyToClipboard() { final l10n = AppLocalizations.of(context)!; final content = _contentController.text; Clipboard.setData(ClipboardData(text: content)); HapticFeedback.selectionClick(); ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l10n.noteCopiedToClipboard), duration: const Duration(seconds: 2), ), ); } @override Widget build(BuildContext context) { final sidebarTheme = context.sidebarTheme; // Check if notes feature is enabled - redirect to chat if disabled final notesEnabled = ref.watch(notesFeatureEnabledProvider); if (!notesEnabled) { // Redirect back to chat on next frame WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { context.go('/chat'); } }); // Show empty scaffold while redirecting return Scaffold(backgroundColor: sidebarTheme.background); } return PopScope( // Only allow immediate pop when there are no unsaved changes. // When there are changes, we intercept, save first, then pop manually. canPop: !_hasChanges, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; // Already popped, nothing to do // Capture navigator before async gap final navigator = Navigator.of(context); // Save changes before allowing pop await _saveNote(showFeedback: false); if (!mounted) return; navigator.pop(); }, child: ErrorBoundary( child: Scaffold( backgroundColor: context.conduitTheme.surfaceBackground, extendBodyBehindAppBar: true, appBar: _buildAppBar(context), body: Stack( children: [ // Main content - scrolls behind floating elements Positioned.fill( child: _buildMainContent(context), ), // Floating action buttons if (!_isLoading && _note != null) Positioned( left: Spacing.md, right: Spacing.md, bottom: Spacing.md + MediaQuery.of(context).padding.bottom, child: _buildFloatingActionsRow(context), ), ], ), ), ), ); } PreferredSizeWidget _buildAppBar(BuildContext context) { final theme = Theme.of(context); final conduitTheme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; return PreferredSize( preferredSize: const Size.fromHeight(kToolbarHeight + 40), child: Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, stops: const [0.0, 0.4, 1.0], colors: [ theme.scaffoldBackgroundColor, theme.scaffoldBackgroundColor.withValues(alpha: 0.85), theme.scaffoldBackgroundColor.withValues(alpha: 0.0), ], ), ), child: SafeArea( bottom: false, child: Column( mainAxisSize: MainAxisSize.min, children: [ // App bar row with back button, title, and menu SizedBox( height: kToolbarHeight, child: Row( children: [ // Leading (back button) Padding( padding: const EdgeInsets.only(left: Spacing.inputPadding), child: Center( child: GestureDetector( onTap: () async { final navigator = Navigator.of(context); await _onWillPop(); if (!mounted) return; navigator.pop(); }, child: _buildAppBarPill( context, Icon( UiUtils.platformIcon( ios: CupertinoIcons.back, android: Icons.arrow_back, ), color: conduitTheme.textPrimary, size: IconSize.appBar, ), isCircular: true, ), ), ), ), // Title centered Expanded( child: Center( child: _buildAppBarPill( context, Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.sm, vertical: Spacing.xs, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: _isGeneratingTitle ? Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: IconSize.sm, height: IconSize.sm, child: CircularProgressIndicator( strokeWidth: BorderWidth.medium, valueColor: AlwaysStoppedAnimation( conduitTheme.loadingIndicator, ), ), ), const SizedBox(width: Spacing.sm), Text( l10n.generatingTitle, style: AppTypography.bodyMediumStyle.copyWith( color: conduitTheme.textSecondary, ), ), ], ) : ConstrainedBox( constraints: BoxConstraints( maxWidth: MediaQuery.of(context).size.width * 0.5, ), child: Stack( alignment: Alignment.center, children: [ // Hidden TextField always in tree for focus Opacity( opacity: _titleFocusNode.hasFocus ? 1.0 : 0.0, child: IntrinsicWidth( child: TextField( controller: _titleController, focusNode: _titleFocusNode, enabled: !_isGeneratingTitle, style: AppTypography.headlineSmallStyle .copyWith( color: conduitTheme.textPrimary, fontWeight: FontWeight.w600, ), decoration: InputDecoration( hintText: l10n.untitled, hintStyle: AppTypography.headlineSmallStyle .copyWith( color: conduitTheme.textSecondary .withValues(alpha: 0.6), fontWeight: FontWeight.w600, ), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.zero, isDense: true, ), textAlign: TextAlign.center, textCapitalization: TextCapitalization.sentences, textInputAction: TextInputAction.done, onSubmitted: (_) => _contentFocusNode.requestFocus(), ), ), ), // Visible text when not focused if (!_titleFocusNode.hasFocus) GestureDetector( onTap: () => _titleFocusNode.requestFocus(), child: MiddleEllipsisText( _titleController.text.isEmpty ? l10n.untitled : _titleController.text, style: AppTypography.headlineSmallStyle .copyWith( color: _titleController.text.isEmpty ? conduitTheme.textSecondary .withValues(alpha: 0.6) : conduitTheme.textPrimary, fontWeight: FontWeight.w600, ), ), ), ], ), ), ), if (_hasChanges && !_isSaving) Padding( padding: const EdgeInsets.only(left: Spacing.sm), child: Container( width: 8, height: 8, decoration: BoxDecoration( color: conduitTheme.warning, shape: BoxShape.circle, ), ), ), if (_isSaving) Padding( padding: const EdgeInsets.only(left: Spacing.sm), child: SizedBox( width: IconSize.sm, height: IconSize.sm, child: CircularProgressIndicator( strokeWidth: BorderWidth.medium, valueColor: AlwaysStoppedAnimation( conduitTheme.loadingIndicator, ), ), ), ), ], ), ), ), ), ), // Actions (more menu) Padding( padding: const EdgeInsets.only(right: Spacing.inputPadding), child: Center( child: PopupMenuButton( tooltip: '', shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppBorderRadius.md), ), onSelected: (value) { switch (value) { case 'generate_title': _generateTitle(); case 'copy': _copyToClipboard(); case 'delete': _deleteNote(); } }, itemBuilder: (context) => [ PopupMenuItem( value: 'generate_title', child: Row( children: [ Icon( Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome_rounded, color: conduitTheme.buttonPrimary, size: IconSize.md, ), const SizedBox(width: Spacing.sm), Text(l10n.generateTitle), ], ), ), PopupMenuItem( value: 'copy', child: Row( children: [ Icon( Platform.isIOS ? CupertinoIcons.doc_on_clipboard : Icons.copy_rounded, color: conduitTheme.iconPrimary, size: IconSize.md, ), const SizedBox(width: Spacing.sm), Text(l10n.copy), ], ), ), PopupMenuItem( value: 'delete', child: Row( children: [ Icon( Platform.isIOS ? CupertinoIcons.delete : Icons.delete_rounded, color: conduitTheme.error, size: IconSize.md, ), const SizedBox(width: Spacing.sm), Text( l10n.delete, style: TextStyle(color: conduitTheme.error), ), ], ), ), ], child: _buildAppBarPill( context, Icon( Platform.isIOS ? CupertinoIcons.ellipsis : Icons.more_vert_rounded, color: conduitTheme.textPrimary, size: IconSize.appBar, ), isCircular: true, ), ), ), ), ], ), ), // Metadata stats row if (!_isLoading && _note != null) Padding( padding: const EdgeInsets.only(bottom: Spacing.xs), child: _buildFloatingMetadataBar(context), ), ], ), ), ), ); } Widget _buildAppBarPill( BuildContext context, Widget child, { bool isCircular = false, }) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final backgroundColor = isDark ? Color.lerp(context.conduitTheme.cardBackground, Colors.white, 0.08)! : Color.lerp(context.conduitTheme.inputBackground, Colors.black, 0.06)!; final borderColor = context.conduitTheme.cardBorder.withValues( alpha: isDark ? 0.65 : 0.55, ); final borderRadius = isCircular ? BorderRadius.circular(100) : BorderRadius.circular(AppBorderRadius.pill); if (isCircular) { return SizedBox( width: 44, height: 44, child: ClipRRect( borderRadius: borderRadius, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), child: Container( decoration: BoxDecoration( color: backgroundColor.withValues(alpha: 0.85), borderRadius: borderRadius, border: Border.all(color: borderColor, width: BorderWidth.thin), ), child: Center(child: child), ), ), ), ); } return ClipRRect( borderRadius: borderRadius, child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), child: Container( decoration: BoxDecoration( color: backgroundColor.withValues(alpha: 0.85), borderRadius: borderRadius, border: Border.all(color: borderColor, width: BorderWidth.thin), ), child: child, ), ), ); } Widget _buildFloatingMetadataBar(BuildContext context) { final theme = Theme.of(context); final conduitTheme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; final isDark = theme.brightness == Brightness.dark; final backgroundColor = isDark ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)!; final borderColor = conduitTheme.cardBorder.withValues( alpha: isDark ? 0.65 : 0.55, ); final dateFormat = DateFormat.MMMd(); final timeFormat = DateFormat.jm(); final createdDate = _note != null ? '${dateFormat.format(_note!.createdDateTime)} ${timeFormat.format(_note!.createdDateTime)}' : ''; return ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.pill), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), child: Container( padding: const EdgeInsets.symmetric( horizontal: Spacing.md, vertical: Spacing.xs, ), decoration: BoxDecoration( color: backgroundColor.withValues(alpha: 0.85), borderRadius: BorderRadius.circular(AppBorderRadius.pill), border: Border.all(color: borderColor, width: BorderWidth.thin), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ // Created date _buildMetadataChip( context, icon: Platform.isIOS ? CupertinoIcons.calendar : Icons.calendar_today_rounded, label: createdDate, ), _buildMetadataSeparator(conduitTheme), // Word count _buildMetadataChip( context, icon: Platform.isIOS ? CupertinoIcons.doc_text : Icons.article_rounded, label: l10n.wordCount(_wordCount), ), _buildMetadataSeparator(conduitTheme), // Character count _buildMetadataChip( context, icon: Platform.isIOS ? CupertinoIcons.textformat_abc : Icons.text_fields_rounded, label: l10n.charCount(_charCount), ), ], ), ), ), ); } Widget _buildMetadataSeparator(ConduitThemeExtension theme) { return Padding( padding: const EdgeInsets.symmetric(horizontal: Spacing.xxs), child: Text( 'ยท', style: AppTypography.tiny.copyWith( color: theme.textTertiary.withValues(alpha: 0.5), ), ), ); } Widget _buildMetadataChip( BuildContext context, { required IconData icon, required String label, }) { final theme = context.conduitTheme; return Padding( padding: const EdgeInsets.symmetric( horizontal: Spacing.xs, vertical: Spacing.xxs, ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Icon( icon, color: theme.textTertiary.withValues(alpha: 0.7), size: IconSize.xs, ), const SizedBox(width: Spacing.xxs), Text( label, style: AppTypography.tiny.copyWith( color: theme.textTertiary.withValues(alpha: 0.7), fontWeight: FontWeight.w500, ), ), ], ), ); } Widget _buildMainContent(BuildContext context) { return _buildBody(context); } Widget _buildBody(BuildContext context) { if (_isLoading) { return Center( child: ImprovedLoadingState( message: AppLocalizations.of(context)!.loadingNote, ), ); } if (_note == null) { return _buildNotFoundState(context); } // Title is now edited in the app bar pill, so just show the content editor return _buildEditor(context); } Widget _buildEditor(BuildContext context) { final theme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; final topPadding = MediaQuery.of(context).padding.top; // App bar height: kToolbarHeight + metadata bar (~40) final appBarHeight = kToolbarHeight + 40; return GestureDetector( onTap: () => _contentFocusNode.requestFocus(), behavior: HitTestBehavior.opaque, child: SingleChildScrollView( controller: _scrollController, padding: EdgeInsets.fromLTRB( Spacing.inputPadding, topPadding + appBarHeight + Spacing.sm, // Space for floating app bar Spacing.inputPadding, 120, // Extra padding for floating buttons ), child: TextField( controller: _contentController, focusNode: _contentFocusNode, style: AppTypography.bodyLargeStyle.copyWith( color: theme.textPrimary, height: 1.8, ), decoration: InputDecoration( hintText: l10n.writeNote, hintStyle: AppTypography.bodyLargeStyle.copyWith( color: theme.textSecondary.withValues(alpha: 0.35), height: 1.8, ), filled: false, border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, contentPadding: EdgeInsets.zero, ), maxLines: null, minLines: 20, textAlignVertical: TextAlignVertical.top, textCapitalization: TextCapitalization.sentences, keyboardType: TextInputType.multiline, ), ), ); } Widget _buildFloatingActionsRow(BuildContext context) { final theme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ // Dictation button _buildFloatingButton( context, icon: _isRecording ? (Platform.isIOS ? CupertinoIcons.stop_fill : Icons.stop_rounded) : (Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic_rounded), color: _isRecording ? theme.error : null, isLoading: false, tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation, onPressed: _toggleDictation, ), // AI button _buildFloatingButton( context, icon: Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_awesome_rounded, isLoading: _isEnhancing, tooltip: l10n.enhanceWithAI, onPressed: _isEnhancing ? null : _enhanceContent, showMenu: true, ), ], ); } Widget _buildFloatingButton( BuildContext context, { required IconData icon, required String tooltip, required VoidCallback? onPressed, bool isLoading = false, Color? color, bool showMenu = false, }) { final theme = Theme.of(context); final conduitTheme = context.conduitTheme; final l10n = AppLocalizations.of(context)!; final isDark = theme.brightness == Brightness.dark; final backgroundColor = isDark ? Color.lerp(conduitTheme.cardBackground, Colors.white, 0.08)! .withValues(alpha: 0.85) : Color.lerp(conduitTheme.inputBackground, Colors.black, 0.06)! .withValues(alpha: 0.85); final borderColor = conduitTheme.cardBorder.withValues(alpha: 0.55); final buttonChild = ClipRRect( borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 16, sigmaY: 16), child: Container( width: TouchTarget.button, height: TouchTarget.button, decoration: BoxDecoration( color: backgroundColor, borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), border: Border.all(color: borderColor, width: BorderWidth.thin), boxShadow: ConduitShadows.button(context), ), child: isLoading ? Center( child: SizedBox( width: IconSize.md, height: IconSize.md, child: CircularProgressIndicator( strokeWidth: BorderWidth.medium, valueColor: AlwaysStoppedAnimation(conduitTheme.loadingIndicator), ), ), ) : Icon( icon, color: color ?? conduitTheme.iconPrimary.withValues(alpha: 0.9), size: IconSize.lg, ), ), ), ); if (showMenu) { return PopupMenuButton( tooltip: tooltip, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppBorderRadius.md), ), offset: const Offset(0, -120), onSelected: (value) { switch (value) { case 'enhance': _enhanceContent(); case 'title': _generateTitle(); } }, itemBuilder: (context) => [ PopupMenuItem( value: 'enhance', child: Row( children: [ Icon( Platform.isIOS ? CupertinoIcons.sparkles : Icons.auto_fix_high_rounded, color: conduitTheme.buttonPrimary, size: IconSize.md, ), const SizedBox(width: Spacing.sm), Text(l10n.enhanceNote), ], ), ), PopupMenuItem( value: 'title', child: Row( children: [ Icon( Platform.isIOS ? CupertinoIcons.textformat : Icons.title_rounded, color: conduitTheme.buttonPrimary, size: IconSize.md, ), const SizedBox(width: Spacing.sm), Text(l10n.generateTitle), ], ), ), ], child: buttonChild, ); } return Tooltip( message: tooltip, child: Material( color: Colors.transparent, child: InkWell( onTap: onPressed, customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppBorderRadius.floatingButton), ), child: buttonChild, ), ), ); } Widget _buildNotFoundState(BuildContext context) { final theme = context.conduitTheme; final sidebarTheme = context.sidebarTheme; final l10n = AppLocalizations.of(context)!; return Center( child: Padding( padding: const EdgeInsets.all(Spacing.xxl), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Container( width: 80, height: 80, decoration: BoxDecoration( color: sidebarTheme.accent.withValues(alpha: 0.5), borderRadius: BorderRadius.circular(AppBorderRadius.xl), ), child: Icon( Platform.isIOS ? CupertinoIcons.doc_text : Icons.description_outlined, size: 36, color: sidebarTheme.foreground.withValues(alpha: 0.4), ), ), const SizedBox(height: Spacing.lg), Text( l10n.noteNotFound, style: AppTypography.headlineSmallStyle.copyWith( color: theme.textPrimary, fontWeight: FontWeight.w600, ), textAlign: TextAlign.center, ), const SizedBox(height: Spacing.lg), OutlinedButton.icon( onPressed: () => Navigator.of(context).pop(), icon: Icon( Platform.isIOS ? CupertinoIcons.back : Icons.arrow_back_rounded, ), label: Text(l10n.goBack), style: OutlinedButton.styleFrom( foregroundColor: sidebarTheme.primary, side: BorderSide( color: sidebarTheme.primary.withValues(alpha: 0.5), ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppBorderRadius.button), ), ), ), ], ), ), ); } }