mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 12:32:11 +00:00
Merge pull request #411 from twonlyapp/dev
Some checks are pending
Publish on Github / build_and_publish (push) Waiting to run
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:
commit
5fb51b20d7
66 changed files with 16645 additions and 1077 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -10,6 +10,9 @@
|
|||
.history
|
||||
.svn/
|
||||
.swiftpm/
|
||||
*.sqlite
|
||||
*.sqlite-shm
|
||||
*.sqlite-wal
|
||||
migrate_working_dir/
|
||||
|
||||
# IntelliJ related
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
BIN
assets/fonts/NotoColorEmoji.ttf
Normal file
Binary file not shown.
BIN
docs/header.png
BIN
docs/header.png
Binary file not shown.
|
Before Width: | Height: | Size: 800 KiB |
|
|
@ -36,7 +36,9 @@ 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) {
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
154
lib/main.dart
154
lib/main.dart
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
try {
|
||||
await twonlyDB.batch((batch) async {
|
||||
for (final messageId in messageIds) {
|
||||
batch.insert(
|
||||
messageActions,
|
||||
MessageActionsCompanion(
|
||||
messageId: Value(messageId),
|
||||
contactId: Value(contactId),
|
||||
contactId: contactId,
|
||||
type: const Value(MessageActionType.openedAt),
|
||||
actionAt: Value(timestamp),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
}
|
||||
|
||||
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,6 +317,7 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
String messageId,
|
||||
MessageActionType action,
|
||||
) async {
|
||||
try {
|
||||
final message = await twonlyDB.messagesDao
|
||||
.getMessageById(messageId)
|
||||
.getSingleOrNull();
|
||||
|
|
@ -319,11 +328,16 @@ class MessagesDao extends DatabaseAccessor<TwonlyDB> with _$MessagesDaoMixin {
|
|||
|
||||
final actions =
|
||||
await (select(messageActions)..where(
|
||||
(t) => t.type.equals(action.name) & t.messageId.equals(messageId),
|
||||
(t) =>
|
||||
t.type.equals(action.name) & t.messageId.equals(messageId),
|
||||
))
|
||||
.get();
|
||||
|
||||
return members.length == actions.length;
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateMessageId(
|
||||
|
|
|
|||
3019
lib/src/database/schemas/twonly_db/drift_schema_v16.json
Normal file
3019
lib/src/database/schemas/twonly_db/drift_schema_v16.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()();
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -165,6 +165,9 @@ class UserData {
|
|||
@JsonKey(defaultValue: false)
|
||||
bool skipSetupPages = false;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool hasZoomed = false;
|
||||
|
||||
Map<String, dynamic> toJson() => _$UserDataToJson(this);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,79 +123,20 @@ class MemoriesService {
|
|||
}
|
||||
}
|
||||
|
||||
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 totalToMigrate = nonHashedFiles.length + unanalyzedFiles.length;
|
||||
if (totalToMigrate > 0) {
|
||||
_updateState(filesToMigrate: totalToMigrate);
|
||||
|
||||
for (final mediaFile in nonHashedFiles) {
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
await mediaService.hashMediaFile();
|
||||
_updateState(filesToMigrate: _currentState.filesToMigrate - 1);
|
||||
}
|
||||
|
||||
for (final mediaFile in unanalyzedFiles) {
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
await mediaService.cropTransparentBorders();
|
||||
_updateState(filesToMigrate: _currentState.filesToMigrate - 1);
|
||||
}
|
||||
|
||||
_updateState(filesToMigrate: 0);
|
||||
}
|
||||
|
||||
// 2. Subscribe to stored media files stream
|
||||
await _dbSubscription?.cancel();
|
||||
_dbSubscription = twonlyDB.mediaFilesDao
|
||||
.watchAllStoredMediaFiles()
|
||||
.listen(_processMediaFilesStream);
|
||||
} catch (e) {
|
||||
Log.error('Error initializing MemoriesService: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processMediaFilesStream(List<MediaFile> mediaFiles) async {
|
||||
try {
|
||||
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>>{};
|
||||
|
||||
// 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,
|
||||
);
|
||||
final allContacts = await twonlyDB.contactsDao.getAllContacts();
|
||||
|
||||
final contactMap = {for (final c in allContacts) c.userId: c};
|
||||
final mediaIdToSenderContact = <String, Contact>{};
|
||||
|
||||
for (final msg in allMessages) {
|
||||
if (msg.mediaId != null && msg.senderId != null) {
|
||||
final contact = contactMap[msg.senderId];
|
||||
if (contact != null) {
|
||||
mediaIdToSenderContact[msg.mediaId!] = contact;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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 senderContact = mediaIdToSender[mediaFile.mediaId];
|
||||
final item = MemoryItem(
|
||||
mediaService: mediaService,
|
||||
messages: [],
|
||||
|
|
@ -269,7 +165,6 @@ class MemoriesService {
|
|||
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 =
|
||||
|
|
@ -293,19 +188,106 @@ class MemoriesService {
|
|||
final sortedGalleryItemsLastYears =
|
||||
SplayTreeMap<int, List<MemoryItem>>.from(tempGalleryItemsLastYears);
|
||||
|
||||
final newState = MemoriesState(
|
||||
filesToMigrate: _currentState.filesToMigrate,
|
||||
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 {
|
||||
final pendingFiles = await twonlyDB.mediaFilesDao
|
||||
.getAllMediaFilesPendingMigration();
|
||||
|
||||
if (pendingFiles.isNotEmpty) {
|
||||
_currentState = _currentState.copyWith(
|
||||
filesToMigrate: pendingFiles.length,
|
||||
totalFilesToMigrate: pendingFiles.length,
|
||||
);
|
||||
_notifyState();
|
||||
|
||||
for (final mediaFile in pendingFiles) {
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
_updateMigrationCount(0);
|
||||
}
|
||||
|
||||
await _dbSubscription?.cancel();
|
||||
_dbSubscription = twonlyDB.mediaFilesDao
|
||||
.watchAllStoredMediaFiles()
|
||||
.listen(_processMediaFilesStream);
|
||||
} catch (e) {
|
||||
Log.error('Error initializing MemoriesService: $e');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _processMediaFilesStream(List<MediaFile> mediaFiles) async {
|
||||
try {
|
||||
final mediaIds = mediaFiles.map((m) => m.mediaId).toList();
|
||||
final allMessages = await twonlyDB.messagesDao.getMessagesByMediaIds(
|
||||
mediaIds,
|
||||
);
|
||||
final allContacts = await twonlyDB.contactsDao.getAllContacts();
|
||||
|
||||
final contactMap = {for (final c in allContacts) c.userId: c};
|
||||
final mediaIdToSenderContact = <String, Contact>{};
|
||||
|
||||
for (final msg in allMessages) {
|
||||
if (msg.mediaId != null && msg.senderId != null) {
|
||||
final contact = contactMap[msg.senderId];
|
||||
if (contact != null) {
|
||||
mediaIdToSenderContact[msg.mediaId!] = contact;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
final newState = _computeState(
|
||||
mediaFiles: mediaFiles,
|
||||
mediaIdToSender: mediaIdToSenderContact,
|
||||
filesToMigrate: _currentState.filesToMigrate,
|
||||
).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);
|
||||
}
|
||||
|
|
|
|||
184
lib/src/services/migrations.service.dart
Normal file
184
lib/src/services/migrations.service.dart
Normal 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.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
342
lib/src/visual/components/draggable_scrollbar.comp.dart
Normal file
342
lib/src/visual/components/draggable_scrollbar.comp.dart
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
final ThemeData darkTheme = ThemeData.dark().copyWith(
|
||||
final ThemeData darkTheme = () {
|
||||
final base = ThemeData.dark().copyWith(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
brightness: Brightness.dark,
|
||||
seedColor: const Color(0xFF57CC99),
|
||||
|
|
@ -13,3 +15,10 @@ final ThemeData darkTheme = ThemeData.dark().copyWith(
|
|||
border: OutlineInputBorder(),
|
||||
),
|
||||
);
|
||||
return base.copyWith(
|
||||
textTheme: base.textTheme.apply(
|
||||
fontFamily: Platform.isAndroid ? 'sans-serif' : null,
|
||||
fontFamilyFallback: Platform.isAndroid ? const ['NotoColorEmoji'] : null,
|
||||
),
|
||||
);
|
||||
}();
|
||||
|
|
|
|||
|
|
@ -1,9 +1,11 @@
|
|||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
const primaryColor = Color(0xFF57CC99);
|
||||
|
||||
final ThemeData lightTheme = ThemeData(
|
||||
final ThemeData lightTheme = () {
|
||||
final base = ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
),
|
||||
|
|
@ -11,6 +13,13 @@ final ThemeData lightTheme = ThemeData(
|
|||
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,
|
||||
|
|
|
|||
|
|
@ -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,7 +138,12 @@ class CameraBottomControls extends StatelessWidget {
|
|||
}
|
||||
|
||||
Widget _buildShutterButton() {
|
||||
return GestureDetector(
|
||||
return StreamBuilder(
|
||||
stream: userService.onUserUpdated,
|
||||
builder: (context, snapshot) {
|
||||
return ZoomTutorialOverlay(
|
||||
hasZoomed: userService.currentUser.hasZoomed,
|
||||
child: GestureDetector(
|
||||
onTap: onTakePicture,
|
||||
key: keyTriggerButton,
|
||||
child: Align(
|
||||
|
|
@ -155,6 +162,9 @@ class CameraBottomControls extends StatelessWidget {
|
|||
child: mc.currentFilterType.preview,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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,21 +30,26 @@ class ChatAudioEntry extends StatelessWidget {
|
|||
!mediaService.originalPath.existsSync()) {
|
||||
return Container(); // media file was purged
|
||||
}
|
||||
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: 250,
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10),
|
||||
padding: info.padding,
|
||||
decoration: BoxDecoration(
|
||||
color: info.color,
|
||||
borderRadius: borderRadius,
|
||||
|
|
@ -71,11 +70,17 @@ class ChatAudioEntry extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (info.text != '')
|
||||
if (isExpanded && info.text != '')
|
||||
Expanded(
|
||||
child: BetterText(text: info.text, textColor: info.textColor),
|
||||
child: BetterText(
|
||||
text: info.text,
|
||||
textColor: info.textColor,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
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)
|
||||
|
|
@ -87,6 +92,7 @@ class ChatAudioEntry extends StatelessWidget {
|
|||
: Container()
|
||||
else
|
||||
MessageSendStateIcon([message], [mediaService.mediaFile]),
|
||||
SizedBox(width: spacerWidth),
|
||||
],
|
||||
if (info.displayTime || message.modifiedAt != null)
|
||||
FriendlyMessageTime(message: message),
|
||||
|
|
@ -95,6 +101,8 @@ class ChatAudioEntry extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,21 +34,25 @@ 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,
|
||||
minWidth: info.minWidth,
|
||||
),
|
||||
padding: const EdgeInsets.only(left: 10, top: 6, bottom: 6, right: 10),
|
||||
padding: info.padding,
|
||||
decoration: BoxDecoration(
|
||||
color: info.color,
|
||||
borderRadius: borderRadius,
|
||||
|
|
@ -75,14 +73,17 @@ class ChatTextEntry extends StatelessWidget {
|
|||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (info.expanded)
|
||||
if (isExpanded)
|
||||
Expanded(
|
||||
child: BetterText(text: info.text, textColor: info.textColor),
|
||||
child: BetterText(
|
||||
text: info.text,
|
||||
textColor: info.textColor,
|
||||
),
|
||||
)
|
||||
else ...[
|
||||
BetterText(text: info.text, textColor: info.textColor),
|
||||
SizedBox(
|
||||
width: info.spacerWidth,
|
||||
width: spacerWidth,
|
||||
),
|
||||
],
|
||||
if (info.displayTime || message.modifiedAt != null)
|
||||
|
|
@ -92,5 +93,7 @@ class ChatTextEntry extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,11 +92,10 @@ 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!)) {
|
||||
if (nextMessage.content == null ||
|
||||
!EmojiAnimationComp.supported(nextMessage.content!)) {
|
||||
final diff = nextMessage.createdAt
|
||||
.difference(message.createdAt)
|
||||
.inMinutes;
|
||||
|
|
@ -99,6 +105,5 @@ bool combineTextMessageWithNext(Message message, Message? nextMessage) {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,21 @@ 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(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 6),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (message.modifiedAt != null && !message.isDeletedFromSender)
|
||||
Padding(
|
||||
|
|
@ -25,7 +29,7 @@ class FriendlyMessageTime extends StatelessWidget {
|
|||
height: 10,
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.pencil,
|
||||
color: Colors.white.withAlpha(150),
|
||||
color: color ?? Colors.white.withAlpha(150),
|
||||
size: 10,
|
||||
),
|
||||
),
|
||||
|
|
@ -39,14 +43,13 @@ class FriendlyMessageTime extends StatelessWidget {
|
|||
),
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: Colors.white.withAlpha(150),
|
||||
color: color ?? Colors.white.withAlpha(150),
|
||||
decoration: TextDecoration.none,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,22 +154,33 @@ 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(
|
||||
padding: widget.info.padding,
|
||||
child: Row(
|
||||
children: [
|
||||
MessageSendStateIcon(
|
||||
[widget.message],
|
||||
[widget.mediaService.mediaFile],
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,11 +348,37 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
orderedByMonth = filteredOrdered;
|
||||
}
|
||||
|
||||
return Scrollbar(
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return DraggableScrollbar(
|
||||
controller: _scrollController,
|
||||
thickness: 12,
|
||||
radius: const Radius.circular(6),
|
||||
interactive: true,
|
||||
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(),
|
||||
|
|
@ -390,6 +393,30 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
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
|
||||
|
|
@ -410,13 +437,11 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
),
|
||||
],
|
||||
),
|
||||
|
||||
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),
|
||||
|
|
@ -442,7 +467,8 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
(context, idx) {
|
||||
final globalIndex = orderedByMonth[month]![idx];
|
||||
final item = state.galleryItems[globalIndex];
|
||||
final mediaId = item.mediaService.mediaFile.mediaId;
|
||||
final mediaId =
|
||||
item.mediaService.mediaFile.mediaId;
|
||||
final isSelected = _selectedMediaIds.contains(
|
||||
mediaId,
|
||||
);
|
||||
|
|
@ -461,11 +487,15 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
),
|
||||
),
|
||||
],
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 32)),
|
||||
const SliverPadding(
|
||||
padding: EdgeInsets.only(bottom: 32),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
if (_selectionMode)
|
||||
|
|
|
|||
|
|
@ -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)}';
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,10 +66,23 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text('twonly Backup ${context.lang.twonlySafeRecoverTitle}'),
|
||||
actions: [
|
||||
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: [
|
||||
IconButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
),
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
await showAlertDialog(
|
||||
|
|
@ -77,53 +92,102 @@ class _BackupRecoveryViewState extends State<BackupRecoveryView> {
|
|||
);
|
||||
},
|
||||
icon: const FaIcon(FontAwesomeIcons.circleInfo),
|
||||
iconSize: 18,
|
||||
color: Colors.white,
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsetsGeometry.symmetric(
|
||||
vertical: 40,
|
||||
horizontal: 40,
|
||||
const SizedBox(height: 20),
|
||||
const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: LinkLogoAnimation(),
|
||||
),
|
||||
child: ListView(
|
||||
children: [
|
||||
Text(
|
||||
context.lang.twonlySafeRecoverDesc,
|
||||
),
|
||||
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: 30),
|
||||
),
|
||||
),
|
||||
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: const TextStyle(fontSize: 17),
|
||||
decoration: getInputDecoration(
|
||||
context,
|
||||
context.lang.registerUsernameDecoration,
|
||||
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],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Stack(
|
||||
children: [
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: passwordCtrl,
|
||||
onChanged: (value) {
|
||||
setState(() {});
|
||||
},
|
||||
style: const TextStyle(fontSize: 17),
|
||||
onChanged: (value) => setState(() {}),
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: isDark ? Colors.white : Colors.black,
|
||||
),
|
||||
obscureText: obscureText,
|
||||
decoration: getInputDecoration(
|
||||
context,
|
||||
context.lang.password,
|
||||
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,
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
child: IconButton(
|
||||
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(
|
||||
const SizedBox(height: 32),
|
||||
FilledButton(
|
||||
onPressed: (!isLoading) ? _recoverTwonlySafe : null,
|
||||
icon: isLoading
|
||||
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: 12,
|
||||
width: 12,
|
||||
child: CircularProgressIndicator(strokeWidth: 1),
|
||||
height: 24,
|
||||
width: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.lock_clock_rounded),
|
||||
label: Text(context.lang.twonlySafeRecoverBtn),
|
||||
: Text(
|
||||
context.lang.twonlySafeRecoverBtn,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,89 +150,85 @@ 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(
|
||||
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: 50),
|
||||
Text(
|
||||
context.lang.registerTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 30),
|
||||
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: 30),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
context.lang.registerSlogan,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 12),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.white.withValues(alpha: 0.9),
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 130),
|
||||
),
|
||||
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),
|
||||
const SizedBox(height: 48),
|
||||
] else ...[
|
||||
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: 60),
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10, right: 10),
|
||||
child: Text(
|
||||
context.lang.registerUsernameSlogan,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: sloganColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 15),
|
||||
const SizedBox(height: 20),
|
||||
TextField(
|
||||
controller: usernameController,
|
||||
onChanged: (value) {
|
||||
usernameController.text = value.toLowerCase();
|
||||
usernameController.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: usernameController.text.length),
|
||||
TextPosition(
|
||||
offset: usernameController.text.length,
|
||||
),
|
||||
);
|
||||
setState(() {
|
||||
_isValidUserName = usernameController.text.length >= 3;
|
||||
|
|
@ -236,84 +236,113 @@ class _RegisterViewState extends State<RegisterView> {
|
|||
},
|
||||
inputFormatters: [
|
||||
LengthLimitingTextInputFormatter(12),
|
||||
FilteringTextInputFormatter.allow(RegExp('[a-z0-9A-Z._]')),
|
||||
FilteringTextInputFormatter.allow(
|
||||
RegExp('[a-z0-9A-Z._]'),
|
||||
),
|
||||
],
|
||||
style: const TextStyle(fontSize: 17),
|
||||
decoration: getInputDecoration(
|
||||
context.lang.registerUsernameDecoration,
|
||||
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],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
),
|
||||
if (_showUserNameError &&
|
||||
usernameController.text.length < 3) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.lang.registerUsernameLimits,
|
||||
style: TextStyle(
|
||||
color: _showUserNameError ? Colors.red : Colors.transparent,
|
||||
fontSize: 12,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
if (_showProofOfWorkError) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
context.lang.registerProofOfWorkFailed,
|
||||
style: TextStyle(
|
||||
color: _showProofOfWorkError
|
||||
? Colors.red
|
||||
: Colors.transparent,
|
||||
fontSize: 12,
|
||||
style: const TextStyle(
|
||||
color: Colors.red,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Column(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
icon: _isTryingToRegister
|
||||
],
|
||||
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: 18,
|
||||
height: 18,
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
color: Colors.black,
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.group),
|
||||
onPressed: createNewUser,
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all<EdgeInsets>(
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
horizontal: 30,
|
||||
),
|
||||
),
|
||||
backgroundColor: _isTryingToRegister
|
||||
? WidgetStateProperty.all<MaterialColor>(
|
||||
Colors.grey,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
label: Text(
|
||||
: Text(
|
||||
context.lang.registerSubmitButton,
|
||||
style: const TextStyle(fontSize: 17),
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
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),
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
context.lang.twonlySafeRecoverBtn,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () =>
|
||||
context.push(Routes.settingsBackupRecovery),
|
||||
label: Text(context.lang.twonlySafeRecoverBtn),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -216,6 +216,7 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
isDraftMedia: false,
|
||||
isFavorite: false,
|
||||
hasCropAnalyzed: false,
|
||||
hasThumbnail: false,
|
||||
createdAt: now,
|
||||
);
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
|
|
|
|||
|
|
@ -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 |
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
10584
test/drift/twonly_db/generated/schema_v16.dart
Normal file
10584
test/drift/twonly_db/generated/schema_v16.dart
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue