diff --git a/lib/widgets/compose_box.dart b/lib/widgets/compose_box.dart index d1f3b93583..9f66dfb0da 100644 --- a/lib/widgets/compose_box.dart +++ b/lib/widgets/compose_box.dart @@ -16,6 +16,7 @@ import '../model/store.dart'; import 'autocomplete.dart'; import 'dialog.dart'; import 'store.dart'; +import 'text.dart'; import 'theme.dart'; const double _inputVerticalPadding = 8; @@ -363,18 +364,19 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve } } + @override Widget build(BuildContext context) { ColorScheme colorScheme = Theme.of(context).colorScheme; + final screenHeight = MediaQuery.of(context).size.height; return InputDecorator( decoration: const InputDecoration(), child: ConstrainedBox( - constraints: const BoxConstraints( + constraints: BoxConstraints( minHeight: _sendButtonSize - 2 * _inputVerticalPadding, - // TODO constrain this adaptively (i.e. not hard-coded 200) - maxHeight: 200, + maxHeight: screenHeight * 0.2, ), child: ComposeAutocomplete( narrow: widget.narrow, @@ -389,9 +391,14 @@ class _ContentInputState extends State<_ContentInput> with WidgetsBindingObserve maxLines: null, textCapitalization: TextCapitalization.sentences, ); - }), - )); + }, + ), + ), + ); } + + + } /// The content input for _StreamComposeBox. @@ -985,7 +992,7 @@ class _ComposeBoxContainer extends StatelessWidget { } } -class _ComposeBoxLayout extends StatelessWidget { +class _ComposeBoxLayout extends StatefulWidget { const _ComposeBoxLayout({ required this.topicInput, required this.contentInput, @@ -1000,6 +1007,22 @@ class _ComposeBoxLayout extends StatelessWidget { final ComposeContentController contentController; final FocusNode contentFocusNode; + @override + State<_ComposeBoxLayout> createState() => _ComposeBoxLayoutState(); +} + +class _ComposeBoxLayoutState extends State<_ComposeBoxLayout> { + bool isPreviewMode = false; + + void togglePreview() { + + setState(() { + isPreviewMode = !isPreviewMode; + widget.contentFocusNode.requestFocus(); + + }); + } + @override Widget build(BuildContext context) { ThemeData themeData = Theme.of(context); @@ -1007,10 +1030,9 @@ class _ComposeBoxLayout extends StatelessWidget { final inputThemeData = themeData.copyWith( inputDecorationTheme: InputDecorationTheme( - // Both [contentPadding] and [isDense] combine to make the layout compact. isDense: true, contentPadding: const EdgeInsets.symmetric( - horizontal: 12.0, vertical: _inputVerticalPadding), + horizontal: 10.0, vertical: _inputVerticalPadding), border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(4.0)), borderSide: BorderSide.none), @@ -1026,25 +1048,189 @@ class _ComposeBoxLayout extends StatelessWidget { child: Theme( data: inputThemeData, child: Column(children: [ - if (topicInput != null) topicInput!, - if (topicInput != null) const SizedBox(height: 8), - contentInput, - ]))), + if (widget.topicInput != null) widget.topicInput!, + if (widget.topicInput != null) const SizedBox(height: 8), + // Show input or preview box + isPreviewMode + ? PreviewBox( + content: widget.contentController.textNormalized, + ) + : widget.contentInput, + ]), + ), + ), const SizedBox(width: 8), - sendButton, + widget.sendButton, ]), Theme( data: themeData.copyWith( iconTheme: themeData.iconTheme.copyWith(color: colorScheme.onSurfaceVariant)), child: Row(children: [ - _AttachFileButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachMediaButton(contentController: contentController, contentFocusNode: contentFocusNode), - _AttachFromCameraButton(contentController: contentController, contentFocusNode: contentFocusNode), - ])), - ])); + IconButton( + icon: Icon(isPreviewMode ? Icons.visibility_off : Icons.visibility), + tooltip: isPreviewMode + ? 'Switch to Edit Mode' + : 'Preview Content', + onPressed: togglePreview, + ), + _AttachFileButton( + contentController: widget.contentController, + contentFocusNode: widget.contentFocusNode, + ), + _AttachMediaButton( + contentController: widget.contentController, + contentFocusNode: widget.contentFocusNode, + ), + _AttachFromCameraButton( + contentController: widget.contentController, + contentFocusNode: widget.contentFocusNode, + ), + + ]), + ), + ]), + ); } } + + +class PreviewBox extends StatelessWidget { + const PreviewBox({super.key, required this.content}); + + final String content; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + + return Container( + constraints: const BoxConstraints( + minHeight: _sendButtonSize - 2 * _inputVerticalPadding, + maxHeight: 200, + ), + width: double.infinity, + padding: const EdgeInsets.symmetric( + horizontal: 10.0, + vertical: _inputVerticalPadding + 4, + ), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(4.0), + ), + child: SingleChildScrollView( + child: content.isEmpty + ? Text( + '(No content to preview)', + style: weightVariableTextStyle(context).merge( + const TextStyle( + fontSize: 16, + height: 22 / 16, + ), + ), + ) + : RichText( + text: _buildStyledText(content, context), + ), + ), + ); + } + + InlineSpan _buildStyledText(String text, BuildContext context) { + final boldPattern = RegExp(r'\*\*(.*?)\*\*'); + final italicPattern = RegExp(r'\*(.*?)\*'); + final codePattern = RegExp(r'```(.*?)```', dotAll: true); + + final normalStyle = weightVariableTextStyle(context).merge( + const TextStyle( + fontSize: 16, + height: 22 / 16, + ), + ); + final boldStyle = normalStyle.merge(const TextStyle(fontWeight: FontWeight.bold)); + final italicStyle = normalStyle.merge(const TextStyle(fontStyle: FontStyle.italic)); + + List spans = []; + int currentIndex = 0; + + for (final match in codePattern.allMatches(text)) { + if (match.start > currentIndex) { + spans.add(TextSpan( + text: text.substring(currentIndex, match.start), + style: normalStyle, + )); + } + spans.add(WidgetSpan( + child: Container( + margin: const EdgeInsets.symmetric(vertical: 8.0), + padding: const EdgeInsets.all(10.0), + decoration: BoxDecoration( + color: const Color.fromARGB(156, 47, 47, 47), + borderRadius: BorderRadius.circular(6.0), + border: Border.all(color: Colors.grey[500]!), + ), + child: Text( + match.group(1) ?? '', + style: const TextStyle( + fontFamily: 'Courier', + fontSize: 14, + color: Colors.white, + ), + ), + ), + )); + currentIndex = match.end; + } + + // Handle remaining text + String remainingText = text.substring(currentIndex); + currentIndex = 0; + + // Process bold text + for (final match in boldPattern.allMatches(remainingText)) { + if (match.start > currentIndex) { + spans.add(TextSpan( + text: remainingText.substring(currentIndex, match.start), + style: normalStyle, + )); + } + spans.add(TextSpan( + text: match.group(1), + style: boldStyle, + )); + currentIndex = match.end; + } + + // Process italic text + remainingText = remainingText.substring(currentIndex); + currentIndex = 0; + + for (final match in italicPattern.allMatches(remainingText)) { + if (match.start > currentIndex) { + spans.add(TextSpan( + text: remainingText.substring(currentIndex, match.start), + style: normalStyle, + )); + } + spans.add(TextSpan( + text: match.group(1), + style: italicStyle, + )); + currentIndex = match.end; + } + + // Add any remaining unstyled text + if (currentIndex < remainingText.length) { + spans.add(TextSpan( + text: remainingText.substring(currentIndex), + style: normalStyle, + )); + } + + return TextSpan(children: spans); + } +} abstract class ComposeBoxController extends State { ComposeTopicController? get topicController; ComposeContentController get contentController;