From ab976db8e701e9f82dae2e4c1690ff1e868c59d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20Ku=C3=9Fowski?= Date: Tue, 24 Feb 2026 09:09:49 +0100 Subject: [PATCH] refactor: Enable avoid-returning-widgets lint --- analysis_options.yaml | 3 +- lib/pages/chat/chat_view.dart | 227 ++++++------ lib/pages/chat/input_bar.dart | 5 +- lib/pages/dialer/dialer.dart | 453 ++++++++++++------------ lib/utils/markdown_context_builder.dart | 244 +++++++------ lib/widgets/mxc_image.dart | 38 +- 6 files changed, 497 insertions(+), 473 deletions(-) diff --git a/analysis_options.yaml b/analysis_options.yaml index 6d17e303a..82dc170d2 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -45,8 +45,6 @@ dart_code_linter: - avoid-unnecessary-conditionals # TODO: # - member-ordering - # - avoid-late-keyword - # - avoid-non-null-assertion # - avoid-global-state # - prefer-match-file-name # - avoid-banned-imports: @@ -61,6 +59,7 @@ dart_code_linter: - prefer-media-query-direct-access - avoid-wrapping-in-padding - prefer-correct-edge-insets-constructor + - avoid-returning-widgets # TODO: # - avoid-returning-widgets # - prefer-single-widget-per-file: diff --git a/lib/pages/chat/chat_view.dart b/lib/pages/chat/chat_view.dart index 23e0b8add..2c19524b7 100644 --- a/lib/pages/chat/chat_view.dart +++ b/lib/pages/chat/chat_view.dart @@ -34,120 +34,6 @@ class ChatView extends StatelessWidget { const ChatView(this.controller, {super.key}); - List _appBarActions(BuildContext context) { - if (controller.selectMode) { - return [ - if (controller.canEditSelectedEvents) - IconButton( - icon: const Icon(Icons.edit_outlined), - tooltip: L10n.of(context).edit, - onPressed: controller.editSelectedEventAction, - ), - if (controller.selectedEvents.length == 1 && - controller.activeThreadId == null && - controller.room.canSendDefaultMessages) - IconButton( - icon: const Icon(Icons.message_outlined), - tooltip: L10n.of(context).replyInThread, - onPressed: () => controller.enterThread( - controller.selectedEvents.single.eventId, - ), - ), - IconButton( - icon: const Icon(Icons.copy_outlined), - tooltip: L10n.of(context).copyToClipboard, - onPressed: controller.copyEventsAction, - ), - if (controller.canRedactSelectedEvents) - IconButton( - icon: const Icon(Icons.delete_outlined), - tooltip: L10n.of(context).redactMessage, - onPressed: controller.redactEventsAction, - ), - if (controller.selectedEvents.length == 1) - PopupMenuButton<_EventContextAction>( - useRootNavigator: true, - onSelected: (action) { - switch (action) { - case _EventContextAction.info: - controller.showEventInfo(); - controller.clearSelectedEvents(); - break; - case _EventContextAction.report: - controller.reportEventAction(); - break; - } - }, - itemBuilder: (context) => [ - if (controller.canPinSelectedEvents) - PopupMenuItem( - onTap: controller.pinEvent, - value: null, - child: Row( - mainAxisSize: .min, - children: [ - const Icon(Icons.push_pin_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).pinMessage), - ], - ), - ), - if (controller.canSaveSelectedEvent) - PopupMenuItem( - onTap: () => controller.saveSelectedEvent(context), - value: null, - child: Row( - mainAxisSize: .min, - children: [ - const Icon(Icons.download_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).downloadFile), - ], - ), - ), - PopupMenuItem( - value: _EventContextAction.info, - child: Row( - mainAxisSize: .min, - children: [ - const Icon(Icons.info_outlined), - const SizedBox(width: 12), - Text(L10n.of(context).messageInfo), - ], - ), - ), - if (controller.selectedEvents.single.status.isSent) - PopupMenuItem( - value: _EventContextAction.report, - child: Row( - mainAxisSize: .min, - children: [ - const Icon(Icons.shield_outlined, color: Colors.red), - const SizedBox(width: 12), - Text(L10n.of(context).reportMessage), - ], - ), - ), - ], - ), - ]; - } else if (!controller.room.isArchived) { - return [ - if (AppSettings.experimentalVoip.value && - Matrix.of(context).voipPlugin != null && - controller.room.isDirectChat) - IconButton( - onPressed: controller.onPhoneButtonTap, - icon: const Icon(Icons.call_outlined), - tooltip: L10n.of(context).placeCall, - ), - EncryptionButton(controller.room), - ChatSettingsPopupMenu(controller.room, true), - ]; - } - return []; - } - @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -238,7 +124,118 @@ class ChatView extends StatelessWidget { ), titleSpacing: FluffyThemes.isColumnMode(context) ? 24 : 0, title: ChatAppBarTitle(controller), - actions: _appBarActions(context), + actions: [ + if (controller.selectMode) ...[ + if (controller.canEditSelectedEvents) + IconButton( + icon: const Icon(Icons.edit_outlined), + tooltip: L10n.of(context).edit, + onPressed: controller.editSelectedEventAction, + ), + if (controller.selectedEvents.length == 1 && + controller.activeThreadId == null && + controller.room.canSendDefaultMessages) + IconButton( + icon: const Icon(Icons.message_outlined), + tooltip: L10n.of(context).replyInThread, + onPressed: () => controller.enterThread( + controller.selectedEvents.single.eventId, + ), + ), + IconButton( + icon: const Icon(Icons.copy_outlined), + tooltip: L10n.of(context).copyToClipboard, + onPressed: controller.copyEventsAction, + ), + if (controller.canRedactSelectedEvents) + IconButton( + icon: const Icon(Icons.delete_outlined), + tooltip: L10n.of(context).redactMessage, + onPressed: controller.redactEventsAction, + ), + if (controller.selectedEvents.length == 1) + PopupMenuButton<_EventContextAction>( + useRootNavigator: true, + onSelected: (action) { + switch (action) { + case _EventContextAction.info: + controller.showEventInfo(); + controller.clearSelectedEvents(); + break; + case _EventContextAction.report: + controller.reportEventAction(); + break; + } + }, + itemBuilder: (context) => [ + if (controller.canPinSelectedEvents) + PopupMenuItem( + onTap: controller.pinEvent, + value: null, + child: Row( + mainAxisSize: .min, + children: [ + const Icon(Icons.push_pin_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).pinMessage), + ], + ), + ), + if (controller.canSaveSelectedEvent) + PopupMenuItem( + onTap: () => + controller.saveSelectedEvent(context), + value: null, + child: Row( + mainAxisSize: .min, + children: [ + const Icon(Icons.download_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).downloadFile), + ], + ), + ), + PopupMenuItem( + value: _EventContextAction.info, + child: Row( + mainAxisSize: .min, + children: [ + const Icon(Icons.info_outlined), + const SizedBox(width: 12), + Text(L10n.of(context).messageInfo), + ], + ), + ), + if (controller.selectedEvents.single.status.isSent) + PopupMenuItem( + value: _EventContextAction.report, + child: Row( + mainAxisSize: .min, + children: [ + const Icon( + Icons.shield_outlined, + color: Colors.red, + ), + const SizedBox(width: 12), + Text(L10n.of(context).reportMessage), + ], + ), + ), + ], + ), + ] else if (!controller.room.isArchived) ...[ + if (AppSettings.experimentalVoip.value && + Matrix.of(context).voipPlugin != null && + controller.room.isDirectChat) + IconButton( + onPressed: controller.onPhoneButtonTap, + icon: const Icon(Icons.call_outlined), + tooltip: L10n.of(context).placeCall, + ), + EncryptionButton(controller.room), + ChatSettingsPopupMenu(controller.room, true), + ], + ], bottom: PreferredSize( preferredSize: Size.fromHeight(appbarBottomHeight), child: Column( diff --git a/lib/pages/chat/input_bar.dart b/lib/pages/chat/input_bar.dart index de967b581..3c240d30e 100644 --- a/lib/pages/chat/input_bar.dart +++ b/lib/pages/chat/input_bar.dart @@ -392,7 +392,10 @@ class InputBar extends StatelessWidget { controller: controller, focusNode: focusNode, readOnly: readOnly, - contextMenuBuilder: (c, e) => markdownContextBuilder(c, e, controller), + contextMenuBuilder: (c, e) => MarkdownContextBuilder( + editableTextState: e, + controller: controller, + ), contentInsertionConfiguration: ContentInsertionConfiguration( onContentInserted: (KeyboardInsertedContent content) { final data = content.data; diff --git a/lib/pages/dialer/dialer.dart b/lib/pages/dialer/dialer.dart index ae2d56552..753312b33 100644 --- a/lib/pages/dialer/dialer.dart +++ b/lib/pages/dialer/dialer.dart @@ -339,235 +339,103 @@ class MyCallingPage extends State { } */ - List _buildActionButtons(bool isFloating) { - if (isFloating) { - return []; - } - - final switchCameraButton = FloatingActionButton( - heroTag: 'switchCamera', - onPressed: _switchCamera, - backgroundColor: Colors.black45, - child: const Icon(Icons.switch_camera), - ); - /* - var switchSpeakerButton = FloatingActionButton( - heroTag: 'switchSpeaker', - child: Icon(_speakerOn ? Icons.volume_up : Icons.volume_off), - onPressed: _switchSpeaker, - foregroundColor: Colors.black54, - backgroundColor: Theme.of(widget.context).backgroundColor, - ); - */ - final hangupButton = FloatingActionButton( - heroTag: 'hangup', - onPressed: _hangUp, - tooltip: 'Hangup', - backgroundColor: _state == CallState.kEnded ? Colors.black45 : Colors.red, - child: const Icon(Icons.call_end), - ); - - final answerButton = FloatingActionButton( - heroTag: 'answer', - onPressed: _answerCall, - tooltip: 'Answer', - backgroundColor: Colors.green, - child: const Icon(Icons.phone), - ); - - final muteMicButton = FloatingActionButton( - heroTag: 'muteMic', - onPressed: _muteMic, - foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white, - backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45, - child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic), - ); - - final screenSharingButton = FloatingActionButton( - heroTag: 'screenSharing', - onPressed: _screenSharing, - foregroundColor: isScreensharingEnabled ? Colors.black26 : Colors.white, - backgroundColor: isScreensharingEnabled ? Colors.white : Colors.black45, - child: const Icon(Icons.desktop_mac), - ); - - final holdButton = FloatingActionButton( - heroTag: 'hold', - onPressed: _remoteOnHold, - foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white, - backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45, - child: const Icon(Icons.pause), - ); - - final muteCameraButton = FloatingActionButton( - heroTag: 'muteCam', - onPressed: _muteCamera, - foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white, - backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45, - child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam), - ); - - switch (_state) { - case CallState.kRinging: - case CallState.kInviteSent: - case CallState.kCreateAnswer: - case CallState.kConnecting: - return call.isOutgoing - ? [hangupButton] - : [answerButton, hangupButton]; - case CallState.kConnected: - return [ - muteMicButton, - //switchSpeakerButton, - if (!voiceonly && !kIsWeb) switchCameraButton, - if (!voiceonly) muteCameraButton, - if (PlatformInfos.isMobile || PlatformInfos.isWeb) - screenSharingButton, - holdButton, - hangupButton, - ]; - case CallState.kEnded: - return [hangupButton]; - case CallState.kFledgling: - case CallState.kWaitLocalMedia: - case CallState.kCreateOffer: - case CallState.kEnding: - case null: - break; - } - return []; - } - - List _buildContent(Orientation orientation, bool isFloating) { - final stackWidgets = []; - - final call = this.call; - if (call.callHasEnded) { - return stackWidgets; - } - - if (call.localHold || call.remoteOnHold) { - var title = ''; - if (call.localHold) { - title = - '${call.room.getLocalizedDisplayname(MatrixLocals(L10n.of(widget.context)))} held the call.'; - } else if (call.remoteOnHold) { - title = 'You held the call.'; - } - stackWidgets.add( - Center( - child: Column( - mainAxisAlignment: .center, - children: [ - const Icon(Icons.pause, size: 48.0, color: Colors.white), - Text( - title, - style: const TextStyle(color: Colors.white, fontSize: 24.0), - ), - ], - ), - ), - ); - return stackWidgets; - } - - var primaryStream = - call.remoteScreenSharingStream ?? - call.localScreenSharingStream ?? - call.remoteUserMediaStream ?? - call.localUserMediaStream; - - if (!connected) { - primaryStream = call.localUserMediaStream; - } - - if (primaryStream != null) { - stackWidgets.add( - Center( - child: _StreamView( - primaryStream, - mainView: true, - matrixClient: widget.client, - ), - ), - ); - } - - if (isFloating || !connected) { - return stackWidgets; - } - - _resizeLocalVideo(orientation); - - if (call.getRemoteStreams.isEmpty) { - return stackWidgets; - } - - final secondaryStreamViews = []; - - if (call.remoteScreenSharingStream != null) { - final remoteUserMediaStream = call.remoteUserMediaStream; - secondaryStreamViews.add( - SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView( - remoteUserMediaStream!, - matrixClient: widget.client, - ), - ), - ); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - final localStream = - call.localUserMediaStream ?? call.localScreenSharingStream; - if (localStream != null && !isFloating) { - secondaryStreamViews.add( - SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView(localStream, matrixClient: widget.client), - ), - ); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - if (call.localScreenSharingStream != null && !isFloating) { - secondaryStreamViews.add( - SizedBox( - width: _localVideoWidth, - height: _localVideoHeight, - child: _StreamView( - call.remoteUserMediaStream!, - matrixClient: widget.client, - ), - ), - ); - secondaryStreamViews.add(const SizedBox(height: 10)); - } - - if (secondaryStreamViews.isNotEmpty) { - stackWidgets.add( - Container( - padding: const EdgeInsets.only(top: 20, bottom: 120), - alignment: Alignment.bottomRight, - child: Container( - width: _localVideoWidth, - margin: _localVideoMargin, - child: Column(children: secondaryStreamViews), - ), - ), - ); - } - - return stackWidgets; - } - @override Widget build(BuildContext context) { return PIPView( builder: (context, isFloating) { + // Build action buttons + final switchCameraButton = FloatingActionButton( + heroTag: 'switchCamera', + onPressed: _switchCamera, + backgroundColor: Colors.black45, + child: const Icon(Icons.switch_camera), + ); + final hangupButton = FloatingActionButton( + heroTag: 'hangup', + onPressed: _hangUp, + tooltip: 'Hangup', + backgroundColor: _state == CallState.kEnded + ? Colors.black45 + : Colors.red, + child: const Icon(Icons.call_end), + ); + final answerButton = FloatingActionButton( + heroTag: 'answer', + onPressed: _answerCall, + tooltip: 'Answer', + backgroundColor: Colors.green, + child: const Icon(Icons.phone), + ); + final muteMicButton = FloatingActionButton( + heroTag: 'muteMic', + onPressed: _muteMic, + foregroundColor: isMicrophoneMuted ? Colors.black26 : Colors.white, + backgroundColor: isMicrophoneMuted ? Colors.white : Colors.black45, + child: Icon(isMicrophoneMuted ? Icons.mic_off : Icons.mic), + ); + final screenSharingButton = FloatingActionButton( + heroTag: 'screenSharing', + onPressed: _screenSharing, + foregroundColor: isScreensharingEnabled + ? Colors.black26 + : Colors.white, + backgroundColor: isScreensharingEnabled + ? Colors.white + : Colors.black45, + child: const Icon(Icons.desktop_mac), + ); + final holdButton = FloatingActionButton( + heroTag: 'hold', + onPressed: _remoteOnHold, + foregroundColor: isRemoteOnHold ? Colors.black26 : Colors.white, + backgroundColor: isRemoteOnHold ? Colors.white : Colors.black45, + child: const Icon(Icons.pause), + ); + final muteCameraButton = FloatingActionButton( + heroTag: 'muteCam', + onPressed: _muteCamera, + foregroundColor: isLocalVideoMuted ? Colors.black26 : Colors.white, + backgroundColor: isLocalVideoMuted ? Colors.white : Colors.black45, + child: Icon(isLocalVideoMuted ? Icons.videocam_off : Icons.videocam), + ); + + late final List actionButtons; + if (!isFloating) { + switch (_state) { + case CallState.kRinging: + case CallState.kInviteSent: + case CallState.kCreateAnswer: + case CallState.kConnecting: + actionButtons = call.isOutgoing + ? [hangupButton] + : [answerButton, hangupButton]; + break; + case CallState.kConnected: + actionButtons = [ + muteMicButton, + if (!voiceonly && !kIsWeb) switchCameraButton, + if (!voiceonly) muteCameraButton, + if (PlatformInfos.isMobile || PlatformInfos.isWeb) + screenSharingButton, + holdButton, + hangupButton, + ]; + break; + case CallState.kEnded: + actionButtons = [hangupButton]; + break; + case CallState.kFledgling: + case CallState.kWaitLocalMedia: + case CallState.kCreateOffer: + case CallState.kEnding: + case null: + actionButtons = []; + break; + } + } else { + actionButtons = []; + } + return Scaffold( resizeToAvoidBottomInset: !isFloating, floatingActionButtonLocation: @@ -577,16 +445,147 @@ class MyCallingPage extends State { height: 150.0, child: Row( mainAxisAlignment: .spaceAround, - children: _buildActionButtons(isFloating), + children: actionButtons, ), ), body: OrientationBuilder( builder: (BuildContext context, Orientation orientation) { + final stackWidgets = []; + + final callHasEnded = call.callHasEnded; + if (!callHasEnded) { + if (call.localHold || call.remoteOnHold) { + var title = ''; + if (call.localHold) { + title = + '${call.room.getLocalizedDisplayname(MatrixLocals(L10n.of(widget.context)))} held the call.'; + } else if (call.remoteOnHold) { + title = 'You held the call.'; + } + stackWidgets.add( + Center( + child: Column( + mainAxisAlignment: .center, + children: [ + const Icon( + Icons.pause, + size: 48.0, + color: Colors.white, + ), + Text( + title, + style: const TextStyle( + color: Colors.white, + fontSize: 24.0, + ), + ), + ], + ), + ), + ); + } else { + var primaryStream = + call.remoteScreenSharingStream ?? + call.localScreenSharingStream ?? + call.remoteUserMediaStream ?? + call.localUserMediaStream; + + if (!connected) { + primaryStream = call.localUserMediaStream; + } + + if (primaryStream != null) { + stackWidgets.add( + Center( + child: _StreamView( + primaryStream, + mainView: true, + matrixClient: widget.client, + ), + ), + ); + } + + if (!isFloating && connected) { + _resizeLocalVideo(orientation); + + if (call.getRemoteStreams.isNotEmpty) { + final secondaryStreamViews = []; + + if (call.remoteScreenSharingStream != null) { + final remoteUserMediaStream = + call.remoteUserMediaStream; + secondaryStreamViews.add( + SizedBox( + width: _localVideoWidth, + height: _localVideoHeight, + child: _StreamView( + remoteUserMediaStream!, + matrixClient: widget.client, + ), + ), + ); + secondaryStreamViews.add(const SizedBox(height: 10)); + } + + final localStream = + call.localUserMediaStream ?? + call.localScreenSharingStream; + if (localStream != null && !isFloating) { + secondaryStreamViews.add( + SizedBox( + width: _localVideoWidth, + height: _localVideoHeight, + child: _StreamView( + localStream, + matrixClient: widget.client, + ), + ), + ); + secondaryStreamViews.add(const SizedBox(height: 10)); + } + + if (call.localScreenSharingStream != null && + !isFloating) { + secondaryStreamViews.add( + SizedBox( + width: _localVideoWidth, + height: _localVideoHeight, + child: _StreamView( + call.remoteUserMediaStream!, + matrixClient: widget.client, + ), + ), + ); + secondaryStreamViews.add(const SizedBox(height: 10)); + } + + if (secondaryStreamViews.isNotEmpty) { + stackWidgets.add( + Container( + padding: const EdgeInsets.only( + top: 20, + bottom: 120, + ), + alignment: Alignment.bottomRight, + child: Container( + width: _localVideoWidth, + margin: _localVideoMargin, + child: Column(children: secondaryStreamViews), + ), + ), + ); + } + } + } + } + } + return Container( decoration: const BoxDecoration(color: Colors.black87), child: Stack( children: [ - ..._buildContent(orientation, isFloating), + ...stackWidgets, if (!isFloating) Positioned( top: 24.0, diff --git a/lib/utils/markdown_context_builder.dart b/lib/utils/markdown_context_builder.dart index 18b5d5117..f03da17fb 100644 --- a/lib/utils/markdown_context_builder.dart +++ b/lib/utils/markdown_context_builder.dart @@ -3,128 +3,136 @@ import 'package:flutter/material.dart'; import 'package:fluffychat/l10n/l10n.dart'; import 'package:fluffychat/widgets/adaptive_dialogs/show_text_input_dialog.dart'; -Widget markdownContextBuilder( - BuildContext context, - EditableTextState editableTextState, - TextEditingController controller, -) { - final value = editableTextState.textEditingValue; - final selectedText = value.selection.textInside(value.text); - final buttonItems = editableTextState.contextMenuButtonItems; - final l10n = L10n.of(context); +class MarkdownContextBuilder extends StatelessWidget { + final EditableTextState editableTextState; + final TextEditingController controller; - return AdaptiveTextSelectionToolbar.buttonItems( - anchors: editableTextState.contextMenuAnchors, - buttonItems: [ - ...buttonItems, - if (selectedText.isNotEmpty) ...[ - ContextMenuButtonItem( - label: l10n.link, - onPressed: () async { - final input = await showTextInputDialog( - context: context, - title: l10n.addLink, - okLabel: l10n.ok, - cancelLabel: l10n.cancel, - validator: (text) { - if (text.isEmpty) { - return l10n.pleaseFillOut; - } - try { - text.startsWith('http') ? Uri.parse(text) : Uri.https(text); - } catch (_) { - return l10n.invalidUrl; - } - return null; - }, - hintText: 'www...', - keyboardType: TextInputType.url, - ); - final urlString = input; - if (urlString == null) return; - final url = urlString.startsWith('http') - ? Uri.parse(urlString) - : Uri.https(urlString); - final selection = controller.selection; - controller.text = controller.text.replaceRange( - selection.start, - selection.end, - '[$selectedText](${url.toString()})', - ); - ContextMenuController.removeAny(); - }, - ), - ContextMenuButtonItem( - label: l10n.checkList, - onPressed: () { - final text = controller.text; - final selection = controller.selection; + const MarkdownContextBuilder({ + required this.editableTextState, + required this.controller, + super.key, + }); - var start = selection.textBefore(text).lastIndexOf('\n'); - if (start == -1) start = 0; - final end = selection.end; + @override + Widget build(BuildContext context) { + final value = editableTextState.textEditingValue; + final selectedText = value.selection.textInside(value.text); + final buttonItems = editableTextState.contextMenuButtonItems; + final l10n = L10n.of(context); - final fullLineSelection = TextSelection( - baseOffset: start, - extentOffset: end, - ); + return AdaptiveTextSelectionToolbar.buttonItems( + anchors: editableTextState.contextMenuAnchors, + buttonItems: [ + ...buttonItems, + if (selectedText.isNotEmpty) ...[ + ContextMenuButtonItem( + label: l10n.link, + onPressed: () async { + final input = await showTextInputDialog( + context: context, + title: l10n.addLink, + okLabel: l10n.ok, + cancelLabel: l10n.cancel, + validator: (text) { + if (text.isEmpty) { + return l10n.pleaseFillOut; + } + try { + text.startsWith('http') ? Uri.parse(text) : Uri.https(text); + } catch (_) { + return l10n.invalidUrl; + } + return null; + }, + hintText: 'www...', + keyboardType: TextInputType.url, + ); + final urlString = input; + if (urlString == null) return; + final url = urlString.startsWith('http') + ? Uri.parse(urlString) + : Uri.https(urlString); + final selection = controller.selection; + controller.text = controller.text.replaceRange( + selection.start, + selection.end, + '[$selectedText](${url.toString()})', + ); + ContextMenuController.removeAny(); + }, + ), + ContextMenuButtonItem( + label: l10n.checkList, + onPressed: () { + final text = controller.text; + final selection = controller.selection; - const checkBox = '- [ ]'; + var start = selection.textBefore(text).lastIndexOf('\n'); + if (start == -1) start = 0; + final end = selection.end; - final replacedRange = fullLineSelection - .textInside(text) - .split('\n') - .map( - (line) => line.startsWith(checkBox) || line.isEmpty - ? line - : '$checkBox $line', - ) - .join('\n'); - controller.text = controller.text.replaceRange( - start, - end, - replacedRange, - ); - ContextMenuController.removeAny(); - }, - ), - ContextMenuButtonItem( - label: l10n.boldText, - onPressed: () { - final selection = controller.selection; - controller.text = controller.text.replaceRange( - selection.start, - selection.end, - '**$selectedText**', - ); - ContextMenuController.removeAny(); - }, - ), - ContextMenuButtonItem( - label: l10n.italicText, - onPressed: () { - final selection = controller.selection; - controller.text = controller.text.replaceRange( - selection.start, - selection.end, - '*$selectedText*', - ); - ContextMenuController.removeAny(); - }, - ), - ContextMenuButtonItem( - label: l10n.strikeThrough, - onPressed: () { - final selection = controller.selection; - controller.text = controller.text.replaceRange( - selection.start, - selection.end, - '~~$selectedText~~', - ); - ContextMenuController.removeAny(); - }, - ), + final fullLineSelection = TextSelection( + baseOffset: start, + extentOffset: end, + ); + + const checkBox = '- [ ]'; + + final replacedRange = fullLineSelection + .textInside(text) + .split('\n') + .map( + (line) => line.startsWith(checkBox) || line.isEmpty + ? line + : '$checkBox $line', + ) + .join('\n'); + controller.text = controller.text.replaceRange( + start, + end, + replacedRange, + ); + ContextMenuController.removeAny(); + }, + ), + ContextMenuButtonItem( + label: l10n.boldText, + onPressed: () { + final selection = controller.selection; + controller.text = controller.text.replaceRange( + selection.start, + selection.end, + '**$selectedText**', + ); + ContextMenuController.removeAny(); + }, + ), + ContextMenuButtonItem( + label: l10n.italicText, + onPressed: () { + final selection = controller.selection; + controller.text = controller.text.replaceRange( + selection.start, + selection.end, + '*$selectedText*', + ); + ContextMenuController.removeAny(); + }, + ), + ContextMenuButtonItem( + label: l10n.strikeThrough, + onPressed: () { + final selection = controller.selection; + controller.text = controller.text.replaceRange( + selection.start, + selection.end, + '~~$selectedText~~', + ); + ContextMenuController.removeAny(); + }, + ), + ], ], - ], - ); + ); + } } diff --git a/lib/widgets/mxc_image.dart b/lib/widgets/mxc_image.dart index 1572fa6b9..680fe9e2a 100644 --- a/lib/widgets/mxc_image.dart +++ b/lib/widgets/mxc_image.dart @@ -128,15 +128,6 @@ class _MxcImageState extends State { WidgetsBinding.instance.addPostFrameCallback((_) => _tryLoad()); } - Widget placeholder(BuildContext context) => - widget.placeholder?.call(context) ?? - Container( - width: widget.width, - height: widget.height, - alignment: Alignment.center, - child: const CircularProgressIndicator.adaptive(strokeWidth: 2), - ); - @override Widget build(BuildContext context) { final data = _imageData; @@ -172,7 +163,34 @@ class _MxcImageState extends State { }, ), ) - : placeholder(context), + : _MxcImagePlaceholder( + width: widget.width, + height: widget.height, + placeholder: widget.placeholder, + ), ); } } + +class _MxcImagePlaceholder extends StatelessWidget { + final double? width; + final double? height; + final Widget Function(BuildContext context)? placeholder; + + const _MxcImagePlaceholder({ + required this.width, + required this.height, + required this.placeholder, + }); + + @override + Widget build(BuildContext context) { + return placeholder?.call(context) ?? + Container( + width: width, + height: height, + alignment: Alignment.center, + child: const CircularProgressIndicator.adaptive(strokeWidth: 2), + ); + } +}