mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-05-25 10:12:12 +00:00
Improved: Memories viewer redesigned
This commit is contained in:
parent
23004acbed
commit
a5fc97c648
34 changed files with 7144 additions and 729 deletions
|
|
@ -1,5 +1,9 @@
|
|||
# Changelog
|
||||
|
||||
## 0.2.12
|
||||
|
||||
- Improved: Memories viewer redesigned with smoother animations and new quick-action controls.
|
||||
|
||||
## 0.2.11
|
||||
|
||||
- New: Create custom shortcuts to quickly share images with pre-selected groups
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ class AppEnvironment {
|
|||
|
||||
static bool _isInitialized = false;
|
||||
|
||||
static bool _isInitialized = false;
|
||||
|
||||
// will be loaded in the main_camera_controller.dart
|
||||
static List<CameraDescription> cameras = [];
|
||||
|
||||
|
|
@ -34,5 +32,6 @@ class AppState {
|
|||
static bool isInBackgroundTask = false;
|
||||
static bool allowErrorTrackingViaSentry = false;
|
||||
static bool gotMessageFromServer = false;
|
||||
static int latestAppVersionId = 113;
|
||||
static int latestAppVersionId = 114;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,24 @@ 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 (kDebugMode) {
|
||||
assert(
|
||||
AppState.latestAppVersionId == 113,
|
||||
AppState.latestAppVersionId == 114,
|
||||
'Forgot to update the target version in runMigrations() after incrementing AppState.latestAppVersionId.',
|
||||
);
|
||||
assert(
|
||||
|
|
@ -261,6 +281,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());
|
||||
|
|
|
|||
|
|
@ -122,6 +122,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) =>
|
||||
|
|
|
|||
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
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ class TwonlyDB extends _$TwonlyDB {
|
|||
TwonlyDB.forTesting(DatabaseConnection super.connection);
|
||||
|
||||
@override
|
||||
int get schemaVersion => 13;
|
||||
int get schemaVersion => 14;
|
||||
|
||||
static QueryExecutor _openConnection() {
|
||||
return driftDatabase(
|
||||
|
|
@ -195,6 +195,17 @@ 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,
|
||||
);
|
||||
},
|
||||
)(m, from, to);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -2676,6 +2676,36 @@ class $MediaFilesTable extends MediaFiles
|
|||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _isFavoriteMeta = const VerificationMeta(
|
||||
'isFavorite',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> isFavorite = GeneratedColumn<bool>(
|
||||
'is_favorite',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("is_favorite" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _hasCropAnalyzedMeta = const VerificationMeta(
|
||||
'hasCropAnalyzed',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<bool> hasCropAnalyzed = GeneratedColumn<bool>(
|
||||
'has_crop_analyzed',
|
||||
aliasedName,
|
||||
false,
|
||||
type: DriftSqlType.bool,
|
||||
requiredDuringInsert: false,
|
||||
defaultConstraints: GeneratedColumn.constraintIsAlways(
|
||||
'CHECK ("has_crop_analyzed" IN (0, 1))',
|
||||
),
|
||||
defaultValue: const Constant(false),
|
||||
);
|
||||
static const VerificationMeta _preProgressingProcessMeta =
|
||||
const VerificationMeta('preProgressingProcess');
|
||||
@override
|
||||
|
|
@ -2792,6 +2822,17 @@ class $MediaFilesTable extends MediaFiles
|
|||
requiredDuringInsert: false,
|
||||
defaultValue: currentDateAndTime,
|
||||
);
|
||||
static const VerificationMeta _createdAtMonthMeta = const VerificationMeta(
|
||||
'createdAtMonth',
|
||||
);
|
||||
@override
|
||||
late final GeneratedColumn<String> createdAtMonth = GeneratedColumn<String>(
|
||||
'created_at_month',
|
||||
aliasedName,
|
||||
true,
|
||||
type: DriftSqlType.string,
|
||||
requiredDuringInsert: false,
|
||||
);
|
||||
@override
|
||||
List<GeneratedColumn> get $columns => [
|
||||
mediaId,
|
||||
|
|
@ -2801,6 +2842,8 @@ class $MediaFilesTable extends MediaFiles
|
|||
requiresAuthentication,
|
||||
stored,
|
||||
isDraftMedia,
|
||||
isFavorite,
|
||||
hasCropAnalyzed,
|
||||
preProgressingProcess,
|
||||
reuploadRequestedBy,
|
||||
displayLimitInMilliseconds,
|
||||
|
|
@ -2811,6 +2854,7 @@ class $MediaFilesTable extends MediaFiles
|
|||
encryptionNonce,
|
||||
storedFileHash,
|
||||
createdAt,
|
||||
createdAtMonth,
|
||||
];
|
||||
@override
|
||||
String get aliasedName => _alias ?? actualTableName;
|
||||
|
|
@ -2856,6 +2900,21 @@ class $MediaFilesTable extends MediaFiles
|
|||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('is_favorite')) {
|
||||
context.handle(
|
||||
_isFavoriteMeta,
|
||||
isFavorite.isAcceptableOrUnknown(data['is_favorite']!, _isFavoriteMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('has_crop_analyzed')) {
|
||||
context.handle(
|
||||
_hasCropAnalyzedMeta,
|
||||
hasCropAnalyzed.isAcceptableOrUnknown(
|
||||
data['has_crop_analyzed']!,
|
||||
_hasCropAnalyzedMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('pre_progressing_process')) {
|
||||
context.handle(
|
||||
_preProgressingProcessMeta,
|
||||
|
|
@ -2934,6 +2993,15 @@ class $MediaFilesTable extends MediaFiles
|
|||
createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta),
|
||||
);
|
||||
}
|
||||
if (data.containsKey('created_at_month')) {
|
||||
context.handle(
|
||||
_createdAtMonthMeta,
|
||||
createdAtMonth.isAcceptableOrUnknown(
|
||||
data['created_at_month']!,
|
||||
_createdAtMonthMeta,
|
||||
),
|
||||
);
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
|
|
@ -2977,6 +3045,14 @@ class $MediaFilesTable extends MediaFiles
|
|||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_draft_media'],
|
||||
)!,
|
||||
isFavorite: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}is_favorite'],
|
||||
)!,
|
||||
hasCropAnalyzed: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.bool,
|
||||
data['${effectivePrefix}has_crop_analyzed'],
|
||||
)!,
|
||||
preProgressingProcess: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.int,
|
||||
data['${effectivePrefix}pre_progressing_process'],
|
||||
|
|
@ -3020,6 +3096,10 @@ class $MediaFilesTable extends MediaFiles
|
|||
DriftSqlType.dateTime,
|
||||
data['${effectivePrefix}created_at'],
|
||||
)!,
|
||||
createdAtMonth: attachedDatabase.typeMapping.read(
|
||||
DriftSqlType.string,
|
||||
data['${effectivePrefix}created_at_month'],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3056,6 +3136,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
final bool requiresAuthentication;
|
||||
final bool stored;
|
||||
final bool isDraftMedia;
|
||||
final bool isFavorite;
|
||||
final bool hasCropAnalyzed;
|
||||
final int? preProgressingProcess;
|
||||
final List<int>? reuploadRequestedBy;
|
||||
final int? displayLimitInMilliseconds;
|
||||
|
|
@ -3066,6 +3148,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
final Uint8List? encryptionNonce;
|
||||
final Uint8List? storedFileHash;
|
||||
final DateTime createdAt;
|
||||
final String? createdAtMonth;
|
||||
const MediaFile({
|
||||
required this.mediaId,
|
||||
required this.type,
|
||||
|
|
@ -3074,6 +3157,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
required this.requiresAuthentication,
|
||||
required this.stored,
|
||||
required this.isDraftMedia,
|
||||
required this.isFavorite,
|
||||
required this.hasCropAnalyzed,
|
||||
this.preProgressingProcess,
|
||||
this.reuploadRequestedBy,
|
||||
this.displayLimitInMilliseconds,
|
||||
|
|
@ -3084,6 +3169,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
this.encryptionNonce,
|
||||
this.storedFileHash,
|
||||
required this.createdAt,
|
||||
this.createdAtMonth,
|
||||
});
|
||||
@override
|
||||
Map<String, Expression> toColumns(bool nullToAbsent) {
|
||||
|
|
@ -3107,6 +3193,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
map['requires_authentication'] = Variable<bool>(requiresAuthentication);
|
||||
map['stored'] = Variable<bool>(stored);
|
||||
map['is_draft_media'] = Variable<bool>(isDraftMedia);
|
||||
map['is_favorite'] = Variable<bool>(isFavorite);
|
||||
map['has_crop_analyzed'] = Variable<bool>(hasCropAnalyzed);
|
||||
if (!nullToAbsent || preProgressingProcess != null) {
|
||||
map['pre_progressing_process'] = Variable<int>(preProgressingProcess);
|
||||
}
|
||||
|
|
@ -3141,6 +3229,9 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
map['stored_file_hash'] = Variable<Uint8List>(storedFileHash);
|
||||
}
|
||||
map['created_at'] = Variable<DateTime>(createdAt);
|
||||
if (!nullToAbsent || createdAtMonth != null) {
|
||||
map['created_at_month'] = Variable<String>(createdAtMonth);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
|
|
@ -3157,6 +3248,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
requiresAuthentication: Value(requiresAuthentication),
|
||||
stored: Value(stored),
|
||||
isDraftMedia: Value(isDraftMedia),
|
||||
isFavorite: Value(isFavorite),
|
||||
hasCropAnalyzed: Value(hasCropAnalyzed),
|
||||
preProgressingProcess: preProgressingProcess == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(preProgressingProcess),
|
||||
|
|
@ -3186,6 +3279,9 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
? const Value.absent()
|
||||
: Value(storedFileHash),
|
||||
createdAt: Value(createdAt),
|
||||
createdAtMonth: createdAtMonth == null && nullToAbsent
|
||||
? const Value.absent()
|
||||
: Value(createdAtMonth),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3210,6 +3306,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
),
|
||||
stored: serializer.fromJson<bool>(json['stored']),
|
||||
isDraftMedia: serializer.fromJson<bool>(json['isDraftMedia']),
|
||||
isFavorite: serializer.fromJson<bool>(json['isFavorite']),
|
||||
hasCropAnalyzed: serializer.fromJson<bool>(json['hasCropAnalyzed']),
|
||||
preProgressingProcess: serializer.fromJson<int?>(
|
||||
json['preProgressingProcess'],
|
||||
),
|
||||
|
|
@ -3226,6 +3324,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
encryptionNonce: serializer.fromJson<Uint8List?>(json['encryptionNonce']),
|
||||
storedFileHash: serializer.fromJson<Uint8List?>(json['storedFileHash']),
|
||||
createdAt: serializer.fromJson<DateTime>(json['createdAt']),
|
||||
createdAtMonth: serializer.fromJson<String?>(json['createdAtMonth']),
|
||||
);
|
||||
}
|
||||
@override
|
||||
|
|
@ -3245,6 +3344,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
'requiresAuthentication': serializer.toJson<bool>(requiresAuthentication),
|
||||
'stored': serializer.toJson<bool>(stored),
|
||||
'isDraftMedia': serializer.toJson<bool>(isDraftMedia),
|
||||
'isFavorite': serializer.toJson<bool>(isFavorite),
|
||||
'hasCropAnalyzed': serializer.toJson<bool>(hasCropAnalyzed),
|
||||
'preProgressingProcess': serializer.toJson<int?>(preProgressingProcess),
|
||||
'reuploadRequestedBy': serializer.toJson<List<int>?>(reuploadRequestedBy),
|
||||
'displayLimitInMilliseconds': serializer.toJson<int?>(
|
||||
|
|
@ -3257,6 +3358,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
'encryptionNonce': serializer.toJson<Uint8List?>(encryptionNonce),
|
||||
'storedFileHash': serializer.toJson<Uint8List?>(storedFileHash),
|
||||
'createdAt': serializer.toJson<DateTime>(createdAt),
|
||||
'createdAtMonth': serializer.toJson<String?>(createdAtMonth),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -3268,6 +3370,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
bool? requiresAuthentication,
|
||||
bool? stored,
|
||||
bool? isDraftMedia,
|
||||
bool? isFavorite,
|
||||
bool? hasCropAnalyzed,
|
||||
Value<int?> preProgressingProcess = const Value.absent(),
|
||||
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
||||
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
||||
|
|
@ -3278,6 +3382,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
Value<Uint8List?> encryptionNonce = const Value.absent(),
|
||||
Value<Uint8List?> storedFileHash = const Value.absent(),
|
||||
DateTime? createdAt,
|
||||
Value<String?> createdAtMonth = const Value.absent(),
|
||||
}) => MediaFile(
|
||||
mediaId: mediaId ?? this.mediaId,
|
||||
type: type ?? this.type,
|
||||
|
|
@ -3289,6 +3394,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
requiresAuthentication ?? this.requiresAuthentication,
|
||||
stored: stored ?? this.stored,
|
||||
isDraftMedia: isDraftMedia ?? this.isDraftMedia,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed ?? this.hasCropAnalyzed,
|
||||
preProgressingProcess: preProgressingProcess.present
|
||||
? preProgressingProcess.value
|
||||
: this.preProgressingProcess,
|
||||
|
|
@ -3315,6 +3422,9 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
? storedFileHash.value
|
||||
: this.storedFileHash,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
createdAtMonth: createdAtMonth.present
|
||||
? createdAtMonth.value
|
||||
: this.createdAtMonth,
|
||||
);
|
||||
MediaFile copyWithCompanion(MediaFilesCompanion data) {
|
||||
return MediaFile(
|
||||
|
|
@ -3333,6 +3443,12 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
isDraftMedia: data.isDraftMedia.present
|
||||
? data.isDraftMedia.value
|
||||
: this.isDraftMedia,
|
||||
isFavorite: data.isFavorite.present
|
||||
? data.isFavorite.value
|
||||
: this.isFavorite,
|
||||
hasCropAnalyzed: data.hasCropAnalyzed.present
|
||||
? data.hasCropAnalyzed.value
|
||||
: this.hasCropAnalyzed,
|
||||
preProgressingProcess: data.preProgressingProcess.present
|
||||
? data.preProgressingProcess.value
|
||||
: this.preProgressingProcess,
|
||||
|
|
@ -3361,6 +3477,9 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
? data.storedFileHash.value
|
||||
: this.storedFileHash,
|
||||
createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt,
|
||||
createdAtMonth: data.createdAtMonth.present
|
||||
? data.createdAtMonth.value
|
||||
: this.createdAtMonth,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -3374,6 +3493,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
..write('requiresAuthentication: $requiresAuthentication, ')
|
||||
..write('stored: $stored, ')
|
||||
..write('isDraftMedia: $isDraftMedia, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('hasCropAnalyzed: $hasCropAnalyzed, ')
|
||||
..write('preProgressingProcess: $preProgressingProcess, ')
|
||||
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
|
||||
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
|
||||
|
|
@ -3383,7 +3504,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
..write('encryptionMac: $encryptionMac, ')
|
||||
..write('encryptionNonce: $encryptionNonce, ')
|
||||
..write('storedFileHash: $storedFileHash, ')
|
||||
..write('createdAt: $createdAt')
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('createdAtMonth: $createdAtMonth')
|
||||
..write(')'))
|
||||
.toString();
|
||||
}
|
||||
|
|
@ -3397,6 +3519,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
requiresAuthentication,
|
||||
stored,
|
||||
isDraftMedia,
|
||||
isFavorite,
|
||||
hasCropAnalyzed,
|
||||
preProgressingProcess,
|
||||
reuploadRequestedBy,
|
||||
displayLimitInMilliseconds,
|
||||
|
|
@ -3407,6 +3531,7 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
$driftBlobEquality.hash(encryptionNonce),
|
||||
$driftBlobEquality.hash(storedFileHash),
|
||||
createdAt,
|
||||
createdAtMonth,
|
||||
);
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
|
|
@ -3419,6 +3544,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
other.requiresAuthentication == this.requiresAuthentication &&
|
||||
other.stored == this.stored &&
|
||||
other.isDraftMedia == this.isDraftMedia &&
|
||||
other.isFavorite == this.isFavorite &&
|
||||
other.hasCropAnalyzed == this.hasCropAnalyzed &&
|
||||
other.preProgressingProcess == this.preProgressingProcess &&
|
||||
other.reuploadRequestedBy == this.reuploadRequestedBy &&
|
||||
other.displayLimitInMilliseconds == this.displayLimitInMilliseconds &&
|
||||
|
|
@ -3434,7 +3561,8 @@ class MediaFile extends DataClass implements Insertable<MediaFile> {
|
|||
other.storedFileHash,
|
||||
this.storedFileHash,
|
||||
) &&
|
||||
other.createdAt == this.createdAt);
|
||||
other.createdAt == this.createdAt &&
|
||||
other.createdAtMonth == this.createdAtMonth);
|
||||
}
|
||||
|
||||
class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
||||
|
|
@ -3445,6 +3573,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
final Value<bool> requiresAuthentication;
|
||||
final Value<bool> stored;
|
||||
final Value<bool> isDraftMedia;
|
||||
final Value<bool> isFavorite;
|
||||
final Value<bool> hasCropAnalyzed;
|
||||
final Value<int?> preProgressingProcess;
|
||||
final Value<List<int>?> reuploadRequestedBy;
|
||||
final Value<int?> displayLimitInMilliseconds;
|
||||
|
|
@ -3455,6 +3585,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
final Value<Uint8List?> encryptionNonce;
|
||||
final Value<Uint8List?> storedFileHash;
|
||||
final Value<DateTime> createdAt;
|
||||
final Value<String?> createdAtMonth;
|
||||
final Value<int> rowid;
|
||||
const MediaFilesCompanion({
|
||||
this.mediaId = const Value.absent(),
|
||||
|
|
@ -3464,6 +3595,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
this.requiresAuthentication = const Value.absent(),
|
||||
this.stored = const Value.absent(),
|
||||
this.isDraftMedia = const Value.absent(),
|
||||
this.isFavorite = const Value.absent(),
|
||||
this.hasCropAnalyzed = const Value.absent(),
|
||||
this.preProgressingProcess = const Value.absent(),
|
||||
this.reuploadRequestedBy = const Value.absent(),
|
||||
this.displayLimitInMilliseconds = const Value.absent(),
|
||||
|
|
@ -3474,6 +3607,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
this.encryptionNonce = const Value.absent(),
|
||||
this.storedFileHash = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
this.createdAtMonth = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
});
|
||||
MediaFilesCompanion.insert({
|
||||
|
|
@ -3484,6 +3618,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
this.requiresAuthentication = const Value.absent(),
|
||||
this.stored = const Value.absent(),
|
||||
this.isDraftMedia = const Value.absent(),
|
||||
this.isFavorite = const Value.absent(),
|
||||
this.hasCropAnalyzed = const Value.absent(),
|
||||
this.preProgressingProcess = const Value.absent(),
|
||||
this.reuploadRequestedBy = const Value.absent(),
|
||||
this.displayLimitInMilliseconds = const Value.absent(),
|
||||
|
|
@ -3494,6 +3630,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
this.encryptionNonce = const Value.absent(),
|
||||
this.storedFileHash = const Value.absent(),
|
||||
this.createdAt = const Value.absent(),
|
||||
this.createdAtMonth = const Value.absent(),
|
||||
this.rowid = const Value.absent(),
|
||||
}) : mediaId = Value(mediaId),
|
||||
type = Value(type);
|
||||
|
|
@ -3505,6 +3642,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
Expression<bool>? requiresAuthentication,
|
||||
Expression<bool>? stored,
|
||||
Expression<bool>? isDraftMedia,
|
||||
Expression<bool>? isFavorite,
|
||||
Expression<bool>? hasCropAnalyzed,
|
||||
Expression<int>? preProgressingProcess,
|
||||
Expression<String>? reuploadRequestedBy,
|
||||
Expression<int>? displayLimitInMilliseconds,
|
||||
|
|
@ -3515,6 +3654,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
Expression<Uint8List>? encryptionNonce,
|
||||
Expression<Uint8List>? storedFileHash,
|
||||
Expression<DateTime>? createdAt,
|
||||
Expression<String>? createdAtMonth,
|
||||
Expression<int>? rowid,
|
||||
}) {
|
||||
return RawValuesInsertable({
|
||||
|
|
@ -3526,6 +3666,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
'requires_authentication': requiresAuthentication,
|
||||
if (stored != null) 'stored': stored,
|
||||
if (isDraftMedia != null) 'is_draft_media': isDraftMedia,
|
||||
if (isFavorite != null) 'is_favorite': isFavorite,
|
||||
if (hasCropAnalyzed != null) 'has_crop_analyzed': hasCropAnalyzed,
|
||||
if (preProgressingProcess != null)
|
||||
'pre_progressing_process': preProgressingProcess,
|
||||
if (reuploadRequestedBy != null)
|
||||
|
|
@ -3539,6 +3681,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
if (encryptionNonce != null) 'encryption_nonce': encryptionNonce,
|
||||
if (storedFileHash != null) 'stored_file_hash': storedFileHash,
|
||||
if (createdAt != null) 'created_at': createdAt,
|
||||
if (createdAtMonth != null) 'created_at_month': createdAtMonth,
|
||||
if (rowid != null) 'rowid': rowid,
|
||||
});
|
||||
}
|
||||
|
|
@ -3551,6 +3694,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
Value<bool>? requiresAuthentication,
|
||||
Value<bool>? stored,
|
||||
Value<bool>? isDraftMedia,
|
||||
Value<bool>? isFavorite,
|
||||
Value<bool>? hasCropAnalyzed,
|
||||
Value<int?>? preProgressingProcess,
|
||||
Value<List<int>?>? reuploadRequestedBy,
|
||||
Value<int?>? displayLimitInMilliseconds,
|
||||
|
|
@ -3561,6 +3706,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
Value<Uint8List?>? encryptionNonce,
|
||||
Value<Uint8List?>? storedFileHash,
|
||||
Value<DateTime>? createdAt,
|
||||
Value<String?>? createdAtMonth,
|
||||
Value<int>? rowid,
|
||||
}) {
|
||||
return MediaFilesCompanion(
|
||||
|
|
@ -3572,6 +3718,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
requiresAuthentication ?? this.requiresAuthentication,
|
||||
stored: stored ?? this.stored,
|
||||
isDraftMedia: isDraftMedia ?? this.isDraftMedia,
|
||||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed ?? this.hasCropAnalyzed,
|
||||
preProgressingProcess:
|
||||
preProgressingProcess ?? this.preProgressingProcess,
|
||||
reuploadRequestedBy: reuploadRequestedBy ?? this.reuploadRequestedBy,
|
||||
|
|
@ -3584,6 +3732,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
encryptionNonce: encryptionNonce ?? this.encryptionNonce,
|
||||
storedFileHash: storedFileHash ?? this.storedFileHash,
|
||||
createdAt: createdAt ?? this.createdAt,
|
||||
createdAtMonth: createdAtMonth ?? this.createdAtMonth,
|
||||
rowid: rowid ?? this.rowid,
|
||||
);
|
||||
}
|
||||
|
|
@ -3620,6 +3769,12 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
if (isDraftMedia.present) {
|
||||
map['is_draft_media'] = Variable<bool>(isDraftMedia.value);
|
||||
}
|
||||
if (isFavorite.present) {
|
||||
map['is_favorite'] = Variable<bool>(isFavorite.value);
|
||||
}
|
||||
if (hasCropAnalyzed.present) {
|
||||
map['has_crop_analyzed'] = Variable<bool>(hasCropAnalyzed.value);
|
||||
}
|
||||
if (preProgressingProcess.present) {
|
||||
map['pre_progressing_process'] = Variable<int>(
|
||||
preProgressingProcess.value,
|
||||
|
|
@ -3658,6 +3813,9 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
if (createdAt.present) {
|
||||
map['created_at'] = Variable<DateTime>(createdAt.value);
|
||||
}
|
||||
if (createdAtMonth.present) {
|
||||
map['created_at_month'] = Variable<String>(createdAtMonth.value);
|
||||
}
|
||||
if (rowid.present) {
|
||||
map['rowid'] = Variable<int>(rowid.value);
|
||||
}
|
||||
|
|
@ -3674,6 +3832,8 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
..write('requiresAuthentication: $requiresAuthentication, ')
|
||||
..write('stored: $stored, ')
|
||||
..write('isDraftMedia: $isDraftMedia, ')
|
||||
..write('isFavorite: $isFavorite, ')
|
||||
..write('hasCropAnalyzed: $hasCropAnalyzed, ')
|
||||
..write('preProgressingProcess: $preProgressingProcess, ')
|
||||
..write('reuploadRequestedBy: $reuploadRequestedBy, ')
|
||||
..write('displayLimitInMilliseconds: $displayLimitInMilliseconds, ')
|
||||
|
|
@ -3684,6 +3844,7 @@ class MediaFilesCompanion extends UpdateCompanion<MediaFile> {
|
|||
..write('encryptionNonce: $encryptionNonce, ')
|
||||
..write('storedFileHash: $storedFileHash, ')
|
||||
..write('createdAt: $createdAt, ')
|
||||
..write('createdAtMonth: $createdAtMonth, ')
|
||||
..write('rowid: $rowid')
|
||||
..write(')'))
|
||||
.toString();
|
||||
|
|
@ -14890,6 +15051,8 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
|
|||
Value<bool> requiresAuthentication,
|
||||
Value<bool> stored,
|
||||
Value<bool> isDraftMedia,
|
||||
Value<bool> isFavorite,
|
||||
Value<bool> hasCropAnalyzed,
|
||||
Value<int?> preProgressingProcess,
|
||||
Value<List<int>?> reuploadRequestedBy,
|
||||
Value<int?> displayLimitInMilliseconds,
|
||||
|
|
@ -14900,6 +15063,7 @@ typedef $$MediaFilesTableCreateCompanionBuilder =
|
|||
Value<Uint8List?> encryptionNonce,
|
||||
Value<Uint8List?> storedFileHash,
|
||||
Value<DateTime> createdAt,
|
||||
Value<String?> createdAtMonth,
|
||||
Value<int> rowid,
|
||||
});
|
||||
typedef $$MediaFilesTableUpdateCompanionBuilder =
|
||||
|
|
@ -14911,6 +15075,8 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
|
|||
Value<bool> requiresAuthentication,
|
||||
Value<bool> stored,
|
||||
Value<bool> isDraftMedia,
|
||||
Value<bool> isFavorite,
|
||||
Value<bool> hasCropAnalyzed,
|
||||
Value<int?> preProgressingProcess,
|
||||
Value<List<int>?> reuploadRequestedBy,
|
||||
Value<int?> displayLimitInMilliseconds,
|
||||
|
|
@ -14921,6 +15087,7 @@ typedef $$MediaFilesTableUpdateCompanionBuilder =
|
|||
Value<Uint8List?> encryptionNonce,
|
||||
Value<Uint8List?> storedFileHash,
|
||||
Value<DateTime> createdAt,
|
||||
Value<String?> createdAtMonth,
|
||||
Value<int> rowid,
|
||||
});
|
||||
|
||||
|
|
@ -14994,6 +15161,16 @@ class $$MediaFilesTableFilterComposer
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get isFavorite => $composableBuilder(
|
||||
column: $table.isFavorite,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<bool> get hasCropAnalyzed => $composableBuilder(
|
||||
column: $table.hasCropAnalyzed,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<int> get preProgressingProcess => $composableBuilder(
|
||||
column: $table.preProgressingProcess,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
|
|
@ -15045,6 +15222,11 @@ class $$MediaFilesTableFilterComposer
|
|||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
ColumnFilters<String> get createdAtMonth => $composableBuilder(
|
||||
column: $table.createdAtMonth,
|
||||
builder: (column) => ColumnFilters(column),
|
||||
);
|
||||
|
||||
Expression<bool> messagesRefs(
|
||||
Expression<bool> Function($$MessagesTableFilterComposer f) f,
|
||||
) {
|
||||
|
|
@ -15115,6 +15297,16 @@ class $$MediaFilesTableOrderingComposer
|
|||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get isFavorite => $composableBuilder(
|
||||
column: $table.isFavorite,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<bool> get hasCropAnalyzed => $composableBuilder(
|
||||
column: $table.hasCropAnalyzed,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<int> get preProgressingProcess => $composableBuilder(
|
||||
column: $table.preProgressingProcess,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
|
|
@ -15164,6 +15356,11 @@ class $$MediaFilesTableOrderingComposer
|
|||
column: $table.createdAt,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
|
||||
ColumnOrderings<String> get createdAtMonth => $composableBuilder(
|
||||
column: $table.createdAtMonth,
|
||||
builder: (column) => ColumnOrderings(column),
|
||||
);
|
||||
}
|
||||
|
||||
class $$MediaFilesTableAnnotationComposer
|
||||
|
|
@ -15206,6 +15403,16 @@ class $$MediaFilesTableAnnotationComposer
|
|||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get isFavorite => $composableBuilder(
|
||||
column: $table.isFavorite,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<bool> get hasCropAnalyzed => $composableBuilder(
|
||||
column: $table.hasCropAnalyzed,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
GeneratedColumn<int> get preProgressingProcess => $composableBuilder(
|
||||
column: $table.preProgressingProcess,
|
||||
builder: (column) => column,
|
||||
|
|
@ -15255,6 +15462,11 @@ class $$MediaFilesTableAnnotationComposer
|
|||
GeneratedColumn<DateTime> get createdAt =>
|
||||
$composableBuilder(column: $table.createdAt, builder: (column) => column);
|
||||
|
||||
GeneratedColumn<String> get createdAtMonth => $composableBuilder(
|
||||
column: $table.createdAtMonth,
|
||||
builder: (column) => column,
|
||||
);
|
||||
|
||||
Expression<T> messagesRefs<T extends Object>(
|
||||
Expression<T> Function($$MessagesTableAnnotationComposer a) f,
|
||||
) {
|
||||
|
|
@ -15316,6 +15528,8 @@ class $$MediaFilesTableTableManager
|
|||
Value<bool> requiresAuthentication = const Value.absent(),
|
||||
Value<bool> stored = const Value.absent(),
|
||||
Value<bool> isDraftMedia = const Value.absent(),
|
||||
Value<bool> isFavorite = const Value.absent(),
|
||||
Value<bool> hasCropAnalyzed = const Value.absent(),
|
||||
Value<int?> preProgressingProcess = const Value.absent(),
|
||||
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
||||
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
||||
|
|
@ -15326,6 +15540,7 @@ class $$MediaFilesTableTableManager
|
|||
Value<Uint8List?> encryptionNonce = const Value.absent(),
|
||||
Value<Uint8List?> storedFileHash = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
Value<String?> createdAtMonth = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => MediaFilesCompanion(
|
||||
mediaId: mediaId,
|
||||
|
|
@ -15335,6 +15550,8 @@ class $$MediaFilesTableTableManager
|
|||
requiresAuthentication: requiresAuthentication,
|
||||
stored: stored,
|
||||
isDraftMedia: isDraftMedia,
|
||||
isFavorite: isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed,
|
||||
preProgressingProcess: preProgressingProcess,
|
||||
reuploadRequestedBy: reuploadRequestedBy,
|
||||
displayLimitInMilliseconds: displayLimitInMilliseconds,
|
||||
|
|
@ -15345,6 +15562,7 @@ class $$MediaFilesTableTableManager
|
|||
encryptionNonce: encryptionNonce,
|
||||
storedFileHash: storedFileHash,
|
||||
createdAt: createdAt,
|
||||
createdAtMonth: createdAtMonth,
|
||||
rowid: rowid,
|
||||
),
|
||||
createCompanionCallback:
|
||||
|
|
@ -15356,6 +15574,8 @@ class $$MediaFilesTableTableManager
|
|||
Value<bool> requiresAuthentication = const Value.absent(),
|
||||
Value<bool> stored = const Value.absent(),
|
||||
Value<bool> isDraftMedia = const Value.absent(),
|
||||
Value<bool> isFavorite = const Value.absent(),
|
||||
Value<bool> hasCropAnalyzed = const Value.absent(),
|
||||
Value<int?> preProgressingProcess = const Value.absent(),
|
||||
Value<List<int>?> reuploadRequestedBy = const Value.absent(),
|
||||
Value<int?> displayLimitInMilliseconds = const Value.absent(),
|
||||
|
|
@ -15366,6 +15586,7 @@ class $$MediaFilesTableTableManager
|
|||
Value<Uint8List?> encryptionNonce = const Value.absent(),
|
||||
Value<Uint8List?> storedFileHash = const Value.absent(),
|
||||
Value<DateTime> createdAt = const Value.absent(),
|
||||
Value<String?> createdAtMonth = const Value.absent(),
|
||||
Value<int> rowid = const Value.absent(),
|
||||
}) => MediaFilesCompanion.insert(
|
||||
mediaId: mediaId,
|
||||
|
|
@ -15375,6 +15596,8 @@ class $$MediaFilesTableTableManager
|
|||
requiresAuthentication: requiresAuthentication,
|
||||
stored: stored,
|
||||
isDraftMedia: isDraftMedia,
|
||||
isFavorite: isFavorite,
|
||||
hasCropAnalyzed: hasCropAnalyzed,
|
||||
preProgressingProcess: preProgressingProcess,
|
||||
reuploadRequestedBy: reuploadRequestedBy,
|
||||
displayLimitInMilliseconds: displayLimitInMilliseconds,
|
||||
|
|
@ -15385,6 +15608,7 @@ class $$MediaFilesTableTableManager
|
|||
encryptionNonce: encryptionNonce,
|
||||
storedFileHash: storedFileHash,
|
||||
createdAt: createdAt,
|
||||
createdAtMonth: createdAtMonth,
|
||||
rowid: rowid,
|
||||
),
|
||||
withReferenceMapper: (p0) => p0
|
||||
|
|
|
|||
|
|
@ -7056,6 +7056,511 @@ i1.GeneratedColumn<int> _column_238(String aliasedName) =>
|
|||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL REFERENCES shortcuts(id)ON DELETE CASCADE',
|
||||
);
|
||||
|
||||
final class Schema14 extends i0.VersionedSchema {
|
||||
Schema14({required super.database}) : super(version: 14);
|
||||
@override
|
||||
late final List<i1.DatabaseSchemaEntity> entities = [
|
||||
contacts,
|
||||
groups,
|
||||
mediaFiles,
|
||||
messages,
|
||||
messageHistories,
|
||||
reactions,
|
||||
groupMembers,
|
||||
receipts,
|
||||
receivedReceipts,
|
||||
signalIdentityKeyStores,
|
||||
signalPreKeyStores,
|
||||
signalSenderKeyStores,
|
||||
signalSessionStores,
|
||||
messageActions,
|
||||
groupHistories,
|
||||
keyVerifications,
|
||||
verificationTokens,
|
||||
userDiscoveryAnnouncedUsers,
|
||||
userDiscoveryUserRelations,
|
||||
userDiscoveryOtherPromotions,
|
||||
userDiscoveryOwnPromotions,
|
||||
userDiscoveryShares,
|
||||
shortcuts,
|
||||
shortcutMembers,
|
||||
];
|
||||
late final Shape39 contacts = Shape39(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'contacts',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(user_id)'],
|
||||
columns: [
|
||||
_column_106,
|
||||
_column_107,
|
||||
_column_108,
|
||||
_column_109,
|
||||
_column_110,
|
||||
_column_111,
|
||||
_column_112,
|
||||
_column_113,
|
||||
_column_114,
|
||||
_column_115,
|
||||
_column_116,
|
||||
_column_117,
|
||||
_column_118,
|
||||
_column_211,
|
||||
_column_212,
|
||||
_column_213,
|
||||
_column_214,
|
||||
_column_215,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape23 groups = Shape23(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'groups',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(group_id)'],
|
||||
columns: [
|
||||
_column_119,
|
||||
_column_120,
|
||||
_column_121,
|
||||
_column_122,
|
||||
_column_123,
|
||||
_column_124,
|
||||
_column_125,
|
||||
_column_126,
|
||||
_column_127,
|
||||
_column_128,
|
||||
_column_129,
|
||||
_column_130,
|
||||
_column_131,
|
||||
_column_132,
|
||||
_column_133,
|
||||
_column_134,
|
||||
_column_118,
|
||||
_column_135,
|
||||
_column_136,
|
||||
_column_137,
|
||||
_column_138,
|
||||
_column_139,
|
||||
_column_140,
|
||||
_column_141,
|
||||
_column_142,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape49 mediaFiles = Shape49(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'media_files',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(media_id)'],
|
||||
columns: [
|
||||
_column_143,
|
||||
_column_144,
|
||||
_column_145,
|
||||
_column_146,
|
||||
_column_147,
|
||||
_column_148,
|
||||
_column_149,
|
||||
_column_239,
|
||||
_column_240,
|
||||
_column_207,
|
||||
_column_150,
|
||||
_column_151,
|
||||
_column_152,
|
||||
_column_153,
|
||||
_column_154,
|
||||
_column_155,
|
||||
_column_156,
|
||||
_column_157,
|
||||
_column_118,
|
||||
_column_241,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape25 messages = Shape25(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'messages',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(message_id)'],
|
||||
columns: [
|
||||
_column_158,
|
||||
_column_159,
|
||||
_column_160,
|
||||
_column_144,
|
||||
_column_161,
|
||||
_column_162,
|
||||
_column_163,
|
||||
_column_164,
|
||||
_column_165,
|
||||
_column_153,
|
||||
_column_166,
|
||||
_column_167,
|
||||
_column_168,
|
||||
_column_169,
|
||||
_column_118,
|
||||
_column_170,
|
||||
_column_171,
|
||||
_column_172,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape26 messageHistories = Shape26(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'message_histories',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [
|
||||
_column_173,
|
||||
_column_174,
|
||||
_column_175,
|
||||
_column_161,
|
||||
_column_118,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape27 reactions = Shape27(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'reactions',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(message_id, sender_id, emoji)'],
|
||||
columns: [_column_174, _column_176, _column_177, _column_118],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape38 groupMembers = Shape38(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'group_members',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(group_id, contact_id)'],
|
||||
columns: [
|
||||
_column_158,
|
||||
_column_178,
|
||||
_column_179,
|
||||
_column_180,
|
||||
_column_209,
|
||||
_column_210,
|
||||
_column_181,
|
||||
_column_118,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape37 receipts = Shape37(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'receipts',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(receipt_id)'],
|
||||
columns: [
|
||||
_column_182,
|
||||
_column_183,
|
||||
_column_184,
|
||||
_column_185,
|
||||
_column_186,
|
||||
_column_208,
|
||||
_column_187,
|
||||
_column_188,
|
||||
_column_189,
|
||||
_column_190,
|
||||
_column_191,
|
||||
_column_118,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape30 receivedReceipts = Shape30(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'received_receipts',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(receipt_id)'],
|
||||
columns: [_column_182, _column_118],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape31 signalIdentityKeyStores = Shape31(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_identity_key_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(device_id, name)'],
|
||||
columns: [_column_192, _column_193, _column_194, _column_118],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape32 signalPreKeyStores = Shape32(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_pre_key_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(pre_key_id)'],
|
||||
columns: [_column_195, _column_196, _column_118],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape11 signalSenderKeyStores = Shape11(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_sender_key_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(sender_key_name)'],
|
||||
columns: [_column_197, _column_198],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape33 signalSessionStores = Shape33(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'signal_session_stores',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(device_id, name)'],
|
||||
columns: [_column_192, _column_193, _column_199, _column_118],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape34 messageActions = Shape34(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'message_actions',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(message_id, contact_id, type)'],
|
||||
columns: [_column_174, _column_183, _column_144, _column_200],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape35 groupHistories = Shape35(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'group_histories',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(group_history_id)'],
|
||||
columns: [
|
||||
_column_201,
|
||||
_column_158,
|
||||
_column_202,
|
||||
_column_203,
|
||||
_column_204,
|
||||
_column_205,
|
||||
_column_206,
|
||||
_column_144,
|
||||
_column_200,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape40 keyVerifications = Shape40(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'key_verifications',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_216, _column_183, _column_144, _column_118],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape41 verificationTokens = Shape41(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'verification_tokens',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_217, _column_218, _column_118],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape42 userDiscoveryAnnouncedUsers = Shape42(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_discovery_announced_users',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(announced_user_id)'],
|
||||
columns: [
|
||||
_column_219,
|
||||
_column_220,
|
||||
_column_221,
|
||||
_column_222,
|
||||
_column_223,
|
||||
_column_224,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape43 userDiscoveryUserRelations = Shape43(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_discovery_user_relations',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(announced_user_id, from_contact_id)'],
|
||||
columns: [_column_225, _column_226, _column_227],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape44 userDiscoveryOtherPromotions = Shape44(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_discovery_other_promotions',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(from_contact_id, public_id)'],
|
||||
columns: [
|
||||
_column_226,
|
||||
_column_228,
|
||||
_column_229,
|
||||
_column_230,
|
||||
_column_231,
|
||||
_column_227,
|
||||
],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape45 userDiscoveryOwnPromotions = Shape45(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_discovery_own_promotions',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_232, _column_183, _column_233],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape46 userDiscoveryShares = Shape46(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'user_discovery_shares',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_234, _column_235, _column_175],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape47 shortcuts = Shape47(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'shortcuts',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: [],
|
||||
columns: [_column_173, _column_236, _column_237],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
late final Shape48 shortcutMembers = Shape48(
|
||||
source: i0.VersionedTable(
|
||||
entityName: 'shortcut_members',
|
||||
withoutRowId: false,
|
||||
isStrict: false,
|
||||
tableConstraints: ['PRIMARY KEY(shortcut_id, group_id)'],
|
||||
columns: [_column_238, _column_158],
|
||||
attachedDatabase: database,
|
||||
),
|
||||
alias: null,
|
||||
);
|
||||
}
|
||||
|
||||
class Shape49 extends i0.VersionedTable {
|
||||
Shape49({required super.source, required super.alias}) : super.aliased();
|
||||
i1.GeneratedColumn<String> get mediaId =>
|
||||
columnsByName['media_id']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get type =>
|
||||
columnsByName['type']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get uploadState =>
|
||||
columnsByName['upload_state']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<String> get downloadState =>
|
||||
columnsByName['download_state']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get requiresAuthentication =>
|
||||
columnsByName['requires_authentication']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get stored =>
|
||||
columnsByName['stored']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get isDraftMedia =>
|
||||
columnsByName['is_draft_media']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get isFavorite =>
|
||||
columnsByName['is_favorite']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get hasCropAnalyzed =>
|
||||
columnsByName['has_crop_analyzed']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get preProgressingProcess =>
|
||||
columnsByName['pre_progressing_process']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get reuploadRequestedBy =>
|
||||
columnsByName['reupload_requested_by']! as i1.GeneratedColumn<String>;
|
||||
i1.GeneratedColumn<int> get displayLimitInMilliseconds =>
|
||||
columnsByName['display_limit_in_milliseconds']!
|
||||
as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<int> get removeAudio =>
|
||||
columnsByName['remove_audio']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<i2.Uint8List> get downloadToken =>
|
||||
columnsByName['download_token']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||
i1.GeneratedColumn<i2.Uint8List> get encryptionKey =>
|
||||
columnsByName['encryption_key']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||
i1.GeneratedColumn<i2.Uint8List> get encryptionMac =>
|
||||
columnsByName['encryption_mac']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||
i1.GeneratedColumn<i2.Uint8List> get encryptionNonce =>
|
||||
columnsByName['encryption_nonce']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||
i1.GeneratedColumn<i2.Uint8List> get storedFileHash =>
|
||||
columnsByName['stored_file_hash']! as i1.GeneratedColumn<i2.Uint8List>;
|
||||
i1.GeneratedColumn<int> get createdAt =>
|
||||
columnsByName['created_at']! as i1.GeneratedColumn<int>;
|
||||
i1.GeneratedColumn<String> get createdAtMonth =>
|
||||
columnsByName['created_at_month']! as i1.GeneratedColumn<String>;
|
||||
}
|
||||
|
||||
i1.GeneratedColumn<int> _column_239(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'is_favorite',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints: 'NOT NULL DEFAULT 0 CHECK (is_favorite IN (0, 1))',
|
||||
defaultValue: const i1.CustomExpression('0'),
|
||||
);
|
||||
i1.GeneratedColumn<int> _column_240(String aliasedName) =>
|
||||
i1.GeneratedColumn<int>(
|
||||
'has_crop_analyzed',
|
||||
aliasedName,
|
||||
false,
|
||||
type: i1.DriftSqlType.int,
|
||||
$customConstraints:
|
||||
'NOT NULL DEFAULT 0 CHECK (has_crop_analyzed IN (0, 1))',
|
||||
defaultValue: const i1.CustomExpression('0'),
|
||||
);
|
||||
i1.GeneratedColumn<String> _column_241(String aliasedName) =>
|
||||
i1.GeneratedColumn<String>(
|
||||
'created_at_month',
|
||||
aliasedName,
|
||||
true,
|
||||
type: i1.DriftSqlType.string,
|
||||
$customConstraints: 'NULL',
|
||||
);
|
||||
i0.MigrationStepWithVersion migrationSteps({
|
||||
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
|
||||
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
|
||||
|
|
@ -7069,6 +7574,7 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
}) {
|
||||
return (currentVersion, database) async {
|
||||
switch (currentVersion) {
|
||||
|
|
@ -7132,6 +7638,11 @@ i0.MigrationStepWithVersion migrationSteps({
|
|||
final migrator = i1.Migrator(database, schema);
|
||||
await from12To13(migrator, schema);
|
||||
return 13;
|
||||
case 13:
|
||||
final schema = Schema14(database: database);
|
||||
final migrator = i1.Migrator(database, schema);
|
||||
await from13To14(migrator, schema);
|
||||
return 14;
|
||||
default:
|
||||
throw ArgumentError.value('Unknown migration from $currentVersion');
|
||||
}
|
||||
|
|
@ -7151,6 +7662,7 @@ i1.OnUpgrade stepByStep({
|
|||
required Future<void> Function(i1.Migrator m, Schema11 schema) from10To11,
|
||||
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
|
||||
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
|
||||
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
|
||||
}) => i0.VersionedSchema.stepByStepHelper(
|
||||
step: migrationSteps(
|
||||
from1To2: from1To2,
|
||||
|
|
@ -7165,5 +7677,6 @@ i1.OnUpgrade stepByStep({
|
|||
from10To11: from10To11,
|
||||
from11To12: from11To12,
|
||||
from12To13: from12To13,
|
||||
from13To14: from13To14,
|
||||
),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1178,12 +1178,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 +1316,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 +1328,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 +3175,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
|
||||
|
|
|
|||
|
|
@ -601,9 +601,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 +674,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 +1789,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.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -595,9 +595,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 +668,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 +1773,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 10b4bfedcc6c99e4ba0374b306610d297b9e2276
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -372,4 +374,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
332
lib/src/services/memories/memories.service.dart
Normal file
332
lib/src/services/memories/memories.service.dart
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
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.hashStoredMedia();
|
||||
_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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
galleryItems: widget.galleryItems,
|
||||
initialIndex: galleryItemIndex!,
|
||||
),
|
||||
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(
|
||||
galleryItems: galleryItems,
|
||||
),
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,39 +283,48 @@ class HomeViewState extends State<HomeView> {
|
|||
],
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: BottomNavigationBar(
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
unselectedIconTheme: IconThemeData(
|
||||
color: Theme.of(context).colorScheme.inverseSurface.withAlpha(150),
|
||||
),
|
||||
selectedIconTheme: IconThemeData(
|
||||
color: Theme.of(context).colorScheme.inverseSurface,
|
||||
),
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: FaIcon(FontAwesomeIcons.solidComments),
|
||||
label: '',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: FaIcon(FontAwesomeIcons.camera),
|
||||
label: '',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: FaIcon(FontAwesomeIcons.photoFilm),
|
||||
label: '',
|
||||
),
|
||||
],
|
||||
onTap: (index) async {
|
||||
_activePageIdx = index;
|
||||
await _homeViewPageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.bounceIn,
|
||||
);
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
currentIndex: _activePageIdx,
|
||||
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),
|
||||
),
|
||||
selectedIconTheme: IconThemeData(
|
||||
color: Theme.of(context).colorScheme.inverseSurface,
|
||||
),
|
||||
items: const [
|
||||
BottomNavigationBarItem(
|
||||
icon: FaIcon(FontAwesomeIcons.solidComments),
|
||||
label: '',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: FaIcon(FontAwesomeIcons.camera),
|
||||
label: '',
|
||||
),
|
||||
BottomNavigationBarItem(
|
||||
icon: FaIcon(FontAwesomeIcons.photoFilm),
|
||||
label: '',
|
||||
),
|
||||
],
|
||||
onTap: (index) async {
|
||||
_activePageIdx = index;
|
||||
await _homeViewPageController.animateToPage(
|
||||
index,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.bounceIn,
|
||||
);
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
currentIndex: _activePageIdx,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,104 @@
|
|||
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(16, 8, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 150,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
physics: const BouncingScrollPhysics(),
|
||||
itemCount: lastYears.length,
|
||||
separatorBuilder: (context, _) => const SizedBox(width: 12),
|
||||
itemBuilder: (context, idx) {
|
||||
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,254 +22,498 @@ 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;
|
||||
void _onActiveMediaChanged() {
|
||||
if (_isViewingFlashback) return;
|
||||
final mediaId = _activeMediaIdNotifier.value;
|
||||
if (mediaId == null) return;
|
||||
final state = _service.currentState;
|
||||
if (state.isEmpty) return;
|
||||
|
||||
final index = state.galleryItems.indexWhere(
|
||||
(item) => item.mediaService.mediaFile.mediaId == mediaId,
|
||||
);
|
||||
if (index == -1) return;
|
||||
|
||||
double offset = 56;
|
||||
if (state.galleryItemsLastYears.isNotEmpty) {
|
||||
offset += 220;
|
||||
}
|
||||
await messageSub?.cancel();
|
||||
final msgStream = twonlyDB.mediaFilesDao.watchAllStoredMediaFiles();
|
||||
|
||||
messageSub = msgStream.listen((mediaFiles) async {
|
||||
// Group items by month
|
||||
orderedByMonth = {};
|
||||
months = [];
|
||||
var lastMonth = '';
|
||||
galleryItems = [];
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final itemWidth = (screenWidth - 8) / 4;
|
||||
final itemHeight = itemWidth * (16 / 9);
|
||||
final rowHeight = itemHeight + 2;
|
||||
|
||||
final now = clock.now();
|
||||
for (final month in state.months) {
|
||||
final indices = state.orderedByMonth[month]!;
|
||||
offset += 44;
|
||||
|
||||
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: [],
|
||||
);
|
||||
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] = [];
|
||||
}
|
||||
_galleryItemsLastYears[diff]!.add(item);
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
galleryItems.sort(
|
||||
(a, b) => b.mediaService.mediaFile.createdAt.compareTo(
|
||||
a.mediaService.mediaFile.createdAt,
|
||||
),
|
||||
}
|
||||
|
||||
if (_scrollController.hasClients) {
|
||||
final targetOffset = (offset - 100).clamp(
|
||||
0.0,
|
||||
_scrollController.position.maxScrollExtent,
|
||||
);
|
||||
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);
|
||||
}
|
||||
orderedByMonth.putIfAbsent(month, () => []).add(i);
|
||||
}
|
||||
if (mounted) {
|
||||
setState(() {});
|
||||
_scrollController.jumpTo(targetOffset);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
if (isFlashback) {
|
||||
_isViewingFlashback = false;
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleSelection(String mediaId) {
|
||||
setState(() {
|
||||
if (_selectedMediaIds.contains(mediaId)) {
|
||||
_selectedMediaIds.remove(mediaId);
|
||||
} else {
|
||||
_selectedMediaIds.add(mediaId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget child = Center(
|
||||
child: Text(
|
||||
context.lang.memoriesEmpty,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
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 (_filesToMigrate > 0) {
|
||||
child = Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
ThreeRotatingDots(
|
||||
size: 40,
|
||||
color: context.color.primary,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
context.lang.migrationOfMemories(_filesToMigrate),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
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,
|
||||
);
|
||||
} 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,
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
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(
|
||||
crossAxisCount: 4,
|
||||
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);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
} 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) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Memories')),
|
||||
body: Scrollbar(
|
||||
child: child,
|
||||
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: [
|
||||
ThreeRotatingDots(
|
||||
size: 40,
|
||||
color: context.color.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.lang.migrationOfMemories(state.filesToMigrate),
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (state.isEmpty) {
|
||||
return Center(
|
||||
child: Padding(
|
||||
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;
|
||||
});
|
||||
},
|
||||
tooltip: _filterFavoritesOnly
|
||||
? 'Show all'
|
||||
: 'Show favorites only',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
MemoriesFlashbackBannerComp(
|
||||
lastYears: lastYears,
|
||||
onOpenFlashback: (items, idx) =>
|
||||
_openViewer(items, idx, isFlashback: true),
|
||||
),
|
||||
|
||||
for (final month in months) ...[
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 12, 8, 6),
|
||||
sliver: SliverToBoxAdapter(
|
||||
child: Text(
|
||||
month,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 2,
|
||||
crossAxisSpacing: 2,
|
||||
childAspectRatio: 9 / 16,
|
||||
),
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, idx) {
|
||||
final globalIndex = orderedByMonth[month]![idx];
|
||||
final item = state.galleryItems[globalIndex];
|
||||
final mediaId =
|
||||
item.mediaService.mediaFile.mediaId;
|
||||
final isSelected = _selectedMediaIds.contains(
|
||||
mediaId,
|
||||
);
|
||||
|
||||
return MemoriesThumbnailComp(
|
||||
galleryItem: item,
|
||||
index: globalIndex,
|
||||
selectionMode: _selectionMode,
|
||||
isSelected: isSelected,
|
||||
activeMediaIdNotifier: _activeMediaIdNotifier,
|
||||
onLongPress: () => _onLongPressItem(mediaId),
|
||||
onTap: () => _onTapItem(mediaId, globalIndex),
|
||||
);
|
||||
},
|
||||
childCount: orderedByMonth[month]!.length,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SliverPadding(padding: EdgeInsets.only(bottom: 32)),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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,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 {
|
||||
|
|
|
|||
|
|
@ -57,21 +57,16 @@ class _AdditionalUsersViewState extends State<AdditionalUsersView> {
|
|||
}
|
||||
|
||||
Future<void> addAdditionalUser() async {
|
||||
final selectedUserIds =
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (context) => SelectAdditionalUsers(
|
||||
limit: _planLimit,
|
||||
alreadySelected:
|
||||
ballance?.additionalAccounts
|
||||
.map((e) => e.userId.toInt())
|
||||
.toList() ??
|
||||
[],
|
||||
),
|
||||
),
|
||||
)
|
||||
as List<int>?;
|
||||
final selectedUserIds = await context.navPush(
|
||||
SelectAdditionalUsers(
|
||||
limit: _planLimit,
|
||||
alreadySelected:
|
||||
ballance?.additionalAccounts
|
||||
.map((e) => e.userId.toInt())
|
||||
.toList() ??
|
||||
[],
|
||||
),
|
||||
) 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,26 +59,69 @@ class _SubscriptionViewState extends State<SubscriptionView> {
|
|||
),
|
||||
body: ListView(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Center(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.color.primary,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
child: Text(
|
||||
currentPlan.name,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkMode(context) ? Colors.black : Colors.white,
|
||||
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(
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.color.primary,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
child: Text(
|
||||
currentPlan.name,
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: isDarkMode(context) ? Colors.black : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(
|
||||
ballance: ballance,
|
||||
);
|
||||
},
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -17,6 +17,7 @@ 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;
|
||||
|
||||
class GeneratedHelper implements SchemaInstantiationHelper {
|
||||
@override
|
||||
|
|
@ -48,10 +49,12 @@ class GeneratedHelper implements SchemaInstantiationHelper {
|
|||
return v12.DatabaseAtV12(db);
|
||||
case 13:
|
||||
return v13.DatabaseAtV13(db);
|
||||
case 14:
|
||||
return v14.DatabaseAtV14(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];
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Reference in a new issue