mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-03-03 15:26:47 +00:00
starting with #366
This commit is contained in:
parent
2ef4566d69
commit
15c5a44b7d
32 changed files with 806 additions and 40 deletions
|
|
@ -3,9 +3,9 @@
|
|||
## 0.0.89
|
||||
|
||||
- Adds option to manual focus in the camera
|
||||
- Adds support to switch between front and back camera during video recording
|
||||
- Adds support to switch between front and back cameras during video recording
|
||||
- Adds basic face filters
|
||||
- Improves image editor like emojies or text under a drawing can be moved
|
||||
- Improves image editor, like emojis or text under a drawing can be moved
|
||||
- Fixes issue with emojis disappearing in the image editor
|
||||
|
||||
## 0.0.86
|
||||
|
|
|
|||
|
|
@ -148,10 +148,8 @@ Future<void> handleIntentMediaFile(
|
|||
);
|
||||
}
|
||||
|
||||
Future<void> handleIntentSharedFile(
|
||||
BuildContext context,
|
||||
List<SharedFile> files,
|
||||
) async {
|
||||
Future<void> handleIntentSharedFile(BuildContext context,
|
||||
List<SharedFile> files, void Function(Uri) onUrlCallBack) async {
|
||||
for (final file in files) {
|
||||
if (file.value == null) {
|
||||
Log.error(
|
||||
|
|
@ -163,7 +161,9 @@ Future<void> handleIntentSharedFile(
|
|||
|
||||
switch (file.type) {
|
||||
case SharedMediaType.URL:
|
||||
// await handleIntentUrl(context, Uri.parse(file.value!));
|
||||
if (file.value?.startsWith('http') ?? false) {
|
||||
onUrlCallBack(Uri.parse(file.value!));
|
||||
}
|
||||
case SharedMediaType.IMAGE:
|
||||
var type = MediaType.image;
|
||||
if (file.value!.endsWith('.gif')) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
|
|
@ -27,8 +28,8 @@ import 'package:twonly/src/views/camera/camera_preview_components/permissions_vi
|
|||
import 'package:twonly/src/views/camera/camera_preview_components/send_to.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/video_recording_time.dart';
|
||||
import 'package:twonly/src/views/camera/camera_preview_components/zoom_selector.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor.view.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
|
||||
import 'package:twonly/src/views/components/avatar_icon.component.dart';
|
||||
import 'package:twonly/src/views/components/loader.dart';
|
||||
import 'package:twonly/src/views/components/media_view_sizing.dart';
|
||||
|
|
@ -351,6 +352,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
sendToGroup: widget.sendToGroup,
|
||||
mediaFileService: mediaFileService,
|
||||
mainCameraController: mc,
|
||||
previewLink: mc.sharedLinkForPreview,
|
||||
),
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return child;
|
||||
|
|
@ -631,7 +633,18 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
|
|||
if (!_sharePreviewIsShown &&
|
||||
widget.sendToGroup != null &&
|
||||
!_isVideoRecording)
|
||||
SendToWidget(sendTo: widget.sendToGroup!.groupName),
|
||||
ShowTitleText(
|
||||
title: widget.sendToGroup!.groupName,
|
||||
desc: context.lang.cameraPreviewSendTo,
|
||||
),
|
||||
if (!_sharePreviewIsShown &&
|
||||
mc.sharedLinkForPreview != null &&
|
||||
!_isVideoRecording)
|
||||
ShowTitleText(
|
||||
title: mc.sharedLinkForPreview?.host ?? '',
|
||||
desc: 'Link',
|
||||
isLink: true,
|
||||
),
|
||||
if (!_sharePreviewIsShown &&
|
||||
!_isVideoRecording &&
|
||||
!widget.hideControllers)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,13 @@ class MainCameraController {
|
|||
GlobalKey cameraPreviewKey = GlobalKey();
|
||||
bool isSelectingFaceFilters = false;
|
||||
|
||||
Uri? sharedLinkForPreview;
|
||||
|
||||
void setSharedLinkForPreview(Uri url) {
|
||||
sharedLinkForPreview = url;
|
||||
setState();
|
||||
}
|
||||
|
||||
final BarcodeScanner _barcodeScanner = BarcodeScanner();
|
||||
final FaceDetector _faceDetector = FaceDetector(
|
||||
options: FaceDetectorOptions(
|
||||
|
|
|
|||
|
|
@ -1,22 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
class SendToWidget extends StatelessWidget {
|
||||
const SendToWidget({
|
||||
required this.sendTo,
|
||||
class ShowTitleText extends StatelessWidget {
|
||||
const ShowTitleText({
|
||||
required this.desc,
|
||||
required this.title,
|
||||
this.isLink = false,
|
||||
super.key,
|
||||
});
|
||||
final String sendTo;
|
||||
final String title;
|
||||
final String desc;
|
||||
final bool isLink;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const textStyle = TextStyle(
|
||||
final textStyle = TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 24,
|
||||
fontSize: isLink ? 14 : 24,
|
||||
decoration: TextDecoration.none,
|
||||
shadows: [
|
||||
shadows: const [
|
||||
Shadow(
|
||||
color: Color.fromARGB(122, 0, 0, 0),
|
||||
blurRadius: 5,
|
||||
|
|
@ -26,7 +29,7 @@ class SendToWidget extends StatelessWidget {
|
|||
|
||||
final boldTextStyle = textStyle.copyWith(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 28,
|
||||
fontSize: isLink ? 17 : 28,
|
||||
);
|
||||
|
||||
return Positioned(
|
||||
|
|
@ -36,12 +39,12 @@ class SendToWidget extends StatelessWidget {
|
|||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
context.lang.cameraPreviewSendTo,
|
||||
desc,
|
||||
textAlign: TextAlign.center,
|
||||
style: textStyle,
|
||||
),
|
||||
Text(
|
||||
substringBy(sendTo, 20),
|
||||
substringBy(title, isLink ? 30 : 20),
|
||||
textAlign: TextAlign.center,
|
||||
style: boldTextStyle, // Use the bold text style here
|
||||
),
|
||||
|
|
|
|||
|
|
@ -22,8 +22,8 @@ import 'package:twonly/src/views/camera/camera_preview_components/save_to_galler
|
|||
import 'package:twonly/src/views/camera/share_image_contact_selection.view.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_contact_selection/select_show_time.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/image_item.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/image_item.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers_viewer.dart';
|
||||
import 'package:twonly/src/views/components/emoji_picker.bottom.dart';
|
||||
import 'package:twonly/src/views/components/media_view_sizing.dart';
|
||||
|
|
@ -38,6 +38,7 @@ class ShareImageEditorView extends StatefulWidget {
|
|||
const ShareImageEditorView({
|
||||
required this.sharedFromGallery,
|
||||
required this.mediaFileService,
|
||||
this.previewLink,
|
||||
super.key,
|
||||
this.imageBytesFuture,
|
||||
this.sendToGroup,
|
||||
|
|
@ -48,6 +49,7 @@ class ShareImageEditorView extends StatefulWidget {
|
|||
final bool sharedFromGallery;
|
||||
final MediaFileService mediaFileService;
|
||||
final MainCameraController? mainCameraController;
|
||||
final Uri? previewLink;
|
||||
@override
|
||||
State<ShareImageEditorView> createState() => _ShareImageEditorView();
|
||||
}
|
||||
|
|
@ -78,6 +80,12 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
|
|||
layers.add(FilterLayerData(key: GlobalKey()));
|
||||
}
|
||||
|
||||
if (widget.previewLink != null) {
|
||||
layers.add(
|
||||
LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!),
|
||||
);
|
||||
}
|
||||
|
||||
if (widget.sendToGroup != null) {
|
||||
selectedGroupIds.add(widget.sendToGroup!.groupId);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hand_signature/signature.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/image_item.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/image_item.dart';
|
||||
|
||||
/// Layer class with some common properties
|
||||
class Layer {
|
||||
|
|
@ -28,7 +28,6 @@ class Layer {
|
|||
bool showCustomButtons;
|
||||
}
|
||||
|
||||
/// Attributes used by [BackgroundLayer]
|
||||
class BackgroundLayerData extends Layer {
|
||||
BackgroundLayerData({
|
||||
required super.key,
|
||||
|
|
@ -38,6 +37,14 @@ class BackgroundLayerData extends Layer {
|
|||
bool imageLoaded = false;
|
||||
}
|
||||
|
||||
class LinkPreviewLayerData extends Layer {
|
||||
LinkPreviewLayerData({
|
||||
required super.key,
|
||||
required this.link,
|
||||
});
|
||||
Uri link;
|
||||
}
|
||||
|
||||
class FilterLayerData extends Layer {
|
||||
FilterLayerData({
|
||||
required super.key,
|
||||
|
|
@ -46,7 +53,6 @@ class FilterLayerData extends Layer {
|
|||
int page = 1;
|
||||
}
|
||||
|
||||
/// Attributes used by [EmojiLayer]
|
||||
class EmojiLayerData extends Layer {
|
||||
EmojiLayerData({
|
||||
required super.key,
|
||||
|
|
@ -62,7 +68,6 @@ class EmojiLayerData extends Layer {
|
|||
double size;
|
||||
}
|
||||
|
||||
/// Attributes used by [TextLayer]
|
||||
class TextLayerData extends Layer {
|
||||
TextLayerData({
|
||||
required super.key,
|
||||
|
|
@ -78,9 +83,7 @@ class TextLayerData extends Layer {
|
|||
int textLayersBefore;
|
||||
}
|
||||
|
||||
/// Attributes used by [DrawLayer]
|
||||
class DrawLayerData extends Layer {
|
||||
// String text;
|
||||
DrawLayerData({
|
||||
required super.key,
|
||||
super.offset,
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
|
||||
class BackgroundLayer extends StatefulWidget {
|
||||
const BackgroundLayer({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import 'package:hand_signature/signature.dart';
|
|||
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/draw/custom_hand_signature.dart';
|
||||
|
||||
class DrawLayer extends StatefulWidget {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import 'package:flutter/services.dart';
|
|||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
|
||||
/// Emoji layer
|
||||
class EmojiLayer extends StatefulWidget {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'dart:async';
|
|||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/filters/datetime_filter.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/filters/image_filter.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/filters/location_filter.dart';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
|
||||
class LinkPreviewLayer extends StatefulWidget {
|
||||
const LinkPreviewLayer({
|
||||
required this.layerData,
|
||||
super.key,
|
||||
this.onUpdate,
|
||||
});
|
||||
final LinkPreviewLayerData layerData;
|
||||
final VoidCallback? onUpdate;
|
||||
|
||||
@override
|
||||
State<LinkPreviewLayer> createState() => _LinkPreviewLayerState();
|
||||
}
|
||||
|
||||
class _LinkPreviewLayerState extends State<LinkPreviewLayer> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: EdgeInsets.zero,
|
||||
child: Text(widget.layerData.link.toString()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,185 @@
|
|||
// Based on: https://github.com/sur950/any_link_preview
|
||||
// Copyright (c) 2020-2024 Konakanchi Venkata Suresh Babu
|
||||
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart' show Document;
|
||||
import 'package:html/parser.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/html_parser.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/json_ld_parser.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/other_parser.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/twitter_parser.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/youtube_parser.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/utils.dart';
|
||||
|
||||
Future<Metadata?> getMetadata(String link) async {
|
||||
const userAgent = 'WhatsApp/2.21.12.21 A';
|
||||
try {
|
||||
final linkToFetch = link.trim();
|
||||
final info = await getInfo(linkToFetch, userAgent);
|
||||
|
||||
final img = info?.image ?? '';
|
||||
if (img.isNotEmpty) {
|
||||
info?.image = resolveImageUrl(link, img);
|
||||
}
|
||||
|
||||
return info;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
String resolveImageUrl(String baseUrl, String imageUrl) {
|
||||
try {
|
||||
final baseUri = Uri.parse(baseUrl);
|
||||
return baseUri.resolve(imageUrl).toString();
|
||||
} catch (e) {
|
||||
return imageUrl;
|
||||
}
|
||||
}
|
||||
|
||||
Future<Metadata?> getInfo(
|
||||
String url,
|
||||
String userAgent,
|
||||
) async {
|
||||
Metadata? info;
|
||||
|
||||
info = Metadata()
|
||||
..title = getDomain(url)
|
||||
..desc = url
|
||||
..siteName = getDomain(url)
|
||||
..url = url;
|
||||
|
||||
try {
|
||||
final videoId = getYouTubeVideoId(url);
|
||||
final response = videoId == null
|
||||
? await fetchWithRedirects(
|
||||
Uri.parse(url),
|
||||
userAgent: userAgent,
|
||||
)
|
||||
: await getYoutubeData(
|
||||
videoId,
|
||||
userAgent,
|
||||
);
|
||||
|
||||
final headerContentType = response.headers['content-type'];
|
||||
|
||||
if (headerContentType != null && headerContentType.startsWith('image/')) {
|
||||
info
|
||||
..title = ''
|
||||
..desc = ''
|
||||
..siteName = ''
|
||||
..image = url;
|
||||
return info;
|
||||
}
|
||||
|
||||
final document = responseToDocument(response);
|
||||
if (document == null) return info;
|
||||
|
||||
final data_ = _parse(document, url: url);
|
||||
|
||||
return data_;
|
||||
} catch (error) {
|
||||
Log.warn('Error in $url response ($error)');
|
||||
return info;
|
||||
}
|
||||
}
|
||||
|
||||
Document? responseToDocument(http.Response response) {
|
||||
if (response.statusCode != 200) return null;
|
||||
|
||||
Document? document;
|
||||
try {
|
||||
document = parse(utf8.decode(response.bodyBytes));
|
||||
} catch (err) {
|
||||
return document;
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
Metadata _parse(Document? document, {String? url}) {
|
||||
final output = Metadata();
|
||||
|
||||
final parsers = [
|
||||
_openGraph(document),
|
||||
_twitterCard(document),
|
||||
_youtubeCard(document),
|
||||
_jsonLdSchema(document),
|
||||
_htmlMeta(document),
|
||||
_otherParser(document),
|
||||
];
|
||||
|
||||
for (final p in parsers) {
|
||||
if (p == null) continue;
|
||||
|
||||
output.title ??= p.title;
|
||||
output.desc ??= p.desc;
|
||||
output.image ??= p.image;
|
||||
output.siteName ??= p.siteName;
|
||||
output.url ??= p.url ?? url;
|
||||
|
||||
if (output.hasAllMetadata) break;
|
||||
}
|
||||
|
||||
final url_ = output.url ?? url;
|
||||
final image = output.image;
|
||||
if (url_ != null && image != null) {
|
||||
output.image = Uri.parse(url_).resolve(image).toString();
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
Metadata? _openGraph(Document? document) {
|
||||
try {
|
||||
return OpenGraphParser(document).parse();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Metadata? _htmlMeta(Document? document) {
|
||||
try {
|
||||
return HtmlMetaParser(document).parse();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Metadata? _jsonLdSchema(Document? document) {
|
||||
try {
|
||||
return JsonLdParser(document).parse();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Metadata? _youtubeCard(Document? document) {
|
||||
try {
|
||||
return YoutubeParser(document).parse();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Metadata? _twitterCard(Document? document) {
|
||||
try {
|
||||
return TwitterParser(document).parse();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
Metadata? _otherParser(Document? document) {
|
||||
try {
|
||||
return OtherParser(document).parse();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
mixin BaseMetaInfo {
|
||||
String? title;
|
||||
String? desc;
|
||||
String? image;
|
||||
String? url;
|
||||
String? siteName;
|
||||
|
||||
/// Returns `true` if any parameter other than [url] is filled.
|
||||
bool get hasData =>
|
||||
((title?.isNotEmpty ?? false) && title != 'null') ||
|
||||
((desc?.isNotEmpty ?? false) && desc != 'null') ||
|
||||
((image?.isNotEmpty ?? false) && image != 'null');
|
||||
|
||||
Metadata parse() {
|
||||
return Metadata()
|
||||
..title = title
|
||||
..desc = desc
|
||||
..image = image
|
||||
..url = url
|
||||
..siteName = siteName;
|
||||
}
|
||||
}
|
||||
|
||||
/// Container class for Metadata.
|
||||
class Metadata with BaseMetaInfo {
|
||||
bool get hasAllMetadata {
|
||||
return title != null && desc != null && image != null && url != null;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,40 @@
|
|||
import 'package:html/dom.dart';
|
||||
|
||||
import 'base.dart';
|
||||
import 'util.dart';
|
||||
|
||||
/// Parses [Metadata] from `<meta>`, `<title>`, and `<img>` tags.
|
||||
class HtmlMetaParser with BaseMetaInfo {
|
||||
HtmlMetaParser(this._document);
|
||||
|
||||
/// The [Document] to parse.
|
||||
final Document? _document;
|
||||
|
||||
/// Get the [Metadata.title] from the <title> tag.
|
||||
@override
|
||||
String? get title => _document?.head?.querySelector('title')?.text;
|
||||
|
||||
/// Get the [Metadata.desc] from the content of the
|
||||
/// <meta name="description"> tag.
|
||||
@override
|
||||
String? get desc => _document?.head
|
||||
?.querySelector("meta[name='description']")
|
||||
?.attributes
|
||||
.get('content');
|
||||
|
||||
/// Get the [Metadata.image] from the first <img> tag in the body.
|
||||
@override
|
||||
String? get image =>
|
||||
_document?.body?.querySelector('img')?.attributes.get('src');
|
||||
|
||||
/// Get the [Metadata.siteName] from the content of the
|
||||
/// <meta name="site_name"> meta tag.
|
||||
@override
|
||||
String? get siteName => _document?.head
|
||||
?.querySelector("meta[name='site_name']")
|
||||
?.attributes
|
||||
.get('content');
|
||||
|
||||
@override
|
||||
String toString() => parse().toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import 'dart:convert';
|
||||
|
||||
import 'package:html/dom.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/og_parser.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/util.dart';
|
||||
|
||||
/// Parses [Metadata] from `json-ld` data in `<script>` tags.
|
||||
class JsonLdParser with BaseMetaInfo {
|
||||
JsonLdParser(this.document) {
|
||||
_jsonData = _parseToJson(document);
|
||||
}
|
||||
|
||||
/// The [Document] to parse.
|
||||
Document? document;
|
||||
dynamic _jsonData;
|
||||
|
||||
dynamic _parseToJson(Document? document) {
|
||||
final data = document?.head
|
||||
?.querySelector("script[type='application/ld+json']")
|
||||
?.innerHtml;
|
||||
if (data == null) return null;
|
||||
// For multiline json file
|
||||
// Replacing all new line characters with empty space
|
||||
// before performing json decode on data
|
||||
return jsonDecode(data.replaceAll('\n', ' '));
|
||||
}
|
||||
|
||||
/// Get the [Metadata.title] from the <title> tag.
|
||||
@override
|
||||
String? get title {
|
||||
final data = _jsonData;
|
||||
if (data is Map<String, dynamic>) {
|
||||
return data['name'] as String? ?? data['headline'] as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the [Metadata.desc] from the content of the
|
||||
/// <meta name="description"> tag.
|
||||
@override
|
||||
String? get desc {
|
||||
final data = _jsonData;
|
||||
if (data is List<Map<String, dynamic>>) {
|
||||
return data.first['description'] as String? ??
|
||||
data.first['headline'] as String?;
|
||||
} else if (data is Map<String, dynamic>) {
|
||||
return data['description'] as String? ?? data['description'] as String?;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the [Metadata.image] from the first <img> tag in the body.
|
||||
@override
|
||||
String? get image {
|
||||
final data = _jsonData;
|
||||
if (data is List && data.isNotEmpty) {
|
||||
return _imgResultToStr(data.first['logo'] ?? data.first['image']);
|
||||
} else if (data is Map) {
|
||||
return _imgResultToStr(
|
||||
data.getDynamic('logo') ?? data.getDynamic('image'),
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// JSON LD does not have a siteName property, so we get it from
|
||||
/// [og:site_name] if available.
|
||||
@override
|
||||
String? get siteName => OpenGraphParser(document).siteName;
|
||||
|
||||
String? _imgResultToStr(dynamic result) {
|
||||
if (result is List && result.isNotEmpty) result = result.first;
|
||||
if (result is String) return result;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => parse().toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:html/dom.dart';
|
||||
|
||||
import 'base.dart';
|
||||
import 'util.dart';
|
||||
|
||||
/// Parses [Metadata] from `<meta property='og:*'>` tags.
|
||||
class OpenGraphParser with BaseMetaInfo {
|
||||
OpenGraphParser(this._document);
|
||||
|
||||
/// The [Document] to parse.
|
||||
final Document? _document;
|
||||
|
||||
/// Get [Metadata.title] from 'og:title'.
|
||||
@override
|
||||
String? get title => getProperty(_document, property: 'og:title');
|
||||
|
||||
/// Get [Metadata.desc] from 'og:description'.
|
||||
@override
|
||||
String? get desc => getProperty(_document, property: 'og:description');
|
||||
|
||||
/// Get [Metadata.image] from 'og:image'.
|
||||
@override
|
||||
String? get image => getProperty(_document, property: 'og:image');
|
||||
|
||||
/// Get [Metadata.siteName] from 'og:site_name'.
|
||||
@override
|
||||
String? get siteName => getProperty(_document, property: 'og:site_name');
|
||||
|
||||
/// Get [Metadata.url] from 'og:url'.
|
||||
@override
|
||||
String? get url => getProperty(_document, property: 'og:url');
|
||||
|
||||
@override
|
||||
String toString() => parse().toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
import 'package:html/dom.dart';
|
||||
|
||||
import 'base.dart';
|
||||
import 'util.dart';
|
||||
|
||||
/// Parses [Metadata] from `<meta attribute: 'name' property='*'>` tags.
|
||||
class OtherParser with BaseMetaInfo {
|
||||
OtherParser(this._document);
|
||||
|
||||
/// The [Document] to be parse
|
||||
final Document? _document;
|
||||
|
||||
/// Get [Metadata.title] from 'title'.
|
||||
@override
|
||||
String? get title =>
|
||||
getProperty(_document, attribute: 'name', property: 'title');
|
||||
|
||||
/// Get [Metadata.desc] from 'description'.
|
||||
@override
|
||||
String? get desc =>
|
||||
getProperty(_document, attribute: 'name', property: 'description');
|
||||
|
||||
/// Get [Metadata.image] from 'image'.
|
||||
@override
|
||||
String? get image =>
|
||||
getProperty(_document, attribute: 'name', property: 'image');
|
||||
|
||||
/// Get [Metadata.siteName] from 'description'.
|
||||
@override
|
||||
String? get siteName =>
|
||||
getProperty(_document, attribute: 'name', property: 'site_name');
|
||||
|
||||
/// Get [Metadata.url] from 'url'.
|
||||
@override
|
||||
String? get url => getProperty(_document, attribute: 'name', property: 'url');
|
||||
|
||||
@override
|
||||
String toString() => parse().toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:html/dom.dart';
|
||||
|
||||
import 'base.dart';
|
||||
import 'og_parser.dart';
|
||||
import 'util.dart';
|
||||
|
||||
/// Parses [Metadata] from `<meta property='twitter:*'>` tags.
|
||||
class TwitterParser with BaseMetaInfo {
|
||||
TwitterParser(this._document);
|
||||
|
||||
/// The [Document] to parse.
|
||||
final Document? _document;
|
||||
|
||||
/// Get [Metadata.title] from 'twitter:title'
|
||||
@override
|
||||
String? get title =>
|
||||
getProperty(_document, attribute: 'name', property: 'twitter:title') ??
|
||||
getProperty(_document, property: 'twitter:title');
|
||||
|
||||
/// Get [Metadata.desc] from 'twitter:description'
|
||||
@override
|
||||
String? get desc =>
|
||||
getProperty(
|
||||
_document,
|
||||
attribute: 'name',
|
||||
property: 'twitter:description',
|
||||
) ??
|
||||
getProperty(_document, property: 'twitter:description');
|
||||
|
||||
/// Get [Metadata.image] from 'twitter:image'
|
||||
@override
|
||||
String? get image =>
|
||||
getProperty(_document, attribute: 'name', property: 'twitter:image') ??
|
||||
getProperty(_document, property: 'twitter:image');
|
||||
|
||||
/// Twitter Cards do not have a siteName property, so we get it from
|
||||
/// [og:site_name] if available.
|
||||
@override
|
||||
String? get siteName => OpenGraphParser(_document).siteName;
|
||||
|
||||
/// Twitter Cards do not have a url property, so we get the url from
|
||||
/// [og:url] if available.
|
||||
@override
|
||||
String? get url => OpenGraphParser(_document).url;
|
||||
|
||||
@override
|
||||
String toString() => parse().toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
import 'package:html/dom.dart';
|
||||
|
||||
// ignore: strict_raw_type
|
||||
extension GetMethod on Map {
|
||||
String? get(dynamic key) {
|
||||
final value = this[key];
|
||||
if (value is List<String>) return value.first;
|
||||
return value?.toString();
|
||||
}
|
||||
|
||||
dynamic getDynamic(dynamic key) {
|
||||
return this[key];
|
||||
}
|
||||
}
|
||||
|
||||
String? getDomain(String url) {
|
||||
return Uri.parse(url).host.split('.')[0];
|
||||
}
|
||||
|
||||
String? getProperty(
|
||||
Document? document, {
|
||||
String tag = 'meta',
|
||||
String attribute = 'property',
|
||||
String? property,
|
||||
String key = 'content',
|
||||
}) {
|
||||
final value_ = document
|
||||
?.getElementsByTagName(tag)
|
||||
.cast<Element?>()
|
||||
.firstWhere(
|
||||
(element) => element?.attributes[attribute] == property,
|
||||
orElse: () => null,
|
||||
)
|
||||
?.attributes
|
||||
.get(key);
|
||||
|
||||
return value_;
|
||||
}
|
||||
|
|
@ -0,0 +1,80 @@
|
|||
import 'dart:convert';
|
||||
import 'package:html/dom.dart';
|
||||
import 'base.dart';
|
||||
import 'util.dart';
|
||||
|
||||
class YoutubeParser with BaseMetaInfo {
|
||||
YoutubeParser(this.document) {
|
||||
_jsonData = _parseToJson(document);
|
||||
}
|
||||
|
||||
Document? document;
|
||||
dynamic _jsonData;
|
||||
|
||||
dynamic _parseToJson(Document? document) {
|
||||
final data = document?.outerHtml
|
||||
.replaceAll('<html><head></head><body>', '')
|
||||
.replaceAll('</body></html>', '');
|
||||
if (data == null) return null;
|
||||
/* For multiline json file */
|
||||
// Replacing all new line characters with empty space
|
||||
// before performing json decode on data
|
||||
final d = jsonDecode(data.replaceAll('\n', ' '));
|
||||
return d;
|
||||
}
|
||||
|
||||
/// Get the [Metadata.title] from the [<title>] tag
|
||||
@override
|
||||
String? get title {
|
||||
final data = _jsonData;
|
||||
if (data is List<Map<String, dynamic>>) {
|
||||
return data.first['title'] as String?;
|
||||
} else if (data is Map) {
|
||||
return data.get('title');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Get the [Metadata.image] from the first <img> tag in the body
|
||||
@override
|
||||
String? get image {
|
||||
final data = _jsonData;
|
||||
if (data is List && data.isNotEmpty) {
|
||||
return _imgResultToStr(data.first['thumbnail_url']);
|
||||
} else if (data is Map) {
|
||||
return _imgResultToStr(data.getDynamic('thumbnail_url'));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get siteName {
|
||||
final data = _jsonData;
|
||||
if (data is List<Map<String, dynamic>>) {
|
||||
return data.first['provider_name'] as String?;
|
||||
} else if (data is Map) {
|
||||
return data.get('provider_name');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String? get url {
|
||||
final data = _jsonData;
|
||||
if (data is List<Map<String, dynamic>>) {
|
||||
return data.first['provider_url'] as String?;
|
||||
} else if (data is Map) {
|
||||
return data.get('provider_url');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _imgResultToStr(dynamic result) {
|
||||
if (result is List && result.isNotEmpty) result = result.first;
|
||||
if (result is String) return result;
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => parse().toString();
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
import 'package:http/http.dart' as http;
|
||||
|
||||
Future<http.Response> fetchWithRedirects(
|
||||
Uri uri, {
|
||||
int maxRedirects = 7,
|
||||
Map<String, String> headers = const {},
|
||||
String? userAgent,
|
||||
}) async {
|
||||
const userAgentFallback = 'WhatsApp/2.21.12.21 A';
|
||||
final allHeaders = <String, String>{
|
||||
...headers,
|
||||
'User-Agent': userAgent ?? userAgentFallback,
|
||||
};
|
||||
var response = await http.get(uri, headers: allHeaders);
|
||||
var redirectCount = 0;
|
||||
|
||||
while (_isRedirect(response) && redirectCount < maxRedirects) {
|
||||
final location = response.headers['location'];
|
||||
if (location == null) {
|
||||
throw Exception('HTTP redirect without Location header');
|
||||
}
|
||||
|
||||
response = await http.get(Uri.parse(location), headers: allHeaders);
|
||||
redirectCount++;
|
||||
}
|
||||
|
||||
if (redirectCount >= maxRedirects) {
|
||||
throw Exception('Maximum redirect limit reached');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
bool _isRedirect(http.Response response) {
|
||||
return [301, 302, 303, 307, 308].contains(response.statusCode);
|
||||
}
|
||||
|
||||
Future<http.Response> getYoutubeData(String videoId, String userAgent) async {
|
||||
final response = await http.get(
|
||||
Uri.parse(
|
||||
'https://www.youtube.com/oembed?url=https://www.youtube.com/watch?v=$videoId&format=json',
|
||||
),
|
||||
headers: {
|
||||
'User-Agent': userAgent,
|
||||
},
|
||||
);
|
||||
return response;
|
||||
}
|
||||
|
||||
String? getYouTubeVideoId(String url) {
|
||||
// Regular expression pattern to detect YouTube URLs
|
||||
// with or without a proxy prefix
|
||||
final regExp = RegExp(
|
||||
r'(?:https?:\/\/)?(?:[^\/]+\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|v\/|.+\?v=)|youtu\.be\/)([a-zA-Z0-9_-]{11})',
|
||||
);
|
||||
|
||||
// Apply the regex to the URL
|
||||
final match = regExp.firstMatch(url);
|
||||
|
||||
// If a match is found, return the first capture group, which is the video ID
|
||||
return match?.group(1);
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
|||
import 'package:provider/provider.dart';
|
||||
import 'package:twonly/src/providers/image_editor.provider.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
|
||||
/// Text layer
|
||||
class TextLayer extends StatefulWidget {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,10 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/background.layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/draw.layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/emoji.layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/filter.layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview.layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/text.layer.dart';
|
||||
|
||||
/// View stacked layers (unbounded height, width)
|
||||
|
|
@ -39,6 +40,7 @@ class LayersViewer extends StatelessWidget {
|
|||
(layerItem) =>
|
||||
layerItem is EmojiLayerData ||
|
||||
layerItem is DrawLayerData ||
|
||||
layerItem is LinkPreviewLayerData ||
|
||||
layerItem is TextLayerData,
|
||||
)
|
||||
.map((layerItem) {
|
||||
|
|
@ -60,6 +62,12 @@ class LayersViewer extends StatelessWidget {
|
|||
layerData: layerItem,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
} else if (layerItem is LinkPreviewLayerData) {
|
||||
return LinkPreviewLayer(
|
||||
key: layerItem.key,
|
||||
layerData: layerItem,
|
||||
onUpdate: onUpdate,
|
||||
);
|
||||
}
|
||||
return Container();
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import 'package:twonly/src/model/protobuf/client/generated/messages.pbserver.dar
|
|||
import 'package:twonly/src/services/api/messages.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
import 'package:twonly/src/views/components/emoji_picker.bottom.dart';
|
||||
import 'package:twonly/src/views/chats/message_info.view.dart';
|
||||
import 'package:twonly/src/views/components/alert_dialog.dart';
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import 'package:flutter/scheduler.dart';
|
|||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
import 'package:twonly/src/views/components/emoji_picker.bottom.dart';
|
||||
import 'package:twonly/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart';
|
||||
import 'package:twonly/src/views/components/animate_icon.dart';
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import 'dart:io';
|
|||
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/data/layer.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
|
||||
|
||||
class EmojiPickerBottom extends StatelessWidget {
|
||||
const EmojiPickerBottom({super.key});
|
||||
|
|
|
|||
|
|
@ -120,7 +120,13 @@ class HomeViewState extends State<HomeView> {
|
|||
|
||||
_intentStreamSub = FlutterSharingIntent.instance.getMediaStream().listen(
|
||||
(f) {
|
||||
if (mounted) handleIntentSharedFile(context, f);
|
||||
if (mounted) {
|
||||
handleIntentSharedFile(
|
||||
context,
|
||||
f,
|
||||
_mainCameraController.setSharedLinkForPreview,
|
||||
);
|
||||
}
|
||||
},
|
||||
// ignore: inference_failure_on_untyped_parameter
|
||||
onError: (err) {
|
||||
|
|
@ -129,7 +135,13 @@ class HomeViewState extends State<HomeView> {
|
|||
);
|
||||
|
||||
FlutterSharingIntent.instance.getInitialSharing().then((f) {
|
||||
if (mounted) handleIntentSharedFile(context, f);
|
||||
if (mounted) {
|
||||
handleIntentSharedFile(
|
||||
context,
|
||||
f,
|
||||
_mainCameraController.setSharedLinkForPreview,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -914,7 +914,7 @@ packages:
|
|||
source: path
|
||||
version: "3.0.1"
|
||||
html:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: html
|
||||
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ dependencies:
|
|||
convert: ^3.1.2
|
||||
crypto: ^3.0.7
|
||||
clock: ^1.1.2
|
||||
html: ^0.15.6
|
||||
|
||||
|
||||
# Trusted publisher flutter.dev
|
||||
|
|
|
|||
50
test/features/link_parser_test.dart
Normal file
50
test/features/link_parser_test.dart
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart';
|
||||
|
||||
class LinkParserTest {
|
||||
LinkParserTest({
|
||||
required this.url,
|
||||
required this.title,
|
||||
required this.siteName,
|
||||
required this.desc,
|
||||
this.image,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final String title;
|
||||
final String siteName;
|
||||
final String desc;
|
||||
final String? image;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('testing different urls', () async {
|
||||
final testCases = [
|
||||
LinkParserTest(
|
||||
url: 'https://mastodon.social/@islieb/115883317936171927',
|
||||
title: 'islieb? (@islieb@mastodon.social)',
|
||||
siteName: 'Mastodon',
|
||||
desc: 'Attached: 1 image',
|
||||
image:
|
||||
'https://files.mastodon.social/media_attachments/files/115/883/317/526/523/824/original/6fa7ef90ec68f1f1.jpg',
|
||||
),
|
||||
LinkParserTest(
|
||||
url: 'https://chaos.social/@netzpolitik_feed/115921534467938262',
|
||||
title: 'netzpolitik.org (@netzpolitik_feed@chaos.social)',
|
||||
siteName: 'chaos.social',
|
||||
desc:
|
||||
'Die EU-Kommission erkennt Open Source als entscheidend für die digitale Souveränität an und wünscht sich mehr Kommerzialisierung. Bis April will Brüssel eine neue Strategie veröffentlichen. In einer laufenden Konsultation bekräftigen Stimmen aus ganz Europa, welche Vorteile sie in offenem Quellcode sehen.\n'
|
||||
'\n'
|
||||
'https://netzpolitik.org/2026/konsultation-eu-kommission-arbeitet-an-neuer-open-source-strategie/',
|
||||
),
|
||||
];
|
||||
|
||||
for (final testCase in testCases) {
|
||||
final metadata = (await getMetadata(testCase.url))!;
|
||||
expect(metadata.title, testCase.title);
|
||||
expect(metadata.siteName, testCase.siteName);
|
||||
expect(metadata.desc, testCase.desc);
|
||||
expect(metadata.image, testCase.image);
|
||||
}
|
||||
});
|
||||
}
|
||||
Loading…
Reference in a new issue