Merge pull request #10 from ente-io/butter_scroll

Smooth scroll
This commit is contained in:
Vishnu Mohandas 2021-05-04 04:30:55 +05:30 committed by GitHub
commit 98fc8cad30
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
54 changed files with 2348 additions and 2256 deletions

View file

@ -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
}
}

View file

@ -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

View file

@ -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,
),
);
}

View file

@ -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,
),
);
}

View file

@ -15,3 +15,5 @@ class UserCancelledUploadError extends Error {}
class LockAlreadyAcquiredError extends Error {}
class UnauthorizedError extends Error {}
class RequestCancelledError extends Error{}

View file

@ -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) {

View file

@ -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);
}

View 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,
}

View file

@ -0,0 +1,3 @@
import 'package:photos/events/event.dart';
class FirstImportSucceededEvent extends Event {}

View file

@ -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);
}

View file

@ -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() {

View file

@ -1,4 +1,4 @@
import 'package:sentry/sentry.dart';
import 'package:photos/events/event.dart';
class TabChangedEvent extends Event {
final int selectedIndex;

View file

@ -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

View file

@ -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();
}

View file

@ -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;
}
}

View file

@ -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);
}

View file

@ -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]);
}

View 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();

View file

@ -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,
});
}
}

View file

@ -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,
),
)
]),
);
}
}

View file

@ -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 {

View file

@ -33,3 +33,5 @@ RaisedButton button(
),
);
}
final emptyContainer = Container();

View file

@ -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]);

View file

@ -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: [

View file

@ -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,
),
),
);
}
}

View file

@ -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));
}
}

View file

@ -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();
},
));
}

View file

@ -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();

View 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),
));
}
}
}

View 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));
}
}

View 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,
),
);
}
}

View 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];
}
}

View 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,
),
),
);
}
}

View file

@ -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;

View file

@ -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);

View 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);
}
}
}
}

View file

@ -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,
),
);

View file

@ -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,
);
}
}

View file

@ -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;
},
),
);
},
);
}
}

View file

@ -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;

View file

@ -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;
});
}
}

View file

@ -66,7 +66,9 @@ class _VideoWidgetState extends State<VideoWidget> {
});
},
).then((file) {
_setVideoPlayerController(file: file);
if (file != null) {
_setVideoPlayerController(file: file);
}
});
}

View 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;

View file

@ -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;
}

View file

@ -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;
}

View 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();
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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());
}

View 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());
}
}
}

View file

@ -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"

View file

@ -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

View file

@ -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.

View file

@ -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: