starting with #366

This commit is contained in:
otsmr 2026-01-20 00:44:32 +01:00
parent 2ef4566d69
commit 15c5a44b7d
32 changed files with 806 additions and 40 deletions

View file

@ -3,9 +3,9 @@
## 0.0.89 ## 0.0.89
- Adds option to manual focus in the camera - 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 - 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 - Fixes issue with emojis disappearing in the image editor
## 0.0.86 ## 0.0.86

View file

@ -148,10 +148,8 @@ Future<void> handleIntentMediaFile(
); );
} }
Future<void> handleIntentSharedFile( Future<void> handleIntentSharedFile(BuildContext context,
BuildContext context, List<SharedFile> files, void Function(Uri) onUrlCallBack) async {
List<SharedFile> files,
) async {
for (final file in files) { for (final file in files) {
if (file.value == null) { if (file.value == null) {
Log.error( Log.error(
@ -163,7 +161,9 @@ Future<void> handleIntentSharedFile(
switch (file.type) { switch (file.type) {
case SharedMediaType.URL: case SharedMediaType.URL:
// await handleIntentUrl(context, Uri.parse(file.value!)); if (file.value?.startsWith('http') ?? false) {
onUrlCallBack(Uri.parse(file.value!));
}
case SharedMediaType.IMAGE: case SharedMediaType.IMAGE:
var type = MediaType.image; var type = MediaType.image;
if (file.value!.endsWith('.gif')) { if (file.value!.endsWith('.gif')) {

View file

@ -1,5 +1,6 @@
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'package:camera/camera.dart'; import 'package:camera/camera.dart';
import 'package:clock/clock.dart'; import 'package:clock/clock.dart';
import 'package:device_info_plus/device_info_plus.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/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/video_recording_time.dart';
import 'package:twonly/src/views/camera/camera_preview_components/zoom_selector.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.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/avatar_icon.component.dart';
import 'package:twonly/src/views/components/loader.dart'; import 'package:twonly/src/views/components/loader.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart';
@ -351,6 +352,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
sendToGroup: widget.sendToGroup, sendToGroup: widget.sendToGroup,
mediaFileService: mediaFileService, mediaFileService: mediaFileService,
mainCameraController: mc, mainCameraController: mc,
previewLink: mc.sharedLinkForPreview,
), ),
transitionsBuilder: (context, animation, secondaryAnimation, child) { transitionsBuilder: (context, animation, secondaryAnimation, child) {
return child; return child;
@ -631,7 +633,18 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
if (!_sharePreviewIsShown && if (!_sharePreviewIsShown &&
widget.sendToGroup != null && widget.sendToGroup != null &&
!_isVideoRecording) !_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 && if (!_sharePreviewIsShown &&
!_isVideoRecording && !_isVideoRecording &&
!widget.hideControllers) !widget.hideControllers)

View file

@ -55,6 +55,13 @@ class MainCameraController {
GlobalKey cameraPreviewKey = GlobalKey(); GlobalKey cameraPreviewKey = GlobalKey();
bool isSelectingFaceFilters = false; bool isSelectingFaceFilters = false;
Uri? sharedLinkForPreview;
void setSharedLinkForPreview(Uri url) {
sharedLinkForPreview = url;
setState();
}
final BarcodeScanner _barcodeScanner = BarcodeScanner(); final BarcodeScanner _barcodeScanner = BarcodeScanner();
final FaceDetector _faceDetector = FaceDetector( final FaceDetector _faceDetector = FaceDetector(
options: FaceDetectorOptions( options: FaceDetectorOptions(

View file

@ -1,22 +1,25 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart'; import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/utils/misc.dart';
class SendToWidget extends StatelessWidget { class ShowTitleText extends StatelessWidget {
const SendToWidget({ const ShowTitleText({
required this.sendTo, required this.desc,
required this.title,
this.isLink = false,
super.key, super.key,
}); });
final String sendTo; final String title;
final String desc;
final bool isLink;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const textStyle = TextStyle( final textStyle = TextStyle(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.bold, fontWeight: FontWeight.bold,
fontSize: 24, fontSize: isLink ? 14 : 24,
decoration: TextDecoration.none, decoration: TextDecoration.none,
shadows: [ shadows: const [
Shadow( Shadow(
color: Color.fromARGB(122, 0, 0, 0), color: Color.fromARGB(122, 0, 0, 0),
blurRadius: 5, blurRadius: 5,
@ -26,7 +29,7 @@ class SendToWidget extends StatelessWidget {
final boldTextStyle = textStyle.copyWith( final boldTextStyle = textStyle.copyWith(
fontWeight: FontWeight.normal, fontWeight: FontWeight.normal,
fontSize: 28, fontSize: isLink ? 17 : 28,
); );
return Positioned( return Positioned(
@ -36,12 +39,12 @@ class SendToWidget extends StatelessWidget {
child: Column( child: Column(
children: [ children: [
Text( Text(
context.lang.cameraPreviewSendTo, desc,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: textStyle, style: textStyle,
), ),
Text( Text(
substringBy(sendTo, 20), substringBy(title, isLink ? 30 : 20),
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: boldTextStyle, // Use the bold text style here style: boldTextStyle, // Use the bold text style here
), ),

View file

@ -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.view.dart';
import 'package:twonly/src/views/camera/share_image_contact_selection/select_show_time.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/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/image_item.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_viewer.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/emoji_picker.bottom.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart'; import 'package:twonly/src/views/components/media_view_sizing.dart';
@ -38,6 +38,7 @@ class ShareImageEditorView extends StatefulWidget {
const ShareImageEditorView({ const ShareImageEditorView({
required this.sharedFromGallery, required this.sharedFromGallery,
required this.mediaFileService, required this.mediaFileService,
this.previewLink,
super.key, super.key,
this.imageBytesFuture, this.imageBytesFuture,
this.sendToGroup, this.sendToGroup,
@ -48,6 +49,7 @@ class ShareImageEditorView extends StatefulWidget {
final bool sharedFromGallery; final bool sharedFromGallery;
final MediaFileService mediaFileService; final MediaFileService mediaFileService;
final MainCameraController? mainCameraController; final MainCameraController? mainCameraController;
final Uri? previewLink;
@override @override
State<ShareImageEditorView> createState() => _ShareImageEditorView(); State<ShareImageEditorView> createState() => _ShareImageEditorView();
} }
@ -78,6 +80,12 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
layers.add(FilterLayerData(key: GlobalKey())); layers.add(FilterLayerData(key: GlobalKey()));
} }
if (widget.previewLink != null) {
layers.add(
LinkPreviewLayerData(key: GlobalKey(), link: widget.previewLink!),
);
}
if (widget.sendToGroup != null) { if (widget.sendToGroup != null) {
selectedGroupIds.add(widget.sendToGroup!.groupId); selectedGroupIds.add(widget.sendToGroup!.groupId);
} }

View file

@ -2,7 +2,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hand_signature/signature.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 /// Layer class with some common properties
class Layer { class Layer {
@ -28,7 +28,6 @@ class Layer {
bool showCustomButtons; bool showCustomButtons;
} }
/// Attributes used by [BackgroundLayer]
class BackgroundLayerData extends Layer { class BackgroundLayerData extends Layer {
BackgroundLayerData({ BackgroundLayerData({
required super.key, required super.key,
@ -38,6 +37,14 @@ class BackgroundLayerData extends Layer {
bool imageLoaded = false; bool imageLoaded = false;
} }
class LinkPreviewLayerData extends Layer {
LinkPreviewLayerData({
required super.key,
required this.link,
});
Uri link;
}
class FilterLayerData extends Layer { class FilterLayerData extends Layer {
FilterLayerData({ FilterLayerData({
required super.key, required super.key,
@ -46,7 +53,6 @@ class FilterLayerData extends Layer {
int page = 1; int page = 1;
} }
/// Attributes used by [EmojiLayer]
class EmojiLayerData extends Layer { class EmojiLayerData extends Layer {
EmojiLayerData({ EmojiLayerData({
required super.key, required super.key,
@ -62,7 +68,6 @@ class EmojiLayerData extends Layer {
double size; double size;
} }
/// Attributes used by [TextLayer]
class TextLayerData extends Layer { class TextLayerData extends Layer {
TextLayerData({ TextLayerData({
required super.key, required super.key,
@ -78,9 +83,7 @@ class TextLayerData extends Layer {
int textLayersBefore; int textLayersBefore;
} }
/// Attributes used by [DrawLayer]
class DrawLayerData extends Layer { class DrawLayerData extends Layer {
// String text;
DrawLayerData({ DrawLayerData({
required super.key, required super.key,
super.offset, super.offset,

View file

@ -1,5 +1,5 @@
import 'package:flutter/material.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';
class BackgroundLayer extends StatefulWidget { class BackgroundLayer extends StatefulWidget {
const BackgroundLayer({ const BackgroundLayer({

View file

@ -4,7 +4,7 @@ import 'package:hand_signature/signature.dart';
import 'package:twonly/src/utils/misc.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/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'; import 'package:twonly/src/views/camera/share_image_editor/layers/draw/custom_hand_signature.dart';
class DrawLayer extends StatefulWidget { class DrawLayer extends StatefulWidget {

View file

@ -6,7 +6,7 @@ import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/log.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/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 /// Emoji layer
class EmojiLayer extends StatefulWidget { class EmojiLayer extends StatefulWidget {

View file

@ -2,7 +2,7 @@ import 'dart:async';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:flutter/material.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/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/image_filter.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/filters/location_filter.dart'; import 'package:twonly/src/views/camera/share_image_editor/layers/filters/location_filter.dart';

View file

@ -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()),
);
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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_;
}

View file

@ -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();
}

View file

@ -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);
}

View file

@ -6,7 +6,7 @@ import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:twonly/src/providers/image_editor.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/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 /// Text layer
class TextLayer extends StatefulWidget { class TextLayer extends StatefulWidget {

View file

@ -1,9 +1,10 @@
import 'package:flutter/material.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/background.layer.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/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/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/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'; import 'package:twonly/src/views/camera/share_image_editor/layers/text.layer.dart';
/// View stacked layers (unbounded height, width) /// View stacked layers (unbounded height, width)
@ -39,6 +40,7 @@ class LayersViewer extends StatelessWidget {
(layerItem) => (layerItem) =>
layerItem is EmojiLayerData || layerItem is EmojiLayerData ||
layerItem is DrawLayerData || layerItem is DrawLayerData ||
layerItem is LinkPreviewLayerData ||
layerItem is TextLayerData, layerItem is TextLayerData,
) )
.map((layerItem) { .map((layerItem) {
@ -60,6 +62,12 @@ class LayersViewer extends StatelessWidget {
layerData: layerItem, layerData: layerItem,
onUpdate: onUpdate, onUpdate: onUpdate,
); );
} else if (layerItem is LinkPreviewLayerData) {
return LinkPreviewLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} }
return Container(); return Container();
}), }),

View file

@ -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/api/messages.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart'; import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.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/components/emoji_picker.bottom.dart';
import 'package:twonly/src/views/chats/message_info.view.dart'; import 'package:twonly/src/views/chats/message_info.view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart'; import 'package:twonly/src/views/components/alert_dialog.dart';

View file

@ -5,7 +5,7 @@ import 'package:flutter/scheduler.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart'; import 'package:twonly/globals.dart';
import 'package:twonly/src/utils/misc.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/components/emoji_picker.bottom.dart';
import 'package:twonly/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart'; import 'package:twonly/src/views/chats/media_viewer_components/emoji_reactions_row.component.dart';
import 'package:twonly/src/views/components/animate_icon.dart'; import 'package:twonly/src/views/components/animate_icon.dart';

View file

@ -2,7 +2,7 @@ import 'dart:io';
import 'package:emoji_picker_flutter/emoji_picker_flutter.dart'; import 'package:emoji_picker_flutter/emoji_picker_flutter.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.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 { class EmojiPickerBottom extends StatelessWidget {
const EmojiPickerBottom({super.key}); const EmojiPickerBottom({super.key});

View file

@ -120,7 +120,13 @@ class HomeViewState extends State<HomeView> {
_intentStreamSub = FlutterSharingIntent.instance.getMediaStream().listen( _intentStreamSub = FlutterSharingIntent.instance.getMediaStream().listen(
(f) { (f) {
if (mounted) handleIntentSharedFile(context, f); if (mounted) {
handleIntentSharedFile(
context,
f,
_mainCameraController.setSharedLinkForPreview,
);
}
}, },
// ignore: inference_failure_on_untyped_parameter // ignore: inference_failure_on_untyped_parameter
onError: (err) { onError: (err) {
@ -129,7 +135,13 @@ class HomeViewState extends State<HomeView> {
); );
FlutterSharingIntent.instance.getInitialSharing().then((f) { FlutterSharingIntent.instance.getInitialSharing().then((f) {
if (mounted) handleIntentSharedFile(context, f); if (mounted) {
handleIntentSharedFile(
context,
f,
_mainCameraController.setSharedLinkForPreview,
);
}
}); });
} }

View file

@ -914,7 +914,7 @@ packages:
source: path source: path
version: "3.0.1" version: "3.0.1"
html: html:
dependency: transitive dependency: "direct main"
description: description:
name: html name: html
sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602" sha256: "6d1264f2dffa1b1101c25a91dff0dc2daee4c18e87cd8538729773c073dbf602"

View file

@ -26,6 +26,7 @@ dependencies:
convert: ^3.1.2 convert: ^3.1.2
crypto: ^3.0.7 crypto: ^3.0.7
clock: ^1.1.2 clock: ^1.1.2
html: ^0.15.6
# Trusted publisher flutter.dev # Trusted publisher flutter.dev

View 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);
}
});
}