Merge pull request #388 from twonlyapp/dev

- Adds link preview to images
- Adds option to manual focus in the camera
- Adds support to switch between front and back cameras during video recording
- Adds basic face filters
- Improves image editor, like emojis or text under a drawing can be moved
- Improves speed after taking a picture  
- Fixes issue with emojis disappearing in the image editor
This commit is contained in:
Tobi 2026-01-24 13:44:01 +01:00 committed by GitHub
commit e8b98761a7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
112 changed files with 11350 additions and 1550 deletions

View file

@ -1,5 +1,15 @@
# Changelog
## 0.0.89
- Adds link preview to images
- Adds option to manual focus in the camera
- Adds support to switch between front and back cameras during video recording
- Adds basic face filters
- Improves image editor, like emojis or text under a drawing can be moved
- Improves speed after taking a picture
- Fixes issue with emojis disappearing in the image editor
## 0.0.86
- Allows to reopen send images (if send without time limit or enabled auth)

View file

@ -33,6 +33,21 @@
<data android:scheme="http" android:host="me.twonly.eu" />
<data android:scheme="https" />
</intent-filter>
<!-- Allow other apps to share links -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="http" />
<data android:scheme="https" />
<data android:host="*" />
</intent-filter>
<!-- Allow other apps to share links via plain text -->
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="text/plain" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

View file

@ -109,10 +109,10 @@ func getPushNotificationData(pushData: String) -> (
} else if pushUser != nil {
return (
pushUser!.displayName,
getPushNotificationText(pushNotification: pushNotification).0, pushUser!.userID
getPushNotificationText(pushNotification: pushNotification, userKnown: true).0, pushUser!.userID
)
} else {
let content = getPushNotificationText(pushNotification: pushNotification)
let content = getPushNotificationText(pushNotification: pushNotification, userKnown: false)
return (
content.1, content.0, 1
)
@ -205,15 +205,20 @@ func readFromKeychain(key: String) -> String? {
return nil
}
func getPushNotificationText(pushNotification: PushNotification) -> (String, String) {
func getPushNotificationText(pushNotification: PushNotification, userKnown: Bool) -> (String, String) {
let systemLanguage = Locale.current.language.languageCode?.identifier ?? "en" // Get the current system language
var pushNotificationText: [PushKind: String] = [:]
var title = "[Unknown]"
var title = "You"
var noTranslationFoundTitle = "You have a new message."
var noTranslationFoundBody = "Open twonly to learn more."
// Define the messages based on the system language
if systemLanguage.contains("de") { // German
title = "[Unbekannt]"
title = "Du"
noTranslationFoundTitle = "Du hast eine neue Nachricht."
noTranslationFoundBody = "Öffne twonly um mehr zu erfahren."
if (userKnown) {
pushNotificationText = [
.text: "hat eine Nachricht{inGroup} gesendet.",
.twonly: "hat ein twonly{inGroup} gesendet.",
@ -233,7 +238,13 @@ func getPushNotificationText(pushNotification: PushNotification) -> (String, Str
.response: "hat dir{inGroup} geantwortet.",
.addedToGroup: "hat dich zu \"{{content}}\" hinzugefügt.",
]
} else { // Default to English
} else {
pushNotificationText = [
.contactRequest: "hast eine neue Kontaktanfrage erhalten.",
]
}
} else {
if (userKnown) {
pushNotificationText = [
.text: "sent a message{inGroup}.",
.twonly: "sent a twonly{inGroup}.",
@ -253,9 +264,18 @@ func getPushNotificationText(pushNotification: PushNotification) -> (String, Str
.response: "has responded{inGroup}.",
.addedToGroup: "has added you to \"{{content}}\"",
]
} else {
pushNotificationText = [
.contactRequest: "have received a new contact request.",
]
}
}
var content = pushNotificationText[pushNotification.kind] ?? ""
if (content == "") {
title = noTranslationFoundTitle
content = noTranslationFoundBody
}
if pushNotification.hasAdditionalContent {
content.replace("{{content}}", with: pushNotification.additionalContent)

View file

@ -136,6 +136,10 @@ PODS:
- google_mlkit_commons (0.11.0):
- Flutter
- MLKitVision
- google_mlkit_face_detection (0.13.1):
- Flutter
- google_mlkit_commons
- GoogleMLKit/FaceDetection (~> 7.0.0)
- GoogleAdsOnDeviceConversion (3.2.0):
- GoogleUtilities/Environment (~> 8.1)
- GoogleUtilities/Logger (~> 8.1)
@ -169,6 +173,9 @@ PODS:
- GoogleMLKit/BarcodeScanning (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitBarcodeScanning (~> 6.0.0)
- GoogleMLKit/FaceDetection (7.0.0):
- GoogleMLKit/MLKitCore
- MLKitFaceDetection (~> 6.0.0)
- GoogleMLKit/MLKitCore (7.0.0):
- MLKitCommon (~> 12.0.0)
- GoogleToolboxForMac/Defines (4.2.1)
@ -251,6 +258,9 @@ PODS:
- GoogleUtilities/Logger (~> 8.0)
- GoogleUtilities/UserDefaults (~> 8.0)
- GTMSessionFetcher/Core (< 4.0, >= 3.3.2)
- MLKitFaceDetection (6.0.0):
- MLKitCommon (~> 12.0)
- MLKitVision (~> 8.0)
- MLKitVision (8.0.0):
- GoogleToolboxForMac/Logger (< 5.0, >= 4.2.1)
- "GoogleToolboxForMac/NSData+zlib (< 5.0, >= 4.2.1)"
@ -357,6 +367,7 @@ DEPENDENCIES:
- gal (from `.symlinks/plugins/gal/darwin`)
- google_mlkit_barcode_scanning (from `.symlinks/plugins/google_mlkit_barcode_scanning/ios`)
- google_mlkit_commons (from `.symlinks/plugins/google_mlkit_commons/ios`)
- google_mlkit_face_detection (from `.symlinks/plugins/google_mlkit_face_detection/ios`)
- GoogleUtilities
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
- in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`)
@ -398,6 +409,7 @@ SPEC REPOS:
- MLImage
- MLKitBarcodeScanning
- MLKitCommon
- MLKitFaceDetection
- MLKitVision
- nanopb
- PromisesObjC
@ -454,6 +466,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/google_mlkit_barcode_scanning/ios"
google_mlkit_commons:
:path: ".symlinks/plugins/google_mlkit_commons/ios"
google_mlkit_face_detection:
:path: ".symlinks/plugins/google_mlkit_face_detection/ios"
image_picker_ios:
:path: ".symlinks/plugins/image_picker_ios/ios"
in_app_purchase_storekit:
@ -518,6 +532,7 @@ SPEC CHECKSUMS:
gal: baecd024ebfd13c441269ca7404792a7152fde89
google_mlkit_barcode_scanning: 8f5987f244a43fe1167689c548342a5174108159
google_mlkit_commons: 2abe6a70e1824e431d16a51085cb475b672c8aab
google_mlkit_face_detection: 754da2113a1952f063c7c5dc347ac6ae8934fb77
GoogleAdsOnDeviceConversion: d68c69dd9581a0f5da02617b6f377e5be483970f
GoogleAppMeasurement: 3bf40aff49a601af5da1c3345702fcb4991d35ee
GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7
@ -533,6 +548,7 @@ SPEC CHECKSUMS:
MLImage: 0ad1c5f50edd027672d8b26b0fee78a8b4a0fc56
MLKitBarcodeScanning: 0a3064da0a7f49ac24ceb3cb46a5bc67496facd2
MLKitCommon: 07c2c33ae5640e5380beaaa6e4b9c249a205542d
MLKitFaceDetection: 2a593db4837db503ad3426b565e7aab045cefea5
MLKitVision: 45e79d68845a2de77e2dd4d7f07947f0ed157b0e
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
no_screenshot: 89e778ede9f1e39cc3fb9404d782a42712f2a0b2

View file

@ -22,6 +22,8 @@
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
<key>NSExtensionActivationSupportsImageWithMaxCount</key>

View file

@ -22,7 +22,7 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
String emoji,
bool remove,
) async {
if (!isEmoji(emoji)) {
if (!isOneEmoji(emoji)) {
Log.error('Did not update reaction as it is not an emoji!');
return;
}
@ -59,7 +59,7 @@ class ReactionsDao extends DatabaseAccessor<TwonlyDB> with _$ReactionsDaoMixin {
String emoji,
bool remove,
) async {
if (!isEmoji(emoji)) {
if (!isOneEmoji(emoji)) {
Log.error('Did not update reaction as it is not an emoji!');
return;
}

File diff suppressed because one or more lines are too long

View file

@ -22,6 +22,8 @@ class Messages extends Table {
.nullable()
.references(MediaFiles, #mediaId, onDelete: KeyAction.setNull)();
BlobColumn get additionalMessageData => blob().nullable()();
BoolColumn get mediaStored => boolean().withDefault(const Constant(false))();
BoolColumn get mediaReopened =>
boolean().withDefault(const Constant(false))();
@ -56,7 +58,7 @@ class MessageActions extends Table {
text().references(Messages, #messageId, onDelete: KeyAction.cascade)();
IntColumn get contactId =>
integer().references(Contacts, #contactId, onDelete: KeyAction.cascade)();
integer().references(Contacts, #userId, onDelete: KeyAction.cascade)();
TextColumn get type => textEnum<MessageActionType>()();
@ -75,7 +77,7 @@ class MessageHistories extends Table {
IntColumn get contactId => integer()
.nullable()
.references(Contacts, #contactId, onDelete: KeyAction.cascade)();
.references(Contacts, #userId, onDelete: KeyAction.cascade)();
TextColumn get content => text().nullable()();

View file

@ -68,7 +68,7 @@ class TwonlyDB extends _$TwonlyDB {
TwonlyDB.forTesting(DatabaseConnection super.connection);
@override
int get schemaVersion => 6;
int get schemaVersion => 7;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -85,7 +85,10 @@ class TwonlyDB extends _$TwonlyDB {
beforeOpen: (details) async {
await customStatement('PRAGMA foreign_keys = ON');
},
onUpgrade: stepByStep(
onUpgrade: (m, from, to) async {
// disable foreign_keys before migrations
await customStatement('PRAGMA foreign_keys = OFF');
return stepByStep(
from1To2: (m, schema) async {
await m.addColumn(schema.messages, schema.messages.mediaReopened);
await m.dropColumn(schema.mediaFiles, 'reopen_by_contact');
@ -117,7 +120,14 @@ class TwonlyDB extends _$TwonlyDB {
schema.receipts.markForRetryAfterAccepted,
);
},
),
from6To7: (m, schema) async {
await m.addColumn(
schema.messages,
schema.messages.additionalMessageData,
);
},
)(m, from, to);
},
);
}

View file

@ -2796,6 +2796,12 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES media_files (media_id) ON DELETE SET NULL'));
static const VerificationMeta _additionalMessageDataMeta =
const VerificationMeta('additionalMessageData');
@override
late final GeneratedColumn<Uint8List> additionalMessageData =
GeneratedColumn<Uint8List>('additional_message_data', aliasedName, true,
type: DriftSqlType.blob, requiredDuringInsert: false);
static const VerificationMeta _mediaStoredMeta =
const VerificationMeta('mediaStored');
@override
@ -2884,6 +2890,7 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
type,
content,
mediaId,
additionalMessageData,
mediaStored,
mediaReopened,
downloadToken,
@ -2930,6 +2937,12 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
context.handle(_mediaIdMeta,
mediaId.isAcceptableOrUnknown(data['media_id']!, _mediaIdMeta));
}
if (data.containsKey('additional_message_data')) {
context.handle(
_additionalMessageDataMeta,
additionalMessageData.isAcceptableOrUnknown(
data['additional_message_data']!, _additionalMessageDataMeta));
}
if (data.containsKey('media_stored')) {
context.handle(
_mediaStoredMeta,
@ -3013,6 +3026,8 @@ class $MessagesTable extends Messages with TableInfo<$MessagesTable, Message> {
.read(DriftSqlType.string, data['${effectivePrefix}content']),
mediaId: attachedDatabase.typeMapping
.read(DriftSqlType.string, data['${effectivePrefix}media_id']),
additionalMessageData: attachedDatabase.typeMapping.read(
DriftSqlType.blob, data['${effectivePrefix}additional_message_data']),
mediaStored: attachedDatabase.typeMapping
.read(DriftSqlType.bool, data['${effectivePrefix}media_stored'])!,
mediaReopened: attachedDatabase.typeMapping
@ -3054,6 +3069,7 @@ class Message extends DataClass implements Insertable<Message> {
final MessageType type;
final String? content;
final String? mediaId;
final Uint8List? additionalMessageData;
final bool mediaStored;
final bool mediaReopened;
final Uint8List? downloadToken;
@ -3072,6 +3088,7 @@ class Message extends DataClass implements Insertable<Message> {
required this.type,
this.content,
this.mediaId,
this.additionalMessageData,
required this.mediaStored,
required this.mediaReopened,
this.downloadToken,
@ -3100,6 +3117,10 @@ class Message extends DataClass implements Insertable<Message> {
if (!nullToAbsent || mediaId != null) {
map['media_id'] = Variable<String>(mediaId);
}
if (!nullToAbsent || additionalMessageData != null) {
map['additional_message_data'] =
Variable<Uint8List>(additionalMessageData);
}
map['media_stored'] = Variable<bool>(mediaStored);
map['media_reopened'] = Variable<bool>(mediaReopened);
if (!nullToAbsent || downloadToken != null) {
@ -3142,6 +3163,9 @@ class Message extends DataClass implements Insertable<Message> {
mediaId: mediaId == null && nullToAbsent
? const Value.absent()
: Value(mediaId),
additionalMessageData: additionalMessageData == null && nullToAbsent
? const Value.absent()
: Value(additionalMessageData),
mediaStored: Value(mediaStored),
mediaReopened: Value(mediaReopened),
downloadToken: downloadToken == null && nullToAbsent
@ -3181,6 +3205,8 @@ class Message extends DataClass implements Insertable<Message> {
.fromJson(serializer.fromJson<String>(json['type'])),
content: serializer.fromJson<String?>(json['content']),
mediaId: serializer.fromJson<String?>(json['mediaId']),
additionalMessageData:
serializer.fromJson<Uint8List?>(json['additionalMessageData']),
mediaStored: serializer.fromJson<bool>(json['mediaStored']),
mediaReopened: serializer.fromJson<bool>(json['mediaReopened']),
downloadToken: serializer.fromJson<Uint8List?>(json['downloadToken']),
@ -3206,6 +3232,8 @@ class Message extends DataClass implements Insertable<Message> {
serializer.toJson<String>($MessagesTable.$convertertype.toJson(type)),
'content': serializer.toJson<String?>(content),
'mediaId': serializer.toJson<String?>(mediaId),
'additionalMessageData':
serializer.toJson<Uint8List?>(additionalMessageData),
'mediaStored': serializer.toJson<bool>(mediaStored),
'mediaReopened': serializer.toJson<bool>(mediaReopened),
'downloadToken': serializer.toJson<Uint8List?>(downloadToken),
@ -3227,6 +3255,7 @@ class Message extends DataClass implements Insertable<Message> {
MessageType? type,
Value<String?> content = const Value.absent(),
Value<String?> mediaId = const Value.absent(),
Value<Uint8List?> additionalMessageData = const Value.absent(),
bool? mediaStored,
bool? mediaReopened,
Value<Uint8List?> downloadToken = const Value.absent(),
@ -3245,6 +3274,9 @@ class Message extends DataClass implements Insertable<Message> {
type: type ?? this.type,
content: content.present ? content.value : this.content,
mediaId: mediaId.present ? mediaId.value : this.mediaId,
additionalMessageData: additionalMessageData.present
? additionalMessageData.value
: this.additionalMessageData,
mediaStored: mediaStored ?? this.mediaStored,
mediaReopened: mediaReopened ?? this.mediaReopened,
downloadToken:
@ -3268,6 +3300,9 @@ class Message extends DataClass implements Insertable<Message> {
type: data.type.present ? data.type.value : this.type,
content: data.content.present ? data.content.value : this.content,
mediaId: data.mediaId.present ? data.mediaId.value : this.mediaId,
additionalMessageData: data.additionalMessageData.present
? data.additionalMessageData.value
: this.additionalMessageData,
mediaStored:
data.mediaStored.present ? data.mediaStored.value : this.mediaStored,
mediaReopened: data.mediaReopened.present
@ -3303,6 +3338,7 @@ class Message extends DataClass implements Insertable<Message> {
..write('type: $type, ')
..write('content: $content, ')
..write('mediaId: $mediaId, ')
..write('additionalMessageData: $additionalMessageData, ')
..write('mediaStored: $mediaStored, ')
..write('mediaReopened: $mediaReopened, ')
..write('downloadToken: $downloadToken, ')
@ -3326,6 +3362,7 @@ class Message extends DataClass implements Insertable<Message> {
type,
content,
mediaId,
$driftBlobEquality.hash(additionalMessageData),
mediaStored,
mediaReopened,
$driftBlobEquality.hash(downloadToken),
@ -3347,6 +3384,8 @@ class Message extends DataClass implements Insertable<Message> {
other.type == this.type &&
other.content == this.content &&
other.mediaId == this.mediaId &&
$driftBlobEquality.equals(
other.additionalMessageData, this.additionalMessageData) &&
other.mediaStored == this.mediaStored &&
other.mediaReopened == this.mediaReopened &&
$driftBlobEquality.equals(other.downloadToken, this.downloadToken) &&
@ -3367,6 +3406,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
final Value<MessageType> type;
final Value<String?> content;
final Value<String?> mediaId;
final Value<Uint8List?> additionalMessageData;
final Value<bool> mediaStored;
final Value<bool> mediaReopened;
final Value<Uint8List?> downloadToken;
@ -3386,6 +3426,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
this.type = const Value.absent(),
this.content = const Value.absent(),
this.mediaId = const Value.absent(),
this.additionalMessageData = const Value.absent(),
this.mediaStored = const Value.absent(),
this.mediaReopened = const Value.absent(),
this.downloadToken = const Value.absent(),
@ -3406,6 +3447,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
required MessageType type,
this.content = const Value.absent(),
this.mediaId = const Value.absent(),
this.additionalMessageData = const Value.absent(),
this.mediaStored = const Value.absent(),
this.mediaReopened = const Value.absent(),
this.downloadToken = const Value.absent(),
@ -3428,6 +3470,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
Expression<String>? type,
Expression<String>? content,
Expression<String>? mediaId,
Expression<Uint8List>? additionalMessageData,
Expression<bool>? mediaStored,
Expression<bool>? mediaReopened,
Expression<Uint8List>? downloadToken,
@ -3448,6 +3491,8 @@ class MessagesCompanion extends UpdateCompanion<Message> {
if (type != null) 'type': type,
if (content != null) 'content': content,
if (mediaId != null) 'media_id': mediaId,
if (additionalMessageData != null)
'additional_message_data': additionalMessageData,
if (mediaStored != null) 'media_stored': mediaStored,
if (mediaReopened != null) 'media_reopened': mediaReopened,
if (downloadToken != null) 'download_token': downloadToken,
@ -3471,6 +3516,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
Value<MessageType>? type,
Value<String?>? content,
Value<String?>? mediaId,
Value<Uint8List?>? additionalMessageData,
Value<bool>? mediaStored,
Value<bool>? mediaReopened,
Value<Uint8List?>? downloadToken,
@ -3490,6 +3536,8 @@ class MessagesCompanion extends UpdateCompanion<Message> {
type: type ?? this.type,
content: content ?? this.content,
mediaId: mediaId ?? this.mediaId,
additionalMessageData:
additionalMessageData ?? this.additionalMessageData,
mediaStored: mediaStored ?? this.mediaStored,
mediaReopened: mediaReopened ?? this.mediaReopened,
downloadToken: downloadToken ?? this.downloadToken,
@ -3527,6 +3575,10 @@ class MessagesCompanion extends UpdateCompanion<Message> {
if (mediaId.present) {
map['media_id'] = Variable<String>(mediaId.value);
}
if (additionalMessageData.present) {
map['additional_message_data'] =
Variable<Uint8List>(additionalMessageData.value);
}
if (mediaStored.present) {
map['media_stored'] = Variable<bool>(mediaStored.value);
}
@ -3575,6 +3627,7 @@ class MessagesCompanion extends UpdateCompanion<Message> {
..write('type: $type, ')
..write('content: $content, ')
..write('mediaId: $mediaId, ')
..write('additionalMessageData: $additionalMessageData, ')
..write('mediaStored: $mediaStored, ')
..write('mediaReopened: $mediaReopened, ')
..write('downloadToken: $downloadToken, ')
@ -3621,7 +3674,10 @@ class $MessageHistoriesTable extends MessageHistories
@override
late final GeneratedColumn<int> contactId = GeneratedColumn<int>(
'contact_id', aliasedName, true,
type: DriftSqlType.int, requiredDuringInsert: false);
type: DriftSqlType.int,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES contacts (user_id) ON DELETE CASCADE'));
static const VerificationMeta _contentMeta =
const VerificationMeta('content');
@override
@ -6964,7 +7020,10 @@ class $MessageActionsTable extends MessageActions
@override
late final GeneratedColumn<int> contactId = GeneratedColumn<int>(
'contact_id', aliasedName, false,
type: DriftSqlType.int, requiredDuringInsert: true);
type: DriftSqlType.int,
requiredDuringInsert: true,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'REFERENCES contacts (user_id) ON DELETE CASCADE'));
@override
late final GeneratedColumnWithTypeConverter<MessageActionType, String> type =
GeneratedColumn<String>('type', aliasedName, false,
@ -7837,6 +7896,13 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
TableUpdate('message_histories', kind: UpdateKind.delete),
],
),
WritePropagation(
on: TableUpdateQuery.onTableName('contacts',
limitUpdateKind: UpdateKind.delete),
result: [
TableUpdate('message_histories', kind: UpdateKind.delete),
],
),
WritePropagation(
on: TableUpdateQuery.onTableName('messages',
limitUpdateKind: UpdateKind.delete),
@ -7894,6 +7960,13 @@ abstract class _$TwonlyDB extends GeneratedDatabase {
TableUpdate('message_actions', kind: UpdateKind.delete),
],
),
WritePropagation(
on: TableUpdateQuery.onTableName('contacts',
limitUpdateKind: UpdateKind.delete),
result: [
TableUpdate('message_actions', kind: UpdateKind.delete),
],
),
WritePropagation(
on: TableUpdateQuery.onTableName('groups',
limitUpdateKind: UpdateKind.delete),
@ -7955,6 +8028,23 @@ final class $$ContactsTableReferences
manager.$state.copyWith(prefetchedData: cache));
}
static MultiTypedResultKey<$MessageHistoriesTable, List<MessageHistory>>
_messageHistoriesRefsTable(_$TwonlyDB db) =>
MultiTypedResultKey.fromTable(db.messageHistories,
aliasName: $_aliasNameGenerator(
db.contacts.userId, db.messageHistories.contactId));
$$MessageHistoriesTableProcessedTableManager get messageHistoriesRefs {
final manager =
$$MessageHistoriesTableTableManager($_db, $_db.messageHistories).filter(
(f) => f.contactId.userId.sqlEquals($_itemColumn<int>('user_id')!));
final cache =
$_typedResult.readTableOrNull(_messageHistoriesRefsTable($_db));
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache));
}
static MultiTypedResultKey<$ReactionsTable, List<Reaction>>
_reactionsRefsTable(_$TwonlyDB db) => MultiTypedResultKey.fromTable(
db.reactions,
@ -8041,6 +8131,22 @@ final class $$ContactsTableReferences
manager.$state.copyWith(prefetchedData: cache));
}
static MultiTypedResultKey<$MessageActionsTable, List<MessageAction>>
_messageActionsRefsTable(_$TwonlyDB db) =>
MultiTypedResultKey.fromTable(db.messageActions,
aliasName: $_aliasNameGenerator(
db.contacts.userId, db.messageActions.contactId));
$$MessageActionsTableProcessedTableManager get messageActionsRefs {
final manager = $$MessageActionsTableTableManager($_db, $_db.messageActions)
.filter(
(f) => f.contactId.userId.sqlEquals($_itemColumn<int>('user_id')!));
final cache = $_typedResult.readTableOrNull(_messageActionsRefsTable($_db));
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: cache));
}
static MultiTypedResultKey<$GroupHistoriesTable, List<GroupHistory>>
_groupHistoriesRefsTable(_$TwonlyDB db) =>
MultiTypedResultKey.fromTable(db.groupHistories,
@ -8130,6 +8236,27 @@ class $$ContactsTableFilterComposer
return f(composer);
}
Expression<bool> messageHistoriesRefs(
Expression<bool> Function($$MessageHistoriesTableFilterComposer f) f) {
final $$MessageHistoriesTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: $db.messageHistories,
getReferencedColumn: (t) => t.contactId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$MessageHistoriesTableFilterComposer(
$db: $db,
$table: $db.messageHistories,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return f(composer);
}
Expression<bool> reactionsRefs(
Expression<bool> Function($$ReactionsTableFilterComposer f) f) {
final $$ReactionsTableFilterComposer composer = $composerBuilder(
@ -8239,6 +8366,27 @@ class $$ContactsTableFilterComposer
return f(composer);
}
Expression<bool> messageActionsRefs(
Expression<bool> Function($$MessageActionsTableFilterComposer f) f) {
final $$MessageActionsTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: $db.messageActions,
getReferencedColumn: (t) => t.contactId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$MessageActionsTableFilterComposer(
$db: $db,
$table: $db.messageActions,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return f(composer);
}
Expression<bool> groupHistoriesRefs(
Expression<bool> Function($$GroupHistoriesTableFilterComposer f) f) {
final $$GroupHistoriesTableFilterComposer composer = $composerBuilder(
@ -8383,6 +8531,27 @@ class $$ContactsTableAnnotationComposer
return f(composer);
}
Expression<T> messageHistoriesRefs<T extends Object>(
Expression<T> Function($$MessageHistoriesTableAnnotationComposer a) f) {
final $$MessageHistoriesTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: $db.messageHistories,
getReferencedColumn: (t) => t.contactId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$MessageHistoriesTableAnnotationComposer(
$db: $db,
$table: $db.messageHistories,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return f(composer);
}
Expression<T> reactionsRefs<T extends Object>(
Expression<T> Function($$ReactionsTableAnnotationComposer a) f) {
final $$ReactionsTableAnnotationComposer composer = $composerBuilder(
@ -8493,6 +8662,27 @@ class $$ContactsTableAnnotationComposer
return f(composer);
}
Expression<T> messageActionsRefs<T extends Object>(
Expression<T> Function($$MessageActionsTableAnnotationComposer a) f) {
final $$MessageActionsTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.userId,
referencedTable: $db.messageActions,
getReferencedColumn: (t) => t.contactId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$MessageActionsTableAnnotationComposer(
$db: $db,
$table: $db.messageActions,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return f(composer);
}
Expression<T> groupHistoriesRefs<T extends Object>(
Expression<T> Function($$GroupHistoriesTableAnnotationComposer a) f) {
final $$GroupHistoriesTableAnnotationComposer composer = $composerBuilder(
@ -8528,11 +8718,13 @@ class $$ContactsTableTableManager extends RootTableManager<
Contact,
PrefetchHooks Function(
{bool messagesRefs,
bool messageHistoriesRefs,
bool reactionsRefs,
bool groupMembersRefs,
bool receiptsRefs,
bool signalContactPreKeysRefs,
bool signalContactSignedPreKeysRefs,
bool messageActionsRefs,
bool groupHistoriesRefs})> {
$$ContactsTableTableManager(_$TwonlyDB db, $ContactsTable table)
: super(TableManagerState(
@ -8610,22 +8802,26 @@ class $$ContactsTableTableManager extends RootTableManager<
.toList(),
prefetchHooksCallback: (
{messagesRefs = false,
messageHistoriesRefs = false,
reactionsRefs = false,
groupMembersRefs = false,
receiptsRefs = false,
signalContactPreKeysRefs = false,
signalContactSignedPreKeysRefs = false,
messageActionsRefs = false,
groupHistoriesRefs = false}) {
return PrefetchHooks(
db: db,
explicitlyWatchedTables: [
if (messagesRefs) db.messages,
if (messageHistoriesRefs) db.messageHistories,
if (reactionsRefs) db.reactions,
if (groupMembersRefs) db.groupMembers,
if (receiptsRefs) db.receipts,
if (signalContactPreKeysRefs) db.signalContactPreKeys,
if (signalContactSignedPreKeysRefs)
db.signalContactSignedPreKeys,
if (messageActionsRefs) db.messageActions,
if (groupHistoriesRefs) db.groupHistories
],
addJoins: null,
@ -8643,6 +8839,19 @@ class $$ContactsTableTableManager extends RootTableManager<
(item, referencedItems) => referencedItems
.where((e) => e.senderId == item.userId),
typedResults: items),
if (messageHistoriesRefs)
await $_getPrefetchedData<Contact, $ContactsTable,
MessageHistory>(
currentTable: table,
referencedTable: $$ContactsTableReferences
._messageHistoriesRefsTable(db),
managerFromTypedResult: (p0) =>
$$ContactsTableReferences(db, table, p0)
.messageHistoriesRefs,
referencedItemsForCurrentItem:
(item, referencedItems) => referencedItems
.where((e) => e.contactId == item.userId),
typedResults: items),
if (reactionsRefs)
await $_getPrefetchedData<Contact, $ContactsTable,
Reaction>(
@ -8707,6 +8916,19 @@ class $$ContactsTableTableManager extends RootTableManager<
(item, referencedItems) => referencedItems
.where((e) => e.contactId == item.userId),
typedResults: items),
if (messageActionsRefs)
await $_getPrefetchedData<Contact, $ContactsTable,
MessageAction>(
currentTable: table,
referencedTable: $$ContactsTableReferences
._messageActionsRefsTable(db),
managerFromTypedResult: (p0) =>
$$ContactsTableReferences(db, table, p0)
.messageActionsRefs,
referencedItemsForCurrentItem:
(item, referencedItems) => referencedItems
.where((e) => e.contactId == item.userId),
typedResults: items),
if (groupHistoriesRefs)
await $_getPrefetchedData<Contact, $ContactsTable,
GroupHistory>(
@ -8740,11 +8962,13 @@ typedef $$ContactsTableProcessedTableManager = ProcessedTableManager<
Contact,
PrefetchHooks Function(
{bool messagesRefs,
bool messageHistoriesRefs,
bool reactionsRefs,
bool groupMembersRefs,
bool receiptsRefs,
bool signalContactPreKeysRefs,
bool signalContactSignedPreKeysRefs,
bool messageActionsRefs,
bool groupHistoriesRefs})>;
typedef $$GroupsTableCreateCompanionBuilder = GroupsCompanion Function({
required String groupId,
@ -9927,6 +10151,7 @@ typedef $$MessagesTableCreateCompanionBuilder = MessagesCompanion Function({
required MessageType type,
Value<String?> content,
Value<String?> mediaId,
Value<Uint8List?> additionalMessageData,
Value<bool> mediaStored,
Value<bool> mediaReopened,
Value<Uint8List?> downloadToken,
@ -9947,6 +10172,7 @@ typedef $$MessagesTableUpdateCompanionBuilder = MessagesCompanion Function({
Value<MessageType> type,
Value<String?> content,
Value<String?> mediaId,
Value<Uint8List?> additionalMessageData,
Value<bool> mediaStored,
Value<bool> mediaReopened,
Value<Uint8List?> downloadToken,
@ -10096,6 +10322,10 @@ class $$MessagesTableFilterComposer
ColumnFilters<String> get content => $composableBuilder(
column: $table.content, builder: (column) => ColumnFilters(column));
ColumnFilters<Uint8List> get additionalMessageData => $composableBuilder(
column: $table.additionalMessageData,
builder: (column) => ColumnFilters(column));
ColumnFilters<bool> get mediaStored => $composableBuilder(
column: $table.mediaStored, builder: (column) => ColumnFilters(column));
@ -10294,6 +10524,10 @@ class $$MessagesTableOrderingComposer
ColumnOrderings<String> get content => $composableBuilder(
column: $table.content, builder: (column) => ColumnOrderings(column));
ColumnOrderings<Uint8List> get additionalMessageData => $composableBuilder(
column: $table.additionalMessageData,
builder: (column) => ColumnOrderings(column));
ColumnOrderings<bool> get mediaStored => $composableBuilder(
column: $table.mediaStored, builder: (column) => ColumnOrderings(column));
@ -10410,6 +10644,9 @@ class $$MessagesTableAnnotationComposer
GeneratedColumn<String> get content =>
$composableBuilder(column: $table.content, builder: (column) => column);
GeneratedColumn<Uint8List> get additionalMessageData => $composableBuilder(
column: $table.additionalMessageData, builder: (column) => column);
GeneratedColumn<bool> get mediaStored => $composableBuilder(
column: $table.mediaStored, builder: (column) => column);
@ -10624,6 +10861,7 @@ class $$MessagesTableTableManager extends RootTableManager<
Value<MessageType> type = const Value.absent(),
Value<String?> content = const Value.absent(),
Value<String?> mediaId = const Value.absent(),
Value<Uint8List?> additionalMessageData = const Value.absent(),
Value<bool> mediaStored = const Value.absent(),
Value<bool> mediaReopened = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
@ -10644,6 +10882,7 @@ class $$MessagesTableTableManager extends RootTableManager<
type: type,
content: content,
mediaId: mediaId,
additionalMessageData: additionalMessageData,
mediaStored: mediaStored,
mediaReopened: mediaReopened,
downloadToken: downloadToken,
@ -10664,6 +10903,7 @@ class $$MessagesTableTableManager extends RootTableManager<
required MessageType type,
Value<String?> content = const Value.absent(),
Value<String?> mediaId = const Value.absent(),
Value<Uint8List?> additionalMessageData = const Value.absent(),
Value<bool> mediaStored = const Value.absent(),
Value<bool> mediaReopened = const Value.absent(),
Value<Uint8List?> downloadToken = const Value.absent(),
@ -10684,6 +10924,7 @@ class $$MessagesTableTableManager extends RootTableManager<
type: type,
content: content,
mediaId: mediaId,
additionalMessageData: additionalMessageData,
mediaStored: mediaStored,
mediaReopened: mediaReopened,
downloadToken: downloadToken,
@ -10878,6 +11119,21 @@ final class $$MessageHistoriesTableReferences
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
static $ContactsTable _contactIdTable(_$TwonlyDB db) =>
db.contacts.createAlias($_aliasNameGenerator(
db.messageHistories.contactId, db.contacts.userId));
$$ContactsTableProcessedTableManager? get contactId {
final $_column = $_itemColumn<int>('contact_id');
if ($_column == null) return null;
final manager = $$ContactsTableTableManager($_db, $_db.contacts)
.filter((f) => f.userId.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_contactIdTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$MessageHistoriesTableFilterComposer
@ -10892,9 +11148,6 @@ class $$MessageHistoriesTableFilterComposer
ColumnFilters<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnFilters(column));
ColumnFilters<int> get contactId => $composableBuilder(
column: $table.contactId, builder: (column) => ColumnFilters(column));
ColumnFilters<String> get content => $composableBuilder(
column: $table.content, builder: (column) => ColumnFilters(column));
@ -10920,6 +11173,26 @@ class $$MessageHistoriesTableFilterComposer
));
return composer;
}
$$ContactsTableFilterComposer get contactId {
final $$ContactsTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.contactId,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$ContactsTableFilterComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$MessageHistoriesTableOrderingComposer
@ -10934,9 +11207,6 @@ class $$MessageHistoriesTableOrderingComposer
ColumnOrderings<int> get id => $composableBuilder(
column: $table.id, builder: (column) => ColumnOrderings(column));
ColumnOrderings<int> get contactId => $composableBuilder(
column: $table.contactId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get content => $composableBuilder(
column: $table.content, builder: (column) => ColumnOrderings(column));
@ -10962,6 +11232,26 @@ class $$MessageHistoriesTableOrderingComposer
));
return composer;
}
$$ContactsTableOrderingComposer get contactId {
final $$ContactsTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.contactId,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$ContactsTableOrderingComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$MessageHistoriesTableAnnotationComposer
@ -10976,9 +11266,6 @@ class $$MessageHistoriesTableAnnotationComposer
GeneratedColumn<int> get id =>
$composableBuilder(column: $table.id, builder: (column) => column);
GeneratedColumn<int> get contactId =>
$composableBuilder(column: $table.contactId, builder: (column) => column);
GeneratedColumn<String> get content =>
$composableBuilder(column: $table.content, builder: (column) => column);
@ -11004,6 +11291,26 @@ class $$MessageHistoriesTableAnnotationComposer
));
return composer;
}
$$ContactsTableAnnotationComposer get contactId {
final $$ContactsTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.contactId,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$ContactsTableAnnotationComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$MessageHistoriesTableTableManager extends RootTableManager<
@ -11017,7 +11324,7 @@ class $$MessageHistoriesTableTableManager extends RootTableManager<
$$MessageHistoriesTableUpdateCompanionBuilder,
(MessageHistory, $$MessageHistoriesTableReferences),
MessageHistory,
PrefetchHooks Function({bool messageId})> {
PrefetchHooks Function({bool messageId, bool contactId})> {
$$MessageHistoriesTableTableManager(
_$TwonlyDB db, $MessageHistoriesTable table)
: super(TableManagerState(
@ -11063,7 +11370,7 @@ class $$MessageHistoriesTableTableManager extends RootTableManager<
$$MessageHistoriesTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({messageId = false}) {
prefetchHooksCallback: ({messageId = false, contactId = false}) {
return PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
@ -11091,6 +11398,17 @@ class $$MessageHistoriesTableTableManager extends RootTableManager<
.messageId,
) as T;
}
if (contactId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.contactId,
referencedTable:
$$MessageHistoriesTableReferences._contactIdTable(db),
referencedColumn: $$MessageHistoriesTableReferences
._contactIdTable(db)
.userId,
) as T;
}
return state;
},
@ -11113,7 +11431,7 @@ typedef $$MessageHistoriesTableProcessedTableManager = ProcessedTableManager<
$$MessageHistoriesTableUpdateCompanionBuilder,
(MessageHistory, $$MessageHistoriesTableReferences),
MessageHistory,
PrefetchHooks Function({bool messageId})>;
PrefetchHooks Function({bool messageId, bool contactId})>;
typedef $$ReactionsTableCreateCompanionBuilder = ReactionsCompanion Function({
required String messageId,
required String emoji,
@ -13581,6 +13899,21 @@ final class $$MessageActionsTableReferences
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
static $ContactsTable _contactIdTable(_$TwonlyDB db) =>
db.contacts.createAlias($_aliasNameGenerator(
db.messageActions.contactId, db.contacts.userId));
$$ContactsTableProcessedTableManager get contactId {
final $_column = $_itemColumn<int>('contact_id')!;
final manager = $$ContactsTableTableManager($_db, $_db.contacts)
.filter((f) => f.userId.sqlEquals($_column));
final item = $_typedResult.readTableOrNull(_contactIdTable($_db));
if (item == null) return manager;
return ProcessedTableManager(
manager.$state.copyWith(prefetchedData: [item]));
}
}
class $$MessageActionsTableFilterComposer
@ -13592,9 +13925,6 @@ class $$MessageActionsTableFilterComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnFilters<int> get contactId => $composableBuilder(
column: $table.contactId, builder: (column) => ColumnFilters(column));
ColumnWithTypeConverterFilters<MessageActionType, MessageActionType, String>
get type => $composableBuilder(
column: $table.type,
@ -13622,6 +13952,26 @@ class $$MessageActionsTableFilterComposer
));
return composer;
}
$$ContactsTableFilterComposer get contactId {
final $$ContactsTableFilterComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.contactId,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$ContactsTableFilterComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$MessageActionsTableOrderingComposer
@ -13633,9 +13983,6 @@ class $$MessageActionsTableOrderingComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
ColumnOrderings<int> get contactId => $composableBuilder(
column: $table.contactId, builder: (column) => ColumnOrderings(column));
ColumnOrderings<String> get type => $composableBuilder(
column: $table.type, builder: (column) => ColumnOrderings(column));
@ -13661,6 +14008,26 @@ class $$MessageActionsTableOrderingComposer
));
return composer;
}
$$ContactsTableOrderingComposer get contactId {
final $$ContactsTableOrderingComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.contactId,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$ContactsTableOrderingComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$MessageActionsTableAnnotationComposer
@ -13672,9 +14039,6 @@ class $$MessageActionsTableAnnotationComposer
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
GeneratedColumn<int> get contactId =>
$composableBuilder(column: $table.contactId, builder: (column) => column);
GeneratedColumnWithTypeConverter<MessageActionType, String> get type =>
$composableBuilder(column: $table.type, builder: (column) => column);
@ -13700,6 +14064,26 @@ class $$MessageActionsTableAnnotationComposer
));
return composer;
}
$$ContactsTableAnnotationComposer get contactId {
final $$ContactsTableAnnotationComposer composer = $composerBuilder(
composer: this,
getCurrentColumn: (t) => t.contactId,
referencedTable: $db.contacts,
getReferencedColumn: (t) => t.userId,
builder: (joinBuilder,
{$addJoinBuilderToRootComposer,
$removeJoinBuilderFromRootComposer}) =>
$$ContactsTableAnnotationComposer(
$db: $db,
$table: $db.contacts,
$addJoinBuilderToRootComposer: $addJoinBuilderToRootComposer,
joinBuilder: joinBuilder,
$removeJoinBuilderFromRootComposer:
$removeJoinBuilderFromRootComposer,
));
return composer;
}
}
class $$MessageActionsTableTableManager extends RootTableManager<
@ -13713,7 +14097,7 @@ class $$MessageActionsTableTableManager extends RootTableManager<
$$MessageActionsTableUpdateCompanionBuilder,
(MessageAction, $$MessageActionsTableReferences),
MessageAction,
PrefetchHooks Function({bool messageId})> {
PrefetchHooks Function({bool messageId, bool contactId})> {
$$MessageActionsTableTableManager(_$TwonlyDB db, $MessageActionsTable table)
: super(TableManagerState(
db: db,
@ -13758,7 +14142,7 @@ class $$MessageActionsTableTableManager extends RootTableManager<
$$MessageActionsTableReferences(db, table, e)
))
.toList(),
prefetchHooksCallback: ({messageId = false}) {
prefetchHooksCallback: ({messageId = false, contactId = false}) {
return PrefetchHooks(
db: db,
explicitlyWatchedTables: [],
@ -13786,6 +14170,17 @@ class $$MessageActionsTableTableManager extends RootTableManager<
.messageId,
) as T;
}
if (contactId) {
state = state.withJoin(
currentTable: table,
currentColumn: table.contactId,
referencedTable:
$$MessageActionsTableReferences._contactIdTable(db),
referencedColumn: $$MessageActionsTableReferences
._contactIdTable(db)
.userId,
) as T;
}
return state;
},
@ -13808,7 +14203,7 @@ typedef $$MessageActionsTableProcessedTableManager = ProcessedTableManager<
$$MessageActionsTableUpdateCompanionBuilder,
(MessageAction, $$MessageActionsTableReferences),
MessageAction,
PrefetchHooks Function({bool messageId})>;
PrefetchHooks Function({bool messageId, bool contactId})>;
typedef $$GroupHistoriesTableCreateCompanionBuilder = GroupHistoriesCompanion
Function({
required String groupHistoryId,

View file

@ -2804,12 +2804,439 @@ i1.GeneratedColumn<DateTime> _column_104(String aliasedName) =>
i1.GeneratedColumn<DateTime>(
'mark_for_retry_after_accepted', aliasedName, true,
type: i1.DriftSqlType.dateTime);
final class Schema7 extends i0.VersionedSchema {
Schema7({required super.database}) : super(version: 7);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
groups,
mediaFiles,
messages,
messageHistories,
reactions,
groupMembers,
receipts,
receivedReceipts,
signalIdentityKeyStores,
signalPreKeyStores,
signalSenderKeyStores,
signalSessionStores,
signalContactPreKeys,
signalContactSignedPreKeys,
messageActions,
groupHistories,
];
late final Shape0 contacts = Shape0(
source: i0.VersionedTable(
entityName: 'contacts',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(user_id)',
],
columns: [
_column_0,
_column_1,
_column_2,
_column_3,
_column_4,
_column_5,
_column_6,
_column_7,
_column_8,
_column_9,
_column_10,
_column_11,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape17 groups = Shape17(
source: i0.VersionedTable(
entityName: 'groups',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(group_id)',
],
columns: [
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_22,
_column_23,
_column_24,
_column_100,
_column_25,
_column_26,
_column_27,
_column_12,
_column_28,
_column_29,
_column_30,
_column_31,
_column_32,
_column_33,
_column_34,
_column_35,
],
attachedDatabase: database,
),
alias: null);
late final Shape18 mediaFiles = Shape18(
source: i0.VersionedTable(
entityName: 'media_files',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(media_id)',
],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_102,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape21 messages = Shape21(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(message_id)',
],
columns: [
_column_50,
_column_51,
_column_52,
_column_37,
_column_53,
_column_54,
_column_105,
_column_55,
_column_56,
_column_46,
_column_57,
_column_58,
_column_59,
_column_60,
_column_12,
_column_61,
_column_62,
_column_63,
],
attachedDatabase: database,
),
alias: null);
late final Shape4 messageHistories = Shape4(
source: i0.VersionedTable(
entityName: 'message_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(id)',
],
columns: [
_column_64,
_column_65,
_column_66,
_column_53,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape5 reactions = Shape5(
source: i0.VersionedTable(
entityName: 'reactions',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(message_id, sender_id, emoji)',
],
columns: [
_column_65,
_column_67,
_column_68,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape6 groupMembers = Shape6(
source: i0.VersionedTable(
entityName: 'group_members',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(group_id, contact_id)',
],
columns: [
_column_50,
_column_69,
_column_70,
_column_71,
_column_72,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape20 receipts = Shape20(
source: i0.VersionedTable(
entityName: 'receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(receipt_id)',
],
columns: [
_column_73,
_column_74,
_column_75,
_column_76,
_column_77,
_column_103,
_column_104,
_column_78,
_column_79,
_column_80,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape8 receivedReceipts = Shape8(
source: i0.VersionedTable(
entityName: 'received_receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(receipt_id)',
],
columns: [
_column_73,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape9 signalIdentityKeyStores = Shape9(
source: i0.VersionedTable(
entityName: 'signal_identity_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(device_id, name)',
],
columns: [
_column_81,
_column_82,
_column_83,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape10 signalPreKeyStores = Shape10(
source: i0.VersionedTable(
entityName: 'signal_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(pre_key_id)',
],
columns: [
_column_84,
_column_85,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape11 signalSenderKeyStores = Shape11(
source: i0.VersionedTable(
entityName: 'signal_sender_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(sender_key_name)',
],
columns: [
_column_86,
_column_87,
],
attachedDatabase: database,
),
alias: null);
late final Shape12 signalSessionStores = Shape12(
source: i0.VersionedTable(
entityName: 'signal_session_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(device_id, name)',
],
columns: [
_column_81,
_column_82,
_column_88,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape13 signalContactPreKeys = Shape13(
source: i0.VersionedTable(
entityName: 'signal_contact_pre_keys',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(contact_id, pre_key_id)',
],
columns: [
_column_74,
_column_84,
_column_85,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape14 signalContactSignedPreKeys = Shape14(
source: i0.VersionedTable(
entityName: 'signal_contact_signed_pre_keys',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(contact_id)',
],
columns: [
_column_74,
_column_89,
_column_90,
_column_91,
_column_12,
],
attachedDatabase: database,
),
alias: null);
late final Shape15 messageActions = Shape15(
source: i0.VersionedTable(
entityName: 'message_actions',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(message_id, contact_id, type)',
],
columns: [
_column_65,
_column_92,
_column_37,
_column_93,
],
attachedDatabase: database,
),
alias: null);
late final Shape16 groupHistories = Shape16(
source: i0.VersionedTable(
entityName: 'group_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: [
'PRIMARY KEY(group_history_id)',
],
columns: [
_column_94,
_column_50,
_column_95,
_column_101,
_column_97,
_column_98,
_column_99,
_column_37,
_column_93,
],
attachedDatabase: database,
),
alias: null);
}
class Shape21 extends i0.VersionedTable {
Shape21({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get groupId =>
columnsByName['group_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get messageId =>
columnsByName['message_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get senderId =>
columnsByName['sender_id']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get type =>
columnsByName['type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get content =>
columnsByName['content']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get mediaId =>
columnsByName['media_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<i2.Uint8List> get additionalMessageData =>
columnsByName['additional_message_data']!
as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<bool> get mediaStored =>
columnsByName['media_stored']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<bool> get mediaReopened =>
columnsByName['media_reopened']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<i2.Uint8List> get downloadToken =>
columnsByName['download_token']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<String> get quotesMessageId =>
columnsByName['quotes_message_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isDeletedFromSender =>
columnsByName['is_deleted_from_sender']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<DateTime> get openedAt =>
columnsByName['opened_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get openedByAll =>
columnsByName['opened_by_all']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get modifiedAt =>
columnsByName['modified_at']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get ackByUser =>
columnsByName['ack_by_user']! as i1.GeneratedColumn<DateTime>;
i1.GeneratedColumn<DateTime> get ackByServer =>
columnsByName['ack_by_server']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<i2.Uint8List> _column_105(String aliasedName) =>
i1.GeneratedColumn<i2.Uint8List>(
'additional_message_data', aliasedName, true,
type: i1.DriftSqlType.blob);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -2838,6 +3265,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from5To6(migrator, schema);
return 6;
case 6:
final schema = Schema7(database: database);
final migrator = i1.Migrator(database, schema);
await from6To7(migrator, schema);
return 7;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -2850,6 +3282,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema4 schema) from3To4,
required Future<void> Function(i1.Migrator m, Schema5 schema) from4To5,
required Future<void> Function(i1.Migrator m, Schema6 schema) from5To6,
required Future<void> Function(i1.Migrator m, Schema7 schema) from6To7,
}) =>
i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
@ -2858,4 +3291,5 @@ i1.OnUpgrade stepByStep({
from3To4: from3To4,
from4To5: from4To5,
from5To6: from5To6,
from6To7: from6To7,
));

View file

@ -2518,6 +2518,12 @@ abstract class AppLocalizations {
/// **'wants to connect with you.'**
String get notificationContactRequest;
/// No description provided for @notificationContactRequestUnknownUser.
///
/// In en, this message translates to:
/// **'have received a new contact request.'**
String get notificationContactRequestUnknownUser;
/// No description provided for @notificationAcceptRequest.
///
/// In en, this message translates to:
@ -2572,11 +2578,17 @@ abstract class AppLocalizations {
/// **'has responded{inGroup}.'**
String notificationResponse(Object inGroup);
/// No description provided for @notificationTitleUnknownUser.
/// No description provided for @notificationTitleUnknown.
///
/// In en, this message translates to:
/// **'[Unknown]'**
String get notificationTitleUnknownUser;
/// **'You have a new message.'**
String get notificationTitleUnknown;
/// No description provided for @notificationBodyUnknown.
///
/// In en, this message translates to:
/// **'Open twonly to learn more.'**
String get notificationBodyUnknown;
/// No description provided for @notificationCategoryMessageTitle.
///

View file

@ -1380,6 +1380,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get notificationContactRequest => 'möchte sich mit dir vernetzen.';
@override
String get notificationContactRequestUnknownUser =>
'hast eine neue Kontaktanfrage erhalten.';
@override
String get notificationAcceptRequest => 'ist jetzt mit dir vernetzt.';
@ -1418,7 +1422,10 @@ class AppLocalizationsDe extends AppLocalizations {
}
@override
String get notificationTitleUnknownUser => '[Unbekannt]';
String get notificationTitleUnknown => 'Du hast eine neue Nachricht.';
@override
String get notificationBodyUnknown => 'Öffne twonly um mehr zu erfahren.';
@override
String get notificationCategoryMessageTitle => 'Nachrichten';

View file

@ -1372,6 +1372,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get notificationContactRequest => 'wants to connect with you.';
@override
String get notificationContactRequestUnknownUser =>
'have received a new contact request.';
@override
String get notificationAcceptRequest => 'is now connected with you.';
@ -1410,7 +1414,10 @@ class AppLocalizationsEn extends AppLocalizations {
}
@override
String get notificationTitleUnknownUser => '[Unknown]';
String get notificationTitleUnknown => 'You have a new message.';
@override
String get notificationBodyUnknown => 'Open twonly to learn more.';
@override
String get notificationCategoryMessageTitle => 'Messages';

View file

@ -1372,6 +1372,10 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get notificationContactRequest => 'wants to connect with you.';
@override
String get notificationContactRequestUnknownUser =>
'have received a new contact request.';
@override
String get notificationAcceptRequest => 'is now connected with you.';
@ -1410,7 +1414,10 @@ class AppLocalizationsSv extends AppLocalizations {
}
@override
String get notificationTitleUnknownUser => '[Unknown]';
String get notificationTitleUnknown => 'You have a new message.';
@override
String get notificationBodyUnknown => 'Open twonly to learn more.';
@override
String get notificationCategoryMessageTitle => 'Messages';

@ -1 +1 @@
Subproject commit 20f3c2f0a49e4c9be452ecbc84d98054c92974e1
Subproject commit 9d04e9e1d0cdba8f1be4b0cbba341706c3cffac9

View file

@ -108,6 +108,19 @@ class UserData {
DateTime? nextTimeToShowBackupNotice;
BackupServer? backupServer;
TwonlySafeBackup? twonlySafeBackup;
// For my master thesis I want to create a anonymous user study:
// - users in the "Tester" Plan can, if they want, take part of the user study
@JsonKey(defaultValue: false)
bool askedForUserStudyPermission = false;
// So update data can be assigned. If set the user choose to participate.
String? userStudyParticipantsToken;
// Once a day the anonymous data is collected and send to the server
DateTime? lastUserStudyDataUpload;
Map<String, dynamic> toJson() => _$UserDataToJson(this);
}

View file

@ -77,7 +77,14 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) => UserData(
..twonlySafeBackup = json['twonlySafeBackup'] == null
? null
: TwonlySafeBackup.fromJson(
json['twonlySafeBackup'] as Map<String, dynamic>);
json['twonlySafeBackup'] as Map<String, dynamic>)
..askedForUserStudyPermission =
json['askedForUserStudyPermission'] as bool? ?? false
..userStudyParticipantsToken =
json['userStudyParticipantsToken'] as String?
..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null
? null
: DateTime.parse(json['lastUserStudyDataUpload'] as String);
Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'userId': instance.userId,
@ -122,6 +129,10 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
instance.nextTimeToShowBackupNotice?.toIso8601String(),
'backupServer': instance.backupServer,
'twonlySafeBackup': instance.twonlySafeBackup,
'askedForUserStudyPermission': instance.askedForUserStudyPermission,
'userStudyParticipantsToken': instance.userStudyParticipantsToken,
'lastUserStudyDataUpload':
instance.lastUserStudyDataUpload?.toIso8601String(),
};
const _$ThemeModeEnumMap = {

View file

@ -0,0 +1,11 @@
syntax = "proto3";
message AdditionalMessageData {
enum Type {
LINK = 0;
}
Type type = 1;
optional string link = 2;
}

View file

@ -0,0 +1,99 @@
// This is a generated file - do not edit.
//
// Generated from data.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
import 'data.pbenum.dart';
export 'package:protobuf/protobuf.dart' show GeneratedMessageGenericExtensions;
export 'data.pbenum.dart';
class AdditionalMessageData extends $pb.GeneratedMessage {
factory AdditionalMessageData({
AdditionalMessageData_Type? type,
$core.String? link,
}) {
final result = create();
if (type != null) result.type = type;
if (link != null) result.link = link;
return result;
}
AdditionalMessageData._();
factory AdditionalMessageData.fromBuffer($core.List<$core.int> data,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromBuffer(data, registry);
factory AdditionalMessageData.fromJson($core.String json,
[$pb.ExtensionRegistry registry = $pb.ExtensionRegistry.EMPTY]) =>
create()..mergeFromJson(json, registry);
static final $pb.BuilderInfo _i = $pb.BuilderInfo(
_omitMessageNames ? '' : 'AdditionalMessageData',
createEmptyInstance: create)
..e<AdditionalMessageData_Type>(
1, _omitFieldNames ? '' : 'type', $pb.PbFieldType.OE,
defaultOrMaker: AdditionalMessageData_Type.LINK,
valueOf: AdditionalMessageData_Type.valueOf,
enumValues: AdditionalMessageData_Type.values)
..aOS(2, _omitFieldNames ? '' : 'link')
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
AdditionalMessageData clone() =>
AdditionalMessageData()..mergeFromMessage(this);
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
AdditionalMessageData copyWith(
void Function(AdditionalMessageData) updates) =>
super.copyWith((message) => updates(message as AdditionalMessageData))
as AdditionalMessageData;
@$core.override
$pb.BuilderInfo get info_ => _i;
@$core.pragma('dart2js:noInline')
static AdditionalMessageData create() => AdditionalMessageData._();
@$core.override
AdditionalMessageData createEmptyInstance() => create();
static $pb.PbList<AdditionalMessageData> createRepeated() =>
$pb.PbList<AdditionalMessageData>();
@$core.pragma('dart2js:noInline')
static AdditionalMessageData getDefault() => _defaultInstance ??=
$pb.GeneratedMessage.$_defaultFor<AdditionalMessageData>(create);
static AdditionalMessageData? _defaultInstance;
@$pb.TagNumber(1)
AdditionalMessageData_Type get type => $_getN(0);
@$pb.TagNumber(1)
set type(AdditionalMessageData_Type value) => $_setField(1, value);
@$pb.TagNumber(1)
$core.bool hasType() => $_has(0);
@$pb.TagNumber(1)
void clearType() => $_clearField(1);
@$pb.TagNumber(2)
$core.String get link => $_getSZ(1);
@$pb.TagNumber(2)
set link($core.String value) => $_setString(1, value);
@$pb.TagNumber(2)
$core.bool hasLink() => $_has(1);
@$pb.TagNumber(2)
void clearLink() => $_clearField(2);
}
const $core.bool _omitFieldNames =
$core.bool.fromEnvironment('protobuf.omit_field_names');
const $core.bool _omitMessageNames =
$core.bool.fromEnvironment('protobuf.omit_message_names');

View file

@ -0,0 +1,35 @@
// This is a generated file - do not edit.
//
// Generated from data.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names
import 'dart:core' as $core;
import 'package:protobuf/protobuf.dart' as $pb;
class AdditionalMessageData_Type extends $pb.ProtobufEnum {
static const AdditionalMessageData_Type LINK =
AdditionalMessageData_Type._(0, _omitEnumNames ? '' : 'LINK');
static const $core.List<AdditionalMessageData_Type> values =
<AdditionalMessageData_Type>[
LINK,
];
static final $core.List<AdditionalMessageData_Type?> _byValue =
$pb.ProtobufEnum.$_initByValueList(values, 0);
static AdditionalMessageData_Type? valueOf($core.int value) =>
value < 0 || value >= _byValue.length ? null : _byValue[value];
const AdditionalMessageData_Type._(super.value, super.name);
}
const $core.bool _omitEnumNames =
$core.bool.fromEnvironment('protobuf.omit_enum_names');

View file

@ -0,0 +1,49 @@
// This is a generated file - do not edit.
//
// Generated from data.proto.
// @dart = 3.3
// ignore_for_file: annotate_overrides, camel_case_types, comment_references
// ignore_for_file: constant_identifier_names
// ignore_for_file: curly_braces_in_flow_control_structures
// ignore_for_file: deprecated_member_use_from_same_package, library_prefixes
// ignore_for_file: non_constant_identifier_names, unused_import
import 'dart:convert' as $convert;
import 'dart:core' as $core;
import 'dart:typed_data' as $typed_data;
@$core.Deprecated('Use additionalMessageDataDescriptor instead')
const AdditionalMessageData$json = {
'1': 'AdditionalMessageData',
'2': [
{
'1': 'type',
'3': 1,
'4': 1,
'5': 14,
'6': '.AdditionalMessageData.Type',
'10': 'type'
},
{'1': 'link', '3': 2, '4': 1, '5': 9, '9': 0, '10': 'link', '17': true},
],
'4': [AdditionalMessageData_Type$json],
'8': [
{'1': '_link'},
],
};
@$core.Deprecated('Use additionalMessageDataDescriptor instead')
const AdditionalMessageData_Type$json = {
'1': 'Type',
'2': [
{'1': 'LINK', '2': 0},
],
};
/// Descriptor for `AdditionalMessageData`. Decode as a `google.protobuf.DescriptorProto`.
final $typed_data.Uint8List additionalMessageDataDescriptor = $convert.base64Decode(
'ChVBZGRpdGlvbmFsTWVzc2FnZURhdGESLwoEdHlwZRgBIAEoDjIbLkFkZGl0aW9uYWxNZXNzYW'
'dlRGF0YS5UeXBlUgR0eXBlEhcKBGxpbmsYAiABKAlIAFIEbGlua4gBASIQCgRUeXBlEggKBExJ'
'TksQAEIHCgVfbGluaw==');

View file

@ -969,6 +969,7 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
$core.List<$core.int>? encryptionKey,
$core.List<$core.int>? encryptionMac,
$core.List<$core.int>? encryptionNonce,
$core.List<$core.int>? additionalMessageData,
}) {
final result = create();
if (senderMessageId != null) result.senderMessageId = senderMessageId;
@ -983,6 +984,8 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
if (encryptionKey != null) result.encryptionKey = encryptionKey;
if (encryptionMac != null) result.encryptionMac = encryptionMac;
if (encryptionNonce != null) result.encryptionNonce = encryptionNonce;
if (additionalMessageData != null)
result.additionalMessageData = additionalMessageData;
return result;
}
@ -1024,6 +1027,8 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
..a<$core.List<$core.int>>(
10, _omitFieldNames ? '' : 'encryptionNonce', $pb.PbFieldType.OY,
protoName: 'encryptionNonce')
..a<$core.List<$core.int>>(
11, _omitFieldNames ? '' : 'additionalMessageData', $pb.PbFieldType.OY)
..hasRequiredFields = false;
@$core.Deprecated('See https://github.com/google/protobuf.dart/issues/998.')
@ -1138,6 +1143,16 @@ class EncryptedContent_Media extends $pb.GeneratedMessage {
$core.bool hasEncryptionNonce() => $_has(9);
@$pb.TagNumber(10)
void clearEncryptionNonce() => $_clearField(10);
@$pb.TagNumber(11)
$core.List<$core.int> get additionalMessageData => $_getN(10);
@$pb.TagNumber(11)
set additionalMessageData($core.List<$core.int> value) =>
$_setBytes(10, value);
@$pb.TagNumber(11)
$core.bool hasAdditionalMessageData() => $_has(10);
@$pb.TagNumber(11)
void clearAdditionalMessageData() => $_clearField(11);
}
class EncryptedContent_MediaUpdate extends $pb.GeneratedMessage {

View file

@ -603,6 +603,15 @@ const EncryptedContent_Media$json = {
'10': 'encryptionNonce',
'17': true
},
{
'1': 'additional_message_data',
'3': 11,
'4': 1,
'5': 12,
'9': 6,
'10': 'additionalMessageData',
'17': true
},
],
'4': [EncryptedContent_Media_Type$json],
'8': [
@ -612,6 +621,7 @@ const EncryptedContent_Media$json = {
{'1': '_encryptionKey'},
{'1': '_encryptionMac'},
{'1': '_encryptionNonce'},
{'1': '_additional_message_data'},
],
};
@ -840,7 +850,7 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
'EjoKGG11bHRpcGxlVGFyZ2V0TWVzc2FnZUlkcxgDIAMoCVIYbXVsdGlwbGVUYXJnZXRNZXNzYW'
'dlSWRzEhcKBHRleHQYBCABKAlIAVIEdGV4dIgBARIcCgl0aW1lc3RhbXAYBSABKANSCXRpbWVz'
'dGFtcCItCgRUeXBlEgoKBkRFTEVURRAAEg0KCUVESVRfVEVYVBABEgoKBk9QRU5FRBACQhIKEF'
'9zZW5kZXJNZXNzYWdlSWRCBwoFX3RleHQalwUKBU1lZGlhEigKD3NlbmRlck1lc3NhZ2VJZBgB'
'9zZW5kZXJNZXNzYWdlSWRCBwoFX3RleHQa8AUKBU1lZGlhEigKD3NlbmRlck1lc3NhZ2VJZBgB'
'IAEoCVIPc2VuZGVyTWVzc2FnZUlkEjAKBHR5cGUYAiABKA4yHC5FbmNyeXB0ZWRDb250ZW50Lk'
'1lZGlhLlR5cGVSBHR5cGUSQwoaZGlzcGxheUxpbWl0SW5NaWxsaXNlY29uZHMYAyABKANIAFIa'
'ZGlzcGxheUxpbWl0SW5NaWxsaXNlY29uZHOIAQESNgoWcmVxdWlyZXNBdXRoZW50aWNhdGlvbh'
@ -849,29 +859,31 @@ final $typed_data.Uint8List encryptedContentDescriptor = $convert.base64Decode(
'dubG9hZFRva2VuGAcgASgMSAJSDWRvd25sb2FkVG9rZW6IAQESKQoNZW5jcnlwdGlvbktleRgI'
'IAEoDEgDUg1lbmNyeXB0aW9uS2V5iAEBEikKDWVuY3J5cHRpb25NYWMYCSABKAxIBFINZW5jcn'
'lwdGlvbk1hY4gBARItCg9lbmNyeXB0aW9uTm9uY2UYCiABKAxIBVIPZW5jcnlwdGlvbk5vbmNl'
'iAEBIj4KBFR5cGUSDAoIUkVVUExPQUQQABIJCgVJTUFHRRABEgkKBVZJREVPEAISBwoDR0lGEA'
'MSCQoFQVVESU8QBEIdChtfZGlzcGxheUxpbWl0SW5NaWxsaXNlY29uZHNCEQoPX3F1b3RlTWVz'
'c2FnZUlkQhAKDl9kb3dubG9hZFRva2VuQhAKDl9lbmNyeXB0aW9uS2V5QhAKDl9lbmNyeXB0aW'
'9uTWFjQhIKEF9lbmNyeXB0aW9uTm9uY2UapwEKC01lZGlhVXBkYXRlEjYKBHR5cGUYASABKA4y'
'Ii5FbmNyeXB0ZWRDb250ZW50Lk1lZGlhVXBkYXRlLlR5cGVSBHR5cGUSKAoPdGFyZ2V0TWVzc2'
'FnZUlkGAIgASgJUg90YXJnZXRNZXNzYWdlSWQiNgoEVHlwZRIMCghSRU9QRU5FRBAAEgoKBlNU'
'T1JFRBABEhQKEERFQ1JZUFRJT05fRVJST1IQAhp4Cg5Db250YWN0UmVxdWVzdBI5CgR0eXBlGA'
'EgASgOMiUuRW5jcnlwdGVkQ29udGVudC5Db250YWN0UmVxdWVzdC5UeXBlUgR0eXBlIisKBFR5'
'cGUSCwoHUkVRVUVTVBAAEgoKBlJFSkVDVBABEgoKBkFDQ0VQVBACGp4CCg1Db250YWN0VXBkYX'
'RlEjgKBHR5cGUYASABKA4yJC5FbmNyeXB0ZWRDb250ZW50LkNvbnRhY3RVcGRhdGUuVHlwZVIE'
'dHlwZRI1ChNhdmF0YXJTdmdDb21wcmVzc2VkGAIgASgMSABSE2F2YXRhclN2Z0NvbXByZXNzZW'
'SIAQESHwoIdXNlcm5hbWUYAyABKAlIAVIIdXNlcm5hbWWIAQESJQoLZGlzcGxheU5hbWUYBCAB'
'KAlIAlILZGlzcGxheU5hbWWIAQEiHwoEVHlwZRILCgdSRVFVRVNUEAASCgoGVVBEQVRFEAFCFg'
'oUX2F2YXRhclN2Z0NvbXByZXNzZWRCCwoJX3VzZXJuYW1lQg4KDF9kaXNwbGF5TmFtZRrVAQoI'
'UHVzaEtleXMSMwoEdHlwZRgBIAEoDjIfLkVuY3J5cHRlZENvbnRlbnQuUHVzaEtleXMuVHlwZV'
'IEdHlwZRIZCgVrZXlJZBgCIAEoA0gAUgVrZXlJZIgBARIVCgNrZXkYAyABKAxIAVIDa2V5iAEB'
'EiEKCWNyZWF0ZWRBdBgEIAEoA0gCUgljcmVhdGVkQXSIAQEiHwoEVHlwZRILCgdSRVFVRVNUEA'
'ASCgoGVVBEQVRFEAFCCAoGX2tleUlkQgYKBF9rZXlCDAoKX2NyZWF0ZWRBdBqpAQoJRmxhbWVT'
'eW5jEiIKDGZsYW1lQ291bnRlchgBIAEoA1IMZmxhbWVDb3VudGVyEjYKFmxhc3RGbGFtZUNvdW'
'50ZXJDaGFuZ2UYAiABKANSFmxhc3RGbGFtZUNvdW50ZXJDaGFuZ2USHgoKYmVzdEZyaWVuZBgD'
'IAEoCFIKYmVzdEZyaWVuZBIgCgtmb3JjZVVwZGF0ZRgEIAEoCFILZm9yY2VVcGRhdGVCCgoIX2'
'dyb3VwSWRCDwoNX2lzRGlyZWN0Q2hhdEIXChVfc2VuZGVyUHJvZmlsZUNvdW50ZXJCEAoOX21l'
'c3NhZ2VVcGRhdGVCCAoGX21lZGlhQg4KDF9tZWRpYVVwZGF0ZUIQCg5fY29udGFjdFVwZGF0ZU'
'IRCg9fY29udGFjdFJlcXVlc3RCDAoKX2ZsYW1lU3luY0ILCglfcHVzaEtleXNCCwoJX3JlYWN0'
'aW9uQg4KDF90ZXh0TWVzc2FnZUIOCgxfZ3JvdXBDcmVhdGVCDAoKX2dyb3VwSm9pbkIOCgxfZ3'
'JvdXBVcGRhdGVCFwoVX3Jlc2VuZEdyb3VwUHVibGljS2V5QhEKD19lcnJvcl9tZXNzYWdlcw==');
'iAEBEjsKF2FkZGl0aW9uYWxfbWVzc2FnZV9kYXRhGAsgASgMSAZSFWFkZGl0aW9uYWxNZXNzYW'
'dlRGF0YYgBASI+CgRUeXBlEgwKCFJFVVBMT0FEEAASCQoFSU1BR0UQARIJCgVWSURFTxACEgcK'
'A0dJRhADEgkKBUFVRElPEARCHQobX2Rpc3BsYXlMaW1pdEluTWlsbGlzZWNvbmRzQhEKD19xdW'
'90ZU1lc3NhZ2VJZEIQCg5fZG93bmxvYWRUb2tlbkIQCg5fZW5jcnlwdGlvbktleUIQCg5fZW5j'
'cnlwdGlvbk1hY0ISChBfZW5jcnlwdGlvbk5vbmNlQhoKGF9hZGRpdGlvbmFsX21lc3NhZ2VfZG'
'F0YRqnAQoLTWVkaWFVcGRhdGUSNgoEdHlwZRgBIAEoDjIiLkVuY3J5cHRlZENvbnRlbnQuTWVk'
'aWFVcGRhdGUuVHlwZVIEdHlwZRIoCg90YXJnZXRNZXNzYWdlSWQYAiABKAlSD3RhcmdldE1lc3'
'NhZ2VJZCI2CgRUeXBlEgwKCFJFT1BFTkVEEAASCgoGU1RPUkVEEAESFAoQREVDUllQVElPTl9F'
'UlJPUhACGngKDkNvbnRhY3RSZXF1ZXN0EjkKBHR5cGUYASABKA4yJS5FbmNyeXB0ZWRDb250ZW'
'50LkNvbnRhY3RSZXF1ZXN0LlR5cGVSBHR5cGUiKwoEVHlwZRILCgdSRVFVRVNUEAASCgoGUkVK'
'RUNUEAESCgoGQUNDRVBUEAIangIKDUNvbnRhY3RVcGRhdGUSOAoEdHlwZRgBIAEoDjIkLkVuY3'
'J5cHRlZENvbnRlbnQuQ29udGFjdFVwZGF0ZS5UeXBlUgR0eXBlEjUKE2F2YXRhclN2Z0NvbXBy'
'ZXNzZWQYAiABKAxIAFITYXZhdGFyU3ZnQ29tcHJlc3NlZIgBARIfCgh1c2VybmFtZRgDIAEoCU'
'gBUgh1c2VybmFtZYgBARIlCgtkaXNwbGF5TmFtZRgEIAEoCUgCUgtkaXNwbGF5TmFtZYgBASIf'
'CgRUeXBlEgsKB1JFUVVFU1QQABIKCgZVUERBVEUQAUIWChRfYXZhdGFyU3ZnQ29tcHJlc3NlZE'
'ILCglfdXNlcm5hbWVCDgoMX2Rpc3BsYXlOYW1lGtUBCghQdXNoS2V5cxIzCgR0eXBlGAEgASgO'
'Mh8uRW5jcnlwdGVkQ29udGVudC5QdXNoS2V5cy5UeXBlUgR0eXBlEhkKBWtleUlkGAIgASgDSA'
'BSBWtleUlkiAEBEhUKA2tleRgDIAEoDEgBUgNrZXmIAQESIQoJY3JlYXRlZEF0GAQgASgDSAJS'
'CWNyZWF0ZWRBdIgBASIfCgRUeXBlEgsKB1JFUVVFU1QQABIKCgZVUERBVEUQAUIICgZfa2V5SW'
'RCBgoEX2tleUIMCgpfY3JlYXRlZEF0GqkBCglGbGFtZVN5bmMSIgoMZmxhbWVDb3VudGVyGAEg'
'ASgDUgxmbGFtZUNvdW50ZXISNgoWbGFzdEZsYW1lQ291bnRlckNoYW5nZRgCIAEoA1IWbGFzdE'
'ZsYW1lQ291bnRlckNoYW5nZRIeCgpiZXN0RnJpZW5kGAMgASgIUgpiZXN0RnJpZW5kEiAKC2Zv'
'cmNlVXBkYXRlGAQgASgIUgtmb3JjZVVwZGF0ZUIKCghfZ3JvdXBJZEIPCg1faXNEaXJlY3RDaG'
'F0QhcKFV9zZW5kZXJQcm9maWxlQ291bnRlckIQCg5fbWVzc2FnZVVwZGF0ZUIICgZfbWVkaWFC'
'DgoMX21lZGlhVXBkYXRlQhAKDl9jb250YWN0VXBkYXRlQhEKD19jb250YWN0UmVxdWVzdEIMCg'
'pfZmxhbWVTeW5jQgsKCV9wdXNoS2V5c0ILCglfcmVhY3Rpb25CDgoMX3RleHRNZXNzYWdlQg4K'
'DF9ncm91cENyZWF0ZUIMCgpfZ3JvdXBKb2luQg4KDF9ncm91cFVwZGF0ZUIXChVfcmVzZW5kR3'
'JvdXBQdWJsaWNLZXlCEQoPX2Vycm9yX21lc3NhZ2Vz');

View file

@ -132,6 +132,8 @@ message EncryptedContent {
optional bytes encryptionKey = 8;
optional bytes encryptionMac = 9;
optional bytes encryptionNonce = 10;
optional bytes additional_message_data = 11;
}
message MediaUpdate {

View file

@ -26,6 +26,7 @@ import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart
as server;
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pbserver.dart';
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/services/api/server_messages.dart';
import 'package:twonly/src/services/api/utils.dart';
@ -41,6 +42,7 @@ import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/user_study/user_study_data_collection.dart';
import 'package:web_socket_channel/io.dart';
final lockConnecting = Mutex();
@ -100,6 +102,11 @@ class ApiService {
unawaited(fetchGroupStatesForUnjoinedGroups());
unawaited(fetchMissingGroupPublicKey());
unawaited(checkForDeletedUsernames());
if (gUser.userStudyParticipantsToken != null) {
// In case the user participates in the user study, call the handler after authenticated, to be sure there is a internet connection
unawaited(handleUserStudyUpload());
}
}
}
@ -313,6 +320,12 @@ class ApiService {
return user;
});
globalCallbackUpdatePlan(planFromString(authenticated.plan));
// this was triggered by apiService.ipaPurchase, so call the onAuthenticated again
if (isAuthenticated) {
// Trigger the re-upload from images, after Plan change, in case the limit was reached before...
unawaited(finishStartedPreprocessing());
}
}
}
if (res.isError) {

View file

@ -105,6 +105,11 @@ Future<void> handleMedia(
groupId: Value(groupId),
mediaId: Value(mediaFile.mediaId),
type: const Value(MessageType.media),
additionalMessageData: Value.absentIfNull(
media.hasAdditionalMessageData()
? Uint8List.fromList(media.additionalMessageData)
: null,
),
quotesMessageId: Value(
media.hasQuoteMessageId() ? media.quoteMessageId : null,
),

View file

@ -16,6 +16,7 @@ import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/http/http_requests.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/mediafiles/media_background.service.dart';
import 'package:twonly/src/services/api/messages.dart';
@ -88,8 +89,9 @@ Future<MediaFileService?> initializeMediaUpload(
Future<void> insertMediaFileInMessagesTable(
MediaFileService mediaService,
List<String> groupIds,
) async {
List<String> groupIds, {
AdditionalMessageData? additionalData,
}) async {
await twonlyDB.mediaFilesDao.updateAllMediaFiles(
const MediaFilesCompanion(
isDraftMedia: Value(false),
@ -101,6 +103,8 @@ Future<void> insertMediaFileInMessagesTable(
groupId: Value(groupId),
mediaId: Value(mediaService.mediaFile.mediaId),
type: const Value(MessageType.media),
additionalMessageData:
Value.absentIfNull(additionalData?.writeToBuffer()),
),
);
await twonlyDB.groupsDao.increaseLastMessageExchange(groupId, clock.now());
@ -245,6 +249,7 @@ Future<void> _createUploadRequest(MediaFileService media) async {
encryptionKey: media.mediaFile.encryptionKey,
encryptionNonce: media.mediaFile.encryptionNonce,
encryptionMac: media.mediaFile.encryptionMac,
additionalMessageData: message.additionalMessageData,
),
);

View file

@ -61,6 +61,7 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
String? receiptId,
Receipt? receipt,
bool onlyReturnEncryptedData = false,
bool blocking = true,
}) async {
try {
if (receiptId == null && receipt == null) return null;
@ -238,12 +239,11 @@ Future<void> sendCipherTextToGroup(
encryptedContent.groupId = groupId;
for (final groupMember in groupMembers) {
unawaited(
sendCipherText(
await sendCipherText(
groupMember.contactId,
encryptedContent,
messageId: messageId,
),
blocking: false,
);
}
}
@ -252,6 +252,7 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
int contactId,
pb.EncryptedContent encryptedContent, {
bool onlyReturnEncryptedData = false,
bool blocking = true,
String? messageId,
}) async {
encryptedContent.senderProfileCounter = Int64(gUser.avatarCounter);
@ -270,10 +271,15 @@ Future<(Uint8List, Uint8List?)?> sendCipherText(
);
if (receipt != null) {
return tryToSendCompleteMessage(
final tmp = tryToSendCompleteMessage(
receipt: receipt,
onlyReturnEncryptedData: onlyReturnEncryptedData,
blocking: blocking,
);
if (!blocking) {
return null;
}
return tmp;
}
return null;
}
@ -302,6 +308,7 @@ Future<void> notifyContactAboutOpeningMessage(
timestamp: Int64(actionAt.millisecondsSinceEpoch),
),
),
blocking: false,
);
for (final messageId in messageOtherIds) {
await twonlyDB.messagesDao.updateMessageId(

View file

@ -13,7 +13,7 @@ import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/signal/session.signal.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.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/chats/add_new_user.view.dart';
import 'package:twonly/src/views/components/alert_dialog.dart';
import 'package:twonly/src/views/contact/contact.view.dart';
@ -151,6 +151,7 @@ Future<void> handleIntentMediaFile(
Future<void> handleIntentSharedFile(
BuildContext context,
List<SharedFile> files,
void Function(Uri) onUrlCallBack,
) async {
for (final file in files) {
if (file.value == null) {
@ -163,7 +164,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')) {

View file

@ -24,6 +24,7 @@ class MediaFileService {
}
static Future<void> purgeTempFolder() async {
try {
final tempDirectory = MediaFileService.buildDirectoryPath(
'tmp',
globalApplicationSupportDirectory,
@ -70,7 +71,8 @@ class MediaFileService {
} else {
// Check weather the image was send in a group. Then the images is preserved for two days in case another person stores the image.
// This also allows to reopen this image for two days.
final group = await twonlyDB.groupsDao.getGroup(message.groupId);
final group =
await twonlyDB.groupsDao.getGroup(message.groupId);
if (group != null && !group.isDirectChat) {
delete = false;
}
@ -85,6 +87,9 @@ class MediaFileService {
file.deleteSync();
}
}
} catch (e) {
Log.error(e);
}
}
Future<void> updateFromDB() async {

View file

@ -99,9 +99,10 @@ Future<void> handlePushData(String pushDataB64) async {
}
} catch (e) {
Log.error(e);
final lang = getLocalizations();
await customLocalPushNotification(
'Du hast eine neue Nachricht.',
'Öffne twonly um mehr zu erfahren.',
lang.notificationTitleUnknown,
lang.notificationBodyUnknown,
);
}
}
@ -186,16 +187,14 @@ Future<void> showLocalPushNotification(
Future<void> showLocalPushNotificationWithoutUserId(
PushNotification pushNotification,
) async {
String? body;
body = getPushNotificationText(pushNotification);
final lang = getLocalizations();
final title = lang.notificationTitleUnknownUser;
var title = lang.notificationTitleUnknown;
var body = lang.notificationBodyUnknown;
if (body == '') {
Log.error('No push notification type defined!');
if (pushNotification.kind == PushKind.contactRequest) {
title = lang.you;
body = lang.notificationContactRequestUnknownUser;
}
final androidNotificationDetails = AndroidNotificationDetails(

View file

@ -4,38 +4,41 @@ import 'package:path_provider/path_provider.dart';
import 'package:twonly/src/utils/log.dart';
class KeyValueStore {
static Future<String> _getFilePath(String key) async {
static Future<File> _getFilePath(String key) async {
final directory = await getApplicationSupportDirectory();
return '${directory.path}/keyvalue/$key.json';
return File('${directory.path}/keyvalue/$key.json');
}
static Future<void> delete(String key) async {
try {
final file = await _getFilePath(key);
if (file.existsSync()) {
file.deleteSync();
}
} catch (e) {
Log.error('Error deleting file: $e');
}
}
static Future<Map<String, dynamic>?> get(String key) async {
try {
final filePath = await _getFilePath(key);
final file = File(filePath);
// Check if the file exists
final file = await _getFilePath(key);
if (file.existsSync()) {
final contents = await file.readAsString();
return jsonDecode(contents) as Map<String, dynamic>;
} else {
return null; // File does not exist
return null;
}
} catch (e) {
Log.error('Error reading file: $e');
Log.warn('Error reading file: $e');
return null;
}
}
static Future<void> put(String key, Map<String, dynamic> value) async {
try {
final filePath = await _getFilePath(key);
final file = File(filePath);
// Create the directory if it doesn't exist
final file = await _getFilePath(key);
await file.parent.create(recursive: true);
// Write the JSON data to the file
await file.writeAsString(jsonEncode(value));
} catch (e) {
Log.error('Error writing file: $e');

View file

@ -23,7 +23,9 @@ class MainCameraPreview extends StatelessWidget {
requiredHeight: 0,
additionalPadding: 59,
bottomNavigation: Container(),
child: Screenshot(
child: Stack(
children: [
Screenshot(
controller: mainCameraController.screenshotController,
child: AspectRatio(
aspectRatio: 9 / 16,
@ -36,8 +38,19 @@ class MainCameraPreview extends StatelessWidget {
height: mainCameraController
.cameraController!.value.previewSize!.width,
child: CameraPreview(
key: mainCameraController.cameraPreviewKey,
mainCameraController.cameraController!,
child: mainCameraController.customPaint,
child: Stack(
children: [
if (mainCameraController.customPaint != null)
Positioned.fill(
child: mainCameraController.customPaint!,
),
if (mainCameraController.facePaint != null)
Positioned.fill(
child: mainCameraController.facePaint!,
),
],
),
),
),
@ -45,6 +58,45 @@ class MainCameraPreview extends StatelessWidget {
),
),
),
if (mainCameraController.focusPointOffset != null &&
!mainCameraController.isSharePreviewIsShown)
AspectRatio(
aspectRatio: 9 / 16,
child: ClipRect(
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width: mainCameraController
.cameraController!.value.previewSize!.height,
height: mainCameraController
.cameraController!.value.previewSize!.width,
child: Stack(
children: [
Positioned(
top: mainCameraController.focusPointOffset!.dy - 40,
left:
mainCameraController.focusPointOffset!.dx - 40,
child: Container(
height: 80,
width: 80,
clipBehavior: Clip.antiAliasWithSaveLayer,
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
color: Colors.white.withAlpha(150),
),
),
),
),
],
),
),
),
),
),
],
),
),
);
}
}

View file

@ -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';
@ -21,13 +22,14 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.dart';
import 'package:twonly/src/utils/screenshot.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart';
import 'package:twonly/src/views/camera/camera_preview_components/main_camera_controller.dart';
import 'package:twonly/src/views/camera/camera_preview_components/permissions_view.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/zoom_selector.dart';
import 'package:twonly/src/views/camera/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/loader.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart';
@ -36,55 +38,6 @@ import 'package:url_launcher/url_launcher_string.dart';
int maxVideoRecordingTime = 60;
Future<(SelectedCameraDetails, CameraController)?> initializeCameraController(
SelectedCameraDetails details,
int sCameraId,
bool init,
) async {
var cameraId = sCameraId;
if (cameraId >= gCameras.length) return null;
if (init) {
for (; cameraId < gCameras.length; cameraId++) {
if (gCameras[cameraId].lensDirection == CameraLensDirection.back) {
break;
}
}
}
details.isZoomAble = false;
if (details.cameraId != cameraId) {
// switch between front and back
details.scaleFactor = 1;
}
final cameraController = CameraController(
gCameras[cameraId],
ResolutionPreset.high,
enableAudio: await Permission.microphone.isGranted,
imageFormatGroup:
Platform.isAndroid ? ImageFormatGroup.nv21 : ImageFormatGroup.bgra8888,
);
await cameraController.initialize().then((_) async {
await cameraController.setZoomLevel(details.scaleFactor);
await cameraController.lockCaptureOrientation(DeviceOrientation.portraitUp);
await cameraController
.setFlashMode(details.isFlashOn ? FlashMode.always : FlashMode.off);
await cameraController
.getMaxZoomLevel()
.then((double value) => details.maxAvailableZoom = value);
await cameraController
.getMinZoomLevel()
.then((double value) => details.minAvailableZoom = value);
details
..isZoomAble = details.maxAvailableZoom != details.minAvailableZoom
..cameraLoaded = true
..cameraId = cameraId;
}).catchError((Object e) {
Log.error('$e');
});
return (details, cameraController);
}
class SelectedCameraDetails {
double maxAvailableZoom = 1;
double minAvailableZoom = 1;
@ -156,12 +109,10 @@ class CameraPreviewView extends StatefulWidget {
}
class _CameraPreviewViewState extends State<CameraPreviewView> {
bool _sharePreviewIsShown = false;
bool _galleryLoadedImageIsShown = false;
bool _showSelfieFlash = false;
double _basePanY = 0;
double _baseScaleFactor = 0;
bool _isVideoRecording = false;
bool _hasAudioPermission = true;
DateTime? _videoRecordingStarted;
Timer? _videoRecordingTimer;
@ -317,10 +268,10 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}
Future<void> takePicture() async {
if (_sharePreviewIsShown || _isVideoRecording) return;
if (mc.isSharePreviewIsShown || mc.isVideoRecording) return;
setState(() {
_sharePreviewIsShown = true;
mc.isSharePreviewIsShown = true;
});
if (mc.selectedCameraDetails.isFlashOn) {
if (isFront) {
@ -353,12 +304,12 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
return;
}
setState(() {
_sharePreviewIsShown = false;
mc.isSharePreviewIsShown = false;
});
}
Future<bool> pushMediaEditor(
ScreenshotImage? imageBytes,
ScreenshotImage? screenshotImage,
File? videoFilePath, {
bool sharedFromGallery = false,
MediaType? mediaType,
@ -394,11 +345,12 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
PageRouteBuilder(
opaque: false,
pageBuilder: (context, a1, a2) => ShareImageEditorView(
imageBytesFuture: imageBytes,
screenshotImage: screenshotImage,
sharedFromGallery: sharedFromGallery,
sendToGroup: widget.sendToGroup,
mediaFileService: mediaFileService,
mainCameraController: mc,
previewLink: mc.sharedLinkForPreview,
),
transitionsBuilder: (context, animation, secondaryAnimation, child) {
return child;
@ -409,7 +361,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
) as bool?;
if (mounted) {
setState(() {
_sharePreviewIsShown = false;
mc.isSharePreviewIsShown = false;
_showSelfieFlash = false;
});
}
@ -459,7 +411,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
Future<void> pickImageFromGallery() async {
setState(() {
_galleryLoadedImageIsShown = true;
_sharePreviewIsShown = true;
mc.isSharePreviewIsShown = true;
});
final picker = ImagePicker();
final pickedFile = await picker.pickMedia();
@ -502,17 +454,47 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}
setState(() {
_galleryLoadedImageIsShown = false;
_sharePreviewIsShown = false;
mc.isSharePreviewIsShown = false;
});
}
Future<void> pressSideButtonLeft() async {
if (!mc.isSelectingFaceFilters) {
return pickImageFromGallery();
}
if (mc.currentFilterType.index == 1) {
mc.setFilter(FaceFilterType.none);
setState(() {
mc.isSelectingFaceFilters = false;
});
return;
}
mc.setFilter(mc.currentFilterType.goLeft());
}
Future<void> pressSideButtonRight() async {
if (!mc.isSelectingFaceFilters) {
setState(() {
mc.isSelectingFaceFilters = true;
});
}
if (mc.currentFilterType.index == FaceFilterType.values.length - 1) {
mc.setFilter(FaceFilterType.none);
setState(() {
mc.isSelectingFaceFilters = false;
});
return;
}
mc.setFilter(mc.currentFilterType.goRight());
}
Future<void> startVideoRecording() async {
if (mc.cameraController != null &&
mc.cameraController!.value.isRecordingVideo) {
return;
}
setState(() {
_isVideoRecording = true;
mc.isVideoRecording = true;
});
try {
@ -532,11 +514,11 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
});
setState(() {
_videoRecordingStarted = clock.now();
_isVideoRecording = true;
mc.isVideoRecording = true;
});
} on CameraException catch (e) {
setState(() {
_isVideoRecording = false;
mc.isVideoRecording = false;
});
_showCameraException(e);
return;
@ -551,7 +533,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
setState(() {
_videoRecordingStarted = null;
_isVideoRecording = false;
mc.isVideoRecording = false;
});
if (mc.cameraController == null ||
@ -560,7 +542,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
}
setState(() {
_sharePreviewIsShown = true;
mc.isSharePreviewIsShown = true;
});
try {
@ -646,12 +628,23 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
),
),
),
if (!_sharePreviewIsShown &&
if (!mc.isSharePreviewIsShown &&
widget.sendToGroup != null &&
!_isVideoRecording)
SendToWidget(sendTo: widget.sendToGroup!.groupName),
if (!_sharePreviewIsShown &&
!_isVideoRecording &&
!mc.isVideoRecording)
ShowTitleText(
title: widget.sendToGroup!.groupName,
desc: context.lang.cameraPreviewSendTo,
),
if (!mc.isSharePreviewIsShown &&
mc.sharedLinkForPreview != null &&
!mc.isVideoRecording)
ShowTitleText(
title: mc.sharedLinkForPreview?.host ?? '',
desc: 'Link',
isLink: true,
),
if (!mc.isSharePreviewIsShown &&
!mc.isVideoRecording &&
!widget.hideControllers)
Positioned(
right: 5,
@ -707,7 +700,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
),
),
),
if (!_sharePreviewIsShown && !widget.hideControllers)
if (!mc.isSharePreviewIsShown && !widget.hideControllers)
Positioned(
bottom: 30,
left: 0,
@ -718,7 +711,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
children: [
if (mc.cameraController!.value.isInitialized &&
mc.selectedCameraDetails.isZoomAble &&
!_isVideoRecording)
!mc.isVideoRecording)
SizedBox(
width: 120,
child: CameraZoomButtons(
@ -734,17 +727,21 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (!_isVideoRecording)
if (!mc.isVideoRecording)
GestureDetector(
onTap: pickImageFromGallery,
onTap: pressSideButtonLeft,
child: Align(
child: Container(
height: 50,
width: 80,
padding: const EdgeInsets.all(2),
child: const Center(
child: Center(
child: FaIcon(
FontAwesomeIcons.photoFilm,
mc.isSelectingFaceFilters
? mc.currentFilterType.index == 1
? FontAwesomeIcons.xmark
: FontAwesomeIcons.arrowLeft
: FontAwesomeIcons.photoFilm,
color: Colors.white,
size: 25,
),
@ -766,15 +763,44 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
shape: BoxShape.circle,
border: Border.all(
width: 7,
color: _isVideoRecording
color: mc.isVideoRecording
? Colors.red
: Colors.white,
),
),
child: mc.currentFilterType.preview,
),
),
),
if (!_isVideoRecording) const SizedBox(width: 80),
if (!mc.isVideoRecording)
if (isFront)
GestureDetector(
onTap: pressSideButtonRight,
child: Align(
child: Container(
height: 50,
width: 80,
padding: const EdgeInsets.all(2),
child: Center(
child: FaIcon(
mc.isSelectingFaceFilters
? mc.currentFilterType.index ==
FaceFilterType
.values.length -
1
? FontAwesomeIcons.xmark
: FontAwesomeIcons.arrowRight
: FontAwesomeIcons
.faceGrinTongueSquint,
color: Colors.white,
size: 25,
),
),
),
),
)
else
const SizedBox(width: 80),
],
),
],
@ -785,7 +811,7 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
videoRecordingStarted: _videoRecordingStarted,
maxVideoRecordingTime: maxVideoRecordingTime,
),
if (!_sharePreviewIsShown && widget.sendToGroup != null ||
if (!mc.isSharePreviewIsShown && widget.sendToGroup != null ||
widget.hideControllers)
Positioned(
left: 5,

View file

@ -0,0 +1,33 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/beard_filter_painter.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart';
enum FaceFilterType {
none,
dogBrown,
beardUpperLip,
}
extension FaceFilterTypeExtension on FaceFilterType {
FaceFilterType goRight() {
final nextIndex = (index + 1) % FaceFilterType.values.length;
return FaceFilterType.values[nextIndex];
}
FaceFilterType goLeft() {
final prevIndex = (index - 1 + FaceFilterType.values.length) %
FaceFilterType.values.length;
return FaceFilterType.values[prevIndex];
}
Widget get preview {
switch (this) {
case FaceFilterType.none:
return Container();
case FaceFilterType.dogBrown:
return DogFilterPainter.getPreview();
case FaceFilterType.beardUpperLip:
return BeardFilterPainter.getPreview();
}
}
}

View file

@ -1,3 +1,4 @@
import 'dart:async';
import 'dart:io';
import 'package:camera/camera.dart';
import 'package:collection/collection.dart';
@ -5,6 +6,8 @@ import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mlkit_barcode_scanning/google_mlkit_barcode_scanning.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
@ -15,7 +18,11 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/qr.dart';
import 'package:twonly/src/utils/screenshot.dart';
import 'package:twonly/src/views/camera/camera_preview_components/camera_preview_controller_view.dart';
import 'package:twonly/src/views/camera/painters/barcode_detector_painter.dart';
import 'package:twonly/src/views/camera/camera_preview_components/face_filters.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/barcode_detector_painter.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/beard_filter_painter.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/dog_filter_painter.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
class ScannedVerifiedContact {
ScannedVerifiedContact({
@ -45,6 +52,34 @@ class MainCameraController {
Map<int, ScannedNewProfile> scannedNewProfiles = {};
String? scannedUrl;
GlobalKey zoomButtonKey = GlobalKey();
GlobalKey cameraPreviewKey = GlobalKey();
bool isSelectingFaceFilters = false;
bool isSharePreviewIsShown = false;
bool isVideoRecording = false;
Uri? sharedLinkForPreview;
void setSharedLinkForPreview(Uri url) {
sharedLinkForPreview = url;
setState();
}
final BarcodeScanner _barcodeScanner = BarcodeScanner();
final FaceDetector _faceDetector = FaceDetector(
options: FaceDetectorOptions(
enableContours: true,
enableLandmarks: true,
),
);
bool _isBusy = false;
bool _isBusyFaces = false;
CustomPaint? customPaint;
CustomPaint? facePaint;
Offset? focusPointOffset;
FaceFilterType _currentFilterType = FaceFilterType.beardUpperLip;
FaceFilterType get currentFilterType => _currentFilterType;
Future<void> closeCamera() async {
contactsVerified = {};
@ -57,51 +92,131 @@ class MainCameraController {
}
final cameraControllerTemp = cameraController;
cameraController = null;
// prevents: CameraException(Disposed CameraController, buildPreview() was called on a disposed CameraController.)
Future.delayed(const Duration(milliseconds: 100), () async {
await cameraControllerTemp?.dispose();
});
initCameraStarted = false;
selectedCameraDetails = SelectedCameraDetails();
}
Future<CameraController?> selectCamera(int sCameraId, bool init) async {
Future<void> selectCamera(int sCameraId, bool init) async {
initCameraStarted = true;
final opts = await initializeCameraController(
selectedCameraDetails,
sCameraId,
init,
);
if (opts != null) {
selectedCameraDetails = opts.$1;
cameraController = opts.$2;
}
if (cameraController?.description.lensDirection ==
CameraLensDirection.back) {
await cameraController?.startImageStream(_processCameraImage);
}
zoomButtonKey = GlobalKey();
setState();
return cameraController;
}
Future<void> toggleSelectedCamera() async {
if (cameraController == null) return;
// do not allow switching camera when recording
if (cameraController!.value.isRecordingVideo) {
var cameraId = sCameraId;
if (cameraId >= gCameras.length) {
Log.warn(
'Trying to select a non existing camera $cameraId >= ${gCameras.length}',
);
return;
}
try {
await cameraController!.stopImageStream();
} catch (e) {
// Log.warn(e);
if (init) {
for (; cameraId < gCameras.length; cameraId++) {
if (gCameras[cameraId].lensDirection == CameraLensDirection.back) {
break;
}
final tmp = cameraController;
cameraController = null;
await tmp!.dispose();
}
}
selectedCameraDetails.isZoomAble = false;
if (cameraController == null) {
cameraController = CameraController(
gCameras[cameraId],
ResolutionPreset.high,
enableAudio: await Permission.microphone.isGranted,
imageFormatGroup: Platform.isAndroid
? ImageFormatGroup.nv21
: ImageFormatGroup.bgra8888,
);
await cameraController?.initialize();
await cameraController?.startImageStream(_processCameraImage);
await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor);
} else {
try {
if (!isVideoRecording) {
await cameraController?.stopImageStream();
}
} catch (e) {
Log.info(e);
}
selectedCameraDetails.scaleFactor = 1;
await cameraController?.setZoomLevel(1);
await cameraController?.setDescription(gCameras[cameraId]);
try {
if (!isVideoRecording) {
await cameraController?.startImageStream(_processCameraImage);
}
} catch (e) {
Log.info(e);
}
}
await cameraController
?.lockCaptureOrientation(DeviceOrientation.portraitUp);
await cameraController?.setFlashMode(
selectedCameraDetails.isFlashOn ? FlashMode.always : FlashMode.off,
);
selectedCameraDetails.maxAvailableZoom =
await cameraController?.getMaxZoomLevel() ?? 1;
selectedCameraDetails.minAvailableZoom =
await cameraController?.getMinZoomLevel() ?? 1;
selectedCameraDetails
..isZoomAble = selectedCameraDetails.maxAvailableZoom !=
selectedCameraDetails.minAvailableZoom
..cameraLoaded = true
..cameraId = cameraId;
facePaint = null;
customPaint = null;
isSelectingFaceFilters = false;
setFilter(FaceFilterType.none);
zoomButtonKey = GlobalKey();
setState();
}
Future<void> onDoubleTap() async {
await selectCamera((selectedCameraDetails.cameraId + 1) % 2, false);
}
final BarcodeScanner _barcodeScanner = BarcodeScanner();
bool _isBusy = false;
CustomPaint? customPaint;
Future<void> onTapDown(TapDownDetails details) async {
final box =
cameraPreviewKey.currentContext?.findRenderObject() as RenderBox?;
if (box == null) return;
final localPosition = box.globalToLocal(details.globalPosition);
focusPointOffset = Offset(localPosition.dx, localPosition.dy);
final dx = localPosition.dx / box.size.width;
final dy = localPosition.dy / box.size.height;
setState();
await HapticFeedback.lightImpact();
try {
await cameraController?.setFocusPoint(Offset(dx, dy));
await cameraController?.setFocusMode(FocusMode.auto);
} catch (e) {
Log.error(e);
}
focusPointOffset = null;
setState();
}
void setFilter(FaceFilterType type) {
_currentFilterType = type;
if (_currentFilterType == FaceFilterType.none) {
faceFilterPainter = null;
facePaint = null;
_isBusyFaces = false;
}
setState();
}
FaceFilterPainter? faceFilterPainter;
final Map<DeviceOrientation, int> _orientations = {
DeviceOrientation.portraitUp: 0,
@ -111,9 +226,21 @@ class MainCameraController {
};
void _processCameraImage(CameraImage image) {
if (isVideoRecording || isSharePreviewIsShown) {
return;
}
final inputImage = _inputImageFromCameraImage(image);
if (inputImage == null) return;
_processImage(inputImage);
_processBarcode(inputImage);
// check if front camera is selected
if (cameraController?.description.lensDirection ==
CameraLensDirection.front) {
if (_currentFilterType != FaceFilterType.none) {
_processFaces(inputImage);
}
} else {
_processBarcode(inputImage);
}
}
InputImage? _inputImageFromCameraImage(CameraImage image) {
@ -175,7 +302,7 @@ class MainCameraController {
);
}
Future<void> _processImage(InputImage inputImage) async {
Future<void> _processBarcode(InputImage inputImage) async {
if (_isBusy) return;
_isBusy = true;
final barcodes = await _barcodeScanner.processImage(inputImage);
@ -255,4 +382,48 @@ class MainCameraController {
_isBusy = false;
setState();
}
Future<void> _processFaces(InputImage inputImage) async {
if (_isBusyFaces) return;
_isBusyFaces = true;
final faces = await _faceDetector.processImage(inputImage);
if (inputImage.metadata?.size != null &&
inputImage.metadata?.rotation != null &&
cameraController != null) {
if (faces.isNotEmpty) {
CustomPainter? painter;
if (_currentFilterType == FaceFilterType.dogBrown) {
painter = DogFilterPainter(
faces,
inputImage.metadata!.size,
inputImage.metadata!.rotation,
cameraController!.description.lensDirection,
);
} else if (_currentFilterType == FaceFilterType.beardUpperLip) {
painter = BeardFilterPainter(
faces,
inputImage.metadata!.size,
inputImage.metadata!.rotation,
cameraController!.description.lensDirection,
);
}
if (painter != null) {
facePaint = CustomPaint(painter: painter);
// Also set the correct FaceFilterPainter reference if needed for other logic,
// though currently facePaint is what's used for display.
if (painter is FaceFilterPainter) {
faceFilterPainter = painter;
}
} else {
facePaint = null;
faceFilterPainter = null;
}
} else {
facePaint = null;
}
}
_isBusyFaces = false;
setState();
}
}

View file

@ -0,0 +1,175 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/coordinates_translator.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
class BeardFilterPainter extends FaceFilterPainter {
BeardFilterPainter(
super.faces,
super.imageSize,
super.rotation,
super.cameraLensDirection,
) {
_loadAssets();
}
static ui.Image? _beardImage;
static bool _loading = false;
static Future<void> _loadAssets() async {
if (_loading || _beardImage != null) return;
_loading = true;
try {
_beardImage = await _loadImage('assets/filters/beard_upper_lip.webp');
} catch (e) {
Log.error('Failed to load filter assets: $e');
} finally {
_loading = false;
}
}
static Future<ui.Image> _loadImage(String assetPath) async {
final data = await rootBundle.load(assetPath);
final list = Uint8List.view(data.buffer);
final completer = Completer<ui.Image>();
ui.decodeImageFromList(list, completer.complete);
return completer.future;
}
@override
void paint(Canvas canvas, Size size) {
if (_beardImage == null) return;
for (final face in faces) {
final noseBase = face.landmarks[FaceLandmarkType.noseBase];
final mouthLeft = face.landmarks[FaceLandmarkType.leftMouth];
final mouthRight = face.landmarks[FaceLandmarkType.rightMouth];
final bottomMouth = face.landmarks[FaceLandmarkType.bottomMouth];
if (noseBase != null &&
mouthLeft != null &&
mouthRight != null &&
bottomMouth != null) {
final noseX = translateX(
noseBase.position.x.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final noseY = translateY(
noseBase.position.y.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final mouthLeftX = translateX(
mouthLeft.position.x.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final mouthLeftY = translateY(
mouthLeft.position.y.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final mouthRightX = translateX(
mouthRight.position.x.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final mouthRightY = translateY(
mouthRight.position.y.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final mouthCenterX = (mouthLeftX + mouthRightX) / 2;
final mouthCenterY = (mouthLeftY + mouthRightY) / 2;
final beardCenterX = (noseX + mouthCenterX) / 2;
final beardCenterY = (noseY + mouthCenterY) / 2;
final dx = mouthRightX - mouthLeftX;
final dy = mouthRightY - mouthLeftY;
final angle = atan2(dy, dx);
final mouthWidth = sqrt(dx * dx + dy * dy);
final beardWidth = mouthWidth * 1.5;
final yaw = face.headEulerAngleY ?? 0;
final scaleX = cos(yaw * pi / 180).abs();
_drawImage(
canvas,
_beardImage!,
Offset(beardCenterX, beardCenterY),
beardWidth,
angle,
scaleX,
);
}
}
}
void _drawImage(
Canvas canvas,
ui.Image image,
Offset position,
double width,
double rotation,
double scaleX,
) {
canvas
..save()
..translate(position.dx, position.dy)
..rotate(rotation)
..scale(scaleX, Platform.isAndroid ? -1 : 1);
final srcRect =
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
final aspectRatio = image.width / image.height;
final dstWidth = width;
final dstHeight = width / aspectRatio;
final dstRect = Rect.fromCenter(
center: Offset.zero,
width: dstWidth,
height: dstHeight,
);
canvas
..drawImageRect(image, srcRect, dstRect, Paint())
..restore();
}
static Widget getPreview() {
return Preview(
child: Padding(
padding: const EdgeInsets.all(8),
child: Image.asset(
'assets/filters/beard_upper_lip.webp',
fit: BoxFit.contain,
),
),
);
}
}

View file

@ -0,0 +1,243 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/coordinates_translator.dart';
import 'package:twonly/src/views/camera/camera_preview_components/painters/face_filters/face_filter_painter.dart';
class DogFilterPainter extends FaceFilterPainter {
DogFilterPainter(
super.faces,
super.imageSize,
super.rotation,
super.cameraLensDirection,
) {
_loadAssets();
}
static ui.Image? _earImage;
static ui.Image? _noseImage;
static bool _loading = false;
static Future<void> _loadAssets() async {
if (_loading || (_earImage != null && _noseImage != null)) return;
_loading = true;
try {
_earImage = await _loadImage('assets/filters/dog_brown_ear.webp');
_noseImage = await _loadImage('assets/filters/dog_brown_nose.webp');
} catch (e) {
Log.error('Failed to load filter assets: $e');
} finally {
_loading = false;
}
}
static Future<ui.Image> _loadImage(String assetPath) async {
final data = await rootBundle.load(assetPath);
final list = Uint8List.view(data.buffer);
final completer = Completer<ui.Image>();
ui.decodeImageFromList(list, completer.complete);
return completer.future;
}
@override
void paint(Canvas canvas, Size size) {
if (_earImage == null || _noseImage == null) return;
for (final face in faces) {
final faceContour = face.contours[FaceContourType.face];
final noseBase = face.landmarks[FaceLandmarkType.noseBase];
if (faceContour != null && noseBase != null) {
final points = faceContour.points;
if (points.isEmpty) continue;
final upperPoints =
points.where((p) => p.y < noseBase.position.y).toList();
if (upperPoints.isEmpty) continue;
Point<int>? leftMost;
Point<int>? rightMost;
Point<int>? topMost;
for (final point in upperPoints) {
if (leftMost == null || point.x < leftMost.x) {
leftMost = point;
}
if (rightMost == null || point.x > rightMost.x) {
rightMost = point;
}
if (topMost == null || point.y < topMost.y) {
topMost = point;
}
}
if (leftMost == null || rightMost == null || topMost == null) continue;
final leftEarX = translateX(
leftMost.x.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final leftEarY = translateY(
topMost.y.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final rightEarX = translateX(
rightMost.x.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final rightEarY = translateY(
topMost.y.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final noseX = translateX(
noseBase.position.x.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final noseY = translateY(
noseBase.position.y.toDouble(),
size,
imageSize,
rotation,
cameraLensDirection,
);
final dx = rightEarX - leftEarX;
final dy = rightEarY - leftEarY;
final faceWidth = sqrt(dx * dx + dy * dy) * 1.5;
final angle = atan2(dy, dx);
final yaw = face.headEulerAngleY ?? 0;
final scaleX = cos(yaw * pi / 180).abs();
final earSize = faceWidth / 2.5;
_drawImage(
canvas,
_earImage!,
Offset(leftEarX, leftEarY + earSize * 0.3),
earSize,
angle,
scaleX,
);
_drawImage(
canvas,
_earImage!,
Offset(rightEarX, rightEarY + earSize * 0.3),
earSize,
angle,
scaleX,
isFlipped: true,
);
final noseSize = faceWidth * 0.4;
_drawImage(
canvas,
_noseImage!,
Offset(noseX, noseY + noseSize * 0.1),
noseSize,
angle,
scaleX,
);
}
}
}
void _drawImage(
Canvas canvas,
ui.Image image,
Offset position,
double size,
double rotation,
double scaleX, {
bool isFlipped = false,
}) {
canvas
..save()
..translate(position.dx, position.dy)
..rotate(rotation);
if (isFlipped) {
canvas.scale(-scaleX, Platform.isAndroid ? -1 : 1);
} else {
canvas.scale(scaleX, Platform.isAndroid ? -1 : 1);
}
final srcRect =
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble());
final aspectRatio = image.width / image.height;
final dstWidth = size;
final dstHeight = size / aspectRatio;
final dstRect = Rect.fromCenter(
center: Offset.zero,
width: dstWidth,
height: dstHeight,
);
canvas
..drawImageRect(image, srcRect, dstRect, Paint())
..restore();
}
static Widget getPreview() {
return Preview(
child: Stack(
alignment: Alignment.center,
children: [
Padding(
padding: const EdgeInsets.only(top: 25),
child: Image.asset(
'assets/filters/dog_brown_nose.webp',
width: 25,
),
),
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Image.asset(
'assets/filters/dog_brown_ear.webp',
width: 20,
),
const SizedBox(width: 15),
Transform.scale(
scaleX: -1,
child: Image.asset(
'assets/filters/dog_brown_ear.webp',
width: 20,
),
),
],
),
),
],
),
);
}
}

View file

@ -0,0 +1,44 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart';
import 'package:google_mlkit_face_detection/google_mlkit_face_detection.dart';
abstract class FaceFilterPainter extends CustomPainter {
FaceFilterPainter(
this.faces,
this.imageSize,
this.rotation,
this.cameraLensDirection,
);
final List<Face> faces;
final Size imageSize;
final InputImageRotation rotation;
final CameraLensDirection cameraLensDirection;
@override
bool shouldRepaint(covariant FaceFilterPainter oldDelegate) {
return oldDelegate.imageSize != imageSize ||
oldDelegate.faces != faces ||
oldDelegate.rotation != rotation ||
oldDelegate.cameraLensDirection != cameraLensDirection;
}
}
class Preview extends StatelessWidget {
const Preview({required this.child, super.key});
final Widget child;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.grey.withValues(alpha: 0.2),
),
child: Center(
child: child,
),
);
}
}

View file

@ -1,5 +1,4 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
@ -8,6 +7,7 @@ import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/screenshot.dart';
class SaveToGalleryButton extends StatefulWidget {
const SaveToGalleryButton({
@ -17,7 +17,7 @@ class SaveToGalleryButton extends StatefulWidget {
this.storeImageAsOriginal,
super.key,
});
final Future<Uint8List?> Function()? storeImageAsOriginal;
final Future<ScreenshotImage?> Function()? storeImageAsOriginal;
final bool displayButtonLabel;
final MediaFileService mediaService;
final bool isLoading;

View file

@ -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
),

View file

@ -32,7 +32,8 @@ class QrCodeScannerState extends State<QrCodeScanner> {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
onDoubleTap: _mainCameraController.toggleSelectedCamera,
onDoubleTap: _mainCameraController.onDoubleTap,
onTapDown: _mainCameraController.onTapDown,
child: Stack(
children: [
MainCameraPreview(

View file

@ -34,7 +34,8 @@ class CameraSendToViewState extends State<CameraSendToView> {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
onDoubleTap: _mainCameraController.toggleSelectedCamera,
onDoubleTap: _mainCameraController.onDoubleTap,
onTapDown: _mainCameraController.onTapDown,
child: Stack(
children: [
MainCameraPreview(

View file

@ -1,710 +0,0 @@
Map<String, String> emojiWeights = {};
List<String> emojis = [
'😀',
'😁',
'😂',
'🤣',
'😃',
'😄',
'😅',
'😆',
'😉',
'😊',
'😋',
'😎',
'😍',
'😘',
'🥰',
'😗',
'😙',
'😚',
'🙂️',
'🤗',
'🤩',
'🤔',
'🤔',
'🤨',
'😐',
'😑',
'😶',
'🙄',
'😏',
'😣',
'😥',
'😮',
'🤐',
'😯',
'😪',
'😫',
'😴',
'😌',
'😛',
'😜',
'😝',
'🤤',
'😒',
'😓',
'😔',
'😕',
'🙃',
'🤑',
'😲',
'🙁',
'😖',
'😞',
'😟',
'😤',
'😢',
'😭',
'😦',
'😧',
'😨',
'😩',
'🤯',
'😬',
'😰',
'😱',
'🥵',
'🥶',
'😳',
'🤪',
'😵',
'😡',
'😠',
'🤬',
'😷',
'🤒',
'🤕',
'🤢',
'🤮',
'🤧',
'😇',
'🤠',
'🤡',
'🥳',
'🥴',
'🤥',
'🤫',
'🤭',
'🤭',
'🧐',
'🤓',
'😈',
'👿',
'👹',
'👺',
'💀',
'👻',
'👽',
'🤖',
'💩',
'😺',
'😸',
'😹',
'😻',
'😼',
'😽',
'🙀',
'😿',
'😾',
'😾',
/// People and Fantasy
'👶',
'👧',
'🧒',
'👩',
'🧑',
'👨',
'👵',
'👴',
'👲',
'👳‍♀️‍️',
'👳‍♂️️‍️',
'🧕️️‍️',
'🧔‍',
'👱‍♂️️‍',
'👱‍♀️️️‍',
'👨‍🦰️️️‍',
'👩‍🦰‍',
'👨‍🦱‍‍',
'👨‍🦲‍‍',
'👩‍🦲‍‍',
'👨‍🦳‍‍',
'👩‍🦳‍‍',
'🦸‍♀️‍‍',
'🦸‍♂️️‍‍',
'🦹‍♀️️️‍‍',
'🦹‍♂️️️️‍‍',
'👮‍♀️‍‍',
'👮‍♂️️‍‍',
'👷‍♀️️️‍‍',
'👷‍♂️️️️‍‍',
'💂‍♀️️️️️‍‍',
'💂‍♂️️️️️️‍‍',
'🕵️‍♀️️️️️️️‍‍',
'🕵️‍♂️️️️️️️️‍‍',
'👩‍⚕️️️️️️️️️‍‍',
'👨‍⚕️️️️️️️️️️‍‍',
'👩‍🌾️️️️️️️️️️‍‍',
'👨‍🌾‍‍',
'👩‍🍳‍‍',
'👨‍🍳‍‍',
'👩‍🎓‍‍',
'👨‍🎓‍‍',
'👩‍🎤‍‍',
'👨‍🎤‍‍',
'👩‍🏫‍‍',
'👨‍🏫‍‍',
'👩‍🏭‍‍',
'👨‍🏭‍‍',
'👩‍💻‍‍',
'👨‍💻‍‍',
'👩‍💼‍‍',
'👨‍💼‍‍',
'👩‍🔧‍‍',
'👨‍🔧‍‍',
'👩‍🔬‍‍',
'👨‍🔬‍‍',
'👩‍🎨‍‍',
'👨‍🎨‍‍',
'👩‍🚒‍‍',
'👨‍🚒‍‍',
'👩‍✈️‍‍',
'👨‍✈️️‍‍',
'👩‍🚀‍‍',
'👨‍🚀‍‍',
'👩‍⚖️‍‍',
'👨‍⚖️️‍‍',
'👰‍‍',
'🤵‍‍',
'👸‍‍',
'🤴‍‍',
'🤶‍‍',
'🎅‍‍',
'🧙‍♀️‍‍',
'🧙‍♂️️‍‍',
'🧝‍♀️️️‍‍',
'🧝‍♂️‍‍',
'🧛‍♀️️‍‍',
'🧛‍♂️️️‍‍',
'🧟‍♀️️️️‍‍',
'🧟‍♂️️️️️‍‍',
'🧞‍♀️️️️️️‍‍',
'🧞‍♂️️️️️️️‍‍',
'🧜‍♀️️️️️️️️‍‍',
'🧜‍♂️️️️️️️️️‍‍',
'🧚‍♀️️️️️️️️️️‍‍',
'🧚‍♂️️️️️️️️️️️‍‍',
'👼️️️️️️️️️️️‍‍',
'🤰‍‍',
'🤱‍‍',
'🙇‍♀️‍‍',
'🙇‍♂️‍‍',
'💁‍♀️️‍‍',
'💁‍♂️️️‍‍',
'🙅‍♀️️️️‍‍',
'🙅‍♂️‍‍',
'🙆‍♀️️‍‍',
'🙆‍♂️️️‍‍',
'🙋‍♀️️️️‍‍',
'🙋‍♂️‍‍',
'🤦‍♀️️‍‍',
'🤦‍♂️️️‍‍',
'🤷‍♀️️️️‍‍',
'🤷‍♂️️️️️‍‍',
'🙎‍♀️️️️️️‍‍',
'🙎‍♂️️️️️️️‍‍',
'🙍‍♀️️️️️️️️‍‍',
'🙍‍♂️️️️️️️️️‍‍',
'💇‍♀️️️️️️️️️️‍‍',
'💇‍♂️️️️️️️️️️️‍‍',
'💆‍♀️️️️️️️️️️️️‍‍',
'💆‍♂️️️️️️️️️️️️️‍‍',
'🧖‍♀️️️️️️️️️️️️️️‍‍',
'🧖‍♂️️️️️️️️️️️️️️️‍‍',
'💅️️️️️️️️️️️️️️️‍‍',
'🤳️️️️️️️️️️️️️️‍‍',
'💃️️️️️️️️️️️️️‍‍',
'🕺️️️️️️️️️️️️‍‍',
'👯‍♀️‍‍',
'👯‍♂️️‍‍',
'🕴️️‍‍',
'🚶‍♀️️‍‍',
'🚶‍♂️️️‍‍',
'🏃‍♀️️️️‍‍',
'🏃‍♂️‍‍',
'👫️‍‍',
'👭‍‍',
'👬‍‍',
'💑‍‍',
'👩‍❤️‍👩‍‍',
'👨‍❤️‍👨‍‍',
'💏‍‍',
'👩‍❤️‍💋‍👩‍‍',
'👨‍❤️‍💋‍👨‍‍',
'👪‍‍',
'👨‍👩‍👧‍‍',
'👨‍👩‍👧‍👦‍‍',
'👨‍👩‍👦‍👦‍‍',
'👨‍👩‍👧‍👧‍‍',
'👩‍👩‍👦‍‍',
'👩‍👩‍👧‍‍',
'👩‍👩‍👧‍👦‍‍',
'👩‍👩‍👦‍👦‍‍',
'👩‍👩‍👧‍👧‍‍',
'👨‍👨‍👦‍‍',
'👨‍👨‍👧‍‍',
'👨‍👨‍👧‍👦‍‍',
'👨‍👨‍👦‍👦‍‍',
'👨‍👨‍👧‍👧‍‍',
'👩‍👦‍‍',
'👩‍👧‍‍',
'👩‍👧‍👦‍‍',
'👩‍👦‍👦‍‍',
'👩‍👧‍👧‍‍',
'👨‍👦‍‍',
'👨‍👧‍‍',
'👨‍👧‍👦‍‍',
'👨‍👦‍👦‍‍',
'👨‍👧‍👧‍‍',
'🤲‍‍',
'👐‍‍',
'🙌‍‍',
'👏‍‍',
'🤝‍‍',
'👍‍‍',
'👎‍‍',
'👊‍‍',
'✊‍‍',
'🤛‍‍',
'🤜‍‍',
'🤞‍‍',
'✌️‍‍',
'🤟️‍‍',
'🤘‍‍',
'👌‍‍',
'👈‍‍',
'👉‍‍',
'👆‍‍',
'👇‍‍',
'☝️‍‍',
'✋️‍‍',
'🤚️‍‍',
'🤚️‍‍',
'🖐‍‍',
'🖖‍‍',
'👋‍‍',
'🤙‍‍',
'💪‍‍',
'🦵‍‍',
'🦶‍‍',
'🖕‍‍',
'✍️‍‍',
'🙏️‍‍',
'💍‍‍',
'💄‍‍',
'💋‍‍',
'👄‍‍',
'👅‍‍',
'👂‍‍',
'👃‍‍',
'👣‍‍',
'👁‍‍',
'👀‍‍',
'🧠‍‍',
'🦴‍‍',
'🦷‍‍',
'🗣‍‍',
'👤‍‍',
'👥‍‍',
'🧥‍‍',
'👚‍‍',
'👕‍‍',
'👖‍‍',
'👔‍‍',
'👗‍‍',
'👙‍‍',
'👘‍‍',
'👠‍‍',
'👡‍‍',
'👢‍‍',
'👞‍‍',
'👟‍‍',
'🥾‍‍',
'🥿‍‍',
'🧦‍‍',
'🧤‍‍',
'🧣‍‍',
'🎩‍‍',
'🧢‍‍',
'👒‍‍',
'🎓‍‍',
'⛑‍‍',
'👑‍‍',
'👝‍‍',
'👛‍‍',
'👜‍‍',
'💼‍‍',
'🎒‍‍',
'👓‍‍',
'🕶‍‍',
'🥽‍‍',
'🥼‍‍',
'🌂‍‍',
'🧵‍‍',
'🧶‍‍',
/// Animals
'🐶‍‍',
'🐱‍‍',
'🐭‍‍',
'🐰‍‍',
'🦊‍‍',
'🦝‍‍',
'🐻‍‍',
'🦘‍‍',
'🦡‍‍',
'🐨‍‍',
'🐯‍‍',
'🦁‍‍',
'🐼‍‍',
'🐼‍‍',
'🐮‍‍',
'🐷‍‍',
'🐽‍‍',
'🐸‍‍',
'🐵‍‍',
'🙈‍‍',
'🙉‍‍',
'🙊‍‍',
'🐒‍‍',
'🐔‍‍',
'🐧‍‍',
'🐦‍‍',
'🐤‍‍',
'🐣‍‍',
'🐥‍‍',
'🦆‍‍',
'🦢‍‍',
'🦅‍‍',
'🦉‍‍',
'🦚‍‍',
'🦜‍‍',
'🦇‍‍',
'🐺‍‍',
'🐗‍‍',
'🐴‍‍',
'🦄‍‍',
'🐝‍‍',
'🐛‍‍',
'🦋‍‍',
'🐌‍‍',
'🐚‍‍',
'🐞‍‍',
'🐜‍‍',
'🦗‍‍',
'🕷‍‍',
'🕸‍‍',
'🦂‍‍',
'🦟‍‍',
'🦠‍‍',
'🐢‍‍',
'🐍‍‍',
'🦎‍‍',
'🦖‍‍',
'🦕‍‍',
'🐙‍‍',
'🦑‍‍',
'🦐‍‍',
'🦀‍‍',
'🐡‍‍',
'🐠‍‍',
'🐟‍‍',
'🐬‍‍',
'🐳‍‍',
'🐋‍‍',
'🦈‍‍',
'🐊‍‍',
'🐅‍‍',
'🐆‍‍',
'🦓‍‍',
'🦍‍‍',
'🐘‍‍',
'🦏‍‍',
'🦛‍‍',
'🐪‍‍',
'🐫‍‍',
'🦙‍‍',
'🦒‍‍',
'🐃‍‍',
'🐂‍‍',
'🐄‍‍',
'🐎‍‍',
'🐖‍‍',
'🐏‍‍',
'🐐‍‍',
'🦌‍‍',
'🐕‍‍',
'🐩‍‍',
'🐈‍‍',
'🐓‍‍',
'🦃‍‍',
'🕊‍‍',
'🐇‍‍',
'🐁‍‍',
'🐀‍‍',
'🐿‍‍',
'🦔‍‍',
'🐾‍',
'🐉‍',
'🐲‍',
'🌵‍',
'🎄‍',
'🌲‍',
'🌳‍',
'🌴‍',
'🌱‍',
'🌿‍',
'☘️‍',
'🎍️‍',
'🎋️‍',
'🍃‍',
'🍂‍',
'🍁‍',
'🍄‍',
'🌾️‍',
'💐️‍',
'🌷️‍',
'🌹‍',
'🥀‍',
'🌺‍',
'🌸‍',
'🌼‍',
'🌻️‍',
'🌞‍',
'🌝‍',
'🌛‍',
'🌜‍',
'🌚‍',
'🌕‍',
'🌖‍',
'🌗‍',
'🌘‍',
'🌑‍',
'🌒‍',
'🌔‍',
'🌙‍',
'🌎‍',
'🌍‍',
'🌏‍',
'💫‍',
'⭐️‍',
'🌟️‍',
'✨️‍',
'⚡️️‍',
'☄️️️‍',
'💥️️️‍',
'🔥‍',
'🌪‍',
'🌈‍',
'☀️‍',
'🌤️‍',
'⛅️️‍',
'🌥️️‍',
'☁️️‍',
'🌦️️‍',
'🌧️‍',
'⛈‍',
'🌩‍',
'🌨‍',
'❄️‍',
'☃️️‍',
'⛄️️️‍',
'🌬️️️‍',
'💨️️️‍',
'💧️️️‍',
'💦️️️‍',
'☔️️️️‍',
'☂️️️️️‍',
'🌊️️️️️‍',
'🌫️️️️‍',
/// Foods
'🍏‍',
'🍎‍',
'🍐‍',
'🍊‍',
'🍋‍',
'🍌‍',
'🍉‍',
'🍇‍',
'🍓‍',
'🍈‍',
'🍒‍',
'🍑‍',
'🍍‍',
'🥭‍',
'🥥‍',
'🥝‍',
'🍅‍',
'🍆‍',
'🥑‍',
'🥦‍',
'🥒‍',
'🥬‍',
'🌶‍',
'🌽‍',
'🥕‍',
'🥔‍',
'🍠‍',
'🥐‍',
'🍞‍',
'🥖‍',
'🥨‍',
'🥯‍',
'🧀‍',
'🥚‍',
'🍳‍',
'🥞‍',
'🥓‍',
'🥩‍',
'🍗‍',
'🍖‍',
'🌭‍',
'🍔‍',
'🍟‍',
'🍕‍',
'🥪‍',
'🥙‍',
'🌮‍',
'🌯‍',
'🥗‍',
'🥘‍',
'🥫‍',
'🍝‍',
'🍜‍',
'🍲‍',
'🍛‍',
'🍣‍',
'🍱‍',
'🥟‍',
'🍤‍',
'🍙‍',
'🍚‍',
'🍘‍',
'🍥‍',
'🥮‍',
'🥠‍',
'🍢‍',
'🍧‍',
'🍨‍',
'🍦‍',
'🥧‍',
'🍰‍',
'🎂‍',
'🍮‍',
'🍭‍',
'🍬‍',
'🍫‍',
'🍿‍',
'🧂‍',
'🍩‍',
'🍪‍',
'🌰‍',
'🥜‍',
'🍯‍',
'🥛‍',
'🍼‍',
'☕️‍',
'🍵️‍',
'🥤️‍',
'🍶‍',
'🍺‍',
'🍻‍',
'🥂‍',
'🍷‍',
'🍸‍',
'🍹‍',
'🍾‍',
'🥄‍',
'🍴‍',
'🍽‍',
'🥣‍',
'🥡‍',
'🥢‍',
/// Activity and Sports
'⚽️‍',
'🏀️‍',
'🏈‍',
'⚾️‍',
'🥎️‍',
'🏐️‍',
'🏉‍',
'🎾‍',
'🥏‍',
'🎱‍',
'🏓‍',
'🏸‍',
'🥅‍',
'🏒‍',
'🏑‍',
'🥍‍',
'🏏‍',
'⛳️‍',
'🏹️‍',
'🎣️‍',
'🥊‍',
'🥋‍',
'🎽‍',
'⛸‍',
'🥌‍',
'🛷‍',
'🛹‍',
'🎿‍',
'⛷‍',
'🏂‍',
'🏋️‍♀️‍',
'🏋🏼‍♀️‍',
'🏋🏽‍♀️️‍',
'🏋🏾‍♀️️️‍',
'🏋🏿‍♀️️️️‍',
'🏋️‍♂️️️️‍',
'🏋🏻‍♂️️️️‍',
'🏋🏼‍♂️️️️‍',
'🏋🏽‍♂️️️️‍',
'🏋🏾‍♂️️️️‍',
'🏋🏿‍♂️️️️‍',
'🤼‍♀️️️️‍',
'🤼‍♂️️️️‍',
'🤸‍♀️️️️‍',
'🤸🏻‍♀️️️️‍',
'🤸🏼‍♀️️️️‍',
'🤸🏽‍♀️️️️‍',
'🤸🏿‍♀️️️️️‍',
'🤸‍♂️️️️‍',
'🤸🏻‍♂️️️️‍',
'🤸🏼‍♂️️️️️‍',
'🤸🏽‍♂️️️️️️‍',
'🤸🏾‍♂️️️️️️‍',
'🤸🏿‍♂️️️️️️‍',
'⛹️‍♀️️️️️️‍',
'⛹🏻‍♀️️️️️️️‍',
'⛹🏼‍♀️️️️️️️️‍',
'⛹🏽‍♀️️️️️️️️️‍',
'⛹🏾‍♀️️️️️️️️️️‍',
'⛹🏿‍♀️️️️️️️️️️️‍',
'⛹️‍♂️️️️️️️️️️️️‍',
'⛹🏻‍♂️️️️️️️️️️️️️‍',
'⛹🏼‍♂️️️️️️️️️️️️️️‍',
'⛹🏽‍♂️️️️️️️️️️️️️️️‍',
'⛹🏾‍♂️️️️️️️️️️️️️️️️‍',
'⛹🏿‍♂️‍',
'🤺️‍',
'🤾‍♀️‍',
'🤾🏻‍♀️️‍',
'🤾🏼‍♀️️️‍',
'🤾🏾‍♀️️️️‍',
];

View file

@ -1,36 +0,0 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
class ImageItem {
ImageItem([dynamic image]) {
if (image != null) unawaited(load(image));
}
int width = 1;
int height = 1;
Uint8List bytes = Uint8List.fromList([]);
Completer<bool> loader = Completer<bool>();
Future<void> load(dynamic image) async {
loader = Completer<bool>();
if (image is ImageItem) {
bytes = image.bytes;
height = image.height;
width = image.width;
return loader.complete(true);
} else if (image is Uint8List) {
bytes = image;
final decodedImage = await decodeImageFromList(bytes);
height = decodedImage.height;
width = decodedImage.width;
return loader.complete(true);
} else {
return loader.complete(false);
}
}
}

View file

@ -1,38 +0,0 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
class BackgroundLayer extends StatefulWidget {
const BackgroundLayer({
required this.layerData,
super.key,
this.onUpdate,
});
final BackgroundLayerData layerData;
final VoidCallback? onUpdate;
@override
State<BackgroundLayer> createState() => _BackgroundLayerState();
}
class _BackgroundLayerState extends State<BackgroundLayer> {
@override
Widget build(BuildContext context) {
return Container(
width: widget.layerData.image.width.toDouble(),
height: widget.layerData.image.height.toDouble(),
// color: Theme.of(context).colorScheme.surface,
padding: EdgeInsets.zero,
child: Image.memory(
widget.layerData.image.bytes,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.layerData.imageLoaded = true;
});
}
return child;
},
),
);
}
}

View file

@ -1,173 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/views/camera/image_editor/action_button.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
/// Emoji layer
class EmojiLayer extends StatefulWidget {
const EmojiLayer({
required this.layerData,
super.key,
this.onUpdate,
});
final EmojiLayerData layerData;
final VoidCallback? onUpdate;
@override
State<EmojiLayer> createState() => _EmojiLayerState();
}
class _EmojiLayerState extends State<EmojiLayer> {
double initialRotation = 0;
Offset initialOffset = Offset.zero;
Offset initialFocalPoint = Offset.zero;
double initialScale = 1;
bool deleteLayer = false;
bool twoPointerWhereDown = false;
final GlobalKey outlineKey = GlobalKey();
final GlobalKey emojiKey = GlobalKey();
int pointers = 0;
bool display = false;
@override
void initState() {
super.initState();
if (widget.layerData.offset.dy == 0) {
// Set the initial offset to the center of the screen
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
widget.layerData.offset = Offset(
MediaQuery.of(context).size.width / 2 - (153 / 2),
MediaQuery.of(context).size.height / 2 - (153 / 2) - 100,
);
});
display = true;
});
} else {
display = true;
}
}
@override
Widget build(BuildContext context) {
if (!display) return Container();
if (widget.layerData.isDeleted) return Container();
return Stack(
key: outlineKey,
children: [
Positioned(
left: widget.layerData.offset.dx,
top: widget.layerData.offset.dy,
child: Listener(
onPointerUp: (details) {
setState(() {
pointers--;
if (pointers == 0) {
twoPointerWhereDown = false;
}
if (deleteLayer) {
widget.layerData.isDeleted = true;
widget.onUpdate!();
}
});
},
onPointerDown: (details) {
setState(() {
pointers++;
});
},
child: GestureDetector(
onScaleStart: (details) {
initialScale = widget.layerData.size;
initialRotation = widget.layerData.rotation;
initialOffset = widget.layerData.offset;
initialFocalPoint =
Offset(details.focalPoint.dx, details.focalPoint.dy);
setState(() {});
},
onScaleUpdate: (details) async {
if (twoPointerWhereDown && details.pointerCount != 2) {
return;
}
final outlineBox =
outlineKey.currentContext!.findRenderObject()! as RenderBox;
final emojiBox =
emojiKey.currentContext!.findRenderObject()! as RenderBox;
final isAtTheBottom =
(widget.layerData.offset.dy + emojiBox.size.height / 2) >
outlineBox.size.height - 80;
final isInTheCenter = MediaQuery.of(context).size.width / 2 -
30 <
(widget.layerData.offset.dx +
emojiBox.size.width / 2) &&
MediaQuery.of(context).size.width / 2 + 20 >
(widget.layerData.offset.dx + emojiBox.size.width / 2);
if (isAtTheBottom && isInTheCenter) {
if (!deleteLayer) {
await HapticFeedback.heavyImpact();
}
deleteLayer = true;
} else {
deleteLayer = false;
}
setState(() {
twoPointerWhereDown = details.pointerCount >= 2;
widget.layerData.size = initialScale * details.scale;
if (widget.layerData.size > 96) {
// https://github.com/twonlyapp/twonly-app/issues/349
widget.layerData.size = 96;
}
// print(widget.layerData.size);
widget.layerData.rotation =
initialRotation + details.rotation;
// Update the position based on the translation
final dx = (initialOffset.dx) +
(details.focalPoint.dx - initialFocalPoint.dx);
final dy = (initialOffset.dy) +
(details.focalPoint.dy - initialFocalPoint.dy);
widget.layerData.offset = Offset(dx, dy);
});
},
child: Transform.rotate(
angle: widget.layerData.rotation,
key: emojiKey,
child: Container(
padding: const EdgeInsets.all(44),
color: Colors.transparent,
child: Text(
widget.layerData.text,
style: TextStyle(
fontSize: widget.layerData.size,
),
),
),
),
),
),
),
if (pointers > 0)
Positioned(
left: 0,
right: 0,
bottom: 20,
child: Center(
child: GestureDetector(
child: ActionButton(
FontAwesomeIcons.trashCan,
tooltipText: '',
color: deleteLayer ? Colors.red : Colors.white,
),
),
),
),
],
);
}
}

View file

@ -2,18 +2,20 @@
import 'dart:async';
import 'dart:collection';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/flame.service.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_components/best_friends_selector.dart';
import 'package:twonly/src/utils/screenshot.dart';
import 'package:twonly/src/views/camera/share_image_contact_selection/best_friends_selector.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/background.layer.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart';
import 'package:twonly/src/views/components/flame.dart';
import 'package:twonly/src/views/components/headline.dart';
@ -24,12 +26,14 @@ class ShareImageView extends StatefulWidget {
required this.updateSelectedGroupIds,
required this.mediaStoreFuture,
required this.mediaFileService,
required this.additionalData,
super.key,
});
final HashSet<String> selectedGroupIds;
final void Function(String, bool) updateSelectedGroupIds;
final Future<Uint8List?>? mediaStoreFuture;
final Future<ScreenshotImage?>? mediaStoreFuture;
final MediaFileService mediaFileService;
final AdditionalMessageData? additionalData;
@override
State<ShareImageView> createState() => _ShareImageView();
@ -43,7 +47,7 @@ class _ShareImageView extends State<ShareImageView> {
bool sendingImage = false;
bool mediaStoreFutureReady = false;
Uint8List? _imageBytes;
ScreenshotImage? _screenshotImage;
bool hideArchivedUsers = true;
final TextEditingController searchUserName = TextEditingController();
late StreamSubscription<List<Group>> allGroupSub;
@ -66,7 +70,7 @@ class _ShareImageView extends State<ShareImageView> {
Future<void> initAsync() async {
if (widget.mediaStoreFuture != null) {
_imageBytes = await widget.mediaStoreFuture;
_screenshotImage = await widget.mediaStoreFuture;
}
mediaStoreFutureReady = true;
if (!mounted) return;
@ -244,10 +248,11 @@ class _ShareImageView extends State<ShareImageView> {
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (widget.mediaFileService.mediaFile.type == MediaType.image &&
_imageBytes != null &&
_screenshotImage?.image != null &&
gUser.showShowImagePreviewWhenSending)
SizedBox(
height: 100,
width: 100 * 9 / 16,
child: Container(
clipBehavior: Clip.hardEdge,
decoration: BoxDecoration(
@ -258,7 +263,9 @@ class _ShareImageView extends State<ShareImageView> {
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.memory(_imageBytes!),
child: CustomPaint(
painter: UiImagePainter(_screenshotImage!.image!),
),
),
),
),
@ -283,19 +290,15 @@ class _ShareImageView extends State<ShareImageView> {
sendingImage = true;
});
// in case mediaStoreFutureReady is ready, the image is stored in the originalPath
await insertMediaFileInMessagesTable(
widget.mediaFileService,
widget.selectedGroupIds.toList(),
additionalData: widget.additionalData,
);
if (context.mounted) {
Navigator.pop(context, true);
// if (widget.preselectedUser != null) {
// Navigator.pop(context, true);
// } else {
// Navigator.popUntil(context, (route) => route.isFirst, true);
// globalUpdateOfHomeViewPageIndex(1);
// }
}
},
style: ButtonStyle(

View file

@ -2,14 +2,15 @@
import 'dart:async';
import 'dart:collection';
import 'package:drift/drift.dart' show Value;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/log.dart';
@ -18,13 +19,13 @@ import 'package:twonly/src/utils/screenshot.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/views/camera/camera_preview_components/main_camera_controller.dart';
import 'package:twonly/src/views/camera/camera_preview_components/save_to_gallery.dart';
import 'package:twonly/src/views/camera/image_editor/action_button.dart';
import 'package:twonly/src/views/camera/image_editor/data/image_item.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers_viewer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/camera/share_image_components/select_show_time.dart';
import 'package:twonly/src/views/camera/share_image_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_editor/action_button.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';
import 'package:twonly/src/views/components/notification_badge.dart';
import 'package:video_player/video_player.dart';
@ -37,16 +38,18 @@ class ShareImageEditorView extends StatefulWidget {
const ShareImageEditorView({
required this.sharedFromGallery,
required this.mediaFileService,
this.screenshotImage,
this.previewLink,
super.key,
this.imageBytesFuture,
this.sendToGroup,
this.mainCameraController,
});
final ScreenshotImage? imageBytesFuture;
final ScreenshotImage? screenshotImage;
final Group? sendToGroup;
final bool sharedFromGallery;
final MediaFileService mediaFileService;
final MainCameraController? mainCameraController;
final Uri? previewLink;
@override
State<ShareImageEditorView> createState() => _ShareImageEditorView();
}
@ -60,7 +63,6 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
double widthRatio = 1;
double heightRatio = 1;
double pixelRatio = 1;
Uint8List? imageBytes;
VideoPlayerController? videoController;
ImageItem currentImage = ImageItem();
ScreenshotController screenshotController = ScreenshotController();
@ -77,14 +79,20 @@ 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);
}
if (widget.mediaFileService.mediaFile.type == MediaType.image ||
widget.mediaFileService.mediaFile.type == MediaType.gif) {
if (widget.imageBytesFuture != null) {
loadImage(widget.imageBytesFuture!);
if (widget.screenshotImage != null) {
loadImage(widget.screenshotImage!);
} else {
if (widget.mediaFileService.tempPath.existsSync()) {
loadImage(ScreenshotImage(file: widget.mediaFileService.tempPath));
@ -411,6 +419,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
updateSelectedGroupIds: updateSelectedGroupIds,
mediaStoreFuture: mediaStoreFuture,
mediaFileService: mediaService,
additionalData: getAdditionalData(),
),
),
) as bool?;
@ -424,8 +433,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
Future<ScreenshotImage?> getEditedImageBytes() async {
if (layers.length == 1) {
if (layers.first is BackgroundLayerData) {
final image = (layers.first as BackgroundLayerData).image.bytes;
return ScreenshotImage(imageBytes: image);
return (layers.first as BackgroundLayerData).image.image;
}
}
@ -454,7 +462,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
return image;
}
Future<Uint8List?> storeImageAsOriginal() async {
Future<ScreenshotImage?> storeImageAsOriginal() async {
if (mediaService.overlayImagePath.existsSync()) {
mediaService.overlayImagePath.deleteSync();
}
@ -466,11 +474,16 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
mediaService.originalPath.deleteSync();
}
}
var bytes = imageBytes;
ScreenshotImage? image;
var bytes = await widget.screenshotImage?.getBytes();
if (media.type == MediaType.gif) {
mediaService.originalPath.writeAsBytesSync(imageBytes!.toList());
if (bytes != null) {
mediaService.originalPath.writeAsBytesSync(bytes.toList());
} else {
final image = await getEditedImageBytes();
Log.error('Could not load image bytes for gif!');
}
} else {
image = await getEditedImageBytes();
if (image == null) return null;
bytes = await image.getBytes();
if (bytes == null) {
@ -485,16 +498,38 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
Log.error('MediaType not supported: ${media.type}');
}
}
return bytes;
return image;
}
Future<void> loadImage(ScreenshotImage imageBytesFuture) async {
imageBytes = await imageBytesFuture.getBytes();
// store this image so it can be used as a draft in case the app is restarted
Future<void> storeIoImageAsDraft(ScreenshotImage screenshotImage) async {
final imageBytes = await screenshotImage.getBytes();
mediaService.originalPath.writeAsBytesSync(imageBytes!.toList());
}
Future<void> loadImage(ScreenshotImage screenshotImage) async {
if (screenshotImage.image == null &&
screenshotImage.imageBytes == null &&
screenshotImage.imageBytesFuture != null) {
// this ensures that the imageBytes are defined
await storeIoImageAsDraft(screenshotImage);
} else {
// store this image so it can be used as a draft in case the app is restarted
unawaited(storeIoImageAsDraft(screenshotImage));
}
if (screenshotImage.image == null) {
final imageBytes = await screenshotImage.getBytes();
if (imageBytes != null) {
screenshotImage.image = await decodeImageFromList(imageBytes);
}
}
if (screenshotImage.image == null) {
Log.error('Could not load screenshotImage.image');
return;
}
currentImage.load(screenshotImage);
await currentImage.load(imageBytes);
if (isDisposed) return;
if (!context.mounted) return;
@ -536,6 +571,18 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
});
}
AdditionalMessageData? getAdditionalData() {
AdditionalMessageData? additionalData;
if (widget.previewLink != null) {
additionalData = AdditionalMessageData(
type: AdditionalMessageData_Type.LINK,
link: widget.previewLink.toString(),
);
}
return additionalData;
}
Future<void> sendImageToSinglePerson() async {
if (sendingOrLoadingImage) return;
setState(() {
@ -551,6 +598,7 @@ class _ShareImageEditorView extends State<ShareImageEditorView> {
await insertMediaFileInMessagesTable(
mediaService,
[widget.sendToGroup!.groupId],
additionalData: getAdditionalData(),
);
if (mounted) {

View file

@ -0,0 +1,18 @@
import 'dart:async';
import 'package:twonly/src/utils/screenshot.dart';
class ImageItem {
ImageItem();
int width = 1;
int height = 1;
ScreenshotImage? image;
Completer<bool> loader = Completer<bool>();
void load(ScreenshotImage img) {
image = img;
if (image?.image != null) {
height = image!.image!.height;
width = image!.image!.width;
}
}
}

View file

@ -1,8 +1,7 @@
// ignore_for_file: comment_references
import 'package:flutter/material.dart';
import 'package:hand_signature/signature.dart';
import 'package:twonly/src/views/camera/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/layers/link_preview/parser/base.dart';
/// Layer class with some common properties
class Layer {
@ -28,7 +27,6 @@ class Layer {
bool showCustomButtons;
}
/// Attributes used by [BackgroundLayer]
class BackgroundLayerData extends Layer {
BackgroundLayerData({
required super.key,
@ -38,6 +36,16 @@ class BackgroundLayerData extends Layer {
bool imageLoaded = false;
}
class LinkPreviewLayerData extends Layer {
LinkPreviewLayerData({
required super.key,
required this.link,
});
Uri link;
Metadata? metadata;
bool error = false;
}
class FilterLayerData extends Layer {
FilterLayerData({
required super.key,
@ -46,12 +54,11 @@ class FilterLayerData extends Layer {
int page = 1;
}
/// Attributes used by [EmojiLayer]
class EmojiLayerData extends Layer {
EmojiLayerData({
required super.key,
this.text = '',
this.size = 64,
this.size = 94,
super.offset,
super.opacity,
super.rotation,
@ -62,7 +69,6 @@ class EmojiLayerData extends Layer {
double size;
}
/// Attributes used by [TextLayer]
class TextLayerData extends Layer {
TextLayerData({
required super.key,
@ -78,9 +84,7 @@ class TextLayerData extends Layer {
int textLayersBefore;
}
/// Attributes used by [DrawLayer]
class DrawLayerData extends Layer {
// String text;
DrawLayerData({
required super.key,
super.offset,

View file

@ -0,0 +1,62 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
class BackgroundLayer extends StatefulWidget {
const BackgroundLayer({
required this.layerData,
super.key,
this.onUpdate,
});
final BackgroundLayerData layerData;
final VoidCallback? onUpdate;
@override
State<BackgroundLayer> createState() => _BackgroundLayerState();
}
class _BackgroundLayerState extends State<BackgroundLayer> {
@override
void initState() {
WidgetsBinding.instance.addPostFrameCallback((_) {
widget.layerData.imageLoaded = true;
});
super.initState();
}
@override
Widget build(BuildContext context) {
final scImage = widget.layerData.image.image;
if (scImage == null || scImage.image == null) return Container();
return Container(
width: widget.layerData.image.width.toDouble(),
height: widget.layerData.image.height.toDouble(),
padding: EdgeInsets.zero,
color: Colors.green,
child: CustomPaint(
painter: UiImagePainter(scImage.image!),
),
);
}
}
class UiImagePainter extends CustomPainter {
UiImagePainter(this.image);
final ui.Image image;
@override
void paint(Canvas canvas, Size size) {
canvas.drawImageRect(
image,
Rect.fromLTWH(0, 0, image.width.toDouble(), image.height.toDouble()),
Rect.fromLTWH(0, 0, size.width, size.height),
Paint(),
);
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) {
return false;
}
}

View file

@ -1,12 +1,11 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:hand_signature/signature.dart';
// ignore: implementation_imports
import 'package:hand_signature/src/utils.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/screenshot.dart';
import 'package:twonly/src/views/camera/image_editor/action_button.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/share_image_editor/action_button.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 {
const DrawLayer({
@ -23,8 +22,6 @@ class DrawLayer extends StatefulWidget {
class _DrawLayerState extends State<DrawLayer> {
Color currentColor = Colors.red;
ScreenshotController screenshotController = ScreenshotController();
List<CubicPath> undoList = [];
bool skipNextEvent = false;
bool showMagnifyingGlass = false;
@ -85,17 +82,11 @@ class _DrawLayerState extends State<DrawLayer> {
fit: StackFit.expand,
children: [
Positioned.fill(
child: Container(
decoration: const BoxDecoration(
color: Colors.transparent,
),
child: Screenshot(
controller: screenshotController,
child: HandSignature(
child: CustomHandSignature(
control: widget.layerData.control,
drawer: CustomSignatureDrawer(color: currentColor, width: 7),
),
),
isModificationEnabled: widget.layerData.isEditing,
currentColor: currentColor,
width: 7,
),
),
if (widget.layerData.isEditing && widget.layerData.showCustomButtons)
@ -211,12 +202,12 @@ class _DrawLayerState extends State<DrawLayer> {
top: 50 + (185 * _sliderValue),
child: MagnifyingGlass(color: currentColor),
),
if (!widget.layerData.isEditing)
Positioned.fill(
child: Container(
color: Colors.transparent,
),
),
// if (!widget.layerData.isEditing)
// Positioned.fill(
// child: Container(
// color: Colors.transparent,
// ),
// ),
],
);
}
@ -244,33 +235,3 @@ class MagnifyingGlass extends StatelessWidget {
);
}
}
class CustomSignatureDrawer extends HandSignatureDrawer {
const CustomSignatureDrawer({
this.width = 1.0,
this.color = Colors.black,
});
final Color color;
final double width;
@override
void paint(Canvas canvas, Size size, List<CubicPath> paths) {
for (final path in paths) {
var lineColor = color;
if (path.setup.args!['color'] != null) {
lineColor = path.setup.args!['color'] as Color;
} else {
path.setup.args!['color'] = color;
}
final paint = Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = width;
if (path.isFilled) {
canvas.drawPath(PathUtil.toLinePath(path.lines), paint);
}
}
}
}

View file

@ -0,0 +1,77 @@
import 'package:flutter/material.dart';
import 'package:hand_signature/signature.dart';
// ignore: implementation_imports
import 'package:hand_signature/src/utils.dart';
class CustomHandSignature extends StatelessWidget {
const CustomHandSignature({
required this.control,
required this.isModificationEnabled,
required this.currentColor,
required this.width,
super.key,
});
/// The controller that manages the creation and manipulation of signature paths.
final HandSignatureControl control;
final bool isModificationEnabled;
final Color currentColor;
final double width;
@override
Widget build(BuildContext context) {
control.params = SignaturePaintParams(
color: currentColor,
strokeWidth: 7,
);
final drawer = CustomSignatureDrawer(color: currentColor, width: width);
if (isModificationEnabled) {
return HandSignature(
control: control,
drawer: drawer,
);
}
return IgnorePointer(
child: ClipRRect(
child: HandSignaturePaint(
control: control,
drawer: drawer,
onSize: control.notifyDimension,
),
),
);
}
}
class CustomSignatureDrawer extends HandSignatureDrawer {
const CustomSignatureDrawer({
this.width = 1.0,
this.color = Colors.black,
});
final Color color;
final double width;
@override
void paint(Canvas canvas, Size size, List<CubicPath> paths) {
for (final path in paths) {
var lineColor = color;
if (path.setup.args!['color'] != null) {
lineColor = path.setup.args!['color'] as Color;
} else {
path.setup.args!['color'] = color;
}
final paint = Paint()
..color = lineColor
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round
..strokeWidth = width;
if (path.isFilled) {
canvas.drawPath(PathUtil.toLinePath(path.lines), paint);
}
}
}
}

View file

@ -0,0 +1,248 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
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/layer_data.dart';
/// Emoji layer
class EmojiLayer extends StatefulWidget {
const EmojiLayer({
required this.layerData,
super.key,
this.onUpdate,
});
final EmojiLayerData layerData;
final VoidCallback? onUpdate;
@override
State<EmojiLayer> createState() => _EmojiLayerState();
}
class _EmojiLayerState extends State<EmojiLayer> {
double initialRotation = 0;
Offset initialOffset = Offset.zero;
Offset initialFocalPoint = Offset.zero;
double initialScale = 1;
bool deleteLayer = false;
bool twoPointerWhereDown = false;
final GlobalKey outlineKey = GlobalKey();
final GlobalKey emojiKey = GlobalKey();
int pointers = 0;
bool display = false;
@override
void initState() {
super.initState();
if (widget.layerData.offset.dy == 0) {
// Set the initial offset to the center of the screen
WidgetsBinding.instance.addPostFrameCallback((_) {
setState(() {
widget.layerData.offset = Offset(
MediaQuery.of(context).size.width / 2 - (153 / 2),
MediaQuery.of(context).size.height / 2 - (153 / 2) - 100,
);
});
display = true;
});
} else {
display = true;
}
}
@override
Widget build(BuildContext context) {
if (!display) return Container();
if (widget.layerData.isDeleted) return Container();
return Stack(
key: outlineKey,
children: [
Positioned(
left: widget.layerData.offset.dx,
top: widget.layerData.offset.dy,
child: PhysicalModel(
color: Colors.transparent,
borderRadius: BorderRadius.circular(180),
clipBehavior: Clip.antiAlias,
child: Listener(
onPointerUp: (details) {
setState(() {
pointers--;
if (pointers == 0) {
twoPointerWhereDown = false;
}
if (deleteLayer) {
widget.layerData.isDeleted = true;
widget.onUpdate!();
}
});
},
onPointerDown: (details) {
setState(() {
pointers++;
});
},
child: GestureDetector(
onScaleStart: (details) {
initialScale = widget.layerData.size;
initialRotation = widget.layerData.rotation;
initialOffset = widget.layerData.offset;
initialFocalPoint =
Offset(details.focalPoint.dx, details.focalPoint.dy);
setState(() {});
},
onScaleUpdate: (details) async {
if (twoPointerWhereDown && details.pointerCount != 2) {
return;
}
final outlineBox = outlineKey.currentContext!
.findRenderObject()! as RenderBox;
final emojiBox =
emojiKey.currentContext!.findRenderObject()! as RenderBox;
final isAtTheBottom =
(widget.layerData.offset.dy + emojiBox.size.height / 2) >
outlineBox.size.height - 80;
final isInTheCenter =
MediaQuery.of(context).size.width / 2 - 30 <
(widget.layerData.offset.dx +
emojiBox.size.width / 2) &&
MediaQuery.of(context).size.width / 2 + 20 >
(widget.layerData.offset.dx +
emojiBox.size.width / 2);
if (isAtTheBottom && isInTheCenter) {
if (!deleteLayer) {
await HapticFeedback.heavyImpact();
}
deleteLayer = true;
} else {
deleteLayer = false;
}
setState(() {
twoPointerWhereDown = details.pointerCount >= 2;
widget.layerData.size = initialScale * details.scale;
// print(widget.layerData.size);
widget.layerData.rotation =
initialRotation + details.rotation;
// Update the position based on the translation
final dx = (initialOffset.dx) +
(details.focalPoint.dx - initialFocalPoint.dx);
final dy = (initialOffset.dy) +
(details.focalPoint.dy - initialFocalPoint.dy);
widget.layerData.offset = Offset(dx, dy);
});
},
child: Transform.rotate(
angle: widget.layerData.rotation,
key: emojiKey,
child: Container(
padding: const EdgeInsets.all(44),
color: Colors.transparent,
child: ScreenshotEmoji(
emoji: widget.layerData.text,
displaySize: widget.layerData.size,
),
),
),
),
),
),
),
if (pointers > 0)
Positioned(
left: 0,
right: 0,
bottom: 20,
child: Center(
child: GestureDetector(
child: ActionButton(
FontAwesomeIcons.trashCan,
tooltipText: '',
color: deleteLayer ? Colors.red : Colors.white,
),
),
),
),
],
);
}
}
// Workaround: https://github.com/twonlyapp/twonly-app/issues/349
class ScreenshotEmoji extends StatefulWidget {
const ScreenshotEmoji({
required this.emoji,
required this.displaySize,
super.key,
});
final String emoji;
final double displaySize;
@override
State<ScreenshotEmoji> createState() => _ScreenshotEmojiState();
}
class _ScreenshotEmojiState extends State<ScreenshotEmoji> {
final GlobalKey _boundaryKey = GlobalKey();
ui.Image? _capturedImage;
@override
void initState() {
super.initState();
// Capture the emoji immediately after the first frame
WidgetsBinding.instance.addPostFrameCallback((_) => _captureEmoji());
}
Future<void> _captureEmoji() async {
try {
final boundary = _boundaryKey.currentContext?.findRenderObject()
as RenderRepaintBoundary?;
if (boundary == null) return;
final image = await boundary.toImage(pixelRatio: 4);
setState(() {
_capturedImage = image;
});
} catch (e) {
Log.error('Error capturing emoji: $e');
}
}
@override
Widget build(BuildContext context) {
if (_capturedImage != null) {
return SizedBox(
width: widget.displaySize,
height: widget.displaySize,
child: RawImage(
image: _capturedImage,
fit: BoxFit.contain,
),
);
}
return Stack(
children: [
Positioned(
top: -200, // hide from the user as the size changes with the image
child: RepaintBoundary(
key: _boundaryKey,
child: Text(
widget.emoji,
style: const TextStyle(fontSize: 94),
),
),
),
SizedBox(width: widget.displaySize, height: widget.displaySize),
],
);
}
}

View file

@ -2,10 +2,10 @@ import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filters/datetime_filter.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filters/image_filter.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filters/location_filter.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';
/// Main layer
class FilterLayer extends StatefulWidget {

View file

@ -1,7 +1,7 @@
import 'package:clock/clock.dart';
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filter_layer.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/filter.layer.dart';
class DateTimeFilter extends StatelessWidget {
const DateTimeFilter({super.key, this.color = Colors.white});

View file

@ -1,6 +1,6 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filter_layer.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/filter.layer.dart';
class ImageFilter extends StatelessWidget {
const ImageFilter({required this.imagePath, super.key});

View file

@ -11,8 +11,8 @@ import 'package:path_provider/path_provider.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/model/protobuf/api/websocket/server_to_client.pb.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filter_layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filters/datetime_filter.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/filter.layer.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/filters/datetime_filter.dart';
class LocationFilter extends StatefulWidget {
const LocationFilter({super.key});

View file

@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/cards/custom.card.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/cards/mastodon.card.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/cards/twitter.card.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/cards/youtube.card.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parse_link.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
import 'package:twonly/src/views/components/loader.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> {
Metadata? metadata;
@override
void initState() {
initAsync();
super.initState();
}
Future<void> initAsync() async {
if (widget.layerData.metadata == null) {
widget.layerData.metadata =
await getMetadata(widget.layerData.link.toString());
if (widget.layerData.metadata == null) {
widget.layerData.error = true;
}
if (mounted) setState(() {});
}
}
@override
Widget build(BuildContext context) {
if (widget.layerData.error) {
return Container();
}
final meta = widget.layerData.metadata;
late Widget child;
if (meta == null) {
child = const ThreeRotatingDots(size: 30);
} else if (meta.title == null) {
return Container();
} else if (meta.vendor == Vendor.mastodonSocialMediaPosting) {
child = MastodonPostCard(info: meta);
} else if (meta.vendor == Vendor.twitterPosting) {
child = TwitterPostCard(info: meta);
} else if (meta.vendor == Vendor.youtubeVideo) {
child = YouTubePostCard(info: meta);
} else {
child = CustomLinkCard(info: meta);
}
return Center(
child: child,
);
}
}

View file

@ -0,0 +1,87 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
class CustomLinkCard extends StatelessWidget {
const CustomLinkCard({required this.info, super.key});
final Metadata info;
@override
Widget build(BuildContext context) {
return FractionallySizedBox(
widthFactor: 0.8,
child: Container(
decoration: BoxDecoration(
color: const Color(0xFF1E1E1E),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.white10),
),
clipBehavior: Clip.antiAlias,
child: IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
Uri.parse(info.url).host.toUpperCase(),
style: TextStyle(
color: context.color.primary,
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
),
),
const SizedBox(height: 4),
Text(
substringBy(info.title ?? 'Link Preview', 35),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
if (info.desc != null && info.desc != 'null') ...[
const SizedBox(height: 6),
Text(
substringBy(info.desc!, 500),
maxLines: 3,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: Color(0xFFB0B0B0),
fontSize: 13,
height: 1.4,
),
),
],
if (info.image != null && info.image != 'null')
Padding(
padding: const EdgeInsets.only(top: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(4),
child: CachedNetworkImage(
imageUrl: info.image!,
fit: BoxFit.cover,
width: double.infinity,
),
),
),
],
),
),
),
],
),
),
),
);
}
}

View file

@ -0,0 +1,110 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
import 'package:twonly/src/views/components/loader.dart';
class MastodonPostCard extends StatelessWidget {
const MastodonPostCard({required this.info, super.key});
final Metadata info;
@override
Widget build(BuildContext context) {
const backgroundColor = Color(0xFF282C37);
const secondaryTextColor = Color(0xFF9BA3AF);
const accentColor = Color(0xFF6364FF);
return FractionallySizedBox(
widthFactor: 0.8,
child: Container(
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(12),
),
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const FaIcon(
FontAwesomeIcons.mastodon,
color: accentColor,
size: 20,
),
const SizedBox(width: 10),
Text(
substringBy(info.title ?? 'Mastodon User', 37),
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
],
),
const SizedBox(height: 4),
if (info.desc != null && info.desc != 'null')
Text(
substringBy(info.desc!, 1000),
style: const TextStyle(color: Colors.white, fontSize: 14),
),
if (info.image != null && info.image != 'null')
Padding(
padding: const EdgeInsets.only(top: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 250),
child: CachedNetworkImage(
imageUrl: info.image!,
fit: BoxFit.contain,
width: double.infinity,
placeholder: (context, url) => Container(
height: 150,
color: Colors.black12,
child: const Center(
child: ThreeRotatingDots(size: 20),
),
),
),
),
),
),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_buildAction(
Icons.repeat,
'${info.shareAction ?? 0}',
secondaryTextColor,
),
const SizedBox(width: 20),
_buildAction(
Icons.star_border,
'${info.likeAction ?? 0}',
secondaryTextColor,
),
],
),
],
),
),
);
}
Widget _buildAction(IconData icon, String count, Color color) {
return Row(
children: [
Icon(icon, size: 18, color: color),
if (count.isNotEmpty && count != '0') ...[
const SizedBox(width: 5),
Text(count, style: TextStyle(color: color, fontSize: 13)),
],
],
);
}
}

View file

@ -0,0 +1,101 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
// Assuming the same Metadata import structure
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
class TwitterPostCard extends StatelessWidget {
const TwitterPostCard({required this.info, super.key});
final Metadata info;
@override
Widget build(BuildContext context) {
// Classic Twitter Brand Colors
const twitterBlue = Color(0xFF1DA1F2);
const backgroundWhite = Colors.white;
const primaryText = Color(0xFF14171A);
const borderColor = Color(0xFFE1E8ED);
return FractionallySizedBox(
widthFactor: 0.9, // Twitter cards often feel a bit wider
child: Container(
decoration: BoxDecoration(
color: backgroundWhite,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: borderColor),
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const FaIcon(
FontAwesomeIcons.twitter,
color: twitterBlue,
size: 22,
),
const SizedBox(width: 12),
Expanded(
child: Text(
substringBy(info.title ?? 'Twitter User', 37),
style: const TextStyle(
color: primaryText,
fontWeight: FontWeight.w800,
fontSize: 16,
letterSpacing: -0.5,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
if (info.desc != null && info.desc != 'null')
Text(
substringBy(info.desc!, 1000),
style: const TextStyle(
color: primaryText,
fontSize: 15,
height: 1.3,
),
),
if (info.image != null && info.image != 'null')
Padding(
padding: const EdgeInsets.only(top: 12),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: borderColor),
borderRadius: BorderRadius.circular(14),
),
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 300),
child: CachedNetworkImage(
imageUrl: info.image!,
fit: BoxFit.cover,
width: double.infinity,
placeholder: (context, url) => Container(
height: 150,
color: const Color(0xFFF5F8FA),
child: const Center(
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation(twitterBlue),
),
),
),
),
),
),
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,90 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
class YouTubePostCard extends StatelessWidget {
const YouTubePostCard({required this.info, super.key});
final Metadata info;
@override
Widget build(BuildContext context) {
const ytBlack = Color(0xFF0F0F0F);
const ytWhite = Colors.white;
const ytRed = Color.fromARGB(255, 255, 1, 51);
return FractionallySizedBox(
widthFactor: 0.8,
child: Container(
decoration: BoxDecoration(
color: ytBlack,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Stack(
alignment: Alignment.bottomRight,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(12),
child: AspectRatio(
aspectRatio: 16 / 9,
child: CachedNetworkImage(
imageUrl: info.image ?? '',
fit: BoxFit.cover,
placeholder: (context, url) =>
Container(color: Colors.white10),
errorWidget: (context, url, error) => const ColoredBox(
color: Colors.white10,
child: Icon(
Icons.play_circle_outline,
color: ytWhite,
size: 50,
),
),
),
),
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FaIcon(
FontAwesomeIcons.youtube,
color: ytRed,
size: 20,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
substringBy(info.title ?? 'Video Title', 600),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
color: ytWhite,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.2,
),
),
],
),
),
],
),
),
],
),
),
);
}
}

View file

@ -0,0 +1,147 @@
// 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/mastodon.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);
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()..url = url;
final allParsers = [
// start with vendor specific to parse the vendor type
MastodonParser(document),
YoutubeParser(document, url),
TwitterParser(document, url),
JsonLdParser(document),
OpenGraphParser(document),
HtmlMetaParser(document),
OtherParser(document),
];
for (final parser in allParsers) {
try {
output.vendor ??= parser.vendor;
output.title ??= parser.title;
output.desc ??= parser.desc;
if (output.vendor == Vendor.twitterPosting) {
if (output.image == null) {
if (parser.image?.contains('/media/') ?? false) {
output.image ??= parser.image;
}
}
} else {
output.image ??= parser.image;
}
output.siteName ??= parser.siteName;
output.publishDate ??= parser.publishDate;
output.likeAction ??= parser.likeAction;
output.shareAction ??= parser.shareAction;
if (output.hasAllMetadata) break;
} catch (e) {
Log.error(e);
}
}
return output;
}

View file

@ -0,0 +1,29 @@
enum Vendor { mastodonSocialMediaPosting, youtubeVideo, twitterPosting }
mixin BaseMetaInfo {
late String url;
String? title;
String? desc;
String? image;
String? siteName;
Vendor? vendor;
DateTime? publishDate;
int? likeAction; // https://schema.org/LikeAction
int? shareAction; // https://schema.org/ShareAction
/// 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');
}
/// Container class for Metadata.
class Metadata with BaseMetaInfo {
Metadata();
bool get hasAllMetadata {
return title != null && desc != null && image != null;
}
}

View file

@ -0,0 +1,37 @@
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');
}

View file

@ -0,0 +1,101 @@
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) {
_parseToJson(document);
}
Document? document;
Map<String, dynamic>? _jsonData;
void _parseToJson(Document? document) {
try {
final data = document?.head
?.querySelector("script[type='application/ld+json']")
?.innerHtml;
if (data == null) return;
// For multiline json file
// Replacing all new line characters with empty space
// before performing json decode on data
_jsonData =
jsonDecode(data.replaceAll('\n', ' ')) as Map<String, dynamic>;
// ignore: empty_catches
} catch (e) {}
}
/// 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;
}
@override
int? get shareAction {
final statistics = _jsonData?['interactionStatistic'] as List<dynamic>?;
if (statistics != null) {
for (final statsDy in statistics) {
final stats = statsDy as Map<String, dynamic>?;
if (stats != null) {
if (stats['interactionType'] == 'https://schema.org/ShareAction') {
return stats['userInteractionCount'] as int?;
}
}
}
}
return null;
}
@override
int? get likeAction {
final statistics = _jsonData?['interactionStatistic'] as List<dynamic>?;
if (statistics != null) {
for (final statsDy in statistics) {
final stats = statsDy as Map<String, dynamic>?;
if (stats != null) {
if (stats['interactionType'] == 'https://schema.org/LikeAction') {
return stats['userInteractionCount'] as int?;
}
}
}
}
return null;
}
@override
String? get desc {
return _jsonData?['description'] as String?;
}
/// Get the [Metadata.image] from the first <img> tag in the body.
@override
String? get image {
final data = _jsonData;
return _imgResultToStr(
data?.getDynamic('logo') ?? data?.getDynamic('image'),
);
}
/// 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) {
final tmp = result.first;
if (tmp is String) return tmp;
}
if (result is String) return result;
return null;
}
}

View file

@ -0,0 +1,15 @@
import 'package:html/dom.dart';
import 'package:twonly/src/views/camera/share_image_editor/layers/link_preview/parser/base.dart';
class MastodonParser with BaseMetaInfo {
MastodonParser(this._document);
final Document? _document;
@override
Vendor? get vendor => ((_document?.head?.innerHtml
.contains('"repository":"mastodon/mastodon"') ??
false) &&
(_document?.head?.innerHtml.contains('SocialMediaPosting') ?? false))
? Vendor.mastodonSocialMediaPosting
: null;
}

View file

@ -0,0 +1,28 @@
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');
}

View file

@ -0,0 +1,27 @@
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);
final Document? _document;
@override
String? get title =>
getProperty(_document, attribute: 'name', property: 'title');
@override
String? get desc =>
getProperty(_document, attribute: 'name', property: 'description');
@override
String? get image =>
getProperty(_document, attribute: 'name', property: 'image');
@override
String? get siteName =>
getProperty(_document, attribute: 'name', property: 'site_name');
}

View file

@ -0,0 +1,35 @@
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/util.dart';
/// Parses [Metadata] from `<meta property='twitter:*'>` tags.
class TwitterParser with BaseMetaInfo {
TwitterParser(this._document, this._url);
final Document? _document;
final String _url;
@override
String? get title =>
getProperty(_document, attribute: 'name', property: 'twitter:title') ??
getProperty(_document, property: 'twitter:title');
@override
String? get desc =>
getProperty(
_document,
attribute: 'name',
property: 'twitter:description',
) ??
getProperty(_document, property: 'twitter:description');
@override
String? get image =>
getProperty(_document, attribute: 'name', property: 'twitter:image') ??
getProperty(_document, property: 'twitter:image');
@override
Vendor? get vendor =>
_url.startsWith('https://x.com/') && _url.contains('/status/')
? Vendor.twitterPosting
: null;
}

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,78 @@
import 'dart:convert';
import 'package:html/dom.dart';
import 'base.dart';
import 'util.dart';
class YoutubeParser with BaseMetaInfo {
YoutubeParser(this.document, this._url) {
_jsonData = _parseToJson(document);
}
final String _url;
Document? document;
dynamic _jsonData;
dynamic _parseToJson(Document? document) {
try {
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;
} catch (e) {
return null;
}
}
@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;
}
@override
String? get image {
final data = _jsonData;
if (data is List<Map<String, dynamic>> && 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
Vendor? get vendor => (Uri.parse(_url).host.contains('youtube.com'))
? Vendor.youtubeVideo
: null;
String? _imgResultToStr(dynamic result) {
if (result is List && result.isNotEmpty) {
final tmp = result.first;
if (tmp is String) return tmp;
}
if (result is String) return result;
return null;
}
}

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

@ -5,8 +5,8 @@ import 'package:flutter/services.dart';
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/image_editor/action_button.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/share_image_editor/action_button.dart';
import 'package:twonly/src/views/camera/share_image_editor/layer_data.dart';
/// Text layer
class TextLayer extends StatefulWidget {

View file

@ -1,10 +1,11 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/views/camera/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers/background_layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers/draw_layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers/emoji_layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers/filter_layer.dart';
import 'package:twonly/src/views/camera/image_editor/layers/text_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)
class LayersViewer extends StatelessWidget {
@ -37,7 +38,10 @@ class LayersViewer extends StatelessWidget {
...layers
.where(
(layerItem) =>
layerItem is EmojiLayerData || layerItem is DrawLayerData,
layerItem is EmojiLayerData ||
layerItem is DrawLayerData ||
layerItem is LinkPreviewLayerData ||
layerItem is TextLayerData,
)
.map((layerItem) {
if (layerItem is EmojiLayerData) {
@ -52,15 +56,20 @@ class LayersViewer extends StatelessWidget {
layerData: layerItem,
onUpdate: onUpdate,
);
}
return Container();
}),
...layers.whereType<TextLayerData>().map((layerItem) {
} else if (layerItem is TextLayerData) {
return TextLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
} else if (layerItem is LinkPreviewLayerData) {
return LinkPreviewLayer(
key: layerItem.key,
layerData: layerItem,
onUpdate: onUpdate,
);
}
return Container();
}),
],
);

View file

@ -25,6 +25,7 @@ import 'package:twonly/src/views/settings/help/changelog.view.dart';
import 'package:twonly/src/views/settings/profile/profile.view.dart';
import 'package:twonly/src/views/settings/settings_main.view.dart';
import 'package:twonly/src/views/settings/subscription/subscription.view.dart';
import 'package:twonly/src/views/user_study/user_study_welcome.view.dart';
class ChatListView extends StatefulWidget {
const ChatListView({super.key});
@ -58,6 +59,23 @@ class _ChatListViewState extends State<ChatListView> {
});
});
// In case the user is already a Tester, ask him for permission.
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (gUser.subscriptionPlan == SubscriptionPlan.Tester.name &&
!gUser.askedForUserStudyPermission) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) {
return const UserStudyWelcomeView(
wasOpenedAutomatic: true,
);
},
),
);
}
final changeLog = await rootBundle.loadString('CHANGELOG.md');
final changeLogHash =
(await compute(Sha256().hash, changeLog.codeUnits)).bytes;
@ -83,6 +101,7 @@ class _ChatListViewState extends State<ChatListView> {
);
}
}
});
}
@override
@ -295,9 +314,13 @@ class _ChatListViewState extends State<ChatListView> {
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton.small(
backgroundColor: context.color.primary,
onPressed: () {
Material(
elevation: 3,
shape: const CircleBorder(),
color: context.color.primary,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () {
Navigator.push(
context,
MaterialPageRoute(
@ -307,11 +330,18 @@ class _ChatListViewState extends State<ChatListView> {
),
);
},
child: SizedBox(
width: 45,
height: 45,
child: Center(
child: FaIcon(
FontAwesomeIcons.qrcode,
color: isDarkMode(context) ? Colors.black : Colors.white,
),
),
),
),
),
const SizedBox(height: 12),
FloatingActionButton(
backgroundColor: context.color.primary,

View file

@ -9,7 +9,7 @@ import 'package:twonly/src/database/tables/messages.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart';
@ -135,6 +135,7 @@ class _UserListItem extends State<GroupListItem> {
_previewMessages.where((x) => x.type == MessageType.media).toList();
if (msgs.isNotEmpty &&
msgs.first.type == MessageType.media &&
!msgs.first.isDeletedFromSender &&
msgs.first.senderId != null &&
msgs.first.openedAt == null) {
_hasNonOpenedMediaFile = true;

View file

@ -18,7 +18,7 @@ class FriendlyMessageTime extends StatelessWidget {
padding: const EdgeInsets.only(left: 6),
child: Row(
children: [
if (message.modifiedAt != null)
if (message.modifiedAt != null && !message.isDeletedFromSender)
Padding(
padding: const EdgeInsets.only(right: 5),
child: SizedBox(

View file

@ -14,11 +14,11 @@ 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/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/camera/share_image_editor/layer_data.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/context_menu.component.dart';
import 'package:twonly/src/views/components/emoji_picker.bottom.dart';
import 'package:twonly/src/views/memories/memories_photo_slider.view.dart';
class MessageContextMenu extends StatelessWidget {

View file

@ -14,7 +14,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/services/api/messages.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/views/chats/chat_messages_components/entries/chat_audio_entry.dart';
class MessageInput extends StatefulWidget {

View file

@ -112,7 +112,7 @@ class _MessageSendStateIconState extends State<MessageSendStateIcon> {
case MessageSendState.receivedOpened:
icon = Icon(Icons.crop_square, size: 14, color: color);
if (message.content != null) {
if (isEmoji(message.content!)) {
if (isOneEmoji(message.content!)) {
icon = Text(
message.content!,
style: const TextStyle(fontSize: 12),

View file

@ -20,7 +20,8 @@ import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/notifications/background.notifications.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_send_to_view.dart';
import 'package:twonly/src/views/camera/camera_send_to.view.dart';
import 'package:twonly/src/views/chats/media_viewer_components/additional_message_content.dart';
import 'package:twonly/src/views/chats/media_viewer_components/reaction_buttons.component.dart';
import 'package:twonly/src/views/components/animate_icon.dart';
import 'package:twonly/src/views/components/loader.dart';
@ -91,8 +92,9 @@ class _MediaViewerViewState extends State<MediaViewerView> {
_noScreenshot.screenshotOn();
_subscription.cancel();
downloadStateListener?.cancel();
videoController?.dispose();
final tmp = videoController;
videoController = null;
tmp?.dispose();
super.dispose();
}
@ -493,15 +495,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
if (_showDownloadingLoader)
Center(
Widget _loader() {
return Center(
child: SizedBox(
height: 60,
width: 60,
@ -510,7 +505,17 @@ class _MediaViewerViewState extends State<MediaViewerView> {
color: context.color.primary,
),
),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: SafeArea(
child: Stack(
fit: StackFit.expand,
children: [
if (_showDownloadingLoader) _loader(),
if ((currentMedia != null || videoController != null) &&
(canBeSeenUntil == null || progress >= 0))
GestureDetector(
@ -549,9 +554,7 @@ class _MediaViewerViewState extends State<MediaViewerView> {
if (displayTwonlyPresent)
Positioned.fill(
child: GestureDetector(
onTap: () {
loadCurrentMediaFile(showTwonly: true);
},
onTap: () => loadCurrentMediaFile(showTwonly: true),
child: Column(
children: [
Expanded(
@ -575,26 +578,14 @@ class _MediaViewerViewState extends State<MediaViewerView> {
IconButton(
icon: const Icon(Icons.close, size: 30),
color: Colors.white,
onPressed: () async {
Navigator.pop(context);
},
onPressed: () => Navigator.pop(context),
),
],
),
),
if (currentMedia != null &&
currentMedia?.mediaFile.downloadState != DownloadState.ready)
const Positioned.fill(
child: Center(
child: SizedBox(
height: 60,
width: 60,
child: CircularProgressIndicator(
strokeWidth: 6,
),
),
),
),
Positioned.fill(child: _loader()),
if (canBeSeenUntil != null || progress >= 0)
Positioned(
right: 20,
@ -700,6 +691,8 @@ class _MediaViewerViewState extends State<MediaViewerView> {
),
),
),
if (currentMessage != null)
AdditionalMessageContent(currentMessage!),
if (currentMedia != null)
ReactionButtons(
show: showShortReactions,

View file

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/database/daos/contacts.dao.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/client/generated/data.pb.dart';
import 'package:url_launcher/url_launcher_string.dart';
class AdditionalMessageContent extends StatelessWidget {
const AdditionalMessageContent(this.message, {super.key});
final Message message;
@override
Widget build(BuildContext context) {
if (message.additionalMessageData == null) return Container();
try {
final data =
AdditionalMessageData.fromBuffer(message.additionalMessageData!);
switch (data.type) {
case AdditionalMessageData_Type.LINK:
if (!data.link.startsWith('http://') &&
!data.link.startsWith('https://')) {
return Container();
}
return Positioned(
bottom: 150,
right: 0,
left: 0,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FilledButton.icon(
icon: const FaIcon(FontAwesomeIcons.shareFromSquare),
onPressed: () => launchUrlString(data.link),
label: Text(
substringBy(
data.link
.replaceAll('http://', '')
.replaceAll('https://', ''),
30,
),
),
),
],
),
);
default:
}
// ignore: empty_catches
} catch (e) {}
return Container();
}
}

View file

@ -1,14 +1,15 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
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/image_editor/data/layer.dart';
import 'package:twonly/src/views/camera/image_editor/modules/all_emojis.dart';
import 'package:twonly/src/views/camera/share_image_editor/layer_data.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/emoji_picker.bottom.dart';
class ReactionButtons extends StatefulWidget {
const ReactionButtons({

File diff suppressed because one or more lines are too long

View file

@ -6,8 +6,8 @@ class BlinkWidget extends StatefulWidget {
required this.child,
required this.enabled,
super.key,
this.blinkDuration = const Duration(milliseconds: 2500),
this.interval = const Duration(milliseconds: 100),
this.blinkDuration = const Duration(milliseconds: 2000),
this.interval = const Duration(milliseconds: 300),
this.visibleOpacity = 1.0,
this.hiddenOpacity = 0.05,
});

View file

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

View file

@ -14,7 +14,7 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_preview_components/camera_preview.dart';
import 'package:twonly/src/views/camera/camera_preview_components/camera_preview_controller_view.dart';
import 'package:twonly/src/views/camera/camera_preview_components/main_camera_controller.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/chats/chat_list.view.dart';
import 'package:twonly/src/views/memories/memories.view.dart';
@ -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,
);
}
});
}
@ -172,9 +184,9 @@ class HomeViewState extends State<HomeView> {
Widget build(BuildContext context) {
return Scaffold(
body: GestureDetector(
onDoubleTap: offsetRatio == 0
? _mainCameraController.toggleSelectedCamera
: null,
onDoubleTap:
offsetRatio == 0 ? _mainCameraController.onDoubleTap : null,
onTapDown: offsetRatio == 0 ? _mainCameraController.onTapDown : null,
child: Stack(
children: <Widget>[
MainCameraPreview(mainCameraController: _mainCameraController),

View file

@ -9,7 +9,7 @@ import 'package:twonly/src/services/api/mediafiles/upload.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/camera/camera_preview_components/save_to_gallery.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/components/alert_dialog.dart';
import 'package:twonly/src/views/components/media_view_sizing.dart';
import 'package:twonly/src/views/components/video_player_wrapper.dart';
@ -105,7 +105,11 @@ class _MemoriesPhotoSliderViewState extends State<MemoriesPhotoSliderView> {
return;
}
if (orgMediaService.storedPath.existsSync()) {
orgMediaService.storedPath.copySync(newMediaService.originalPath.path);
} else if (orgMediaService.tempPath.existsSync()) {
orgMediaService.tempPath.copySync(newMediaService.originalPath.path);
}
if (!mounted) return;

Some files were not shown because too many files have changed in this diff Show more