mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 10:52:12 +00:00
Merge pull request #410 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: Automatically mark identical media as opened across all chats (Settings > Chats). - Improved: Memories viewer redesigned with smoother animations and new quick-action controls. - Fix: Reliability of receiving media files.
This commit is contained in:
commit
0a9c74f515
61 changed files with 12601 additions and 914 deletions
|
|
@ -1,5 +1,11 @@
|
|||
# Changelog
|
||||
|
||||
## 0.2.12
|
||||
|
||||
- New: Automatically mark identical media as opened across all chats (Settings > Chats).
|
||||
- Improved: Memories viewer redesigned with smoother animations and new quick-action controls.
|
||||
- Fix: Reliability of receiving media files.
|
||||
|
||||
## 0.2.11
|
||||
|
||||
- New: Create custom shortcuts to quickly share images with pre-selected groups
|
||||
|
|
|
|||
|
|
@ -73,4 +73,5 @@ flutter {
|
|||
dependencies {
|
||||
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
|
||||
implementation 'com.otaliastudios:transcoder:0.11.0'
|
||||
implementation 'androidx.core:core-splashscreen:1.0.1'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,9 +8,16 @@ import android.view.KeyEvent.KEYCODE_VOLUME_UP
|
|||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import android.content.Context
|
||||
import io.crates.keyring.Keyring
|
||||
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
|
||||
import android.os.Bundle
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
installSplashScreen()
|
||||
super.onCreate(savedInstanceState)
|
||||
}
|
||||
|
||||
override fun onKeyDown(keyCode: Int, event: KeyEvent): Boolean {
|
||||
if (keyCode == KEYCODE_VOLUME_DOWN && eventSink != null) {
|
||||
eventSink!!.success(true)
|
||||
|
|
|
|||
69
android/app/src/main/res/drawable/link_animated.xml
Normal file
69
android/app/src/main/res/drawable/link_animated.xml
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
<animated-vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt">
|
||||
<aapt:attr name="android:drawable">
|
||||
<vector
|
||||
android:width="100dp"
|
||||
android:height="100dp"
|
||||
android:viewportWidth="640"
|
||||
android:viewportHeight="640">
|
||||
|
||||
<!-- Wrap everything in a scaling group to add padding and prevent splash screen circular cropping -->
|
||||
<group
|
||||
android:pivotX="320"
|
||||
android:pivotY="320"
|
||||
android:scaleX="0.6"
|
||||
android:scaleY="0.6">
|
||||
|
||||
<!-- Link One pivots around its visual center (approx X=416, Y=288) -->
|
||||
<group
|
||||
android:name="link_one_group"
|
||||
android:pivotX="416"
|
||||
android:pivotY="288">
|
||||
<path
|
||||
android:name="link_one_path"
|
||||
android:fillColor="#fff"
|
||||
android:pathData="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" />
|
||||
</group>
|
||||
|
||||
<!-- Link Two pivots around its visual center (approx X=224, Y=352) -->
|
||||
<group
|
||||
android:name="link_two_group"
|
||||
android:pivotX="224"
|
||||
android:pivotY="352">
|
||||
<path
|
||||
android:name="link_two_path"
|
||||
android:fillColor="#ffffff"
|
||||
android:pathData="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" />
|
||||
</group>
|
||||
</group>
|
||||
</vector>
|
||||
</aapt:attr>
|
||||
|
||||
<!-- Rotate Link One smoothly back and forth -->
|
||||
<target android:name="link_one_group">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:duration="800"
|
||||
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
|
||||
android:propertyName="rotation"
|
||||
android:repeatCount="-1"
|
||||
android:repeatMode="reverse"
|
||||
android:valueFrom="-3"
|
||||
android:valueTo="3" />
|
||||
</aapt:attr>
|
||||
</target>
|
||||
|
||||
<!-- Rotate Link Two smoothly in the opposite direction to create the opening/closing effect -->
|
||||
<target android:name="link_two_group">
|
||||
<aapt:attr name="android:animation">
|
||||
<objectAnimator
|
||||
android:duration="800"
|
||||
android:interpolator="@android:anim/accelerate_decelerate_interpolator"
|
||||
android:propertyName="rotation"
|
||||
android:repeatCount="-1"
|
||||
android:repeatMode="reverse"
|
||||
android:valueFrom="3"
|
||||
android:valueTo="-3" />
|
||||
</aapt:attr>
|
||||
</target>
|
||||
</animated-vector>
|
||||
|
|
@ -1,10 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<style name="LaunchTheme" parent="Theme.SplashScreen">
|
||||
<!-- Configure the Androidx Splash Screen API parameters -->
|
||||
<item name="windowSplashScreenBackground">#FF57CC99</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/link_animated</item>
|
||||
<item name="windowSplashScreenAnimationDuration">800</item>
|
||||
<item name="postSplashScreenTheme">@style/NormalTheme</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<!-- Set the background color to the primary color so the white logo is visible -->
|
||||
<item name="android:colorBackground">#FF57CC99</item>
|
||||
<style name="LaunchTheme" parent="Theme.SplashScreen">
|
||||
<!-- Configure the Androidx Splash Screen API parameters -->
|
||||
<item name="windowSplashScreenBackground">#FF57CC99</item>
|
||||
<item name="windowSplashScreenAnimatedIcon">@drawable/link_animated</item>
|
||||
<item name="windowSplashScreenAnimationDuration">800</item>
|
||||
<item name="postSplashScreenTheme">@style/NormalTheme</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
|
|
|
|||
|
|
@ -32,5 +32,6 @@ class AppState {
|
|||
static bool isInBackgroundTask = false;
|
||||
static bool allowErrorTrackingViaSentry = false;
|
||||
static bool gotMessageFromServer = false;
|
||||
static int latestAppVersionId = 113;
|
||||
static int latestAppVersionId = 115;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
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';
|
||||
|
|
@ -17,6 +19,7 @@ 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';
|
||||
|
|
@ -28,6 +31,8 @@ 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/notifications/fcm.notifications.dart';
|
||||
import 'package:twonly/src/services/notifications/setup.notifications.dart';
|
||||
import 'package:twonly/src/services/user.service.dart';
|
||||
|
|
@ -247,9 +252,51 @@ Future<void> runMigrations() async {
|
|||
});
|
||||
}
|
||||
}
|
||||
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 == 113,
|
||||
AppState.latestAppVersionId == 115,
|
||||
'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.',
|
||||
);
|
||||
assert(
|
||||
|
|
@ -261,6 +308,8 @@ Future<void> runMigrations() async {
|
|||
|
||||
Future<void> postStartupTasks() async {
|
||||
Log.info('Post startup started.');
|
||||
unawaited(MemoriesService.prewarmCache());
|
||||
|
||||
// 1. Immediate background cleanup (Non-blocking for UI)
|
||||
await twonlyDB.messagesDao.purgeMessageTable();
|
||||
unawaited(twonlyDB.receiptsDao.purgeReceivedReceipts());
|
||||
|
|
|
|||
|
|
@ -126,6 +126,35 @@ class KeyVerificationDao extends DatabaseAccessor<TwonlyDB>
|
|||
return rows.length;
|
||||
}
|
||||
|
||||
Future<int> getCountOfContactsWithVerificationBadge() async {
|
||||
final kv = keyVerifications;
|
||||
final ur = userDiscoveryUserRelations;
|
||||
|
||||
final query = selectOnly(ur, distinct: true)
|
||||
..addColumns([ur.announcedUserId])
|
||||
..join([
|
||||
innerJoin(contacts, contacts.userId.equalsExp(ur.fromContactId)),
|
||||
innerJoin(kv, kv.contactId.equalsExp(ur.fromContactId)),
|
||||
])
|
||||
..where(
|
||||
ur.publicKeyVerifiedTimestamp.isNotNull() &
|
||||
ur.announcedUserId.equalsExp(ur.fromContactId).not(),
|
||||
)
|
||||
..groupBy([ur.announcedUserId]);
|
||||
|
||||
final rows = await query.get();
|
||||
final transferredIds = rows.map((r) => r.read(ur.announcedUserId)!).toSet();
|
||||
|
||||
final directVerifications = await select(kv).get();
|
||||
final directIds = directVerifications.map((v) => v.contactId).toSet();
|
||||
|
||||
// Reduce transferred contacts where announcedUserId is already in KeyVerifications
|
||||
transferredIds.removeWhere(directIds.contains);
|
||||
|
||||
// Add count of all users who are in the KeyVerification table
|
||||
return transferredIds.length + directIds.length;
|
||||
}
|
||||
|
||||
Stream<VerificationStatus> watchAllGroupMembersVerified(String groupId) {
|
||||
final gm = groupMembers;
|
||||
final directKv = alias(keyVerifications, 'directKv');
|
||||
|
|
|
|||
|
|
@ -69,7 +69,6 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
return (select(mediaFiles)..where((t) => t.mediaId.isIn(mediaIds))).get();
|
||||
}
|
||||
|
||||
|
||||
Future<MediaFile?> getDraftMediaFile() async {
|
||||
final medias = await (select(
|
||||
mediaFiles,
|
||||
|
|
@ -122,6 +121,13 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
.get();
|
||||
}
|
||||
|
||||
Future<List<MediaFile>> getAllUnanalyzedStoredMediaFiles() async {
|
||||
return (select(mediaFiles)..where(
|
||||
(t) => t.stored.equals(true) & t.hasCropAnalyzed.equals(false),
|
||||
))
|
||||
.get();
|
||||
}
|
||||
|
||||
Future<List<MediaFile>> getAllMediaFilesPendingUpload() async {
|
||||
return (select(mediaFiles)..where(
|
||||
(t) =>
|
||||
|
|
@ -159,4 +165,24 @@ class MediaFilesDao extends DatabaseAccessor<TwonlyDB>
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<String>> getMessageIdsByMediaHash(
|
||||
Uint8List hash,
|
||||
int senderId,
|
||||
) async {
|
||||
final query =
|
||||
select(db.messages).join([
|
||||
innerJoin(
|
||||
mediaFiles,
|
||||
mediaFiles.mediaId.equalsExp(db.messages.mediaId),
|
||||
),
|
||||
])..where(
|
||||
mediaFiles.storedFileHash.equals(hash) &
|
||||
db.messages.senderId.equals(senderId) &
|
||||
db.messages.openedAt.isNull(),
|
||||
);
|
||||
|
||||
final rows = await query.get();
|
||||
return rows.map((row) => row.readTable(db.messages).messageId).toList();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -191,6 +191,23 @@ class ReceiptsDao extends DatabaseAccessor<TwonlyDB> with _$ReceiptsDaoMixin {
|
|||
)..where((c) => c.receiptId.equals(receiptId))).write(updates);
|
||||
}
|
||||
|
||||
Future<Receipt?> rotateReceiptId(String oldReceiptId) async {
|
||||
final newReceiptId = uuid.v4();
|
||||
await updateReceipt(
|
||||
oldReceiptId,
|
||||
ReceiptsCompanion(
|
||||
receiptId: Value(newReceiptId),
|
||||
),
|
||||
);
|
||||
final updatedReceipt = await getReceiptById(newReceiptId);
|
||||
if (updatedReceipt == null) {
|
||||
Log.error(
|
||||
'Tried to change the receipt ID, but could not get the updated receipt...',
|
||||
);
|
||||
}
|
||||
return updatedReceipt;
|
||||
}
|
||||
|
||||
Future<void> updateReceiptByContactAndMessageId(
|
||||
int contactId,
|
||||
String messageId,
|
||||
|
|
|
|||
2939
lib/src/database/schemas/twonly_db/drift_schema_v14.json
Normal file
2939
lib/src/database/schemas/twonly_db/drift_schema_v14.json
Normal file
File diff suppressed because it is too large
Load diff
2995
lib/src/database/schemas/twonly_db/drift_schema_v15.json
Normal file
2995
lib/src/database/schemas/twonly_db/drift_schema_v15.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,10 +1,11 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:convert';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.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/twonly.db.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
import 'package:twonly/src/utils/secure_storage.dart';
|
||||
|
||||
Future<HashMap<int, Uint8List>> getSignalSignedPreKeyStoreOld() async {
|
||||
|
|
@ -26,25 +27,25 @@ Future<HashMap<int, Uint8List>> getSignalSignedPreKeyStoreOld() async {
|
|||
class SignalSignedPreKeyStore extends SignedPreKeyStore {
|
||||
@override
|
||||
Future<SignedPreKeyRecord> loadSignedPreKey(int signedPreKeyId) async {
|
||||
final store = await RustKeyManager.loadSignedPrekey(
|
||||
signedPreKeyId: signedPreKeyId,
|
||||
);
|
||||
if (store == null) {
|
||||
final record = await (twonlyDB.select(
|
||||
twonlyDB.signalSignedPreKeyStores,
|
||||
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get();
|
||||
if (record.isEmpty) {
|
||||
throw InvalidKeyIdException(
|
||||
'No such signed prekey record! $signedPreKeyId',
|
||||
);
|
||||
}
|
||||
return SignedPreKeyRecord.fromSerialized(store);
|
||||
return SignedPreKeyRecord.fromSerialized(record.first.signedPreKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<SignedPreKeyRecord>> loadSignedPreKeys() async {
|
||||
final store = await RustKeyManager.loadSignedPrekeys();
|
||||
final results = <SignedPreKeyRecord>[];
|
||||
for (final serialized in store.values) {
|
||||
results.add(SignedPreKeyRecord.fromSerialized(serialized));
|
||||
}
|
||||
return results;
|
||||
final records = await twonlyDB
|
||||
.select(twonlyDB.signalSignedPreKeyStores)
|
||||
.get();
|
||||
return records
|
||||
.map((r) => SignedPreKeyRecord.fromSerialized(r.signedPreKey))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
@ -52,21 +53,32 @@ class SignalSignedPreKeyStore extends SignedPreKeyStore {
|
|||
int signedPreKeyId,
|
||||
SignedPreKeyRecord record,
|
||||
) async {
|
||||
await RustKeyManager.storeSignedPrekey(
|
||||
signedPreKeyId: signedPreKeyId,
|
||||
record: record.serialize(),
|
||||
final companion = SignalSignedPreKeyStoresCompanion(
|
||||
signedPreKeyId: Value(signedPreKeyId),
|
||||
signedPreKey: Value(record.serialize()),
|
||||
);
|
||||
|
||||
try {
|
||||
await twonlyDB
|
||||
.into(twonlyDB.signalSignedPreKeyStores)
|
||||
.insert(companion, mode: InsertMode.insertOrReplace);
|
||||
} catch (e) {
|
||||
Log.error('$e');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> containsSignedPreKey(int signedPreKeyId) async =>
|
||||
await RustKeyManager.loadSignedPrekey(
|
||||
signedPreKeyId: signedPreKeyId,
|
||||
) !=
|
||||
null;
|
||||
Future<bool> containsSignedPreKey(int signedPreKeyId) async {
|
||||
final record = await (twonlyDB.select(
|
||||
twonlyDB.signalSignedPreKeyStores,
|
||||
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).get();
|
||||
return record.isNotEmpty;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> removeSignedPreKey(int signedPreKeyId) async {
|
||||
await RustKeyManager.removeSignedPrekey(signedPreKeyId: signedPreKeyId);
|
||||
await (twonlyDB.delete(
|
||||
twonlyDB.signalSignedPreKeyStores,
|
||||
)..where((tbl) => tbl.signedPreKeyId.equals(signedPreKeyId))).go();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -50,6 +50,9 @@ class MediaFiles extends Table {
|
|||
|
||||
BoolColumn get stored => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get isDraftMedia => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get isFavorite => boolean().withDefault(const Constant(false))();
|
||||
BoolColumn get hasCropAnalyzed =>
|
||||
boolean().withDefault(const Constant(false))();
|
||||
|
||||
IntColumn get preProgressingProcess => integer().nullable()();
|
||||
|
||||
|
|
@ -67,6 +70,8 @@ class MediaFiles extends Table {
|
|||
BlobColumn get storedFileHash => blob().nullable()();
|
||||
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
TextColumn get createdAtMonth => text().nullable()();
|
||||
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {mediaId};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
import 'package:drift/drift.dart';
|
||||
|
||||
@DataClassName('SignalSignedPreKeyStore')
|
||||
class SignalSignedPreKeyStores extends Table {
|
||||
IntColumn get signedPreKeyId => integer()();
|
||||
BlobColumn get signedPreKey => blob()();
|
||||
DateTimeColumn get createdAt => dateTime().withDefault(currentDateAndTime)();
|
||||
|
||||
@override
|
||||
Set<Column> get primaryKey => {signedPreKeyId};
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import 'package:twonly/src/database/tables/signal_identity_key_store.table.dart'
|
|||
import 'package:twonly/src/database/tables/signal_pre_key_store.table.dart';
|
||||
import 'package:twonly/src/database/tables/signal_sender_key_store.table.dart';
|
||||
import 'package:twonly/src/database/tables/signal_session_store.table.dart';
|
||||
import 'package:twonly/src/database/tables/signal_signed_pre_key_store.table.dart';
|
||||
import 'package:twonly/src/database/tables/user_discovery.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.steps.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
|
@ -45,6 +46,7 @@ part 'twonly.db.g.dart';
|
|||
SignalPreKeyStores,
|
||||
SignalSenderKeyStores,
|
||||
SignalSessionStores,
|
||||
SignalSignedPreKeyStores,
|
||||
MessageActions,
|
||||
GroupHistories,
|
||||
KeyVerifications,
|
||||
|
|
@ -79,7 +81,7 @@ class TwonlyDB extends _$TwonlyDB {
|
|||
TwonlyDB.forTesting(DatabaseConnection super.connection);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 13;
|
||||
int get schemaVersion => 15;
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
|
|
@ -195,6 +197,20 @@ class TwonlyDB extends _$TwonlyDB {
|
|||
await m.createTable(schema.shortcuts);
|
||||
await m.createTable(schema.shortcutMembers);
|
||||
},
|
||||
from13To14: (m, schema) async {
|
||||
await m.addColumn(
|
||||
schema.mediaFiles,
|
||||
schema.mediaFiles.createdAtMonth,
|
||||
);
|
||||
await m.addColumn(schema.mediaFiles, schema.mediaFiles.isFavorite);
|
||||
await m.addColumn(
|
||||
schema.mediaFiles,
|
||||
schema.mediaFiles.hasCropAnalyzed,
|
||||
);
|
||||
},
|
||||
from14To15: (m, schema) async {
|
||||
await m.createTable(schema.signalSignedPreKeyStores);
|
||||
},
|
||||
)(m, from, to);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -482,6 +482,18 @@ abstract class AppLocalizations {
|
|||
/// **'Preselected reaction emojis'**
|
||||
String get settingsPreSelectedReactions;
|
||||
|
||||
/// No description provided for @settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Mark duplicates as opened'**
|
||||
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle;
|
||||
|
||||
/// No description provided for @settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'If you receive the same media in multiple chats, opening one marks all others as opened.'**
|
||||
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle;
|
||||
|
||||
/// No description provided for @settingsPreSelectedReactionsError.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1178,12 +1190,6 @@ abstract class AppLocalizations {
|
|||
/// **'The plan upgrade must be paid for annually, as the current plan is also billed annually.'**
|
||||
String get errorPlanUpgradeNotYearly;
|
||||
|
||||
/// No description provided for @upgradeToPaidPlan.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Upgrade to a paid plan.'**
|
||||
String get upgradeToPaidPlan;
|
||||
|
||||
/// No description provided for @upgradeToPaidPlanButton.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1322,12 +1328,6 @@ abstract class AppLocalizations {
|
|||
/// **'Delete file'**
|
||||
String get galleryDelete;
|
||||
|
||||
/// No description provided for @galleryDetails.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Show details'**
|
||||
String get galleryDetails;
|
||||
|
||||
/// No description provided for @galleryExport.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -1340,6 +1340,36 @@ abstract class AppLocalizations {
|
|||
/// **'Successfully saved in the Gallery.'**
|
||||
String get galleryExportSuccess;
|
||||
|
||||
/// No description provided for @gallerySelectAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Select all'**
|
||||
String get gallerySelectAll;
|
||||
|
||||
/// No description provided for @galleryDeselectAll.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Deselect all'**
|
||||
String get galleryDeselectAll;
|
||||
|
||||
/// No description provided for @galleryFavorite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Favorite'**
|
||||
String get galleryFavorite;
|
||||
|
||||
/// No description provided for @galleryUnfavorite.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Unfavorite'**
|
||||
String get galleryUnfavorite;
|
||||
|
||||
/// No description provided for @galleryCancel.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Cancel'**
|
||||
String get galleryCancel;
|
||||
|
||||
/// No description provided for @memoriesEmpty.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
|
|
@ -3157,6 +3187,48 @@ abstract class AppLocalizations {
|
|||
/// In en, this message translates to:
|
||||
/// **'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:
|
||||
/// **'Secure by Design'**
|
||||
String get subscriptionPledgeSecureTitle;
|
||||
|
||||
/// No description provided for @subscriptionPledgeSecureDesc.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Your messages and shared moments are fully end-to-end encrypted.'**
|
||||
String get subscriptionPledgeSecureDesc;
|
||||
|
||||
/// No description provided for @subscriptionPledgeNoAdsTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'No Ads or Data selling'**
|
||||
String get subscriptionPledgeNoAdsTitle;
|
||||
|
||||
/// No description provided for @subscriptionPledgeNoAdsDesc.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'twonly will never show advertisements or sell your private data.'**
|
||||
String get subscriptionPledgeNoAdsDesc;
|
||||
|
||||
/// No description provided for @subscriptionPledgeFundedTitle.
|
||||
///
|
||||
/// In en, this message translates to:
|
||||
/// **'Independent and funded by Users'**
|
||||
String get subscriptionPledgeFundedTitle;
|
||||
|
||||
/// No description provided for @subscriptionPledgeFundedDesc.
|
||||
///
|
||||
/// 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;
|
||||
}
|
||||
|
||||
class _AppLocalizationsDelegate
|
||||
|
|
|
|||
|
|
@ -214,6 +214,14 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get settingsPreSelectedReactions => 'Vorgewählte Reaktions-Emojis';
|
||||
|
||||
@override
|
||||
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle =>
|
||||
'Duplikate als geöffnet markieren';
|
||||
|
||||
@override
|
||||
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle =>
|
||||
'Wenn du die selbe Mediendatei in mehreren Chats erhältst, markiert das Öffnen einer Kopie alle anderen als geöffnet.';
|
||||
|
||||
@override
|
||||
String get settingsPreSelectedReactionsError =>
|
||||
'Es können maximal 12 Reaktionen ausgewählt werden.';
|
||||
|
|
@ -601,9 +609,6 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
String get errorPlanUpgradeNotYearly =>
|
||||
'Das Upgrade des Plans muss jährlich bezahlt werden, da der aktuelle Plan ebenfalls jährlich abgerechnet wird.';
|
||||
|
||||
@override
|
||||
String get upgradeToPaidPlan => 'Upgrade auf einen kostenpflichtigen Plan.';
|
||||
|
||||
@override
|
||||
String upgradeToPaidPlanButton(Object planId, Object sufix) {
|
||||
return 'Auf $planId upgraden$sufix';
|
||||
|
|
@ -677,15 +682,27 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
String get galleryDelete => 'Datei löschen';
|
||||
|
||||
@override
|
||||
String get galleryDetails => 'Details anzeigen';
|
||||
|
||||
@override
|
||||
String get galleryExport => 'In Galerie exportieren';
|
||||
|
||||
@override
|
||||
String get galleryExportSuccess => 'Erfolgreich in der Gallery gespeichert.';
|
||||
|
||||
@override
|
||||
String get gallerySelectAll => 'Alle auswählen';
|
||||
|
||||
@override
|
||||
String get galleryDeselectAll => 'Auswahl aufheben';
|
||||
|
||||
@override
|
||||
String get galleryFavorite => 'Als Favorit markieren';
|
||||
|
||||
@override
|
||||
String get galleryUnfavorite => 'Favorit entfernen';
|
||||
|
||||
@override
|
||||
String get galleryCancel => 'Abbrechen';
|
||||
|
||||
@override
|
||||
String get memoriesEmpty =>
|
||||
'Sobald du Bilder oder Videos speicherst, landen sie hier in deinen Erinnerungen.';
|
||||
|
|
@ -1780,4 +1797,29 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||
@override
|
||||
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';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeSecureDesc =>
|
||||
'Deine Nachrichten und Bilder sind immer vollständig Ende-zu-Ende verschlüsselt.';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeNoAdsTitle => 'Keine Werbung oder Datenverkauf';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeNoAdsDesc =>
|
||||
'twonly wird niemals Werbung anzeigen oder deine privaten Daten verkaufen.';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeFundedTitle =>
|
||||
'Unabhängig und durch Nutzer finanziert';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeFundedDesc =>
|
||||
'twonly wird rein durch Nutzer-Abonnements finanziert, um unsere Unabhängigkeit und die Zukunft von twonly zu sichern.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -211,6 +211,14 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get settingsPreSelectedReactions => 'Preselected reaction emojis';
|
||||
|
||||
@override
|
||||
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle =>
|
||||
'Mark duplicates as opened';
|
||||
|
||||
@override
|
||||
String get settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle =>
|
||||
'If you receive the same media in multiple chats, opening one marks all others as opened.';
|
||||
|
||||
@override
|
||||
String get settingsPreSelectedReactionsError =>
|
||||
'A maximum of 12 reactions can be selected.';
|
||||
|
|
@ -595,9 +603,6 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
String get errorPlanUpgradeNotYearly =>
|
||||
'The plan upgrade must be paid for annually, as the current plan is also billed annually.';
|
||||
|
||||
@override
|
||||
String get upgradeToPaidPlan => 'Upgrade to a paid plan.';
|
||||
|
||||
@override
|
||||
String upgradeToPaidPlanButton(Object planId, Object sufix) {
|
||||
return 'Upgrade to $planId$sufix';
|
||||
|
|
@ -671,15 +676,27 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||
@override
|
||||
String get galleryDelete => 'Delete file';
|
||||
|
||||
@override
|
||||
String get galleryDetails => 'Show details';
|
||||
|
||||
@override
|
||||
String get galleryExport => 'Export to gallery';
|
||||
|
||||
@override
|
||||
String get galleryExportSuccess => 'Successfully saved in the Gallery.';
|
||||
|
||||
@override
|
||||
String get gallerySelectAll => 'Select all';
|
||||
|
||||
@override
|
||||
String get galleryDeselectAll => 'Deselect all';
|
||||
|
||||
@override
|
||||
String get galleryFavorite => 'Favorite';
|
||||
|
||||
@override
|
||||
String get galleryUnfavorite => 'Unfavorite';
|
||||
|
||||
@override
|
||||
String get galleryCancel => 'Cancel';
|
||||
|
||||
@override
|
||||
String get memoriesEmpty =>
|
||||
'As soon as you save pictures or videos, they end up here in your memories.';
|
||||
|
|
@ -1764,4 +1781,28 @@ 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';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeSecureDesc =>
|
||||
'Your messages and shared moments are fully end-to-end encrypted.';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeNoAdsTitle => 'No Ads or Data selling';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeNoAdsDesc =>
|
||||
'twonly will never show advertisements or sell your private data.';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeFundedTitle => 'Independent and funded by Users';
|
||||
|
||||
@override
|
||||
String get subscriptionPledgeFundedDesc =>
|
||||
'twonly is funded purely by user subscriptions to secure our independence and support the future of twonly.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
Subproject commit 9218abf0961c072edd2f8aa5035d06a331b853c6
|
||||
Subproject commit f649128fd875a12f23518ff2641190cc129a9339
|
||||
|
|
@ -57,6 +57,9 @@ class UserData {
|
|||
@JsonKey(defaultValue: false)
|
||||
bool requestedAudioPermission = false;
|
||||
|
||||
@JsonKey(defaultValue: false)
|
||||
bool automaticallyMarkEqualMediaFilesAsOpened = false;
|
||||
|
||||
@JsonKey(defaultValue: true)
|
||||
bool videoStabilizationEnabled = true;
|
||||
|
||||
|
|
|
|||
|
|
@ -5,9 +5,11 @@ class MemoryItem {
|
|||
MemoryItem({
|
||||
required this.mediaService,
|
||||
required this.messages,
|
||||
this.sender,
|
||||
});
|
||||
final List<Message> messages;
|
||||
final MediaFileService mediaService;
|
||||
final Contact? sender;
|
||||
|
||||
static Future<Map<String, MemoryItem>> convertFromMessages(
|
||||
List<Message> messages,
|
||||
|
|
|
|||
|
|
@ -121,6 +121,7 @@ Future<void> handleMedia(
|
|||
MediaFile? mediaFile;
|
||||
Message? message;
|
||||
|
||||
Log.info('Starting transaction for media message ${media.senderMessageId}');
|
||||
await twonlyDB.transaction(() async {
|
||||
mediaFile = await twonlyDB.mediaFilesDao.insertOrUpdateMedia(
|
||||
MediaFilesCompanion(
|
||||
|
|
@ -163,6 +164,9 @@ Future<void> handleMedia(
|
|||
),
|
||||
);
|
||||
});
|
||||
Log.info(
|
||||
'Finished transaction for media message ${media.senderMessageId}. Success: ${message != null}',
|
||||
);
|
||||
|
||||
if (message != null && mediaFile != null) {
|
||||
await twonlyDB.groupsDao.increaseLastMessageExchange(
|
||||
|
|
|
|||
|
|
@ -353,6 +353,8 @@ Future<void> handleEncryptedFile(String mediaId) async {
|
|||
),
|
||||
);
|
||||
|
||||
await mediaService.hashMediaFile();
|
||||
|
||||
Log.info('Decryption of $mediaId was successful');
|
||||
|
||||
mediaService.encryptedPath.deleteSync();
|
||||
|
|
|
|||
|
|
@ -27,6 +27,16 @@ import 'package:twonly/src/utils/misc.dart';
|
|||
import 'package:workmanager/workmanager.dart' hide TaskStatus;
|
||||
|
||||
final lockRetransmission = Mutex();
|
||||
final Map<String, Mutex> _uploadMutexes = {};
|
||||
|
||||
Future<void> _protectMediaUpload(
|
||||
String mediaId,
|
||||
Future<void> Function() action,
|
||||
) async {
|
||||
final mutex = _uploadMutexes.putIfAbsent(mediaId, Mutex.new);
|
||||
await mutex.protect(action);
|
||||
_uploadMutexes.remove(mediaId);
|
||||
}
|
||||
|
||||
Future<void> reuploadMediaFiles() async {
|
||||
return exclusiveAccess(
|
||||
|
|
@ -42,18 +52,33 @@ Future<void> reuploadMediaFiles() async {
|
|||
|
||||
final contacts = <int, Contact>{};
|
||||
|
||||
for (final receipt in receipts) {
|
||||
for (var receipt in receipts) {
|
||||
if (receipt.retryCount > 1 && receipt.lastRetry != null) {
|
||||
final twentyFourHoursAgo = DateTime.now().subtract(
|
||||
const Duration(hours: 24),
|
||||
const Duration(hours: 6),
|
||||
);
|
||||
if (receipt.lastRetry!.isAfter(twentyFourHoursAgo)) {
|
||||
Log.info(
|
||||
'Ignoring ${receipt.receiptId} as it was retried in the last 24h',
|
||||
'Ignoring ${receipt.receiptId} as it was retried in the last 6h',
|
||||
);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (receipt.retryCount >= 2) {
|
||||
// After two retries, change the receiptId. This addresses a bug where the receiver received the message and marked it as received, but the app was closed before the message was fully processed. Because the receipt was already stored, subsequent retries were detected as duplicates and rejected.
|
||||
final oldReceiptId = receipt.receiptId;
|
||||
final updatedReceipt = await twonlyDB.receiptsDao.rotateReceiptId(
|
||||
oldReceiptId,
|
||||
);
|
||||
if (updatedReceipt == null) continue;
|
||||
|
||||
Log.info(
|
||||
'Changed receiptId $oldReceiptId to ${updatedReceipt.receiptId} as retryCount is ${receipt.retryCount}',
|
||||
);
|
||||
receipt = updatedReceipt;
|
||||
}
|
||||
|
||||
var messageId = receipt.messageId;
|
||||
if (receipt.messageId == null) {
|
||||
Log.info('Message not in receipt. Loading it from the content.');
|
||||
|
|
@ -146,7 +171,7 @@ Future<void> reuploadMediaFiles() async {
|
|||
Log.info('Reuploading media file $messageId');
|
||||
// the media file should be still on the server, so it should be enough
|
||||
// to just resend the message containing the download token.
|
||||
await tryToSendCompleteMessage(receipt: receipt);
|
||||
await tryToSendCompleteMessage(receiptId: receipt.receiptId);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -158,6 +183,7 @@ Future<void> reuploadMediaFile(
|
|||
MediaFile mediaFile,
|
||||
String messageId,
|
||||
) async {
|
||||
return _protectMediaUpload(mediaFile.mediaId, () async {
|
||||
Log.info('Reuploading media file: ${mediaFile.mediaId}');
|
||||
|
||||
await twonlyDB.receiptsDao.updateReceiptByContactAndMessageId(
|
||||
|
|
@ -169,8 +195,14 @@ Future<void> reuploadMediaFile(
|
|||
),
|
||||
);
|
||||
|
||||
final reuploadRequestedBy = (mediaFile.reuploadRequestedBy ?? [])
|
||||
// Refresh media file to get latest reuploadRequestedBy
|
||||
final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById(
|
||||
mediaFile.mediaId,
|
||||
);
|
||||
|
||||
final reuploadRequestedBy = (currentMedia?.reuploadRequestedBy ?? [])
|
||||
..add(contactId);
|
||||
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
MediaFilesCompanion(
|
||||
|
|
@ -185,8 +217,9 @@ Future<void> reuploadMediaFile(
|
|||
if (mediaFileUpdated.uploadRequestPath.existsSync()) {
|
||||
mediaFileUpdated.uploadRequestPath.deleteSync();
|
||||
}
|
||||
unawaited(startBackgroundMediaUpload(mediaFileUpdated));
|
||||
await _startBackgroundMediaUploadInternal(mediaFileUpdated);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
final Mutex _lockPreprocessing = Mutex();
|
||||
|
|
@ -398,6 +431,18 @@ Future<void> insertMediaFileInMessagesTable(
|
|||
}
|
||||
|
||||
Future<void> startBackgroundMediaUpload(MediaFileService mediaService) async {
|
||||
return _protectMediaUpload(
|
||||
mediaService.mediaFile.mediaId,
|
||||
() => _startBackgroundMediaUploadInternal(mediaService),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _startBackgroundMediaUploadInternal(
|
||||
MediaFileService mediaService,
|
||||
) async {
|
||||
// Refresh the media file state inside the mutex
|
||||
await mediaService.updateFromDB();
|
||||
|
||||
if (mediaService.mediaFile.uploadState == UploadState.initialized ||
|
||||
mediaService.mediaFile.uploadState == UploadState.preprocessing) {
|
||||
await mediaService.setUploadState(UploadState.preprocessing);
|
||||
|
|
@ -603,10 +648,7 @@ Future<void> _createUploadRequest(MediaFileService media) async {
|
|||
await media.uploadRequestPath.writeAsBytes(uploadRequestBytes);
|
||||
}
|
||||
|
||||
Mutex protectUpload = Mutex();
|
||||
|
||||
Future<void> _uploadUploadRequest(MediaFileService media) async {
|
||||
await protectUpload.protect(() async {
|
||||
final currentMedia = await twonlyDB.mediaFilesDao.getMediaFileById(
|
||||
media.mediaFile.mediaId,
|
||||
);
|
||||
|
|
@ -614,7 +656,7 @@ Future<void> _uploadUploadRequest(MediaFileService media) async {
|
|||
if (currentMedia == null ||
|
||||
currentMedia.uploadState == UploadState.backgroundUploadTaskStarted) {
|
||||
Log.info('Download for ${media.mediaFile.mediaId} already started.');
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
final apiUrl =
|
||||
|
|
@ -650,7 +692,6 @@ Future<void> _uploadUploadRequest(MediaFileService media) async {
|
|||
} else {
|
||||
unawaited(uploadFileFastOrEnqueue(task, media));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> uploadFileFastOrEnqueue(
|
||||
|
|
|
|||
|
|
@ -80,15 +80,29 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
|||
return null;
|
||||
}
|
||||
}
|
||||
// ignore: parameter_assignments
|
||||
receiptId = receipt.receiptId;
|
||||
|
||||
if (receipt.retryCount >= 2) {
|
||||
// After two retries, change the receiptId. This addresses a bug where the receiver received the message and marked it as received,
|
||||
// but the app was closed before the message was fully processed. Because the receipt was already stored, subsequent retries were
|
||||
// detected as duplicates and rejected.
|
||||
final oldReceiptId = receipt.receiptId;
|
||||
final updatedReceipt = await twonlyDB.receiptsDao.rotateReceiptId(
|
||||
oldReceiptId,
|
||||
);
|
||||
if (updatedReceipt != null) {
|
||||
Log.info(
|
||||
'Changed receiptId $oldReceiptId to ${updatedReceipt.receiptId} as retryCount is ${receipt.retryCount}',
|
||||
);
|
||||
receipt = updatedReceipt;
|
||||
}
|
||||
}
|
||||
|
||||
final contact = await twonlyDB.contactsDao.getContactById(
|
||||
receipt.contactId,
|
||||
);
|
||||
if (contact == null || contact.accountDeleted) {
|
||||
Log.warn('Will not send message again as user does not exist anymore.');
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receiptId);
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -100,13 +114,13 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
|||
}
|
||||
|
||||
final message = pb.Message.fromBuffer(receipt.message)
|
||||
..receiptId = receiptId;
|
||||
..receiptId = receipt.receiptId;
|
||||
|
||||
final encryptedContent = pb.EncryptedContent.fromBuffer(
|
||||
message.encryptedContent,
|
||||
);
|
||||
|
||||
Log.info('Uploading $receiptId.');
|
||||
Log.info('Uploading ${receipt.receiptId}.');
|
||||
|
||||
Uint8List? pushData;
|
||||
if (receipt.retryCount == 0) {
|
||||
|
|
@ -164,7 +178,7 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
|||
if (resp.isError) {
|
||||
Log.warn('Could not transmit message got ${resp.error}.');
|
||||
if (resp.error == ErrorCode.UserIdNotFound) {
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receiptId);
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
|
||||
await twonlyDB.contactsDao.updateContact(
|
||||
receipt.contactId,
|
||||
const ContactsCompanion(accountDeleted: Value(true)),
|
||||
|
|
@ -182,10 +196,10 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
|||
);
|
||||
}
|
||||
if (!receipt.contactWillSendsReceipt) {
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receiptId);
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
|
||||
} else {
|
||||
await twonlyDB.receiptsDao.updateReceipt(
|
||||
receiptId,
|
||||
receipt.receiptId,
|
||||
ReceiptsCompanion(
|
||||
ackByServerAt: Value(clock.now()),
|
||||
retryCount: Value(receipt.retryCount + 1),
|
||||
|
|
@ -197,8 +211,8 @@ Future<(Uint8List, Uint8List?)?> tryToSendCompleteMessage({
|
|||
}
|
||||
} catch (e) {
|
||||
Log.error('Unknown Error when sending message: $e');
|
||||
if (receiptId != null) {
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receiptId);
|
||||
if (receipt != null) {
|
||||
await twonlyDB.receiptsDao.deleteReceipt(receipt.receiptId);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import 'dart:io';
|
|||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:hashlib/random.dart';
|
||||
import 'package:mutex/mutex.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/daos/contacts.dao.dart';
|
||||
|
|
@ -77,24 +78,32 @@ Future<void> handleServerMessage(server.ServerToClient msg) async {
|
|||
|
||||
DateTime lastPushKeyRequest = clock.now().subtract(const Duration(hours: 1));
|
||||
|
||||
final Map<String, Mutex> _messageLocks = {};
|
||||
|
||||
Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
|
||||
final body = Uint8List.fromList(newMessage.body);
|
||||
final fromUserId = newMessage.fromUserId.toInt();
|
||||
|
||||
final message = Message.fromBuffer(body);
|
||||
final receiptId = message.receiptId;
|
||||
|
||||
final mutex = _messageLocks.putIfAbsent(receiptId, Mutex.new);
|
||||
await mutex.protect(() async {
|
||||
await _handleClient2ClientMessage(newMessage, message);
|
||||
});
|
||||
_messageLocks.remove(receiptId);
|
||||
}
|
||||
|
||||
Future<void> _handleClient2ClientMessage(
|
||||
NewMessage newMessage,
|
||||
Message message,
|
||||
) async {
|
||||
final fromUserId = newMessage.fromUserId.toInt();
|
||||
final receiptId = message.receiptId;
|
||||
|
||||
if (await twonlyDB.receiptsDao.isDuplicated(receiptId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await twonlyDB.receiptsDao.gotReceipt(receiptId);
|
||||
Log.info('Got a message with receiptId $receiptId');
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
return;
|
||||
}
|
||||
Log.info('Started processing message with receiptId $receiptId');
|
||||
|
||||
switch (message.type) {
|
||||
case Message_Type.SENDER_DELIVERY_RECEIPT:
|
||||
|
|
@ -209,7 +218,14 @@ Future<void> handleClient2ClientMessage(NewMessage newMessage) async {
|
|||
await tryToSendCompleteMessage(receiptId: receiptId);
|
||||
}
|
||||
case Message_Type.TEST_NOTIFICATION:
|
||||
return;
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
await twonlyDB.receiptsDao.gotReceipt(receiptId);
|
||||
Log.info('Got a message with receiptId $receiptId');
|
||||
} catch (e) {
|
||||
Log.error('Error marking message as received $receiptId: $e');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -238,6 +254,8 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
|
|||
);
|
||||
}
|
||||
|
||||
Log.info('Calling handleEncryptedMessage for $receiptId');
|
||||
|
||||
final (a, b) = await handleEncryptedMessage(
|
||||
fromUserId,
|
||||
encryptedContent,
|
||||
|
|
@ -245,6 +263,8 @@ Future<(EncryptedContent?, PlaintextContent?)> handleEncryptedMessageRaw(
|
|||
receiptId,
|
||||
);
|
||||
|
||||
Log.info('Finished handleEncryptedMessage for $receiptId');
|
||||
|
||||
if (Platform.isAndroid && a == null && b == null) {
|
||||
// Message was handled without any error -> Show push notification to the user.
|
||||
await showPushNotificationFromServerMessages(fromUserId, encryptedContent);
|
||||
|
|
|
|||
|
|
@ -104,10 +104,10 @@ Future<void> incFlameCounter(
|
|||
contact.userId,
|
||||
ContactsCompanion(
|
||||
mediaReceivedCounter: Value(
|
||||
contacts.first.mediaReceivedCounter + (received ? 1 : 0),
|
||||
contact.mediaReceivedCounter + (received ? 1 : 0),
|
||||
),
|
||||
mediaSendCounter: Value(
|
||||
contacts.first.mediaSendCounter + (received ? 0 : 1),
|
||||
contact.mediaSendCounter + (received ? 0 : 1),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import 'dart:io';
|
|||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:image/image.dart' as img;
|
||||
import 'package:path/path.dart';
|
||||
import 'package:twonly/globals.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
|
|
@ -282,15 +284,19 @@ class MediaFileService {
|
|||
);
|
||||
}
|
||||
unawaited(createThumbnail());
|
||||
await hashStoredMedia();
|
||||
await hashMediaFile();
|
||||
// updateFromDb is done in hashStoredMedia()
|
||||
}
|
||||
|
||||
Future<void> hashStoredMedia() async {
|
||||
if (!storedPath.existsSync()) {
|
||||
Future<void> hashMediaFile() async {
|
||||
late final List<int> checksum;
|
||||
if (storedPath.existsSync()) {
|
||||
checksum = await sha256File(storedPath);
|
||||
} else if (tempPath.existsSync()) {
|
||||
checksum = await sha256File(tempPath);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
final checksum = await sha256File(storedPath);
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
MediaFilesCompanion(
|
||||
|
|
@ -372,4 +378,142 @@ class MediaFileService {
|
|||
namePrefix: '.overlay',
|
||||
extensionParam: 'png',
|
||||
);
|
||||
|
||||
Future<void> cropTransparentBorders() async {
|
||||
if (mediaFile.type != MediaType.image) {
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storedPath.existsSync()) {
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final bytes = storedPath.readAsBytesSync();
|
||||
final image = img.decodeImage(bytes);
|
||||
if (image == null) {
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var minY = 0;
|
||||
var maxY = image.height - 1;
|
||||
var minX = 0;
|
||||
var maxX = image.width - 1;
|
||||
|
||||
var found = false;
|
||||
for (var y = 0; y < image.height; y++) {
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
minY = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var y = image.height - 1; y >= minY; y--) {
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
maxY = y;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var x = 0; x < image.width; x++) {
|
||||
for (var y = minY; y <= maxY; y++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
minX = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
found = false;
|
||||
for (var x = image.width - 1; x >= minX; x--) {
|
||||
for (var y = minY; y <= maxY; y++) {
|
||||
if (image.getPixel(x, y).a > 10) {
|
||||
maxX = x;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (found) break;
|
||||
}
|
||||
|
||||
final newWidth = maxX - minX + 1;
|
||||
final newHeight = maxY - minY + 1;
|
||||
|
||||
if (minY > 0 ||
|
||||
maxY < image.height - 1 ||
|
||||
minX > 0 ||
|
||||
maxX < image.width - 1) {
|
||||
if (newWidth > 10 && newHeight > 10) {
|
||||
final cropped = img.copyCrop(
|
||||
image,
|
||||
x: minX,
|
||||
y: minY,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
);
|
||||
final pngBytes = img.encodePng(cropped);
|
||||
final webpBytes = await FlutterImageCompress.compressWithList(
|
||||
pngBytes,
|
||||
format: CompressFormat.webp,
|
||||
quality: 90,
|
||||
);
|
||||
storedPath.writeAsBytesSync(webpBytes);
|
||||
|
||||
if (thumbnailPath.existsSync()) {
|
||||
thumbnailPath.deleteSync();
|
||||
}
|
||||
await createThumbnail();
|
||||
final checksum = await sha256File(storedPath);
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
MediaFilesCompanion(
|
||||
hasCropAnalyzed: const Value(true),
|
||||
storedFileHash: Value(Uint8List.fromList(checksum)),
|
||||
),
|
||||
);
|
||||
await updateFromDB();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
||||
);
|
||||
await updateFromDB();
|
||||
} catch (e) {
|
||||
Log.error(
|
||||
'Error auto-cropping transparent borders for mediaId ${mediaFile.mediaId}: $e',
|
||||
);
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaFile.mediaId,
|
||||
const MediaFilesCompanion(hasCropAnalyzed: Value(true)),
|
||||
);
|
||||
await updateFromDB();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
340
lib/src/services/memories/memories.service.dart
Normal file
340
lib/src/services/memories/memories.service.dart
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.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/keyvalue.dart';
|
||||
import 'package:twonly/src/utils/log.dart';
|
||||
|
||||
class MemoriesState {
|
||||
const MemoriesState({
|
||||
required this.filesToMigrate,
|
||||
required this.galleryItems,
|
||||
required this.months,
|
||||
required this.orderedByMonth,
|
||||
required this.galleryItemsLastYears,
|
||||
});
|
||||
|
||||
final int filesToMigrate;
|
||||
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;
|
||||
bool get isEmpty => galleryItems.isEmpty && filesToMigrate == 0;
|
||||
|
||||
MemoriesState copyWith({
|
||||
int? filesToMigrate,
|
||||
List<MemoryItem>? galleryItems,
|
||||
List<String>? months,
|
||||
Map<String, List<int>>? orderedByMonth,
|
||||
Map<int, List<MemoryItem>>? galleryItemsLastYears,
|
||||
}) {
|
||||
return MemoriesState(
|
||||
filesToMigrate: filesToMigrate ?? this.filesToMigrate,
|
||||
galleryItems: galleryItems ?? this.galleryItems,
|
||||
months: months ?? this.months,
|
||||
orderedByMonth: orderedByMonth ?? this.orderedByMonth,
|
||||
galleryItemsLastYears:
|
||||
galleryItemsLastYears ?? this.galleryItemsLastYears,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class MemoriesService {
|
||||
MemoriesService() {
|
||||
if (_cachedState != null) {
|
||||
_currentState = _cachedState!;
|
||||
}
|
||||
unawaited(_initAsync());
|
||||
}
|
||||
|
||||
static MemoriesState? _cachedState;
|
||||
|
||||
final _stateController = StreamController<MemoriesState>.broadcast();
|
||||
Stream<MemoriesState> get watchState => _stateController.stream;
|
||||
|
||||
MemoriesState _currentState = const MemoriesState(
|
||||
filesToMigrate: 0,
|
||||
galleryItems: [],
|
||||
months: [],
|
||||
orderedByMonth: {},
|
||||
galleryItemsLastYears: {},
|
||||
);
|
||||
|
||||
MemoriesState get currentState => _currentState;
|
||||
|
||||
StreamSubscription<List<MediaFile>>? _dbSubscription;
|
||||
|
||||
/// Instantly pre-warms the gallery state from disk cache during app loading
|
||||
static Future<void> prewarmCache() async {
|
||||
try {
|
||||
final data = await KeyValueStore.get('memories_cache');
|
||||
if (data != null && data['items'] is List) {
|
||||
final itemList = data['items'] as List;
|
||||
if (itemList.isEmpty) return;
|
||||
|
||||
final mediaIds = itemList
|
||||
.map((e) => (e as Map<String, dynamic>)['mediaId'] as String?)
|
||||
.whereType<String>()
|
||||
.toList();
|
||||
|
||||
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>>{};
|
||||
|
||||
for (final itemJson in itemList) {
|
||||
final map = itemJson as Map<String, dynamic>;
|
||||
final mediaId = map['mediaId'] as String?;
|
||||
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
|
||||
? 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,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
Log.error('Error prewarming memories cache: $e');
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
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 item = MemoryItem(
|
||||
mediaService: mediaService,
|
||||
messages: [],
|
||||
sender: senderContact,
|
||||
);
|
||||
|
||||
tempGalleryItems.add(item);
|
||||
|
||||
if (mediaFile.createdAt.month == now.month &&
|
||||
mediaFile.createdAt.day == now.day) {
|
||||
final diff = now.year - mediaFile.createdAt.year;
|
||||
if (diff > 0) {
|
||||
tempGalleryItemsLastYears.putIfAbsent(diff, () => []).add(item);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Sort descending by creation date
|
||||
tempGalleryItems.sort(
|
||||
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
|
||||
a.mediaService.mediaFile.createdAt,
|
||||
),
|
||||
);
|
||||
|
||||
final tempOrderedByMonth = <String, List<int>>{};
|
||||
final tempMonths = <String>[];
|
||||
var lastMonth = '';
|
||||
|
||||
// High performance grouping leveraging pre-computed createdAtMonth column
|
||||
for (var i = 0; i < tempGalleryItems.length; i++) {
|
||||
final mFile = tempGalleryItems[i].mediaService.mediaFile;
|
||||
final month =
|
||||
mFile.createdAtMonth ??
|
||||
DateFormat('MMMM yyyy').format(mFile.createdAt);
|
||||
if (lastMonth != month) {
|
||||
lastMonth = month;
|
||||
tempMonths.add(month);
|
||||
}
|
||||
tempOrderedByMonth.putIfAbsent(month, () => []).add(i);
|
||||
}
|
||||
|
||||
for (final list in tempGalleryItemsLastYears.values) {
|
||||
list.sort(
|
||||
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
|
||||
a.mediaService.mediaFile.createdAt,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final sortedGalleryItemsLastYears =
|
||||
SplayTreeMap<int, List<MemoryItem>>.from(tempGalleryItemsLastYears);
|
||||
|
||||
final newState = MemoriesState(
|
||||
filesToMigrate: _currentState.filesToMigrate,
|
||||
galleryItems: tempGalleryItems,
|
||||
months: tempMonths,
|
||||
orderedByMonth: tempOrderedByMonth,
|
||||
galleryItemsLastYears: sortedGalleryItemsLastYears,
|
||||
);
|
||||
|
||||
_cachedState = newState;
|
||||
_updateStateWithObject(newState);
|
||||
|
||||
// Persist to KeyValueStore cache asynchronously
|
||||
final cacheList = tempGalleryItems
|
||||
.map(
|
||||
(item) => {
|
||||
'mediaId': item.mediaService.mediaFile.mediaId,
|
||||
'senderUserId': item.sender?.userId,
|
||||
},
|
||||
)
|
||||
.toList();
|
||||
unawaited(KeyValueStore.put('memories_cache', {'items': cacheList}));
|
||||
} catch (e) {
|
||||
Log.error('Error processing media files stream in MemoriesService: $e');
|
||||
}
|
||||
}
|
||||
|
||||
void _updateStateWithObject(MemoriesState newState) {
|
||||
_currentState = newState;
|
||||
if (!_stateController.isClosed) {
|
||||
_stateController.add(_currentState);
|
||||
}
|
||||
}
|
||||
|
||||
void _updateState({int? filesToMigrate}) {
|
||||
_currentState = _currentState.copyWith(filesToMigrate: filesToMigrate);
|
||||
if (!_stateController.isClosed) {
|
||||
_stateController.add(_currentState);
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_dbSubscription?.cancel();
|
||||
_stateController.close();
|
||||
}
|
||||
}
|
||||
|
|
@ -41,8 +41,10 @@ signalDecryptMessage(
|
|||
int type,
|
||||
) async {
|
||||
// Hold the lock only for the cryptographic operation, not for network I/O
|
||||
Log.info('Acquiring lockingSignalProtocol for $fromUserId');
|
||||
final (decryptedContent, errorType, needsResync) = await lockingSignalProtocol
|
||||
.protect(() async {
|
||||
Log.info('Lock acquired for $fromUserId');
|
||||
try {
|
||||
final session = SessionCipher.fromStore(
|
||||
(await getSignalStore())!,
|
||||
|
|
@ -97,6 +99,8 @@ signalDecryptMessage(
|
|||
}
|
||||
});
|
||||
|
||||
Log.info('Released lockingSignalProtocol for $fromUserId');
|
||||
|
||||
// Handle session resync OUTSIDE the lock to avoid holding it during
|
||||
// network round-trips (which can block for up to 60 seconds)
|
||||
if (needsResync && !resyncedUsers.contains(fromUserId)) {
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import 'package:clock/clock.dart';
|
|||
import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart';
|
||||
import 'package:twonly/core/bridge/wrapper/key_manager.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/signal/signal_signed_pre_key_store.dart';
|
||||
import 'package:twonly/src/model/json/signal_identity.model.dart';
|
||||
import 'package:twonly/src/services/signal/consts.signal.dart';
|
||||
import 'package:twonly/src/services/signal/protocol_state.signal.dart';
|
||||
|
|
@ -93,19 +94,38 @@ Future<Uint8List> getUserPublicKey() async {
|
|||
|
||||
Future<void> createIfNotExistsSignalIdentity() async {
|
||||
// check if identity already exists
|
||||
if (await getSignalIdentity() != null) return;
|
||||
final existingIdentity = await getSignalIdentity();
|
||||
if (existingIdentity != null) {
|
||||
final store = await getSignalStoreFromIdentity(existingIdentity);
|
||||
final keys = await store.loadSignedPreKeys();
|
||||
if (keys.isEmpty) {
|
||||
Log.warn(
|
||||
'Signal identity exists but signed prekeys are missing. Generating a new one.',
|
||||
);
|
||||
final keyPair = await store.getIdentityKeyPair();
|
||||
final signedPreKey = generateSignedPreKey(keyPair, defaultDeviceId);
|
||||
await SignalSignedPreKeyStore().storeSignedPreKey(
|
||||
signedPreKey.id,
|
||||
signedPreKey,
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final identityKeyPair = generateIdentityKeyPair();
|
||||
final registrationId = generateRegistrationId(true);
|
||||
|
||||
final signedPreKey = generateSignedPreKey(identityKeyPair, defaultDeviceId);
|
||||
final signedPreKeyStore = <int, Uint8List>{};
|
||||
signedPreKeyStore[signedPreKey.id] = signedPreKey.serialize();
|
||||
|
||||
await SignalSignedPreKeyStore().storeSignedPreKey(
|
||||
signedPreKey.id,
|
||||
signedPreKey,
|
||||
);
|
||||
|
||||
await RustKeyManager.importSignalIdentity(
|
||||
identityKeyPairStructure: identityKeyPair.serialize(),
|
||||
registrationId: registrationId,
|
||||
signedPreKeyStore: signedPreKeyStore,
|
||||
signedPreKeyStore: const {},
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -66,6 +66,7 @@ class UserDiscoveryService {
|
|||
|
||||
static bool shouldRequestManualApproval(Contact c) {
|
||||
final u = userService.currentUser;
|
||||
if (!c.accepted || c.blocked) return false;
|
||||
if (!u.isUserDiscoveryEnabled) return false;
|
||||
if (c.mediaSendCounter < u.requiredSendImages) return false;
|
||||
if (c.userDiscoveryExcluded) return false;
|
||||
|
|
|
|||
|
|
@ -49,6 +49,9 @@ Future<void> handleUserStudyUpload() async {
|
|||
final udVerifiedByContactsCount = await twonlyDB.keyVerificationDao
|
||||
.getTransferredTrustVerificationsCount();
|
||||
|
||||
final totalContactsWithVerificationBadge = await twonlyDB.keyVerificationDao
|
||||
.getCountOfContactsWithVerificationBadge();
|
||||
|
||||
final udFriendsShared = await twonlyDB.contactsDao
|
||||
.getContactsAnnouncedViaUserDiscovery();
|
||||
|
||||
|
|
@ -87,6 +90,8 @@ Future<void> handleUserStudyUpload() async {
|
|||
|
||||
'accepted_contacts': contacts.where((c) => c.accepted).length,
|
||||
'verified_contacts': verifications.length,
|
||||
'total_contacts_with_verification_badge':
|
||||
totalContactsWithVerificationBadge,
|
||||
'verified_contacts_via_migrated_from_old_version': verifications.values
|
||||
.where((c) => c == VerificationType.migratedFromOldVersion)
|
||||
.length,
|
||||
|
|
|
|||
|
|
@ -146,11 +146,6 @@ class MainCameraController {
|
|||
);
|
||||
try {
|
||||
await cameraController?.initialize();
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
cameraController = null; // ensure uninitialized controller is not reused
|
||||
return;
|
||||
}
|
||||
await cameraController?.startImageStream(_processCameraImage);
|
||||
await cameraController?.setZoomLevel(selectedCameraDetails.scaleFactor);
|
||||
if (userService.currentUser.videoStabilizationEnabled && !kDebugMode) {
|
||||
|
|
@ -158,6 +153,10 @@ class MainCameraController {
|
|||
VideoStabilizationMode.level1,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
cameraController = null;
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (!isVideoRecording) {
|
||||
|
|
@ -179,6 +178,7 @@ class MainCameraController {
|
|||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await cameraController?.lockCaptureOrientation(
|
||||
DeviceOrientation.portraitUp,
|
||||
);
|
||||
|
|
@ -202,6 +202,11 @@ class MainCameraController {
|
|||
setFilter(FaceFilterType.none);
|
||||
zoomButtonKey = GlobalKey();
|
||||
setState();
|
||||
} catch (e) {
|
||||
Log.error(e);
|
||||
cameraController = null;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onDoubleTap() async {
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@ 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/visual/views/chats/chat_messages_components/message_send_state_icon.dart';
|
||||
import 'package:twonly/src/visual/views/shared/memory_item_slider.view.dart';
|
||||
import 'package:twonly/src/visual/views/shared/memory_item_thumbnail.comp.dart';
|
||||
import 'package:twonly/src/visual/views/memories/components/memory_thumbnail.comp.dart';
|
||||
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
|
||||
|
||||
class InChatMediaViewer extends StatefulWidget {
|
||||
const InChatMediaViewer({
|
||||
|
|
@ -36,6 +36,8 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
int? galleryItemIndex;
|
||||
StreamSubscription<Message?>? messageStream;
|
||||
Timer? _timer;
|
||||
late final ValueNotifier<String?> _activeMediaIdNotifier =
|
||||
ValueNotifier(widget.message.mediaId);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
|
|
@ -71,10 +73,10 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
messageStream?.cancel();
|
||||
_timer?.cancel();
|
||||
// videoController?.dispose();
|
||||
_activeMediaIdNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> initStream() async {
|
||||
|
|
@ -99,14 +101,27 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
|
||||
Future<void> onTap() async {
|
||||
if (galleryItemIndex == null) return;
|
||||
_activeMediaIdNotifier.value = widget.message.mediaId;
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView(
|
||||
transitionDuration: const Duration(milliseconds: 350),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 350),
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return SynchronizedImageViewerScreen(
|
||||
galleryItems: widget.galleryItems,
|
||||
initialIndex: galleryItemIndex!,
|
||||
),
|
||||
activeMediaIdNotifier: _activeMediaIdNotifier,
|
||||
);
|
||||
},
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
@ -148,9 +163,10 @@ class _InChatMediaViewerState extends State<InChatMediaViewer> {
|
|||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: galleryItemIndex != null
|
||||
? MemoriesItemThumbnailComp(
|
||||
? MemoriesThumbnailComp(
|
||||
galleryItem: widget.galleryItems[galleryItemIndex!],
|
||||
onTap: onTap,
|
||||
activeMediaIdNotifier: _activeMediaIdNotifier,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ import 'package:twonly/src/visual/components/emoji_picker.bottom.dart';
|
|||
import 'package:twonly/src/visual/context_menu/context_menu.helper.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor_components/layer_data.dart';
|
||||
import 'package:twonly/src/visual/views/chats/message_info.view.dart';
|
||||
import 'package:twonly/src/visual/views/shared/memory_item_slider.view.dart';
|
||||
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
|
||||
|
||||
class MessageContextMenu extends StatelessWidget {
|
||||
const MessageContextMenu({
|
||||
|
|
@ -77,9 +77,22 @@ class MessageContextMenu extends StatelessWidget {
|
|||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView(
|
||||
transitionDuration: const Duration(milliseconds: 350),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 350),
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return SynchronizedImageViewerScreen(
|
||||
galleryItems: galleryItems,
|
||||
),
|
||||
initialIndex: 0,
|
||||
activeMediaIdNotifier:
|
||||
ValueNotifier(mediaFileService!.mediaFile.mediaId),
|
||||
);
|
||||
},
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -339,9 +339,28 @@ class _MediaViewerViewState extends State<MediaViewerView> {
|
|||
}
|
||||
}
|
||||
|
||||
var markAsOpenMessageIDs = [currentMessage!.messageId];
|
||||
|
||||
if (userService.currentUser.automaticallyMarkEqualMediaFilesAsOpened &&
|
||||
currentMediaLocal.mediaFile.storedFileHash != null) {
|
||||
final messageIds = await twonlyDB.mediaFilesDao.getMessageIdsByMediaHash(
|
||||
currentMediaLocal.mediaFile.storedFileHash!,
|
||||
currentMessage!.senderId!,
|
||||
);
|
||||
|
||||
if (!messageIds.contains(currentMessage!.messageId)) {
|
||||
Log.error(
|
||||
'Original message ID was not returned from `getMessageIdsByMediaHash`.',
|
||||
);
|
||||
messageIds.add(currentMessage!.messageId);
|
||||
}
|
||||
|
||||
markAsOpenMessageIDs = messageIds;
|
||||
}
|
||||
|
||||
await notifyContactAboutOpeningMessage(
|
||||
currentMessage!.senderId!,
|
||||
[currentMessage!.messageId],
|
||||
markAsOpenMessageIDs,
|
||||
);
|
||||
|
||||
if (!mounted) return;
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ class HomeViewState extends State<HomeView> {
|
|||
int _activePageIdx = 1;
|
||||
double _offsetRatio = 0;
|
||||
double _offsetFromOne = 0;
|
||||
bool _isBottomNavVisible = true;
|
||||
Timer? _disableCameraTimer;
|
||||
|
||||
final MainCameraController _mainCameraController = MainCameraController();
|
||||
|
|
@ -174,6 +175,29 @@ class HomeViewState extends State<HomeView> {
|
|||
bool _onPageView(ScrollNotification notification) {
|
||||
_disableCameraTimer?.cancel();
|
||||
|
||||
if (notification.depth > 0 && notification.metrics.axis == Axis.vertical) {
|
||||
if (_activePageIdx == 2 &&
|
||||
notification.metrics.pixels < 100 &&
|
||||
!_isBottomNavVisible) {
|
||||
setState(() {
|
||||
_isBottomNavVisible = true;
|
||||
});
|
||||
} else if (notification is ScrollUpdateNotification) {
|
||||
final delta = notification.scrollDelta ?? 0;
|
||||
if (delta > 5 &&
|
||||
_isBottomNavVisible &&
|
||||
(_activePageIdx != 2 || notification.metrics.pixels >= 100)) {
|
||||
setState(() {
|
||||
_isBottomNavVisible = false;
|
||||
});
|
||||
} else if (delta < -5 && !_isBottomNavVisible) {
|
||||
setState(() {
|
||||
_isBottomNavVisible = true;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (notification.depth == 0 && notification is ScrollUpdateNotification) {
|
||||
setState(() {
|
||||
_offsetFromOne = 1.0 - (_homeViewPageController.page ?? 0);
|
||||
|
|
@ -259,11 +283,18 @@ class HomeViewState extends State<HomeView> {
|
|||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
bottomNavigationBar: AnimatedSize(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
curve: Curves.easeInOut,
|
||||
child: _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,
|
||||
|
|
@ -292,6 +323,8 @@ class HomeViewState extends State<HomeView> {
|
|||
if (mounted) setState(() {});
|
||||
},
|
||||
currentIndex: _activePageIdx,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
class MemoriesFlashbackBannerComp extends StatelessWidget {
|
||||
const MemoriesFlashbackBannerComp({
|
||||
required this.lastYears,
|
||||
required this.onOpenFlashback,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final Map<int, List<MemoryItem>> lastYears;
|
||||
final void Function(List<MemoryItem> items, int index) onOpenFlashback;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (lastYears.isEmpty) {
|
||||
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||
}
|
||||
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: lastYears.length + 1,
|
||||
separatorBuilder: (context, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, idx) {
|
||||
if (idx == 0) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
idx -= 1;
|
||||
final entry = lastYears.entries.elementAt(idx);
|
||||
final years = entry.key;
|
||||
final items = entry.value;
|
||||
|
||||
var text = context.lang.memoriesAYearAgo;
|
||||
if (years > 1) {
|
||||
text = context.lang.memoriesXYearsAgo(years);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => onOpenFlashback(items, 0),
|
||||
child: Container(
|
||||
width: 120,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 6,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
Image.file(
|
||||
items.first.mediaService.storedPath,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.center,
|
||||
colors: [
|
||||
Colors.black.withValues(alpha: 0.7),
|
||||
Colors.transparent,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 12,
|
||||
left: 8,
|
||||
right: 8,
|
||||
child: Text(
|
||||
text,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,302 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/views/memories/components/memory_transition_painter.dart';
|
||||
|
||||
class MemoriesThumbnailComp extends StatefulWidget {
|
||||
const MemoriesThumbnailComp({
|
||||
required this.galleryItem,
|
||||
required this.onTap,
|
||||
this.index = 0,
|
||||
this.onLongPress,
|
||||
this.selectionMode = false,
|
||||
this.isSelected = false,
|
||||
this.activeMediaIdNotifier,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MemoryItem galleryItem;
|
||||
final int index;
|
||||
final GestureTapCallback onTap;
|
||||
final GestureLongPressCallback? onLongPress;
|
||||
final bool selectionMode;
|
||||
final bool isSelected;
|
||||
final ValueNotifier<String?>? activeMediaIdNotifier;
|
||||
|
||||
@override
|
||||
State<MemoriesThumbnailComp> createState() => _MemoriesThumbnailCompState();
|
||||
}
|
||||
|
||||
final Set<String> _alreadyAnimatedIds = {};
|
||||
|
||||
class _MemoriesThumbnailCompState extends State<MemoriesThumbnailComp>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _scaleController;
|
||||
late final Animation<double> _scaleAnimation;
|
||||
late final Animation<Offset> _slideAnimation;
|
||||
|
||||
ImageProvider? _imageProvider;
|
||||
ImageStream? _imageStream;
|
||||
ImageInfo? _imageInfo;
|
||||
late final ImageStreamListener _listener;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scaleController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 350),
|
||||
);
|
||||
_scaleAnimation = Tween<double>(begin: 0.94, end: 1).animate(
|
||||
CurvedAnimation(parent: _scaleController, curve: Curves.easeOutCubic),
|
||||
);
|
||||
_slideAnimation =
|
||||
Tween<Offset>(
|
||||
begin: const Offset(0, 0.125),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(parent: _scaleController, curve: Curves.easeOutCubic),
|
||||
);
|
||||
|
||||
final mediaId = widget.galleryItem.mediaService.mediaFile.mediaId;
|
||||
final shouldAnimate =
|
||||
widget.index < 20 && !_alreadyAnimatedIds.contains(mediaId);
|
||||
|
||||
if (shouldAnimate) {
|
||||
_alreadyAnimatedIds.add(mediaId);
|
||||
final delayMs = widget.index * 10;
|
||||
if (delayMs > 0) {
|
||||
Future.delayed(Duration(milliseconds: delayMs), () {
|
||||
if (mounted) {
|
||||
_scaleController.forward();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_scaleController.forward();
|
||||
}
|
||||
} else {
|
||||
_scaleController.value = 1.0;
|
||||
}
|
||||
|
||||
_listener = ImageStreamListener((info, _) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageInfo = info;
|
||||
});
|
||||
}
|
||||
});
|
||||
_resolveImage();
|
||||
}
|
||||
|
||||
void _resolveImage() {
|
||||
final media = widget.galleryItem.mediaService;
|
||||
final hasThumbnail = media.thumbnailPath.existsSync();
|
||||
final hasStored = media.storedPath.existsSync();
|
||||
final isImageOrGif =
|
||||
media.mediaFile.type == MediaType.image ||
|
||||
media.mediaFile.type == MediaType.gif;
|
||||
|
||||
if (hasThumbnail) {
|
||||
_imageProvider = FileImage(media.thumbnailPath);
|
||||
} else if (hasStored && isImageOrGif) {
|
||||
_imageProvider = FileImage(media.storedPath);
|
||||
}
|
||||
|
||||
if (_imageProvider != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final config = createLocalImageConfiguration(context);
|
||||
_imageStream = _imageProvider!.resolve(config);
|
||||
_imageStream!.addListener(_listener);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant MemoriesThumbnailComp oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (oldWidget.galleryItem.mediaService.mediaFile.mediaId !=
|
||||
widget.galleryItem.mediaService.mediaFile.mediaId) {
|
||||
_imageStream?.removeListener(_listener);
|
||||
_imageInfo = null;
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleController.dispose();
|
||||
_imageStream?.removeListener(_listener);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = widget.galleryItem.mediaService;
|
||||
final isVideo = media.mediaFile.type == MediaType.video;
|
||||
final cachedInfo = _imageInfo;
|
||||
final mediaId = media.mediaFile.mediaId;
|
||||
|
||||
Widget buildHero(String tag) {
|
||||
return Hero(
|
||||
key: ValueKey(tag),
|
||||
tag: tag,
|
||||
transitionOnUserGestures: true,
|
||||
flightShuttleBuilder: cachedInfo != null
|
||||
? (
|
||||
flightContext,
|
||||
animation,
|
||||
flightDirection,
|
||||
fromHeroContext,
|
||||
toHeroContext,
|
||||
) {
|
||||
return TransitionImage(
|
||||
imageInfo: cachedInfo,
|
||||
animation: animation,
|
||||
thumbnailFit: BoxFit.cover,
|
||||
viewerFit: BoxFit.contain,
|
||||
);
|
||||
}
|
||||
: null,
|
||||
child: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: ScaleTransition(
|
||||
scale: _scaleAnimation,
|
||||
child: FadeTransition(
|
||||
opacity: _scaleController,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSelected
|
||||
? context.color.primary
|
||||
: Colors.transparent,
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black12,
|
||||
blurRadius: 4,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.easeInOut,
|
||||
margin: EdgeInsets.all(widget.isSelected ? 4 : 0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(
|
||||
widget.isSelected ? 12 : 0,
|
||||
),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
if (cachedInfo != null)
|
||||
RawImage(
|
||||
image: cachedInfo.image,
|
||||
fit: BoxFit.cover,
|
||||
)
|
||||
else if (_imageProvider != null)
|
||||
Image(
|
||||
image: _imageProvider!,
|
||||
fit: BoxFit.cover,
|
||||
gaplessPlayback: true,
|
||||
)
|
||||
else
|
||||
ColoredBox(
|
||||
color: Colors.grey.shade200,
|
||||
child: const Center(
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.image,
|
||||
color: Colors.black26,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (isVideo)
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
child: FaIcon(
|
||||
FontAwesomeIcons.circlePlay,
|
||||
color: Colors.white,
|
||||
size: 32,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black54, blurRadius: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (widget.selectionMode)
|
||||
Positioned(
|
||||
top: 6,
|
||||
right: 6,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: widget.isSelected
|
||||
? context.color.primary
|
||||
: Colors.black38,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color:
|
||||
Theme.of(context).brightness ==
|
||||
Brightness.dark
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
child: widget.isSelected
|
||||
? const Icon(
|
||||
Icons.check,
|
||||
size: 14,
|
||||
color: Colors.white,
|
||||
)
|
||||
: const SizedBox(width: 14, height: 14),
|
||||
),
|
||||
),
|
||||
|
||||
if (media.mediaFile.isFavorite)
|
||||
const Positioned(
|
||||
bottom: 6,
|
||||
left: 6,
|
||||
child: Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.redAccent,
|
||||
size: 16,
|
||||
shadows: [
|
||||
Shadow(color: Colors.black54, blurRadius: 4),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
onLongPress: widget.onLongPress,
|
||||
child: widget.activeMediaIdNotifier != null
|
||||
? ValueListenableBuilder<String?>(
|
||||
valueListenable: widget.activeMediaIdNotifier!,
|
||||
builder: (context, activeId, _) {
|
||||
final isActive = activeId == null || activeId == mediaId;
|
||||
return buildHero(
|
||||
isActive ? mediaId : '${mediaId}_grid_inactive',
|
||||
);
|
||||
},
|
||||
)
|
||||
: buildHero(mediaId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,104 @@
|
|||
import 'dart:ui' as ui;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class TransitionImage extends StatelessWidget {
|
||||
const TransitionImage({
|
||||
required this.imageInfo,
|
||||
required this.animation,
|
||||
required this.thumbnailFit,
|
||||
required this.viewerFit,
|
||||
this.background,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final ImageInfo imageInfo;
|
||||
final Animation<double> animation;
|
||||
final BoxFit thumbnailFit;
|
||||
final BoxFit viewerFit;
|
||||
final Color? background;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => CustomPaint(
|
||||
painter: _TransitionImagePainter(
|
||||
image: imageInfo.image,
|
||||
scale: imageInfo.scale,
|
||||
t: animation.value,
|
||||
thumbnailFit: thumbnailFit,
|
||||
viewerFit: viewerFit,
|
||||
background: background,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TransitionImagePainter extends CustomPainter {
|
||||
const _TransitionImagePainter({
|
||||
required this.image,
|
||||
required this.scale,
|
||||
required this.t,
|
||||
required this.thumbnailFit,
|
||||
required this.viewerFit,
|
||||
required this.background,
|
||||
});
|
||||
|
||||
final ui.Image? image;
|
||||
final double scale;
|
||||
final double t;
|
||||
final Color? background;
|
||||
final BoxFit thumbnailFit;
|
||||
final BoxFit viewerFit;
|
||||
|
||||
static final _paint = Paint()
|
||||
..isAntiAlias = true
|
||||
..filterQuality = FilterQuality.medium;
|
||||
static const Alignment _alignment = Alignment.center;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final targetImage = image;
|
||||
if (targetImage == null || size.isEmpty) return;
|
||||
|
||||
final outputSize = size;
|
||||
final inputSize = Size(
|
||||
targetImage.width.toDouble(),
|
||||
targetImage.height.toDouble(),
|
||||
);
|
||||
|
||||
// Calculate source/destination dimensions for both start and end layout states
|
||||
final thumbnailSizes = applyBoxFit(thumbnailFit, inputSize / scale, size);
|
||||
final viewerSizes = applyBoxFit(viewerFit, inputSize / scale, size);
|
||||
|
||||
// Linearly interpolate intermediate source framing and canvas bounds simultaneously
|
||||
final sourceSize =
|
||||
Size.lerp(thumbnailSizes.source, viewerSizes.source, t)! * scale;
|
||||
final destinationSize = Size.lerp(
|
||||
thumbnailSizes.destination,
|
||||
viewerSizes.destination,
|
||||
t,
|
||||
)!;
|
||||
|
||||
final halfWidthDelta = (outputSize.width - destinationSize.width) / 2.0;
|
||||
final halfHeightDelta = (outputSize.height - destinationSize.height) / 2.0;
|
||||
final dx = halfWidthDelta + _alignment.x * halfWidthDelta;
|
||||
final dy = halfHeightDelta + _alignment.y * halfHeightDelta;
|
||||
final destinationRect = Offset(dx, dy) & destinationSize;
|
||||
|
||||
final sourceRect = _alignment.inscribe(sourceSize, Offset.zero & inputSize);
|
||||
|
||||
if (background != null) {
|
||||
canvas.drawRect(destinationRect.deflate(1), Paint()..color = background!);
|
||||
}
|
||||
canvas.drawImageRect(targetImage, sourceRect, destinationRect, _paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant _TransitionImagePainter oldDelegate) {
|
||||
return oldDelegate.t != t ||
|
||||
oldDelegate.image != image ||
|
||||
oldDelegate.scale != scale;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
class MemoriesSelectionToolbarComp extends StatelessWidget {
|
||||
const MemoriesSelectionToolbarComp({
|
||||
required this.selectedCount,
|
||||
required this.areAllSelected,
|
||||
required this.areAllFav,
|
||||
required this.onSelectAll,
|
||||
required this.onExport,
|
||||
required this.onFavorite,
|
||||
required this.onDelete,
|
||||
required this.onClear,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final int selectedCount;
|
||||
final bool areAllSelected;
|
||||
final bool areAllFav;
|
||||
final VoidCallback onSelectAll;
|
||||
final VoidCallback onExport;
|
||||
final VoidCallback onFavorite;
|
||||
final VoidCallback onDelete;
|
||||
final VoidCallback onClear;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: MediaQuery.paddingOf(context).bottom + 24,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: context.color.surface.withValues(alpha: 0.95),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Colors.black26,
|
||||
blurRadius: 16,
|
||||
offset: Offset(0, 4),
|
||||
),
|
||||
],
|
||||
border: Border.all(
|
||||
color: context.color.primary.withValues(alpha: 0.2),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'$selectedCount',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
areAllSelected ? Icons.deselect : Icons.select_all,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: onSelectAll,
|
||||
tooltip: areAllSelected
|
||||
? context.lang.galleryDeselectAll
|
||||
: context.lang.gallerySelectAll,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.color.primary.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
foregroundColor: context.color.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.file_download_outlined, size: 22),
|
||||
onPressed: onExport,
|
||||
tooltip: context.lang.galleryExport,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.color.primary.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
foregroundColor: context.color.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
areAllFav ? Icons.favorite : Icons.favorite_border,
|
||||
size: 22,
|
||||
color: areAllFav ? Colors.redAccent : null,
|
||||
),
|
||||
onPressed: onFavorite,
|
||||
tooltip: areAllFav
|
||||
? context.lang.galleryUnfavorite
|
||||
: context.lang.galleryFavorite,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: context.color.primary.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
foregroundColor: context.color.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete_outline, size: 22),
|
||||
onPressed: onDelete,
|
||||
tooltip: context.lang.galleryDelete,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.redAccent.withValues(
|
||||
alpha: 0.1,
|
||||
),
|
||||
foregroundColor: Colors.redAccent,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 20),
|
||||
onPressed: onClear,
|
||||
tooltip: context.lang.galleryCancel,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/visual/themes/light.dart';
|
||||
|
||||
class SynchronizedViewerActionsToolbarComp extends StatelessWidget {
|
||||
const SynchronizedViewerActionsToolbarComp({
|
||||
required this.isFavorite,
|
||||
required this.onShare,
|
||||
required this.onExport,
|
||||
required this.onToggleFavorite,
|
||||
required this.onDelete,
|
||||
this.showStoreButton = false,
|
||||
this.onStore,
|
||||
this.isImageSaving = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final bool isFavorite;
|
||||
final VoidCallback onShare;
|
||||
final VoidCallback onExport;
|
||||
final VoidCallback onToggleFavorite;
|
||||
final VoidCallback onDelete;
|
||||
final bool showStoreButton;
|
||||
final VoidCallback? onStore;
|
||||
final bool isImageSaving;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
bottom: MediaQuery.paddingOf(context).bottom + 24,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (showStoreButton) ...[
|
||||
IconButton(
|
||||
icon: isImageSaving
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const FaIcon(
|
||||
FontAwesomeIcons.floppyDisk,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
),
|
||||
onPressed: isImageSaving ? null : onStore,
|
||||
tooltip: 'Store media',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black54,
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
],
|
||||
IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.fileArrowDown,
|
||||
color: Colors.white,
|
||||
size: 21,
|
||||
),
|
||||
onPressed: onExport,
|
||||
tooltip: context.lang.galleryExport,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black54,
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: isFavorite ? Colors.redAccent : Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: onToggleFavorite,
|
||||
tooltip: 'Favorite',
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black54,
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
onPressed: onDelete,
|
||||
tooltip: context.lang.galleryDelete,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black54,
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
IconButton(
|
||||
icon: const FaIcon(
|
||||
FontAwesomeIcons.solidPaperPlane,
|
||||
color: primaryColor,
|
||||
size: 22,
|
||||
),
|
||||
onPressed: onShare,
|
||||
tooltip: context.lang.shareImagedEditorSendImage,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.black54,
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +1,18 @@
|
|||
// ignore_for_file: parameter_assignments
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.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/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/snackbar.dart';
|
||||
import 'package:twonly/src/visual/loader/three_rotating_dots.loader.dart';
|
||||
import 'package:twonly/src/visual/views/shared/memory_item_slider.view.dart';
|
||||
import 'package:twonly/src/visual/views/shared/memory_item_thumbnail.comp.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';
|
||||
import 'package:twonly/src/visual/views/memories/synchronized_viewer.view.dart';
|
||||
|
||||
class MemoriesView extends StatefulWidget {
|
||||
const MemoriesView({super.key});
|
||||
|
|
@ -23,109 +22,278 @@ class MemoriesView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class MemoriesViewState extends State<MemoriesView> {
|
||||
int _filesToMigrate = 0;
|
||||
List<MemoryItem> galleryItems = [];
|
||||
Map<String, List<int>> orderedByMonth = {};
|
||||
List<String> months = [];
|
||||
StreamSubscription<List<MediaFile>>? messageSub;
|
||||
late final MemoriesService _service;
|
||||
final ValueNotifier<String?> _activeMediaIdNotifier = ValueNotifier(null);
|
||||
final ScrollController _scrollController = ScrollController();
|
||||
bool _isViewingFlashback = false;
|
||||
|
||||
final Map<int, List<MemoryItem>> _galleryItemsLastYears = {};
|
||||
final Set<String> _selectedMediaIds = {};
|
||||
bool _filterFavoritesOnly = false;
|
||||
bool get _selectionMode => _selectedMediaIds.isNotEmpty;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
unawaited(initAsync());
|
||||
_service = MemoriesService();
|
||||
_activeMediaIdNotifier.addListener(_onActiveMediaChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
messageSub?.cancel();
|
||||
_activeMediaIdNotifier.removeListener(_onActiveMediaChanged);
|
||||
_scrollController.dispose();
|
||||
_service.dispose();
|
||||
_activeMediaIdNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> initAsync() async {
|
||||
final nonHashedFiles = await twonlyDB.mediaFilesDao
|
||||
.getAllNonHashedStoredMediaFiles();
|
||||
if (nonHashedFiles.isNotEmpty) {
|
||||
setState(() {
|
||||
_filesToMigrate = nonHashedFiles.length;
|
||||
});
|
||||
for (final mediaFile in nonHashedFiles) {
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
await mediaService.hashStoredMedia();
|
||||
setState(() {
|
||||
_filesToMigrate -= 1;
|
||||
});
|
||||
}
|
||||
_filesToMigrate = 0;
|
||||
}
|
||||
await messageSub?.cancel();
|
||||
final msgStream = twonlyDB.mediaFilesDao.watchAllStoredMediaFiles();
|
||||
void _onActiveMediaChanged() {
|
||||
if (_isViewingFlashback) return;
|
||||
final mediaId = _activeMediaIdNotifier.value;
|
||||
if (mediaId == null) return;
|
||||
final state = _service.currentState;
|
||||
if (state.isEmpty) return;
|
||||
|
||||
messageSub = msgStream.listen((mediaFiles) async {
|
||||
// Group items by month
|
||||
orderedByMonth = {};
|
||||
months = [];
|
||||
var lastMonth = '';
|
||||
galleryItems = [];
|
||||
|
||||
final now = clock.now();
|
||||
|
||||
for (final mediaFile in mediaFiles) {
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
if (!mediaService.imagePreviewAvailable) continue;
|
||||
if (mediaService.mediaFile.type == MediaType.video) {
|
||||
if (!mediaService.thumbnailPath.existsSync()) {
|
||||
await mediaService.createThumbnail();
|
||||
}
|
||||
}
|
||||
final item = MemoryItem(
|
||||
mediaService: mediaService,
|
||||
messages: [],
|
||||
final index = state.galleryItems.indexWhere(
|
||||
(item) => item.mediaService.mediaFile.mediaId == mediaId,
|
||||
);
|
||||
galleryItems.add(item);
|
||||
if (mediaFile.createdAt.month == now.month &&
|
||||
mediaFile.createdAt.day == now.day) {
|
||||
final diff = now.year - mediaFile.createdAt.year;
|
||||
if (diff > 0) {
|
||||
if (!_galleryItemsLastYears.containsKey(diff)) {
|
||||
_galleryItemsLastYears[diff] = [];
|
||||
if (index == -1) return;
|
||||
|
||||
double offset = 56;
|
||||
if (state.galleryItemsLastYears.isNotEmpty) {
|
||||
offset += 220;
|
||||
}
|
||||
_galleryItemsLastYears[diff]!.add(item);
|
||||
|
||||
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]!;
|
||||
offset += 44;
|
||||
|
||||
if (indices.contains(index)) {
|
||||
final localIdx = indices.indexOf(index);
|
||||
final row = localIdx ~/ 4;
|
||||
offset += row * rowHeight;
|
||||
break;
|
||||
} else {
|
||||
final totalRows = (indices.length + 3) ~/ 4;
|
||||
offset += totalRows * rowHeight;
|
||||
}
|
||||
}
|
||||
|
||||
if (_scrollController.hasClients) {
|
||||
final targetOffset = (offset - 100).clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
_scrollController.jumpTo(targetOffset);
|
||||
}
|
||||
galleryItems.sort(
|
||||
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
|
||||
a.mediaService.mediaFile.createdAt,
|
||||
}
|
||||
|
||||
Future<void> _openViewer(
|
||||
List<MemoryItem> items,
|
||||
int index, {
|
||||
bool isFlashback = false,
|
||||
}) async {
|
||||
if (isFlashback) {
|
||||
_isViewingFlashback = true;
|
||||
}
|
||||
_activeMediaIdNotifier.value = items[index].mediaService.mediaFile.mediaId;
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
transitionDuration: const Duration(milliseconds: 350),
|
||||
reverseTransitionDuration: const Duration(milliseconds: 350),
|
||||
pageBuilder: (context, animation, secondaryAnimation) {
|
||||
return SynchronizedImageViewerScreen(
|
||||
galleryItems: items,
|
||||
initialIndex: index,
|
||||
activeMediaIdNotifier: _activeMediaIdNotifier,
|
||||
);
|
||||
},
|
||||
transitionsBuilder: (context, animation, secondaryAnimation, child) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
for (var i = 0; i < galleryItems.length; i++) {
|
||||
final month = DateFormat(
|
||||
'MMMM yyyy',
|
||||
).format(galleryItems[i].mediaService.mediaFile.createdAt);
|
||||
if (lastMonth != month) {
|
||||
lastMonth = month;
|
||||
months.add(month);
|
||||
|
||||
if (isFlashback) {
|
||||
_isViewingFlashback = false;
|
||||
}
|
||||
orderedByMonth.putIfAbsent(month, () => []).add(i);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
|
||||
void _toggleSelection(String mediaId) {
|
||||
setState(() {
|
||||
if (_selectedMediaIds.contains(mediaId)) {
|
||||
_selectedMediaIds.remove(mediaId);
|
||||
} else {
|
||||
_selectedMediaIds.add(mediaId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onLongPressItem(String mediaId) {
|
||||
setState(() {
|
||||
_selectedMediaIds.add(mediaId);
|
||||
});
|
||||
}
|
||||
|
||||
void _onTapItem(String mediaId, int globalIndex) {
|
||||
if (_selectionMode) {
|
||||
_toggleSelection(mediaId);
|
||||
} else {
|
||||
final state = _service.currentState;
|
||||
var targetItems = state.galleryItems;
|
||||
var targetIndex = globalIndex;
|
||||
|
||||
if (_filterFavoritesOnly) {
|
||||
targetItems = state.galleryItems
|
||||
.where((e) => e.mediaService.mediaFile.isFavorite)
|
||||
.toList();
|
||||
targetIndex = targetItems.indexWhere(
|
||||
(e) => e.mediaService.mediaFile.mediaId == mediaId,
|
||||
);
|
||||
if (targetIndex == -1) targetIndex = 0;
|
||||
}
|
||||
|
||||
_openViewer(targetItems, targetIndex);
|
||||
}
|
||||
}
|
||||
|
||||
void _selectAll() {
|
||||
setState(() {
|
||||
final items = _service.currentState.galleryItems;
|
||||
final targetIds = <String>{};
|
||||
|
||||
for (final item in items) {
|
||||
if (_filterFavoritesOnly) {
|
||||
if (item.mediaService.mediaFile.isFavorite) {
|
||||
targetIds.add(item.mediaService.mediaFile.mediaId);
|
||||
}
|
||||
} else {
|
||||
targetIds.add(item.mediaService.mediaFile.mediaId);
|
||||
}
|
||||
}
|
||||
|
||||
final areAllSelected = targetIds.every(_selectedMediaIds.contains);
|
||||
|
||||
if (areAllSelected) {
|
||||
_selectedMediaIds.removeAll(targetIds);
|
||||
} else {
|
||||
_selectedMediaIds.addAll(targetIds);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _batchDelete() async {
|
||||
final count = _selectedMediaIds.length;
|
||||
final confirmed = await showAlertDialog(
|
||||
context,
|
||||
context.lang.deleteImageTitle,
|
||||
context.lang.deleteImageBody,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
final items = _service.currentState.galleryItems;
|
||||
for (final mediaId in _selectedMediaIds) {
|
||||
final item = items
|
||||
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
||||
.firstOrNull;
|
||||
if (item != null) {
|
||||
item.mediaService.fullMediaRemoval();
|
||||
}
|
||||
await twonlyDB.mediaFilesDao.deleteMediaFile(mediaId);
|
||||
}
|
||||
|
||||
setState(_selectedMediaIds.clear);
|
||||
|
||||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
'Deleted $count items successfully',
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _batchExport() async {
|
||||
final items = _service.currentState.galleryItems;
|
||||
|
||||
try {
|
||||
for (final mediaId in _selectedMediaIds) {
|
||||
final item = items
|
||||
.where((e) => e.mediaService.mediaFile.mediaId == mediaId)
|
||||
.firstOrNull;
|
||||
if (item != null) {
|
||||
final media = item.mediaService;
|
||||
if (media.mediaFile.type == MediaType.video) {
|
||||
await saveVideoToGallery(media.storedPath.path);
|
||||
} else if (media.mediaFile.type == MediaType.image ||
|
||||
media.mediaFile.type == MediaType.gif) {
|
||||
final imageBytes = await media.storedPath.readAsBytes();
|
||||
await saveImageToGallery(imageBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
context.lang.galleryExportSuccess,
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showSnackbar(context, e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _batchFavorite() async {
|
||||
final items = _service.currentState.galleryItems;
|
||||
var favCount = 0;
|
||||
for (final item in items) {
|
||||
if (_selectedMediaIds.contains(item.mediaService.mediaFile.mediaId)) {
|
||||
if (item.mediaService.mediaFile.isFavorite) {
|
||||
favCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
final areAllFav =
|
||||
_selectedMediaIds.isNotEmpty && favCount == _selectedMediaIds.length;
|
||||
final targetFav = !areAllFav;
|
||||
|
||||
for (final mediaId in _selectedMediaIds) {
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaId,
|
||||
MediaFilesCompanion(isFavorite: Value(targetFav)),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Center(
|
||||
child: Text(
|
||||
context.lang.memoriesEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
);
|
||||
if (_filesToMigrate > 0) {
|
||||
child = Center(
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
StreamBuilder<MemoriesState>(
|
||||
initialData: _service.currentState,
|
||||
stream: _service.watchState,
|
||||
builder: (context, snapshot) {
|
||||
final state = snapshot.data ?? _service.currentState;
|
||||
|
||||
if (state.isLoading) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
|
|
@ -133,144 +301,215 @@ class MemoriesViewState extends State<MemoriesView> {
|
|||
size: 40,
|
||||
color: context.color.primary,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.lang.migrationOfMemories(_filesToMigrate),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
} else if (galleryItems.isNotEmpty) {
|
||||
child = ListView.builder(
|
||||
itemCount:
|
||||
(months.length * 2) + (_galleryItemsLastYears.isEmpty ? 0 : 1),
|
||||
itemBuilder: (context, mIndex) {
|
||||
if (_galleryItemsLastYears.isNotEmpty && mIndex == 0) {
|
||||
return SizedBox(
|
||||
height: 140,
|
||||
width: MediaQuery.sizeOf(context).width,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: _galleryItemsLastYears.entries.map(
|
||||
(item) {
|
||||
var text = context.lang.memoriesAYearAgo;
|
||||
if (item.key > 1) {
|
||||
text = context.lang.memoriesXYearsAgo(item.key);
|
||||
}
|
||||
return GestureDetector(
|
||||
onTap: () async {
|
||||
await open(context, item.value, 0);
|
||||
},
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
spreadRadius: -12,
|
||||
blurRadius: 12,
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
height: 150,
|
||||
width: 120,
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Image.file(
|
||||
item.value.first.mediaService.storedPath,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 10,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Text(
|
||||
text,
|
||||
context.lang.migrationOfMemories(state.filesToMigrate),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
shadows: [
|
||||
Shadow(
|
||||
color: Color.fromARGB(122, 0, 0, 0),
|
||||
blurRadius: 5,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.photo_library_outlined,
|
||||
size: 64,
|
||||
color: Colors.grey.shade400,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.lang.memoriesEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
var months = state.months;
|
||||
var orderedByMonth = state.orderedByMonth;
|
||||
final lastYears = state.galleryItemsLastYears;
|
||||
|
||||
if (_filterFavoritesOnly) {
|
||||
final filteredOrdered = <String, List<int>>{};
|
||||
final filteredMonths = <String>[];
|
||||
|
||||
for (final m in months) {
|
||||
final indices = orderedByMonth[m] ?? [];
|
||||
final favIndices = indices.where((idx) {
|
||||
return state
|
||||
.galleryItems[idx]
|
||||
.mediaService
|
||||
.mediaFile
|
||||
.isFavorite;
|
||||
}).toList();
|
||||
|
||||
if (favIndices.isNotEmpty) {
|
||||
filteredOrdered[m] = favIndices;
|
||||
filteredMonths.add(m);
|
||||
}
|
||||
}
|
||||
|
||||
months = filteredMonths;
|
||||
orderedByMonth = filteredOrdered;
|
||||
}
|
||||
|
||||
return Scrollbar(
|
||||
controller: _scrollController,
|
||||
thickness: 12,
|
||||
radius: const Radius.circular(6),
|
||||
interactive: true,
|
||||
child: CustomScrollView(
|
||||
controller: _scrollController,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverAppBar(
|
||||
title: const Text(
|
||||
'Memories',
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
floating: true,
|
||||
snap: true,
|
||||
elevation: 0,
|
||||
backgroundColor: context.color.surface,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
_filterFavoritesOnly
|
||||
? Icons.favorite
|
||||
: Icons.favorite_border,
|
||||
color: _filterFavoritesOnly
|
||||
? Colors.redAccent
|
||||
: null,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_filterFavoritesOnly = !_filterFavoritesOnly;
|
||||
});
|
||||
},
|
||||
).toList(),
|
||||
tooltip: _filterFavoritesOnly
|
||||
? 'Show all'
|
||||
: 'Show favorites only',
|
||||
),
|
||||
);
|
||||
}
|
||||
if (_galleryItemsLastYears.isNotEmpty) {
|
||||
mIndex -= 1;
|
||||
}
|
||||
if (mIndex.isEven) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(8),
|
||||
child: Text(months[(mIndex ~/ 2)]),
|
||||
);
|
||||
}
|
||||
final index = (mIndex - 1) ~/ 2;
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
],
|
||||
),
|
||||
|
||||
MemoriesFlashbackBannerComp(
|
||||
lastYears: lastYears,
|
||||
onOpenFlashback: (items, idx) =>
|
||||
_openViewer(items, idx, isFlashback: true),
|
||||
),
|
||||
|
||||
for (final month in months) ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
month,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverGrid(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
childAspectRatio: 9 / 16,
|
||||
),
|
||||
itemCount: orderedByMonth[months[index]]!.length,
|
||||
itemBuilder: (context, gIndex) {
|
||||
final gaIndex = orderedByMonth[months[index]]![gIndex];
|
||||
return MemoriesItemThumbnailComp(
|
||||
galleryItem: galleryItems[gaIndex],
|
||||
onTap: () async {
|
||||
await open(context, galleryItems, gaIndex);
|
||||
},
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, idx) {
|
||||
final globalIndex = orderedByMonth[month]![idx];
|
||||
final item = state.galleryItems[globalIndex];
|
||||
final mediaId = item.mediaService.mediaFile.mediaId;
|
||||
final isSelected = _selectedMediaIds.contains(
|
||||
mediaId,
|
||||
);
|
||||
|
||||
return MemoriesThumbnailComp(
|
||||
galleryItem: item,
|
||||
index: globalIndex,
|
||||
selectionMode: _selectionMode,
|
||||
isSelected: isSelected,
|
||||
activeMediaIdNotifier: _activeMediaIdNotifier,
|
||||
onLongPress: () => _onLongPressItem(mediaId),
|
||||
onTap: () => _onTapItem(mediaId, globalIndex),
|
||||
);
|
||||
},
|
||||
childCount: orderedByMonth[month]!.length,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
|
||||
if (_selectionMode)
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final items = _service.currentState.galleryItems;
|
||||
var visibleCount = 0;
|
||||
var favCount = 0;
|
||||
|
||||
for (final item in items) {
|
||||
final isFav = item.mediaService.mediaFile.isFavorite;
|
||||
if (!_filterFavoritesOnly || isFav) {
|
||||
visibleCount++;
|
||||
}
|
||||
if (_selectedMediaIds.contains(
|
||||
item.mediaService.mediaFile.mediaId,
|
||||
)) {
|
||||
if (isFav) {
|
||||
favCount++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Memories')),
|
||||
body: Scrollbar(
|
||||
child: child,
|
||||
final areAllSelected =
|
||||
visibleCount > 0 &&
|
||||
_selectedMediaIds.length >= visibleCount;
|
||||
final areAllFav =
|
||||
_selectedMediaIds.isNotEmpty &&
|
||||
favCount == _selectedMediaIds.length;
|
||||
|
||||
return MemoriesSelectionToolbarComp(
|
||||
selectedCount: _selectedMediaIds.length,
|
||||
areAllSelected: areAllSelected,
|
||||
areAllFav: areAllFav,
|
||||
onSelectAll: _selectAll,
|
||||
onExport: _batchExport,
|
||||
onFavorite: _batchFavorite,
|
||||
onDelete: _batchDelete,
|
||||
onClear: () => setState(_selectedMediaIds.clear),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> open(
|
||||
BuildContext context,
|
||||
List<MemoryItem> galleryItems,
|
||||
int index,
|
||||
) async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
PageRouteBuilder(
|
||||
opaque: false,
|
||||
pageBuilder: (context, a1, a2) => MemoriesPhotoSliderView(
|
||||
galleryItems: galleryItems,
|
||||
initialIndex: index,
|
||||
),
|
||||
),
|
||||
)
|
||||
as bool?;
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
383
lib/src/visual/views/memories/synchronized_viewer.view.dart
Normal file
383
lib/src/visual/views/memories/synchronized_viewer.view.dart
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import 'dart:math';
|
||||
import 'package:drift/drift.dart' show Value;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/database/twonly.db.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
|
||||
import 'package:twonly/src/utils/log.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/helpers/video_player_file.helper.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
|
||||
import 'package:twonly/src/visual/views/memories/components/synchronized_viewer_actions_toolbar.comp.dart';
|
||||
|
||||
class SynchronizedImageViewerScreen extends StatefulWidget {
|
||||
const SynchronizedImageViewerScreen({
|
||||
required this.galleryItems,
|
||||
required this.initialIndex,
|
||||
required this.activeMediaIdNotifier,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final List<MemoryItem> galleryItems;
|
||||
final int initialIndex;
|
||||
final ValueNotifier<String?> activeMediaIdNotifier;
|
||||
|
||||
@override
|
||||
State<SynchronizedImageViewerScreen> createState() =>
|
||||
_SynchronizedImageViewerScreenState();
|
||||
}
|
||||
|
||||
class _SynchronizedImageViewerScreenState
|
||||
extends State<SynchronizedImageViewerScreen> {
|
||||
late PageController _verticalPager;
|
||||
late PageController _horizontalPager;
|
||||
late ValueNotifier<String> _currentlyViewedMediaIdNotifier;
|
||||
final ValueNotifier<double> _backdropOpacityNotifier = ValueNotifier(1);
|
||||
|
||||
final Set<String> _favoritedMediaIds = {};
|
||||
bool _isSaving = false;
|
||||
final Set<String> _storedMediaIds = {};
|
||||
|
||||
late int _currentIndex;
|
||||
bool _isZoomed = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky);
|
||||
|
||||
_currentIndex = widget.initialIndex;
|
||||
final initialId =
|
||||
widget.galleryItems[widget.initialIndex].mediaService.mediaFile.mediaId;
|
||||
_currentlyViewedMediaIdNotifier = ValueNotifier(initialId);
|
||||
|
||||
_horizontalPager = PageController(initialPage: widget.initialIndex);
|
||||
_verticalPager = PageController(initialPage: 1);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _verticalPager.addListener(_onVerticalScrollUpdated);
|
||||
});
|
||||
|
||||
for (final item in widget.galleryItems) {
|
||||
if (item.mediaService.mediaFile.isFavorite) {
|
||||
_favoritedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
||||
}
|
||||
if (item.mediaService.storedPath.existsSync()) {
|
||||
_storedMediaIds.add(item.mediaService.mediaFile.mediaId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _storeMediaFile() async {
|
||||
final item = widget.galleryItems[_currentIndex];
|
||||
final mediaId = item.mediaService.mediaFile.mediaId;
|
||||
setState(() => _isSaving = true);
|
||||
try {
|
||||
await item.mediaService.storeMediaFile();
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_storedMediaIds.add(mediaId);
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _isSaving = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleFavorite(String mediaId) async {
|
||||
final wasFavorite = _favoritedMediaIds.contains(mediaId);
|
||||
final isFavoriteNow = !wasFavorite;
|
||||
|
||||
setState(() {
|
||||
if (isFavoriteNow) {
|
||||
_favoritedMediaIds.add(mediaId);
|
||||
} else {
|
||||
_favoritedMediaIds.remove(mediaId);
|
||||
}
|
||||
});
|
||||
|
||||
await twonlyDB.mediaFilesDao.updateMedia(
|
||||
mediaId,
|
||||
MediaFilesCompanion(isFavorite: Value(isFavoriteNow)),
|
||||
);
|
||||
}
|
||||
|
||||
void _restoreSystemUI() {
|
||||
SystemChrome.setEnabledSystemUIMode(
|
||||
SystemUiMode.manual,
|
||||
overlays: SystemUiOverlay.values,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_restoreSystemUI();
|
||||
_verticalPager
|
||||
..removeListener(_onVerticalScrollUpdated)
|
||||
..dispose();
|
||||
_horizontalPager.dispose();
|
||||
_currentlyViewedMediaIdNotifier.dispose();
|
||||
_backdropOpacityNotifier.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onVerticalScrollUpdated() {
|
||||
if (!_verticalPager.hasClients) return;
|
||||
final page = _verticalPager.page ?? 1.0;
|
||||
|
||||
// Map vertical dragging proximity directly to square-root backdrop opacities
|
||||
final linearFraction = min(1, max(0, page)).toDouble();
|
||||
_backdropOpacityNotifier.value = linearFraction * linearFraction;
|
||||
}
|
||||
|
||||
void _onPageSnapped(int index) {
|
||||
if (index == 0) {
|
||||
_triggerSynchronizedPop();
|
||||
}
|
||||
}
|
||||
|
||||
void _triggerSynchronizedPop() {
|
||||
_restoreSystemUI();
|
||||
final targetId = _currentlyViewedMediaIdNotifier.value;
|
||||
|
||||
if (widget.activeMediaIdNotifier.value != targetId) {
|
||||
widget.activeMediaIdNotifier.value = targetId;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) Navigator.maybeOf(context)?.pop(true);
|
||||
});
|
||||
} else {
|
||||
Navigator.maybeOf(context)?.pop(true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteFile() async {
|
||||
final confirmed = await showAlertDialog(
|
||||
context,
|
||||
context.lang.deleteImageTitle,
|
||||
context.lang.deleteImageBody,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
widget.galleryItems[_currentIndex].mediaService.fullMediaRemoval();
|
||||
await twonlyDB.mediaFilesDao.deleteMediaFile(
|
||||
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId,
|
||||
);
|
||||
|
||||
widget.galleryItems.removeAt(_currentIndex);
|
||||
|
||||
if (widget.galleryItems.isEmpty) {
|
||||
if (mounted) Navigator.pop(context, true);
|
||||
return;
|
||||
}
|
||||
|
||||
if (_currentIndex >= widget.galleryItems.length) {
|
||||
_currentIndex = widget.galleryItems.length - 1;
|
||||
}
|
||||
|
||||
final newId =
|
||||
widget.galleryItems[_currentIndex].mediaService.mediaFile.mediaId;
|
||||
_currentlyViewedMediaIdNotifier.value = newId;
|
||||
widget.activeMediaIdNotifier.value = newId;
|
||||
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _exportFile() async {
|
||||
final item = widget.galleryItems[_currentIndex].mediaService;
|
||||
|
||||
try {
|
||||
if (item.mediaFile.type == MediaType.video) {
|
||||
await saveVideoToGallery(item.storedPath.path);
|
||||
} else if (item.mediaFile.type == MediaType.image ||
|
||||
item.mediaFile.type == MediaType.gif) {
|
||||
final imageBytes = await item.storedPath.readAsBytes();
|
||||
await saveImageToGallery(imageBytes);
|
||||
}
|
||||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
context.lang.galleryExportSuccess,
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
e.toString(),
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _shareMediaFile() async {
|
||||
final orgMediaService = widget.galleryItems[_currentIndex].mediaService;
|
||||
|
||||
final newMediaService = await initializeMediaUpload(
|
||||
orgMediaService.mediaFile.type,
|
||||
userService.currentUser.defaultShowTime,
|
||||
);
|
||||
if (newMediaService == null) {
|
||||
Log.error('Could not create new mediaFile');
|
||||
return;
|
||||
}
|
||||
|
||||
if (orgMediaService.storedPath.existsSync()) {
|
||||
orgMediaService.storedPath.copySync(newMediaService.originalPath.path);
|
||||
} else if (orgMediaService.tempPath.existsSync()) {
|
||||
orgMediaService.tempPath.copySync(newMediaService.originalPath.path);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await context.navPush(
|
||||
ShareImageEditorView(
|
||||
mediaFileService: newMediaService,
|
||||
sharedFromGallery: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.galleryItems.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
final orgMediaService = widget.galleryItems[_currentIndex].mediaService;
|
||||
final currentMediaId = orgMediaService.mediaFile.mediaId;
|
||||
|
||||
return PopScope<Object?>(
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
_restoreSystemUI();
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: ValueListenableBuilder<double>(
|
||||
valueListenable: _backdropOpacityNotifier,
|
||||
builder: (context, opacity, child) {
|
||||
return ColoredBox(
|
||||
color: Colors.black.withValues(alpha: opacity),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: PageView(
|
||||
controller: _verticalPager,
|
||||
scrollDirection: Axis.vertical,
|
||||
physics: _isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const BouncingScrollPhysics(
|
||||
parent: AlwaysScrollableScrollPhysics(),
|
||||
),
|
||||
onPageChanged: _onPageSnapped,
|
||||
children: [
|
||||
//Fully transparent dismissal trigger anchor
|
||||
const SizedBox.expand(),
|
||||
|
||||
Stack(
|
||||
children: [
|
||||
PageView.builder(
|
||||
controller: _horizontalPager,
|
||||
physics: _isZoomed
|
||||
? const NeverScrollableScrollPhysics()
|
||||
: const BouncingScrollPhysics(),
|
||||
itemCount: widget.galleryItems.length,
|
||||
onPageChanged: (idx) {
|
||||
setState(() {
|
||||
_currentIndex = idx;
|
||||
});
|
||||
final newMediaId = widget
|
||||
.galleryItems[idx]
|
||||
.mediaService
|
||||
.mediaFile
|
||||
.mediaId;
|
||||
_currentlyViewedMediaIdNotifier.value = newMediaId;
|
||||
widget.activeMediaIdNotifier.value = newMediaId;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
final item = widget.galleryItems[index];
|
||||
final itemMediaId = item.mediaService.mediaFile.mediaId;
|
||||
|
||||
var filePath = item.mediaService.storedPath;
|
||||
if (!filePath.existsSync()) {
|
||||
filePath = item.mediaService.tempPath;
|
||||
}
|
||||
|
||||
final isVideo =
|
||||
item.mediaService.mediaFile.type == MediaType.video;
|
||||
|
||||
return Center(
|
||||
child: ValueListenableBuilder<String>(
|
||||
valueListenable: _currentlyViewedMediaIdNotifier,
|
||||
builder: (context, activeMediaId, childWidget) {
|
||||
// Dynamically resolve Hero tags to prevent layout tree duplicate assertions
|
||||
final isActiveTarget = activeMediaId == itemMediaId;
|
||||
|
||||
if (isActiveTarget) {
|
||||
return Hero(
|
||||
tag: itemMediaId,
|
||||
transitionOnUserGestures: true,
|
||||
child: childWidget!,
|
||||
);
|
||||
}
|
||||
return childWidget!;
|
||||
},
|
||||
child: !filePath.existsSync()
|
||||
? const Center(
|
||||
child: Icon(
|
||||
Icons.broken_image_outlined,
|
||||
color: Colors.white38,
|
||||
size: 64,
|
||||
),
|
||||
)
|
||||
: isVideo
|
||||
? VideoPlayerFileHelper(videoPath: filePath)
|
||||
: PhotoView(
|
||||
imageProvider: FileImage(filePath),
|
||||
initialScale:
|
||||
PhotoViewComputedScale.contained,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale:
|
||||
PhotoViewComputedScale.covered * 4.1,
|
||||
backgroundDecoration: const BoxDecoration(
|
||||
color: Colors.transparent,
|
||||
),
|
||||
scaleStateChangedCallback: (state) {
|
||||
final zoomed =
|
||||
state != PhotoViewScaleState.initial;
|
||||
if (_isZoomed != zoomed) {
|
||||
setState(() {
|
||||
_isZoomed = zoomed;
|
||||
});
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
SynchronizedViewerActionsToolbarComp(
|
||||
isFavorite: _favoritedMediaIds.contains(currentMediaId),
|
||||
onShare: _shareMediaFile,
|
||||
onExport: _exportFile,
|
||||
onToggleFavorite: () => _toggleFavorite(currentMediaId),
|
||||
onDelete: _deleteFile,
|
||||
showStoreButton: !_storedMediaIds.contains(currentMediaId),
|
||||
onStore: _storeMediaFile,
|
||||
isImageSaving: _isSaving,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -82,20 +82,6 @@ class _ChatReactionSelectionView extends State<ChatReactionSelectionView> {
|
|||
);
|
||||
},
|
||||
),
|
||||
floatingActionButton: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 30),
|
||||
child: FloatingActionButton(
|
||||
foregroundColor: Colors.white,
|
||||
onPressed: () => UserService.update(
|
||||
(u) => u.preSelectedEmojies = EmojiAnimationComp
|
||||
.animatedIcons
|
||||
.keys
|
||||
.toList()
|
||||
.sublist(0, 6),
|
||||
),
|
||||
child: const Icon(Icons.settings_backup_restore_rounded),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
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/services/user.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
|
||||
class ChatSettingsView extends StatefulWidget {
|
||||
|
|
@ -16,19 +18,46 @@ class _ChatSettingsViewState extends State<ChatSettingsView> {
|
|||
super.initState();
|
||||
}
|
||||
|
||||
Future<void> setAutomaticallyMarkEqualMediaFilesAsOpened(bool value) async {
|
||||
await UserService.update((u) {
|
||||
u.automaticallyMarkEqualMediaFilesAsOpened = value;
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(context.lang.settingsChats),
|
||||
),
|
||||
body: ListView(
|
||||
body: StreamBuilder<void>(
|
||||
stream: userService.onUserUpdated,
|
||||
builder: (context, snapshot) {
|
||||
return ListView(
|
||||
children: [
|
||||
ListTile(
|
||||
title: Text(context.lang.settingsPreSelectedReactions),
|
||||
onTap: () => context.push(Routes.settingsChatsReactions),
|
||||
),
|
||||
SwitchListTile(
|
||||
title: Text(
|
||||
context
|
||||
.lang
|
||||
.settingsAutomaticallyMarkEqualMediaFilesAsOpenedTitle,
|
||||
),
|
||||
subtitle: Text(
|
||||
context
|
||||
.lang
|
||||
.settingsAutomaticallyMarkEqualMediaFilesAsOpenedSubtitle,
|
||||
),
|
||||
value: userService
|
||||
.currentUser
|
||||
.automaticallyMarkEqualMediaFilesAsOpened,
|
||||
onChanged: setAutomaticallyMarkEqualMediaFilesAsOpened,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:drift/drift.dart';
|
||||
|
|
@ -6,14 +7,18 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:restart_app/restart_app.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/database/twonly.db.dart';
|
||||
import 'package:twonly/src/services/mediafiles/mediafile.service.dart';
|
||||
import 'package:twonly/src/services/user.service.dart';
|
||||
import 'package:twonly/src/utils/misc.dart';
|
||||
import 'package:twonly/src/utils/storage.dart';
|
||||
import 'package:twonly/src/visual/components/alert.dialog.dart';
|
||||
import 'package:twonly/src/visual/components/snackbar.dart';
|
||||
import 'package:twonly/src/visual/views/onboarding/setup.view.dart';
|
||||
import 'package:twonly/src/visual/views/settings/developer/user_discovery_developer.view.dart';
|
||||
|
||||
|
|
@ -25,11 +30,224 @@ class DeveloperSettingsView extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
||||
bool _isGeneratingMockImages = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Future<void> _generate1000MockImages() async {
|
||||
if (_isGeneratingMockImages) return;
|
||||
setState(() {
|
||||
_isGeneratingMockImages = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final now = clock.now();
|
||||
const groupId = 'mock_group_gallery';
|
||||
|
||||
// Ensure mock group exists
|
||||
await twonlyDB.groupsDao.createNewGroup(
|
||||
const GroupsCompanion(
|
||||
groupId: Value(groupId),
|
||||
groupName: Value('Mock Gallery Group'),
|
||||
isDirectChat: Value(false),
|
||||
joinedGroup: Value(true),
|
||||
),
|
||||
);
|
||||
|
||||
const size = Size(360, 640);
|
||||
|
||||
// Batch database entries using cascades for extreme operational speed and clean linting
|
||||
await twonlyDB.batch((batch) async {
|
||||
for (var i = 0; i < 1000; i++) {
|
||||
final mediaId = 'mock_gen_$i';
|
||||
final authorIndex = i % 12;
|
||||
final contactId = 9000000 + authorIndex;
|
||||
|
||||
late DateTime itemDate;
|
||||
if (i < 200) {
|
||||
// Spread over the last month
|
||||
itemDate = now.subtract(Duration(minutes: i * 216));
|
||||
} else if (i < 400) {
|
||||
// Spread between 1 month and 1 year ago
|
||||
final localI = i - 200;
|
||||
itemDate = now.subtract(
|
||||
Duration(days: 30, minutes: localI * 2412),
|
||||
);
|
||||
} else if (i < 600) {
|
||||
// Around a year ago
|
||||
final localI = i - 400;
|
||||
itemDate = now.subtract(
|
||||
Duration(days: 365, minutes: localI * 216),
|
||||
);
|
||||
} else if (i < 800) {
|
||||
// Around three years ago
|
||||
final localI = i - 600;
|
||||
itemDate = now.subtract(
|
||||
Duration(days: 1095, minutes: localI * 216),
|
||||
);
|
||||
} else {
|
||||
// Around four years ago
|
||||
final localI = i - 800;
|
||||
itemDate = now.subtract(
|
||||
Duration(days: 1460, minutes: localI * 216),
|
||||
);
|
||||
}
|
||||
|
||||
batch
|
||||
..insert(
|
||||
twonlyDB.contacts,
|
||||
ContactsCompanion(
|
||||
userId: Value(contactId),
|
||||
username: Value('mock_user_$authorIndex'),
|
||||
displayName: Value('Author $authorIndex'),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
)
|
||||
..insert(
|
||||
twonlyDB.mediaFiles,
|
||||
MediaFilesCompanion(
|
||||
mediaId: Value(mediaId),
|
||||
type: const Value(MediaType.image),
|
||||
stored: const Value(true),
|
||||
createdAt: Value(itemDate),
|
||||
createdAtMonth: Value(DateFormat('MMMM yyyy').format(itemDate)),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
)
|
||||
..insert(
|
||||
twonlyDB.messages,
|
||||
MessagesCompanion(
|
||||
messageId: Value('mock_msg_$i'),
|
||||
groupId: const Value(groupId),
|
||||
senderId: Value(contactId),
|
||||
type: const Value('media'),
|
||||
mediaId: Value(mediaId),
|
||||
mediaStored: const Value(true),
|
||||
openedAt: Value(now),
|
||||
createdAt: Value(itemDate),
|
||||
),
|
||||
mode: InsertMode.insertOrReplace,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Render custom vector avatars and background colors efficiently
|
||||
for (var i = 0; i < 1000; i++) {
|
||||
final mediaId = 'mock_gen_$i';
|
||||
final recorder = ui.PictureRecorder();
|
||||
final canvas = Canvas(recorder);
|
||||
|
||||
// Background color
|
||||
final hue = (i * 137.5) % 360;
|
||||
final bgColor = HSLColor.fromAHSL(1, hue, 0.65, 0.45).toColor();
|
||||
canvas.drawRect(Offset.zero & size, Paint()..color = bgColor);
|
||||
|
||||
// Avatar vector representation on it
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final avatarBgPaint = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.25);
|
||||
canvas.drawCircle(center, 120, avatarBgPaint);
|
||||
|
||||
final eyePaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.fill;
|
||||
final mouthPaint = Paint()
|
||||
..color = Colors.white
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 8
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
const eyeOffset = 35.0;
|
||||
final eyeRadius = 12.0 + (i % 5) * 2;
|
||||
canvas
|
||||
..drawCircle(
|
||||
center + const Offset(-eyeOffset, -20),
|
||||
eyeRadius,
|
||||
eyePaint,
|
||||
)
|
||||
..drawCircle(
|
||||
center + const Offset(eyeOffset, -20),
|
||||
eyeRadius,
|
||||
eyePaint,
|
||||
);
|
||||
|
||||
final mouthRect = Rect.fromCenter(
|
||||
center: center + const Offset(0, 20),
|
||||
width: 60,
|
||||
height: 40,
|
||||
);
|
||||
final startAngle = 0.2 + (i % 3) * 0.1;
|
||||
final sweepAngle = 2.7 - (i % 3) * 0.2;
|
||||
canvas.drawArc(mouthRect, startAngle, sweepAngle, false, mouthPaint);
|
||||
|
||||
final textSpan = TextSpan(
|
||||
text: '#$i',
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
final textPainter = TextPainter(
|
||||
text: textSpan,
|
||||
textDirection: ui.TextDirection.ltr,
|
||||
)..layout();
|
||||
textPainter.paint(
|
||||
canvas,
|
||||
Offset((size.width - textPainter.width) / 2, size.height - 80),
|
||||
);
|
||||
|
||||
final picture = recorder.endRecording();
|
||||
final img = await picture.toImage(
|
||||
size.width.toInt(),
|
||||
size.height.toInt(),
|
||||
);
|
||||
final byteData = await img.toByteData(format: ui.ImageByteFormat.png);
|
||||
if (byteData != null) {
|
||||
final bytes = byteData.buffer.asUint8List();
|
||||
final mediaFile = MediaFile(
|
||||
mediaId: mediaId,
|
||||
type: MediaType.image,
|
||||
stored: true,
|
||||
requiresAuthentication: false,
|
||||
isDraftMedia: false,
|
||||
isFavorite: false,
|
||||
hasCropAnalyzed: false,
|
||||
createdAt: now,
|
||||
);
|
||||
final mediaService = MediaFileService(mediaFile);
|
||||
|
||||
if (!mediaService.storedPath.parent.existsSync()) {
|
||||
mediaService.storedPath.parent.createSync(recursive: true);
|
||||
}
|
||||
mediaService.storedPath.writeAsBytesSync(bytes);
|
||||
mediaService.thumbnailPath.writeAsBytesSync(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
if (mounted) {
|
||||
showSnackbar(
|
||||
context,
|
||||
'Successfully generated 1000 mock images!',
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
showSnackbar(context, 'Error generating images: $e');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isGeneratingMockImages = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> toggleDeveloperSettings() async {
|
||||
await UserService.update((u) => u.isDeveloper = !u.isDeveloper);
|
||||
}
|
||||
|
|
@ -132,6 +350,20 @@ class _DeveloperSettingsViewState extends State<DeveloperSettingsView> {
|
|||
onTap: () =>
|
||||
context.push(Routes.settingsDeveloperAutomatedTesting),
|
||||
),
|
||||
if (kDebugMode)
|
||||
ListTile(
|
||||
title: const Text('Generate 1000 Mock Images'),
|
||||
trailing: _isGeneratingMockImages
|
||||
? const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: null,
|
||||
onTap: _isGeneratingMockImages
|
||||
? null
|
||||
: _generate1000MockImages,
|
||||
),
|
||||
ListTile(
|
||||
title: const Text('Reopen Setup'),
|
||||
onTap: () async {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ class UserDiscoverySetupState {
|
|||
this.isUserDiscoveryEnabled = true,
|
||||
this.sharePromotion = true,
|
||||
this.isManualApprovalEnabled = false,
|
||||
this.threshold = 2,
|
||||
this.threshold = 3,
|
||||
this.requiredSendImages = 4,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -57,11 +57,8 @@ class _AdditionalUsersViewState extends State<AdditionalUsersView> {
|
|||
}
|
||||
|
||||
Future<void> addAdditionalUser() async {
|
||||
final selectedUserIds =
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SelectAdditionalUsers(
|
||||
final selectedUserIds = await context.navPush(
|
||||
SelectAdditionalUsers(
|
||||
limit: _planLimit,
|
||||
alreadySelected:
|
||||
ballance?.additionalAccounts
|
||||
|
|
@ -69,9 +66,7 @@ class _AdditionalUsersViewState extends State<AdditionalUsersView> {
|
|||
.toList() ??
|
||||
[],
|
||||
),
|
||||
),
|
||||
)
|
||||
as List<int>?;
|
||||
) as List<int>?;
|
||||
if (selectedUserIds == null) return;
|
||||
for (final selectedUserId in selectedUserIds) {
|
||||
final res = await apiService.addAdditionalUser(Int64(selectedUserId));
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
// ignore_for_file: inference_failure_on_instance_creation
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
|
@ -61,6 +59,45 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
if (currentPlan.name == SubscriptionPlan.Free.name)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 24),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
context.lang.subscriptionPledgeTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.color.primary,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
_MissionRow(
|
||||
icon: FontAwesomeIcons.shieldHalved,
|
||||
title: context.lang.subscriptionPledgeSecureTitle,
|
||||
desc: context.lang.subscriptionPledgeSecureDesc,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_MissionRow(
|
||||
icon: FontAwesomeIcons.userSecret,
|
||||
title: context.lang.subscriptionPledgeNoAdsTitle,
|
||||
desc: context.lang.subscriptionPledgeNoAdsDesc,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_MissionRow(
|
||||
icon: FontAwesomeIcons.heart,
|
||||
title: context.lang.subscriptionPledgeFundedTitle,
|
||||
desc: context.lang.subscriptionPledgeFundedDesc,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
|
|
@ -69,7 +106,10 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
color: context.color.primary,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
child: Text(
|
||||
currentPlan.name,
|
||||
style: TextStyle(
|
||||
|
|
@ -81,6 +121,7 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (additionalOwnerName != null)
|
||||
Center(
|
||||
child: Text(
|
||||
|
|
@ -95,16 +136,6 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
),
|
||||
if (!isPayingUser(currentPlan) ||
|
||||
currentPlan == SubscriptionPlan.Tester) ...[
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Text(
|
||||
context.lang.upgradeToPaidPlan,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
PlanCard(
|
||||
plan: SubscriptionPlan.Pro,
|
||||
onPurchase: initAsync,
|
||||
|
|
@ -152,14 +183,9 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
text: context.lang.manageAdditionalUsers,
|
||||
subtitle: loaded ? Text('${context.lang.open}: 3') : null,
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) {
|
||||
return AdditionalUsersView(
|
||||
await context.navPush(
|
||||
AdditionalUsersView(
|
||||
ballance: ballance,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
await initAsync();
|
||||
|
|
@ -347,3 +373,47 @@ class _PlanCardState extends State<PlanCard> {
|
|||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MissionRow extends StatelessWidget {
|
||||
const _MissionRow({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.desc,
|
||||
});
|
||||
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String desc;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
FaIcon(
|
||||
icon,
|
||||
size: 24,
|
||||
color: context.color.primary.withValues(alpha: 0.8),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
title,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
desc,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,267 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photo_view/photo_view_gallery.dart';
|
||||
import 'package:twonly/locator.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
import 'package:twonly/src/services/api/mediafiles/upload.api.dart';
|
||||
import 'package:twonly/src/utils/log.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/helpers/media_view_sizing.helper.dart';
|
||||
import 'package:twonly/src/visual/helpers/video_player_file.helper.dart';
|
||||
import 'package:twonly/src/visual/views/camera/camera_preview_components/save_to_gallery.dart';
|
||||
import 'package:twonly/src/visual/views/camera/share_image_editor.view.dart';
|
||||
|
||||
class MemoriesPhotoSliderView extends StatefulWidget {
|
||||
MemoriesPhotoSliderView({
|
||||
required this.galleryItems,
|
||||
super.key,
|
||||
this.loadingBuilder,
|
||||
this.minScale,
|
||||
this.maxScale,
|
||||
this.initialIndex = 0,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
}) : pageController = PageController(initialPage: initialIndex);
|
||||
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
final dynamic minScale;
|
||||
final dynamic maxScale;
|
||||
final int initialIndex;
|
||||
final PageController pageController;
|
||||
final List<MemoryItem> galleryItems;
|
||||
final Axis scrollDirection;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _MemoriesPhotoSliderViewState();
|
||||
}
|
||||
}
|
||||
|
||||
class _MemoriesPhotoSliderViewState extends State<MemoriesPhotoSliderView> {
|
||||
late int currentIndex = widget.initialIndex;
|
||||
final GlobalKey<State<StatefulWidget>> key = GlobalKey();
|
||||
|
||||
void onPageChanged(int index) {
|
||||
setState(() {
|
||||
currentIndex = index;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> deleteFile() async {
|
||||
final confirmed = await showAlertDialog(
|
||||
context,
|
||||
context.lang.deleteImageTitle,
|
||||
context.lang.deleteImageBody,
|
||||
);
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
widget.galleryItems[currentIndex].mediaService.fullMediaRemoval();
|
||||
await twonlyDB.mediaFilesDao.deleteMediaFile(
|
||||
widget.galleryItems[currentIndex].mediaService.mediaFile.mediaId,
|
||||
);
|
||||
|
||||
widget.galleryItems.removeAt(currentIndex);
|
||||
setState(() {});
|
||||
if (mounted) {
|
||||
Navigator.pop(context, true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> exportFile() async {
|
||||
final item = widget.galleryItems[currentIndex].mediaService;
|
||||
|
||||
try {
|
||||
if (item.mediaFile.type == MediaType.video) {
|
||||
await saveVideoToGallery(item.storedPath.path);
|
||||
} else if (item.mediaFile.type == MediaType.image ||
|
||||
item.mediaFile.type == MediaType.gif) {
|
||||
final imageBytes = await item.storedPath.readAsBytes();
|
||||
await saveImageToGallery(imageBytes);
|
||||
}
|
||||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
context.lang.galleryExportSuccess,
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
showSnackbar(
|
||||
context,
|
||||
e.toString(),
|
||||
level: SnackbarLevel.success,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> shareMediaFile() async {
|
||||
final orgMediaService = widget.galleryItems[currentIndex].mediaService;
|
||||
|
||||
final newMediaService = await initializeMediaUpload(
|
||||
orgMediaService.mediaFile.type,
|
||||
userService.currentUser.defaultShowTime,
|
||||
);
|
||||
if (newMediaService == null) {
|
||||
Log.error('Could not create new mediaFIle');
|
||||
return;
|
||||
}
|
||||
|
||||
if (orgMediaService.storedPath.existsSync()) {
|
||||
orgMediaService.storedPath.copySync(newMediaService.originalPath.path);
|
||||
} else if (orgMediaService.tempPath.existsSync()) {
|
||||
orgMediaService.tempPath.copySync(newMediaService.originalPath.path);
|
||||
}
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ShareImageEditorView(
|
||||
mediaFileService: newMediaService,
|
||||
sharedFromGallery: true,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final orgMediaService = widget.galleryItems[currentIndex].mediaService;
|
||||
return Dismissible(
|
||||
key: key,
|
||||
direction: DismissDirection.vertical,
|
||||
resizeDuration: null,
|
||||
onDismissed: (d) {
|
||||
Navigator.pop(context, false);
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.white.withAlpha(0),
|
||||
body: Container(
|
||||
color: context.color.surface,
|
||||
constraints: BoxConstraints.expand(
|
||||
height: MediaQuery.of(context).size.height,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.bottomRight,
|
||||
children: <Widget>[
|
||||
MediaViewSizingHelper(
|
||||
bottomNavigation: ColoredBox(
|
||||
color: context.color.surface,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (!orgMediaService.storedPath.existsSync())
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 12),
|
||||
child: SaveToGalleryButton(
|
||||
isLoading: false,
|
||||
displayButtonLabel: true,
|
||||
mediaService: orgMediaService,
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
icon: const FaIcon(FontAwesomeIcons.solidPaperPlane),
|
||||
onPressed: shareMediaFile,
|
||||
style: ButtonStyle(
|
||||
padding: WidgetStateProperty.all<EdgeInsets>(
|
||||
const EdgeInsets.symmetric(
|
||||
vertical: 10,
|
||||
horizontal: 30,
|
||||
),
|
||||
),
|
||||
backgroundColor: WidgetStateProperty.all<Color>(
|
||||
Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
),
|
||||
label: Text(
|
||||
context.lang.shareImagedEditorSendImage,
|
||||
style: const TextStyle(fontSize: 17),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
PhotoViewGallery.builder(
|
||||
scrollPhysics: const BouncingScrollPhysics(),
|
||||
builder: _buildItem,
|
||||
itemCount: widget.galleryItems.length,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
pageController: widget.pageController,
|
||||
onPageChanged: onPageChanged,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
),
|
||||
Positioned(
|
||||
right: 5,
|
||||
child: PopupMenuButton<String>(
|
||||
onSelected: (result) async {
|
||||
if (result == 'delete') {
|
||||
await deleteFile();
|
||||
}
|
||||
if (result == 'export') {
|
||||
await exportFile();
|
||||
}
|
||||
},
|
||||
itemBuilder: (context) => <PopupMenuEntry<String>>[
|
||||
PopupMenuItem<String>(
|
||||
value: 'delete',
|
||||
child: Text(context.lang.galleryDelete),
|
||||
),
|
||||
PopupMenuItem<String>(
|
||||
value: 'export',
|
||||
child: Text(context.lang.galleryExport),
|
||||
),
|
||||
// PopupMenuItem<String>(
|
||||
// value: 'details',
|
||||
// child: Text(context.lang.galleryDetails),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _buildItem(BuildContext context, int index) {
|
||||
final item = widget.galleryItems[index];
|
||||
|
||||
var filePath = item.mediaService.storedPath;
|
||||
if (!filePath.existsSync()) {
|
||||
filePath = item.mediaService.tempPath;
|
||||
}
|
||||
|
||||
return item.mediaService.mediaFile.type == MediaType.video
|
||||
? PhotoViewGalleryPageOptions.customChild(
|
||||
child: VideoPlayerFileHelper(
|
||||
videoPath: filePath,
|
||||
),
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 4.1,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: item.mediaService.mediaFile.mediaId,
|
||||
),
|
||||
)
|
||||
: PhotoViewGalleryPageOptions(
|
||||
imageProvider: FileImage(filePath),
|
||||
initialScale: PhotoViewComputedScale.contained,
|
||||
minScale: PhotoViewComputedScale.contained,
|
||||
maxScale: PhotoViewComputedScale.covered * 4.1,
|
||||
heroAttributes: PhotoViewHeroAttributes(
|
||||
tag: item.mediaService.mediaFile.mediaId,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,78 +0,0 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
|
||||
import 'package:twonly/src/database/tables/mediafiles.table.dart';
|
||||
import 'package:twonly/src/model/memory_item.model.dart';
|
||||
|
||||
class MemoriesItemThumbnailComp extends StatefulWidget {
|
||||
const MemoriesItemThumbnailComp({
|
||||
required this.galleryItem,
|
||||
required this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final MemoryItem galleryItem;
|
||||
final GestureTapCallback onTap;
|
||||
|
||||
@override
|
||||
State<MemoriesItemThumbnailComp> createState() =>
|
||||
_MemoriesItemThumbnailCompState();
|
||||
}
|
||||
|
||||
class _MemoriesItemThumbnailCompState extends State<MemoriesItemThumbnailComp> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initAsync();
|
||||
}
|
||||
|
||||
Future<void> initAsync() async {
|
||||
if (!widget.galleryItem.mediaService.thumbnailPath.existsSync()) {
|
||||
if (widget.galleryItem.mediaService.storedPath.existsSync()) {
|
||||
await widget.galleryItem.mediaService.createThumbnail();
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String formatDuration(Duration duration) {
|
||||
String twoDigits(int n) => n.toString().padLeft(2, '0');
|
||||
final twoDigitMinutes = twoDigits(duration.inMinutes.remainder(60));
|
||||
final twoDigitSeconds = twoDigits(duration.inSeconds.remainder(60));
|
||||
return '$twoDigitMinutes:$twoDigitSeconds';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final media = widget.galleryItem.mediaService;
|
||||
return GestureDetector(
|
||||
onTap: widget.onTap,
|
||||
child: Hero(
|
||||
tag: media.mediaFile.mediaId,
|
||||
child: Stack(
|
||||
children: [
|
||||
if (media.thumbnailPath.existsSync())
|
||||
Image.file(media.thumbnailPath)
|
||||
else if (media.storedPath.existsSync() &&
|
||||
media.mediaFile.type == MediaType.image ||
|
||||
media.mediaFile.type == MediaType.gif)
|
||||
Image.file(media.storedPath)
|
||||
else
|
||||
const Text('Media file removed.'),
|
||||
if (widget.galleryItem.mediaService.mediaFile.type ==
|
||||
MediaType.video)
|
||||
const Positioned.fill(
|
||||
child: Center(
|
||||
child: FaIcon(FontAwesomeIcons.circlePlay),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,7 +3,7 @@ description: "twonly, a privacy-friendly way to connect with friends through sec
|
|||
|
||||
publish_to: 'none'
|
||||
|
||||
version: 0.2.11+120
|
||||
version: 0.2.12+121
|
||||
|
||||
environment:
|
||||
sdk: ^3.11.0
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ import 'schema_v10.dart' as v10;
|
|||
import 'schema_v11.dart' as v11;
|
||||
import 'schema_v12.dart' as v12;
|
||||
import 'schema_v13.dart' as v13;
|
||||
import 'schema_v14.dart' as v14;
|
||||
import 'schema_v15.dart' as v15;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
|
|
@ -48,10 +50,30 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||
return v12.DatabaseAtV12(db);
|
||||
case 13:
|
||||
return v13.DatabaseAtV13(db);
|
||||
case 14:
|
||||
return v14.DatabaseAtV14(db);
|
||||
case 15:
|
||||
return v15.DatabaseAtV15(db);
|
||||
default:
|
||||
throw MissingSchemaException(version, versions);
|
||||
}
|
||||
}
|
||||
|
||||
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13];
|
||||
static const versions = const [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
7,
|
||||
8,
|
||||
9,
|
||||
10,
|
||||
11,
|
||||
12,
|
||||
13,
|
||||
14,
|
||||
15,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue