💄 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",
"collapsePoll": "Collapse Poll",
"embedView": "Embed View",
"auto": "Auto",
"manual": "Manual",
"iframeCode": "Iframe Code",
"iframeCodeHint": "<iframe src=\"...\" width=\"...\" height=\"...\">",
"parseIframe": "Parse Iframe",
"embedUri": "Embed URI",
"aspectRatio": "Aspect Ratio",
"renderer": "Renderer",
@@ -1023,5 +1028,6 @@
"noEmbed": "No embed yet",
"save": "Save",
"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>(
PostEmbedViewRenderer.webView,
);
final tabController = useTabController(initialLength: 2);
final iframeController = useTextEditingController();
void clearForm() {
uriController.clear();
aspectRatioController.clear();
iframeController.clear();
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(
titleText: 'embedView'.tr(),
heightFactor: 0.7,
@@ -85,7 +139,7 @@ class ComposeEmbedSheet extends HookConsumerWidget {
// Header with save button when editing
if (currentEmbedView != null)
Container(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 6),
color: Theme.of(context).colorScheme.surfaceContainerHigh,
child: Row(
children: [
@@ -97,187 +151,207 @@ class ComposeEmbedSheet extends HookConsumerWidget {
),
TextButton(
onPressed: saveEmbedView,
style: ButtonStyle(visualDensity: VisualDensity.compact),
child: Text('save'.tr()),
),
],
),
),
// Tab bar
TabBar(
controller: tabController,
tabs: [Tab(text: 'auto'.tr()), Tab(text: 'manual'.tr())],
),
// Content area
Expanded(
child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Form fields
TextField(
controller: uriController,
decoration: InputDecoration(
labelText: 'embedUri'.tr(),
hintText: 'https://example.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
child: TabBarView(
controller: tabController,
children: [
// Auto tab
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: iframeController,
decoration: InputDecoration(
labelText: 'iframeCode'.tr(),
hintText: 'iframeCodeHint'.tr(),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
maxLines: 5,
),
),
keyboardType: TextInputType.url,
),
const Gap(16),
TextField(
controller: aspectRatioController,
decoration: InputDecoration(
labelText: 'aspectRatio'.tr(),
hintText: '16/9 = 1.777',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
const Gap(16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: parseIframe,
icon: const Icon(Symbols.auto_fix),
label: Text('parseIframe'.tr()),
),
),
),
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),
),
),
items:
PostEmbedViewRenderer.values.map((renderer) {
return DropdownMenuItem(
value: renderer,
child: Text(renderer.name).tr(),
);
}).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,
),
// Manual tab
SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Form fields
TextField(
controller: uriController,
decoration: InputDecoration(
labelText: 'embedUri'.tr(),
hintText: 'https://example.com',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
keyboardType: TextInputType.url,
),
const Gap(16),
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: [
Icon(
currentEmbedView.renderer ==
PostEmbedViewRenderer.webView
? Symbols.web
: Symbols.web,
color: colorScheme.primary,
Row(
children: [
Icon(
currentEmbedView.renderer ==
PostEmbedViewRenderer.webView
? 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),
Expanded(
child: Text(
currentEmbedView.uri,
style: theme.textTheme.bodyMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
Text(
'aspectRatio'.tr(),
style: theme.textTheme.labelMedium?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
IconButton(
icon: const Icon(Symbols.delete),
onPressed: () {
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:
colorScheme.error,
),
child: Text('delete').tr(),
),
],
),
);
},
tooltip: 'delete'.tr(),
color: colorScheme.error,
const Gap(4),
Text(
currentEmbedView.aspectRatio != null
? currentEmbedView.aspectRatio!
.toStringAsFixed(2)
: 'notSet'.tr(),
style: theme.textTheme.bodyMedium,
),
],
),
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 ...[
// Save button for new embed
const Gap(16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: saveEmbedView,
icon: const Icon(Symbols.add),
label: Text('addEmbed'.tr()),
),
),
],
],
),
] else ...[
const Gap(16),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
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),
child: Row(
children: [
IconButton(
onPressed: pickPhotoMedia,
tooltip: 'addPhoto'.tr(),
icon: const Icon(Symbols.add_a_photo),
color: colorScheme.primary,
),
IconButton(
onPressed: pickVideoMedia,
tooltip: 'addVideo'.tr(),
icon: const Icon(Symbols.videocam),
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,
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: [
IconButton(
onPressed: pickPhotoMedia,
tooltip: 'addPhoto'.tr(),
icon: const Icon(Symbols.add_a_photo),
color: colorScheme.primary,
),
),
);
},
),
// Embed button with visual state when embed is present
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: pickVideoMedia,
tooltip: 'addVideo'.tr(),
icon: const Icon(Symbols.videocam),
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,
),
),
);
},
),
// 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)
IconButton(
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_hooks/flutter_hooks.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
@@ -58,8 +61,8 @@ class EmbedViewRenderer extends HookConsumerWidget {
children: [
Icon(
embedView.renderer == PostEmbedViewRenderer.webView
? Symbols.web
: Symbols.web,
? Symbols.globe
: Symbols.iframe,
size: 16,
color: colorScheme.primary,
),
@@ -74,13 +77,13 @@ class EmbedViewRenderer extends HookConsumerWidget {
overflow: TextOverflow.ellipsis,
),
),
IconButton(
icon: Icon(
InkWell(
child: Icon(
Symbols.open_in_new,
size: 16,
color: colorScheme.onSurfaceVariant,
),
onPressed: () async {
onTap: () async {
final uri = Uri.parse(embedView.uri);
if (await canLaunchUrl(uri)) {
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(
children: [
InAppWebView(
gestureRecognizers: {
Factory<VerticalDragGestureRecognizer>(
() => VerticalDragGestureRecognizer(),
),
Factory<HorizontalDragGestureRecognizer>(
() => HorizontalDragGestureRecognizer(),
),
Factory<ScaleGestureRecognizer>(
() => ScaleGestureRecognizer(),
),
Factory<TapGestureRecognizer>(
() => TapGestureRecognizer(),
),
},
initialUrlRequest: URLRequest(
url: WebUri(embedView.uri),
),
@@ -256,14 +269,14 @@ class EmbedViewRenderer extends HookConsumerWidget {
children: [
Icon(
Symbols.play_arrow,
fill: 1,
size: 48,
color: colorScheme.onSurfaceVariant.withOpacity(
0.6,
),
),
const SizedBox(height: 8),
Text(
'Tap to load content',
'embedViewLoadHint'.tr(),
style: theme.textTheme.bodyMedium?.copyWith(
color: colorScheme.onSurfaceVariant
.withOpacity(0.6),