💄 Optimize the embed view experience

This commit is contained in:
2025-09-23 21:02:14 +08:00
parent cccade763f
commit 27d478ba4f
4 changed files with 339 additions and 238 deletions

View File

@@ -1012,6 +1012,11 @@
"expandPoll": "Expand Poll", "expandPoll": "Expand Poll",
"collapsePoll": "Collapse Poll", "collapsePoll": "Collapse Poll",
"embedView": "Embed View", "embedView": "Embed View",
"auto": "Auto",
"manual": "Manual",
"iframeCode": "Iframe Code",
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
"parseIframe": "Parse Iframe",
"embedUri": "Embed URI", "embedUri": "Embed URI",
"aspectRatio": "Aspect Ratio", "aspectRatio": "Aspect Ratio",
"renderer": "Renderer", "renderer": "Renderer",
@@ -1023,5 +1028,6 @@
"noEmbed": "No embed yet", "noEmbed": "No embed yet",
"save": "Save", "save": "Save",
"webView": "Web View", "webView": "Web View",
"messageActions": "Message Actions" "messageActions": "Message Actions",
"viewEmbedLoadHint": "Tap to load"
} }

View File

@@ -30,10 +30,13 @@ class ComposeEmbedSheet extends HookConsumerWidget {
final selectedRenderer = useState<PostEmbedViewRenderer>( final selectedRenderer = useState<PostEmbedViewRenderer>(
PostEmbedViewRenderer.webView, PostEmbedViewRenderer.webView,
); );
final tabController = useTabController(initialLength: 2);
final iframeController = useTextEditingController();
void clearForm() { void clearForm() {
uriController.clear(); uriController.clear();
aspectRatioController.clear(); aspectRatioController.clear();
iframeController.clear();
selectedRenderer.value = PostEmbedViewRenderer.webView; selectedRenderer.value = PostEmbedViewRenderer.webView;
} }
@@ -77,6 +80,57 @@ class ComposeEmbedSheet extends HookConsumerWidget {
} }
} }
void parseIframe() {
final iframe = iframeController.text.trim();
if (iframe.isEmpty) return;
final srcMatch = RegExp(r'src="([^"]*)"').firstMatch(iframe);
final widthMatch = RegExp(r'width="([^"]*)"').firstMatch(iframe);
final heightMatch = RegExp(r'height="([^"]*)"').firstMatch(iframe);
if (srcMatch != null) {
uriController.text = srcMatch.group(1)!;
}
if (widthMatch != null && heightMatch != null) {
final w = double.tryParse(widthMatch.group(1)!);
final h = double.tryParse(heightMatch.group(1)!);
if (w != null && h != null && h != 0) {
aspectRatioController.text = (w / h).toStringAsFixed(3);
}
}
tabController.animateTo(1);
}
void deleteEmbed(BuildContext context) {
showDialog(
context: context,
builder:
(dialogContext) => AlertDialog(
title: Text('deleteEmbed').tr(),
content: Text('deleteEmbedConfirm').tr(),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: Text('cancel').tr(),
),
TextButton(
onPressed: () {
ComposeLogic.deleteEmbedView(state);
clearForm();
Navigator.of(dialogContext).pop();
},
style: TextButton.styleFrom(
foregroundColor: Theme.of(context).colorScheme.error,
),
child: Text('delete').tr(),
),
],
),
);
}
return SheetScaffold( return SheetScaffold(
titleText: 'embedView'.tr(), titleText: 'embedView'.tr(),
heightFactor: 0.7, heightFactor: 0.7,
@@ -85,7 +139,7 @@ class ComposeEmbedSheet extends HookConsumerWidget {
// Header with save button when editing // Header with save button when editing
if (currentEmbedView != null) if (currentEmbedView != null)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
color: Theme.of(context).colorScheme.surfaceContainerHigh, color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Row( child: Row(
children: [ children: [
@@ -97,187 +151,207 @@ class ComposeEmbedSheet extends HookConsumerWidget {
), ),
TextButton( TextButton(
onPressed: saveEmbedView, onPressed: saveEmbedView,
style: ButtonStyle(visualDensity: VisualDensity.compact),
child: Text('save'.tr()), child: Text('save'.tr()),
), ),
], ],
), ),
), ),
// Tab bar
TabBar(
controller: tabController,
tabs: [Tab(text: 'auto'.tr()), Tab(text: 'manual'.tr())],
),
// Content area // Content area
Expanded( Expanded(
child: SingleChildScrollView( child: TabBarView(
padding: const EdgeInsets.all(16), controller: tabController,
child: Column( children: [
crossAxisAlignment: CrossAxisAlignment.start, // Auto tab
children: [ SingleChildScrollView(
// Form fields padding: const EdgeInsets.all(16),
TextField( child: Column(
controller: uriController, crossAxisAlignment: CrossAxisAlignment.start,
decoration: InputDecoration( children: [
labelText: 'embedUri'.tr(), TextField(
hintText: 'https://example.com', controller: iframeController,
border: OutlineInputBorder( decoration: InputDecoration(
borderRadius: BorderRadius.circular(8), labelText: 'iframeCode'.tr(),
hintText: 'iframeCodeHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
maxLines: 5,
), ),
), const Gap(16),
keyboardType: TextInputType.url, SizedBox(
), width: double.infinity,
const Gap(16), child: FilledButton.icon(
TextField( onPressed: parseIframe,
controller: aspectRatioController, icon: const Icon(Symbols.auto_fix),
decoration: InputDecoration( label: Text('parseIframe'.tr()),
labelText: 'aspectRatio'.tr(), ),
hintText: '16/9 = 1.777',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
), ),
),
keyboardType: TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(RegExp(r'^\d*\.?\d*$')),
], ],
), ),
const Gap(16), ),
DropdownButtonFormField2<PostEmbedViewRenderer>( // Manual tab
value: selectedRenderer.value, SingleChildScrollView(
decoration: InputDecoration( padding: const EdgeInsets.all(16),
labelText: 'renderer'.tr(), child: Column(
border: OutlineInputBorder( crossAxisAlignment: CrossAxisAlignment.start,
borderRadius: BorderRadius.circular(8), children: [
), // Form fields
), TextField(
items: controller: uriController,
PostEmbedViewRenderer.values.map((renderer) { decoration: InputDecoration(
return DropdownMenuItem( labelText: 'embedUri'.tr(),
value: renderer, hintText: 'https://example.com',
child: Text(renderer.name).tr(), border: OutlineInputBorder(
); borderRadius: BorderRadius.circular(8),
}).toList(), ),
onChanged: (value) {
if (value != null) {
selectedRenderer.value = value;
}
},
),
// Current embed view display (when exists)
if (currentEmbedView != null) ...[
const Gap(32),
Text(
'currentEmbed'.tr(),
style: theme.textTheme.titleMedium,
).padding(horizontal: 4),
const Gap(8),
Card(
margin: EdgeInsets.zero,
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 12,
top: 4,
), ),
child: Column( keyboardType: TextInputType.url,
crossAxisAlignment: CrossAxisAlignment.start, ),
children: [ const Gap(16),
Row( TextField(
controller: aspectRatioController,
decoration: InputDecoration(
labelText: 'aspectRatio'.tr(),
hintText: '16/9 = 1.777',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
keyboardType: TextInputType.numberWithOptions(
decimal: true,
),
inputFormatters: [
FilteringTextInputFormatter.allow(
RegExp(r'^\d*\.?\d*$'),
),
],
),
const Gap(16),
DropdownButtonFormField2<PostEmbedViewRenderer>(
value: selectedRenderer.value,
decoration: InputDecoration(
labelText: 'renderer'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
selectedItemBuilder: (context) {
return PostEmbedViewRenderer.values.map((renderer) {
return Text(renderer.name).tr();
}).toList();
},
menuItemStyleData: MenuItemStyleData(
padding: EdgeInsets.zero,
),
items:
PostEmbedViewRenderer.values.map((renderer) {
return DropdownMenuItem(
value: renderer,
child: Text(
renderer.name,
).tr().padding(horizontal: 20),
);
}).toList(),
onChanged: (value) {
if (value != null) {
selectedRenderer.value = value;
}
},
),
// Current embed view display (when exists)
if (currentEmbedView != null) ...[
const Gap(32),
Text(
'currentEmbed'.tr(),
style: theme.textTheme.titleMedium,
).padding(horizontal: 4),
const Gap(8),
Card(
margin: EdgeInsets.zero,
color:
Theme.of(
context,
).colorScheme.surfaceContainerHigh,
child: Padding(
padding: const EdgeInsets.only(
left: 16,
right: 16,
bottom: 12,
top: 4,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Icon( Row(
currentEmbedView.renderer == children: [
PostEmbedViewRenderer.webView Icon(
? Symbols.web currentEmbedView.renderer ==
: Symbols.web, PostEmbedViewRenderer.webView
color: colorScheme.primary, ? Symbols.web
: Symbols.web,
color: colorScheme.primary,
),
const Gap(12),
Expanded(
child: Text(
currentEmbedView.uri,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () => deleteEmbed(context),
tooltip: 'delete'.tr(),
color: colorScheme.error,
),
],
), ),
const Gap(12), const Gap(12),
Expanded( Text(
child: Text( 'aspectRatio'.tr(),
currentEmbedView.uri, style: theme.textTheme.labelMedium?.copyWith(
style: theme.textTheme.bodyMedium, color: colorScheme.onSurfaceVariant,
maxLines: 1,
overflow: TextOverflow.ellipsis,
), ),
), ),
IconButton( const Gap(4),
icon: const Icon(Symbols.delete), Text(
onPressed: () { currentEmbedView.aspectRatio != null
showDialog( ? currentEmbedView.aspectRatio!
context: context, .toStringAsFixed(2)
builder: : 'notSet'.tr(),
(dialogContext) => AlertDialog( style: theme.textTheme.bodyMedium,
title: Text('deleteEmbed').tr(),
content:
Text('deleteEmbedConfirm').tr(),
actions: [
TextButton(
onPressed:
() =>
Navigator.of(
dialogContext,
).pop(),
child: Text('cancel'.tr()),
),
TextButton(
onPressed: () {
ComposeLogic.deleteEmbedView(
state,
);
clearForm();
Navigator.of(
dialogContext,
).pop();
},
style: TextButton.styleFrom(
foregroundColor:
colorScheme.error,
),
child: Text('delete').tr(),
),
],
),
);
},
tooltip: 'delete'.tr(),
color: colorScheme.error,
), ),
], ],
), ),
const Gap(12), ),
Text(
'aspectRatio'.tr(),
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
const Gap(4),
Text(
currentEmbedView.aspectRatio != null
? currentEmbedView.aspectRatio!
.toStringAsFixed(2)
: 'notSet'.tr(),
style: theme.textTheme.bodyMedium,
),
],
), ),
), ] else ...[
), const Gap(16),
] else ...[ SizedBox(
// Save button for new embed width: double.infinity,
const Gap(16), child: FilledButton.icon(
SizedBox( onPressed: saveEmbedView,
width: double.infinity, icon: const Icon(Symbols.add),
child: FilledButton.icon( label: Text('addEmbed'.tr()),
onPressed: saveEmbedView, ),
icon: const Icon(Symbols.add), ),
label: Text('addEmbed'.tr()), ],
), ],
), ),
], ),
], ],
),
), ),
), ),
], ],

View File

@@ -82,75 +82,83 @@ class ComposeToolbar extends HookConsumerWidget {
constraints: const BoxConstraints(maxWidth: 560), constraints: const BoxConstraints(maxWidth: 560),
child: Row( child: Row(
children: [ children: [
IconButton( Expanded(
onPressed: pickPhotoMedia, child: SingleChildScrollView(
tooltip: 'addPhoto'.tr(), scrollDirection: Axis.horizontal,
icon: const Icon(Symbols.add_a_photo), child: Row(
color: colorScheme.primary, children: [
), IconButton(
IconButton( onPressed: pickPhotoMedia,
onPressed: pickVideoMedia, tooltip: 'addPhoto'.tr(),
tooltip: 'addVideo'.tr(), icon: const Icon(Symbols.add_a_photo),
icon: const Icon(Symbols.videocam), color: colorScheme.primary,
color: colorScheme.primary,
),
IconButton(
onPressed: addAudio,
tooltip: 'addAudio'.tr(),
icon: const Icon(Symbols.mic),
color: colorScheme.primary,
),
IconButton(
onPressed: pickGeneralFile,
tooltip: 'uploadFile'.tr(),
icon: const Icon(Symbols.file_upload),
color: colorScheme.primary,
),
IconButton(
onPressed: linkAttachment,
icon: const Icon(Symbols.attach_file),
tooltip: 'linkAttachment'.tr(),
color: colorScheme.primary,
),
// Poll button with visual state when a poll is linked
ListenableBuilder(
listenable: state.pollId,
builder: (context, _) {
return IconButton(
onPressed: pickPoll,
icon: const Icon(Symbols.how_to_vote),
tooltip: 'poll'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.pollId.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
), ),
), IconButton(
); onPressed: pickVideoMedia,
}, tooltip: 'addVideo'.tr(),
), icon: const Icon(Symbols.videocam),
// Embed button with visual state when embed is present color: colorScheme.primary,
ListenableBuilder(
listenable: state.embedView,
builder: (context, _) {
return IconButton(
onPressed: showEmbedSheet,
icon: const Icon(Symbols.web),
tooltip: 'embedView'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.embedView.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
), ),
), IconButton(
); onPressed: addAudio,
}, tooltip: 'addAudio'.tr(),
icon: const Icon(Symbols.mic),
color: colorScheme.primary,
),
IconButton(
onPressed: pickGeneralFile,
tooltip: 'uploadFile'.tr(),
icon: const Icon(Symbols.file_upload),
color: colorScheme.primary,
),
IconButton(
onPressed: linkAttachment,
icon: const Icon(Symbols.attach_file),
tooltip: 'linkAttachment'.tr(),
color: colorScheme.primary,
),
// Poll button with visual state when a poll is linked
ListenableBuilder(
listenable: state.pollId,
builder: (context, _) {
return IconButton(
onPressed: pickPoll,
icon: const Icon(Symbols.how_to_vote),
tooltip: 'poll'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.pollId.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
),
),
);
},
),
// Embed button with visual state when embed is present
ListenableBuilder(
listenable: state.embedView,
builder: (context, _) {
return IconButton(
onPressed: showEmbedSheet,
icon: const Icon(Symbols.iframe),
tooltip: 'embedView'.tr(),
color: colorScheme.primary,
style: ButtonStyle(
backgroundColor: WidgetStatePropertyAll(
state.embedView.value != null
? colorScheme.primary.withOpacity(0.15)
: null,
),
),
);
},
),
],
),
),
), ),
const Spacer(),
if (originalPost == null && state.isEmpty) if (originalPost == null && state.isEmpty)
IconButton( IconButton(
icon: const Icon(Symbols.draft), icon: const Icon(Symbols.draft),

View File

@@ -1,3 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@@ -58,8 +61,8 @@ class EmbedViewRenderer extends HookConsumerWidget {
children: [ children: [
Icon( Icon(
embedView.renderer == PostEmbedViewRenderer.webView embedView.renderer == PostEmbedViewRenderer.webView
? Symbols.web ? Symbols.globe
: Symbols.web, : Symbols.iframe,
size: 16, size: 16,
color: colorScheme.primary, color: colorScheme.primary,
), ),
@@ -74,13 +77,13 @@ class EmbedViewRenderer extends HookConsumerWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
), ),
IconButton( InkWell(
icon: Icon( child: Icon(
Symbols.open_in_new, Symbols.open_in_new,
size: 16, size: 16,
color: colorScheme.onSurfaceVariant, color: colorScheme.onSurfaceVariant,
), ),
onPressed: () async { onTap: () async {
final uri = Uri.parse(embedView.uri); final uri = Uri.parse(embedView.uri);
if (await canLaunchUrl(uri)) { if (await canLaunchUrl(uri)) {
await launchUrl( await launchUrl(
@@ -89,10 +92,6 @@ class EmbedViewRenderer extends HookConsumerWidget {
); );
} }
}, },
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
visualDensity: VisualDensity.compact,
tooltip: 'Open in browser',
), ),
], ],
), ),
@@ -106,6 +105,20 @@ class EmbedViewRenderer extends HookConsumerWidget {
? Stack( ? Stack(
children: [ children: [
InAppWebView( InAppWebView(
gestureRecognizers: {
Factory<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
),
Factory<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(),
),
Factory<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
),
Factory<TapGestureRecognizer>(
() => TapGestureRecognizer(),
),
},
initialUrlRequest: URLRequest( initialUrlRequest: URLRequest(
url: WebUri(embedView.uri), url: WebUri(embedView.uri),
), ),
@@ -256,14 +269,14 @@ class EmbedViewRenderer extends HookConsumerWidget {
children: [ children: [
Icon( Icon(
Symbols.play_arrow, Symbols.play_arrow,
fill: 1,
size: 48, size: 48,
color: colorScheme.onSurfaceVariant.withOpacity( color: colorScheme.onSurfaceVariant.withOpacity(
0.6, 0.6,
), ),
), ),
const SizedBox(height: 8),
Text( Text(
'Tap to load content', 'embedViewLoadHint'.tr(),
style: theme.textTheme.bodyMedium?.copyWith( style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant color: colorScheme.onSurfaceVariant
.withOpacity(0.6), .withOpacity(0.6),