commit
98fc8cad30
|
@ -8,9 +8,8 @@ buildscript {
|
|||
ext.appCompatVersion = '1.1.0' // for background_fetch
|
||||
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:3.5.0'
|
||||
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
||||
classpath 'com.android.tools.build:gradle:3.3.1' // for background_fetch
|
||||
classpath 'com.android.tools.build:gradle:4.0.1' // for background_fetch
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -3,4 +3,4 @@ distributionBase=GRADLE_USER_HOME
|
|||
distributionPath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-5.6.2-all.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-6.1.1-all.zip
|
||||
|
|
25
lib/core/cache/thumbnail_cache_manager.dart
vendored
25
lib/core/cache/thumbnail_cache_manager.dart
vendored
|
@ -1,22 +1,11 @@
|
|||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class ThumbnailCacheManager extends BaseCacheManager {
|
||||
class ThumbnailCacheManager {
|
||||
static const key = 'cached-thumbnail-data';
|
||||
|
||||
static ThumbnailCacheManager _instance;
|
||||
|
||||
factory ThumbnailCacheManager() {
|
||||
_instance ??= ThumbnailCacheManager._();
|
||||
return _instance;
|
||||
}
|
||||
|
||||
ThumbnailCacheManager._() : super(key, maxNrOfCacheObjects: 2500);
|
||||
|
||||
@override
|
||||
Future<String> getFilePath() async {
|
||||
var directory = await getTemporaryDirectory();
|
||||
return p.join(directory.path, key);
|
||||
}
|
||||
static CacheManager instance = CacheManager(
|
||||
Config(
|
||||
key,
|
||||
maxNrOfCacheObjects: 2500,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
24
lib/core/cache/video_cache_manager.dart
vendored
24
lib/core/cache/video_cache_manager.dart
vendored
|
@ -1,22 +1,12 @@
|
|||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
|
||||
class VideoCacheManager extends BaseCacheManager {
|
||||
class VideoCacheManager {
|
||||
static const key = 'cached-video-data';
|
||||
|
||||
static VideoCacheManager _instance;
|
||||
|
||||
factory VideoCacheManager() {
|
||||
_instance ??= VideoCacheManager._();
|
||||
return _instance;
|
||||
}
|
||||
|
||||
VideoCacheManager._() : super(key, maxNrOfCacheObjects: 50);
|
||||
|
||||
@override
|
||||
Future<String> getFilePath() async {
|
||||
var directory = await getTemporaryDirectory();
|
||||
return p.join(directory.path, key);
|
||||
}
|
||||
static CacheManager instance = CacheManager(
|
||||
Config(
|
||||
key,
|
||||
maxNrOfCacheObjects: 50,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -15,3 +15,5 @@ class UserCancelledUploadError extends Error {}
|
|||
class LockAlreadyAcquiredError extends Error {}
|
||||
|
||||
class UnauthorizedError extends Error {}
|
||||
|
||||
class RequestCancelledError extends Error{}
|
||||
|
|
|
@ -10,6 +10,13 @@ import 'package:path_provider/path_provider.dart';
|
|||
import 'package:sqflite_migration/sqflite_migration.dart';
|
||||
|
||||
class FilesDB {
|
||||
/*
|
||||
Note: columnUploadedFileID and columnCollectionID have to be compared against
|
||||
both NULL and -1 because older clients might have entries where the DEFAULT
|
||||
was unset, and a migration script to set the DEFAULT would break in case of
|
||||
duplicate entries for un-uploaded files that were created due to a collision
|
||||
in background and foreground syncs.
|
||||
*/
|
||||
static final _databaseName = "ente.files.db";
|
||||
|
||||
static final Logger _logger = Logger("FilesDB");
|
||||
|
@ -69,9 +76,9 @@ class FilesDB {
|
|||
CREATE TABLE $tableName (
|
||||
$columnGeneratedID INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
$columnLocalID TEXT,
|
||||
$columnUploadedFileID INTEGER,
|
||||
$columnUploadedFileID INTEGER DEFAULT -1,
|
||||
$columnOwnerID INTEGER,
|
||||
$columnCollectionID INTEGER,
|
||||
$columnCollectionID INTEGER DEFAULT -1,
|
||||
$columnTitle TEXT NOT NULL,
|
||||
$columnDeviceFolder TEXT,
|
||||
$columnLatitude REAL,
|
||||
|
@ -204,24 +211,63 @@ class FilesDB {
|
|||
return ids;
|
||||
}
|
||||
|
||||
Future<List<File>> getDeduplicatedFiles() async {
|
||||
_logger.info("Getting files for collection");
|
||||
final db = await instance.database;
|
||||
final results = await db.query(table,
|
||||
where: '$columnIsDeleted = 0',
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
groupBy:
|
||||
'IFNULL($columnUploadedFileID, $columnGeneratedID), IFNULL($columnLocalID, $columnGeneratedID)');
|
||||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getFiles() async {
|
||||
Future<List<File>> getAllFiles(int startTime, int endTime,
|
||||
{int limit}) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
table,
|
||||
where: '$columnIsDeleted = 0',
|
||||
where:
|
||||
'$columnCreationTime >= ? AND $columnCreationTime <= ? AND $columnIsDeleted = 0 AND ($columnLocalID IS NOT NULL OR ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1))',
|
||||
whereArgs: [startTime, endTime],
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
limit: 100,
|
||||
limit: limit,
|
||||
);
|
||||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getFilesInPaths(
|
||||
int startTime, int endTime, List<String> paths,
|
||||
{int limit}) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
table,
|
||||
where:
|
||||
'$columnCreationTime >= ? AND $columnCreationTime <= ? AND $columnIsDeleted = 0 AND (($columnLocalID IS NOT NULL AND $columnDeviceFolder IN (?)) OR ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1))',
|
||||
whereArgs: [startTime, endTime, paths.join(", ")],
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getFilesInCollection(
|
||||
int collectionID, int startTime, int endTime,
|
||||
{int limit}) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
table,
|
||||
where:
|
||||
'$columnCollectionID = ? AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND $columnIsDeleted = 0',
|
||||
whereArgs: [collectionID, startTime, endTime],
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
limit: limit,
|
||||
);
|
||||
final files = _convertToFiles(results);
|
||||
_logger.info("Fetched " + files.length.toString() + " files");
|
||||
return files;
|
||||
}
|
||||
|
||||
Future<List<File>> getFilesInPath(String path, int startTime, int endTime,
|
||||
{int limit}) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
table,
|
||||
where:
|
||||
'$columnDeviceFolder = ? AND $columnCreationTime >= ? AND $columnCreationTime <= ? AND $columnLocalID IS NOT NULL AND $columnIsDeleted = 0',
|
||||
whereArgs: [path, startTime, endTime],
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
groupBy: '$columnLocalID',
|
||||
limit: limit,
|
||||
);
|
||||
return _convertToFiles(results);
|
||||
}
|
||||
|
@ -237,20 +283,6 @@ class FilesDB {
|
|||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getAllInCollectionBeforeCreationTime(
|
||||
int collectionID, int beforeCreationTime, int limit) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
table,
|
||||
where:
|
||||
'$columnCollectionID = ? AND $columnIsDeleted = 0 AND $columnCreationTime < ?',
|
||||
whereArgs: [collectionID, beforeCreationTime],
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
limit: limit,
|
||||
);
|
||||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getAllInPath(String path) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
|
@ -264,40 +296,23 @@ class FilesDB {
|
|||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getAllInPathBeforeCreationTime(
|
||||
String path, int beforeCreationTime, int limit) async {
|
||||
Future<List<File>> getFilesCreatedWithinDurations(
|
||||
List<List<int>> durations) async {
|
||||
final db = await instance.database;
|
||||
String whereClause = "";
|
||||
for (int index = 0; index < durations.length; index++) {
|
||||
whereClause += "($columnCreationTime > " +
|
||||
durations[index][0].toString() +
|
||||
" AND $columnCreationTime < " +
|
||||
durations[index][1].toString() +
|
||||
")";
|
||||
if (index != durations.length - 1) {
|
||||
whereClause += " OR ";
|
||||
}
|
||||
}
|
||||
final results = await db.query(
|
||||
table,
|
||||
where:
|
||||
'$columnLocalID IS NOT NULL AND $columnDeviceFolder = ? AND $columnIsDeleted = 0 AND $columnCreationTime < ?',
|
||||
whereArgs: [path, beforeCreationTime],
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
groupBy: '$columnLocalID',
|
||||
limit: limit,
|
||||
);
|
||||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getAllInCollection(int collectionID) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
table,
|
||||
where: '$columnCollectionID = ?',
|
||||
whereArgs: [collectionID],
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
);
|
||||
return _convertToFiles(results);
|
||||
}
|
||||
|
||||
Future<List<File>> getFilesCreatedWithinDuration(
|
||||
int startCreationTime, int endCreationTime) async {
|
||||
final db = await instance.database;
|
||||
final results = await db.query(
|
||||
table,
|
||||
where:
|
||||
'$columnCreationTime > ? AND $columnCreationTime < ? AND $columnIsDeleted = 0',
|
||||
whereArgs: [startCreationTime, endCreationTime],
|
||||
where: whereClause + " AND $columnIsDeleted = 0",
|
||||
orderBy: '$columnCreationTime ASC',
|
||||
);
|
||||
return _convertToFiles(results);
|
||||
|
@ -333,7 +348,7 @@ class FilesDB {
|
|||
final results = await db.query(
|
||||
table,
|
||||
where:
|
||||
'$columnUploadedFileID IS NULL AND $columnDeviceFolder IN ($inParam)',
|
||||
'($columnUploadedFileID IS NULL OR $columnUploadedFileID IS -1) AND $columnDeviceFolder IN ($inParam)',
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
groupBy: '$columnLocalID',
|
||||
);
|
||||
|
@ -346,7 +361,7 @@ class FilesDB {
|
|||
table,
|
||||
columns: [columnUploadedFileID],
|
||||
where:
|
||||
'($columnLocalID IS NOT NULL AND $columnUploadedFileID IS NOT NULL AND $columnUpdationTime IS NULL AND $columnIsDeleted = 0)',
|
||||
'($columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1) AND $columnUpdationTime IS NULL AND $columnIsDeleted = 0)',
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
distinct: true,
|
||||
);
|
||||
|
@ -394,7 +409,7 @@ class FilesDB {
|
|||
table,
|
||||
columns: [columnUploadedFileID],
|
||||
where:
|
||||
'($columnLocalID IS NOT NULL AND $columnUploadedFileID IS NOT NULL AND $columnUpdationTime IS NOT NULL AND $columnIsDeleted = 0)',
|
||||
'($columnLocalID IS NOT NULL AND ($columnUploadedFileID IS NOT NULL AND $columnUploadedFileID IS NOT -1) AND $columnUpdationTime IS NOT NULL AND $columnIsDeleted = 0)',
|
||||
orderBy: '$columnCreationTime DESC',
|
||||
distinct: true,
|
||||
);
|
||||
|
@ -425,80 +440,6 @@ class FilesDB {
|
|||
);
|
||||
}
|
||||
|
||||
Future<Map<int, File>> getLastCreatedFilesInCollections(
|
||||
List<int> collectionIDs) async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT
|
||||
$columnGeneratedID,
|
||||
$columnLocalID,
|
||||
$columnUploadedFileID,
|
||||
$columnOwnerID,
|
||||
$columnCollectionID,
|
||||
$columnTitle,
|
||||
$columnDeviceFolder,
|
||||
$columnLatitude,
|
||||
$columnLongitude,
|
||||
$columnFileType,
|
||||
$columnModificationTime,
|
||||
$columnEncryptedKey,
|
||||
$columnKeyDecryptionNonce,
|
||||
$columnFileDecryptionHeader,
|
||||
$columnThumbnailDecryptionHeader,
|
||||
$columnMetadataDecryptionHeader,
|
||||
$columnIsDeleted,
|
||||
$columnUpdationTime,
|
||||
MAX($columnCreationTime) as $columnCreationTime
|
||||
FROM $table
|
||||
WHERE $columnCollectionID IN (${collectionIDs.join(', ')}) AND $columnIsDeleted = 0
|
||||
GROUP BY $columnCollectionID
|
||||
ORDER BY $columnCreationTime DESC;
|
||||
''');
|
||||
final result = Map<int, File>();
|
||||
final files = _convertToFiles(rows);
|
||||
for (final file in files) {
|
||||
result[file.collectionID] = file;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<Map<int, File>> getLastUpdatedFilesInCollections(
|
||||
List<int> collectionIDs) async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.rawQuery('''
|
||||
SELECT
|
||||
$columnGeneratedID,
|
||||
$columnLocalID,
|
||||
$columnUploadedFileID,
|
||||
$columnOwnerID,
|
||||
$columnCollectionID,
|
||||
$columnTitle,
|
||||
$columnDeviceFolder,
|
||||
$columnLatitude,
|
||||
$columnLongitude,
|
||||
$columnFileType,
|
||||
$columnModificationTime,
|
||||
$columnEncryptedKey,
|
||||
$columnKeyDecryptionNonce,
|
||||
$columnFileDecryptionHeader,
|
||||
$columnThumbnailDecryptionHeader,
|
||||
$columnMetadataDecryptionHeader,
|
||||
$columnIsDeleted,
|
||||
$columnCreationTime,
|
||||
MAX($columnUpdationTime) AS $columnUpdationTime
|
||||
FROM $table
|
||||
WHERE $columnCollectionID IN (${collectionIDs.join(', ')}) AND $columnIsDeleted = 0
|
||||
GROUP BY $columnCollectionID
|
||||
ORDER BY $columnUpdationTime DESC;
|
||||
''');
|
||||
final result = Map<int, File>();
|
||||
final files = _convertToFiles(rows);
|
||||
for (final file in files) {
|
||||
result[file.collectionID] = file;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<List<File>> getMatchingFiles(
|
||||
String title,
|
||||
String deviceFolder,
|
||||
|
@ -536,20 +477,6 @@ class FilesDB {
|
|||
}
|
||||
}
|
||||
|
||||
Future<File> getMatchingRemoteFile(int uploadedFileID) async {
|
||||
final db = await instance.database;
|
||||
final rows = await db.query(
|
||||
table,
|
||||
where: '$columnUploadedFileID=?',
|
||||
whereArgs: [uploadedFileID],
|
||||
);
|
||||
if (rows.isNotEmpty) {
|
||||
return _getFileFromRow(rows[0]);
|
||||
} else {
|
||||
throw ("No matching file found");
|
||||
}
|
||||
}
|
||||
|
||||
Future<int> update(File file) async {
|
||||
final db = await instance.database;
|
||||
return await db.update(
|
||||
|
@ -570,18 +497,6 @@ class FilesDB {
|
|||
);
|
||||
}
|
||||
|
||||
Future<int> markForDeletion(int uploadedFileID) async {
|
||||
final db = await instance.database;
|
||||
final values = new Map<String, dynamic>();
|
||||
values[columnIsDeleted] = 1;
|
||||
return db.update(
|
||||
table,
|
||||
values,
|
||||
where: '$columnUploadedFileID =?',
|
||||
whereArgs: [uploadedFileID],
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> delete(int uploadedFileID) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
|
@ -591,6 +506,14 @@ class FilesDB {
|
|||
);
|
||||
}
|
||||
|
||||
Future<int> deleteMultipleUploadedFiles(List<int> uploadedFileIDs) async {
|
||||
final db = await instance.database;
|
||||
return await db.delete(
|
||||
table,
|
||||
where: '$columnUploadedFileID IN (${uploadedFileIDs.join(', ')})',
|
||||
);
|
||||
}
|
||||
|
||||
Future<int> deleteLocalFile(String localID) async {
|
||||
final db = await instance.database;
|
||||
return db.delete(
|
||||
|
@ -637,13 +560,24 @@ class FilesDB {
|
|||
(
|
||||
SELECT $columnDeviceFolder, MAX($columnCreationTime) AS max_creation_time
|
||||
FROM $table
|
||||
WHERE $table.$columnLocalID IS NOT NULL
|
||||
GROUP BY $columnDeviceFolder
|
||||
) latest_files
|
||||
ON $table.$columnDeviceFolder = latest_files.$columnDeviceFolder
|
||||
AND $table.$columnCreationTime = latest_files.max_creation_time
|
||||
AND $table.$columnLocalID IS NOT NULL;
|
||||
AND $table.$columnCreationTime = latest_files.max_creation_time;
|
||||
''');
|
||||
return _convertToFiles(rows);
|
||||
final files = _convertToFiles(rows);
|
||||
// TODO: Do this de-duplication within the SQL Query
|
||||
final folderMap = Map<String, File>();
|
||||
for (final file in files) {
|
||||
if (folderMap.containsKey(file.deviceFolder)) {
|
||||
if (folderMap[file.deviceFolder].updationTime < file.updationTime) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
folderMap[file.deviceFolder] = file;
|
||||
}
|
||||
return folderMap.values.toList();
|
||||
}
|
||||
|
||||
Future<List<File>> getLatestCollectionFiles() async {
|
||||
|
@ -656,6 +590,7 @@ class FilesDB {
|
|||
SELECT $columnCollectionID, MAX($columnCreationTime) AS max_creation_time
|
||||
FROM $table
|
||||
GROUP BY $columnCollectionID
|
||||
WHERE $columnCollectionID IS NOT -1
|
||||
) latest_files
|
||||
ON $table.$columnCollectionID = latest_files.$columnCollectionID
|
||||
AND $table.$columnCreationTime = latest_files.max_creation_time;
|
||||
|
@ -703,7 +638,7 @@ class FilesDB {
|
|||
}
|
||||
|
||||
List<File> _convertToFiles(List<Map<String, dynamic>> results) {
|
||||
final files = List<File>();
|
||||
final List<File> files = [];
|
||||
for (final result in results) {
|
||||
files.add(_getFileFromRow(result));
|
||||
}
|
||||
|
@ -716,9 +651,9 @@ class FilesDB {
|
|||
row[columnGeneratedID] = file.generatedID;
|
||||
}
|
||||
row[columnLocalID] = file.localID;
|
||||
row[columnUploadedFileID] = file.uploadedFileID;
|
||||
row[columnUploadedFileID] = file.uploadedFileID ?? -1;
|
||||
row[columnOwnerID] = file.ownerID;
|
||||
row[columnCollectionID] = file.collectionID;
|
||||
row[columnCollectionID] = file.collectionID ?? -1;
|
||||
row[columnTitle] = file.title;
|
||||
row[columnDeviceFolder] = file.deviceFolder;
|
||||
if (file.location != null) {
|
||||
|
@ -749,7 +684,7 @@ class FilesDB {
|
|||
Map<String, dynamic> _getRowForFileWithoutCollection(File file) {
|
||||
final row = new Map<String, dynamic>();
|
||||
row[columnLocalID] = file.localID;
|
||||
row[columnUploadedFileID] = file.uploadedFileID;
|
||||
row[columnUploadedFileID] = file.uploadedFileID ?? -1;
|
||||
row[columnOwnerID] = file.ownerID;
|
||||
row[columnTitle] = file.title;
|
||||
row[columnDeviceFolder] = file.deviceFolder;
|
||||
|
@ -780,9 +715,11 @@ class FilesDB {
|
|||
final file = File();
|
||||
file.generatedID = row[columnGeneratedID];
|
||||
file.localID = row[columnLocalID];
|
||||
file.uploadedFileID = row[columnUploadedFileID];
|
||||
file.uploadedFileID =
|
||||
row[columnUploadedFileID] == -1 ? null : row[columnUploadedFileID];
|
||||
file.ownerID = row[columnOwnerID];
|
||||
file.collectionID = row[columnCollectionID];
|
||||
file.collectionID =
|
||||
row[columnCollectionID] == -1 ? null : row[columnCollectionID];
|
||||
file.title = row[columnTitle];
|
||||
file.deviceFolder = row[columnDeviceFolder];
|
||||
if (row[columnLatitude] != null && row[columnLongitude] != null) {
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import 'package:photos/events/event.dart';
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
|
||||
class CollectionUpdatedEvent extends Event {
|
||||
class CollectionUpdatedEvent extends FilesUpdatedEvent {
|
||||
final int collectionID;
|
||||
|
||||
CollectionUpdatedEvent({this.collectionID});
|
||||
CollectionUpdatedEvent(this.collectionID, updatedFiles, {type})
|
||||
: super(updatedFiles, type: type ?? EventType.added_or_updated);
|
||||
}
|
||||
|
|
17
lib/events/files_updated_event.dart
Normal file
17
lib/events/files_updated_event.dart
Normal file
|
@ -0,0 +1,17 @@
|
|||
import 'package:photos/events/event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
|
||||
class FilesUpdatedEvent extends Event {
|
||||
final List<File> updatedFiles;
|
||||
final EventType type;
|
||||
|
||||
FilesUpdatedEvent(
|
||||
this.updatedFiles, {
|
||||
this.type = EventType.added_or_updated,
|
||||
});
|
||||
}
|
||||
|
||||
enum EventType {
|
||||
added_or_updated,
|
||||
deleted,
|
||||
}
|
3
lib/events/first_import_succeeded_event.dart
Normal file
3
lib/events/first_import_succeeded_event.dart
Normal file
|
@ -0,0 +1,3 @@
|
|||
import 'package:photos/events/event.dart';
|
||||
|
||||
class FirstImportSucceededEvent extends Event {}
|
|
@ -1,3 +1,6 @@
|
|||
import 'package:photos/events/event.dart';
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
|
||||
class LocalPhotosUpdatedEvent extends Event {}
|
||||
class LocalPhotosUpdatedEvent extends FilesUpdatedEvent {
|
||||
LocalPhotosUpdatedEvent(updatedFiles, {type})
|
||||
: super(updatedFiles, type: type ?? EventType.added_or_updated);
|
||||
}
|
||||
|
|
|
@ -7,6 +7,7 @@ class SyncStatusUpdate extends Event {
|
|||
final SyncStatus status;
|
||||
final String reason;
|
||||
final Error error;
|
||||
int timestamp;
|
||||
|
||||
SyncStatusUpdate(
|
||||
this.status, {
|
||||
|
@ -15,7 +16,9 @@ class SyncStatusUpdate extends Event {
|
|||
this.wasStopped = false,
|
||||
this.reason = "",
|
||||
this.error,
|
||||
});
|
||||
}) {
|
||||
this.timestamp = DateTime.now().microsecondsSinceEpoch;
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import 'package:sentry/sentry.dart';
|
||||
import 'package:photos/events/event.dart';
|
||||
|
||||
class TabChangedEvent extends Event {
|
||||
final int selectedIndex;
|
||||
|
|
|
@ -99,14 +99,14 @@ class File {
|
|||
}
|
||||
|
||||
String getDownloadUrl() {
|
||||
if (kDebugMode) {
|
||||
return Configuration.instance.getHttpEndpoint() +
|
||||
"/files/download/" +
|
||||
uploadedFileID.toString();
|
||||
} else {
|
||||
return "https://files.ente.workers.dev/?fileID=" +
|
||||
uploadedFileID.toString();
|
||||
}
|
||||
// if (kDebugMode) {
|
||||
return Configuration.instance.getHttpEndpoint() +
|
||||
"/files/download/" +
|
||||
uploadedFileID.toString();
|
||||
// } else {
|
||||
// return "https://files.ente.workers.dev/?fileID=" +
|
||||
// uploadedFileID.toString();
|
||||
// }
|
||||
}
|
||||
|
||||
// Passing token within the URL due to https://github.com/flutter/flutter/issues/16466
|
||||
|
@ -120,14 +120,14 @@ class File {
|
|||
}
|
||||
|
||||
String getThumbnailUrl() {
|
||||
if (kDebugMode) {
|
||||
return Configuration.instance.getHttpEndpoint() +
|
||||
"/files/preview/" +
|
||||
uploadedFileID.toString();
|
||||
} else {
|
||||
return "https://thumbnails.ente.workers.dev/?fileID=" +
|
||||
uploadedFileID.toString();
|
||||
}
|
||||
// if (!kDebugMode) {
|
||||
return Configuration.instance.getHttpEndpoint() +
|
||||
"/files/preview/" +
|
||||
uploadedFileID.toString();
|
||||
// } else {
|
||||
// return "https://thumbnails.ente.workers.dev/?fileID=" +
|
||||
// uploadedFileID.toString();
|
||||
// }
|
||||
}
|
||||
|
||||
@override
|
||||
|
|
|
@ -3,6 +3,7 @@ import 'package:photos/models/file.dart';
|
|||
|
||||
class SelectedFiles extends ChangeNotifier {
|
||||
final files = Set<File>();
|
||||
final lastSelections = Set<File>();
|
||||
|
||||
void toggleSelection(File file) {
|
||||
if (files.contains(file)) {
|
||||
|
@ -10,10 +11,13 @@ class SelectedFiles extends ChangeNotifier {
|
|||
} else {
|
||||
files.add(file);
|
||||
}
|
||||
lastSelections.clear();
|
||||
lastSelections.add(file);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void clearAll() {
|
||||
lastSelections.addAll(files);
|
||||
files.clear();
|
||||
notifyListeners();
|
||||
}
|
||||
|
|
|
@ -1,65 +0,0 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
|
||||
class FileRepository {
|
||||
final _logger = Logger("FileRepository");
|
||||
final _files = List<File>();
|
||||
|
||||
FileRepository._privateConstructor();
|
||||
static final FileRepository instance = FileRepository._privateConstructor();
|
||||
|
||||
List<File> get files {
|
||||
return _files;
|
||||
}
|
||||
|
||||
bool _hasLoadedFiles = false;
|
||||
|
||||
bool get hasLoadedFiles {
|
||||
return _hasLoadedFiles;
|
||||
}
|
||||
|
||||
Future<List<File>> _cachedFuture;
|
||||
|
||||
Future<List<File>> loadFiles() async {
|
||||
if (_cachedFuture == null) {
|
||||
_cachedFuture = _loadFiles().then((value) {
|
||||
_hasLoadedFiles = true;
|
||||
_cachedFuture = null;
|
||||
return value;
|
||||
});
|
||||
}
|
||||
return _cachedFuture;
|
||||
}
|
||||
|
||||
Future<void> reloadFiles() async {
|
||||
_logger.info("Reloading...");
|
||||
await loadFiles();
|
||||
}
|
||||
|
||||
Future<List<File>> _loadFiles() async {
|
||||
final files = await FilesDB.instance.getFiles();
|
||||
final deduplicatedFiles = List<File>();
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
if (index != 0) {
|
||||
bool isSameUploadedFile = files[index].uploadedFileID != null &&
|
||||
(files[index].uploadedFileID == files[index - 1].uploadedFileID);
|
||||
bool isSameLocalFile = files[index].localID != null &&
|
||||
(files[index].localID == files[index - 1].localID);
|
||||
if (isSameUploadedFile || isSameLocalFile) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
deduplicatedFiles.add(files[index]);
|
||||
}
|
||||
if (!listEquals(_files, deduplicatedFiles)) {
|
||||
_files.clear();
|
||||
_files.addAll(deduplicatedFiles);
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent());
|
||||
}
|
||||
return _files;
|
||||
}
|
||||
}
|
|
@ -13,10 +13,10 @@ import 'package:photos/core/network.dart';
|
|||
import 'package:photos/db/collections_db.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/models/collection.dart';
|
||||
import 'package:photos/models/collection_file_item.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/services/sync_service.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
|
@ -71,7 +71,8 @@ class CollectionsService {
|
|||
await _filesDB.deleteCollection(collection.id);
|
||||
await _db.deleteCollection(collection.id);
|
||||
await setCollectionSyncTime(collection.id, null);
|
||||
FileRepository.instance.reloadFiles();
|
||||
Bus.instance.fire(
|
||||
LocalPhotosUpdatedEvent(List<File>.empty()));
|
||||
} else {
|
||||
updatedCollections.add(collection);
|
||||
}
|
||||
|
@ -87,7 +88,7 @@ class CollectionsService {
|
|||
}
|
||||
if (fetchedCollections.isNotEmpty) {
|
||||
_logger.info("Collections updated");
|
||||
Bus.instance.fire(CollectionUpdatedEvent());
|
||||
Bus.instance.fire(CollectionUpdatedEvent(null, List<File>.empty()));
|
||||
}
|
||||
return collections;
|
||||
}
|
||||
|
@ -323,7 +324,7 @@ class CollectionsService {
|
|||
)
|
||||
.then((value) async {
|
||||
await _filesDB.insertMultiple(files);
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID));
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID, files));
|
||||
SyncService.instance.syncWithRemote(silently: true);
|
||||
});
|
||||
}
|
||||
|
@ -344,7 +345,7 @@ class CollectionsService {
|
|||
Options(headers: {"X-Auth-Token": Configuration.instance.getToken()}),
|
||||
);
|
||||
await _filesDB.removeFromCollection(collectionID, params["fileIDs"]);
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID));
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID, files));
|
||||
SyncService.instance.syncWithRemote(silently: true);
|
||||
}
|
||||
|
||||
|
|
|
@ -43,7 +43,7 @@ class FavoritesService {
|
|||
final collectionID = await _getOrCreateFavoriteCollectionID();
|
||||
if (file.uploadedFileID == null) {
|
||||
await _fileUploader.forceUpload(file, collectionID);
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID));
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID, [file]));
|
||||
} else {
|
||||
await _collectionsService.addToCollection(collectionID, [file]);
|
||||
}
|
||||
|
|
|
@ -2,10 +2,8 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/db/memories_db.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/models/filters/important_items_filter.dart';
|
||||
import 'package:photos/models/memory.dart';
|
||||
import 'package:photos/utils/date_time_util.dart';
|
||||
|
||||
class MemoriesService extends ChangeNotifier {
|
||||
final _logger = Logger("MemoryService");
|
||||
|
@ -18,6 +16,7 @@ class MemoriesService extends ChangeNotifier {
|
|||
static final daysAfter = 1;
|
||||
|
||||
List<Memory> _cachedMemories;
|
||||
Future<List<Memory>> _future;
|
||||
|
||||
MemoriesService._privateConstructor();
|
||||
|
||||
|
@ -33,47 +32,46 @@ class MemoriesService extends ChangeNotifier {
|
|||
|
||||
void clearCache() {
|
||||
_cachedMemories = null;
|
||||
_future = null;
|
||||
}
|
||||
|
||||
Future<List<Memory>> getMemories() async {
|
||||
if (_cachedMemories != null) {
|
||||
return _cachedMemories;
|
||||
}
|
||||
final filter = ImportantItemsFilter();
|
||||
final files = List<File>();
|
||||
if (_future != null) {
|
||||
return _future;
|
||||
}
|
||||
_future = _fetchMemories();
|
||||
return _future;
|
||||
}
|
||||
|
||||
Future<List<Memory>> _fetchMemories() async {
|
||||
_logger.info("Fetching memories");
|
||||
final presentTime = DateTime.now();
|
||||
final present = presentTime.subtract(Duration(
|
||||
hours: presentTime.hour,
|
||||
minutes: presentTime.minute,
|
||||
seconds: presentTime.second));
|
||||
final List<List<int>> durations = [];
|
||||
for (var yearAgo = 1; yearAgo <= yearsBefore; yearAgo++) {
|
||||
final date = _getDate(present, yearAgo);
|
||||
final startCreationTime =
|
||||
date.subtract(Duration(days: daysBefore)).microsecondsSinceEpoch;
|
||||
final endCreationTime =
|
||||
date.add(Duration(days: daysAfter)).microsecondsSinceEpoch;
|
||||
final filesInYear = await _filesDB.getFilesCreatedWithinDuration(
|
||||
startCreationTime, endCreationTime);
|
||||
if (filesInYear.length > 0)
|
||||
_logger.info("Got " +
|
||||
filesInYear.length.toString() +
|
||||
" memories between " +
|
||||
getFormattedTime(
|
||||
DateTime.fromMicrosecondsSinceEpoch(startCreationTime)) +
|
||||
" to " +
|
||||
getFormattedTime(
|
||||
DateTime.fromMicrosecondsSinceEpoch(endCreationTime)));
|
||||
files.addAll(filesInYear);
|
||||
durations.add([startCreationTime, endCreationTime]);
|
||||
}
|
||||
final files = await _filesDB.getFilesCreatedWithinDurations(durations);
|
||||
final seenTimes = await _memoriesDB.getSeenTimes();
|
||||
final memories = List<Memory>();
|
||||
final List<Memory> memories = [];
|
||||
final filter = ImportantItemsFilter();
|
||||
for (final file in files) {
|
||||
if (filter.shouldInclude(file)) {
|
||||
final seenTime = seenTimes[file.generatedID] ?? -1;
|
||||
memories.add(Memory(file, seenTime));
|
||||
}
|
||||
}
|
||||
_logger.info("Number of memories: " + memories.length.toString());
|
||||
_cachedMemories = memories;
|
||||
return _cachedMemories;
|
||||
}
|
||||
|
@ -91,6 +89,7 @@ class MemoriesService extends ChangeNotifier {
|
|||
|
||||
Future markMemoryAsSeen(Memory memory) async {
|
||||
_logger.info("Marking memory " + memory.file.title + " as seen");
|
||||
memory.markSeen();
|
||||
await _memoriesDB.markMemoryAsSeen(
|
||||
memory, DateTime.now().microsecondsSinceEpoch);
|
||||
notifyListeners();
|
||||
|
|
|
@ -2,15 +2,13 @@ import 'dart:async';
|
|||
import 'dart:io';
|
||||
import 'package:connectivity/connectivity.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/cache/thumbnail_cache_manager.dart';
|
||||
import 'package:photos/core/cache/video_cache_manager.dart';
|
||||
import 'package:photos/core/errors.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/core/network.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/events/first_import_succeeded_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/events/permission_granted_event.dart';
|
||||
import 'package:photos/events/sync_status_update_event.dart';
|
||||
|
@ -20,7 +18,6 @@ import 'package:photos/models/file_type.dart';
|
|||
import 'package:photos/services/billing_service.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/utils/diff_fetcher.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/utils/file_sync_util.dart';
|
||||
import 'package:photos/utils/file_uploader.dart';
|
||||
|
@ -123,11 +120,12 @@ class SyncService {
|
|||
_logger.info("Logging user out");
|
||||
Bus.instance.fire(TriggerLogoutEvent());
|
||||
} catch (e, s) {
|
||||
if (e is DioError &&
|
||||
e.type == DioErrorType.DEFAULT &&
|
||||
e.error.osError != null) {
|
||||
final errorCode = e.error.osError?.errorCode;
|
||||
if (errorCode == 111 || errorCode == 101 || errorCode == 7) {
|
||||
if (e is DioError) {
|
||||
if (e.type == DioErrorType.connectTimeout ||
|
||||
e.type == DioErrorType.sendTimeout ||
|
||||
e.type == DioErrorType.receiveTimeout ||
|
||||
e.type == DioErrorType.cancel ||
|
||||
e.type == DioErrorType.other) {
|
||||
Bus.instance.fire(SyncStatusUpdate(SyncStatus.paused,
|
||||
reason: "waiting for network..."));
|
||||
return false;
|
||||
|
@ -211,7 +209,7 @@ class SyncService {
|
|||
if (!result) {
|
||||
_logger.severe("Did not get permission");
|
||||
await _prefs.setInt(kDbUpdationTimeKey, syncStartTime);
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent());
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent(List<File>.empty()));
|
||||
return await syncWithRemote();
|
||||
}
|
||||
}
|
||||
|
@ -260,12 +258,18 @@ class SyncService {
|
|||
null,
|
||||
);
|
||||
}
|
||||
final List<File> allFiles = [];
|
||||
allFiles.addAll(files);
|
||||
files.removeWhere((file) => existingLocalFileIDs.contains(file.localID));
|
||||
await _db.insertMultiple(files);
|
||||
_logger.info("Inserted " + files.length.toString() + " files.");
|
||||
await FileRepository.instance.reloadFiles();
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent(allFiles));
|
||||
}
|
||||
bool isFirstImport = !_prefs.containsKey(kDbUpdationTimeKey);
|
||||
await _prefs.setInt(kDbUpdationTimeKey, toTime);
|
||||
if (isFirstImport) {
|
||||
Bus.instance.fire(FirstImportSucceededEvent());
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> syncWithRemote({bool silently = false}) async {
|
||||
|
@ -284,7 +288,6 @@ class SyncService {
|
|||
await _syncCollectionDiff(c.id);
|
||||
_collectionsService.setCollectionSyncTime(c.id, c.updationTime);
|
||||
}
|
||||
await deleteFilesOnServer();
|
||||
bool hasUploadedFiles = await _uploadDiff();
|
||||
if (hasUploadedFiles) {
|
||||
syncWithRemote(silently: true);
|
||||
|
@ -303,8 +306,9 @@ class SyncService {
|
|||
diff.updatedFiles.length.toString() +
|
||||
" files in collection " +
|
||||
collectionID.toString());
|
||||
FileRepository.instance.reloadFiles();
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID));
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent(diff.updatedFiles));
|
||||
Bus.instance
|
||||
.fire(CollectionUpdatedEvent(collectionID, diff.updatedFiles));
|
||||
if (diff.fetchCount == kDiffLimit) {
|
||||
return await _syncCollectionDiff(collectionID);
|
||||
}
|
||||
|
@ -378,7 +382,7 @@ class SyncService {
|
|||
|
||||
Future<void> _onFileUploaded(
|
||||
File file, int alreadyUploaded, int toBeUploadedInThisSession) async {
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID: file.collectionID));
|
||||
Bus.instance.fire(CollectionUpdatedEvent(file.collectionID, [file]));
|
||||
_completedUploads++;
|
||||
final completed =
|
||||
await FilesDB.instance.getNumberOfUploadedFiles() - alreadyUploaded;
|
||||
|
@ -439,24 +443,16 @@ class SyncService {
|
|||
}
|
||||
}
|
||||
|
||||
Future<void> deleteFilesOnServer() async {
|
||||
return _db.getDeletedFileIDs().then((ids) async {
|
||||
for (int id in ids) {
|
||||
await _deleteFileOnServer(id);
|
||||
await _db.delete(id);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _deleteFileOnServer(int fileID) async {
|
||||
return _dio
|
||||
.delete(
|
||||
Configuration.instance.getHttpEndpoint() +
|
||||
"/files/" +
|
||||
fileID.toString(),
|
||||
options: Options(
|
||||
headers: {"X-Auth-Token": Configuration.instance.getToken()}),
|
||||
)
|
||||
.catchError((e) => _logger.severe(e));
|
||||
Future<void> deleteFilesOnServer(List<int> fileIDs) async {
|
||||
return await _dio
|
||||
.post(Configuration.instance.getHttpEndpoint() + "/files/delete",
|
||||
options: Options(
|
||||
headers: {
|
||||
"X-Auth-Token": Configuration.instance.getToken(),
|
||||
},
|
||||
),
|
||||
data: {
|
||||
"fileIDs": fileIDs,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -9,40 +9,44 @@ import 'package:photos/models/selected_files.dart';
|
|||
import 'gallery.dart';
|
||||
import 'gallery_app_bar_widget.dart';
|
||||
|
||||
class CollectionPage extends StatefulWidget {
|
||||
class CollectionPage extends StatelessWidget {
|
||||
final Collection collection;
|
||||
final String tagPrefix;
|
||||
final _selectedFiles = SelectedFiles();
|
||||
|
||||
const CollectionPage(this.collection,
|
||||
{this.tagPrefix = "collection", Key key})
|
||||
CollectionPage(this.collection, {this.tagPrefix = "collection", Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
_CollectionPageState createState() => _CollectionPageState();
|
||||
}
|
||||
|
||||
class _CollectionPageState extends State<CollectionPage> {
|
||||
final _selectedFiles = SelectedFiles();
|
||||
|
||||
@override
|
||||
Widget build(Object context) {
|
||||
final gallery = Gallery(
|
||||
asyncLoader: (creationStartTime, creationEndTime, {limit}) {
|
||||
return FilesDB.instance.getFilesInCollection(
|
||||
collection.id, creationStartTime, creationEndTime,
|
||||
limit: limit);
|
||||
},
|
||||
reloadEvent: Bus.instance
|
||||
.on<CollectionUpdatedEvent>()
|
||||
.where((event) => event.collectionID == collection.id),
|
||||
tagPrefix: tagPrefix,
|
||||
selectedFiles: _selectedFiles,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: GalleryAppBarWidget(
|
||||
GalleryAppBarType.collection,
|
||||
widget.collection.name,
|
||||
_selectedFiles,
|
||||
collection: widget.collection,
|
||||
),
|
||||
body: Gallery(
|
||||
asyncLoader: (_, __) =>
|
||||
FilesDB.instance.getAllInCollection(widget.collection.id),
|
||||
shouldLoadAll: true,
|
||||
reloadEvent: Bus.instance
|
||||
.on<CollectionUpdatedEvent>()
|
||||
.where((event) => event.collectionID == widget.collection.id),
|
||||
tagPrefix: widget.tagPrefix,
|
||||
selectedFiles: _selectedFiles,
|
||||
),
|
||||
body: Stack(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 80),
|
||||
child: gallery,
|
||||
),
|
||||
Container(
|
||||
height: 80,
|
||||
child: GalleryAppBarWidget(
|
||||
GalleryAppBarType.collection,
|
||||
collection.name,
|
||||
_selectedFiles,
|
||||
collection: collection,
|
||||
),
|
||||
)
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -79,7 +79,6 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
|
|||
}
|
||||
|
||||
Future<CollectionItems> _getCollections() async {
|
||||
var startTime = DateTime.now();
|
||||
final filesDB = FilesDB.instance;
|
||||
final collectionsService = CollectionsService.instance;
|
||||
final userID = Configuration.instance.getUserID();
|
||||
|
@ -103,12 +102,6 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
|
|||
return second.thumbnail.updationTime
|
||||
.compareTo(first.thumbnail.updationTime);
|
||||
});
|
||||
|
||||
var endTime = DateTime.now();
|
||||
var duration = Duration(
|
||||
microseconds:
|
||||
endTime.microsecondsSinceEpoch - startTime.microsecondsSinceEpoch);
|
||||
_logger.info("Total time taken: " + duration.inMilliseconds.toString());
|
||||
return CollectionItems(folders, collectionsWithThumbnail);
|
||||
}
|
||||
|
||||
|
@ -139,8 +132,7 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
|
|||
physics:
|
||||
ScrollPhysics(), // to disable GridView's scrolling
|
||||
itemBuilder: (context, index) {
|
||||
return _buildFolder(
|
||||
context, items.folders[index]);
|
||||
return DeviceFolderIcon(items.folders[index]);
|
||||
},
|
||||
itemCount: items.folders.length,
|
||||
),
|
||||
|
@ -174,7 +166,63 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildFolder(BuildContext context, DeviceFolder folder) {
|
||||
Widget _buildCollection(BuildContext context,
|
||||
List<CollectionWithThumbnail> collections, int index) {
|
||||
if (index < collections.length) {
|
||||
final c = collections[index];
|
||||
return CollectionItem(c);
|
||||
} else {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(28, 0, 28, 58),
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18.0),
|
||||
),
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: Theme.of(context).accentColor.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).accentColor.withOpacity(0.7),
|
||||
),
|
||||
onPressed: () async {
|
||||
await showToast(
|
||||
"long press to select photos and click + to create an album",
|
||||
toastLength: Toast.LENGTH_LONG);
|
||||
Bus.instance.fire(
|
||||
TabChangedEvent(0, TabChangedEventSource.collections_page));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localFilesSubscription.cancel();
|
||||
_collectionUpdatesSubscription.cancel();
|
||||
_loggedOutEvent.cancel();
|
||||
_backupFoldersUpdatedEvent.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class DeviceFolderIcon extends StatelessWidget {
|
||||
const DeviceFolderIcon(
|
||||
this.folder, {
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
final DeviceFolder folder;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isBackedUp =
|
||||
Configuration.instance.getPathsToBackUp().contains(folder.path);
|
||||
return GestureDetector(
|
||||
|
@ -239,87 +287,56 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
|
|||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildCollection(BuildContext context,
|
||||
List<CollectionWithThumbnail> collections, int index) {
|
||||
if (index < collections.length) {
|
||||
final c = collections[index];
|
||||
return GestureDetector(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18.0),
|
||||
child: Container(
|
||||
child: Hero(
|
||||
tag: "collection" + c.thumbnail.tag(),
|
||||
child: ThumbnailWidget(c.thumbnail)),
|
||||
height: 140,
|
||||
width: 140,
|
||||
),
|
||||
),
|
||||
Padding(padding: EdgeInsets.all(4)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
c.collection.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final page = CollectionPage(c.collection);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return page;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
} else {
|
||||
return Container(
|
||||
padding: EdgeInsets.fromLTRB(28, 0, 28, 58),
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18.0),
|
||||
),
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: Theme.of(context).accentColor.withOpacity(0.4),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.add,
|
||||
color: Theme.of(context).accentColor.withOpacity(0.7),
|
||||
),
|
||||
onPressed: () async {
|
||||
await showToast(
|
||||
"long press to select photos and click + to create an album",
|
||||
toastLength: Toast.LENGTH_LONG);
|
||||
Bus.instance.fire(
|
||||
TabChangedEvent(0, TabChangedEventSource.collections_page));
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
class CollectionItem extends StatelessWidget {
|
||||
CollectionItem(this.c, {
|
||||
Key key,
|
||||
}) : super(key: Key(c.collection.id.toString()));
|
||||
|
||||
final CollectionWithThumbnail c;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_localFilesSubscription.cancel();
|
||||
_collectionUpdatesSubscription.cancel();
|
||||
_loggedOutEvent.cancel();
|
||||
_backupFoldersUpdatedEvent.cancel();
|
||||
super.dispose();
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18.0),
|
||||
child: Container(
|
||||
child: Hero(
|
||||
tag: "collection" + c.thumbnail.tag(),
|
||||
child: ThumbnailWidget(
|
||||
c.thumbnail,
|
||||
)),
|
||||
height: 140,
|
||||
width: 140,
|
||||
),
|
||||
),
|
||||
Padding(padding: EdgeInsets.all(4)),
|
||||
Expanded(
|
||||
child: Text(
|
||||
c.collection.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final page = CollectionPage(c.collection);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return page;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class SectionTitle extends StatelessWidget {
|
||||
|
|
|
@ -33,3 +33,5 @@ RaisedButton button(
|
|||
),
|
||||
);
|
||||
}
|
||||
|
||||
final emptyContainer = Container();
|
||||
|
|
|
@ -11,6 +11,7 @@ import 'package:photos/models/file.dart';
|
|||
import 'package:photos/ui/video_widget.dart';
|
||||
import 'package:photos/ui/zoomable_image.dart';
|
||||
import 'package:photos/utils/date_time_util.dart';
|
||||
import 'package:photos/utils/delete_file_util.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
import 'package:photos/utils/share_util.dart';
|
||||
|
@ -19,10 +20,13 @@ import 'package:photos/utils/toast_util.dart';
|
|||
|
||||
class DetailPage extends StatefulWidget {
|
||||
final List<File> files;
|
||||
final Future<List<File>> Function(int creationStartTime, int creationEndTime,
|
||||
{int limit}) asyncLoader;
|
||||
final int selectedIndex;
|
||||
final String tagPrefix;
|
||||
|
||||
DetailPage(this.files, this.selectedIndex, this.tagPrefix, {key})
|
||||
DetailPage(this.files, this.asyncLoader, this.selectedIndex, this.tagPrefix,
|
||||
{key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
|
@ -30,17 +34,21 @@ class DetailPage extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _DetailPageState extends State<DetailPage> {
|
||||
static const kLoadLimit = 100;
|
||||
final _logger = Logger("DetailPageState");
|
||||
bool _shouldDisableScroll = false;
|
||||
List<File> _files;
|
||||
PageController _pageController;
|
||||
int _selectedIndex = 0;
|
||||
bool _hasPageChanged = false;
|
||||
bool _hasLoadedTillStart = false;
|
||||
bool _hasLoadedTillEnd = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_files = widget.files;
|
||||
_selectedIndex = widget.selectedIndex;
|
||||
_preloadEntries(_selectedIndex);
|
||||
super.initState();
|
||||
}
|
||||
|
||||
|
@ -49,7 +57,7 @@ class _DetailPageState extends State<DetailPage> {
|
|||
_logger.info("Opening " +
|
||||
_files[_selectedIndex].toString() +
|
||||
". " +
|
||||
_selectedIndex.toString() +
|
||||
(_selectedIndex + 1).toString() +
|
||||
" / " +
|
||||
_files.length.toString() +
|
||||
" files .");
|
||||
|
@ -66,6 +74,7 @@ class _DetailPageState extends State<DetailPage> {
|
|||
}
|
||||
|
||||
Widget _buildPageView() {
|
||||
_logger.info("Building with " + _selectedIndex.toString());
|
||||
_pageController = PageController(initialPage: _selectedIndex);
|
||||
return PageView.builder(
|
||||
itemBuilder: (context, index) {
|
||||
|
@ -94,10 +103,12 @@ class _DetailPageState extends State<DetailPage> {
|
|||
return content;
|
||||
},
|
||||
onPageChanged: (index) {
|
||||
_logger.info("onPageChanged to " + index.toString());
|
||||
setState(() {
|
||||
_selectedIndex = index;
|
||||
_hasPageChanged = true;
|
||||
});
|
||||
_preloadEntries(index);
|
||||
_preloadFiles(index);
|
||||
},
|
||||
physics: _shouldDisableScroll
|
||||
|
@ -108,6 +119,38 @@ class _DetailPageState extends State<DetailPage> {
|
|||
);
|
||||
}
|
||||
|
||||
void _preloadEntries(int index) {
|
||||
if (index == 0 && !_hasLoadedTillStart) {
|
||||
widget
|
||||
.asyncLoader(_files[index].creationTime + 1,
|
||||
DateTime.now().microsecondsSinceEpoch,
|
||||
limit: kLoadLimit)
|
||||
.then((files) {
|
||||
setState(() {
|
||||
if (files.length < kLoadLimit) {
|
||||
_hasLoadedTillStart = true;
|
||||
}
|
||||
_selectedIndex = files.length;
|
||||
files.addAll(_files);
|
||||
_files = files;
|
||||
_pageController.jumpToPage(_selectedIndex);
|
||||
});
|
||||
});
|
||||
}
|
||||
if (index == _files.length - 1 && !_hasLoadedTillEnd) {
|
||||
widget
|
||||
.asyncLoader(0, _files[index].creationTime - 1, limit: kLoadLimit)
|
||||
.then((files) {
|
||||
setState(() {
|
||||
if (files.length < kLoadLimit) {
|
||||
_hasLoadedTillEnd = true;
|
||||
}
|
||||
_files.addAll(files);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _preloadFiles(int index) {
|
||||
if (index > 0) {
|
||||
preloadFile(_files[index - 1]);
|
||||
|
|
|
@ -9,43 +9,50 @@ import 'package:photos/models/selected_files.dart';
|
|||
import 'package:photos/ui/gallery.dart';
|
||||
import 'package:photos/ui/gallery_app_bar_widget.dart';
|
||||
|
||||
class DeviceFolderPage extends StatefulWidget {
|
||||
class DeviceFolderPage extends StatelessWidget {
|
||||
final DeviceFolder folder;
|
||||
|
||||
const DeviceFolderPage(this.folder, {Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
_DeviceFolderPageState createState() => _DeviceFolderPageState();
|
||||
}
|
||||
|
||||
class _DeviceFolderPageState extends State<DeviceFolderPage> {
|
||||
final _selectedFiles = SelectedFiles();
|
||||
|
||||
DeviceFolderPage(this.folder, {Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(Object context) {
|
||||
final gallery = Gallery(
|
||||
asyncLoader: (_, __) => FilesDB.instance.getAllInPath(widget.folder.path),
|
||||
shouldLoadAll: true,
|
||||
asyncLoader: (creationStartTime, creationEndTime, {limit}) {
|
||||
return FilesDB.instance.getFilesInPath(
|
||||
folder.path, creationStartTime, creationEndTime,
|
||||
limit: limit);
|
||||
},
|
||||
reloadEvent: Bus.instance.on<LocalPhotosUpdatedEvent>(),
|
||||
tagPrefix: "device_folder:" + widget.folder.path,
|
||||
tagPrefix: "device_folder:" + folder.path,
|
||||
selectedFiles: _selectedFiles,
|
||||
headerWidget: Configuration.instance.hasConfiguredAccount()
|
||||
? _getHeaderWidget()
|
||||
: Container(),
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: GalleryAppBarWidget(
|
||||
GalleryAppBarType.local_folder,
|
||||
widget.folder.name,
|
||||
_selectedFiles,
|
||||
path: widget.folder.thumbnail.deviceFolder,
|
||||
body: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 80),
|
||||
child: gallery,
|
||||
),
|
||||
Container(
|
||||
height: 80,
|
||||
child: GalleryAppBarWidget(
|
||||
GalleryAppBarType.local_folder,
|
||||
folder.name,
|
||||
_selectedFiles,
|
||||
path: folder.thumbnail.deviceFolder,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: gallery,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getHeaderWidget() {
|
||||
return BackupConfigurationHeaderWidget(widget.folder.path);
|
||||
return BackupConfigurationHeaderWidget(folder.path);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -68,7 +75,7 @@ class _BackupConfigurationHeaderWidgetState
|
|||
return Container(
|
||||
padding: EdgeInsets.only(left: 12, right: 12),
|
||||
margin: EdgeInsets.only(bottom: 12),
|
||||
color: Colors.grey.withOpacity(0.15),
|
||||
color: Color.fromRGBO(10, 40, 40, 0.3),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
|
|
|
@ -1,640 +0,0 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
/// Build the Scroll Thumb and label using the current configuration
|
||||
typedef Widget ScrollThumbBuilder(
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text labelText,
|
||||
BoxConstraints labelConstraints,
|
||||
});
|
||||
|
||||
/// Build a Text widget using the current scroll position
|
||||
typedef Text LabelTextBuilder(double position);
|
||||
|
||||
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
|
||||
/// for quick navigation of the BoxScrollView.
|
||||
class DraggableScrollbar extends StatefulWidget {
|
||||
/// The view that will be scrolled with the scroll thumb
|
||||
final ScrollablePositionedList child;
|
||||
|
||||
/// A function that builds a thumb using the current configuration
|
||||
final ScrollThumbBuilder scrollThumbBuilder;
|
||||
|
||||
/// The height of the scroll thumb
|
||||
final double heightScrollThumb;
|
||||
|
||||
/// The background color of the label and thumb
|
||||
final Color backgroundColor;
|
||||
|
||||
/// The amount of padding that should surround the thumb
|
||||
final EdgeInsetsGeometry padding;
|
||||
|
||||
/// Determines how quickly the scrollbar will animate in and out
|
||||
final Duration scrollbarAnimationDuration;
|
||||
|
||||
/// How long should the thumb be visible before fading out
|
||||
final Duration scrollbarTimeToFade;
|
||||
|
||||
/// Build a Text widget from the current offset in the BoxScrollView
|
||||
final LabelTextBuilder labelTextBuilder;
|
||||
|
||||
/// Determines box constraints for Container displaying label
|
||||
final BoxConstraints labelConstraints;
|
||||
|
||||
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
|
||||
final bool alwaysVisibleScrollThumb;
|
||||
|
||||
final ValueChanged<double> onChange;
|
||||
|
||||
final itemCount;
|
||||
|
||||
final initialScrollIndex;
|
||||
|
||||
DraggableScrollbar({
|
||||
Key key,
|
||||
this.alwaysVisibleScrollThumb = false,
|
||||
@required this.heightScrollThumb,
|
||||
@required this.backgroundColor,
|
||||
@required this.scrollThumbBuilder,
|
||||
@required this.child,
|
||||
@required this.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(onChange != null),
|
||||
assert(scrollThumbBuilder != null),
|
||||
assert(child.scrollDirection == Axis.vertical),
|
||||
super(key: key);
|
||||
|
||||
DraggableScrollbar.rrect({
|
||||
Key key,
|
||||
Key scrollThumbKey,
|
||||
this.alwaysVisibleScrollThumb = false,
|
||||
@required this.child,
|
||||
@required this.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
this.heightScrollThumb = 48.0,
|
||||
this.backgroundColor = Colors.white,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder =
|
||||
_thumbRRectBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
super(key: key);
|
||||
|
||||
DraggableScrollbar.arrows({
|
||||
Key key,
|
||||
Key scrollThumbKey,
|
||||
this.alwaysVisibleScrollThumb = false,
|
||||
@required this.child,
|
||||
@required this.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
this.heightScrollThumb = 48.0,
|
||||
this.backgroundColor = Colors.white,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder =
|
||||
_thumbArrowBuilder(scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
super(key: key);
|
||||
|
||||
DraggableScrollbar.semicircle({
|
||||
Key key,
|
||||
Key scrollThumbKey,
|
||||
this.alwaysVisibleScrollThumb = false,
|
||||
@required this.child,
|
||||
@required this.onChange,
|
||||
@required this.itemCount,
|
||||
this.initialScrollIndex = 0,
|
||||
this.heightScrollThumb = 48.0,
|
||||
this.backgroundColor = Colors.white,
|
||||
this.padding,
|
||||
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
|
||||
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
|
||||
this.labelTextBuilder,
|
||||
this.labelConstraints,
|
||||
}) : assert(child.scrollDirection == Axis.vertical),
|
||||
scrollThumbBuilder = _thumbSemicircleBuilder(
|
||||
heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
DraggableScrollbarState createState() => DraggableScrollbarState();
|
||||
|
||||
static buildScrollThumbAndLabel(
|
||||
{@required Widget scrollThumb,
|
||||
@required Color backgroundColor,
|
||||
@required Animation<double> thumbAnimation,
|
||||
@required Animation<double> labelAnimation,
|
||||
@required Text labelText,
|
||||
@required BoxConstraints labelConstraints,
|
||||
@required bool alwaysVisibleScrollThumb}) {
|
||||
var scrollThumbAndLabel = labelText == null
|
||||
? scrollThumb
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ScrollLabel(
|
||||
animation: labelAnimation,
|
||||
child: labelText,
|
||||
backgroundColor: backgroundColor,
|
||||
constraints: labelConstraints,
|
||||
),
|
||||
scrollThumb,
|
||||
],
|
||||
);
|
||||
|
||||
if (alwaysVisibleScrollThumb) {
|
||||
return scrollThumbAndLabel;
|
||||
}
|
||||
return SlideFadeTransition(
|
||||
animation: thumbAnimation,
|
||||
child: scrollThumbAndLabel,
|
||||
);
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbSemicircleBuilder(
|
||||
double width, Key scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text labelText,
|
||||
BoxConstraints labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = CustomPaint(
|
||||
key: scrollThumbKey,
|
||||
foregroundPainter: ArrowCustomPainter(Colors.grey),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(Size(width, height)),
|
||||
),
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(height),
|
||||
bottomLeft: Radius.circular(height),
|
||||
topRight: Radius.circular(4.0),
|
||||
bottomRight: Radius.circular(4.0),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
return buildScrollThumbAndLabel(
|
||||
scrollThumb: scrollThumb,
|
||||
backgroundColor: backgroundColor,
|
||||
thumbAnimation: thumbAnimation,
|
||||
labelAnimation: labelAnimation,
|
||||
labelText: labelText,
|
||||
labelConstraints: labelConstraints,
|
||||
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbArrowBuilder(
|
||||
Key scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text labelText,
|
||||
BoxConstraints labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = ClipPath(
|
||||
child: Container(
|
||||
height: height,
|
||||
width: 20.0,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.all(
|
||||
Radius.circular(12.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
clipper: ArrowClipper(),
|
||||
);
|
||||
|
||||
return buildScrollThumbAndLabel(
|
||||
scrollThumb: scrollThumb,
|
||||
backgroundColor: backgroundColor,
|
||||
thumbAnimation: thumbAnimation,
|
||||
labelAnimation: labelAnimation,
|
||||
labelText: labelText,
|
||||
labelConstraints: labelConstraints,
|
||||
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
static ScrollThumbBuilder _thumbRRectBuilder(
|
||||
Key scrollThumbKey, bool alwaysVisibleScrollThumb) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text labelText,
|
||||
BoxConstraints labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = Material(
|
||||
elevation: 4.0,
|
||||
child: Container(
|
||||
constraints: BoxConstraints.tight(
|
||||
Size(16.0, height),
|
||||
),
|
||||
),
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(7.0)),
|
||||
);
|
||||
|
||||
return buildScrollThumbAndLabel(
|
||||
scrollThumb: scrollThumb,
|
||||
backgroundColor: backgroundColor,
|
||||
thumbAnimation: thumbAnimation,
|
||||
labelAnimation: labelAnimation,
|
||||
labelText: labelText,
|
||||
labelConstraints: labelConstraints,
|
||||
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
|
||||
);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class ScrollLabel extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Color backgroundColor;
|
||||
final Text child;
|
||||
|
||||
final BoxConstraints constraints;
|
||||
static const BoxConstraints _defaultConstraints =
|
||||
BoxConstraints.tightFor(width: 72.0, height: 28.0);
|
||||
|
||||
const ScrollLabel({
|
||||
Key key,
|
||||
@required this.child,
|
||||
@required this.animation,
|
||||
@required this.backgroundColor,
|
||||
this.constraints = _defaultConstraints,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(right: 12.0),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.all(Radius.circular(16.0)),
|
||||
child: Container(
|
||||
constraints: constraints ?? _defaultConstraints,
|
||||
alignment: Alignment.center,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
with TickerProviderStateMixin {
|
||||
double _thumbOffset = 0.0;
|
||||
double _lastPosition = 0;
|
||||
bool _isDragInProcess;
|
||||
|
||||
AnimationController _thumbAnimationController;
|
||||
Animation<double> _thumbAnimation;
|
||||
AnimationController _labelAnimationController;
|
||||
Animation<double> _labelAnimation;
|
||||
Timer _fadeoutTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isDragInProcess = false;
|
||||
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
parent: _thumbAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
parent: _labelAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
|
||||
if (widget.initialScrollIndex > 0 && widget.itemCount > 1) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
setState(() => _thumbOffset =
|
||||
(widget.initialScrollIndex / widget.itemCount) *
|
||||
(thumbMax - thumbMin));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
double get barMaxScrollExtent =>
|
||||
context.size.height - widget.heightScrollThumb;
|
||||
|
||||
double get barMinScrollExtent => 0.0;
|
||||
|
||||
double get viewMaxScrollExtent => 1;
|
||||
|
||||
double get viewMinScrollExtent => 0;
|
||||
|
||||
double get thumbMin => 0.0;
|
||||
|
||||
double get thumbMax => context.size.height - widget.heightScrollThumb;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget labelText;
|
||||
if (widget.labelTextBuilder != null && _isDragInProcess) {
|
||||
labelText = widget.labelTextBuilder(_lastPosition);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext context, BoxConstraints constraints) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (ScrollNotification notification) {
|
||||
changePosition(notification);
|
||||
return true;
|
||||
},
|
||||
child: Stack(
|
||||
children: <Widget>[
|
||||
RepaintBoundary(
|
||||
child: widget.child,
|
||||
),
|
||||
RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onVerticalDragStart,
|
||||
onVerticalDragUpdate: _onVerticalDragUpdate,
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
child: Container(
|
||||
alignment: Alignment.topRight,
|
||||
margin: EdgeInsets.only(top: _thumbOffset),
|
||||
padding: widget.padding,
|
||||
child: widget.scrollThumbBuilder(
|
||||
widget.backgroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.heightScrollThumb,
|
||||
labelText: labelText,
|
||||
labelConstraints: widget.labelConstraints,
|
||||
),
|
||||
),
|
||||
)),
|
||||
],
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
void setPosition(double position) {
|
||||
final currentOffset = _thumbOffset;
|
||||
final newOffset = position * (thumbMax - thumbMin);
|
||||
if (currentOffset == newOffset) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_thumbOffset = newOffset;
|
||||
});
|
||||
}
|
||||
|
||||
//scroll bar has received notification that it's view was scrolled
|
||||
//so it should also changes his position
|
||||
//but only if it isn't dragged
|
||||
changePosition(ScrollNotification notification) {
|
||||
if (_isDragInProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
if (notification is ScrollUpdateNotification ||
|
||||
notification is OverscrollNotification) {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setState(() {
|
||||
if (notification is ScrollUpdateNotification ||
|
||||
notification is OverscrollNotification) {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
double getBarDelta(
|
||||
double scrollViewDelta,
|
||||
double barMaxScrollExtent,
|
||||
double viewMaxScrollExtent,
|
||||
) {
|
||||
return scrollViewDelta * barMaxScrollExtent / viewMaxScrollExtent;
|
||||
}
|
||||
|
||||
double getScrollViewDelta(
|
||||
double barDelta,
|
||||
double barMaxScrollExtent,
|
||||
double viewMaxScrollExtent,
|
||||
) {
|
||||
return barDelta * viewMaxScrollExtent / barMaxScrollExtent;
|
||||
}
|
||||
|
||||
void _onVerticalDragStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_isDragInProcess = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeoutTimer?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
if (_isDragInProcess) {
|
||||
_thumbOffset += details.delta.dy;
|
||||
_thumbOffset = _thumbOffset.clamp(thumbMin, thumbMax);
|
||||
double position = _thumbOffset / (thumbMax - thumbMin);
|
||||
_lastPosition = position;
|
||||
widget.onChange?.call(position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
setState(() {
|
||||
_isDragInProcess = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Draws 2 triangles like arrow up and arrow down
|
||||
class ArrowCustomPainter extends CustomPainter {
|
||||
Color color;
|
||||
|
||||
ArrowCustomPainter(this.color);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()..color = color;
|
||||
const width = 12.0;
|
||||
const height = 8.0;
|
||||
final baseX = size.width / 2;
|
||||
final baseY = size.height / 2;
|
||||
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY - 2.0), width, height, true),
|
||||
paint,
|
||||
);
|
||||
canvas.drawPath(
|
||||
_trianglePath(Offset(baseX, baseY + 2.0), width, height, false),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
|
||||
return Path()
|
||||
..moveTo(o.dx, o.dy)
|
||||
..lineTo(o.dx + width, o.dy)
|
||||
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
///This cut 2 lines in arrow shape
|
||||
class ArrowClipper extends CustomClipper<Path> {
|
||||
@override
|
||||
Path getClip(Size size) {
|
||||
Path path = Path();
|
||||
path.lineTo(0.0, size.height);
|
||||
path.lineTo(size.width, size.height);
|
||||
path.lineTo(size.width, 0.0);
|
||||
path.lineTo(0.0, 0.0);
|
||||
path.close();
|
||||
|
||||
double arrowWidth = 8.0;
|
||||
double startPointX = (size.width - arrowWidth) / 2;
|
||||
double startPointY = size.height / 2 - arrowWidth / 2;
|
||||
path.moveTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
|
||||
path.lineTo(startPointX, startPointY + 1.0);
|
||||
path.close();
|
||||
|
||||
startPointY = size.height / 2 + arrowWidth / 2;
|
||||
path.moveTo(startPointX + arrowWidth, startPointY);
|
||||
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
|
||||
path.lineTo(startPointX, startPointY);
|
||||
path.lineTo(startPointX, startPointY - 1.0);
|
||||
path.lineTo(
|
||||
startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
|
||||
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
|
||||
path.close();
|
||||
|
||||
return path;
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
|
||||
}
|
||||
|
||||
class SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
const SlideFadeTransition({
|
||||
Key key,
|
||||
@required this.animation,
|
||||
@required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => animation.value == 0.0 ? Container() : child,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: Offset(0.3, 0.0),
|
||||
end: Offset(0.0, 0.0),
|
||||
).animate(animation),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,41 +1,33 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/cupertino.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/events/event.dart';
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/models/selected_files.dart';
|
||||
import 'package:photos/ui/common_elements.dart';
|
||||
import 'package:photos/ui/detail_page.dart';
|
||||
import 'package:photos/ui/draggable_scrollbar.dart';
|
||||
import 'package:photos/ui/gallery_app_bar_widget.dart';
|
||||
import 'package:photos/ui/huge_listview/huge_listview.dart';
|
||||
import 'package:photos/ui/huge_listview/lazy_loading_gallery.dart';
|
||||
import 'package:photos/ui/huge_listview/place_holder_widget.dart';
|
||||
import 'package:photos/ui/loading_widget.dart';
|
||||
import 'package:photos/ui/thumbnail_widget.dart';
|
||||
import 'package:photos/utils/date_time_util.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class Gallery extends StatefulWidget {
|
||||
final List<File> Function() syncLoader;
|
||||
final Future<List<File>> Function(File lastFile, int limit) asyncLoader;
|
||||
final bool shouldLoadAll;
|
||||
final Stream<Event> reloadEvent;
|
||||
final Future<List<File>> Function(int creationStartTime, int creationEndTime,
|
||||
{int limit}) asyncLoader;
|
||||
final Stream<FilesUpdatedEvent> reloadEvent;
|
||||
final SelectedFiles selectedFiles;
|
||||
final String tagPrefix;
|
||||
final Widget headerWidget;
|
||||
final bool isHomePageGallery;
|
||||
|
||||
Gallery({
|
||||
this.syncLoader,
|
||||
this.asyncLoader,
|
||||
this.shouldLoadAll = false,
|
||||
this.reloadEvent,
|
||||
this.headerWidget,
|
||||
@required this.asyncLoader,
|
||||
@required this.selectedFiles,
|
||||
@required this.tagPrefix,
|
||||
this.isHomePageGallery = false,
|
||||
this.reloadEvent,
|
||||
this.headerWidget,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -45,381 +37,140 @@ class Gallery extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _GalleryState extends State<Gallery> {
|
||||
static final int kLoadLimit = 200;
|
||||
static final int kEagerLoadTrigger = 10;
|
||||
static const int kInitialLoadLimit = 100;
|
||||
|
||||
final Logger _logger = Logger("Gallery");
|
||||
final List<List<File>> _collatedFiles = List<List<File>>();
|
||||
final _itemScrollController = ItemScrollController();
|
||||
final _itemPositionsListener = ItemPositionsListener.create();
|
||||
final _scrollKey = GlobalKey<DraggableScrollbarState>();
|
||||
final _hugeListViewKey = GlobalKey<HugeListViewState>();
|
||||
|
||||
ScrollController _scrollController = ScrollController();
|
||||
double _scrollOffset = 0;
|
||||
bool _requiresLoad = false;
|
||||
bool _hasLoadedAll = false;
|
||||
bool _isLoadingNext = false;
|
||||
bool _hasDraggableScrollbar = false;
|
||||
List<File> _files;
|
||||
int _lastIndex = 0;
|
||||
Logger _logger;
|
||||
int _index = 0;
|
||||
List<List<File>> _collatedFiles = [];
|
||||
bool _hasLoadedFiles = false;
|
||||
StreamSubscription<FilesUpdatedEvent> _reloadEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_requiresLoad = true;
|
||||
_logger = Logger("Gallery_" + widget.tagPrefix);
|
||||
_logger.info("initState");
|
||||
if (widget.reloadEvent != null) {
|
||||
widget.reloadEvent.listen((event) {
|
||||
_logger.info("Building gallery because reload event fired updated");
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_requiresLoad = true;
|
||||
});
|
||||
}
|
||||
_reloadEventSubscription = widget.reloadEvent.listen((event) {
|
||||
_logger.info("Building gallery because reload event fired");
|
||||
_loadFiles();
|
||||
});
|
||||
}
|
||||
widget.selectedFiles.addListener(() {
|
||||
_logger.info("Building gallery because selected files updated");
|
||||
setState(() {
|
||||
_requiresLoad = false;
|
||||
if (!_hasDraggableScrollbar) {
|
||||
_saveScrollPosition();
|
||||
}
|
||||
});
|
||||
});
|
||||
if (widget.asyncLoader == null || widget.shouldLoadAll) {
|
||||
_hasLoadedAll = true;
|
||||
}
|
||||
_itemPositionsListener.itemPositions.addListener(_updateScrollbar);
|
||||
_loadFiles(limit: kInitialLoadLimit).then((value) => _loadFiles());
|
||||
super.initState();
|
||||
}
|
||||
|
||||
Future<bool> _loadFiles({int limit}) async {
|
||||
_logger.info("Loading files");
|
||||
final files = await widget
|
||||
.asyncLoader(0, DateTime.now().microsecondsSinceEpoch, limit: limit);
|
||||
final collatedFiles = _collateFiles(files);
|
||||
if (_collatedFiles.length != collatedFiles.length) {
|
||||
if (mounted) {
|
||||
_logger.info("Days updated");
|
||||
setState(() {
|
||||
_hasLoadedFiles = true;
|
||||
_collatedFiles = collatedFiles;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
_collatedFiles = collatedFiles;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_itemPositionsListener.itemPositions.removeListener(_updateScrollbar);
|
||||
_reloadEventSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_logger.info("Building " + widget.tagPrefix);
|
||||
if (!_requiresLoad) {
|
||||
return _onDataLoaded();
|
||||
if (!_hasLoadedFiles) {
|
||||
return loadWidget;
|
||||
}
|
||||
if (widget.syncLoader != null) {
|
||||
_files = widget.syncLoader();
|
||||
return _onDataLoaded();
|
||||
}
|
||||
return FutureBuilder<List<File>>(
|
||||
future: widget.asyncLoader(null, kLoadLimit),
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
_requiresLoad = false;
|
||||
_files = snapshot.data;
|
||||
return _onDataLoaded();
|
||||
} else if (snapshot.hasError) {
|
||||
_requiresLoad = false;
|
||||
return Center(child: Text(snapshot.error.toString()));
|
||||
} else {
|
||||
return Center(child: loadWidget);
|
||||
}
|
||||
},
|
||||
);
|
||||
return _getListView();
|
||||
}
|
||||
|
||||
Widget _onDataLoaded() {
|
||||
if (_files.isEmpty) {
|
||||
final children = List<Widget>();
|
||||
if (widget.headerWidget != null) {
|
||||
children.add(widget.headerWidget);
|
||||
}
|
||||
children.add(Expanded(child: nothingToSeeHere));
|
||||
return CustomScrollView(
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceAround,
|
||||
children: children,
|
||||
),
|
||||
)
|
||||
],
|
||||
);
|
||||
}
|
||||
_collateFiles();
|
||||
final itemCount =
|
||||
_collatedFiles.length + (widget.headerWidget == null ? 1 : 2);
|
||||
_hasDraggableScrollbar = itemCount > 25 || _files.length > 50;
|
||||
var gallery;
|
||||
if (!_hasDraggableScrollbar) {
|
||||
_scrollController = ScrollController(initialScrollOffset: _scrollOffset);
|
||||
gallery = ListView.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: _buildListItem,
|
||||
controller: _scrollController,
|
||||
cacheExtent: 1500,
|
||||
addAutomaticKeepAlives: true,
|
||||
);
|
||||
} else {
|
||||
gallery = DraggableScrollbar.semicircle(
|
||||
key: _scrollKey,
|
||||
initialScrollIndex: _lastIndex,
|
||||
labelTextBuilder: (position) {
|
||||
final index =
|
||||
min((position * itemCount).floor(), _collatedFiles.length - 1);
|
||||
return Text(
|
||||
getMonthAndYear(DateTime.fromMicrosecondsSinceEpoch(
|
||||
_collatedFiles[index][0].creationTime)),
|
||||
style: TextStyle(
|
||||
color: Colors.black,
|
||||
backgroundColor: Colors.white,
|
||||
fontSize: 14,
|
||||
),
|
||||
);
|
||||
},
|
||||
labelConstraints: BoxConstraints.tightFor(width: 100.0, height: 36.0),
|
||||
onChange: (position) {
|
||||
final index =
|
||||
min((position * itemCount).floor(), _collatedFiles.length - 1);
|
||||
if (index == _lastIndex) {
|
||||
return;
|
||||
}
|
||||
_lastIndex = index;
|
||||
_itemScrollController.jumpTo(index: index);
|
||||
},
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemCount: itemCount,
|
||||
itemBuilder: _buildListItem,
|
||||
itemScrollController: _itemScrollController,
|
||||
initialScrollIndex: _lastIndex,
|
||||
minCacheExtent: 1500,
|
||||
addAutomaticKeepAlives: true,
|
||||
physics: _MaxVelocityPhysics(velocityThreshold: 128),
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
),
|
||||
itemCount: itemCount,
|
||||
);
|
||||
}
|
||||
if (widget.isHomePageGallery) {
|
||||
gallery = Container(
|
||||
margin: const EdgeInsets.only(bottom: 50),
|
||||
child: gallery,
|
||||
);
|
||||
if (widget.selectedFiles.files.isNotEmpty) {
|
||||
gallery = Stack(children: [
|
||||
gallery,
|
||||
Container(
|
||||
height: 60,
|
||||
child: GalleryAppBarWidget(
|
||||
GalleryAppBarType.homepage,
|
||||
null,
|
||||
widget.selectedFiles,
|
||||
),
|
||||
Widget _getListView() {
|
||||
return HugeListView<List<File>>(
|
||||
key: _hugeListViewKey,
|
||||
controller: ItemScrollController(),
|
||||
startIndex: _index,
|
||||
totalCount: _collatedFiles.length,
|
||||
isDraggableScrollbarEnabled: _collatedFiles.length > 30,
|
||||
placeholderBuilder: (context, index) {
|
||||
var day = getDayWidget(_collatedFiles[index][0].creationTime);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
children: [
|
||||
day,
|
||||
PlaceHolderWidget(_collatedFiles[index].length),
|
||||
],
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
return gallery;
|
||||
}
|
||||
|
||||
Widget _buildListItem(BuildContext context, int index) {
|
||||
if (_shouldLoadNextItems(index)) {
|
||||
// Eagerly load next batch
|
||||
_loadNextItems();
|
||||
}
|
||||
var fileIndex;
|
||||
if (widget.headerWidget != null) {
|
||||
if (index == 0) {
|
||||
return widget.headerWidget;
|
||||
}
|
||||
fileIndex = index - 1;
|
||||
} else {
|
||||
fileIndex = index;
|
||||
}
|
||||
if (fileIndex == _collatedFiles.length) {
|
||||
if (widget.asyncLoader != null) {
|
||||
if (!_hasLoadedAll) {
|
||||
return loadWidget;
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fileIndex < 0 || fileIndex >= _collatedFiles.length) {
|
||||
return Container();
|
||||
}
|
||||
var files = _collatedFiles[fileIndex];
|
||||
return Column(
|
||||
children: <Widget>[_getDay(files[0].creationTime), _getGallery(files)],
|
||||
);
|
||||
}
|
||||
|
||||
bool _shouldLoadNextItems(int index) =>
|
||||
widget.asyncLoader != null &&
|
||||
!_isLoadingNext &&
|
||||
(index >= _collatedFiles.length - kEagerLoadTrigger) &&
|
||||
!_hasLoadedAll;
|
||||
|
||||
void _loadNextItems() {
|
||||
_isLoadingNext = true;
|
||||
widget.asyncLoader(_files[_files.length - 1], kLoadLimit).then((files) {
|
||||
setState(() {
|
||||
_isLoadingNext = false;
|
||||
_saveScrollPosition();
|
||||
if (files.length < kLoadLimit) {
|
||||
_hasLoadedAll = true;
|
||||
}
|
||||
_files.addAll(files);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void _saveScrollPosition() {
|
||||
_scrollOffset = _scrollController.offset;
|
||||
}
|
||||
|
||||
Widget _getDay(int timestamp) {
|
||||
final date = DateTime.fromMicrosecondsSinceEpoch(timestamp);
|
||||
final now = DateTime.now();
|
||||
var title = getDayAndMonth(date);
|
||||
if (date.year == now.year && date.month == now.month) {
|
||||
if (date.day == now.day) {
|
||||
title = "Today";
|
||||
} else if (date.day == now.day - 1) {
|
||||
title = "Yesterday";
|
||||
}
|
||||
}
|
||||
if (date.year != DateTime.now().year) {
|
||||
title += " " + date.year.toString();
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 0, 10),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getGallery(List<File> files) {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
padding: EdgeInsets.only(bottom: 12),
|
||||
physics:
|
||||
NeverScrollableScrollPhysics(), // to disable GridView's scrolling
|
||||
);
|
||||
},
|
||||
waitBuilder: (_) {
|
||||
return loadWidget;
|
||||
},
|
||||
emptyResultBuilder: (_) {
|
||||
return nothingToSeeHere;
|
||||
},
|
||||
itemBuilder: (context, index) {
|
||||
return _buildFile(context, files[index]);
|
||||
},
|
||||
itemCount: files.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFile(BuildContext context, File file) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.selectedFiles.files.isNotEmpty) {
|
||||
_selectFile(file);
|
||||
} else {
|
||||
_routeToDetailPage(file, context);
|
||||
var gallery;
|
||||
gallery = LazyLoadingGallery(
|
||||
_collatedFiles[index],
|
||||
widget.reloadEvent,
|
||||
widget.asyncLoader,
|
||||
widget.selectedFiles,
|
||||
widget.tagPrefix,
|
||||
);
|
||||
if (widget.headerWidget != null && index == 0) {
|
||||
gallery = Column(children: [widget.headerWidget, gallery]);
|
||||
}
|
||||
return gallery;
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.lightImpact();
|
||||
_selectFile(file);
|
||||
labelTextBuilder: (int index) {
|
||||
return getMonthAndYear(DateTime.fromMicrosecondsSinceEpoch(
|
||||
_collatedFiles[index][0].creationTime));
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(2.0),
|
||||
decoration: BoxDecoration(
|
||||
border: widget.selectedFiles.files.contains(file)
|
||||
? Border.all(
|
||||
width: 4.0,
|
||||
color: Theme.of(context).accentColor,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Hero(
|
||||
tag: widget.tagPrefix + file.tag(),
|
||||
child: ThumbnailWidget(file),
|
||||
),
|
||||
),
|
||||
thumbBackgroundColor: Color(0xFF151515),
|
||||
thumbDrawColor: Colors.white.withOpacity(0.5),
|
||||
velocityThreshold: 128,
|
||||
);
|
||||
}
|
||||
|
||||
void _selectFile(File file) {
|
||||
widget.selectedFiles.toggleSelection(file);
|
||||
}
|
||||
|
||||
void _routeToDetailPage(File file, BuildContext context) {
|
||||
final page = DetailPage(
|
||||
_files,
|
||||
_files.indexOf(file),
|
||||
widget.tagPrefix,
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return page;
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _collateFiles() {
|
||||
final dailyFiles = List<File>();
|
||||
final collatedFiles = List<List<File>>();
|
||||
for (int index = 0; index < _files.length; index++) {
|
||||
List<List<File>> _collateFiles(List<File> files) {
|
||||
final List<File> dailyFiles = [];
|
||||
final List<List<File>> collatedFiles = [];
|
||||
for (int index = 0; index < files.length; index++) {
|
||||
if (index > 0 &&
|
||||
!_areFilesFromSameDay(_files[index - 1], _files[index])) {
|
||||
final collatedDailyFiles = List<File>();
|
||||
!_areFromSameDay(
|
||||
files[index - 1].creationTime, files[index].creationTime)) {
|
||||
final List<File> collatedDailyFiles = [];
|
||||
collatedDailyFiles.addAll(dailyFiles);
|
||||
collatedFiles.add(collatedDailyFiles);
|
||||
dailyFiles.clear();
|
||||
}
|
||||
dailyFiles.add(_files[index]);
|
||||
dailyFiles.add(files[index]);
|
||||
}
|
||||
if (dailyFiles.isNotEmpty) {
|
||||
collatedFiles.add(dailyFiles);
|
||||
}
|
||||
_collatedFiles.clear();
|
||||
_collatedFiles.addAll(collatedFiles);
|
||||
collatedFiles
|
||||
.sort((a, b) => b[0].creationTime.compareTo(a[0].creationTime));
|
||||
return collatedFiles;
|
||||
}
|
||||
|
||||
bool _areFilesFromSameDay(File first, File second) {
|
||||
var firstDate = DateTime.fromMicrosecondsSinceEpoch(first.creationTime);
|
||||
var secondDate = DateTime.fromMicrosecondsSinceEpoch(second.creationTime);
|
||||
bool _areFromSameDay(int firstCreationTime, int secondCreationTime) {
|
||||
var firstDate = DateTime.fromMicrosecondsSinceEpoch(firstCreationTime);
|
||||
var secondDate = DateTime.fromMicrosecondsSinceEpoch(secondCreationTime);
|
||||
return firstDate.year == secondDate.year &&
|
||||
firstDate.month == secondDate.month &&
|
||||
firstDate.day == secondDate.day;
|
||||
}
|
||||
|
||||
void _updateScrollbar() {
|
||||
final index = _itemPositionsListener.itemPositions.value.first.index;
|
||||
_lastIndex = index;
|
||||
_scrollKey.currentState?.setPosition(index / _collatedFiles.length);
|
||||
}
|
||||
}
|
||||
|
||||
class _MaxVelocityPhysics extends AlwaysScrollableScrollPhysics {
|
||||
final double velocityThreshold;
|
||||
|
||||
_MaxVelocityPhysics({@required this.velocityThreshold, ScrollPhysics parent})
|
||||
: super(parent: parent);
|
||||
|
||||
@override
|
||||
bool recommendDeferredLoading(
|
||||
double velocity, ScrollMetrics metrics, BuildContext context) {
|
||||
return velocity.abs() > velocityThreshold;
|
||||
}
|
||||
|
||||
@override
|
||||
_MaxVelocityPhysics applyTo(ScrollPhysics ancestor) {
|
||||
return _MaxVelocityPhysics(
|
||||
velocityThreshold: velocityThreshold, parent: buildParent(ancestor));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -13,8 +13,8 @@ import 'package:photos/services/billing_service.dart';
|
|||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/ui/create_collection_page.dart';
|
||||
import 'package:photos/ui/share_collection_widget.dart';
|
||||
import 'package:photos/utils/delete_file_util.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
import 'package:photos/utils/share_util.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
||||
|
@ -26,8 +26,7 @@ enum GalleryAppBarType {
|
|||
search_results,
|
||||
}
|
||||
|
||||
class GalleryAppBarWidget extends StatefulWidget
|
||||
implements PreferredSizeWidget {
|
||||
class GalleryAppBarWidget extends StatefulWidget {
|
||||
final GalleryAppBarType type;
|
||||
final String title;
|
||||
final SelectedFiles selectedFiles;
|
||||
|
@ -44,9 +43,6 @@ class GalleryAppBarWidget extends StatefulWidget
|
|||
|
||||
@override
|
||||
_GalleryAppBarWidgetState createState() => _GalleryAppBarWidgetState();
|
||||
|
||||
@override
|
||||
Size get preferredSize => Size.fromHeight(60.0);
|
||||
}
|
||||
|
||||
class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
||||
|
@ -78,7 +74,9 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
Widget build(BuildContext context) {
|
||||
if (widget.selectedFiles.files.isEmpty) {
|
||||
return AppBar(
|
||||
backgroundColor: Color(0x00000000),
|
||||
backgroundColor: widget.type == GalleryAppBarType.homepage
|
||||
? Color(0x00000000)
|
||||
: null,
|
||||
elevation: 0,
|
||||
title: widget.type == GalleryAppBarType.homepage
|
||||
? Container()
|
||||
|
@ -235,7 +233,7 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
final dialog = createProgressDialog(context, "removing files...");
|
||||
await dialog.show();
|
||||
try {
|
||||
CollectionsService.instance.removeFromCollection(
|
||||
await CollectionsService.instance.removeFromCollection(
|
||||
widget.collection.id, widget.selectedFiles.files.toList());
|
||||
await dialog.hide();
|
||||
widget.selectedFiles.clearAll();
|
||||
|
@ -274,22 +272,21 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
child: Text("this device"),
|
||||
isDestructiveAction: true,
|
||||
onPressed: () async {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
await deleteFilesOnDeviceOnly(
|
||||
context, widget.selectedFiles.files.toList());
|
||||
_clearSelectedFiles();
|
||||
showToast("files deleted from device");
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
));
|
||||
actions.add(CupertinoActionSheetAction(
|
||||
child: Text("everywhere"),
|
||||
isDestructiveAction: true,
|
||||
onPressed: () async {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
await deleteFilesFromEverywhere(
|
||||
context, widget.selectedFiles.files.toList());
|
||||
_clearSelectedFiles();
|
||||
showToast("files deleted from everywhere");
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
));
|
||||
} else {
|
||||
|
@ -297,11 +294,10 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
|
|||
child: Text("delete forever"),
|
||||
isDestructiveAction: true,
|
||||
onPressed: () async {
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
await deleteFilesFromEverywhere(
|
||||
context, widget.selectedFiles.files.toList());
|
||||
_clearSelectedFiles();
|
||||
showToast("files deleted from everywhere");
|
||||
Navigator.of(context, rootNavigator: true).pop();
|
||||
},
|
||||
));
|
||||
}
|
||||
|
|
|
@ -6,16 +6,15 @@ import 'package:flutter/services.dart';
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/backup_folders_updated_event.dart';
|
||||
import 'package:photos/events/first_import_succeeded_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/events/permission_granted_event.dart';
|
||||
import 'package:photos/events/subscription_purchased_event.dart';
|
||||
import 'package:photos/events/tab_changed_event.dart';
|
||||
import 'package:photos/events/trigger_logout_event.dart';
|
||||
import 'package:photos/events/user_logged_out_event.dart';
|
||||
import 'package:photos/models/filters/important_items_filter.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/models/selected_files.dart';
|
||||
import 'package:photos/services/billing_service.dart';
|
||||
import 'package:photos/services/sync_service.dart';
|
||||
|
@ -23,9 +22,9 @@ import 'package:photos/ui/backup_folder_selection_widget.dart';
|
|||
import 'package:photos/ui/collections_gallery_widget.dart';
|
||||
import 'package:photos/ui/extents_page_view.dart';
|
||||
import 'package:photos/ui/gallery.dart';
|
||||
import 'package:photos/ui/gallery_app_bar_widget.dart';
|
||||
import 'package:photos/ui/grant_permissions_widget.dart';
|
||||
import 'package:photos/ui/loading_photos_widget.dart';
|
||||
import 'package:photos/ui/loading_widget.dart';
|
||||
import 'package:photos/ui/memories_widget.dart';
|
||||
import 'package:photos/services/user_service.dart';
|
||||
import 'package:photos/ui/nav_bar.dart';
|
||||
|
@ -45,21 +44,21 @@ class HomeWidget extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _HomeWidgetState extends State<HomeWidget> {
|
||||
static const _deviceFolderGalleryWidget = const CollectionsGalleryWidget();
|
||||
static const _sharedCollectionGallery = const SharedCollectionGallery();
|
||||
static const _headerWidget = HeaderWidget();
|
||||
|
||||
final _logger = Logger("HomeWidgetState");
|
||||
final _deviceFolderGalleryWidget = CollectionsGalleryWidget();
|
||||
final _sharedCollectionGallery = SharedCollectionGallery();
|
||||
final _selectedFiles = SelectedFiles();
|
||||
final _settingsButton = SettingsButton();
|
||||
static const _headerWidget = HeaderWidget();
|
||||
final PageController _pageController = PageController();
|
||||
final _future = FileRepository.instance.loadFiles();
|
||||
int _selectedTabIndex = 0;
|
||||
Widget _headerWidgetWithSettingsButton;
|
||||
|
||||
StreamSubscription<LocalPhotosUpdatedEvent> _photosUpdatedEvent;
|
||||
StreamSubscription<TabChangedEvent> _tabChangedEventSubscription;
|
||||
StreamSubscription<PermissionGrantedEvent> _permissionGrantedEvent;
|
||||
StreamSubscription<SubscriptionPurchasedEvent> _subscriptionPurchaseEvent;
|
||||
StreamSubscription<FirstImportSucceededEvent> _firstImportEvent;
|
||||
StreamSubscription<TriggerLogoutEvent> _triggerLogoutEvent;
|
||||
StreamSubscription<UserLoggedOutEvent> _loggedOutEvent;
|
||||
StreamSubscription<BackupFoldersUpdatedEvent> _backupFoldersUpdatedEvent;
|
||||
|
@ -76,11 +75,6 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||
],
|
||||
),
|
||||
);
|
||||
_photosUpdatedEvent =
|
||||
Bus.instance.on<LocalPhotosUpdatedEvent>().listen((event) {
|
||||
_logger.info("Building because local photos updated");
|
||||
setState(() {});
|
||||
});
|
||||
_tabChangedEventSubscription =
|
||||
Bus.instance.on<TabChangedEvent>().listen((event) {
|
||||
if (event.source != TabChangedEventSource.tab_bar) {
|
||||
|
@ -107,6 +101,10 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||
_showBackupFolderSelectionDialog();
|
||||
}
|
||||
});
|
||||
_firstImportEvent =
|
||||
Bus.instance.on<FirstImportSucceededEvent>().listen((event) {
|
||||
setState(() {});
|
||||
});
|
||||
_triggerLogoutEvent =
|
||||
Bus.instance.on<TriggerLogoutEvent>().listen((event) async {
|
||||
AlertDialog alert = AlertDialog(
|
||||
|
@ -230,32 +228,37 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||
}
|
||||
|
||||
Widget _getMainGalleryWidget() {
|
||||
return FutureBuilder(
|
||||
future: _future,
|
||||
builder: (context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
var header;
|
||||
if (_selectedFiles.files.isEmpty) {
|
||||
header = _headerWidgetWithSettingsButton;
|
||||
} else {
|
||||
header = _headerWidget;
|
||||
}
|
||||
return Gallery(
|
||||
syncLoader: () {
|
||||
return _getFilteredPhotos(FileRepository.instance.files);
|
||||
},
|
||||
reloadEvent: Bus.instance.on<LocalPhotosUpdatedEvent>(),
|
||||
tagPrefix: "home_gallery",
|
||||
selectedFiles: _selectedFiles,
|
||||
headerWidget: header,
|
||||
isHomePageGallery: true,
|
||||
);
|
||||
} else if (snapshot.hasError) {
|
||||
return Center(child: Text(snapshot.error.toString()));
|
||||
var header;
|
||||
if (_selectedFiles.files.isEmpty) {
|
||||
header = _headerWidgetWithSettingsButton;
|
||||
} else {
|
||||
header = _headerWidget;
|
||||
}
|
||||
final gallery = Gallery(
|
||||
asyncLoader: (creationStartTime, creationEndTime, {limit}) {
|
||||
final importantPaths = Configuration.instance.getPathsToBackUp();
|
||||
if (importantPaths.isNotEmpty) {
|
||||
return FilesDB.instance.getFilesInPaths(
|
||||
creationStartTime, creationEndTime, importantPaths.toList(),
|
||||
limit: limit);
|
||||
} else {
|
||||
return loadWidget;
|
||||
return FilesDB.instance
|
||||
.getAllFiles(creationStartTime, creationEndTime, limit: limit);
|
||||
}
|
||||
},
|
||||
reloadEvent: Bus.instance.on<LocalPhotosUpdatedEvent>(),
|
||||
tagPrefix: "home_gallery",
|
||||
selectedFiles: _selectedFiles,
|
||||
headerWidget: header,
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(bottom: 50),
|
||||
child: gallery,
|
||||
),
|
||||
HomePageAppBar(_selectedFiles),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -307,19 +310,6 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||
);
|
||||
}
|
||||
|
||||
List<File> _getFilteredPhotos(List<File> unfilteredFiles) {
|
||||
_logger.info("Filtering " + unfilteredFiles.length.toString());
|
||||
final List<File> filteredPhotos = List<File>();
|
||||
final filter = ImportantItemsFilter();
|
||||
for (File file in unfilteredFiles) {
|
||||
if (filter.shouldInclude(file)) {
|
||||
filteredPhotos.add(file);
|
||||
}
|
||||
}
|
||||
_logger.info("Filtered down to " + filteredPhotos.length.toString());
|
||||
return filteredPhotos;
|
||||
}
|
||||
|
||||
void _showBackupFolderSelectionDialog() {
|
||||
Future.delayed(
|
||||
Duration.zero,
|
||||
|
@ -343,9 +333,9 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||
@override
|
||||
void dispose() {
|
||||
_tabChangedEventSubscription.cancel();
|
||||
_photosUpdatedEvent.cancel();
|
||||
_permissionGrantedEvent.cancel();
|
||||
_subscriptionPurchaseEvent.cancel();
|
||||
_firstImportEvent.cancel();
|
||||
_triggerLogoutEvent.cancel();
|
||||
_loggedOutEvent.cancel();
|
||||
_backupFoldersUpdatedEvent.cancel();
|
||||
|
@ -353,6 +343,45 @@ class _HomeWidgetState extends State<HomeWidget> {
|
|||
}
|
||||
}
|
||||
|
||||
class HomePageAppBar extends StatefulWidget {
|
||||
const HomePageAppBar(
|
||||
this.selectedFiles, {
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
final SelectedFiles selectedFiles;
|
||||
|
||||
@override
|
||||
_HomePageAppBarState createState() => _HomePageAppBarState();
|
||||
}
|
||||
|
||||
class _HomePageAppBarState extends State<HomePageAppBar> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
widget.selectedFiles.addListener(() {
|
||||
setState(() {});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final appBar = Container(
|
||||
height: 60,
|
||||
child: GalleryAppBarWidget(
|
||||
GalleryAppBarType.homepage,
|
||||
null,
|
||||
widget.selectedFiles,
|
||||
),
|
||||
);
|
||||
if (widget.selectedFiles.files.isEmpty) {
|
||||
return IgnorePointer(child: appBar);
|
||||
} else {
|
||||
return appBar;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class HeaderWidget extends StatelessWidget {
|
||||
static const _memoriesWidget = const MemoriesWidget();
|
||||
static const _signInHeader = const SignInHeader();
|
||||
|
|
213
lib/ui/huge_listview/draggable_scrollbar.dart
Normal file
213
lib/ui/huge_listview/draggable_scrollbar.dart
Normal file
|
@ -0,0 +1,213 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:photos/ui/huge_listview/scroll_bar_thumb.dart';
|
||||
|
||||
class DraggableScrollbar extends StatefulWidget {
|
||||
final Widget child;
|
||||
final Color backgroundColor;
|
||||
final Color drawColor;
|
||||
final double heightScrollThumb;
|
||||
final EdgeInsetsGeometry padding;
|
||||
final int totalCount;
|
||||
final int initialScrollIndex;
|
||||
final int currentFirstIndex;
|
||||
final ValueChanged<double> onChange;
|
||||
final String Function(int) labelTextBuilder;
|
||||
final bool isEnabled;
|
||||
|
||||
DraggableScrollbar({
|
||||
Key key,
|
||||
@required this.child,
|
||||
this.backgroundColor = Colors.white,
|
||||
this.drawColor = Colors.grey,
|
||||
this.heightScrollThumb = 80.0,
|
||||
this.padding,
|
||||
this.totalCount = 1,
|
||||
this.initialScrollIndex = 0,
|
||||
this.currentFirstIndex = 0,
|
||||
@required this.labelTextBuilder,
|
||||
this.onChange,
|
||||
this.isEnabled = true,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
DraggableScrollbarState createState() => DraggableScrollbarState();
|
||||
}
|
||||
|
||||
class DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
with TickerProviderStateMixin {
|
||||
static final thumbAnimationDuration = Duration(milliseconds: 1000);
|
||||
static final labelAnimationDuration = Duration(milliseconds: 1000);
|
||||
double thumbOffset = 0.0;
|
||||
bool isDragging = false;
|
||||
int currentFirstIndex;
|
||||
|
||||
double get thumbMin => 0.0;
|
||||
|
||||
double get thumbMax => context.size.height - widget.heightScrollThumb;
|
||||
|
||||
AnimationController _thumbAnimationController;
|
||||
Animation<double> _thumbAnimation;
|
||||
AnimationController _labelAnimationController;
|
||||
Animation<double> _labelAnimation;
|
||||
Timer _fadeoutTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
currentFirstIndex = widget.currentFirstIndex;
|
||||
|
||||
if (widget.initialScrollIndex > 0 && widget.totalCount > 1) {
|
||||
WidgetsBinding.instance?.addPostFrameCallback((_) {
|
||||
setState(() => thumbOffset =
|
||||
(widget.initialScrollIndex / widget.totalCount) *
|
||||
(thumbMax - thumbMin));
|
||||
});
|
||||
}
|
||||
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: thumbAnimationDuration,
|
||||
);
|
||||
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
parent: _thumbAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: labelAnimationDuration,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
parent: _labelAnimationController,
|
||||
curve: Curves.fastOutSlowIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_thumbAnimationController.dispose();
|
||||
_labelAnimationController.dispose();
|
||||
_fadeoutTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (widget.isEnabled) {
|
||||
return Stack(
|
||||
children: [
|
||||
RepaintBoundary(child: widget.child),
|
||||
RepaintBoundary(child: buildThumb()),
|
||||
],
|
||||
);
|
||||
} else {
|
||||
return widget.child;
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildKeyboard() {
|
||||
if (defaultTargetPlatform == TargetPlatform.windows)
|
||||
return RawKeyboardListener(
|
||||
focusNode: FocusNode(),
|
||||
onKey: keyHandler,
|
||||
child: buildThumb(),
|
||||
);
|
||||
else
|
||||
return buildThumb();
|
||||
}
|
||||
|
||||
Widget buildThumb() => Container(
|
||||
alignment: Alignment.topRight,
|
||||
margin: EdgeInsets.only(top: thumbOffset),
|
||||
padding: widget.padding,
|
||||
child: ScrollBarThumb(
|
||||
widget.backgroundColor,
|
||||
widget.drawColor,
|
||||
widget.heightScrollThumb,
|
||||
widget.labelTextBuilder.call(this.currentFirstIndex),
|
||||
_labelAnimation,
|
||||
_thumbAnimation,
|
||||
onDragStart,
|
||||
onDragUpdate,
|
||||
onDragEnd,
|
||||
),
|
||||
);
|
||||
|
||||
void setPosition(double position, int currentFirstIndex) {
|
||||
setState(() {
|
||||
this.currentFirstIndex = currentFirstIndex;
|
||||
thumbOffset = position * (thumbMax - thumbMin);
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(thumbAnimationDuration, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
void onDragStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
isDragging = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeoutTimer?.cancel();
|
||||
});
|
||||
}
|
||||
|
||||
void onDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
if (isDragging && details.delta.dy != 0) {
|
||||
thumbOffset += details.delta.dy;
|
||||
thumbOffset = thumbOffset.clamp(thumbMin, thumbMax);
|
||||
double position = thumbOffset / (thumbMax - thumbMin);
|
||||
widget.onChange?.call(position);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void onDragEnd(DragEndDetails details) {
|
||||
_fadeoutTimer = Timer(thumbAnimationDuration, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
setState(() => isDragging = false);
|
||||
}
|
||||
|
||||
void keyHandler(RawKeyEvent value) {
|
||||
if (value.runtimeType == RawKeyDownEvent) {
|
||||
if (value.logicalKey == LogicalKeyboardKey.arrowDown)
|
||||
onDragUpdate(DragUpdateDetails(
|
||||
globalPosition: Offset.zero,
|
||||
delta: Offset(0, 2),
|
||||
));
|
||||
else if (value.logicalKey == LogicalKeyboardKey.arrowUp)
|
||||
onDragUpdate(DragUpdateDetails(
|
||||
globalPosition: Offset.zero,
|
||||
delta: Offset(0, -2),
|
||||
));
|
||||
else if (value.logicalKey == LogicalKeyboardKey.pageDown)
|
||||
onDragUpdate(DragUpdateDetails(
|
||||
globalPosition: Offset.zero,
|
||||
delta: Offset(0, 25),
|
||||
));
|
||||
else if (value.logicalKey == LogicalKeyboardKey.pageUp)
|
||||
onDragUpdate(DragUpdateDetails(
|
||||
globalPosition: Offset.zero,
|
||||
delta: Offset(0, -25),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
206
lib/ui/huge_listview/huge_listview.dart
Normal file
206
lib/ui/huge_listview/huge_listview.dart
Normal file
|
@ -0,0 +1,206 @@
|
|||
import 'dart:math' show max;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
import 'package:photos/ui/huge_listview/draggable_scrollbar.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
typedef HugeListViewItemBuilder<T> = Widget Function(
|
||||
BuildContext context, int index);
|
||||
typedef HugeListViewErrorBuilder = Widget Function(
|
||||
BuildContext context, dynamic error);
|
||||
|
||||
class HugeListView<T> extends StatefulWidget {
|
||||
/// A [ScrollablePositionedList] controller for jumping or scrolling to an item.
|
||||
final ItemScrollController controller;
|
||||
|
||||
/// Index of an item to initially align within the viewport.
|
||||
final int startIndex;
|
||||
|
||||
/// Total number of items in the list.
|
||||
final int totalCount;
|
||||
|
||||
/// Called to build the thumb. One of [DraggableScrollbarThumbs.RoundedRectThumb], [DraggableScrollbarThumbs.ArrowThumb]
|
||||
/// or [DraggableScrollbarThumbs.SemicircleThumb], or build your own.
|
||||
final String Function(int) labelTextBuilder;
|
||||
|
||||
/// Background color of scroll thumb, defaults to white.
|
||||
final Color thumbBackgroundColor;
|
||||
|
||||
/// Drawing color of scroll thumb, defaults to gray.
|
||||
final Color thumbDrawColor;
|
||||
|
||||
/// Height of scroll thumb, defaults to 48.
|
||||
final double thumbHeight;
|
||||
|
||||
/// Called to build an individual item with the specified [index].
|
||||
final HugeListViewItemBuilder<T> itemBuilder;
|
||||
|
||||
/// Called to build a placeholder while the item is not yet availabe.
|
||||
final IndexedWidgetBuilder placeholderBuilder;
|
||||
|
||||
/// Called to build a progress widget while the whole list is initialized.
|
||||
final WidgetBuilder waitBuilder;
|
||||
|
||||
/// Called to build a widget when the list is empty.
|
||||
final WidgetBuilder emptyResultBuilder;
|
||||
|
||||
/// Called to build a widget when there is an error.
|
||||
final HugeListViewErrorBuilder errorBuilder;
|
||||
|
||||
/// The velocity above which the individual items stop being drawn until the scrolling rate drops.
|
||||
final double velocityThreshold;
|
||||
|
||||
/// Event to call with the index of the topmost visible item in the viewport while scrolling.
|
||||
/// Can be used to display the current letter of an alphabetically sorted list, for instance.
|
||||
final ValueChanged<int> firstShown;
|
||||
|
||||
final bool isDraggableScrollbarEnabled;
|
||||
|
||||
HugeListView({
|
||||
Key key,
|
||||
this.controller,
|
||||
@required this.startIndex,
|
||||
@required this.totalCount,
|
||||
@required this.labelTextBuilder,
|
||||
@required this.itemBuilder,
|
||||
@required this.placeholderBuilder,
|
||||
this.waitBuilder,
|
||||
this.emptyResultBuilder,
|
||||
this.errorBuilder,
|
||||
this.velocityThreshold = 128,
|
||||
this.firstShown,
|
||||
this.thumbBackgroundColor = Colors.white,
|
||||
this.thumbDrawColor = Colors.grey,
|
||||
this.thumbHeight = 48.0,
|
||||
this.isDraggableScrollbarEnabled = true,
|
||||
}) : assert(velocityThreshold >= 0),
|
||||
super(key: key);
|
||||
|
||||
@override
|
||||
HugeListViewState<T> createState() => HugeListViewState<T>();
|
||||
}
|
||||
|
||||
class HugeListViewState<T> extends State<HugeListView<T>> {
|
||||
final scrollKey = GlobalKey<DraggableScrollbarState>();
|
||||
final listener = ItemPositionsListener.create();
|
||||
dynamic error;
|
||||
bool _frameCallbackInProgress = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
listener.itemPositions.addListener(_sendScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
listener.itemPositions.removeListener(_sendScroll);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendScroll() {
|
||||
int current = _currentFirst();
|
||||
widget.firstShown?.call(current);
|
||||
scrollKey.currentState?.setPosition(current / widget.totalCount, current);
|
||||
}
|
||||
|
||||
int _currentFirst() {
|
||||
try {
|
||||
return listener.itemPositions.value.first.index;
|
||||
} catch (e) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (error != null && widget.errorBuilder != null)
|
||||
return widget.errorBuilder(context, error);
|
||||
if (widget.totalCount == -1 && widget.waitBuilder != null)
|
||||
return widget.waitBuilder(context);
|
||||
if (widget.totalCount == 0 && widget.emptyResultBuilder != null)
|
||||
return widget.emptyResultBuilder(context);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return DraggableScrollbar(
|
||||
key: scrollKey,
|
||||
totalCount: widget.totalCount,
|
||||
initialScrollIndex: widget.startIndex,
|
||||
onChange: (position) {
|
||||
widget.controller
|
||||
?.jumpTo(index: (position * widget.totalCount).floor());
|
||||
},
|
||||
labelTextBuilder: widget.labelTextBuilder,
|
||||
backgroundColor: widget.thumbBackgroundColor,
|
||||
drawColor: widget.thumbDrawColor,
|
||||
heightScrollThumb: widget.thumbHeight,
|
||||
currentFirstIndex: _currentFirst(),
|
||||
isEnabled: widget.isDraggableScrollbarEnabled,
|
||||
child: ScrollablePositionedList.builder(
|
||||
itemScrollController: widget.controller,
|
||||
itemPositionsListener: listener,
|
||||
physics: _MaxVelocityPhysics(
|
||||
velocityThreshold: widget.velocityThreshold),
|
||||
initialScrollIndex: widget.startIndex,
|
||||
itemCount: max(widget.totalCount, 0),
|
||||
itemBuilder: (context, index) {
|
||||
if (!Scrollable.recommendDeferredLoadingForContext(context)) {
|
||||
return widget.itemBuilder(context, index);
|
||||
} else if (!_frameCallbackInProgress) {
|
||||
_frameCallbackInProgress = true;
|
||||
SchedulerBinding.instance
|
||||
?.scheduleFrameCallback((d) => _deferredReload(context));
|
||||
}
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: 10),
|
||||
child: widget.placeholderBuilder(context, index),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _deferredReload(BuildContext context) {
|
||||
if (!Scrollable.recommendDeferredLoadingForContext(context)) {
|
||||
_frameCallbackInProgress = false;
|
||||
_doReload(-1);
|
||||
} else
|
||||
SchedulerBinding.instance?.scheduleFrameCallback(
|
||||
(d) => _deferredReload(context),
|
||||
rescheduling: true);
|
||||
}
|
||||
|
||||
void _doReload(int index) {
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
/// Jump to the [position] in the list. [position] is between 0.0 (first item) and 1.0 (last item), practically currentIndex / totalCount.
|
||||
/// To jump to a specific item, use [ItemScrollController.jumpTo] or [ItemScrollController.scrollTo].
|
||||
void setPosition(double position) {
|
||||
scrollKey.currentState?.setPosition(position, _currentFirst());
|
||||
}
|
||||
}
|
||||
|
||||
class _MaxVelocityPhysics extends AlwaysScrollableScrollPhysics {
|
||||
final double velocityThreshold;
|
||||
|
||||
_MaxVelocityPhysics({@required this.velocityThreshold, ScrollPhysics parent})
|
||||
: super(parent: parent);
|
||||
|
||||
@override
|
||||
bool recommendDeferredLoading(
|
||||
double velocity, ScrollMetrics metrics, BuildContext context) {
|
||||
return velocity.abs() > velocityThreshold;
|
||||
}
|
||||
|
||||
@override
|
||||
_MaxVelocityPhysics applyTo(ScrollPhysics ancestor) {
|
||||
return _MaxVelocityPhysics(
|
||||
velocityThreshold: velocityThreshold, parent: buildParent(ancestor));
|
||||
}
|
||||
}
|
303
lib/ui/huge_listview/lazy_loading_gallery.dart
Normal file
303
lib/ui/huge_listview/lazy_loading_gallery.dart
Normal file
|
@ -0,0 +1,303 @@
|
|||
import 'dart:async';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/models/selected_files.dart';
|
||||
import 'package:photos/ui/detail_page.dart';
|
||||
import 'package:photos/ui/huge_listview/place_holder_widget.dart';
|
||||
import 'package:photos/ui/thumbnail_widget.dart';
|
||||
import 'package:photos/utils/date_time_util.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
class LazyLoadingGallery extends StatefulWidget {
|
||||
final List<File> files;
|
||||
final Stream<FilesUpdatedEvent> reloadEvent;
|
||||
final Future<List<File>> Function(int creationStartTime, int creationEndTime,
|
||||
{int limit}) asyncLoader;
|
||||
final SelectedFiles selectedFiles;
|
||||
final String tag;
|
||||
|
||||
LazyLoadingGallery(this.files, this.reloadEvent, this.asyncLoader,
|
||||
this.selectedFiles, this.tag,
|
||||
{Key key})
|
||||
: super(key: key);
|
||||
|
||||
@override
|
||||
_LazyLoadingGalleryState createState() => _LazyLoadingGalleryState();
|
||||
}
|
||||
|
||||
class _LazyLoadingGalleryState extends State<LazyLoadingGallery> {
|
||||
static const kSubGalleryItemLimit = 80;
|
||||
static const kMicroSecondsInADay = 86400000000;
|
||||
|
||||
static final Logger _logger = Logger("LazyLoadingGallery");
|
||||
|
||||
List<File> _files;
|
||||
StreamSubscription<FilesUpdatedEvent> _reloadEventSubscription;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_init();
|
||||
}
|
||||
|
||||
void _init() {
|
||||
_files = widget.files;
|
||||
final galleryDate =
|
||||
DateTime.fromMicrosecondsSinceEpoch(_files[0].creationTime);
|
||||
_reloadEventSubscription = widget.reloadEvent.listen((event) async {
|
||||
final filesUpdatedThisDay = event.updatedFiles
|
||||
.where((file) =>
|
||||
file.creationTime !=
|
||||
null) // Filtering out noise of deleted files diff from server
|
||||
.where((file) {
|
||||
final fileDate = DateTime.fromMicrosecondsSinceEpoch(file.creationTime);
|
||||
return fileDate.year == galleryDate.year &&
|
||||
fileDate.month == galleryDate.month &&
|
||||
fileDate.day == galleryDate.day;
|
||||
});
|
||||
if (filesUpdatedThisDay.isNotEmpty) {
|
||||
_logger.info(filesUpdatedThisDay.length.toString() +
|
||||
" files were updated on " +
|
||||
getDayTitle(galleryDate.microsecondsSinceEpoch));
|
||||
if (event.type == EventType.added_or_updated) {
|
||||
final dayStartTime =
|
||||
DateTime(galleryDate.year, galleryDate.month, galleryDate.day);
|
||||
final files = await widget.asyncLoader(
|
||||
dayStartTime.microsecondsSinceEpoch,
|
||||
dayStartTime.microsecondsSinceEpoch + kMicroSecondsInADay - 1);
|
||||
if (files.isEmpty) {
|
||||
// All files on this day were deleted, let gallery trigger the reload
|
||||
} else {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_files = files;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Files were deleted
|
||||
final updateFileIDs = Set<int>();
|
||||
for (final file in filesUpdatedThisDay) {
|
||||
updateFileIDs.add(file.generatedID);
|
||||
}
|
||||
final List<File> files = [];
|
||||
files.addAll(_files);
|
||||
files.removeWhere((file) => updateFileIDs.contains(file.generatedID));
|
||||
if (files.isNotEmpty && mounted) {
|
||||
// If all files on this day were deleted, ignore and let the gallery reload itself
|
||||
setState(() {
|
||||
_files = files;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_reloadEventSubscription.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LazyLoadingGallery oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!listEquals(_files, widget.files)) {
|
||||
setState(() {
|
||||
_reloadEventSubscription.cancel();
|
||||
_init();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_files.length == 0) {
|
||||
return Container();
|
||||
}
|
||||
return Column(
|
||||
children: <Widget>[
|
||||
getDayWidget(_files[0].creationTime),
|
||||
_getGallery(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getGallery() {
|
||||
List<Widget> childGalleries = [];
|
||||
for (int index = 0; index < _files.length; index += kSubGalleryItemLimit) {
|
||||
childGalleries.add(LazyLoadingGridView(
|
||||
widget.tag,
|
||||
_files.sublist(index, min(index + kSubGalleryItemLimit, _files.length)),
|
||||
widget.asyncLoader,
|
||||
widget.selectedFiles,
|
||||
index == 0,
|
||||
));
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
children: childGalleries,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LazyLoadingGridView extends StatefulWidget {
|
||||
static const kThumbnailDiskLoadDeferDuration = Duration(milliseconds: 40);
|
||||
static const kThumbnailServerLoadDeferDuration = Duration(milliseconds: 80);
|
||||
|
||||
final String tag;
|
||||
final List<File> files;
|
||||
final Future<List<File>> Function(int creationStartTime, int creationEndTime,
|
||||
{int limit}) asyncLoader;
|
||||
final SelectedFiles selectedFiles;
|
||||
final bool isVisible;
|
||||
|
||||
LazyLoadingGridView(this.tag, this.files, this.asyncLoader,
|
||||
this.selectedFiles, this.isVisible,
|
||||
{Key key})
|
||||
: super(key: key ?? GlobalKey<_LazyLoadingGridViewState>());
|
||||
|
||||
@override
|
||||
_LazyLoadingGridViewState createState() => _LazyLoadingGridViewState();
|
||||
}
|
||||
|
||||
class _LazyLoadingGridViewState extends State<LazyLoadingGridView> {
|
||||
bool _isVisible;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_isVisible = widget.isVisible;
|
||||
widget.selectedFiles.addListener(() {
|
||||
bool shouldRefresh = false;
|
||||
for (final file in widget.files) {
|
||||
if (widget.selectedFiles.lastSelections.contains(file)) {
|
||||
shouldRefresh = true;
|
||||
}
|
||||
}
|
||||
if (shouldRefresh && mounted) {
|
||||
setState(() {});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(LazyLoadingGridView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (!listEquals(widget.files, oldWidget.files)) {
|
||||
setState(() {
|
||||
_isVisible = widget.isVisible;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_isVisible) {
|
||||
return VisibilityDetector(
|
||||
key: Key(widget.tag + widget.files[0].creationTime.toString()),
|
||||
onVisibilityChanged: (visibility) {
|
||||
if (visibility.visibleFraction > 0 && !_isVisible) {
|
||||
setState(() {
|
||||
_isVisible = true;
|
||||
});
|
||||
}
|
||||
},
|
||||
child: PlaceHolderWidget(widget.files.length),
|
||||
);
|
||||
} else {
|
||||
return GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics:
|
||||
NeverScrollableScrollPhysics(), // to disable GridView's scrolling
|
||||
itemBuilder: (context, index) {
|
||||
return _buildFile(context, widget.files[index]);
|
||||
},
|
||||
itemCount: widget.files.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
),
|
||||
padding: EdgeInsets.all(0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildFile(BuildContext context, File file) {
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (widget.selectedFiles.files.isNotEmpty) {
|
||||
_selectFile(file);
|
||||
} else {
|
||||
_routeToDetailPage(file, context);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
HapticFeedback.lightImpact();
|
||||
_selectFile(file);
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.all(2.0),
|
||||
decoration: BoxDecoration(
|
||||
border: widget.selectedFiles.files.contains(file)
|
||||
? Border.all(
|
||||
width: 4.0,
|
||||
color: Theme.of(context).accentColor,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Hero(
|
||||
tag: widget.tag + file.tag(),
|
||||
child: ThumbnailWidget(
|
||||
file,
|
||||
diskLoadDeferDuration:
|
||||
LazyLoadingGridView.kThumbnailDiskLoadDeferDuration,
|
||||
serverLoadDeferDuration:
|
||||
LazyLoadingGridView.kThumbnailServerLoadDeferDuration,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _selectFile(File file) {
|
||||
widget.selectedFiles.toggleSelection(file);
|
||||
}
|
||||
|
||||
void _routeToDetailPage(File file, BuildContext context) {
|
||||
final page = DetailPage(
|
||||
widget.files,
|
||||
widget.asyncLoader,
|
||||
widget.files.indexOf(file),
|
||||
widget.tag,
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
PageRouteBuilder(
|
||||
pageBuilder: (BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation) {
|
||||
return page;
|
||||
},
|
||||
transitionsBuilder: (BuildContext context, Animation<double> animation,
|
||||
Animation<double> secondaryAnimation, Widget child) {
|
||||
return Align(
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
transitionDuration: Duration(milliseconds: 200),
|
||||
opaque: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
32
lib/ui/huge_listview/place_holder_widget.dart
Normal file
32
lib/ui/huge_listview/place_holder_widget.dart
Normal file
|
@ -0,0 +1,32 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class PlaceHolderWidget extends StatelessWidget {
|
||||
const PlaceHolderWidget(this.count,{
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
final int count;
|
||||
|
||||
static final _gridViewCache = Map<int, GridView>();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_gridViewCache.containsKey(count)) {
|
||||
_gridViewCache[count] = GridView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(2.0),
|
||||
color: Colors.grey[900],
|
||||
);
|
||||
},
|
||||
itemCount: count,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
),
|
||||
);
|
||||
}
|
||||
return _gridViewCache[count];
|
||||
}
|
||||
}
|
148
lib/ui/huge_listview/scroll_bar_thumb.dart
Normal file
148
lib/ui/huge_listview/scroll_bar_thumb.dart
Normal file
|
@ -0,0 +1,148 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class ScrollBarThumb extends StatelessWidget {
|
||||
final backgroundColor;
|
||||
final drawColor;
|
||||
final height;
|
||||
final title;
|
||||
final labelAnimation;
|
||||
final thumbAnimation;
|
||||
final Function(DragStartDetails details) onDragStart;
|
||||
final Function(DragUpdateDetails details) onDragUpdate;
|
||||
final Function(DragEndDetails details) onDragEnd;
|
||||
|
||||
const ScrollBarThumb(
|
||||
this.backgroundColor,
|
||||
this.drawColor,
|
||||
this.height,
|
||||
this.title,
|
||||
this.labelAnimation,
|
||||
this.thumbAnimation,
|
||||
this.onDragStart,
|
||||
this.onDragUpdate,
|
||||
this.onDragEnd, {
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
FadeTransition(
|
||||
opacity: labelAnimation,
|
||||
child: Container(
|
||||
padding: EdgeInsets.fromLTRB(20, 12, 20, 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: backgroundColor,
|
||||
),
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
backgroundColor: Colors.transparent,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.all(12),
|
||||
),
|
||||
GestureDetector(
|
||||
onVerticalDragStart: onDragStart,
|
||||
onVerticalDragUpdate: onDragUpdate,
|
||||
onVerticalDragEnd: onDragEnd,
|
||||
child: SlideFadeTransition(
|
||||
animation: thumbAnimation,
|
||||
child: CustomPaint(
|
||||
foregroundPainter: _ArrowCustomPainter(drawColor),
|
||||
child: Material(
|
||||
elevation: 4.0,
|
||||
child: Container(
|
||||
constraints:
|
||||
BoxConstraints.tight(Size(height * 0.6, height))),
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(height),
|
||||
bottomLeft: Radius.circular(height),
|
||||
topRight: Radius.circular(4.0),
|
||||
bottomRight: Radius.circular(4.0),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ArrowCustomPainter extends CustomPainter {
|
||||
final Color drawColor;
|
||||
|
||||
_ArrowCustomPainter(this.drawColor);
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint()
|
||||
..isAntiAlias = true
|
||||
..style = PaintingStyle.fill
|
||||
..color = drawColor;
|
||||
const width = 10.0;
|
||||
const height = 8.0;
|
||||
final baseX = size.width / 2;
|
||||
final baseY = size.height / 2;
|
||||
|
||||
canvas.drawPath(
|
||||
trianglePath(Offset(baseX - 2.0, baseY - 2.0), width, height, true),
|
||||
paint);
|
||||
canvas.drawPath(
|
||||
trianglePath(Offset(baseX - 2.0, baseY + 2.0), width, height, false),
|
||||
paint);
|
||||
}
|
||||
|
||||
static Path trianglePath(
|
||||
Offset offset, double width, double height, bool isUp) {
|
||||
return Path()
|
||||
..moveTo(offset.dx, offset.dy)
|
||||
..lineTo(offset.dx + width, offset.dy)
|
||||
..lineTo(offset.dx + (width / 2),
|
||||
isUp ? offset.dy - height : offset.dy + height)
|
||||
..close();
|
||||
}
|
||||
}
|
||||
|
||||
class SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
const SlideFadeTransition({
|
||||
Key key,
|
||||
@required this.animation,
|
||||
@required this.child,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (context, child) => animation.value == 0.0 ? Container() : child,
|
||||
child: SlideTransition(
|
||||
position: Tween(
|
||||
begin: Offset(0.3, 0.0),
|
||||
end: Offset(0.0, 0.0),
|
||||
).animate(animation),
|
||||
child: FadeTransition(
|
||||
opacity: animation,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -2,7 +2,6 @@ import 'package:flutter/foundation.dart';
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/models/location.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/models/selected_files.dart';
|
||||
import 'package:photos/ui/gallery.dart';
|
||||
|
||||
|
@ -39,7 +38,6 @@ class _LocationSearchResultsPageState extends State<LocationSearchResultsPage> {
|
|||
),
|
||||
body: Container(
|
||||
child: Gallery(
|
||||
syncLoader: _getResult,
|
||||
tagPrefix: "location_search",
|
||||
selectedFiles: _selectedFiles,
|
||||
),
|
||||
|
@ -48,7 +46,7 @@ class _LocationSearchResultsPageState extends State<LocationSearchResultsPage> {
|
|||
}
|
||||
|
||||
List<File> _getResult() {
|
||||
final files = FileRepository.instance.files;
|
||||
List<File> files = [];
|
||||
final args = Map<String, dynamic>();
|
||||
args['files'] = files;
|
||||
args['viewPort'] = widget.viewPort;
|
||||
|
|
|
@ -23,6 +23,7 @@ class MemoriesWidget extends StatefulWidget {
|
|||
class _MemoriesWidgetState extends State<MemoriesWidget>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
final _logger = Logger("MemoriesWidget");
|
||||
|
||||
Function _listener;
|
||||
|
||||
@override
|
||||
|
@ -96,7 +97,7 @@ class _MemoriesWidgetState extends State<MemoriesWidget>
|
|||
if (yearlyMemories.isNotEmpty) {
|
||||
collatedMemories.add(yearlyMemories);
|
||||
}
|
||||
return collatedMemories;
|
||||
return collatedMemories.reversed.toList();
|
||||
}
|
||||
|
||||
bool _areMemoriesFromSameYear(Memory first, Memory second) {
|
||||
|
@ -188,16 +189,15 @@ class MemoryWidget extends StatelessWidget {
|
|||
}
|
||||
|
||||
int _getNextMemoryIndex() {
|
||||
for (var index = 0; index < memories.length; index++) {
|
||||
int lastSeenIndex = 0;
|
||||
for (var index = memories.length - 1; index >=0; index--) {
|
||||
if (!memories[index].isSeen()) {
|
||||
return index;
|
||||
}
|
||||
if (index > 0 &&
|
||||
memories[index - 1].seenTime() > memories[index].seenTime()) {
|
||||
return index;
|
||||
lastSeenIndex = index;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
return lastSeenIndex;
|
||||
}
|
||||
|
||||
String _getTitle(Memory memory) {
|
||||
|
@ -319,7 +319,8 @@ class _FullScreenMemoryState extends State<FullScreenMemory> {
|
|||
itemBuilder: (BuildContext context, int index) {
|
||||
if (index < widget.memories.length - 1) {
|
||||
final nextFile = widget.memories[index + 1].file;
|
||||
preloadLocalFileThumbnail(nextFile);
|
||||
preloadThumbnail(nextFile);
|
||||
preloadFile(nextFile);
|
||||
}
|
||||
final file = widget.memories[index].file;
|
||||
return MemoryItem(file);
|
||||
|
|
|
@ -198,7 +198,8 @@ class _PasswordEntryPageState extends State<PasswordEntryPage> {
|
|||
}
|
||||
|
||||
void _updatePassword() async {
|
||||
final dialog = createProgressDialog(context, "generating encryption keys...");
|
||||
final dialog =
|
||||
createProgressDialog(context, "generating encryption keys...");
|
||||
await dialog.show();
|
||||
try {
|
||||
final keyAttributes = await Configuration.instance
|
||||
|
@ -266,8 +267,14 @@ class _PasswordEntryPageState extends State<PasswordEntryPage> {
|
|||
barrierDismissible: false,
|
||||
);
|
||||
} catch (e) {
|
||||
_logger.severe(e);
|
||||
await dialog.hide();
|
||||
showGenericErrorDialog(context);
|
||||
if (e is UnsupportedError) {
|
||||
showErrorDialog(context, "insecure device",
|
||||
"sorry, we could not generate secure keys on this device.\n\nplease sign up from a different device.");
|
||||
} else {
|
||||
showGenericErrorDialog(context);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -109,14 +109,15 @@ class CrispChatPage extends StatefulWidget {
|
|||
|
||||
class _CrispChatPageState extends State<CrispChatPage> {
|
||||
static const websiteID = "86d56ea2-68a2-43f9-8acb-95e06dee42e8";
|
||||
CrispMain _crisp;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
crisp.initialize(
|
||||
_crisp = CrispMain(
|
||||
websiteId: websiteID,
|
||||
);
|
||||
crisp.register(
|
||||
CrispUser(
|
||||
_crisp.register(
|
||||
user: CrispUser(
|
||||
email: Configuration.instance.getEmail(),
|
||||
),
|
||||
);
|
||||
|
@ -130,6 +131,7 @@ class _CrispChatPageState extends State<CrispChatPage> {
|
|||
title: Text("support chat"),
|
||||
),
|
||||
body: CrispView(
|
||||
crispMain: _crisp,
|
||||
loadingWidget: loadWidget,
|
||||
),
|
||||
);
|
||||
|
|
|
@ -7,38 +7,44 @@ import 'package:photos/models/collection.dart';
|
|||
import 'package:photos/ui/gallery.dart';
|
||||
import 'package:photos/ui/gallery_app_bar_widget.dart';
|
||||
|
||||
class SharedCollectionPage extends StatefulWidget {
|
||||
class SharedCollectionPage extends StatelessWidget {
|
||||
final Collection collection;
|
||||
|
||||
const SharedCollectionPage(this.collection, {Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
_SharedCollectionPageState createState() => _SharedCollectionPageState();
|
||||
}
|
||||
|
||||
class _SharedCollectionPageState extends State<SharedCollectionPage> {
|
||||
final _selectedFiles = SelectedFiles();
|
||||
|
||||
SharedCollectionPage(this.collection, {Key key}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(Object context) {
|
||||
var gallery = Gallery(
|
||||
asyncLoader: (_, __) =>
|
||||
FilesDB.instance.getAllInCollection(widget.collection.id),
|
||||
shouldLoadAll: true,
|
||||
asyncLoader: (creationStartTime, creationEndTime, {limit}) {
|
||||
return FilesDB.instance.getFilesInCollection(
|
||||
collection.id, creationStartTime, creationEndTime,
|
||||
limit: limit);
|
||||
},
|
||||
reloadEvent: Bus.instance
|
||||
.on<CollectionUpdatedEvent>()
|
||||
.where((event) => event.collectionID == widget.collection.id),
|
||||
.where((event) => event.collectionID == collection.id),
|
||||
tagPrefix: "shared_collection",
|
||||
selectedFiles: _selectedFiles,
|
||||
);
|
||||
return Scaffold(
|
||||
appBar: GalleryAppBarWidget(
|
||||
GalleryAppBarType.shared_collection,
|
||||
widget.collection.name,
|
||||
_selectedFiles,
|
||||
collection: widget.collection,
|
||||
body: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 80),
|
||||
child: gallery,
|
||||
),
|
||||
Container(
|
||||
height: 80,
|
||||
child: GalleryAppBarWidget(
|
||||
GalleryAppBarType.shared_collection,
|
||||
collection.name,
|
||||
_selectedFiles,
|
||||
collection: collection,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
body: gallery,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,12 +10,10 @@ import 'package:photos/db/files_db.dart';
|
|||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/events/tab_changed_event.dart';
|
||||
import 'package:photos/events/user_logged_out_event.dart';
|
||||
import 'package:photos/models/collection.dart';
|
||||
import 'package:photos/models/collection_items.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/ui/collection_page.dart';
|
||||
import 'package:photos/ui/collections_gallery_widget.dart';
|
||||
import 'package:photos/ui/common_elements.dart';
|
||||
import 'package:photos/ui/loading_widget.dart';
|
||||
import 'package:photos/ui/shared_collection_page.dart';
|
||||
import 'package:photos/ui/thumbnail_widget.dart';
|
||||
|
@ -53,7 +51,6 @@ class _SharedCollectionGalleryState extends State<SharedCollectionGallery>
|
|||
return FutureBuilder<SharedCollections>(
|
||||
future: Future.value(FilesDB.instance.getLatestCollectionFiles())
|
||||
.then((files) async {
|
||||
var startTime = DateTime.now();
|
||||
final List<CollectionWithThumbnail> outgoing = [];
|
||||
final List<CollectionWithThumbnail> incoming = [];
|
||||
for (final file in files) {
|
||||
|
@ -87,12 +84,6 @@ class _SharedCollectionGalleryState extends State<SharedCollectionGallery>
|
|||
return second.lastUpdatedFile.updationTime
|
||||
.compareTo(first.lastUpdatedFile.updationTime);
|
||||
});
|
||||
|
||||
var endTime = DateTime.now();
|
||||
var duration = Duration(
|
||||
microseconds: endTime.microsecondsSinceEpoch -
|
||||
startTime.microsecondsSinceEpoch);
|
||||
_logger.info("Total time taken: " + duration.inMilliseconds.toString());
|
||||
return SharedCollections(outgoing, incoming);
|
||||
}),
|
||||
builder: (context, snapshot) {
|
||||
|
@ -122,8 +113,8 @@ class _SharedCollectionGalleryState extends State<SharedCollectionGallery>
|
|||
shrinkWrap: true,
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildIncomingCollection(
|
||||
context, collections.incoming[index]);
|
||||
return IncomingCollectionItem(
|
||||
collections.incoming[index]);
|
||||
},
|
||||
itemCount: collections.incoming.length,
|
||||
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||
|
@ -144,8 +135,8 @@ class _SharedCollectionGalleryState extends State<SharedCollectionGallery>
|
|||
padding: EdgeInsets.only(bottom: 12),
|
||||
physics: NeverScrollableScrollPhysics(),
|
||||
itemBuilder: (context, index) {
|
||||
return _buildOutgoingCollection(
|
||||
context, collections.outgoing[index]);
|
||||
return OutgoingCollectionItem(
|
||||
collections.outgoing[index]);
|
||||
},
|
||||
itemCount: collections.outgoing.length,
|
||||
),
|
||||
|
@ -157,152 +148,6 @@ class _SharedCollectionGalleryState extends State<SharedCollectionGallery>
|
|||
);
|
||||
}
|
||||
|
||||
Widget _buildOutgoingCollection(
|
||||
BuildContext context, CollectionWithThumbnail c) {
|
||||
final sharees = List<String>();
|
||||
for (int index = 0; index < c.collection.sharees.length; index++) {
|
||||
if (index < 2) {
|
||||
sharees.add(c.collection.sharees[index].name);
|
||||
} else {
|
||||
final remaining = c.collection.sharees.length - index;
|
||||
if (remaining == 1) {
|
||||
// If it's the last sharee
|
||||
sharees.add(c.collection.sharees[index].name);
|
||||
} else {
|
||||
sharees.add("and " +
|
||||
remaining.toString() +
|
||||
" other" +
|
||||
(remaining > 1 ? "s" : ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: Container(
|
||||
child: Hero(
|
||||
tag: "outgoing_collection" + c.thumbnail.tag(),
|
||||
child: ThumbnailWidget(
|
||||
c.thumbnail,
|
||||
)),
|
||||
height: 60,
|
||||
width: 60,
|
||||
),
|
||||
),
|
||||
Padding(padding: EdgeInsets.all(8)),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.collection.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 4, 0, 0),
|
||||
child: Text(
|
||||
"Shared with " + sharees.join(", "),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
final page = CollectionPage(
|
||||
c.collection,
|
||||
tagPrefix: "outgoing_collection",
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return page;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIncomingCollection(
|
||||
BuildContext context, CollectionWithThumbnail c) {
|
||||
return GestureDetector(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18.0),
|
||||
child: Container(
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: "shared_collection" + c.thumbnail.tag(),
|
||||
child: ThumbnailWidget(c.thumbnail)),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Container(
|
||||
child: Text(
|
||||
c.collection.owner.name == null ||
|
||||
c.collection.owner.name.isEmpty
|
||||
? c.collection.owner.email.substring(0, 1)
|
||||
: c.collection.owner.name.substring(0, 1),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
padding: EdgeInsets.all(8),
|
||||
margin: EdgeInsets.fromLTRB(0, 0, 4, 0),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 160,
|
||||
width: 160,
|
||||
),
|
||||
),
|
||||
Padding(padding: EdgeInsets.all(2)),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
|
||||
child: Text(
|
||||
c.collection.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final page = SharedCollectionPage(c.collection);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return page;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getIncomingCollectionEmptyState() {
|
||||
return Container(
|
||||
padding: EdgeInsets.only(top: 10),
|
||||
|
@ -416,3 +261,167 @@ class _SharedCollectionGalleryState extends State<SharedCollectionGallery>
|
|||
@override
|
||||
bool get wantKeepAlive => true;
|
||||
}
|
||||
|
||||
class OutgoingCollectionItem extends StatelessWidget {
|
||||
final CollectionWithThumbnail c;
|
||||
|
||||
const OutgoingCollectionItem(
|
||||
this.c, {
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sharees = List<String>();
|
||||
for (int index = 0; index < c.collection.sharees.length; index++) {
|
||||
if (index < 2) {
|
||||
sharees.add(c.collection.sharees[index].name);
|
||||
} else {
|
||||
final remaining = c.collection.sharees.length - index;
|
||||
if (remaining == 1) {
|
||||
// If it's the last sharee
|
||||
sharees.add(c.collection.sharees[index].name);
|
||||
} else {
|
||||
sharees.add("and " +
|
||||
remaining.toString() +
|
||||
" other" +
|
||||
(remaining > 1 ? "s" : ""));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: EdgeInsets.fromLTRB(16, 12, 8, 12),
|
||||
child: Row(
|
||||
children: <Widget>[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8.0),
|
||||
child: Container(
|
||||
child: Hero(
|
||||
tag: "outgoing_collection" + c.thumbnail.tag(),
|
||||
child: ThumbnailWidget(
|
||||
c.thumbnail,
|
||||
)),
|
||||
height: 60,
|
||||
width: 60,
|
||||
),
|
||||
),
|
||||
Padding(padding: EdgeInsets.all(8)),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
c.collection.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 4, 0, 0),
|
||||
child: Text(
|
||||
"Shared with " + sharees.join(", "),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Theme.of(context).primaryColorLight,
|
||||
),
|
||||
textAlign: TextAlign.left,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onTap: () {
|
||||
final page = CollectionPage(
|
||||
c.collection,
|
||||
tagPrefix: "outgoing_collection",
|
||||
);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return page;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class IncomingCollectionItem extends StatelessWidget {
|
||||
final CollectionWithThumbnail c;
|
||||
|
||||
const IncomingCollectionItem(
|
||||
this.c, {
|
||||
Key key,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18.0),
|
||||
child: Container(
|
||||
child: Stack(
|
||||
children: [
|
||||
Hero(
|
||||
tag: "shared_collection" + c.thumbnail.tag(),
|
||||
child: ThumbnailWidget(c.thumbnail)),
|
||||
Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Container(
|
||||
child: Text(
|
||||
c.collection.owner.name == null ||
|
||||
c.collection.owner.name.isEmpty
|
||||
? c.collection.owner.email.substring(0, 1)
|
||||
: c.collection.owner.name.substring(0, 1),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
padding: EdgeInsets.all(8),
|
||||
margin: EdgeInsets.fromLTRB(0, 0, 4, 0),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Theme.of(context).accentColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
height: 160,
|
||||
width: 160,
|
||||
),
|
||||
),
|
||||
Padding(padding: EdgeInsets.all(2)),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 0, 8, 0),
|
||||
child: Text(
|
||||
c.collection.name,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
final page = SharedCollectionPage(c.collection);
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (BuildContext context) {
|
||||
return page;
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,6 +16,7 @@ class SyncIndicator extends StatefulWidget {
|
|||
}
|
||||
|
||||
class _SyncIndicatorState extends State<SyncIndicator> {
|
||||
static const kSleepDuration = Duration(milliseconds: 3000);
|
||||
SyncStatusUpdate _event;
|
||||
double _containerHeight = 48;
|
||||
StreamSubscription<SyncStatusUpdate> _subscription;
|
||||
|
@ -40,7 +41,12 @@ class _SyncIndicatorState extends State<SyncIndicator> {
|
|||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_event == null) {
|
||||
bool isNotOutdatedEvent = _event != null &&
|
||||
(_event.status == SyncStatus.completed_backup ||
|
||||
_event.status == SyncStatus.completed_first_gallery_import) &&
|
||||
(DateTime.now().microsecondsSinceEpoch - _event.timestamp >
|
||||
kSleepDuration.inMicroseconds);
|
||||
if (_event == null || isNotOutdatedEvent) {
|
||||
return Container();
|
||||
}
|
||||
if (_event.status == SyncStatus.error) {
|
||||
|
@ -48,7 +54,7 @@ class _SyncIndicatorState extends State<SyncIndicator> {
|
|||
}
|
||||
if (_event.status == SyncStatus.completed_first_gallery_import ||
|
||||
_event.status == SyncStatus.completed_backup) {
|
||||
Future.delayed(Duration(milliseconds: 3000), () {
|
||||
Future.delayed(kSleepDuration, () {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_containerHeight = 0;
|
||||
|
|
|
@ -1,24 +1,31 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:photos/core/cache/image_cache.dart';
|
||||
import 'package:photos/core/cache/thumbnail_cache.dart';
|
||||
import 'package:photos/core/errors.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/models/file_type.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
import 'package:photos/ui/common_elements.dart';
|
||||
import 'package:photos/utils/thumbnail_util.dart';
|
||||
|
||||
class ThumbnailWidget extends StatefulWidget {
|
||||
final File file;
|
||||
final BoxFit fit;
|
||||
final bool shouldShowSyncStatus;
|
||||
final Duration diskLoadDeferDuration;
|
||||
final Duration serverLoadDeferDuration;
|
||||
|
||||
ThumbnailWidget(
|
||||
this.file, {
|
||||
Key key,
|
||||
this.fit = BoxFit.cover,
|
||||
this.shouldShowSyncStatus = true,
|
||||
this.diskLoadDeferDuration,
|
||||
this.serverLoadDeferDuration,
|
||||
}) : super(key: key ?? Key(file.generatedID.toString()));
|
||||
@override
|
||||
_ThumbnailWidgetState createState() => _ThumbnailWidgetState();
|
||||
|
@ -26,6 +33,28 @@ class ThumbnailWidget extends StatefulWidget {
|
|||
|
||||
class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
||||
static final _logger = Logger("ThumbnailWidget");
|
||||
|
||||
static final kVideoIconOverlay = Container(
|
||||
height: 64,
|
||||
child: Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 40,
|
||||
color: Colors.white70,
|
||||
),
|
||||
);
|
||||
|
||||
static final kUnsyncedIconOverlay = Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8, bottom: 4),
|
||||
child: Icon(
|
||||
Icons.cloud_off_outlined,
|
||||
size: 18,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
static final Widget loadingWidget = Container(
|
||||
alignment: Alignment.center,
|
||||
color: Colors.grey[900],
|
||||
|
@ -68,14 +97,7 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
|||
content = Stack(
|
||||
children: [
|
||||
image,
|
||||
Container(
|
||||
height: 64,
|
||||
child: Icon(
|
||||
Icons.play_circle_outline,
|
||||
size: 40,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
kVideoIconOverlay,
|
||||
],
|
||||
fit: StackFit.expand,
|
||||
);
|
||||
|
@ -92,18 +114,8 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
|||
child: content,
|
||||
),
|
||||
widget.shouldShowSyncStatus && widget.file.uploadedFileID == null
|
||||
? Align(
|
||||
alignment: Alignment.bottomRight,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8, bottom: 4),
|
||||
child: Icon(
|
||||
Icons.cloud_off_outlined,
|
||||
size: 18,
|
||||
color: Colors.white.withOpacity(0.8),
|
||||
),
|
||||
),
|
||||
)
|
||||
: Container(),
|
||||
? kUnsyncedIconOverlay
|
||||
: emptyContainer,
|
||||
],
|
||||
fit: StackFit.expand,
|
||||
);
|
||||
|
@ -120,46 +132,58 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
|||
_imageProvider = Image.memory(cachedSmallThumbnail).image;
|
||||
_hasLoadedThumbnail = true;
|
||||
} else {
|
||||
widget.file.getAsset().then((asset) async {
|
||||
if (asset == null || !(await asset.exists)) {
|
||||
if (widget.file.uploadedFileID != null) {
|
||||
widget.file.localID = null;
|
||||
FilesDB.instance.update(widget.file);
|
||||
_loadNetworkImage();
|
||||
} else {
|
||||
FilesDB.instance.deleteLocalFile(widget.file.localID);
|
||||
FileRepository.instance.reloadFiles();
|
||||
if (widget.diskLoadDeferDuration != null) {
|
||||
Future.delayed(widget.diskLoadDeferDuration, () {
|
||||
if (mounted) {
|
||||
_getThumbnailFromDisk();
|
||||
}
|
||||
return;
|
||||
}
|
||||
asset
|
||||
.thumbDataWithSize(
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
quality: THUMBNAIL_QUALITY,
|
||||
)
|
||||
.then((data) {
|
||||
if (data != null && mounted) {
|
||||
final imageProvider = Image.memory(data).image;
|
||||
precacheImage(imageProvider, context).then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageProvider = imageProvider;
|
||||
_hasLoadedThumbnail = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ThumbnailLruCache.put(widget.file, THUMBNAIL_SMALL_SIZE, data);
|
||||
});
|
||||
}).catchError((e) {
|
||||
_logger.warning("Could not load image: ", e);
|
||||
_encounteredErrorLoadingThumbnail = true;
|
||||
});
|
||||
} else {
|
||||
_getThumbnailFromDisk();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future _getThumbnailFromDisk() async {
|
||||
widget.file.getAsset().then((asset) async {
|
||||
if (asset == null || !(await asset.exists)) {
|
||||
if (widget.file.uploadedFileID != null) {
|
||||
widget.file.localID = null;
|
||||
FilesDB.instance.update(widget.file);
|
||||
_loadNetworkImage();
|
||||
} else {
|
||||
FilesDB.instance.deleteLocalFile(widget.file.localID);
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent([widget.file]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
asset
|
||||
.thumbDataWithSize(
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
quality: THUMBNAIL_QUALITY,
|
||||
)
|
||||
.then((data) {
|
||||
if (data != null && mounted) {
|
||||
final imageProvider = Image.memory(data).image;
|
||||
precacheImage(imageProvider, context).then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_imageProvider = imageProvider;
|
||||
_hasLoadedThumbnail = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
ThumbnailLruCache.put(widget.file, THUMBNAIL_SMALL_SIZE, data);
|
||||
});
|
||||
}).catchError((e) {
|
||||
_logger.warning("Could not load image: ", e);
|
||||
_encounteredErrorLoadingThumbnail = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _loadNetworkImage() {
|
||||
if (!_hasLoadedThumbnail &&
|
||||
!_encounteredErrorLoadingThumbnail &&
|
||||
|
@ -171,14 +195,23 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
|||
_hasLoadedThumbnail = true;
|
||||
return;
|
||||
}
|
||||
_getThumbnailFromServer();
|
||||
if (widget.serverLoadDeferDuration != null) {
|
||||
Future.delayed(widget.serverLoadDeferDuration, () {
|
||||
if (mounted) {
|
||||
_getThumbnailFromServer();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
_getThumbnailFromServer();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _getThumbnailFromServer() {
|
||||
getThumbnailFromServer(widget.file).then((file) async {
|
||||
void _getThumbnailFromServer() async {
|
||||
try {
|
||||
final thumbnail = await getThumbnailFromServer(widget.file);
|
||||
if (mounted) {
|
||||
final imageProvider = Image.file(file).image;
|
||||
final imageProvider = Image.file(thumbnail).image;
|
||||
precacheImage(imageProvider, context).then((value) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
|
@ -186,24 +219,35 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
|
|||
_hasLoadedThumbnail = true;
|
||||
});
|
||||
}
|
||||
}).catchError((e) {
|
||||
_logger.severe("Could not load image " + widget.file.toString());
|
||||
_encounteredErrorLoadingThumbnail = true;
|
||||
});
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
if (e is RequestCancelledError) {
|
||||
if (mounted) {
|
||||
_logger.info("Thumbnail request was aborted although it is in view, will retry");
|
||||
_reset();
|
||||
}
|
||||
} else {
|
||||
_logger.severe("Could not load image " + widget.file.toString(), e);
|
||||
_encounteredErrorLoadingThumbnail = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ThumbnailWidget oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.file.generatedID != oldWidget.file.generatedID) {
|
||||
setState(() {
|
||||
_hasLoadedThumbnail = false;
|
||||
_isLoadingThumbnail = false;
|
||||
_encounteredErrorLoadingThumbnail = false;
|
||||
_imageProvider = null;
|
||||
});
|
||||
_reset();
|
||||
}
|
||||
}
|
||||
|
||||
void _reset() {
|
||||
setState(() {
|
||||
_hasLoadedThumbnail = false;
|
||||
_isLoadingThumbnail = false;
|
||||
_encounteredErrorLoadingThumbnail = false;
|
||||
_imageProvider = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -66,7 +66,9 @@ class _VideoWidgetState extends State<VideoWidget> {
|
|||
});
|
||||
},
|
||||
).then((file) {
|
||||
_setVideoPlayerController(file: file);
|
||||
if (file != null) {
|
||||
_setVideoPlayerController(file: file);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
@ -23,7 +23,7 @@ class _WebPageState extends State<WebPage> {
|
|||
actions: [_hasLoadedPage ? Container() : loadWidget],
|
||||
),
|
||||
body: InAppWebView(
|
||||
initialUrl: widget.url,
|
||||
initialUrlRequest: URLRequest(url: Uri.parse(widget.url)),
|
||||
onLoadStop: (c, url) {
|
||||
setState(() {
|
||||
_hasLoadedPage = true;
|
||||
|
|
|
@ -4,13 +4,15 @@ import 'package:flutter/widgets.dart';
|
|||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/cache/image_cache.dart';
|
||||
import 'package:photos/core/cache/thumbnail_cache.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/ui/loading_widget.dart';
|
||||
import 'package:photo_view/photo_view.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
import 'package:photos/utils/thumbnail_util.dart';
|
||||
|
||||
class ZoomableImage extends StatefulWidget {
|
||||
final File photo;
|
||||
|
@ -167,7 +169,7 @@ class _ZoomableImageState extends State<ZoomableImage>
|
|||
_loadNetworkImage();
|
||||
} else {
|
||||
FilesDB.instance.deleteLocalFile(_photo.localID);
|
||||
FileRepository.instance.reloadFiles();
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent([_photo]));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
@ -1,3 +1,5 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
Map<int, String> _months = {
|
||||
1: "Jan",
|
||||
2: "Feb",
|
||||
|
@ -151,3 +153,34 @@ bool isLeapYear(DateTime dateTime) {
|
|||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Widget getDayWidget(int timestamp) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(10, 8, 0, 10),
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
getDayTitle(timestamp),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.white.withOpacity(0.85),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String getDayTitle(int timestamp) {
|
||||
final date = DateTime.fromMicrosecondsSinceEpoch(timestamp);
|
||||
final now = DateTime.now();
|
||||
var title = getDayAndMonth(date);
|
||||
if (date.year == now.year && date.month == now.month) {
|
||||
if (date.day == now.day) {
|
||||
title = "Today";
|
||||
} else if (date.day == now.day - 1) {
|
||||
title = "Yesterday";
|
||||
}
|
||||
}
|
||||
if (date.year != DateTime.now().year) {
|
||||
title += " " + date.year.toString();
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
|
115
lib/utils/delete_file_util.dart
Normal file
115
lib/utils/delete_file_util.dart
Normal file
|
@ -0,0 +1,115 @@
|
|||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/events/files_updated_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/services/sync_service.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/toast_util.dart';
|
||||
|
||||
final _logger = Logger("DeleteFileUtil");
|
||||
|
||||
Future<void> deleteFilesFromEverywhere(
|
||||
BuildContext context, List<File> files) async {
|
||||
final dialog = createProgressDialog(context, "deleting...");
|
||||
await dialog.show();
|
||||
final localIDs = List<String>();
|
||||
for (final file in files) {
|
||||
if (file.localID != null) {
|
||||
localIDs.add(file.localID);
|
||||
}
|
||||
}
|
||||
var deletedIDs;
|
||||
try {
|
||||
deletedIDs = (await PhotoManager.editor.deleteWithIds(localIDs)).toSet();
|
||||
} catch (e, s) {
|
||||
_logger.severe("Could not delete file", e, s);
|
||||
}
|
||||
final updatedCollectionIDs = Set<int>();
|
||||
final List<int> uploadedFileIDsToBeDeleted = [];
|
||||
final List<File> deletedFiles = [];
|
||||
for (final file in files) {
|
||||
if (file.localID != null) {
|
||||
// Remove only those files that have been removed from disk
|
||||
if (deletedIDs.contains(file.localID)) {
|
||||
deletedFiles.add(file);
|
||||
if (file.uploadedFileID != null) {
|
||||
uploadedFileIDsToBeDeleted.add(file.uploadedFileID);
|
||||
updatedCollectionIDs.add(file.collectionID);
|
||||
} else {
|
||||
await FilesDB.instance.deleteLocalFile(file.localID);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
updatedCollectionIDs.add(file.collectionID);
|
||||
deletedFiles.add(file);
|
||||
uploadedFileIDsToBeDeleted.add(file.uploadedFileID);
|
||||
}
|
||||
}
|
||||
if (uploadedFileIDsToBeDeleted.isNotEmpty) {
|
||||
try {
|
||||
await SyncService.instance
|
||||
.deleteFilesOnServer(uploadedFileIDsToBeDeleted);
|
||||
await FilesDB.instance
|
||||
.deleteMultipleUploadedFiles(uploadedFileIDsToBeDeleted);
|
||||
} catch (e) {
|
||||
_logger.severe(e);
|
||||
await dialog.hide();
|
||||
showGenericErrorDialog(context);
|
||||
throw e;
|
||||
}
|
||||
for (final collectionID in updatedCollectionIDs) {
|
||||
Bus.instance.fire(CollectionUpdatedEvent(
|
||||
collectionID,
|
||||
deletedFiles
|
||||
.where((file) => file.collectionID == collectionID)
|
||||
.toList(),
|
||||
type: EventType.deleted,
|
||||
));
|
||||
}
|
||||
}
|
||||
if (deletedFiles.isNotEmpty) {
|
||||
Bus.instance
|
||||
.fire(LocalPhotosUpdatedEvent(deletedFiles, type: EventType.deleted));
|
||||
}
|
||||
await dialog.hide();
|
||||
showToast("deleted from everywhere");
|
||||
if (uploadedFileIDsToBeDeleted.isNotEmpty) {
|
||||
SyncService.instance.syncWithRemote(silently: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteFilesOnDeviceOnly(
|
||||
BuildContext context, List<File> files) async {
|
||||
final dialog = createProgressDialog(context, "deleting...");
|
||||
await dialog.show();
|
||||
final localIDs = List<String>();
|
||||
for (final file in files) {
|
||||
if (file.localID != null) {
|
||||
localIDs.add(file.localID);
|
||||
}
|
||||
}
|
||||
final deletedIDs =
|
||||
(await PhotoManager.editor.deleteWithIds(localIDs)).toSet();
|
||||
final List<File> deletedFiles = [];
|
||||
for (final file in files) {
|
||||
// Remove only those files that have been removed from disk
|
||||
if (deletedIDs.contains(file.localID)) {
|
||||
deletedFiles.add(file);
|
||||
file.localID = null;
|
||||
FilesDB.instance.update(file);
|
||||
}
|
||||
}
|
||||
if (deletedFiles.isNotEmpty) {
|
||||
Bus.instance
|
||||
.fire(LocalPhotosUpdatedEvent(deletedFiles, type: EventType.deleted));
|
||||
}
|
||||
await dialog.hide();
|
||||
}
|
|
@ -8,9 +8,9 @@ import 'package:photos/core/event_bus.dart';
|
|||
import 'package:photos/core/network.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/events/remote_sync_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
|
||||
|
@ -49,8 +49,8 @@ class DiffFetcher {
|
|||
await FilesDB.instance.deleteFromCollection(
|
||||
file.uploadedFileID, file.collectionID);
|
||||
Bus.instance.fire(
|
||||
CollectionUpdatedEvent(collectionID: file.collectionID));
|
||||
FileRepository.instance.reloadFiles();
|
||||
CollectionUpdatedEvent(file.collectionID, [file]));
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent([file]));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
|
|
@ -14,12 +14,12 @@ import 'package:photos/core/event_bus.dart';
|
|||
import 'package:photos/core/network.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/db/upload_locks_db.dart';
|
||||
import 'package:photos/events/local_photos_updated_event.dart';
|
||||
import 'package:photos/events/subscription_purchased_event.dart';
|
||||
import 'package:photos/models/encryption_result.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/models/location.dart';
|
||||
import 'package:photos/models/upload_url.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/sync_service.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
|
@ -365,7 +365,7 @@ class FileUploader {
|
|||
await FilesDB.instance.update(remoteFile);
|
||||
}
|
||||
if (!_isBackground) {
|
||||
FileRepository.instance.reloadFiles();
|
||||
Bus.instance.fire(LocalPhotosUpdatedEvent([file]));
|
||||
}
|
||||
_logger.info("File upload complete for " + remoteFile.toString());
|
||||
return remoteFile;
|
||||
|
|
|
@ -1,114 +1,36 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
import 'dart:io' as io;
|
||||
import 'dart:io';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_sodium/flutter_sodium.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:path/path.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter_image_compress/flutter_image_compress.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
import 'package:photos/core/cache/image_cache.dart';
|
||||
import 'package:photos/core/cache/thumbnail_cache.dart';
|
||||
import 'package:photos/core/cache/thumbnail_cache_manager.dart';
|
||||
import 'package:photos/core/cache/video_cache_manager.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/event_bus.dart';
|
||||
import 'package:photos/core/network.dart';
|
||||
import 'package:photos/db/files_db.dart';
|
||||
import 'package:photos/events/collection_updated_event.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/models/file_type.dart';
|
||||
import 'package:photos/repositories/file_repository.dart';
|
||||
import 'package:photos/services/collections_service.dart';
|
||||
import 'package:photos/services/sync_service.dart';
|
||||
import 'package:photos/utils/dialog_util.dart';
|
||||
import 'package:photos/utils/thumbnail_util.dart';
|
||||
|
||||
import 'crypto_util.dart';
|
||||
|
||||
final _logger = Logger("FileUtil");
|
||||
|
||||
Future<void> deleteFilesFromEverywhere(
|
||||
BuildContext context, List<File> files) async {
|
||||
final dialog = createProgressDialog(context, "deleting...");
|
||||
await dialog.show();
|
||||
final localIDs = List<String>();
|
||||
for (final file in files) {
|
||||
if (file.localID != null) {
|
||||
localIDs.add(file.localID);
|
||||
}
|
||||
}
|
||||
var deletedIDs;
|
||||
try {
|
||||
deletedIDs = (await PhotoManager.editor.deleteWithIds(localIDs)).toSet();
|
||||
} catch (e, s) {
|
||||
_logger.severe("Could not delete file", e, s);
|
||||
}
|
||||
bool hasUploadedFiles = false;
|
||||
final updatedCollectionIDs = Set<int>();
|
||||
for (final file in files) {
|
||||
if (file.localID != null) {
|
||||
// Remove only those files that have been removed from disk
|
||||
if (deletedIDs.contains(file.localID)) {
|
||||
if (file.uploadedFileID != null) {
|
||||
hasUploadedFiles = true;
|
||||
await FilesDB.instance.markForDeletion(file.uploadedFileID);
|
||||
updatedCollectionIDs.add(file.collectionID);
|
||||
} else {
|
||||
await FilesDB.instance.deleteLocalFile(file.localID);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
hasUploadedFiles = true;
|
||||
await FilesDB.instance.markForDeletion(file.uploadedFileID);
|
||||
}
|
||||
await dialog.hide();
|
||||
}
|
||||
|
||||
await FileRepository.instance.reloadFiles();
|
||||
if (hasUploadedFiles) {
|
||||
for (final collectionID in updatedCollectionIDs) {
|
||||
Bus.instance.fire(CollectionUpdatedEvent(collectionID: collectionID));
|
||||
}
|
||||
// TODO: Blocking call?
|
||||
SyncService.instance.deleteFilesOnServer();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> deleteFilesOnDeviceOnly(
|
||||
BuildContext context, List<File> files) async {
|
||||
final dialog = createProgressDialog(context, "deleting...");
|
||||
await dialog.show();
|
||||
final localIDs = List<String>();
|
||||
for (final file in files) {
|
||||
if (file.localID != null) {
|
||||
localIDs.add(file.localID);
|
||||
}
|
||||
}
|
||||
final deletedIDs =
|
||||
(await PhotoManager.editor.deleteWithIds(localIDs)).toSet();
|
||||
for (final file in files) {
|
||||
// Remove only those files that have been removed from disk
|
||||
if (deletedIDs.contains(file.localID)) {
|
||||
file.localID = null;
|
||||
FilesDB.instance.update(file);
|
||||
}
|
||||
}
|
||||
await FileRepository.instance.reloadFiles();
|
||||
await dialog.hide();
|
||||
}
|
||||
|
||||
void preloadFile(File file) {
|
||||
if (file.fileType == FileType.video) {
|
||||
return;
|
||||
}
|
||||
if (file.localID == null) {
|
||||
// getFileFromServer(file);
|
||||
getFileFromServer(file);
|
||||
} else {
|
||||
if (FileLruCache.get(file) == null) {
|
||||
file.getAsset().then((asset) {
|
||||
|
@ -120,22 +42,25 @@ void preloadFile(File file) {
|
|||
}
|
||||
}
|
||||
|
||||
void preloadLocalFileThumbnail(File file) {
|
||||
if (file.localID == null ||
|
||||
ThumbnailLruCache.get(file, THUMBNAIL_SMALL_SIZE) != null) {
|
||||
return;
|
||||
}
|
||||
file.getAsset().then((asset) {
|
||||
asset
|
||||
.thumbDataWithSize(
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
quality: THUMBNAIL_QUALITY,
|
||||
)
|
||||
.then((data) {
|
||||
ThumbnailLruCache.put(file, THUMBNAIL_SMALL_SIZE, data);
|
||||
void preloadThumbnail(File file) {
|
||||
if (file.localID == null) {
|
||||
getThumbnailFromServer(file);
|
||||
} else {
|
||||
if (ThumbnailLruCache.get(file, THUMBNAIL_SMALL_SIZE) != null) {
|
||||
return;
|
||||
}
|
||||
file.getAsset().then((asset) {
|
||||
asset
|
||||
.thumbDataWithSize(
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
THUMBNAIL_SMALL_SIZE,
|
||||
quality: THUMBNAIL_QUALITY,
|
||||
)
|
||||
.then((data) {
|
||||
ThumbnailLruCache.put(file, THUMBNAIL_SMALL_SIZE, data);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<io.File> getNativeFile(File file) async {
|
||||
|
@ -171,31 +96,10 @@ Future<Uint8List> getBytesFromDisk(File file, {int quality = 100}) async {
|
|||
final Map<int, Future<io.File>> fileDownloadsInProgress =
|
||||
Map<int, Future<io.File>>();
|
||||
|
||||
final _thumbnailQueue = LinkedHashMap<int, FileDownloadItem>();
|
||||
int _currentlyDownloading = 0;
|
||||
int kMaximumConcurrentDownloads = 100;
|
||||
|
||||
class FileDownloadItem {
|
||||
final File file;
|
||||
final Completer<io.File> completer;
|
||||
DownloadStatus status;
|
||||
|
||||
FileDownloadItem(
|
||||
this.file,
|
||||
this.completer, {
|
||||
this.status = DownloadStatus.not_started,
|
||||
});
|
||||
}
|
||||
|
||||
enum DownloadStatus {
|
||||
not_started,
|
||||
in_progress,
|
||||
}
|
||||
|
||||
Future<io.File> getFileFromServer(File file,
|
||||
{ProgressCallback progressCallback}) async {
|
||||
final cacheManager = file.fileType == FileType.video
|
||||
? VideoCacheManager()
|
||||
? VideoCacheManager.instance
|
||||
: DefaultCacheManager();
|
||||
return cacheManager.getFileFromCache(file.getDownloadUrl()).then((info) {
|
||||
if (info == null) {
|
||||
|
@ -213,61 +117,6 @@ Future<io.File> getFileFromServer(File file,
|
|||
});
|
||||
}
|
||||
|
||||
Future<io.File> getThumbnailFromServer(File file) async {
|
||||
return ThumbnailCacheManager()
|
||||
.getFileFromCache(file.getThumbnailUrl())
|
||||
.then((info) {
|
||||
if (info == null) {
|
||||
if (!_thumbnailQueue.containsKey(file.uploadedFileID)) {
|
||||
final completer = Completer<io.File>();
|
||||
_thumbnailQueue[file.uploadedFileID] =
|
||||
FileDownloadItem(file, completer);
|
||||
_pollQueue();
|
||||
return completer.future;
|
||||
} else {
|
||||
return _thumbnailQueue[file.uploadedFileID].completer.future;
|
||||
}
|
||||
} else {
|
||||
ThumbnailFileLruCache.put(file, info.file);
|
||||
return info.file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void removePendingGetThumbnailRequestIfAny(File file) {
|
||||
if (_thumbnailQueue[file.uploadedFileID] != null &&
|
||||
_thumbnailQueue[file.uploadedFileID].status ==
|
||||
DownloadStatus.not_started) {
|
||||
_thumbnailQueue.remove(file.uploadedFileID);
|
||||
}
|
||||
}
|
||||
|
||||
void _pollQueue() async {
|
||||
if (_thumbnailQueue.length > 0 &&
|
||||
_currentlyDownloading < kMaximumConcurrentDownloads) {
|
||||
final firstPendingEntry = _thumbnailQueue.entries.firstWhere(
|
||||
(entry) => entry.value.status == DownloadStatus.not_started,
|
||||
orElse: () => null);
|
||||
if (firstPendingEntry != null) {
|
||||
final item = firstPendingEntry.value;
|
||||
_currentlyDownloading++;
|
||||
item.status = DownloadStatus.in_progress;
|
||||
try {
|
||||
final data = await _downloadAndDecryptThumbnail(item.file);
|
||||
ThumbnailFileLruCache.put(item.file, data);
|
||||
item.completer.complete(data);
|
||||
} catch (e, s) {
|
||||
_logger.severe(
|
||||
"Failed to download thumbnail " + item.file.toString(), e, s);
|
||||
item.completer.completeError(e);
|
||||
}
|
||||
_currentlyDownloading--;
|
||||
_thumbnailQueue.remove(firstPendingEntry.key);
|
||||
_pollQueue();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<io.File> _downloadAndDecrypt(File file, BaseCacheManager cacheManager,
|
||||
{ProgressCallback progressCallback}) async {
|
||||
_logger.info("Downloading file " + file.uploadedFileID.toString());
|
||||
|
@ -334,41 +183,6 @@ Future<io.File> _downloadAndDecrypt(File file, BaseCacheManager cacheManager,
|
|||
});
|
||||
}
|
||||
|
||||
Future<io.File> _downloadAndDecryptThumbnail(File file) async {
|
||||
final temporaryPath = Configuration.instance.getTempDirectory() +
|
||||
file.generatedID.toString() +
|
||||
"_thumbnail.decrypted";
|
||||
await Network.instance.getDio().download(
|
||||
file.getThumbnailUrl(),
|
||||
temporaryPath,
|
||||
options: Options(
|
||||
headers: {"X-Auth-Token": Configuration.instance.getToken()},
|
||||
),
|
||||
);
|
||||
final encryptedFile = io.File(temporaryPath);
|
||||
final thumbnailDecryptionKey = decryptFileKey(file);
|
||||
var data = CryptoUtil.decryptChaCha(
|
||||
encryptedFile.readAsBytesSync(),
|
||||
thumbnailDecryptionKey,
|
||||
Sodium.base642bin(file.thumbnailDecryptionHeader),
|
||||
);
|
||||
final thumbnailSize = data.length;
|
||||
if (thumbnailSize > THUMBNAIL_DATA_LIMIT) {
|
||||
data = await compressThumbnail(data);
|
||||
_logger.info("Compressed thumbnail from " +
|
||||
thumbnailSize.toString() +
|
||||
" to " +
|
||||
data.length.toString());
|
||||
}
|
||||
encryptedFile.deleteSync();
|
||||
final cachedThumbnail = ThumbnailCacheManager().putFile(
|
||||
file.getThumbnailUrl(),
|
||||
data,
|
||||
eTag: file.getThumbnailUrl(),
|
||||
maxAge: Duration(days: 365),
|
||||
);
|
||||
return cachedThumbnail;
|
||||
}
|
||||
|
||||
Uint8List decryptFileKey(File file) {
|
||||
final encryptedKey = Sodium.base642bin(file.encryptedKey);
|
||||
|
@ -389,9 +203,9 @@ Future<Uint8List> compressThumbnail(Uint8List thumbnail) {
|
|||
|
||||
void clearCache(File file) {
|
||||
if (file.fileType == FileType.video) {
|
||||
VideoCacheManager().removeFile(file.getDownloadUrl());
|
||||
VideoCacheManager.instance.removeFile(file.getDownloadUrl());
|
||||
} else {
|
||||
DefaultCacheManager().removeFile(file.getDownloadUrl());
|
||||
}
|
||||
ThumbnailCacheManager().removeFile(file.getThumbnailUrl());
|
||||
ThumbnailCacheManager.instance.removeFile(file.getThumbnailUrl());
|
||||
}
|
||||
|
|
127
lib/utils/thumbnail_util.dart
Normal file
127
lib/utils/thumbnail_util.dart
Normal file
|
@ -0,0 +1,127 @@
|
|||
import 'dart:async';
|
||||
import 'dart:collection';
|
||||
|
||||
import 'dart:io' as io;
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_sodium/flutter_sodium.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:photos/core/cache/image_cache.dart';
|
||||
import 'package:photos/core/cache/thumbnail_cache_manager.dart';
|
||||
import 'package:photos/core/configuration.dart';
|
||||
import 'package:photos/core/constants.dart';
|
||||
import 'package:photos/core/errors.dart';
|
||||
import 'package:photos/core/network.dart';
|
||||
import 'package:photos/models/file.dart';
|
||||
import 'package:photos/utils/crypto_util.dart';
|
||||
import 'package:photos/utils/file_util.dart';
|
||||
|
||||
final _logger = Logger("ThumbnailUtil");
|
||||
final _map = LinkedHashMap<int, FileDownloadItem>();
|
||||
final _queue = Queue<int>();
|
||||
const int kMaximumConcurrentDownloads = 2500;
|
||||
|
||||
class FileDownloadItem {
|
||||
final File file;
|
||||
final Completer<io.File> completer;
|
||||
final CancelToken cancelToken;
|
||||
|
||||
FileDownloadItem(this.file, this.completer, this.cancelToken);
|
||||
}
|
||||
|
||||
Future<io.File> getThumbnailFromServer(File file) async {
|
||||
return ThumbnailCacheManager.instance
|
||||
.getFileFromCache(file.getThumbnailUrl())
|
||||
.then((info) {
|
||||
if (info == null) {
|
||||
if (!_map.containsKey(file.uploadedFileID)) {
|
||||
if (_queue.length == kMaximumConcurrentDownloads) {
|
||||
final id = _queue.removeFirst();
|
||||
final item = _map.remove(id);
|
||||
item.cancelToken.cancel();
|
||||
item.completer.completeError(RequestCancelledError());
|
||||
}
|
||||
final item =
|
||||
FileDownloadItem(file, Completer<io.File>(), CancelToken());
|
||||
_map[file.uploadedFileID] = item;
|
||||
_queue.add(file.uploadedFileID);
|
||||
_downloadItem(item);
|
||||
return item.completer.future;
|
||||
} else {
|
||||
return _map[file.uploadedFileID].completer.future;
|
||||
}
|
||||
} else {
|
||||
ThumbnailFileLruCache.put(file, info.file);
|
||||
return info.file;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void removePendingGetThumbnailRequestIfAny(File file) {
|
||||
if (_map.containsKey(file.uploadedFileID)) {
|
||||
final item = _map.remove(file.uploadedFileID);
|
||||
item.cancelToken.cancel();
|
||||
_queue.removeWhere((element) => element == file.uploadedFileID);
|
||||
}
|
||||
}
|
||||
|
||||
void _downloadItem(FileDownloadItem item) async {
|
||||
try {
|
||||
await _downloadAndDecryptThumbnail(item);
|
||||
} catch (e, s) {
|
||||
_logger.severe(
|
||||
"Failed to download thumbnail " + item.file.toString(), e, s);
|
||||
item.completer.completeError(e);
|
||||
}
|
||||
_queue.removeWhere((element) => element == item.file.uploadedFileID);
|
||||
_map.remove(item.file.uploadedFileID);
|
||||
}
|
||||
|
||||
Future<void> _downloadAndDecryptThumbnail(FileDownloadItem item) async {
|
||||
final file = item.file;
|
||||
var encryptedThumbnail;
|
||||
try {
|
||||
encryptedThumbnail = (await Network.instance.getDio().get(
|
||||
file.getThumbnailUrl(),
|
||||
options: Options(
|
||||
headers: {"X-Auth-Token": Configuration.instance.getToken()},
|
||||
responseType: ResponseType.bytes,
|
||||
),
|
||||
cancelToken: item.cancelToken,
|
||||
))
|
||||
.data;
|
||||
} catch (e) {
|
||||
if (e is DioError && CancelToken.isCancel(e)) {
|
||||
return;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (!_map.containsKey(file.uploadedFileID)) {
|
||||
return;
|
||||
}
|
||||
final thumbnailDecryptionKey = decryptFileKey(file);
|
||||
var data = CryptoUtil.decryptChaCha(
|
||||
encryptedThumbnail,
|
||||
thumbnailDecryptionKey,
|
||||
Sodium.base642bin(file.thumbnailDecryptionHeader),
|
||||
);
|
||||
final thumbnailSize = data.length;
|
||||
if (thumbnailSize > THUMBNAIL_DATA_LIMIT) {
|
||||
data = await compressThumbnail(data);
|
||||
}
|
||||
final cachedThumbnail = await ThumbnailCacheManager.instance.putFile(
|
||||
file.getThumbnailUrl(),
|
||||
data,
|
||||
eTag: file.getThumbnailUrl(),
|
||||
maxAge: Duration(days: 365),
|
||||
);
|
||||
ThumbnailFileLruCache.put(item.file, cachedThumbnail);
|
||||
if (_map.containsKey(file.uploadedFileID)) {
|
||||
try {
|
||||
item.completer.complete(cachedThumbnail);
|
||||
} catch (e) {
|
||||
_logger.severe("Error while completing request for " +
|
||||
file.uploadedFileID.toString());
|
||||
}
|
||||
}
|
||||
}
|
266
pubspec.lock
266
pubspec.lock
|
@ -14,14 +14,14 @@ packages:
|
|||
name: archive
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.13"
|
||||
version: "3.1.2"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: args
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
version: "2.0.0"
|
||||
async:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -35,7 +35,7 @@ packages:
|
|||
name: background_fetch
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.7.1"
|
||||
version: "0.7.2"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -49,7 +49,7 @@ packages:
|
|||
name: cached_network_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.3.3"
|
||||
version: "3.0.0"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -98,49 +98,56 @@ packages:
|
|||
name: connectivity
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
version: "3.0.3"
|
||||
connectivity_for_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_for_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.3.1+4"
|
||||
version: "0.4.0"
|
||||
connectivity_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0+7"
|
||||
version: "0.2.0"
|
||||
connectivity_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: connectivity_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.6"
|
||||
convert:
|
||||
version: "2.0.1"
|
||||
contact_picker_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: convert
|
||||
name: contact_picker_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
version: "4.4.0"
|
||||
contact_picker_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: contact_picker_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.4.0"
|
||||
crisp:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crisp
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
version: "0.1.4"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: crypto
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
version: "3.0.1"
|
||||
cupertino_icons:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -154,35 +161,21 @@ packages:
|
|||
name: device_info
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.2+10"
|
||||
device_info_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: device_info_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "0.4.2+6"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: dio
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
draggable_scrollbar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: draggable_scrollbar
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4"
|
||||
version: "4.0.0"
|
||||
event_bus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: event_bus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "2.0.0"
|
||||
expansion_card:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -203,7 +196,7 @@ packages:
|
|||
name: ffi
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.3"
|
||||
version: "1.0.0"
|
||||
file:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -222,21 +215,21 @@ packages:
|
|||
name: flutter_blurhash
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.0"
|
||||
version: "0.6.0"
|
||||
flutter_cache_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_cache_manager
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
version: "3.0.1"
|
||||
flutter_email_sender:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_email_sender
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "5.0.0"
|
||||
flutter_image_compress:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -250,70 +243,56 @@ packages:
|
|||
name: flutter_inappwebview
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.0+4"
|
||||
version: "5.3.2"
|
||||
flutter_keyboard_visibility:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
flutter_keyboard_visibility_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
flutter_keyboard_visibility_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_keyboard_visibility_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
version: "3.3.0"
|
||||
flutter_launcher_icons:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.8.0"
|
||||
version: "0.9.0"
|
||||
flutter_native_splash:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.9"
|
||||
version: "1.1.8+4"
|
||||
flutter_password_strength:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_password_strength
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.4"
|
||||
version: "0.1.6"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_plugin_android_lifecycle
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
version: "2.0.1"
|
||||
flutter_secure_storage:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_secure_storage
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.3.5"
|
||||
version: "4.2.0"
|
||||
flutter_sodium:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_sodium
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.10"
|
||||
version: "0.2.0"
|
||||
flutter_test:
|
||||
dependency: "direct dev"
|
||||
description: flutter
|
||||
|
@ -325,7 +304,7 @@ packages:
|
|||
name: flutter_typeahead
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.3"
|
||||
version: "1.8.8"
|
||||
flutter_user_agent:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -338,13 +317,6 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_webview_plugin:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: flutter_webview_plugin
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.3.11"
|
||||
flutter_windowmanager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -358,51 +330,42 @@ packages:
|
|||
name: fluttercontactpicker
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
version: "4.4.0"
|
||||
fluttertoast:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: fluttertoast
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "7.1.6"
|
||||
version: "8.0.6"
|
||||
google_nav_bar:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: google_nav_bar
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.0.2"
|
||||
version: "5.0.5"
|
||||
http:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.12.2"
|
||||
version: "0.13.2"
|
||||
http_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: http_parser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.4"
|
||||
huge_listview:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: HEAD
|
||||
resolved-ref: d3cfed2da2756a8894402e2ca523693a041c1e31
|
||||
url: "https://github.com/deakjahn/huge_listview.git"
|
||||
source: git
|
||||
version: "1.0.2"
|
||||
version: "4.0.0"
|
||||
image:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.19"
|
||||
version: "3.0.2"
|
||||
in_app_purchase:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -437,14 +400,14 @@ packages:
|
|||
name: like_button
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "2.0.2"
|
||||
local_auth:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: local_auth
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.0"
|
||||
version: "1.1.5"
|
||||
logging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -472,56 +435,56 @@ packages:
|
|||
name: mime
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.9.7"
|
||||
version: "1.0.0"
|
||||
octo_image:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: octo_image
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
version: "1.0.0+1"
|
||||
package_info_plus:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: package_info_plus
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.6.4"
|
||||
version: "1.0.1"
|
||||
package_info_plus_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.1"
|
||||
version: "1.0.1"
|
||||
package_info_plus_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "1.1.1"
|
||||
package_info_plus_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.3.0"
|
||||
version: "1.0.1"
|
||||
package_info_plus_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
version: "1.0.1"
|
||||
package_info_plus_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: package_info_plus_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "1.0.1"
|
||||
page_transition:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -542,49 +505,49 @@ packages:
|
|||
name: path_provider
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.6.27"
|
||||
version: "2.0.1"
|
||||
path_provider_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+2"
|
||||
version: "2.0.0"
|
||||
path_provider_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4+8"
|
||||
version: "2.0.0"
|
||||
path_provider_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "2.0.1"
|
||||
path_provider_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: path_provider_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.4+3"
|
||||
version: "2.0.1"
|
||||
pedantic:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pedantic
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.9.2"
|
||||
version: "1.11.0"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: petitparser
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.1.0"
|
||||
version: "4.1.0"
|
||||
photo_manager:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
|
@ -598,7 +561,7 @@ packages:
|
|||
name: photo_view
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.9.2"
|
||||
version: "0.11.1"
|
||||
platform:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -606,27 +569,13 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
platform_detect:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: platform_detect
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
plugin_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: plugin_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3"
|
||||
pretty_dio_logger:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: pretty_dio_logger
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
version: "2.0.0"
|
||||
process:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -641,90 +590,83 @@ packages:
|
|||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.2.0"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: pub_semver
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.4.4"
|
||||
quiver:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: quiver
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.1.5"
|
||||
version: "3.0.1"
|
||||
rxdart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: rxdart
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.24.1"
|
||||
version: "0.26.0"
|
||||
scrollable_positioned_list:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: scrollable_positioned_list
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.9"
|
||||
version: "0.1.10"
|
||||
sentry:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sentry
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.0.1"
|
||||
version: "5.0.0"
|
||||
share:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: share
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.6.5+4"
|
||||
version: "2.0.1"
|
||||
shared_preferences:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: shared_preferences
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.5.12+4"
|
||||
version: "2.0.5"
|
||||
shared_preferences_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.2+4"
|
||||
version: "2.0.0"
|
||||
shared_preferences_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+11"
|
||||
version: "2.0.0"
|
||||
shared_preferences_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
version: "2.0.0"
|
||||
shared_preferences_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.2+7"
|
||||
version: "2.0.0"
|
||||
shared_preferences_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: shared_preferences_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.2+2"
|
||||
version: "2.0.0"
|
||||
sky_engine:
|
||||
dependency: transitive
|
||||
description: flutter
|
||||
|
@ -743,21 +685,21 @@ packages:
|
|||
name: sqflite
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.3.1+2"
|
||||
version: "2.0.0+3"
|
||||
sqflite_common:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.3+1"
|
||||
version: "2.0.0+2"
|
||||
sqflite_migration:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_migration
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
version: "0.3.0"
|
||||
stack_trace:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -792,7 +734,7 @@ packages:
|
|||
name: synchronized
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.0+2"
|
||||
version: "3.0.0"
|
||||
term_glyph:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -820,63 +762,77 @@ packages:
|
|||
name: uni_links
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.4.0"
|
||||
version: "0.5.1"
|
||||
uni_links_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uni_links_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
uni_links_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uni_links_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.0"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: universal_io
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
url_launcher:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: url_launcher
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "5.7.10"
|
||||
version: "6.0.3"
|
||||
url_launcher_linux:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_linux
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+4"
|
||||
version: "2.0.0"
|
||||
url_launcher_macos:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_macos
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+9"
|
||||
version: "2.0.0"
|
||||
url_launcher_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_platform_interface
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
version: "2.0.2"
|
||||
url_launcher_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_web
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.5+1"
|
||||
version: "2.0.0"
|
||||
url_launcher_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: url_launcher_windows
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.0.1+3"
|
||||
usage:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: usage
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "3.4.2"
|
||||
version: "2.0.0"
|
||||
uuid:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: uuid
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.2"
|
||||
version: "3.0.4"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -911,7 +867,7 @@ packages:
|
|||
name: visibility_detector
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.5"
|
||||
version: "0.2.0"
|
||||
wakelock:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -946,28 +902,28 @@ packages:
|
|||
name: win32
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "1.7.4"
|
||||
version: "2.0.5"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xdg_directories
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "0.1.2"
|
||||
version: "0.2.0"
|
||||
xml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: xml
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "4.5.1"
|
||||
version: "5.1.0"
|
||||
yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: yaml
|
||||
url: "https://pub.dartlang.org"
|
||||
source: hosted
|
||||
version: "2.2.1"
|
||||
version: "3.1.0"
|
||||
sdks:
|
||||
dart: ">=2.12.0 <3.0.0"
|
||||
flutter: ">=2.0.0"
|
||||
|
|
69
pubspec.yaml
69
pubspec.yaml
|
@ -25,65 +25,62 @@ dependencies:
|
|||
cupertino_icons: ^1.0.0
|
||||
photo_manager: ^1.0.6
|
||||
provider: ^3.1.0
|
||||
sqflite: ^1.3.0
|
||||
sqflite_migration: ^0.2.0
|
||||
path_provider: ^1.6.5
|
||||
shared_preferences: ^0.5.6
|
||||
dio: ^3.0.9
|
||||
image: ^2.1.4
|
||||
share: ^0.6.5+4
|
||||
draggable_scrollbar: ^0.0.4
|
||||
photo_view: ^0.9.2
|
||||
visibility_detector: ^0.1.5
|
||||
event_bus: ^1.1.1
|
||||
sentry: ">=3.0.0 <4.0.0"
|
||||
sqflite: ^2.0.0+3
|
||||
sqflite_migration: ^0.3.0
|
||||
path_provider: ^2.0.1
|
||||
shared_preferences: ^2.0.5
|
||||
dio: ^4.0.0
|
||||
image: ^3.0.2
|
||||
share: ^2.0.1
|
||||
photo_view: ^0.11.1
|
||||
visibility_detector: ^0.2.0
|
||||
event_bus: ^2.0.0
|
||||
sentry: ^5.0.0
|
||||
super_logging:
|
||||
path: thirdparty/super_logging
|
||||
archive: ^2.0.11
|
||||
flutter_email_sender: ^3.0.1
|
||||
like_button: ^0.2.0
|
||||
archive: ^3.1.2
|
||||
flutter_email_sender: ^5.0.0
|
||||
like_button: ^2.0.2
|
||||
logging: ^0.11.4
|
||||
flutter_image_compress:
|
||||
path: thirdparty/flutter_image_compress
|
||||
flutter_typeahead: ^1.8.1
|
||||
fluttertoast: ^7.1.5
|
||||
fluttertoast: ^8.0.6
|
||||
video_player: ^2.0.0
|
||||
chewie: ^1.0.0
|
||||
cached_network_image: ^2.3.0-beta
|
||||
cached_network_image: ^3.0.0
|
||||
animate_do: ^1.7.2
|
||||
flutter_cache_manager: ^1.4.1
|
||||
flutter_cache_manager: ^3.0.1
|
||||
computer: ^1.0.2
|
||||
flutter_secure_storage: ^3.3.3
|
||||
uni_links: ^0.4.0
|
||||
flutter_secure_storage: ^4.2.0
|
||||
uni_links: ^0.5.1
|
||||
crisp: ^0.1.3
|
||||
flutter_sodium: ^0.1.8
|
||||
flutter_sodium: ^0.2.0
|
||||
pedantic: ^1.9.2
|
||||
page_transition: "^1.1.7+2"
|
||||
scrollable_positioned_list: ^0.1.8
|
||||
connectivity: ^2.0.1
|
||||
pretty_dio_logger: ^1.1.1
|
||||
url_launcher: ^5.7.10
|
||||
fluttercontactpicker: ^3.1.0
|
||||
scrollable_positioned_list: ^0.1.10
|
||||
connectivity: ^3.0.3
|
||||
url_launcher: ^6.0.3
|
||||
fluttercontactpicker: ^4.4.0
|
||||
in_app_purchase:
|
||||
path: thirdparty/in_app_purchase
|
||||
expansion_card: ^0.1.0
|
||||
flutter_password_strength: ^0.1.4
|
||||
flutter_inappwebview: ^4.0.0+4
|
||||
background_fetch: ^0.7.1
|
||||
flutter_password_strength: ^0.1.6
|
||||
flutter_inappwebview: ^5.3.2
|
||||
background_fetch: ^0.7.2
|
||||
# flutter_inapp_purchase: ^3.0.1
|
||||
google_nav_bar: ^4.0.2
|
||||
huge_listview:
|
||||
git: https://github.com/deakjahn/huge_listview.git
|
||||
package_info_plus: ^0.6.4
|
||||
local_auth: ^1.1.0
|
||||
google_nav_bar: ^5.0.5
|
||||
package_info_plus: ^1.0.1
|
||||
local_auth: ^1.1.5
|
||||
flutter_windowmanager: ^0.0.2
|
||||
flutter_user_agent: ^1.2.2
|
||||
quiver: ^3.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_launcher_icons: "0.8.0"
|
||||
flutter_native_splash: ^0.2.9
|
||||
flutter_launcher_icons: "0.9.0"
|
||||
flutter_native_splash: ^1.1.8+4
|
||||
|
||||
flutter_icons:
|
||||
android: true
|
||||
|
|
45
thirdparty/super_logging/lib/super_logging.dart
vendored
45
thirdparty/super_logging/lib/super_logging.dart
vendored
|
@ -12,8 +12,6 @@ import 'package:path/path.dart';
|
|||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:sentry/sentry.dart';
|
||||
|
||||
export 'package:sentry/sentry.dart' show User;
|
||||
|
||||
typedef FutureOr<void> FutureOrVoidCallback();
|
||||
|
||||
extension SuperString on String {
|
||||
|
@ -52,17 +50,6 @@ extension SuperLogRecord on LogRecord {
|
|||
|
||||
return msg;
|
||||
}
|
||||
|
||||
Event toEvent({String appVersion}) {
|
||||
return Event(
|
||||
release: appVersion,
|
||||
level: SeverityLevel.error,
|
||||
culprit: message,
|
||||
loggerName: loggerName,
|
||||
exception: error,
|
||||
stackTrace: stackTrace,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class LogConfig {
|
||||
|
@ -205,7 +192,7 @@ class SuperLogging {
|
|||
static void _sendErrorToSentry(Object error, StackTrace stack) {
|
||||
try {
|
||||
sentryClient.captureException(
|
||||
exception: error,
|
||||
error,
|
||||
stackTrace: stack,
|
||||
);
|
||||
$.info('Error sent to sentry.io: $error');
|
||||
|
@ -240,8 +227,7 @@ class SuperLogging {
|
|||
|
||||
// add error to sentry queue
|
||||
if (sentryIsEnabled && rec.error != null) {
|
||||
var event = rec.toEvent(appVersion: appVersion);
|
||||
sentryQueueControl.add(event);
|
||||
_sendErrorToSentry(rec.error, null);
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -254,33 +240,30 @@ class SuperLogging {
|
|||
}
|
||||
|
||||
/// A queue to be consumed by [setupSentry].
|
||||
static final sentryQueueControl = StreamController<Event>();
|
||||
static final sentryQueueControl = StreamController<Error>();
|
||||
|
||||
/// Whether sentry logging is currently enabled or not.
|
||||
static bool sentryIsEnabled;
|
||||
|
||||
static Future<void> setupSentry() async {
|
||||
sentryClient = SentryClient(dsn: config.sentryDsn);
|
||||
await for (final event in sentryQueueControl.stream) {
|
||||
dynamic error;
|
||||
sentryClient = SentryClient(SentryOptions(dsn: config.sentryDsn));
|
||||
await for (final error in sentryQueueControl.stream) {
|
||||
try {
|
||||
var response = await sentryClient.capture(event: event);
|
||||
error = response.error;
|
||||
sentryClient.captureException(
|
||||
error,
|
||||
);
|
||||
} catch (e) {
|
||||
error = e;
|
||||
$.fine(
|
||||
"sentry upload failed; will retry after ${config.sentryRetryDelay}",
|
||||
);
|
||||
doSentryRetry(error);
|
||||
}
|
||||
|
||||
if (error == null) continue;
|
||||
$.fine(
|
||||
"sentry upload failed; will retry after ${config.sentryRetryDelay} ($error)",
|
||||
);
|
||||
doSentryRetry(event);
|
||||
}
|
||||
}
|
||||
|
||||
static void doSentryRetry(Event event) async {
|
||||
static void doSentryRetry(Error error) async {
|
||||
await Future.delayed(config.sentryRetryDelay);
|
||||
sentryQueueControl.add(event);
|
||||
sentryQueueControl.add(error);
|
||||
}
|
||||
|
||||
/// The log file currently in use.
|
||||
|
|
6
thirdparty/super_logging/pubspec.yaml
vendored
6
thirdparty/super_logging/pubspec.yaml
vendored
|
@ -11,13 +11,13 @@ dependencies:
|
|||
flutter:
|
||||
sdk: flutter
|
||||
|
||||
package_info_plus: ^0.6.4
|
||||
package_info_plus: ^1.0.1
|
||||
device_info: ^0.4.1+4
|
||||
logging: ^0.11.4
|
||||
sentry: ^3.0.1
|
||||
sentry: ^5.0.0
|
||||
intl: ^0.17.0
|
||||
path: ^1.6.4
|
||||
path_provider: ^1.6.0
|
||||
path_provider: ^2.0.1
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
|
Loading…
Reference in a new issue