Merge pull request #411 from twonlyapp/dev
Some checks are pending
Publish on Github / build_and_publish (push) Waiting to run

- New: Tutorial on how to use zoom. 
- New: Manage storage view.
- Improved: Media thumbnails for faster loading.
- Fix: Some message where not marked as opened.
This commit is contained in:
Tobi 2026-05-17 01:35:34 +02:00 committed by GitHub
commit 5fb51b20d7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 16645 additions and 1077 deletions

3
.gitignore vendored
View file

@ -10,6 +10,9 @@
.history
.svn/
.swiftpm/
*.sqlite
*.sqlite-shm
*.sqlite-wal
migrate_working_dir/
# IntelliJ related

View file

@ -1,5 +1,12 @@
# Changelog
## 0.2.13
- New: Tutorial on how to use zoom.
- New: Manage storage view.
- Improved: Media thumbnails for faster loading.
- Fix: Some message where not marked as opened.
## 0.2.12
- New: Automatically mark identical media as opened across all chats (Settings > Chats).

View file

@ -1,6 +1,6 @@
# twonly
<a href="https://twonly.eu" rel="some text"><img src="docs/header.png" alt="twonly, a privacy-friendly way to connect with friends through secure, spontaneous image sharing." /></a>
<a href="https://twonly.eu" rel="some text"><img src="metadata/en-US/images/featureGraphic.png" alt="twonly, a privacy-friendly way to connect with friends through secure, spontaneous image sharing." /></a>
This repository contains the complete source code of the [twonly](https://twonly.eu) app. twonly is a replacement for Snapchat, but its purpose is not to replace instant messaging apps, as there are already [many fantastic alternatives](https://www.messenger-matrix.de/messenger-matrix-en.html) out there. It was started because I liked the basic features of Snapchat, such as opening with the camera, the easy-to-use image editor, and the focus on sending fun pictures to friends. But I was annoyed by Snapchat's forced AI chat, receiving random messages to follow strangers, and not knowing how my sent images/text messages were encrypted, if at all. I am also very critical of the direction in which the US is currently moving and therefore try to avoid US providers wherever possible.

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

View file

@ -36,16 +36,18 @@ import workmanager_apple
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
override func application(_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey : Any] = [:]) -> Bool {
override func application(
_ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:]
) -> Bool {
let sharingIntent = SwiftFlutterSharingIntentPlugin.instance
if sharingIntent.hasSameSchemePrefix(url: url) {
return sharingIntent.application(app, open: url, options: options)
}
let sharingIntent = SwiftFlutterSharingIntentPlugin.instance
if sharingIntent.hasSameSchemePrefix(url: url) {
return sharingIntent.application(app, open: url, options: options)
}
// Proceed url handling for other Flutter libraries like app_links
return super.application(app, open: url, options:options)
}
// Proceed url handling for other Flutter libraries like app_links
return super.application(app, open: url, options: options)
}
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
@ -58,7 +60,8 @@ import workmanager_apple
NSLog(
"Application delegate method userNotificationCenter:didReceive:withCompletionHandler: is called with user info: %@",
response.notification.request.content.userInfo)
//...
super.userNotificationCenter(
center, didReceive: response, withCompletionHandler: completionHandler)
}
override func userNotificationCenter(

View file

@ -32,6 +32,5 @@ class AppState {
static bool isInBackgroundTask = false;
static bool allowErrorTrackingViaSentry = false;
static bool gotMessageFromServer = false;
static int latestAppVersionId = 115;
static int latestAppVersionId = 116;
}

View file

@ -1,10 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:intl/intl.dart';
import 'package:mutex/mutex.dart';
import 'package:provider/provider.dart';
import 'package:sentry_flutter/sentry_flutter.dart';
@ -15,34 +11,24 @@ import 'package:twonly/core/frb_generated.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/callbacks/callbacks.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/database/signal/signal_signed_pre_key_store.dart'
show getSignalSignedPreKeyStoreOld;
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/signal_identity.model.dart';
import 'package:twonly/src/providers/connection.provider.dart';
import 'package:twonly/src/providers/image_editor.provider.dart';
import 'package:twonly/src/providers/purchases.provider.dart';
import 'package:twonly/src/providers/settings.provider.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/api/mediafiles/media_background.api.dart';
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
import 'package:twonly/src/services/background/callback_dispatcher.background.dart';
import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/services/memories/memories.service.dart';
import 'package:twonly/src/services/migrations.service.dart';
import 'package:twonly/src/services/notifications/fcm.notifications.dart';
import 'package:twonly/src/services/notifications/setup.notifications.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/services/user_discovery.service.dart';
import 'package:twonly/src/utils/avatars.dart';
import 'package:twonly/src/utils/exclusive_access.utils.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/secure_storage.dart';
import 'package:twonly/src/utils/startup_guard.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
final _initMutex = Mutex();
@ -168,144 +154,6 @@ void main() async {
);
}
Future<void> runMigrations() async {
if (userService.currentUser.appVersion < 90) {
// BUG: Requested media files for reupload where not reuploaded because the wrong state...
await twonlyDB.mediaFilesDao.updateAllRetransmissionUploadingState();
await UserService.update((u) => u.appVersion = 90);
}
if (userService.currentUser.appVersion < 91) {
// BUG: Requested media files for reupload where not reuploaded because the wrong state...
await makeMigrationToVersion91();
await UserService.update((u) => u.appVersion = 91);
}
if (userService.currentUser.appVersion < 109) {
final contacts = await twonlyDB.contactsDao.getAllContacts();
for (final contact in contacts) {
if (contact.verified) {
await twonlyDB.keyVerificationDao.addKeyVerification(
contact.userId,
VerificationType.migratedFromOldVersion,
);
}
}
await UserService.update((u) {
u
..appVersion = 109
..skipSetupPages = true;
if (u.avatarSvg == null) {
u.currentSetupPage = SetupPages.profile.name;
} else {
u.currentSetupPage = SetupPages.shareYourFriends.name;
}
});
}
if (userService.currentUser.appVersion < 113) {
var migrationSuccess = true;
final signalIdentity = await SecureStorage.instance.read(
// ignore: deprecated_member_use_from_same_package
key: SecureStorageKeys.signalIdentity,
);
if (signalIdentity != null) {
try {
final decoded = jsonDecode(signalIdentity);
final identity = SignalIdentity.fromJson(
decoded as Map<String, dynamic>,
);
await RustKeyManager.importSignalIdentity(
identityKeyPairStructure: identity.identityKeyPairU8List,
registrationId: identity.registrationId,
signedPreKeyStore: await getSignalSignedPreKeyStoreOld(),
);
Log.info('Importing signal identiy to the rust key manager');
// Clean up old keys after successful migration
await SecureStorage.instance.delete(
// ignore: deprecated_member_use_from_same_package
key: SecureStorageKeys.signalIdentity,
);
await SecureStorage.instance.delete(
// ignore: deprecated_member_use_from_same_package
key: SecureStorageKeys.signalSignedPreKey,
);
} catch (e) {
Log.error('Failed to migrate signal identity: $e');
migrationSuccess = false;
}
}
if (migrationSuccess) {
await UserService.update((u) {
u
..appVersion = 113
..canUseLoginTokenForAuth = false
// As usernames changes where not considered in the old version force users
// to reenter there passwords.
// ignore: deprecated_member_use_from_same_package
..twonlySafeBackup?.encryptionKey = []
// ignore: deprecated_member_use_from_same_package
..twonlySafeBackup?.backupId = [];
});
}
}
if (userService.currentUser.appVersion < 114) {
final allMedia = await twonlyDB.mediaFilesDao
.select(twonlyDB.mediaFiles)
.get();
for (final media in allMedia) {
if (media.createdAtMonth == null) {
final monthStr = DateFormat('MMMM yyyy').format(media.createdAt);
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
MediaFilesCompanion(createdAtMonth: Value(monthStr)),
);
}
}
await UserService.update((u) => u.appVersion = 114);
}
if (userService.currentUser.appVersion < 115) {
var migrationSuccess = true;
try {
final rustStore = await RustKeyManager.loadSignedPrekeys();
for (final entry in rustStore.entries) {
final companion = SignalSignedPreKeyStoresCompanion(
signedPreKeyId: Value(entry.key),
signedPreKey: Value(entry.value),
);
await twonlyDB
.into(twonlyDB.signalSignedPreKeyStores)
.insert(
companion,
mode: InsertMode.insertOrReplace,
);
await RustKeyManager.removeSignedPrekey(signedPreKeyId: entry.key);
}
} catch (e) {
Log.error('Failed to migrate signed prekeys to Drift: $e');
migrationSuccess = false;
}
if (migrationSuccess) {
await UserService.update((u) => u.appVersion = 115);
}
}
if (kDebugMode) {
assert(
AppState.latestAppVersionId == 115,
'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.',
);
assert(
AppState.latestAppVersionId == userService.currentUser.appVersion,
"Migration incomplete: currentUser.appVersion (${userService.currentUser.appVersion}) does not match AppState.latestAppVersionId (${AppState.latestAppVersionId}). Ensure the user's appVersion is updated in the migration block.",
);
}
}
Future<void> postStartupTasks() async {
Log.info('Post startup started.');
unawaited(MemoriesService.prewarmCache());

View file

@ -37,6 +37,7 @@ class Routes {
'/settings/privacy/user_discovery';
static const String settingsNotification = '/settings/notification';
static const String settingsStorage = '/settings/storage_data';
static const String settingsStorageManage = '/settings/storage_data/manage';
static const String settingsStorageImport = '/settings/storage_data/import';
static const String settingsStorageExport = '/settings/storage_data/export';
static const String settingsHelp = '/settings/help';

View file

@ -114,16 +114,15 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
.get();
}
Future<List<MediaFile>> getAllNonHashedStoredMediaFiles() async {
Future<List<MediaFile>> getAllMediaFilesPendingMigration() async {
return (select(mediaFiles)..where(
(t) => t.stored.equals(true) & t.storedFileHash.isNull(),
))
.get();
}
Future<List<MediaFile>> getAllUnanalyzedStoredMediaFiles() async {
return (select(mediaFiles)..where(
(t) => t.stored.equals(true) & t.hasCropAnalyzed.equals(false),
(t) =>
t.stored.equals(true) &
(t.storedFileHash.isNull() |
t.hasCropAnalyzed.equals(false) |
(t.hasThumbnail.equals(false) &
t.type.equals(MediaType.audio.name).not()) |
t.sizeInBytes.isNull()),
))
.get();
}
@ -185,4 +184,17 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
final rows = await query.get();
return rows.map((row) => row.readTable(db.messages).messageId).toList();
}
Future<Map<MediaType, int>> getStorageStats() async {
final rows = await select(mediaFiles).get();
final stats = <MediaType, int>{};
for (final row in rows) {
final type = row.type;
final size = row.sizeInBytes ?? 0;
stats[type] = (stats[type] ?? 0) + size;
}
return stats;
}
}

View file

@ -249,41 +249,49 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
}
Future<void> handleMessagesOpened(
int contactId,
Value<int> contactId,
List<String> messageIds,
DateTime timestamp,
) async {
await batch((batch) async {
for (final messageId in messageIds) {
batch.insert(
messageActions,
MessageActionsCompanion(
messageId: Value(messageId),
contactId: Value(contactId),
type: const Value(MessageActionType.openedAt),
actionAt: Value(timestamp),
),
mode: InsertMode.insertOrReplace,
);
}
try {
await twonlyDB.batch((batch) async {
for (final messageId in messageIds) {
batch.insert(
messageActions,
MessageActionsCompanion(
messageId: Value(messageId),
contactId: contactId,
type: const Value(MessageActionType.openedAt),
actionAt: Value(timestamp),
),
mode: InsertMode.insertOrReplace,
);
}
});
} catch (e) {
Log.error(e);
}
for (final messageId in messageIds) {
for (final messageId in messageIds) {
try {
final isOpenedByAll = await haveAllMembers(
messageId,
MessageActionType.openedAt,
);
final now = clock.now();
batch.update(
twonlyDB.messages,
await (update(
messages,
)..where((tbl) => tbl.messageId.equals(messageId))).write(
MessagesCompanion(
openedAt: Value(now),
openedByAll: Value(isOpenedByAll ? now : null),
),
where: (tbl) => tbl.messageId.equals(messageId),
);
} catch (e) {
Log.error(e);
}
});
}
}
Future<void> handleMessageAckByServer(
@ -309,21 +317,27 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
String messageId,
MessageActionType action,
) async {
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (message == null) return true;
final members = await twonlyDB.groupsDao.getGroupNonLeftMembers(
message.groupId,
);
try {
final message = await twonlyDB.messagesDao
.getMessageById(messageId)
.getSingleOrNull();
if (message == null) return true;
final members = await twonlyDB.groupsDao.getGroupNonLeftMembers(
message.groupId,
);
final actions =
await (select(messageActions)..where(
(t) => t.type.equals(action.name) & t.messageId.equals(messageId),
))
.get();
final actions =
await (select(messageActions)..where(
(t) =>
t.type.equals(action.name) & t.messageId.equals(messageId),
))
.get();
return members.length == actions.length;
return members.length == actions.length;
} catch (e) {
Log.error(e);
return true;
}
}
Future<void> updateMessageId(

File diff suppressed because it is too large Load diff

View file

@ -3,14 +3,13 @@ import 'dart:convert';
import 'package:drift/drift.dart';
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/secure_storage.dart';
Future<HashMap<int, Uint8List>> getSignalSignedPreKeyStoreOld() async {
final storeSerialized = await SecureStorage.instance.read(
key: SecureStorageKeys.signalSignedPreKey,
key: 'signed_pre_key_store',
);
final store = HashMap<int, Uint8List>();
if (storeSerialized == null) {

View file

@ -69,6 +69,11 @@ class MediaFiles extends Table {
BlobColumn get storedFileHash => blob().nullable()();
BoolColumn get hasThumbnail =>
boolean().withDefault(const Constant(false))();
IntColumn get sizeInBytes => integer().nullable()();
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
TextColumn get createdAtMonth => text().nullable()();

View file

@ -81,7 +81,7 @@ class TwonlyDB extends _$TwonlyDB {
TwonlyDB.forTesting(DatabaseConnection super.connection);
@override
int get schemaVersion => 15;
int get schemaVersion => 16;
static QueryExecutor _openConnection() {
return driftDatabase(
@ -211,6 +211,13 @@ class TwonlyDB extends _$TwonlyDB {
from14To15: (m, schema) async {
await m.createTable(schema.signalSignedPreKeyStores);
},
from15To16: (m, schema) async {
await m.addColumn(
schema.mediaFiles,
schema.mediaFiles.hasThumbnail,
);
await m.addColumn(schema.mediaFiles, schema.mediaFiles.sizeInBytes);
},
)(m, from, to);
},
);

View file

@ -2810,6 +2810,32 @@ class $MediaFilesTable extends MediaFiles
type: DriftSqlType.blob,
requiredDuringInsert: false,
);
static const VerificationMeta _hasThumbnailMeta = const VerificationMeta(
'hasThumbnail',
);
@override
late final GeneratedColumn<bool> hasThumbnail = GeneratedColumn<bool>(
'has_thumbnail',
aliasedName,
false,
type: DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: GeneratedColumn.constraintIsAlways(
'CHECK ("has_thumbnail" IN (0, 1))',
),
defaultValue: const Constant(false),
);
static const VerificationMeta _sizeInBytesMeta = const VerificationMeta(
'sizeInBytes',
);
@override
late final GeneratedColumn<int> sizeInBytes = GeneratedColumn<int>(
'size_in_bytes',
aliasedName,
true,
type: DriftSqlType.int,
requiredDuringInsert: false,
);
static const VerificationMeta _createdAtMeta = const VerificationMeta(
'createdAt',
);
@ -2853,6 +2879,8 @@ class $MediaFilesTable extends MediaFiles
encryptionMac,
encryptionNonce,
storedFileHash,
hasThumbnail,
sizeInBytes,
createdAt,
createdAtMonth,
];
@ -2987,6 +3015,24 @@ class $MediaFilesTable extends MediaFiles
),
);
}
if (data.containsKey('has_thumbnail')) {
context.handle(
_hasThumbnailMeta,
hasThumbnail.isAcceptableOrUnknown(
data['has_thumbnail']!,
_hasThumbnailMeta,
),
);
}
if (data.containsKey('size_in_bytes')) {
context.handle(
_sizeInBytesMeta,
sizeInBytes.isAcceptableOrUnknown(
data['size_in_bytes']!,
_sizeInBytesMeta,
),
);
}
if (data.containsKey('created_at')) {
context.handle(
_createdAtMeta,
@ -3092,6 +3138,14 @@ class $MediaFilesTable extends MediaFiles
DriftSqlType.blob,
data['${effectivePrefix}stored_file_hash'],
),
hasThumbnail: attachedDatabase.typeMapping.read(
DriftSqlType.bool,
data['${effectivePrefix}has_thumbnail'],
)!,
sizeInBytes: attachedDatabase.typeMapping.read(
DriftSqlType.int,
data['${effectivePrefix}size_in_bytes'],
),
createdAt: attachedDatabase.typeMapping.read(
DriftSqlType.dateTime,
data['${effectivePrefix}created_at'],
@ -3147,6 +3201,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
final Uint8List? encryptionMac;
final Uint8List? encryptionNonce;
final Uint8List? storedFileHash;
final bool hasThumbnail;
final int? sizeInBytes;
final DateTime createdAt;
final String? createdAtMonth;
const MediaFile({
@ -3168,6 +3224,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
this.encryptionMac,
this.encryptionNonce,
this.storedFileHash,
required this.hasThumbnail,
this.sizeInBytes,
required this.createdAt,
this.createdAtMonth,
});
@ -3228,6 +3286,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
if (!nullToAbsent || storedFileHash != null) {
map['stored_file_hash'] = Variable<Uint8List>(storedFileHash);
}
map['has_thumbnail'] = Variable<bool>(hasThumbnail);
if (!nullToAbsent || sizeInBytes != null) {
map['size_in_bytes'] = Variable<int>(sizeInBytes);
}
map['created_at'] = Variable<DateTime>(createdAt);
if (!nullToAbsent || createdAtMonth != null) {
map['created_at_month'] = Variable<String>(createdAtMonth);
@ -3278,6 +3340,10 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
storedFileHash: storedFileHash == null && nullToAbsent
? const Value.absent()
: Value(storedFileHash),
hasThumbnail: Value(hasThumbnail),
sizeInBytes: sizeInBytes == null && nullToAbsent
? const Value.absent()
: Value(sizeInBytes),
createdAt: Value(createdAt),
createdAtMonth: createdAtMonth == null && nullToAbsent
? const Value.absent()
@ -3323,6 +3389,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
encryptionMac: serializer.fromJson<Uint8List?>(json['encryptionMac']),
encryptionNonce: serializer.fromJson<Uint8List?>(json['encryptionNonce']),
storedFileHash: serializer.fromJson<Uint8List?>(json['storedFileHash']),
hasThumbnail: serializer.fromJson<bool>(json['hasThumbnail']),
sizeInBytes: serializer.fromJson<int?>(json['sizeInBytes']),
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
createdAtMonth: serializer.fromJson<String?>(json['createdAtMonth']),
);
@ -3357,6 +3425,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
'encryptionMac': serializer.toJson<Uint8List?>(encryptionMac),
'encryptionNonce': serializer.toJson<Uint8List?>(encryptionNonce),
'storedFileHash': serializer.toJson<Uint8List?>(storedFileHash),
'hasThumbnail': serializer.toJson<bool>(hasThumbnail),
'sizeInBytes': serializer.toJson<int?>(sizeInBytes),
'createdAt': serializer.toJson<DateTime>(createdAt),
'createdAtMonth': serializer.toJson<String?>(createdAtMonth),
};
@ -3381,6 +3451,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
Value<Uint8List?> encryptionMac = const Value.absent(),
Value<Uint8List?> encryptionNonce = const Value.absent(),
Value<Uint8List?> storedFileHash = const Value.absent(),
bool? hasThumbnail,
Value<int?> sizeInBytes = const Value.absent(),
DateTime? createdAt,
Value<String?> createdAtMonth = const Value.absent(),
}) => MediaFile(
@ -3421,6 +3493,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
storedFileHash: storedFileHash.present
? storedFileHash.value
: this.storedFileHash,
hasThumbnail: hasThumbnail ?? this.hasThumbnail,
sizeInBytes: sizeInBytes.present ? sizeInBytes.value : this.sizeInBytes,
createdAt: createdAt ?? this.createdAt,
createdAtMonth: createdAtMonth.present
? createdAtMonth.value
@ -3476,6 +3550,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
storedFileHash: data.storedFileHash.present
? data.storedFileHash.value
: this.storedFileHash,
hasThumbnail: data.hasThumbnail.present
? data.hasThumbnail.value
: this.hasThumbnail,
sizeInBytes: data.sizeInBytes.present
? data.sizeInBytes.value
: this.sizeInBytes,
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
createdAtMonth: data.createdAtMonth.present
? data.createdAtMonth.value
@ -3504,6 +3584,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
..write('encryptionMac: $encryptionMac, ')
..write('encryptionNonce: $encryptionNonce, ')
..write('storedFileHash: $storedFileHash, ')
..write('hasThumbnail: $hasThumbnail, ')
..write('sizeInBytes: $sizeInBytes, ')
..write('createdAt: $createdAt, ')
..write('createdAtMonth: $createdAtMonth')
..write(')'))
@ -3511,7 +3593,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
}
@override
int get hashCode => Object.hash(
int get hashCode => Object.hashAll([
mediaId,
type,
uploadState,
@ -3530,9 +3612,11 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
$driftBlobEquality.hash(encryptionMac),
$driftBlobEquality.hash(encryptionNonce),
$driftBlobEquality.hash(storedFileHash),
hasThumbnail,
sizeInBytes,
createdAt,
createdAtMonth,
);
]);
@override
bool operator ==(Object other) =>
identical(this, other) ||
@ -3561,6 +3645,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
other.storedFileHash,
this.storedFileHash,
) &&
other.hasThumbnail == this.hasThumbnail &&
other.sizeInBytes == this.sizeInBytes &&
other.createdAt == this.createdAt &&
other.createdAtMonth == this.createdAtMonth);
}
@ -3584,6 +3670,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
final Value<Uint8List?> encryptionMac;
final Value<Uint8List?> encryptionNonce;
final Value<Uint8List?> storedFileHash;
final Value<bool> hasThumbnail;
final Value<int?> sizeInBytes;
final Value<DateTime> createdAt;
final Value<String?> createdAtMonth;
final Value<int> rowid;
@ -3606,6 +3694,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.encryptionMac = const Value.absent(),
this.encryptionNonce = const Value.absent(),
this.storedFileHash = const Value.absent(),
this.hasThumbnail = const Value.absent(),
this.sizeInBytes = const Value.absent(),
this.createdAt = const Value.absent(),
this.createdAtMonth = const Value.absent(),
this.rowid = const Value.absent(),
@ -3629,6 +3719,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
this.encryptionMac = const Value.absent(),
this.encryptionNonce = const Value.absent(),
this.storedFileHash = const Value.absent(),
this.hasThumbnail = const Value.absent(),
this.sizeInBytes = const Value.absent(),
this.createdAt = const Value.absent(),
this.createdAtMonth = const Value.absent(),
this.rowid = const Value.absent(),
@ -3653,6 +3745,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Expression<Uint8List>? encryptionMac,
Expression<Uint8List>? encryptionNonce,
Expression<Uint8List>? storedFileHash,
Expression<bool>? hasThumbnail,
Expression<int>? sizeInBytes,
Expression<DateTime>? createdAt,
Expression<String>? createdAtMonth,
Expression<int>? rowid,
@ -3680,6 +3774,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (encryptionMac != null) 'encryption_mac': encryptionMac,
if (encryptionNonce != null) 'encryption_nonce': encryptionNonce,
if (storedFileHash != null) 'stored_file_hash': storedFileHash,
if (hasThumbnail != null) 'has_thumbnail': hasThumbnail,
if (sizeInBytes != null) 'size_in_bytes': sizeInBytes,
if (createdAt != null) 'created_at': createdAt,
if (createdAtMonth != null) 'created_at_month': createdAtMonth,
if (rowid != null) 'rowid': rowid,
@ -3705,6 +3801,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
Value<Uint8List?>? encryptionMac,
Value<Uint8List?>? encryptionNonce,
Value<Uint8List?>? storedFileHash,
Value<bool>? hasThumbnail,
Value<int?>? sizeInBytes,
Value<DateTime>? createdAt,
Value<String?>? createdAtMonth,
Value<int>? rowid,
@ -3731,6 +3829,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
encryptionMac: encryptionMac ?? this.encryptionMac,
encryptionNonce: encryptionNonce ?? this.encryptionNonce,
storedFileHash: storedFileHash ?? this.storedFileHash,
hasThumbnail: hasThumbnail ?? this.hasThumbnail,
sizeInBytes: sizeInBytes ?? this.sizeInBytes,
createdAt: createdAt ?? this.createdAt,
createdAtMonth: createdAtMonth ?? this.createdAtMonth,
rowid: rowid ?? this.rowid,
@ -3810,6 +3910,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
if (storedFileHash.present) {
map['stored_file_hash'] = Variable<Uint8List>(storedFileHash.value);
}
if (hasThumbnail.present) {
map['has_thumbnail'] = Variable<bool>(hasThumbnail.value);
}
if (sizeInBytes.present) {
map['size_in_bytes'] = Variable<int>(sizeInBytes.value);
}
if (createdAt.present) {
map['created_at'] = Variable<DateTime>(createdAt.value);
}
@ -3843,6 +3949,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
..write('encryptionMac: $encryptionMac, ')
..write('encryptionNonce: $encryptionNonce, ')
..write('storedFileHash: $storedFileHash, ')
..write('hasThumbnail: $hasThumbnail, ')
..write('sizeInBytes: $sizeInBytes, ')
..write('createdAt: $createdAt, ')
..write('createdAtMonth: $createdAtMonth, ')
..write('rowid: $rowid')
@ -15344,6 +15452,8 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
Value<Uint8List?> encryptionMac,
Value<Uint8List?> encryptionNonce,
Value<Uint8List?> storedFileHash,
Value<bool> hasThumbnail,
Value<int?> sizeInBytes,
Value<DateTime> createdAt,
Value<String?> createdAtMonth,
Value<int> rowid,
@ -15368,6 +15478,8 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
Value<Uint8List?> encryptionMac,
Value<Uint8List?> encryptionNonce,
Value<Uint8List?> storedFileHash,
Value<bool> hasThumbnail,
Value<int?> sizeInBytes,
Value<DateTime> createdAt,
Value<String?> createdAtMonth,
Value<int> rowid,
@ -15499,6 +15611,16 @@ class $$MediaFilesTableFilterComposer
builder: (column) => ColumnFilters(column),
);
ColumnFilters<bool> get hasThumbnail => $composableBuilder(
column: $table.hasThumbnail,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<int> get sizeInBytes => $composableBuilder(
column: $table.sizeInBytes,
builder: (column) => ColumnFilters(column),
);
ColumnFilters<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnFilters(column),
@ -15634,6 +15756,16 @@ class $$MediaFilesTableOrderingComposer
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<bool> get hasThumbnail => $composableBuilder(
column: $table.hasThumbnail,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<int> get sizeInBytes => $composableBuilder(
column: $table.sizeInBytes,
builder: (column) => ColumnOrderings(column),
);
ColumnOrderings<DateTime> get createdAt => $composableBuilder(
column: $table.createdAt,
builder: (column) => ColumnOrderings(column),
@ -15741,6 +15873,16 @@ class $$MediaFilesTableAnnotationComposer
builder: (column) => column,
);
GeneratedColumn<bool> get hasThumbnail => $composableBuilder(
column: $table.hasThumbnail,
builder: (column) => column,
);
GeneratedColumn<int> get sizeInBytes => $composableBuilder(
column: $table.sizeInBytes,
builder: (column) => column,
);
GeneratedColumn<DateTime> get createdAt =>
$composableBuilder(column: $table.createdAt, builder: (column) => column);
@ -15821,6 +15963,8 @@ class $$MediaFilesTableTableManager
Value<Uint8List?> encryptionMac = const Value.absent(),
Value<Uint8List?> encryptionNonce = const Value.absent(),
Value<Uint8List?> storedFileHash = const Value.absent(),
Value<bool> hasThumbnail = const Value.absent(),
Value<int?> sizeInBytes = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<String?> createdAtMonth = const Value.absent(),
Value<int> rowid = const Value.absent(),
@ -15843,6 +15987,8 @@ class $$MediaFilesTableTableManager
encryptionMac: encryptionMac,
encryptionNonce: encryptionNonce,
storedFileHash: storedFileHash,
hasThumbnail: hasThumbnail,
sizeInBytes: sizeInBytes,
createdAt: createdAt,
createdAtMonth: createdAtMonth,
rowid: rowid,
@ -15867,6 +16013,8 @@ class $$MediaFilesTableTableManager
Value<Uint8List?> encryptionMac = const Value.absent(),
Value<Uint8List?> encryptionNonce = const Value.absent(),
Value<Uint8List?> storedFileHash = const Value.absent(),
Value<bool> hasThumbnail = const Value.absent(),
Value<int?> sizeInBytes = const Value.absent(),
Value<DateTime> createdAt = const Value.absent(),
Value<String?> createdAtMonth = const Value.absent(),
Value<int> rowid = const Value.absent(),
@ -15889,6 +16037,8 @@ class $$MediaFilesTableTableManager
encryptionMac: encryptionMac,
encryptionNonce: encryptionNonce,
storedFileHash: storedFileHash,
hasThumbnail: hasThumbnail,
sizeInBytes: sizeInBytes,
createdAt: createdAt,
createdAtMonth: createdAtMonth,
rowid: rowid,

View file

@ -8032,6 +8032,519 @@ i1.GeneratedColumn<i2.Uint8List> _column_243(String aliasedName) =>
type: i1.DriftSqlType.blob,
$customConstraints: 'NOT NULL',
);
final class Schema16 extends i0.VersionedSchema {
Schema16({required super.database}) : super(version: 16);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
contacts,
groups,
mediaFiles,
messages,
messageHistories,
reactions,
groupMembers,
receipts,
receivedReceipts,
signalIdentityKeyStores,
signalPreKeyStores,
signalSenderKeyStores,
signalSessionStores,
signalSignedPreKeyStores,
messageActions,
groupHistories,
keyVerifications,
verificationTokens,
userDiscoveryAnnouncedUsers,
userDiscoveryUserRelations,
userDiscoveryOtherPromotions,
userDiscoveryOwnPromotions,
userDiscoveryShares,
shortcuts,
shortcutMembers,
];
late final Shape39 contacts = Shape39(
source: i0.VersionedTable(
entityName: 'contacts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(user_id)'],
columns: [
_column_106,
_column_107,
_column_108,
_column_109,
_column_110,
_column_111,
_column_112,
_column_113,
_column_114,
_column_115,
_column_116,
_column_117,
_column_118,
_column_211,
_column_212,
_column_213,
_column_214,
_column_215,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 groups = Shape23(
source: i0.VersionedTable(
entityName: 'groups',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id)'],
columns: [
_column_119,
_column_120,
_column_121,
_column_122,
_column_123,
_column_124,
_column_125,
_column_126,
_column_127,
_column_128,
_column_129,
_column_130,
_column_131,
_column_132,
_column_133,
_column_134,
_column_118,
_column_135,
_column_136,
_column_137,
_column_138,
_column_139,
_column_140,
_column_141,
_column_142,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape51 mediaFiles = Shape51(
source: i0.VersionedTable(
entityName: 'media_files',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(media_id)'],
columns: [
_column_143,
_column_144,
_column_145,
_column_146,
_column_147,
_column_148,
_column_149,
_column_239,
_column_240,
_column_207,
_column_150,
_column_151,
_column_152,
_column_153,
_column_154,
_column_155,
_column_156,
_column_157,
_column_244,
_column_245,
_column_118,
_column_241,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 messages = Shape25(
source: i0.VersionedTable(
entityName: 'messages',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id)'],
columns: [
_column_158,
_column_159,
_column_160,
_column_144,
_column_161,
_column_162,
_column_163,
_column_164,
_column_165,
_column_153,
_column_166,
_column_167,
_column_168,
_column_169,
_column_118,
_column_170,
_column_171,
_column_172,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape26 messageHistories = Shape26(
source: i0.VersionedTable(
entityName: 'message_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [
_column_173,
_column_174,
_column_175,
_column_161,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape27 reactions = Shape27(
source: i0.VersionedTable(
entityName: 'reactions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, sender_id, emoji)'],
columns: [_column_174, _column_176, _column_177, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape38 groupMembers = Shape38(
source: i0.VersionedTable(
entityName: 'group_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_id, contact_id)'],
columns: [
_column_158,
_column_178,
_column_179,
_column_180,
_column_209,
_column_210,
_column_181,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape37 receipts = Shape37(
source: i0.VersionedTable(
entityName: 'receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [
_column_182,
_column_183,
_column_184,
_column_185,
_column_186,
_column_208,
_column_187,
_column_188,
_column_189,
_column_190,
_column_191,
_column_118,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape30 receivedReceipts = Shape30(
source: i0.VersionedTable(
entityName: 'received_receipts',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(receipt_id)'],
columns: [_column_182, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape31 signalIdentityKeyStores = Shape31(
source: i0.VersionedTable(
entityName: 'signal_identity_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_194, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape32 signalPreKeyStores = Shape32(
source: i0.VersionedTable(
entityName: 'signal_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(pre_key_id)'],
columns: [_column_195, _column_196, _column_118],
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_197, _column_198],
attachedDatabase: database,
),
alias: null,
);
late final Shape33 signalSessionStores = Shape33(
source: i0.VersionedTable(
entityName: 'signal_session_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(device_id, name)'],
columns: [_column_192, _column_193, _column_199, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape50 signalSignedPreKeyStores = Shape50(
source: i0.VersionedTable(
entityName: 'signal_signed_pre_key_stores',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(signed_pre_key_id)'],
columns: [_column_242, _column_243, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape34 messageActions = Shape34(
source: i0.VersionedTable(
entityName: 'message_actions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(message_id, contact_id, type)'],
columns: [_column_174, _column_183, _column_144, _column_200],
attachedDatabase: database,
),
alias: null,
);
late final Shape35 groupHistories = Shape35(
source: i0.VersionedTable(
entityName: 'group_histories',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(group_history_id)'],
columns: [
_column_201,
_column_158,
_column_202,
_column_203,
_column_204,
_column_205,
_column_206,
_column_144,
_column_200,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape40 keyVerifications = Shape40(
source: i0.VersionedTable(
entityName: 'key_verifications',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_216, _column_183, _column_144, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape41 verificationTokens = Shape41(
source: i0.VersionedTable(
entityName: 'verification_tokens',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_217, _column_218, _column_118],
attachedDatabase: database,
),
alias: null,
);
late final Shape42 userDiscoveryAnnouncedUsers = Shape42(
source: i0.VersionedTable(
entityName: 'user_discovery_announced_users',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id)'],
columns: [
_column_219,
_column_220,
_column_221,
_column_222,
_column_223,
_column_224,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape43 userDiscoveryUserRelations = Shape43(
source: i0.VersionedTable(
entityName: 'user_discovery_user_relations',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(announced_user_id, from_contact_id)'],
columns: [_column_225, _column_226, _column_227],
attachedDatabase: database,
),
alias: null,
);
late final Shape44 userDiscoveryOtherPromotions = Shape44(
source: i0.VersionedTable(
entityName: 'user_discovery_other_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(from_contact_id, public_id)'],
columns: [
_column_226,
_column_228,
_column_229,
_column_230,
_column_231,
_column_227,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape45 userDiscoveryOwnPromotions = Shape45(
source: i0.VersionedTable(
entityName: 'user_discovery_own_promotions',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_232, _column_183, _column_233],
attachedDatabase: database,
),
alias: null,
);
late final Shape46 userDiscoveryShares = Shape46(
source: i0.VersionedTable(
entityName: 'user_discovery_shares',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_234, _column_235, _column_175],
attachedDatabase: database,
),
alias: null,
);
late final Shape47 shortcuts = Shape47(
source: i0.VersionedTable(
entityName: 'shortcuts',
withoutRowId: false,
isStrict: false,
tableConstraints: [],
columns: [_column_173, _column_236, _column_237],
attachedDatabase: database,
),
alias: null,
);
late final Shape48 shortcutMembers = Shape48(
source: i0.VersionedTable(
entityName: 'shortcut_members',
withoutRowId: false,
isStrict: false,
tableConstraints: ['PRIMARY KEY(shortcut_id, group_id)'],
columns: [_column_238, _column_158],
attachedDatabase: database,
),
alias: null,
);
}
class Shape51 extends i0.VersionedTable {
Shape51({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get mediaId =>
columnsByName['media_id']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get type =>
columnsByName['type']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get uploadState =>
columnsByName['upload_state']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<String> get downloadState =>
columnsByName['download_state']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get requiresAuthentication =>
columnsByName['requires_authentication']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get stored =>
columnsByName['stored']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get isDraftMedia =>
columnsByName['is_draft_media']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get isFavorite =>
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get hasCropAnalyzed =>
columnsByName['has_crop_analyzed']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get preProgressingProcess =>
columnsByName['pre_progressing_process']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get reuploadRequestedBy =>
columnsByName['reupload_requested_by']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<int> get displayLimitInMilliseconds =>
columnsByName['display_limit_in_milliseconds']!
as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get removeAudio =>
columnsByName['remove_audio']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<i2.Uint8List> get downloadToken =>
columnsByName['download_token']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get encryptionKey =>
columnsByName['encryption_key']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get encryptionMac =>
columnsByName['encryption_mac']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get encryptionNonce =>
columnsByName['encryption_nonce']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<i2.Uint8List> get storedFileHash =>
columnsByName['stored_file_hash']! as i1.GeneratedColumn<i2.Uint8List>;
i1.GeneratedColumn<int> get hasThumbnail =>
columnsByName['has_thumbnail']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get sizeInBytes =>
columnsByName['size_in_bytes']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<int> get createdAt =>
columnsByName['created_at']! as i1.GeneratedColumn<int>;
i1.GeneratedColumn<String> get createdAtMonth =>
columnsByName['created_at_month']! as i1.GeneratedColumn<String>;
}
i1.GeneratedColumn<int> _column_244(String aliasedName) =>
i1.GeneratedColumn<int>(
'has_thumbnail',
aliasedName,
false,
type: i1.DriftSqlType.int,
$customConstraints: 'NOT NULL DEFAULT 0 CHECK (has_thumbnail IN (0, 1))',
defaultValue: const i1.CustomExpression('0'),
);
i1.GeneratedColumn<int> _column_245(String aliasedName) =>
i1.GeneratedColumn<int>(
'size_in_bytes',
aliasedName,
true,
type: i1.DriftSqlType.int,
$customConstraints: 'NULL',
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -8047,6 +8560,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -8120,6 +8634,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from14To15(migrator, schema);
return 15;
case 15:
final schema = Schema16(database: database);
final migrator = i1.Migrator(database, schema);
await from15To16(migrator, schema);
return 16;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -8141,6 +8660,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
required Future<void> Function(i1.Migrator m, Schema16 schema) from15To16,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -8157,5 +8677,6 @@ i1.OnUpgrade stepByStep({
from12To13: from12To13,
from13To14: from13To14,
from14To15: from14To15,
from15To16: from15To16,
),
);

View file

@ -98,16 +98,10 @@ abstract class AppLocalizations {
Locale('en'),
];
/// No description provided for @registerTitle.
///
/// In en, this message translates to:
/// **'Welcome to twonly!'**
String get registerTitle;
/// No description provided for @registerSlogan.
///
/// In en, this message translates to:
/// **'twonly, a privacy friendly way to connect with friends through secure, spontaneous image sharing'**
/// **'Stay in touch with friends privately and securely.'**
String get registerSlogan;
/// No description provided for @onboardingWelcomeTitle.
@ -179,7 +173,7 @@ abstract class AppLocalizations {
/// No description provided for @registerUsernameSlogan.
///
/// In en, this message translates to:
/// **'Please select a username so others can find you!'**
/// **'Your public username'**
String get registerUsernameSlogan;
/// No description provided for @registerUsernameDecoration.
@ -191,7 +185,7 @@ abstract class AppLocalizations {
/// No description provided for @registerUsernameLimits.
///
/// In en, this message translates to:
/// **'Your username must be at least 3 characters long.'**
/// **'At least 3 characters.'**
String get registerUsernameLimits;
/// No description provided for @registerProofOfWorkFailed.
@ -542,6 +536,36 @@ abstract class AppLocalizations {
/// **'When using WI-FI'**
String get settingsStorageDataAutoDownWifi;
/// No description provided for @settingsStorageManageTitle.
///
/// In en, this message translates to:
/// **'Manage storage'**
String get settingsStorageManageTitle;
/// No description provided for @settingsStorageUsed.
///
/// In en, this message translates to:
/// **'Storage used'**
String get settingsStorageUsed;
/// No description provided for @settingsStorageImages.
///
/// In en, this message translates to:
/// **'Images'**
String get settingsStorageImages;
/// No description provided for @settingsStorageVideos.
///
/// In en, this message translates to:
/// **'Videos'**
String get settingsStorageVideos;
/// No description provided for @settingsStorageGifs.
///
/// In en, this message translates to:
/// **'GIFs'**
String get settingsStorageGifs;
/// No description provided for @settingsProfileCustomizeAvatar.
///
/// In en, this message translates to:
@ -1553,15 +1577,9 @@ abstract class AppLocalizations {
/// No description provided for @twonlySafeRecoverTitle.
///
/// In en, this message translates to:
/// **'Recovery'**
/// **'Restore backup'**
String get twonlySafeRecoverTitle;
/// No description provided for @twonlySafeRecoverDesc.
///
/// In en, this message translates to:
/// **'If you have created a backup with twonly Backup, you can restore it here.'**
String get twonlySafeRecoverDesc;
/// No description provided for @twonlySafeRecoverBtn.
///
/// In en, this message translates to:
@ -3188,12 +3206,6 @@ abstract class AppLocalizations {
/// **'Emoji already used or invalid'**
String get errorEmojiUsedOrInvalid;
/// No description provided for @subscriptionPledgeTitle.
///
/// In en, this message translates to:
/// **'Support independent privacy.'**
String get subscriptionPledgeTitle;
/// No description provided for @subscriptionPledgeSecureTitle.
///
/// In en, this message translates to:
@ -3218,17 +3230,17 @@ abstract class AppLocalizations {
/// **'twonly will never show advertisements or sell your private data.'**
String get subscriptionPledgeNoAdsDesc;
/// No description provided for @subscriptionPledgeFundedTitle.
/// No description provided for @subscriptionPledgeSubtitle.
///
/// In en, this message translates to:
/// **'Independent and funded by Users'**
String get subscriptionPledgeFundedTitle;
/// **'Zero ads. Total privacy.'**
String get subscriptionPledgeSubtitle;
/// No description provided for @subscriptionPledgeFundedDesc.
/// No description provided for @dragToZoom.
///
/// In en, this message translates to:
/// **'twonly is funded purely by user subscriptions to secure our independence and support the future of twonly.'**
String get subscriptionPledgeFundedDesc;
/// **'Drag to Zoom'**
String get dragToZoom;
}
class _AppLocalizationsDelegate

View file

@ -8,12 +8,9 @@ import 'app_localizations.dart';
class AppLocalizationsDe extends AppLocalizations {
AppLocalizationsDe([String locale = 'de']) : super(locale);
@override
String get registerTitle => 'Willkommen bei twonly!';
@override
String get registerSlogan =>
'twonly, eine private und sichere Möglichkeit um mit Freunden in Kontakt zu bleiben.';
'Privat und sicher mit Freunden in Kontakt bleiben.';
@override
String get onboardingWelcomeTitle => 'Willkommen bei twonly!';
@ -55,15 +52,13 @@ class AppLocalizationsDe extends AppLocalizations {
String get onboardingGetStartedTitle => 'Auf geht\'s';
@override
String get registerUsernameSlogan =>
'Bitte wähle einen Benutzernamen, damit dich andere finden können!';
String get registerUsernameSlogan => 'Dein öffentlicher Benutzername';
@override
String get registerUsernameDecoration => 'Benutzername';
@override
String get registerUsernameLimits =>
'Der Benutzername muss mindestens 3 Zeichen lang sein.';
String get registerUsernameLimits => 'Mindestens 3 Zeichen.';
@override
String get registerProofOfWorkFailed =>
@ -249,6 +244,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get settingsStorageDataAutoDownWifi => 'Bei Nutzung von WLAN';
@override
String get settingsStorageManageTitle => 'Speicher verwalten';
@override
String get settingsStorageUsed => 'Speicherplatz belegt';
@override
String get settingsStorageImages => 'Bilder';
@override
String get settingsStorageVideos => 'Videos';
@override
String get settingsStorageGifs => 'GIFs';
@override
String get settingsProfileCustomizeAvatar => 'Avatar anpassen';
@ -801,11 +811,7 @@ class AppLocalizationsDe extends AppLocalizations {
String get backupChangePassword => 'Password ändern';
@override
String get twonlySafeRecoverTitle => 'Recovery';
@override
String get twonlySafeRecoverDesc =>
'Wenn du ein Backup mit twonly Backup erstellt hast, kannst du es hier wiederherstellen.';
String get twonlySafeRecoverTitle => 'Backup wiederherstellen';
@override
String get twonlySafeRecoverBtn => 'Backup wiederherstellen';
@ -1798,9 +1804,6 @@ class AppLocalizationsDe extends AppLocalizations {
String get errorEmojiUsedOrInvalid =>
'Emoji wird bereits verwendet oder ist ungültig';
@override
String get subscriptionPledgeTitle => 'Unterstütze unabhängigen Datenschutz.';
@override
String get subscriptionPledgeSecureTitle => 'Secure by Design';
@ -1816,10 +1819,8 @@ class AppLocalizationsDe extends AppLocalizations {
'twonly wird niemals Werbung anzeigen oder deine privaten Daten verkaufen.';
@override
String get subscriptionPledgeFundedTitle =>
'Unabhängig und durch Nutzer finanziert';
String get subscriptionPledgeSubtitle => 'Keine Werbung. Volle Privatsphäre.';
@override
String get subscriptionPledgeFundedDesc =>
'twonly wird rein durch Nutzer-Abonnements finanziert, um unsere Unabhängigkeit und die Zukunft von twonly zu sichern.';
String get dragToZoom => 'Zum Zoomen ziehen';
}

View file

@ -8,12 +8,9 @@ import 'app_localizations.dart';
class AppLocalizationsEn extends AppLocalizations {
AppLocalizationsEn([String locale = 'en']) : super(locale);
@override
String get registerTitle => 'Welcome to twonly!';
@override
String get registerSlogan =>
'twonly, a privacy friendly way to connect with friends through secure, spontaneous image sharing';
'Stay in touch with friends privately and securely.';
@override
String get onboardingWelcomeTitle => 'Welcome to twonly!';
@ -54,15 +51,13 @@ class AppLocalizationsEn extends AppLocalizations {
String get onboardingGetStartedTitle => 'Let\'s go!';
@override
String get registerUsernameSlogan =>
'Please select a username so others can find you!';
String get registerUsernameSlogan => 'Your public username';
@override
String get registerUsernameDecoration => 'Username';
@override
String get registerUsernameLimits =>
'Your username must be at least 3 characters long.';
String get registerUsernameLimits => 'At least 3 characters.';
@override
String get registerProofOfWorkFailed =>
@ -245,6 +240,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get settingsStorageDataAutoDownWifi => 'When using WI-FI';
@override
String get settingsStorageManageTitle => 'Manage storage';
@override
String get settingsStorageUsed => 'Storage used';
@override
String get settingsStorageImages => 'Images';
@override
String get settingsStorageVideos => 'Videos';
@override
String get settingsStorageGifs => 'GIFs';
@override
String get settingsProfileCustomizeAvatar => 'Customize your avatar';
@ -795,11 +805,7 @@ class AppLocalizationsEn extends AppLocalizations {
String get backupChangePassword => 'Change password';
@override
String get twonlySafeRecoverTitle => 'Recovery';
@override
String get twonlySafeRecoverDesc =>
'If you have created a backup with twonly Backup, you can restore it here.';
String get twonlySafeRecoverTitle => 'Restore backup';
@override
String get twonlySafeRecoverBtn => 'Restore backup';
@ -1782,9 +1788,6 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get errorEmojiUsedOrInvalid => 'Emoji already used or invalid';
@override
String get subscriptionPledgeTitle => 'Support independent privacy.';
@override
String get subscriptionPledgeSecureTitle => 'Secure by Design';
@ -1800,9 +1803,8 @@ class AppLocalizationsEn extends AppLocalizations {
'twonly will never show advertisements or sell your private data.';
@override
String get subscriptionPledgeFundedTitle => 'Independent and funded by Users';
String get subscriptionPledgeSubtitle => 'Zero ads. Total privacy.';
@override
String get subscriptionPledgeFundedDesc =>
'twonly is funded purely by user subscriptions to secure our independence and support the future of twonly.';
String get dragToZoom => 'Drag to Zoom';
}

@ -1 +1 @@
Subproject commit f649128fd875a12f23518ff2641190cc129a9339
Subproject commit a8c5a355abf95578f1bdbf6a71077c5078b9dd93

View file

@ -165,6 +165,9 @@ class UserData {
@JsonKey(defaultValue: false)
bool skipSetupPages = false;
@JsonKey(defaultValue: false)
bool hasZoomed = false;
Map<String, dynamic> toJson() => _$UserDataToJson(this);
}

View file

@ -33,6 +33,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
..defaultShowTime = (json['defaultShowTime'] as num?)?.toInt()
..requestedAudioPermission =
json['requestedAudioPermission'] as bool? ?? false
..automaticallyMarkEqualMediaFilesAsOpened =
json['automaticallyMarkEqualMediaFilesAsOpened'] as bool? ?? false
..videoStabilizationEnabled =
json['videoStabilizationEnabled'] as bool? ?? true
..showFeedbackShortcut = json['showFeedbackShortcut'] as bool? ?? true
@ -100,7 +102,8 @@ UserData _$UserDataFromJson(Map<String, dynamic> json) =>
..lastUserStudyDataUpload = json['lastUserStudyDataUpload'] == null
? null
: DateTime.parse(json['lastUserStudyDataUpload'] as String)
..skipSetupPages = json['skipSetupPages'] as bool? ?? false;
..skipSetupPages = json['skipSetupPages'] as bool? ?? false
..hasZoomed = json['hasZoomed'] as bool? ?? false;
Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'userId': instance.userId,
@ -121,6 +124,8 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
'themeMode': _$ThemeModeEnumMap[instance.themeMode]!,
'defaultShowTime': instance.defaultShowTime,
'requestedAudioPermission': instance.requestedAudioPermission,
'automaticallyMarkEqualMediaFilesAsOpened':
instance.automaticallyMarkEqualMediaFilesAsOpened,
'videoStabilizationEnabled': instance.videoStabilizationEnabled,
'showFeedbackShortcut': instance.showFeedbackShortcut,
'showShowImagePreviewWhenSending': instance.showShowImagePreviewWhenSending,
@ -160,6 +165,7 @@ Map<String, dynamic> _$UserDataToJson(UserData instance) => <String, dynamic>{
?.toIso8601String(),
'currentSetupPage': instance.currentSetupPage,
'skipSetupPages': instance.skipSetupPages,
'hasZoomed': instance.hasZoomed,
};
const _$ThemeModeEnumMap = {

View file

@ -23,6 +23,7 @@ import 'package:twonly/src/visual/views/settings/chat/chat_settings.view.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage.view.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/export_media.view.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/import_media.view.dart';
import 'package:twonly/src/visual/views/settings/data_and_storage/manage_storage.view.dart';
import 'package:twonly/src/visual/views/settings/developer/automated_testing.view.dart';
import 'package:twonly/src/visual/views/settings/developer/developer.view.dart';
import 'package:twonly/src/visual/views/settings/developer/reduce_flames.view.dart';
@ -210,6 +211,10 @@ final routerProvider = GoRouter(
path: 'storage_data',
builder: (context, state) => const DataAndStorageView(),
routes: [
GoRoute(
path: 'manage',
builder: (context, state) => const ManageStorageView(),
),
GoRoute(
path: 'import',
builder: (context, state) => const ImportMediaView(),

View file

@ -18,7 +18,6 @@ import 'package:package_info_plus/package_info_plus.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pbserver.dart';
import 'package:twonly/src/model/protobuf/api/websocket/error.pb.dart';
@ -426,7 +425,7 @@ class ApiService {
Future<bool> tryAuthenticateWithToken() async {
final apiAuthToken = await SecureStorage.instance.read(
key: SecureStorageKeys.apiAuthToken,
key: 'api_auth_token',
);
if (apiAuthToken != null) {
@ -464,7 +463,7 @@ class ApiService {
Log.info('Switch was successfully.');
await UserService.update((u) => u.canUseLoginTokenForAuth = true);
await SecureStorage.instance.delete(
key: SecureStorageKeys.apiAuthToken,
key: 'api_auth_token',
);
}
} catch (e) {
@ -586,7 +585,7 @@ class ApiService {
final apiAuthTokenB64 = base64Encode(apiAuthToken);
await SecureStorage.instance.write(
key: SecureStorageKeys.apiAuthToken,
key: 'api_auth_token',
value: apiAuthTokenB64,
);

View file

@ -1,3 +1,4 @@
import 'package:drift/drift.dart' show Value;
import 'package:twonly/locator.dart';
import 'package:twonly/src/model/protobuf/client/generated/messages.pb.dart';
import 'package:twonly/src/services/api/utils.api.dart';
@ -14,7 +15,7 @@ Future<void> handleMessageUpdate(
);
try {
await twonlyDB.messagesDao.handleMessagesOpened(
contactId,
Value(contactId),
messageUpdate.multipleTargetMessageIds,
fromTimestamp(messageUpdate.timestamp),
);

View file

@ -4,7 +4,6 @@ import 'package:drift/drift.dart';
import 'package:fixnum/fixnum.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/protobuf/api/websocket/client_to_server.pb.dart'
@ -129,7 +128,7 @@ Future<Map<String, String>?> getAuthenticationHeader() async {
};
} else {
final apiAuthTokenRaw = await SecureStorage.instance.read(
key: SecureStorageKeys.apiAuthToken,
key: 'api_auth_token',
);
if (apiAuthTokenRaw == null) {

View file

@ -200,14 +200,24 @@ class MediaFileService {
Log.error('Could not create Thumbnail as stored media does not exists.');
return;
}
var success = false;
switch (mediaFile.type) {
case MediaType.gif:
case MediaType.audio:
success = await createThumbnailsForGif(storedPath, thumbnailPath);
case MediaType.image:
// all images are already compress..
break;
success = await createThumbnailsForImage(storedPath, thumbnailPath);
case MediaType.video:
await createThumbnailsForVideo(storedPath, thumbnailPath);
success = await createThumbnailsForVideo(storedPath, thumbnailPath);
case MediaType.audio:
break;
}
if (success) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
await updateFromDB();
}
}
@ -253,7 +263,9 @@ class MediaFileService {
tempPath.existsSync();
bool get imagePreviewAvailable =>
thumbnailPath.existsSync() || storedPath.existsSync();
mediaFile.hasThumbnail ||
thumbnailPath.existsSync() ||
storedPath.existsSync();
Future<void> storeMediaFile() async {
Log.info('Storing media file ${mediaFile.mediaId}');
@ -284,10 +296,24 @@ class MediaFileService {
);
}
unawaited(createThumbnail());
await calculateAndSaveSize();
await hashMediaFile();
// updateFromDb is done in hashStoredMedia()
}
Future<void> calculateAndSaveSize() async {
if (storedPath.existsSync()) {
final size = storedPath.lengthSync();
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
MediaFilesCompanion(
sizeInBytes: Value(size),
),
);
await updateFromDB();
}
}
Future<void> hashMediaFile() async {
late final List<int> checksum;
if (storedPath.existsSync()) {

View file

@ -1,16 +1,18 @@
import 'dart:io';
import 'dart:ui';
import 'package:flutter_image_compress/flutter_image_compress.dart';
import 'package:image/image.dart' as img;
import 'package:pro_video_editor/pro_video_editor.dart';
import 'package:twonly/src/utils/log.dart';
Future<void> createThumbnailsForVideo(
Future<bool> createThumbnailsForVideo(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
if (destinationFile.existsSync()) {
return;
return true;
}
final images = await ProVideoEditor.instance.getThumbnails(
@ -28,11 +30,83 @@ Future<void> createThumbnailsForVideo(
stopwatch.stop();
destinationFile.writeAsBytesSync(images.first);
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the thumbnail.',
'It took ${stopwatch.elapsedMilliseconds}ms to create the video thumbnail.',
);
return true;
} else {
Log.warn(
'Thumbnail creation failed for the video with exit code.',
'Thumbnail creation failed for the video.',
);
return false;
}
}
Future<bool> createThumbnailsForImage(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
try {
await FlutterImageCompress.compressAndGetFile(
sourceFile.absolute.path,
destinationFile.absolute.path,
minWidth: 300,
minHeight: 300,
quality: 100,
format: CompressFormat.webp,
);
stopwatch.stop();
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the image thumbnail.',
);
return true;
} catch (e) {
Log.error('Error creating image thumbnail: $e');
return false;
}
}
Future<bool> createThumbnailsForGif(
File sourceFile,
File destinationFile,
) async {
final stopwatch = Stopwatch()..start();
if (destinationFile.existsSync()) {
return true;
}
try {
// For GIFs, we decode the first frame and save it as WebP
final bytes = sourceFile.readAsBytesSync();
final image = img.decodeGif(bytes);
if (image == null) {
Log.error('Could not decode GIF for thumbnail.');
return false;
}
final thumbnail = img.copyResize(
image,
width: image.width > image.height ? 400 : null,
height: image.height >= image.width ? 400 : null,
);
final pngBytes = img.encodePng(thumbnail);
final webp = await FlutterImageCompress.compressWithList(
pngBytes,
format: CompressFormat.webp,
quality: 85,
);
destinationFile.writeAsBytesSync(webp);
stopwatch.stop();
Log.info(
'It took ${stopwatch.elapsedMilliseconds}ms to create the GIF thumbnail.',
);
return true;
} catch (e) {
Log.error('Error creating GIF thumbnail: $e');
return false;
}
}

View file

@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:collection';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart' show Value;
import 'package:intl/intl.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
@ -14,6 +15,7 @@ import 'package:twonly/src/utils/log.dart';
class MemoriesState {
const MemoriesState({
required this.filesToMigrate,
required this.totalFilesToMigrate,
required this.galleryItems,
required this.months,
required this.orderedByMonth,
@ -21,16 +23,21 @@ class MemoriesState {
});
final int filesToMigrate;
final int totalFilesToMigrate;
final List<MemoryItem> galleryItems;
final List<String> months;
final Map<String, List<int>> orderedByMonth;
final Map<int, List<MemoryItem>> galleryItemsLastYears;
bool get isLoading => filesToMigrate > 0;
double get migrationProgress => totalFilesToMigrate > 0
? (totalFilesToMigrate - filesToMigrate) / totalFilesToMigrate
: 0;
bool get isEmpty => galleryItems.isEmpty && filesToMigrate == 0;
MemoriesState copyWith({
int? filesToMigrate,
int? totalFilesToMigrate,
List<MemoryItem>? galleryItems,
List<String>? months,
Map<String, List<int>>? orderedByMonth,
@ -38,6 +45,7 @@ class MemoriesState {
}) {
return MemoriesState(
filesToMigrate: filesToMigrate ?? this.filesToMigrate,
totalFilesToMigrate: totalFilesToMigrate ?? this.totalFilesToMigrate,
galleryItems: galleryItems ?? this.galleryItems,
months: months ?? this.months,
orderedByMonth: orderedByMonth ?? this.orderedByMonth,
@ -62,6 +70,7 @@ class MemoriesService {
MemoriesState _currentState = const MemoriesState(
filesToMigrate: 0,
totalFilesToMigrate: 0,
galleryItems: [],
months: [],
orderedByMonth: {},
@ -88,14 +97,10 @@ class MemoriesService {
final mediaFiles = await twonlyDB.mediaFilesDao.getMediaFilesByIds(
mediaIds,
);
final mediaFileMap = {for (final m in mediaFiles) m.mediaId: m};
final allContacts = await twonlyDB.contactsDao.getAllContacts();
final contactMap = {for (final c in allContacts) c.userId: c};
final now = clock.now();
final tempGalleryItems = <MemoryItem>[];
final tempGalleryItemsLastYears = <int, List<MemoryItem>>{};
final mediaIdToSender = <String, Contact?>{};
for (final itemJson in itemList) {
final map = itemJson as Map<String, dynamic>;
@ -103,64 +108,14 @@ class MemoriesService {
final senderUserId = map['senderUserId'] as int?;
if (mediaId == null) continue;
final mediaFile = mediaFileMap[mediaId];
if (mediaFile == null) continue;
final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue;
final contact = senderUserId != null
mediaIdToSender[mediaId] = senderUserId != null
? contactMap[senderUserId]
: null;
final item = MemoryItem(
mediaService: mediaService,
messages: [],
sender: contact,
);
tempGalleryItems.add(item);
if (mediaFile.createdAt.month == now.month &&
mediaFile.createdAt.day == now.day) {
final diff = now.year - mediaFile.createdAt.year;
if (diff > 0) {
tempGalleryItemsLastYears.putIfAbsent(diff, () => []).add(item);
}
}
}
final tempOrderedByMonth = <String, List<int>>{};
final tempMonths = <String>[];
var lastMonth = '';
for (var i = 0; i < tempGalleryItems.length; i++) {
final mFile = tempGalleryItems[i].mediaService.mediaFile;
final month =
mFile.createdAtMonth ??
DateFormat('MMMM yyyy').format(mFile.createdAt);
if (lastMonth != month) {
lastMonth = month;
tempMonths.add(month);
}
tempOrderedByMonth.putIfAbsent(month, () => []).add(i);
}
for (final list in tempGalleryItemsLastYears.values) {
list.sort(
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
a.mediaService.mediaFile.createdAt,
),
);
}
final sortedGalleryItemsLastYears =
SplayTreeMap<int, List<MemoryItem>>.from(tempGalleryItemsLastYears);
_cachedState = MemoriesState(
filesToMigrate: 0,
galleryItems: tempGalleryItems,
months: tempMonths,
orderedByMonth: tempOrderedByMonth,
galleryItemsLastYears: sortedGalleryItemsLastYears,
_cachedState = _computeState(
mediaFiles: mediaFiles,
mediaIdToSender: mediaIdToSender,
);
}
} catch (e) {
@ -168,34 +123,124 @@ class MemoriesService {
}
}
static MemoriesState _computeState({
required List<MediaFile> mediaFiles,
required Map<String, Contact?> mediaIdToSender,
int filesToMigrate = 0,
}) {
final now = clock.now();
final tempGalleryItems = <MemoryItem>[];
final tempGalleryItemsLastYears = <int, List<MemoryItem>>{};
for (final mediaFile in mediaFiles) {
final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue;
final senderContact = mediaIdToSender[mediaFile.mediaId];
final item = MemoryItem(
mediaService: mediaService,
messages: [],
sender: senderContact,
);
tempGalleryItems.add(item);
if (mediaFile.createdAt.month == now.month &&
mediaFile.createdAt.day == now.day) {
final diff = now.year - mediaFile.createdAt.year;
if (diff > 0) {
tempGalleryItemsLastYears.putIfAbsent(diff, () => []).add(item);
}
}
}
// Sort descending by creation date
tempGalleryItems.sort(
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
a.mediaService.mediaFile.createdAt,
),
);
final tempOrderedByMonth = <String, List<int>>{};
final tempMonths = <String>[];
var lastMonth = '';
for (var i = 0; i < tempGalleryItems.length; i++) {
final mFile = tempGalleryItems[i].mediaService.mediaFile;
final month =
mFile.createdAtMonth ??
DateFormat('MMMM yyyy').format(mFile.createdAt);
if (lastMonth != month) {
lastMonth = month;
tempMonths.add(month);
}
tempOrderedByMonth.putIfAbsent(month, () => []).add(i);
}
for (final list in tempGalleryItemsLastYears.values) {
list.sort(
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
a.mediaService.mediaFile.createdAt,
),
);
}
final sortedGalleryItemsLastYears =
SplayTreeMap<int, List<MemoryItem>>.from(tempGalleryItemsLastYears);
return MemoriesState(
filesToMigrate: filesToMigrate,
totalFilesToMigrate: filesToMigrate, // Reset total when computing new state? No, keep existing total if migrating.
galleryItems: tempGalleryItems,
months: tempMonths,
orderedByMonth: tempOrderedByMonth,
galleryItemsLastYears: sortedGalleryItemsLastYears,
);
}
Future<void> _initAsync() async {
try {
// 1. Perform Inventory / Migration of non-hashed stored files
final nonHashedFiles = await twonlyDB.mediaFilesDao
.getAllNonHashedStoredMediaFiles();
final unanalyzedFiles = await twonlyDB.mediaFilesDao
.getAllUnanalyzedStoredMediaFiles();
final pendingFiles = await twonlyDB.mediaFilesDao
.getAllMediaFilesPendingMigration();
final totalToMigrate = nonHashedFiles.length + unanalyzedFiles.length;
if (totalToMigrate > 0) {
_updateState(filesToMigrate: totalToMigrate);
if (pendingFiles.isNotEmpty) {
_currentState = _currentState.copyWith(
filesToMigrate: pendingFiles.length,
totalFilesToMigrate: pendingFiles.length,
);
_notifyState();
for (final mediaFile in nonHashedFiles) {
for (final mediaFile in pendingFiles) {
final mediaService = MediaFileService(mediaFile);
await mediaService.hashMediaFile();
_updateState(filesToMigrate: _currentState.filesToMigrate - 1);
if (mediaService.mediaFile.storedFileHash == null) {
await mediaService.hashMediaFile();
}
if (!mediaService.mediaFile.hasCropAnalyzed) {
await mediaService.cropTransparentBorders();
}
if (mediaService.mediaFile.sizeInBytes == null) {
await mediaService.calculateAndSaveSize();
}
if (!mediaService.mediaFile.hasThumbnail) {
if (mediaService.thumbnailPath.existsSync()) {
await twonlyDB.mediaFilesDao.updateMedia(
mediaFile.mediaId,
const MediaFilesCompanion(hasThumbnail: Value(true)),
);
} else if (mediaFile.type != MediaType.audio) {
await mediaService.createThumbnail();
}
}
_updateMigrationCount(_currentState.filesToMigrate - 1);
}
for (final mediaFile in unanalyzedFiles) {
final mediaService = MediaFileService(mediaFile);
await mediaService.cropTransparentBorders();
_updateState(filesToMigrate: _currentState.filesToMigrate - 1);
}
_updateState(filesToMigrate: 0);
_updateMigrationCount(0);
}
// 2. Subscribe to stored media files stream
await _dbSubscription?.cancel();
_dbSubscription = twonlyDB.mediaFilesDao
.watchAllStoredMediaFiles()
@ -207,11 +252,6 @@ class MemoriesService {
Future<void> _processMediaFilesStream(List<MediaFile> mediaFiles) async {
try {
final now = clock.now();
final tempGalleryItems = <MemoryItem>[];
final tempGalleryItemsLastYears = <int, List<MemoryItem>>{};
// High-performance batch DB fetch for sender attribution via Messages table mapping
final mediaIds = mediaFiles.map((m) => m.mediaId).toList();
final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds(
mediaIds,
@ -230,82 +270,24 @@ class MemoriesService {
}
}
for (final mediaFile in mediaFiles) {
final mediaService = MediaFileService(mediaFile);
if (!mediaService.imagePreviewAvailable) continue;
if (mediaService.mediaFile.type == MediaType.video) {
if (!mediaService.thumbnailPath.existsSync()) {
unawaited(mediaService.createThumbnail());
}
}
final senderContact = mediaIdToSenderContact[mediaFile.mediaId];
final item = MemoryItem(
mediaService: mediaService,
messages: [],
sender: senderContact,
);
tempGalleryItems.add(item);
if (mediaFile.createdAt.month == now.month &&
mediaFile.createdAt.day == now.day) {
final diff = now.year - mediaFile.createdAt.year;
if (diff > 0) {
tempGalleryItemsLastYears.putIfAbsent(diff, () => []).add(item);
}
}
}
// Sort descending by creation date
tempGalleryItems.sort(
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
a.mediaService.mediaFile.createdAt,
),
);
final tempOrderedByMonth = <String, List<int>>{};
final tempMonths = <String>[];
var lastMonth = '';
// High performance grouping leveraging pre-computed createdAtMonth column
for (var i = 0; i < tempGalleryItems.length; i++) {
final mFile = tempGalleryItems[i].mediaService.mediaFile;
final month =
mFile.createdAtMonth ??
DateFormat('MMMM yyyy').format(mFile.createdAt);
if (lastMonth != month) {
lastMonth = month;
tempMonths.add(month);
}
tempOrderedByMonth.putIfAbsent(month, () => []).add(i);
}
for (final list in tempGalleryItemsLastYears.values) {
list.sort(
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
a.mediaService.mediaFile.createdAt,
),
);
}
final sortedGalleryItemsLastYears =
SplayTreeMap<int, List<MemoryItem>>.from(tempGalleryItemsLastYears);
final newState = MemoriesState(
final newState = _computeState(
mediaFiles: mediaFiles,
mediaIdToSender: mediaIdToSenderContact,
filesToMigrate: _currentState.filesToMigrate,
galleryItems: tempGalleryItems,
months: tempMonths,
orderedByMonth: tempOrderedByMonth,
galleryItemsLastYears: sortedGalleryItemsLastYears,
);
).copyWith(totalFilesToMigrate: _currentState.totalFilesToMigrate);
for (final item in newState.galleryItems) {
if (!item.mediaService.mediaFile.hasThumbnail &&
item.mediaService.mediaFile.type != MediaType.audio) {
unawaited(item.mediaService.createThumbnail());
}
}
_cachedState = newState;
_updateStateWithObject(newState);
_updateState(newState);
// Persist to KeyValueStore cache asynchronously
final cacheList = tempGalleryItems
final cacheList = newState.galleryItems
.map(
(item) => {
'mediaId': item.mediaService.mediaFile.mediaId,
@ -319,15 +301,17 @@ class MemoriesService {
}
}
void _updateStateWithObject(MemoriesState newState) {
void _updateState(MemoriesState newState) {
_currentState = newState;
if (!_stateController.isClosed) {
_stateController.add(_currentState);
}
_notifyState();
}
void _updateState({int? filesToMigrate}) {
void _updateMigrationCount(int filesToMigrate) {
_currentState = _currentState.copyWith(filesToMigrate: filesToMigrate);
_notifyState();
}
void _notifyState() {
if (!_stateController.isClosed) {
_stateController.add(_currentState);
}

View file

@ -0,0 +1,184 @@
import 'dart:async';
import 'dart:convert';
import 'package:clock/clock.dart';
import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import 'package:intl/intl.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/database/signal/signal_signed_pre_key_store.dart'
show getSignalSignedPreKeyStoreOld;
import 'package:twonly/src/database/tables/contacts.table.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/json/signal_identity.model.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/log.dart';
import 'package:twonly/src/utils/secure_storage.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
Future<void> runMigrations() async {
if (userService.currentUser.appVersion < 90) {
// BUG: Requested media files for reupload where not reuploaded because the wrong state...
await twonlyDB.mediaFilesDao.updateAllRetransmissionUploadingState();
await UserService.update((u) => u.appVersion = 90);
}
if (userService.currentUser.appVersion < 91) {
// BUG: Requested media files for reupload where not reuploaded because the wrong state...
await makeMigrationToVersion91();
await UserService.update((u) => u.appVersion = 91);
}
if (userService.currentUser.appVersion < 109) {
final contacts = await twonlyDB.contactsDao.getAllContacts();
for (final contact in contacts) {
if (contact.verified) {
await twonlyDB.keyVerificationDao.addKeyVerification(
contact.userId,
VerificationType.migratedFromOldVersion,
);
}
}
await UserService.update((u) {
u
..appVersion = 109
..skipSetupPages = true;
if (u.avatarSvg == null) {
u.currentSetupPage = SetupPages.profile.name;
} else {
u.currentSetupPage = SetupPages.shareYourFriends.name;
}
});
}
if (userService.currentUser.appVersion < 113) {
var migrationSuccess = true;
final signalIdentity = await SecureStorage.instance.read(
// ignore: deprecated_member_use_from_same_package
key: SecureStorageKeys.signalIdentity,
);
if (signalIdentity != null) {
try {
final decoded = jsonDecode(signalIdentity);
final identity = SignalIdentity.fromJson(
decoded as Map<String, dynamic>,
);
await RustKeyManager.importSignalIdentity(
identityKeyPairStructure: identity.identityKeyPairU8List,
registrationId: identity.registrationId,
signedPreKeyStore: await getSignalSignedPreKeyStoreOld(),
);
Log.info('Importing signal identify to the rust key manager');
// Clean up old keys after successful migration
await SecureStorage.instance.delete(
// ignore: deprecated_member_use_from_same_package
key: SecureStorageKeys.signalIdentity,
);
await SecureStorage.instance.delete(
// ignore: deprecated_member_use_from_same_package
key: SecureStorageKeys.signalSignedPreKey,
);
} catch (e) {
Log.error('Failed to migrate signal identity: $e');
migrationSuccess = false;
}
}
if (migrationSuccess) {
await UserService.update((u) {
u
..appVersion = 113
..canUseLoginTokenForAuth = false
// As usernames changes where not considered in the old version force users
// to reenter there passwords.
// ignore: deprecated_member_use_from_same_package
..twonlySafeBackup?.encryptionKey = []
// ignore: deprecated_member_use_from_same_package
..twonlySafeBackup?.backupId = [];
});
}
}
if (userService.currentUser.appVersion < 114) {
final allMedia = await twonlyDB.mediaFilesDao
.select(twonlyDB.mediaFiles)
.get();
for (final media in allMedia) {
if (media.createdAtMonth == null) {
final monthStr = DateFormat('MMMM yyyy').format(media.createdAt);
await twonlyDB.mediaFilesDao.updateMedia(
media.mediaId,
MediaFilesCompanion(createdAtMonth: Value(monthStr)),
);
}
}
await UserService.update((u) => u.appVersion = 114);
}
if (userService.currentUser.appVersion < 115) {
var migrationSuccess = true;
try {
final rustStore = await RustKeyManager.loadSignedPrekeys();
for (final entry in rustStore.entries) {
final companion = SignalSignedPreKeyStoresCompanion(
signedPreKeyId: Value(entry.key),
signedPreKey: Value(entry.value),
);
await twonlyDB
.into(twonlyDB.signalSignedPreKeyStores)
.insert(
companion,
mode: InsertMode.insertOrReplace,
);
await RustKeyManager.removeSignedPrekey(signedPreKeyId: entry.key);
}
} catch (e) {
Log.error('Failed to migrate signed prekeys to Drift: $e');
migrationSuccess = false;
}
if (migrationSuccess) {
await UserService.update((u) => u.appVersion = 115);
}
}
if (userService.currentUser.appVersion < 116) {
// Because of a Bug in the handleMessagesOpened function, some messages where not marked as opened. So use the logs,
// to mark the files as opened.
final logs = await loadLogFile();
final openedMessages = logs.split(
'messages.c2c.dart:12 > Opened message [',
);
for (final opened in openedMessages) {
final messageIds = opened.split(']');
if (messageIds.isNotEmpty) {
final now = clock.now();
for (final messageId in messageIds.first.split(',')) {
await (twonlyDB.update(
twonlyDB.messages,
)..where((tbl) => tbl.messageId.equals(messageId))).write(
MessagesCompanion(
openedAt: Value(now),
openedByAll: Value(now),
),
);
}
}
}
await UserService.update((u) => u.appVersion = 116);
}
if (kDebugMode) {
assert(
AppState.latestAppVersionId == 116,
'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.',
);
assert(
AppState.latestAppVersionId == userService.currentUser.appVersion,
"Migration incomplete: currentUser.appVersion (${userService.currentUser.appVersion}) does not match AppState.latestAppVersionId (${AppState.latestAppVersionId}). Ensure the user's appVersion is updated in the migration block.",
);
}
}

View file

@ -4,7 +4,6 @@ import 'dart:convert';
import 'package:mutex/mutex.dart';
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/secure_storage.keys.dart';
import 'package:twonly/src/model/json/userdata.model.dart';
import 'package:twonly/src/utils/keyvalue.dart';
import 'package:twonly/src/utils/log.dart';
@ -38,7 +37,7 @@ class UserService {
// 2. If not found, try to load from SecureStorage (Migration path)
final userDataJson = await SecureStorage.instance.read(
key: SecureStorageKeys.userData,
key: 'userData',
);
if (userDataJson != null) {

View file

@ -0,0 +1,342 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
typedef TextLabelBuilder = Widget Function(String label);
class DraggableScrollbar extends StatefulWidget {
const DraggableScrollbar({
required this.controller,
required this.child,
this.labelBuilder,
super.key,
});
final ScrollController controller;
final Widget child;
final String? Function(double scrollOffset)? labelBuilder;
@override
State<DraggableScrollbar> createState() => _DraggableScrollbarState();
static const double labelThumbPadding = 16;
}
class _DraggableScrollbarState extends State<DraggableScrollbar>
with TickerProviderStateMixin {
final ValueNotifier<double> _thumbOffsetNotifier = ValueNotifier(0);
final ValueNotifier<double> _viewOffsetNotifier = ValueNotifier(0);
bool _isDragInProcess = false;
double _boundlessThumbOffset = 0;
late AnimationController _thumbAnimationController;
late CurvedAnimation _thumbAnimation;
late AnimationController _labelAnimationController;
late CurvedAnimation _labelAnimation;
Timer? _fadeoutTimer;
static const double thumbHeight = 60;
static const double thumbWidth = 20;
@override
void initState() {
super.initState();
_thumbAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_thumbAnimation = CurvedAnimation(
parent: _thumbAnimationController,
curve: Curves.fastOutSlowIn,
);
_labelAnimationController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_labelAnimation = CurvedAnimation(
parent: _labelAnimationController,
curve: Curves.fastOutSlowIn,
);
}
@override
void dispose() {
_thumbOffsetNotifier.dispose();
_viewOffsetNotifier.dispose();
_thumbAnimation.dispose();
_thumbAnimationController.dispose();
_labelAnimation.dispose();
_labelAnimationController.dispose();
_fadeoutTimer?.cancel();
super.dispose();
}
ScrollController get controller => widget.controller;
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (notification) {
_onScrollNotification(notification);
return false;
},
child: Stack(
children: [
RepaintBoundary(
child: widget.child,
),
// Scrollbar layer restricted to SafeArea
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
final maxThumbOffset = constraints.maxHeight - thumbHeight;
return ExcludeSemantics(
child: RepaintBoundary(
child: ValueListenableBuilder<double>(
valueListenable: _thumbOffsetNotifier,
builder: (context, thumbOffset, child) {
final isDark =
Theme.of(context).brightness == Brightness.dark;
final handleColor = isDark
? Colors.grey.shade900
: Colors.white;
final iconColor = isDark
? Colors.white70
: Colors.black54;
final label = widget.labelBuilder?.call(
_viewOffsetNotifier.value,
);
return Container(
alignment: AlignmentDirectional.topEnd,
padding: EdgeInsets.only(top: thumbOffset),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onVerticalDragStart: (_) => _onVerticalDragStart(),
onVerticalDragUpdate: (details) =>
_onVerticalDragUpdate(
details.delta.dy,
maxThumbOffset,
),
onVerticalDragEnd: (_) => _onVerticalDragEnd(),
child: SlideFadeTransition(
animation: _thumbAnimation,
child: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (label != null && _isDragInProcess)
FadeTransition(
opacity: _labelAnimation,
child: ScaleTransition(
scale: _labelAnimation,
child: Container(
margin: const EdgeInsets.only(
right: DraggableScrollbar
.labelThumbPadding,
),
padding: const EdgeInsets.symmetric(
vertical: 6,
horizontal: 12,
),
decoration: BoxDecoration(
color:
(isDark
? Colors.grey.shade900
: Colors.grey.shade200)
.withValues(alpha: 0.95),
borderRadius: BorderRadius.circular(
8,
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.2,
),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Text(
label,
style: TextStyle(
color: isDark
? Colors.white
: Colors.black,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
),
),
Container(
width: thumbWidth,
height: thumbHeight,
decoration: BoxDecoration(
color: handleColor,
border: Border.all(
color: isDark
? Colors.white10
: Colors.black12,
width: 0.5,
),
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(8),
bottomLeft: Radius.circular(8),
),
),
child: Column(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Padding(
padding: const EdgeInsets.only(
top: 4,
),
child: FaIcon(
FontAwesomeIcons.angleUp,
size: 14,
color: iconColor,
),
),
Padding(
padding: const EdgeInsets.only(
bottom: 4,
),
child: FaIcon(
FontAwesomeIcons.angleDown,
size: 14,
color: iconColor,
),
),
],
),
),
],
),
),
),
);
},
),
),
);
},
),
),
],
),
);
}
void _onScrollNotification(ScrollNotification notification) {
final scrollMetrics = notification.metrics;
if (scrollMetrics.minScrollExtent >= scrollMetrics.maxScrollExtent) return;
_viewOffsetNotifier.value = scrollMetrics.pixels;
if (!_isDragInProcess) {
if (notification is ScrollUpdateNotification) {
// Find constraints and update thumb offset
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final renderBox = context.findRenderObject() as RenderBox?;
if (renderBox == null) return;
// Subtract SafeArea top/bottom
final padding = MediaQuery.paddingOf(context);
final availableHeight = renderBox.size.height - padding.vertical;
final maxThumbOffset = availableHeight - thumbHeight;
final scrollExtent =
scrollMetrics.pixels /
scrollMetrics.maxScrollExtent *
maxThumbOffset;
_thumbOffsetNotifier.value = scrollExtent.clamp(0.0, maxThumbOffset);
});
}
if (notification is ScrollUpdateNotification ||
notification is OverscrollNotification) {
_showThumb();
_scheduleFadeout();
}
}
}
void _onVerticalDragStart() {
_boundlessThumbOffset = _thumbOffsetNotifier.value;
_labelAnimationController.forward();
_fadeoutTimer?.cancel();
_showThumb();
setState(() => _isDragInProcess = true);
}
void _onVerticalDragUpdate(double deltaY, double maxThumbOffset) {
_showThumb();
if (_isDragInProcess && maxThumbOffset > 0) {
_boundlessThumbOffset += deltaY;
_thumbOffsetNotifier.value = _boundlessThumbOffset.clamp(
0.0,
maxThumbOffset,
);
final max = controller.position.maxScrollExtent;
final scrollOffset = (_thumbOffsetNotifier.value / maxThumbOffset) * max;
controller.jumpTo(
scrollOffset.clamp(0.0, controller.position.maxScrollExtent),
);
}
}
void _onVerticalDragEnd() {
_scheduleFadeout();
setState(() => _isDragInProcess = false);
}
void _showThumb() {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
}
void _scheduleFadeout() {
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(const Duration(milliseconds: 1500), () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
}
}
class SlideFadeTransition extends StatelessWidget {
const SlideFadeTransition({
required this.animation,
required this.child,
super.key,
});
final Animation<double> animation;
final Widget child;
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) {
return Opacity(
opacity: animation.value,
child: child,
);
},
child: child,
);
}
}

View file

@ -53,8 +53,12 @@ class EmojiPickerBottom extends StatelessWidget {
config: Config(
height: 400,
locale: Localizations.localeOf(context),
checkPlatformCompatibility: false,
emojiTextStyle: TextStyle(
fontSize: 24 * (Platform.isIOS ? 1.2 : 1),
fontFamilyFallback: Platform.isAndroid
? const ['NotoColorEmoji']
: null,
),
emojiViewConfig: EmojiViewConfig(
backgroundColor: context.color.surfaceContainer,

View file

@ -1,15 +1,24 @@
import 'dart:io';
import 'package:flutter/material.dart';
final ThemeData darkTheme = ThemeData.dark().copyWith(
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: const Color(0xFF57CC99),
surface: const Color.fromARGB(255, 20, 18, 23),
surfaceContainer: const Color.fromARGB(255, 45, 41, 54),
surfaceContainerLow: const Color.fromARGB(255, 38, 34, 45),
surfaceContainerHigh: const Color.fromARGB(255, 52, 48, 62),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
final ThemeData darkTheme = () {
final base = ThemeData.dark().copyWith(
colorScheme: ColorScheme.fromSeed(
brightness: Brightness.dark,
seedColor: const Color(0xFF57CC99),
surface: const Color.fromARGB(255, 20, 18, 23),
surfaceContainer: const Color.fromARGB(255, 45, 41, 54),
surfaceContainerLow: const Color.fromARGB(255, 38, 34, 45),
surfaceContainerHigh: const Color.fromARGB(255, 52, 48, 62),
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
return base.copyWith(
textTheme: base.textTheme.apply(
fontFamily: Platform.isAndroid ? 'sans-serif' : null,
fontFamilyFallback: Platform.isAndroid ? const ['NotoColorEmoji'] : null,
),
);
}();

View file

@ -1,16 +1,25 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
const primaryColor = Color(0xFF57CC99);
final ThemeData lightTheme = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
final ThemeData lightTheme = () {
final base = ThemeData(
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
),
);
return base.copyWith(
textTheme: base.textTheme.apply(
fontFamily: Platform.isAndroid ? 'sans-serif' : null,
fontFamilyFallback: Platform.isAndroid ? const ['NotoColorEmoji'] : null,
),
);
}();
final ButtonStyle primaryColorButtonStyle = FilledButton.styleFrom(
backgroundColor: primaryColor,

View file

@ -1,6 +1,8 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/camera_preview_controller_components/zoom_tutorial_overlay.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/face_filters.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/main_camera_controller.dart';
import 'package:twonly/src/visual/views/camera/camera_preview_components/zoom_selector.dart';
@ -136,25 +138,33 @@ class CameraBottomControls extends StatelessWidget {
}
Widget _buildShutterButton() {
return GestureDetector(
onTap: onTakePicture,
key: keyTriggerButton,
child: Align(
child: Container(
height: 100,
width: 100,
clipBehavior: Clip.antiAliasWithSaveLayer,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
width: 7,
color: isVideoRecording ? Colors.red : Colors.white,
return StreamBuilder(
stream: userService.onUserUpdated,
builder: (context, snapshot) {
return ZoomTutorialOverlay(
hasZoomed: userService.currentUser.hasZoomed,
child: GestureDetector(
onTap: onTakePicture,
key: keyTriggerButton,
child: Align(
child: Container(
height: 100,
width: 100,
clipBehavior: Clip.antiAliasWithSaveLayer,
padding: const EdgeInsets.all(2),
decoration: BoxDecoration(
shape: BoxShape.circle,
border: Border.all(
width: 7,
color: isVideoRecording ? Colors.red : Colors.white,
),
),
child: mc.currentFilterType.preview,
),
),
),
child: mc.currentFilterType.preview,
),
),
);
},
);
}

View file

@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:twonly/src/utils/misc.dart';
class ZoomTutorialOverlay extends StatefulWidget {
const ZoomTutorialOverlay({
required this.child,
required this.hasZoomed,
super.key,
});
final Widget child;
final bool hasZoomed;
@override
State<ZoomTutorialOverlay> createState() => _ZoomTutorialOverlayState();
}
class _ZoomTutorialOverlayState extends State<ZoomTutorialOverlay>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _dragAnim;
late Animation<double> _opacityAnim;
late Animation<double> _scaleAnim;
late Animation<double> _textOpacityAnim;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 3500),
)..repeat();
_opacityAnim = TweenSequence<double>([
TweenSequenceItem(tween: Tween<double>(begin: 0, end: 1), weight: 10),
TweenSequenceItem(tween: ConstantTween<double>(1), weight: 70),
TweenSequenceItem(tween: Tween<double>(begin: 1, end: 0), weight: 10),
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 10),
]).animate(_controller);
_textOpacityAnim = TweenSequence<double>([
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 15),
TweenSequenceItem(tween: Tween<double>(begin: 0, end: 1), weight: 15),
TweenSequenceItem(tween: ConstantTween<double>(1), weight: 50),
TweenSequenceItem(tween: Tween<double>(begin: 1, end: 0), weight: 15),
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 5),
]).animate(_controller);
_scaleAnim = TweenSequence<double>([
TweenSequenceItem(
tween: Tween<double>(
begin: 1.2,
end: 0.85,
).chain(CurveTween(curve: Curves.easeInQuad)),
weight: 20,
),
TweenSequenceItem(tween: ConstantTween<double>(0.85), weight: 80),
]).animate(_controller);
_dragAnim = TweenSequence<double>([
TweenSequenceItem(tween: ConstantTween<double>(0), weight: 35),
TweenSequenceItem(
tween: Tween<double>(
begin: 0,
end: -75,
).chain(CurveTween(curve: Curves.easeInOutQuart)),
weight: 40,
),
TweenSequenceItem(tween: ConstantTween<double>(-75), weight: 25),
]).animate(_controller);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.hasZoomed) return widget.child;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
widget.child,
IgnorePointer(
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
Positioned(
top: _dragAnim.value - 8,
right: 60,
child: Opacity(
opacity: _textOpacityAnim.value,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
decoration: BoxDecoration(
color: Colors.black,
borderRadius: BorderRadius.circular(16),
),
child: Text(
context.lang.dragToZoom,
style: const TextStyle(
color: Colors.white,
fontSize: 12,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
),
),
),
),
Opacity(
opacity: _opacityAnim.value,
child: Transform.translate(
offset: Offset(0, _dragAnim.value),
child: Transform.scale(
scale: _scaleAnim.value,
child: Container(
width: 42,
height: 42,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.white.withValues(alpha: 0.5),
),
child: const Center(
child: FaIcon(
FontAwesomeIcons.handPointer,
size: 18,
color: Colors.white,
),
),
),
),
),
),
],
);
},
),
),
],
);
}
}

View file

@ -441,6 +441,9 @@ class _CameraPreviewViewState extends State<CameraPreviewView> {
await mc.cameraController!.setZoomLevel(
mc.selectedCameraDetails.scaleFactor,
);
if (!userService.currentUser.hasZoomed) {
await UserService.update((u) => u.hasZoomed = true);
}
}
Future<void> pickImageFromGallery() async {

View file

@ -1,3 +1,4 @@
import 'dart:io';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
@ -242,7 +243,13 @@ class _ScreenshotEmojiState extends State<ScreenshotEmoji> {
key: _boundaryKey,
child: Text(
widget.emoji,
style: const TextStyle(fontSize: 94),
style: TextStyle(
fontSize: 94,
fontFamily: Platform.isAndroid ? 'sans-serif' : null,
fontFamilyFallback: Platform.isAndroid
? const ['NotoColorEmoji']
: null,
),
),
),
),

View file

@ -101,15 +101,16 @@ class _ChatListEntryState extends State<ChatListEntry> {
setState(() {});
}
Widget? _getChatEntry(BorderRadius borderRadius, int reactionsForWidth) {
Widget? _getChatEntry(
BorderRadius borderRadius,
int reactionsForWidth,
BubbleInfo info,
) {
if (widget.message.type == MessageType.text.name) {
return ChatTextEntry(
message: widget.message,
nextMessage: widget.nextMessage,
prevMessage: widget.prevMessage,
userIdToContact: widget.userIdToContact,
borderRadius: borderRadius,
minWidth: reactionsForWidth * 43,
info: info,
);
}
@ -118,12 +119,9 @@ class _ChatListEntryState extends State<ChatListEntry> {
if (mediaService!.mediaFile.type == MediaType.audio) {
return ChatAudioEntry(
message: widget.message,
nextMessage: widget.nextMessage,
prevMessage: widget.prevMessage,
mediaService: mediaService!,
userIdToContact: widget.userIdToContact,
borderRadius: borderRadius,
minWidth: reactionsForWidth * 43,
info: info,
);
}
return ChatMediaEntry(
@ -131,7 +129,8 @@ class _ChatListEntryState extends State<ChatListEntry> {
group: widget.group,
mediaService: mediaService!,
galleryItems: widget.galleryItems,
minWidth: reactionsForWidth * 43,
borderRadius: borderRadius,
info: info,
);
}
@ -168,6 +167,15 @@ class _ChatListEntryState extends State<ChatListEntry> {
.length;
if (reactionsForWidth > 4) reactionsForWidth = 4;
final info = getBubbleInfo(
context,
widget.message,
widget.nextMessage,
widget.prevMessage,
widget.userIdToContact,
reactionsForWidth * 43.0,
);
Widget child = Stack(
// overflow: Overflow.visible,
// clipBehavior: Clip.none,
@ -176,11 +184,8 @@ class _ChatListEntryState extends State<ChatListEntry> {
if (widget.message.isDeletedFromSender)
ChatTextEntry(
message: widget.message,
nextMessage: widget.nextMessage,
prevMessage: widget.prevMessage,
userIdToContact: widget.userIdToContact,
borderRadius: borderRadius,
minWidth: reactionsForWidth * 43,
info: info,
)
else
Column(
@ -191,7 +196,7 @@ class _ChatListEntryState extends State<ChatListEntry> {
mediaService: mediaService,
borderRadius: borderRadius,
scrollToMessage: widget.scrollToMessage,
child: _getChatEntry(borderRadius, reactionsForWidth),
child: _getChatEntry(borderRadius, reactionsForWidth, info),
),
if (reactionsForWidth > 0) const SizedBox(height: 20, width: 10),
],

View file

@ -13,22 +13,16 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/message_s
class ChatAudioEntry extends StatelessWidget {
const ChatAudioEntry({
required this.message,
required this.nextMessage,
required this.mediaService,
required this.prevMessage,
required this.borderRadius,
required this.userIdToContact,
required this.minWidth,
required this.info,
super.key,
});
final Message message;
final MediaFileService mediaService;
final Message? nextMessage;
final Message? prevMessage;
final Map<int, Contact>? userIdToContact;
final BorderRadius borderRadius;
final double minWidth;
final BubbleInfo info;
@override
Widget build(BuildContext context) {
@ -36,64 +30,78 @@ class ChatAudioEntry extends StatelessWidget {
!mediaService.originalPath.existsSync()) {
return Container(); // media file was purged
}
final info = getBubbleInfo(
context,
message,
nextMessage,
prevMessage,
userIdToContact,
minWidth,
);
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
minWidth: 250,
),
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10),
decoration: BoxDecoration(
color: info.color,
borderRadius: borderRadius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (info.displayUserName != '')
Text(
info.displayUserName,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
return LayoutBuilder(
builder: (context, constraints) {
final textWidth = measureTextWidth(info.text);
const timeWidth = 60.0;
final isExpanded =
info.expanded ||
(textWidth + timeWidth + 20 > constraints.maxWidth);
final effectiveSpacerWidth =
constraints.minWidth - textWidth - timeWidth;
final spacerWidth = effectiveSpacerWidth > 0
? effectiveSpacerWidth
: 0.0;
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
minWidth: 250,
),
padding: info.padding,
decoration: BoxDecoration(
color: info.color,
borderRadius: borderRadius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (info.text != '')
Expanded(
child: BetterText(text: info.text, textColor: info.textColor),
)
else ...[
if (mediaService.mediaFile.downloadState ==
DownloadState.ready ||
mediaService.mediaFile.downloadState == null)
mediaService.tempPath.existsSync()
? InChatAudioPlayer(
path: mediaService.tempPath.path,
message: message,
)
: Container()
else
MessageSendStateIcon([message], [mediaService.mediaFile]),
],
if (info.displayTime || message.modifiedAt != null)
FriendlyMessageTime(message: message),
if (info.displayUserName != '')
Text(
info.displayUserName,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isExpanded && info.text != '')
Expanded(
child: BetterText(
text: info.text,
textColor: info.textColor,
),
)
else if (info.text != '') ...[
BetterText(text: info.text, textColor: info.textColor),
SizedBox(width: spacerWidth),
] else ...[
if (mediaService.mediaFile.downloadState ==
DownloadState.ready ||
mediaService.mediaFile.downloadState == null)
mediaService.tempPath.existsSync()
? InChatAudioPlayer(
path: mediaService.tempPath.path,
message: message,
)
: Container()
else
MessageSendStateIcon([message], [mediaService.mediaFile]),
SizedBox(width: spacerWidth),
],
if (info.displayTime || message.modifiedAt != null)
FriendlyMessageTime(message: message),
],
),
],
),
],
),
);
},
);
}
}

View file

@ -26,15 +26,17 @@ class ChatMediaEntry extends StatefulWidget {
required this.group,
required this.galleryItems,
required this.mediaService,
required this.minWidth,
required this.borderRadius,
required this.info,
super.key,
});
final Message message;
final double minWidth;
final Group group;
final List<MemoryItem> galleryItems;
final MediaFileService mediaService;
final BorderRadius borderRadius;
final BubbleInfo info;
@override
State<ChatMediaEntry> createState() => _ChatMediaEntryState();
@ -116,52 +118,34 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
context,
);
var imageBorderRadius = BorderRadius.circular(12);
var imageBorderRadius = widget.borderRadius;
Widget additionalMessageData = Container();
final addData = widget.message.additionalMessageData;
if (addData != null) {
final info = getBubbleInfo(
context,
widget.message,
null,
null,
null,
200,
);
final data = AdditionalMessageData.fromBuffer(addData);
if (data.hasLink() && widget.message.mediaStored) {
imageBorderRadius = const BorderRadius.only(
topLeft: Radius.circular(12),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(5),
bottomRight: Radius.circular(5),
imageBorderRadius = widget.borderRadius.copyWith(
bottomLeft: const Radius.circular(5),
bottomRight: const Radius.circular(5),
);
additionalMessageData = Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
padding: const EdgeInsets.only(
left: 10,
top: 6,
bottom: 6,
right: 10,
),
padding: widget.info.padding,
decoration: BoxDecoration(
color: info.color,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(5),
topRight: Radius.circular(12),
bottomLeft: Radius.circular(12),
bottomRight: Radius.circular(12),
color: widget.info.color,
borderRadius: widget.borderRadius.copyWith(
topLeft: const Radius.circular(5),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BetterText(text: data.link, textColor: info.textColor),
BetterText(text: data.link, textColor: widget.info.textColor),
],
),
);
@ -178,7 +162,12 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
onDoubleTap: onDoubleTap,
onTap: (widget.message.type == MessageType.media.name) ? onTap : null,
child: SizedBox(
width: (widget.minWidth > 150) ? widget.minWidth : 150,
width: (widget.info.minWidth > 150)
? widget.info.minWidth
: (widget.message.mediaStored &&
widget.mediaService.imagePreviewAvailable)
? 150
: null,
height:
(widget.message.mediaStored &&
widget.mediaService.imagePreviewAvailable)
@ -195,6 +184,8 @@ class _ChatMediaEntryState extends State<ChatMediaEntry> {
color: color,
galleryItems: widget.galleryItems,
canBeReopened: _canBeReopened,
borderRadius: imageBorderRadius,
info: widget.info,
),
),
),

View file

@ -8,20 +8,14 @@ import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/f
class ChatTextEntry extends StatelessWidget {
const ChatTextEntry({
required this.message,
required this.nextMessage,
required this.prevMessage,
required this.borderRadius,
required this.userIdToContact,
required this.minWidth,
required this.info,
super.key,
});
final Message message;
final Message? nextMessage;
final Message? prevMessage;
final Map<int, Contact>? userIdToContact;
final BorderRadius borderRadius;
final double minWidth;
final BubbleInfo info;
@override
Widget build(BuildContext context) {
@ -40,57 +34,66 @@ class ChatTextEntry extends StatelessWidget {
);
}
final info = getBubbleInfo(
context,
message,
nextMessage,
prevMessage,
userIdToContact,
minWidth,
);
return LayoutBuilder(
builder: (context, constraints) {
final textWidth = measureTextWidth(info.text);
const timeWidth = 60.0;
final isExpanded =
info.expanded ||
(textWidth + timeWidth + 20 > constraints.maxWidth);
final effectiveSpacerWidth =
constraints.minWidth - textWidth - timeWidth;
final spacerWidth = effectiveSpacerWidth > 0
? effectiveSpacerWidth
: 0.0;
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
minWidth: minWidth,
),
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10),
decoration: BoxDecoration(
color: info.color,
borderRadius: borderRadius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (info.displayUserName != '')
Text(
info.displayUserName,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
return Container(
constraints: BoxConstraints(
maxWidth: MediaQuery.of(context).size.width * 0.8,
minWidth: info.minWidth,
),
padding: info.padding,
decoration: BoxDecoration(
color: info.color,
borderRadius: borderRadius,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (info.expanded)
Expanded(
child: BetterText(text: info.text, textColor: info.textColor),
)
else ...[
BetterText(text: info.text, textColor: info.textColor),
SizedBox(
width: info.spacerWidth,
if (info.displayUserName != '')
Text(
info.displayUserName,
textAlign: TextAlign.left,
style: const TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
if (info.displayTime || message.modifiedAt != null)
FriendlyMessageTime(message: message),
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
if (isExpanded)
Expanded(
child: BetterText(
text: info.text,
textColor: info.textColor,
),
)
else ...[
BetterText(text: info.text, textColor: info.textColor),
SizedBox(
width: spacerWidth,
),
],
if (info.displayTime || message.modifiedAt != null)
FriendlyMessageTime(message: message),
],
),
],
),
],
),
);
},
);
}
}

View file

@ -14,6 +14,8 @@ class BubbleInfo {
late Color color;
late bool expanded;
late double spacerWidth;
late EdgeInsets padding;
late double minWidth;
}
BubbleInfo getBubbleInfo(
@ -29,7 +31,11 @@ BubbleInfo getBubbleInfo(
..textColor = Colors.white
..color = getMessageColor(message.senderId != null)
..displayTime = !combineTextMessageWithNext(message, nextMessage)
..displayUserName = '';
..displayUserName = ''
..minWidth = minWidth
..padding = message.type == MessageType.media.name
? const EdgeInsets.symmetric(horizontal: 10, vertical: 2)
: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10);
if (message.senderId != null &&
userIdToContact != null &&
@ -50,10 +56,11 @@ BubbleInfo getBubbleInfo(
info.spacerWidth = minWidth - measureTextWidth(info.text) - 53;
if (info.spacerWidth < 0) info.spacerWidth = 0;
info.expanded = false;
if (message.quotesMessageId == null) {
info.color = getMessageColor(message.senderId != null);
}
info
..expanded = false
..color = message.quotesMessageId != null
? Colors.transparent
: getMessageColor(message.senderId != null);
if (message.isDeletedFromSender) {
info
..color = context.color.surfaceBright
@ -85,17 +92,15 @@ double measureTextWidth(
}
bool combineTextMessageWithNext(Message message, Message? nextMessage) {
if (nextMessage != null && nextMessage.content != null) {
if (nextMessage != null) {
if (nextMessage.senderId == message.senderId) {
if (nextMessage.type == MessageType.text.name &&
message.type == MessageType.text.name) {
if (!EmojiAnimationComp.supported(nextMessage.content!)) {
final diff = nextMessage.createdAt
.difference(message.createdAt)
.inMinutes;
if (diff <= 1) {
return true;
}
if (nextMessage.content == null ||
!EmojiAnimationComp.supported(nextMessage.content!)) {
final diff = nextMessage.createdAt
.difference(message.createdAt)
.inMinutes;
if (diff <= 1) {
return true;
}
}
}

View file

@ -6,46 +6,49 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/utils/misc.dart';
class FriendlyMessageTime extends StatelessWidget {
const FriendlyMessageTime({required this.message, super.key});
const FriendlyMessageTime({
required this.message,
this.color,
super.key,
});
final Message message;
final Color? color;
@override
Widget build(BuildContext context) {
return Align(
alignment: AlignmentGeometry.centerRight,
child: Padding(
padding: const EdgeInsets.only(left: 6),
child: Row(
children: [
if (message.modifiedAt != null && !message.isDeletedFromSender)
Padding(
padding: const EdgeInsets.only(right: 5),
child: SizedBox(
height: 10,
child: FaIcon(
FontAwesomeIcons.pencil,
color: Colors.white.withAlpha(150),
size: 10,
),
return Padding(
padding: const EdgeInsets.only(left: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (message.modifiedAt != null && !message.isDeletedFromSender)
Padding(
padding: const EdgeInsets.only(right: 5),
child: SizedBox(
height: 10,
child: FaIcon(
FontAwesomeIcons.pencil,
color: color ?? Colors.white.withAlpha(150),
size: 10,
),
),
Text(
friendlyTime(
context,
(message.modifiedAt != null)
? message.modifiedAt!
: message.createdAt,
),
style: TextStyle(
fontSize: 10,
color: Colors.white.withAlpha(150),
decoration: TextDecoration.none,
fontWeight: FontWeight.normal,
),
),
],
),
Text(
friendlyTime(
context,
(message.modifiedAt != null)
? message.modifiedAt!
: message.createdAt,
),
style: TextStyle(
fontSize: 10,
color: color ?? Colors.white.withAlpha(150),
decoration: TextDecoration.none,
fontWeight: FontWeight.normal,
),
),
],
),
);
}

View file

@ -5,6 +5,9 @@ import 'package:twonly/locator.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/common.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/entries/friendly_message_time.comp.dart';
import 'package:twonly/src/visual/views/chats/chat_messages_components/message_send_state_icon.dart';
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
@ -17,6 +20,8 @@ class InChatMediaViewer extends StatefulWidget {
required this.color,
required this.galleryItems,
required this.canBeReopened,
required this.borderRadius,
required this.info,
super.key,
});
@ -26,6 +31,8 @@ class InChatMediaViewer extends StatefulWidget {
final List<MemoryItem> galleryItems;
final Color color;
final bool canBeReopened;
final BorderRadius borderRadius;
final BubbleInfo info;
@override
State<InChatMediaViewer> createState() => _InChatMediaViewerState();
@ -36,8 +43,9 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
int? galleryItemIndex;
StreamSubscription<Message?>? messageStream;
Timer? _timer;
late final ValueNotifier<String?> _activeMediaIdNotifier =
ValueNotifier(widget.message.mediaId);
late final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(
widget.message.mediaId,
);
@override
void initState() {
@ -46,14 +54,25 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
unawaited(initStream());
}
@override
void didUpdateWidget(InChatMediaViewer oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.message.mediaStored != oldWidget.message.mediaStored ||
widget.galleryItems != oldWidget.galleryItems) {
if (widget.message.mediaStored) {
unawaited(loadIndexAsync());
}
}
}
Future<void> loadIndexAsync() async {
if (!widget.message.mediaStored) return;
_timer?.cancel();
_timer = Timer.periodic(const Duration(milliseconds: 10), (timer) {
/// when the galleryItems are updated this widget is not reloaded
/// so using this timer as a workaround
if (loadIndex()) {
timer.cancel();
setState(() {});
if (mounted) setState(() {});
}
});
}
@ -135,21 +154,32 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
minHeight: 39,
),
decoration: BoxDecoration(
color: widget.info.color.withValues(alpha: 0.3),
border: Border.all(
color: widget.color,
color: widget.info.color.withValues(alpha: 0.4),
),
borderRadius: BorderRadius.circular(12),
borderRadius: widget.borderRadius,
),
child: Padding(
padding: EdgeInsets.symmetric(
vertical: (widget.canBeReopened) ? 5 : 10.0,
horizontal: 4,
),
child: MessageSendStateIcon(
[widget.message],
[widget.mediaService.mediaFile],
mainAxisAlignment: MainAxisAlignment.center,
canBeReopened: widget.canBeReopened,
padding: widget.info.padding,
child: Row(
children: [
MessageSendStateIcon(
[widget.message],
[widget.mediaService.mediaFile],
mainAxisAlignment: widget.message.senderId == null
? MainAxisAlignment.end
: MainAxisAlignment.start,
canBeReopened: widget.canBeReopened,
),
if (widget.info.displayTime || widget.message.modifiedAt != null)
FriendlyMessageTime(
message: widget.message,
color: isDarkMode(context)
? Colors.white.withAlpha(100)
: Colors.black.withAlpha(100),
),
],
),
),
);
@ -160,7 +190,7 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
color: Colors.transparent,
),
color: Colors.transparent,
borderRadius: BorderRadius.circular(12),
borderRadius: widget.borderRadius,
),
child: galleryItemIndex != null
? MemoriesThumbnailComp(

View file

@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
@ -305,6 +306,10 @@ class _ParticlePainter extends CustomPainter {
style: TextStyle(
fontSize: 24 * p.currentScale,
color: Colors.black.withValues(alpha: p.opacity),
fontFamily: Platform.isAndroid ? 'sans-serif' : null,
fontFamilyFallback: Platform.isAndroid
? const ['NotoColorEmoji']
: null,
),
);
textPainter

View file

@ -205,6 +205,8 @@ class _MessageInfoViewState extends State<MessageInfoView> {
Text(
'${context.lang.received}: ${friendlyDateTime(context, widget.message.ackByServer!)}',
),
if (userService.currentUser.isDeveloper)
Text('ID: ${widget.message.messageId}'),
if (messageHistory.isNotEmpty) ...[
const SizedBox(height: 10),
const Divider(),

View file

@ -40,7 +40,7 @@ class HomeViewState extends State<HomeView> {
Timer? _disableCameraTimer;
final MainCameraController _mainCameraController = MainCameraController();
final PageController _homeViewPageController = PageController(initialPage: 1);
late final PageController _homeViewPageController;
StreamSubscription<List<SharedFile>>? _intentStreamSub;
StreamSubscription<Uri>? _deepLinkSub;
@ -53,12 +53,21 @@ class HomeViewState extends State<HomeView> {
@override
void initState() {
super.initState();
var initialPage = widget.initialPage;
if (initialPage == 1 && !userService.currentUser.startWithCameraOpen) {
initialPage = 0;
}
_activePageIdx = initialPage;
_homeViewPageController = PageController(initialPage: initialPage);
_mainCameraController.setState = () {
if (mounted) setState(() {});
};
_homeViewPageIndexSub = streamHomeViewPageIndex.stream.listen((index) {
_homeViewPageController.jumpToPage(index);
if (_homeViewPageController.hasClients) {
_homeViewPageController.jumpToPage(index);
}
setState(() {
_activePageIdx = index;
});
@ -286,15 +295,14 @@ class HomeViewState extends State<HomeView> {
bottomNavigationBar: AnimatedSize(
duration: const Duration(milliseconds: 250),
curve: Curves.easeInOut,
child: _isBottomNavVisible
child: (_activePageIdx != 2 || _isBottomNavVisible)
? BottomNavigationBar(
showSelectedLabels: false,
showUnselectedLabels: false,
unselectedIconTheme: IconThemeData(
color: Theme.of(context)
.colorScheme
.inverseSurface
.withAlpha(150),
color: Theme.of(
context,
).colorScheme.inverseSurface.withAlpha(150),
),
selectedIconTheme: IconThemeData(
color: Theme.of(context).colorScheme.inverseSurface,

View file

@ -7,8 +7,8 @@ import 'package:twonly/src/model/memory_item.model.dart';
import 'package:twonly/src/services/memories/memories.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/draggable_scrollbar.comp.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
import 'package:twonly/src/visual/views/memories/components/flashback_banner.comp.dart';
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
import 'package:twonly/src/visual/views/memories/components/selection_toolbar.comp.dart';
@ -292,29 +292,6 @@ class MemoriesViewState extends State<MemoriesView> {
builder: (context, snapshot) {
final state = snapshot.data ?? _service.currentState;
if (state.isLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ThreeRotatingDots(
size: 40,
color: context.color.primary,
),
const SizedBox(height: 16),
Text(
context.lang.migrationOfMemories(state.filesToMigrate),
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
],
),
);
}
if (state.isEmpty) {
return Center(
child: Padding(
@ -371,99 +348,152 @@ class MemoriesViewState extends State<MemoriesView> {
orderedByMonth = filteredOrdered;
}
return Scrollbar(
controller: _scrollController,
thickness: 12,
radius: const Radius.circular(6),
interactive: true,
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverAppBar(
title: const Text(
'Memories',
style: TextStyle(fontWeight: FontWeight.bold),
),
floating: true,
snap: true,
elevation: 0,
backgroundColor: context.color.surface,
actions: [
IconButton(
icon: Icon(
_filterFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _filterFavoritesOnly
? Colors.redAccent
: null,
return LayoutBuilder(
builder: (context, constraints) {
return DraggableScrollbar(
controller: _scrollController,
labelBuilder: (offset) {
final state = _service.currentState;
if (state.isEmpty) return null;
// Simple heuristic to find month by offset
double currentOffset = 56;
if (state.galleryItemsLastYears.isNotEmpty) {
currentOffset += 220;
}
final screenWidth = MediaQuery.sizeOf(context).width;
final itemWidth = (screenWidth - 8) / 4;
final itemHeight = itemWidth * (16 / 9);
final rowHeight = itemHeight + 2;
for (final month in state.months) {
final indices = state.orderedByMonth[month]!;
final totalRows = (indices.length + 3) ~/ 4;
final monthHeight = 44 + (totalRows * rowHeight);
if (offset < currentOffset + monthHeight) {
return month;
}
currentOffset += monthHeight;
}
return state.months.last;
},
child: CustomScrollView(
controller: _scrollController,
physics: const BouncingScrollPhysics(),
slivers: [
SliverAppBar(
title: const Text(
'Memories',
style: TextStyle(fontWeight: FontWeight.bold),
),
onPressed: () {
setState(() {
_filterFavoritesOnly = !_filterFavoritesOnly;
});
},
tooltip: _filterFavoritesOnly
? 'Show all'
: 'Show favorites only',
floating: true,
snap: true,
elevation: 0,
backgroundColor: context.color.surface,
actions: [
if (state.isLoading)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: Center(
child: Tooltip(
message: context.lang.migrationOfMemories(
state.filesToMigrate,
),
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
value: state.migrationProgress,
strokeWidth: 2.5,
color: context.color.primary,
backgroundColor: context.color.primary
.withValues(alpha: 0.2),
),
),
),
),
),
IconButton(
icon: Icon(
_filterFavoritesOnly
? Icons.favorite
: Icons.favorite_border,
color: _filterFavoritesOnly
? Colors.redAccent
: null,
),
onPressed: () {
setState(() {
_filterFavoritesOnly = !_filterFavoritesOnly;
});
},
tooltip: _filterFavoritesOnly
? 'Show all'
: 'Show favorites only',
),
],
),
MemoriesFlashbackBannerComp(
lastYears: lastYears,
onOpenFlashback: (items, idx) =>
_openViewer(items, idx, isFlashback: true),
),
for (final month in months) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
sliver: SliverToBoxAdapter(
child: Text(
month,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
childAspectRatio: 9 / 16,
),
delegate: SliverChildBuilderDelegate(
(context, idx) {
final globalIndex = orderedByMonth[month]![idx];
final item = state.galleryItems[globalIndex];
final mediaId =
item.mediaService.mediaFile.mediaId;
final isSelected = _selectedMediaIds.contains(
mediaId,
);
return MemoriesThumbnailComp(
galleryItem: item,
index: globalIndex,
selectionMode: _selectionMode,
isSelected: isSelected,
activeMediaIdNotifier: _activeMediaIdNotifier,
onLongPress: () => _onLongPressItem(mediaId),
onTap: () => _onTapItem(mediaId, globalIndex),
);
},
childCount: orderedByMonth[month]!.length,
),
),
],
const SliverPadding(
padding: EdgeInsets.only(bottom: 32),
),
],
),
MemoriesFlashbackBannerComp(
lastYears: lastYears,
onOpenFlashback: (items, idx) =>
_openViewer(items, idx, isFlashback: true),
),
for (final month in months) ...[
SliverPadding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
sliver: SliverToBoxAdapter(
child: Text(
month,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
),
),
SliverGrid(
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 2,
crossAxisSpacing: 2,
childAspectRatio: 9 / 16,
),
delegate: SliverChildBuilderDelegate(
(context, idx) {
final globalIndex = orderedByMonth[month]![idx];
final item = state.galleryItems[globalIndex];
final mediaId = item.mediaService.mediaFile.mediaId;
final isSelected = _selectedMediaIds.contains(
mediaId,
);
return MemoriesThumbnailComp(
galleryItem: item,
index: globalIndex,
selectionMode: _selectionMode,
isSelected: isSelected,
activeMediaIdNotifier: _activeMediaIdNotifier,
onLongPress: () => _onLongPressItem(mediaId),
onTap: () => _onTapItem(mediaId, globalIndex),
);
},
childCount: orderedByMonth[month]!.length,
),
),
],
const SliverPadding(padding: EdgeInsets.only(bottom: 32)),
],
),
);
},
);
},
),

View file

@ -0,0 +1,101 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:flutter_svg/flutter_svg.dart';
class LinkLogoAnimation extends StatefulWidget {
const LinkLogoAnimation({
super.key,
this.size = 130,
this.color = Colors.white,
});
final double size;
final Color color;
@override
State<LinkLogoAnimation> createState() => _LinkLogoAnimationState();
}
class _LinkLogoAnimationState extends State<LinkLogoAnimation>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _rotation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
)..repeat(reverse: true);
_rotation =
Tween<double>(
begin: -2.0 * (math.pi / 180.0),
end: 2.0 * (math.pi / 180.0),
).animate(
CurvedAnimation(
parent: _controller,
curve: Curves.easeInOut,
),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const originalViewportSize = 640.0;
const path1 =
'M451.5 160C434.9 160 418.8 164.5 404.7 172.7C388.9 156.7 370.5 143.3 350.2 133.2C378.4 109.2 414.3 96 451.5 96C537.9 96 608 166 608 252.5C608 294 591.5 333.8 562.2 363.1L491.1 434.2C461.8 463.5 422 480 380.5 480C294.1 480 224 410 224 323.5C224 322 224 320.5 224.1 319C224.6 301.3 239.3 287.4 257 287.9C274.7 288.4 288.6 303.1 288.1 320.8C288.1 321.7 288.1 322.6 288.1 323.4C288.1 374.5 329.5 415.9 380.6 415.9C405.1 415.9 428.6 406.2 446 388.8L517.1 317.7C534.4 300.4 544.2 276.8 544.2 252.3C544.2 201.2 502.8 159.8 451.7 159.8z';
const path2 =
'M307.2 237.3C305.3 236.5 303.4 235.4 301.7 234.2C289.1 227.7 274.7 224 259.6 224C235.1 224 211.6 233.7 194.2 251.1L123.1 322.2C105.8 339.5 96 363.1 96 387.6C96 438.7 137.4 480.1 188.5 480.1C205 480.1 221.1 475.7 235.2 467.5C251 483.5 269.4 496.9 289.8 507C261.6 530.9 225.8 544.2 188.5 544.2C102.1 544.2 32 474.2 32 387.7C32 346.2 48.5 306.4 77.8 277.1L148.9 206C178.2 176.7 218 160.2 259.5 160.2C346.1 160.2 416 230.8 416 317.1C416 318.4 416 319.7 416 321C415.6 338.7 400.9 352.6 383.2 352.2C365.5 351.8 351.6 337.1 352 319.4C352 318.6 352 317.9 352 317.1C352 283.4 334 253.8 307.2 237.5z';
return SizedBox(
width: widget.size,
height: widget.size,
child: AnimatedBuilder(
animation: _rotation,
builder: (context, child) {
return Stack(
children: [
Positioned.fill(
child: Transform(
alignment: const Alignment(
(416 * 2 / originalViewportSize) - 1,
(288 * 2 / originalViewportSize) - 1,
),
transform: Matrix4.identity()..rotateZ(_rotation.value),
child: SvgPicture.string(
'<svg viewBox="0 0 640 640"><path d="$path1" fill="${_toHex(widget.color)}"/></svg>',
),
),
),
Positioned.fill(
child: Transform(
alignment: const Alignment(
(224 * 2 / originalViewportSize) - 1,
(352 * 2 / originalViewportSize) - 1,
),
transform: Matrix4.identity()..rotateZ(-_rotation.value),
child: SvgPicture.string(
'<svg viewBox="0 0 640 640"><path d="$path2" fill="${_toHex(widget.color)}"/></svg>',
),
),
),
],
);
},
),
);
}
String _toHex(Color color) {
return '#${color.toARGB32().toRadixString(16).substring(2)}';
}
}

View file

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/themes/light.dart';
class OnboardingWrapper extends StatelessWidget {
const OnboardingWrapper({
required this.children,
super.key,
});
final List<Widget> children;
@override
Widget build(BuildContext context) {
final isDark = isDarkMode(context);
final backgroundColor = isDark ? const Color(0xFF0F172A) : primaryColor;
final topBlobColor = isDark
? primaryColor.withValues(alpha: 0.15)
: Colors.white.withValues(alpha: 0.1);
final bottomBlobColor = isDark
? primaryColor.withValues(alpha: 0.08)
: Colors.black.withValues(alpha: 0.05);
return GestureDetector(
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
behavior: HitTestBehavior.opaque,
child: Scaffold(
backgroundColor: backgroundColor,
body: Stack(
children: [
Positioned(
top: -100,
right: -100,
child: Container(
width: 300,
height: 300,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: topBlobColor,
),
),
),
Positioned(
bottom: -50,
left: -50,
child: Container(
width: 200,
height: 200,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: bottomBlobColor,
),
),
),
SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: constraints.maxHeight,
),
child: IntrinsicHeight(
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: children,
),
),
),
);
},
),
),
],
),
),
);
}
}

View file

@ -5,7 +5,9 @@ import 'package:twonly/src/services/backup.service.dart';
import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/components/snackbar.dart';
import 'package:twonly/src/visual/decorations/input_text.decoration.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart';
class BackupRecoveryView extends StatefulWidget {
const BackupRecoveryView({super.key});
@ -64,66 +66,128 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('twonly Backup ${context.lang.twonlySafeRecoverTitle}'),
actions: [
IconButton(
onPressed: () async {
await showAlertDialog(
context,
'twonly Backup',
context.lang.backupTwonlySafeLongDesc,
);
},
icon: const FaIcon(FontAwesomeIcons.circleInfo),
iconSize: 18,
),
],
),
body: Padding(
padding: const EdgeInsetsGeometry.symmetric(
vertical: 40,
horizontal: 40,
),
child: ListView(
final isDark = isDarkMode(context);
final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100];
return OnboardingWrapper(
children: [
Row(
children: [
Text(
context.lang.twonlySafeRecoverDesc,
textAlign: TextAlign.center,
),
const SizedBox(height: 30),
TextField(
controller: usernameCtrl,
onChanged: (value) {
setState(() {});
},
style: const TextStyle(fontSize: 17),
decoration: getInputDecoration(
context,
context.lang.registerUsernameDecoration,
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(
Icons.arrow_back_ios_new_rounded,
),
color: Colors.white,
iconSize: 20,
),
const SizedBox(height: 10),
Stack(
children: [
TextField(
controller: passwordCtrl,
onChanged: (value) {
setState(() {});
},
style: const TextStyle(fontSize: 17),
obscureText: obscureText,
decoration: getInputDecoration(
context,
context.lang.password,
const Spacer(),
IconButton(
onPressed: () async {
await showAlertDialog(
context,
'twonly Backup',
context.lang.backupTwonlySafeLongDesc,
);
},
icon: const FaIcon(FontAwesomeIcons.circleInfo),
color: Colors.white,
iconSize: 20,
),
],
),
const SizedBox(height: 20),
const Center(
child: Padding(
padding: EdgeInsets.all(20),
child: LinkLogoAnimation(),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.lang.twonlySafeRecoverTitle,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.white,
letterSpacing: -0.5,
),
),
),
const SizedBox(height: 48),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
TextField(
controller: usernameCtrl,
onChanged: (value) => setState(() {}),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
),
filled: true,
fillColor: inputColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.alternate_email,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
Positioned(
right: 0,
top: 0,
bottom: 0,
child: IconButton(
),
const SizedBox(height: 16),
TextField(
controller: passwordCtrl,
onChanged: (value) => setState(() {}),
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
obscureText: obscureText,
decoration: InputDecoration(
hintText: context.lang.password,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
),
filled: true,
fillColor: inputColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.lock_outline_rounded,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
suffixIcon: IconButton(
onPressed: () {
setState(() {
obscureText = !obscureText;
@ -134,28 +198,46 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
? FontAwesomeIcons.eye
: FontAwesomeIcons.eyeSlash,
size: 16,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
),
],
),
const SizedBox(height: 10),
Center(
child: FilledButton.icon(
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
icon: isLoading
? const SizedBox(
height: 12,
width: 12,
child: CircularProgressIndicator(strokeWidth: 1),
)
: const Icon(Icons.lock_clock_rounded),
label: Text(context.lang.twonlySafeRecoverBtn),
),
),
],
const SizedBox(height: 32),
FilledButton(
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
child: isLoading
? const SizedBox(
height: 24,
width: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: Text(
context.lang.twonlySafeRecoverBtn,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
],
),
),
),
const Spacer(),
const SizedBox(height: 40),
],
);
}
}

View file

@ -1,6 +1,7 @@
// ignore_for_file: avoid_dynamic_calls
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
@ -16,7 +17,10 @@ import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/utils/pow.dart';
import 'package:twonly/src/utils/storage.dart';
import 'package:twonly/src/visual/components/alert.dialog.dart';
import 'package:twonly/src/visual/themes/light.dart';
import 'package:twonly/src/visual/views/groups/group.view.dart';
import 'package:twonly/src/visual/views/onboarding/components/link_logo_animation.dart';
import 'package:twonly/src/visual/views/onboarding/components/onboarding_wrapper.dart';
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
class RegisterView extends StatefulWidget {
@ -134,7 +138,7 @@ class _RegisterViewState extends State<RegisterView> {
userId: userId,
username: username,
displayName: username,
subscriptionPlan: 'Preview',
subscriptionPlan: 'Free',
currentSetupPage: SetupPages.profile.name,
)..appVersion = AppState.latestAppVersionId;
@ -146,174 +150,199 @@ class _RegisterViewState extends State<RegisterView> {
@override
Widget build(BuildContext context) {
if (_registrationDisabled) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(10),
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: ListView(
children: [
const SizedBox(height: 50),
Text(
context.lang.registerTitle,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 30),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Text(
context.lang.registerSlogan,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12),
),
),
const SizedBox(height: 130),
final isDark = isDarkMode(context);
final cardColor = isDark ? const Color(0xFF1E293B) : Colors.white;
final inputColor = isDark ? const Color(0xFF0F172A) : Colors.grey[100];
final sloganColor = isDark
? Colors.white.withValues(alpha: 0.9)
: Colors.grey[800];
final secondaryButtonColor = isDark ? Colors.grey[400] : Colors.grey[600];
return OnboardingWrapper(
children: [
const SizedBox(height: 40),
Center(
child: Container(
padding: const EdgeInsets.all(20),
child: const LinkLogoAnimation(),
),
),
const SizedBox(height: 16),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Text(
context.lang.registerSlogan,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.white.withValues(alpha: 0.9),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 48),
Container(
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: cardColor,
borderRadius: BorderRadius.circular(32),
boxShadow: [
BoxShadow(
color: isDark
? Colors.black.withValues(alpha: 0.3)
: Colors.black.withValues(alpha: 0.1),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (_registrationDisabled) ...[
const SizedBox(height: 24),
Text(
context.lang.registrationClosed,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 16,
color: Colors.red,
),
),
],
),
),
),
);
}
InputDecoration getInputDecoration(String hintText) {
return InputDecoration(hintText: hintText, fillColor: Colors.grey[400]);
}
return Scaffold(
appBar: AppBar(
title: const Text(''),
),
body: Padding(
padding: const EdgeInsets.all(10),
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: ListView(
children: [
const SizedBox(height: 50),
Text(
context.lang.registerTitle,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 30),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 30),
child: Text(
context.lang.registerSlogan,
const SizedBox(height: 48),
] else ...[
Text(
context.lang.registerUsernameSlogan,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 12),
style: TextStyle(
fontSize: 16,
color: sloganColor,
fontWeight: FontWeight.w600,
),
),
),
const SizedBox(height: 60),
Center(
child: Padding(
padding: const EdgeInsets.only(left: 10, right: 10),
child: Text(
context.lang.registerUsernameSlogan,
const SizedBox(height: 20),
TextField(
controller: usernameController,
onChanged: (value) {
usernameController.text = value.toLowerCase();
usernameController.selection = TextSelection.fromPosition(
TextPosition(
offset: usernameController.text.length,
),
);
setState(() {
_isValidUserName = usernameController.text.length >= 3;
});
},
inputFormatters: [
LengthLimitingTextInputFormatter(12),
FilteringTextInputFormatter.allow(
RegExp('[a-z0-9A-Z._]'),
),
],
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: isDark ? Colors.white : Colors.black,
),
decoration: InputDecoration(
hintText: context.lang.registerUsernameDecoration,
hintStyle: TextStyle(
color: isDark ? Colors.grey[500] : Colors.grey[600],
),
filled: true,
fillColor: inputColor,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(16),
borderSide: BorderSide.none,
),
prefixIcon: Icon(
Icons.alternate_email,
color: isDark ? Colors.grey[400] : Colors.grey[600],
),
),
),
if (_showUserNameError &&
usernameController.text.length < 3) ...[
const SizedBox(height: 8),
Text(
context.lang.registerUsernameLimits,
style: const TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 15),
),
),
),
const SizedBox(height: 15),
TextField(
controller: usernameController,
onChanged: (value) {
usernameController.text = value.toLowerCase();
usernameController.selection = TextSelection.fromPosition(
TextPosition(offset: usernameController.text.length),
);
setState(() {
_isValidUserName = usernameController.text.length >= 3;
});
},
inputFormatters: [
LengthLimitingTextInputFormatter(12),
FilteringTextInputFormatter.allow(RegExp('[a-z0-9A-Z._]')),
],
style: const TextStyle(fontSize: 17),
decoration: getInputDecoration(
context.lang.registerUsernameDecoration,
),
),
const SizedBox(height: 10),
Text(
context.lang.registerUsernameLimits,
style: TextStyle(
color: _showUserNameError ? Colors.red : Colors.transparent,
fontSize: 12,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Text(
context.lang.registerProofOfWorkFailed,
style: TextStyle(
color: _showProofOfWorkError
? Colors.red
: Colors.transparent,
fontSize: 12,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 10),
Column(
children: [
FilledButton.icon(
icon: _isTryingToRegister
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
color: Colors.black,
strokeWidth: 2,
),
)
: const Icon(Icons.group),
onPressed: createNewUser,
style: ButtonStyle(
padding: WidgetStateProperty.all<EdgeInsets>(
const EdgeInsets.symmetric(
vertical: 10,
horizontal: 30,
if (_showProofOfWorkError) ...[
const SizedBox(height: 8),
Text(
context.lang.registerProofOfWorkFailed,
style: const TextStyle(
color: Colors.red,
fontSize: 13,
fontWeight: FontWeight.w500,
),
textAlign: TextAlign.center,
),
],
const SizedBox(height: 24),
FilledButton(
onPressed: _isTryingToRegister ? null : createNewUser,
style: FilledButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(60),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
elevation: 0,
),
child: _isTryingToRegister
? const SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 3,
),
)
: Text(
context.lang.registerSubmitButton,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
),
backgroundColor: _isTryingToRegister
? WidgetStateProperty.all<MaterialColor>(
Colors.grey,
)
: null,
),
label: Text(
context.lang.registerSubmitButton,
style: const TextStyle(fontSize: 17),
),
),
const SizedBox(height: 16),
],
TextButton(
onPressed: () => context.push(
Routes.settingsBackupRecovery,
),
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(50),
foregroundColor: secondaryButtonColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
const SizedBox(height: 10),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
OutlinedButton.icon(
onPressed: () =>
context.push(Routes.settingsBackupRecovery),
label: Text(context.lang.twonlySafeRecoverBtn),
),
],
),
child: Text(
context.lang.twonlySafeRecoverBtn,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
),
],
),
),
// ),
],
),
),
),
const Spacer(),
const SizedBox(height: 40),
],
);
}
}

View file

@ -148,7 +148,7 @@ class _SetupBackupViewState extends State<SetupBackupView> {
)
: const Icon(Icons.lock_clock_rounded),
label: Text(
userService.currentUser.twonlySafeBackup == null
userService.currentUser.isBackupEnabled
? context.lang.backupEnableBackup
: context.lang.backupChangePassword,
),

View file

@ -6,6 +6,7 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/constants/routes.keys.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/services/api/mediafiles/download.api.dart';
import 'package:twonly/src/services/user.service.dart';
import 'package:twonly/src/utils/misc.dart';
@ -64,6 +65,22 @@ class _DataAndStorageViewState extends State<DataAndStorageView> {
defaultAutoDownloadOptions;
return ListView(
children: [
FutureBuilder<Map<MediaType, int>>(
future: twonlyDB.mediaFilesDao.getStorageStats(),
builder: (context, snapshot) {
final stats = snapshot.data ?? {};
final totalBytes = stats.values.fold<int>(0, (a, b) => a + b);
final sizeStr = formatBytes(totalBytes);
return ListTile(
title: Text(context.lang.settingsStorageManageTitle),
subtitle: Text(sizeStr),
onTap: () => context.push(Routes.settingsStorageManage),
trailing: const Icon(Icons.chevron_right),
);
},
),
const Divider(),
ListTile(
title: Text(context.lang.settingsStorageDataStoreInGTitle),
subtitle: Text(

View file

@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:twonly/locator.dart';
import 'package:twonly/src/database/tables/mediafiles.table.dart';
import 'package:twonly/src/utils/misc.dart';
class ManageStorageView extends StatefulWidget {
const ManageStorageView({super.key});
@override
State<ManageStorageView> createState() => _ManageStorageViewState();
}
class _ManageStorageViewState extends State<ManageStorageView> {
Map<MediaType, int> _stats = {};
@override
void initState() {
super.initState();
_loadStats();
}
Future<void> _loadStats() async {
final stats = await twonlyDB.mediaFilesDao.getStorageStats();
if (mounted) {
setState(() {
_stats = stats;
});
}
}
@override
Widget build(BuildContext context) {
final totalBytes = _stats.entries
.where((e) => e.key != MediaType.audio)
.fold<int>(0, (a, b) => a + b.value);
final imageBytes = _stats[MediaType.image] ?? 0;
final videoBytes = _stats[MediaType.video] ?? 0;
final gifBytes = _stats[MediaType.gif] ?? 0;
return Scaffold(
appBar: AppBar(
title: Text(context.lang.settingsStorageManageTitle),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
Text(
context.lang.settingsStorageUsed,
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Text(
formatBytes(totalBytes),
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 24),
Container(
height: 24,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: LayoutBuilder(
builder: (context, constraints) {
if (totalBytes == 0) return const SizedBox.shrink();
final maxWidth = constraints.maxWidth;
final imageWidth = (imageBytes / totalBytes) * maxWidth;
final videoWidth = (videoBytes / totalBytes) * maxWidth;
final gifWidth = (gifBytes / totalBytes) * maxWidth;
return Row(
children: [
if (imageBytes > 0)
Container(width: imageWidth, color: Colors.blue),
if (videoBytes > 0)
Container(width: videoWidth, color: Colors.green),
if (gifBytes > 0)
Container(width: gifWidth, color: Colors.orange),
],
);
},
),
),
),
const SizedBox(height: 24),
_StorageCategoryTile(
title: context.lang.settingsStorageImages,
size: formatBytes(imageBytes),
color: Colors.blue,
),
_StorageCategoryTile(
title: context.lang.settingsStorageVideos,
size: formatBytes(videoBytes),
color: Colors.green,
),
_StorageCategoryTile(
title: context.lang.settingsStorageGifs,
size: formatBytes(gifBytes),
color: Colors.orange,
),
],
),
);
}
}
class _StorageCategoryTile extends StatelessWidget {
const _StorageCategoryTile({
required this.title,
required this.size,
required this.color,
});
final String title;
final String size;
final Color color;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
),
const SizedBox(width: 16),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 16),
),
),
Text(
size,
style: const TextStyle(
fontSize: 16,
color: Colors.grey,
),
),
],
),
);
}
}

View file

@ -216,6 +216,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
isDraftMedia: false,
isFavorite: false,
hasCropAnalyzed: false,
hasThumbnail: false,
createdAt: now,
);
final mediaService = MediaFileService(mediaFile);

View file

@ -66,10 +66,10 @@ class _SubscriptionViewState extends State<SubscriptionView> {
children: [
const SizedBox(height: 20),
Text(
context.lang.subscriptionPledgeTitle,
context.lang.subscriptionPledgeSubtitle,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 20,
fontSize: 22,
fontWeight: FontWeight.bold,
color: context.color.primary,
letterSpacing: 0.5,
@ -88,12 +88,6 @@ class _SubscriptionViewState extends State<SubscriptionView> {
desc: context.lang.subscriptionPledgeNoAdsDesc,
),
const SizedBox(height: 24),
_MissionRow(
icon: FontAwesomeIcons.heart,
title: context.lang.subscriptionPledgeFundedTitle,
desc: context.lang.subscriptionPledgeFundedDesc,
),
const SizedBox(height: 24),
],
),
)
@ -145,7 +139,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
onPurchase: initAsync,
),
],
const SizedBox(height: 10),
const SizedBox(height: 30),
BetterListTile(
icon: FontAwesomeIcons.fileContract,
text: context.lang.termsOfService,

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 815 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 198 KiB

After

Width:  |  Height:  |  Size: 800 KiB

View file

@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
publish_to: 'none'
version: 0.2.12+121
version: 0.2.13+122
environment:
sdk: ^3.11.0
@ -212,3 +212,9 @@ flutter:
- assets/passwords/
- CHANGELOG.md
fonts:
- family: NotoColorEmoji
fonts:
- asset: assets/fonts/NotoColorEmoji.ttf

View file

@ -19,6 +19,7 @@ import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
import 'schema_v16.dart' as v16;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -54,6 +55,8 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v14.DatabaseAtV14(db);
case 15:
return v15.DatabaseAtV15(db);
case 16:
return v16.DatabaseAtV16(db);
default:
throw MissingSchemaException(version, versions);
}
@ -75,5 +78,6 @@ class GeneratedHelper implements SchemaInstantiationHelper {
13,
14,
15,
16,
];
}

File diff suppressed because it is too large Load diff

View file

@ -1,4 +1,5 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pro_video_editor/core/platform/io/io_helper.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parse_link.dart';
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layers/link_preview/parser/base.dart';
@ -29,6 +30,9 @@ class LinkParserTest {
void main() {
test('testing different urls', () async {
if (!Platform.isMacOS) {
return;
}
final testCases = [
LinkParserTest(
url: 'https://mastodon.social/@islieb/115883317936171927',