albums: distinct naming improvements & tests, localized common albums
This commit is contained in:
parent
0464bd8678
commit
0db76a46de
34 changed files with 241 additions and 108 deletions
|
@ -4,6 +4,7 @@ All notable changes to this project will be documented in this file.
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
- Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
|
- Collection / Albums / Countries / Tags: added label when dragging scrollbar thumb
|
||||||
|
- Albums: localized common album names
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
- Upgraded Flutter to beta v2.1.0-12.2.pre
|
- Upgraded Flutter to beta v2.1.0-12.2.pre
|
||||||
|
|
|
@ -6,7 +6,6 @@ import android.net.Uri
|
||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import android.os.storage.StorageManager
|
import android.os.storage.StorageManager
|
||||||
import android.util.Log
|
|
||||||
import androidx.core.os.EnvironmentCompat
|
import androidx.core.os.EnvironmentCompat
|
||||||
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
|
||||||
import deckers.thibault.aves.utils.PermissionManager
|
import deckers.thibault.aves.utils.PermissionManager
|
||||||
|
@ -150,7 +149,6 @@ class StorageHandler(private val context: Context) : MethodCallHandler {
|
||||||
try {
|
try {
|
||||||
val dir = File(it)
|
val dir = File(it)
|
||||||
if (dir.isDirectory && dir.listFiles()?.isEmpty() == true && dir.delete()) {
|
if (dir.isDirectory && dir.listFiles()?.isEmpty() == true && dir.delete()) {
|
||||||
Log.d("TLAD", "deleted empty directory=$dir")
|
|
||||||
deleted++
|
deleted++
|
||||||
}
|
}
|
||||||
} catch (e: SecurityException) {
|
} catch (e: SecurityException) {
|
||||||
|
|
|
@ -450,6 +450,15 @@
|
||||||
"albumPickPageTitleMove": "Move to Album",
|
"albumPickPageTitleMove": "Move to Album",
|
||||||
"@albumPickPageTitleMove": {},
|
"@albumPickPageTitleMove": {},
|
||||||
|
|
||||||
|
"albumCamera": "Camera",
|
||||||
|
"@albumCamera": {},
|
||||||
|
"albumDownload": "Download",
|
||||||
|
"@albumDownload": {},
|
||||||
|
"albumScreenshots": "Screenshots",
|
||||||
|
"@albumScreenshots": {},
|
||||||
|
"albumScreenRecordings": "Screen recordings",
|
||||||
|
"@albumScreenRecordings": {},
|
||||||
|
|
||||||
"albumPageTitle": "Albums",
|
"albumPageTitle": "Albums",
|
||||||
"@albumPageTitle": {},
|
"@albumPageTitle": {},
|
||||||
"albumEmpty": "No albums",
|
"albumEmpty": "No albums",
|
||||||
|
|
|
@ -201,6 +201,11 @@
|
||||||
"albumPickPageTitleExport": "앨범으로 내보내기",
|
"albumPickPageTitleExport": "앨범으로 내보내기",
|
||||||
"albumPickPageTitleMove": "앨범으로 이동",
|
"albumPickPageTitleMove": "앨범으로 이동",
|
||||||
|
|
||||||
|
"albumCamera": "카메라",
|
||||||
|
"albumDownload": "다운로드",
|
||||||
|
"albumScreenshots": "스크린샷",
|
||||||
|
"albumScreenRecordings": "화면 녹화 파일",
|
||||||
|
|
||||||
"albumPageTitle": "앨범",
|
"albumPageTitle": "앨범",
|
||||||
"albumEmpty": "앨범이 없습니다",
|
"albumEmpty": "앨범이 없습니다",
|
||||||
"createAlbumTooltip": "새 앨범 만들기",
|
"createAlbumTooltip": "새 앨범 만들기",
|
||||||
|
|
|
@ -18,7 +18,6 @@ import 'package:country_code/country_code.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:latlong/latlong.dart';
|
import 'package:latlong/latlong.dart';
|
||||||
import 'package:path/path.dart' as ppath;
|
|
||||||
|
|
||||||
import '../ref/mime_types.dart';
|
import '../ref/mime_types.dart';
|
||||||
|
|
||||||
|
@ -186,17 +185,17 @@ class AvesEntry {
|
||||||
String get path => _path;
|
String get path => _path;
|
||||||
|
|
||||||
String get directory {
|
String get directory {
|
||||||
_directory ??= path != null ? ppath.dirname(path) : null;
|
_directory ??= path != null ? pContext.dirname(path) : null;
|
||||||
return _directory;
|
return _directory;
|
||||||
}
|
}
|
||||||
|
|
||||||
String get filenameWithoutExtension {
|
String get filenameWithoutExtension {
|
||||||
_filename ??= path != null ? ppath.basenameWithoutExtension(path) : null;
|
_filename ??= path != null ? pContext.basenameWithoutExtension(path) : null;
|
||||||
return _filename;
|
return _filename;
|
||||||
}
|
}
|
||||||
|
|
||||||
String get extension {
|
String get extension {
|
||||||
_extension ??= path != null ? ppath.extension(path) : null;
|
_extension ??= path != null ? pContext.extension(path) : null;
|
||||||
return _extension;
|
return _extension;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
import 'package:aves/image_providers/app_icon_image_provider.dart';
|
||||||
import 'package:aves/model/filters/filters.dart';
|
import 'package:aves/model/filters/filters.dart';
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
import 'package:aves/widgets/common/identity/aves_icons.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:palette_generator/palette_generator.dart';
|
import 'package:palette_generator/palette_generator.dart';
|
||||||
import 'package:path/path.dart';
|
|
||||||
|
|
||||||
class AlbumFilter extends CollectionFilter {
|
class AlbumFilter extends CollectionFilter {
|
||||||
static const type = 'album';
|
static const type = 'album';
|
||||||
|
@ -14,9 +14,9 @@ class AlbumFilter extends CollectionFilter {
|
||||||
static final Map<String, Color> _appColors = {};
|
static final Map<String, Color> _appColors = {};
|
||||||
|
|
||||||
final String album;
|
final String album;
|
||||||
final String uniqueName;
|
final String displayName;
|
||||||
|
|
||||||
const AlbumFilter(this.album, this.uniqueName);
|
const AlbumFilter(this.album, this.displayName);
|
||||||
|
|
||||||
AlbumFilter.fromMap(Map<String, dynamic> json)
|
AlbumFilter.fromMap(Map<String, dynamic> json)
|
||||||
: this(
|
: this(
|
||||||
|
@ -28,14 +28,14 @@ class AlbumFilter extends CollectionFilter {
|
||||||
Map<String, dynamic> toMap() => {
|
Map<String, dynamic> toMap() => {
|
||||||
'type': type,
|
'type': type,
|
||||||
'album': album,
|
'album': album,
|
||||||
'uniqueName': uniqueName,
|
'uniqueName': displayName,
|
||||||
};
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
EntryFilter get test => (entry) => entry.directory == album;
|
EntryFilter get test => (entry) => entry.directory == album;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String get universalLabel => uniqueName ?? album.split(separator).last;
|
String get universalLabel => displayName ?? pContext.split(album).last;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String getTooltip(BuildContext context) => album;
|
String getTooltip(BuildContext context) => album;
|
||||||
|
|
|
@ -5,8 +5,8 @@ import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/favourites.dart';
|
import 'package:aves/model/favourites.dart';
|
||||||
import 'package:aves/model/metadata.dart';
|
import 'package:aves/model/metadata.dart';
|
||||||
import 'package:aves/model/metadata_db_upgrade.dart';
|
import 'package:aves/model/metadata_db_upgrade.dart';
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:path/path.dart';
|
|
||||||
import 'package:sqflite/sqflite.dart';
|
import 'package:sqflite/sqflite.dart';
|
||||||
|
|
||||||
abstract class MetadataDb {
|
abstract class MetadataDb {
|
||||||
|
@ -82,7 +82,7 @@ abstract class MetadataDb {
|
||||||
class SqfliteMetadataDb implements MetadataDb {
|
class SqfliteMetadataDb implements MetadataDb {
|
||||||
Future<Database> _database;
|
Future<Database> _database;
|
||||||
|
|
||||||
Future<String> get path async => join(await getDatabasesPath(), 'metadata.db');
|
Future<String> get path async => pContext.join(await getDatabasesPath(), 'metadata.db');
|
||||||
|
|
||||||
static const entryTable = 'entry';
|
static const entryTable = 'entry';
|
||||||
static const dateTakenTable = 'dateTaken';
|
static const dateTakenTable = 'dateTaken';
|
||||||
|
|
|
@ -2,10 +2,11 @@ import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/model/filters/album.dart';
|
import 'package:aves/model/filters/album.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:path/path.dart';
|
|
||||||
|
|
||||||
mixin AlbumMixin on SourceBase {
|
mixin AlbumMixin on SourceBase {
|
||||||
final Set<String> _directories = {};
|
final Set<String> _directories = {};
|
||||||
|
@ -13,8 +14,8 @@ mixin AlbumMixin on SourceBase {
|
||||||
List<String> get rawAlbums => List.unmodifiable(_directories);
|
List<String> get rawAlbums => List.unmodifiable(_directories);
|
||||||
|
|
||||||
int compareAlbumsByName(String a, String b) {
|
int compareAlbumsByName(String a, String b) {
|
||||||
final ua = getUniqueAlbumName(null, a);
|
final ua = getAlbumDisplayName(null, a);
|
||||||
final ub = getUniqueAlbumName(null, b);
|
final ub = getAlbumDisplayName(null, b);
|
||||||
final c = compareAsciiUpperCase(ua, ub);
|
final c = compareAsciiUpperCase(ua, ub);
|
||||||
if (c != 0) return c;
|
if (c != 0) return c;
|
||||||
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
|
final va = androidFileUtils.getStorageVolume(a)?.path ?? '';
|
||||||
|
@ -24,30 +25,45 @@ mixin AlbumMixin on SourceBase {
|
||||||
|
|
||||||
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
void _notifyAlbumChange() => eventBus.fire(AlbumsChangedEvent());
|
||||||
|
|
||||||
String getUniqueAlbumName(BuildContext context, String dirPath) {
|
String getAlbumDisplayName(BuildContext context, String dirPath) {
|
||||||
String unique(String dirPath, [bool Function(String) test]) {
|
assert(!dirPath.endsWith(pContext.separator));
|
||||||
final otherAlbums = _directories.where(test ?? (_) => true).where((item) => item != dirPath);
|
|
||||||
final parts = dirPath.split(separator);
|
if (context != null) {
|
||||||
var partCount = 0;
|
final type = androidFileUtils.getAlbumType(dirPath);
|
||||||
String testName;
|
if (type == AlbumType.camera) return context.l10n.albumCamera;
|
||||||
do {
|
if (type == AlbumType.download) return context.l10n.albumDownload;
|
||||||
testName = separator + parts.skip(parts.length - ++partCount).join(separator);
|
if (type == AlbumType.screenshots) return context.l10n.albumScreenshots;
|
||||||
} while (otherAlbums.any((item) => item.endsWith(testName)));
|
if (type == AlbumType.screenRecordings) return context.l10n.albumScreenRecordings;
|
||||||
final uniqueName = parts.skip(parts.length - partCount).join(separator);
|
|
||||||
return uniqueName;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
final dir = VolumeRelativeDirectory.fromPath(dirPath);
|
||||||
if (dir == null) return dirPath;
|
if (dir == null) return dirPath;
|
||||||
|
|
||||||
final uniqueNameInDevice = unique(dirPath);
|
|
||||||
final relativeDir = dir.relativeDir;
|
final relativeDir = dir.relativeDir;
|
||||||
if (relativeDir.isEmpty) return uniqueNameInDevice;
|
if (relativeDir.isEmpty) {
|
||||||
|
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||||
|
return volume.getDescription(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
String unique(String dirPath, Set<String> others) {
|
||||||
|
final parts = pContext.split(dirPath);
|
||||||
|
for (var i = parts.length - 1; i > 0; i--) {
|
||||||
|
final testName = pContext.joinAll(['', ...parts.skip(i)]);
|
||||||
|
if (others.every((item) => !item.endsWith(testName))) return testName;
|
||||||
|
}
|
||||||
|
return dirPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
final otherAlbumsOnDevice = _directories.where((item) => item != dirPath).toSet();
|
||||||
|
final uniqueNameInDevice = unique(dirPath, otherAlbumsOnDevice);
|
||||||
if (uniqueNameInDevice.length < relativeDir.length) {
|
if (uniqueNameInDevice.length < relativeDir.length) {
|
||||||
return uniqueNameInDevice;
|
return uniqueNameInDevice;
|
||||||
} else {
|
}
|
||||||
final uniqueNameInVolume = unique(dirPath, (item) => item.startsWith(dir.volumePath));
|
|
||||||
|
final volumePath = dir.volumePath;
|
||||||
|
String trimVolumePath(String path) => path.substring(dir.volumePath.length);
|
||||||
|
final otherAlbumsOnVolume = otherAlbumsOnDevice.where((path) => path.startsWith(volumePath)).map(trimVolumePath).toSet();
|
||||||
|
final uniqueNameInVolume = unique(trimVolumePath(dirPath), otherAlbumsOnVolume);
|
||||||
final volume = androidFileUtils.getStorageVolume(dirPath);
|
final volume = androidFileUtils.getStorageVolume(dirPath);
|
||||||
if (volume.isPrimary) {
|
if (volume.isPrimary) {
|
||||||
return uniqueNameInVolume;
|
return uniqueNameInVolume;
|
||||||
|
@ -55,7 +71,6 @@ mixin AlbumMixin on SourceBase {
|
||||||
return '$uniqueNameInVolume (${volume.getDescription(context)})';
|
return '$uniqueNameInVolume (${volume.getDescription(context)})';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Map<String, AvesEntry> getAlbumEntries() {
|
Map<String, AvesEntry> getAlbumEntries() {
|
||||||
final entries = sortedEntriesByDate;
|
final entries = sortedEntriesByDate;
|
||||||
|
|
|
@ -3,25 +3,31 @@ import 'package:aves/model/metadata_db.dart';
|
||||||
import 'package:aves/services/image_file_service.dart';
|
import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/services/media_store_service.dart';
|
import 'package:aves/services/media_store_service.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
|
import 'package:aves/services/storage_service.dart';
|
||||||
import 'package:aves/services/time_service.dart';
|
import 'package:aves/services/time_service.dart';
|
||||||
import 'package:get_it/get_it.dart';
|
import 'package:get_it/get_it.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
final getIt = GetIt.instance;
|
final getIt = GetIt.instance;
|
||||||
|
|
||||||
|
final pContext = getIt<p.Context>();
|
||||||
final availability = getIt<AvesAvailability>();
|
final availability = getIt<AvesAvailability>();
|
||||||
final metadataDb = getIt<MetadataDb>();
|
final metadataDb = getIt<MetadataDb>();
|
||||||
|
|
||||||
final imageFileService = getIt<ImageFileService>();
|
final imageFileService = getIt<ImageFileService>();
|
||||||
final mediaStoreService = getIt<MediaStoreService>();
|
final mediaStoreService = getIt<MediaStoreService>();
|
||||||
final metadataService = getIt<MetadataService>();
|
final metadataService = getIt<MetadataService>();
|
||||||
|
final storageService = getIt<StorageService>();
|
||||||
final timeService = getIt<TimeService>();
|
final timeService = getIt<TimeService>();
|
||||||
|
|
||||||
void initPlatformServices() {
|
void initPlatformServices() {
|
||||||
|
getIt.registerLazySingleton<p.Context>(() => p.Context());
|
||||||
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
|
getIt.registerLazySingleton<AvesAvailability>(() => LiveAvesAvailability());
|
||||||
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
|
getIt.registerLazySingleton<MetadataDb>(() => SqfliteMetadataDb());
|
||||||
|
|
||||||
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
|
getIt.registerLazySingleton<ImageFileService>(() => PlatformImageFileService());
|
||||||
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
|
getIt.registerLazySingleton<MediaStoreService>(() => PlatformMediaStoreService());
|
||||||
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
|
getIt.registerLazySingleton<MetadataService>(() => PlatformMetadataService());
|
||||||
|
getIt.registerLazySingleton<StorageService>(() => PlatformStorageService());
|
||||||
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
|
getIt.registerLazySingleton<TimeService>(() => PlatformTimeService());
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,11 +5,35 @@ import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:streams_channel/streams_channel.dart';
|
import 'package:streams_channel/streams_channel.dart';
|
||||||
|
|
||||||
class StorageService {
|
abstract class StorageService {
|
||||||
|
Future<Set<StorageVolume>> getStorageVolumes();
|
||||||
|
|
||||||
|
Future<int> getFreeSpace(StorageVolume volume);
|
||||||
|
|
||||||
|
Future<List<String>> getGrantedDirectories();
|
||||||
|
|
||||||
|
Future<void> revokeDirectoryAccess(String path);
|
||||||
|
|
||||||
|
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths);
|
||||||
|
|
||||||
|
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories();
|
||||||
|
|
||||||
|
// returns whether user granted access to volume root at `volumePath`
|
||||||
|
Future<bool> requestVolumeAccess(String volumePath);
|
||||||
|
|
||||||
|
// returns number of deleted directories
|
||||||
|
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths);
|
||||||
|
|
||||||
|
// returns media URI
|
||||||
|
Future<Uri> scanFile(String path, String mimeType);
|
||||||
|
}
|
||||||
|
|
||||||
|
class PlatformStorageService implements StorageService {
|
||||||
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
static const platform = MethodChannel('deckers.thibault/aves/storage');
|
||||||
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
|
static final StreamsChannel storageAccessChannel = StreamsChannel('deckers.thibault/aves/storageaccessstream');
|
||||||
|
|
||||||
static Future<Set<StorageVolume>> getStorageVolumes() async {
|
@override
|
||||||
|
Future<Set<StorageVolume>> getStorageVolumes() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getStorageVolumes');
|
final result = await platform.invokeMethod('getStorageVolumes');
|
||||||
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
|
return (result as List).cast<Map>().map((map) => StorageVolume.fromMap(map)).toSet();
|
||||||
|
@ -19,7 +43,8 @@ class StorageService {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<int> getFreeSpace(StorageVolume volume) async {
|
@override
|
||||||
|
Future<int> getFreeSpace(StorageVolume volume) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{
|
final result = await platform.invokeMethod('getFreeSpace', <String, dynamic>{
|
||||||
'path': volume.path,
|
'path': volume.path,
|
||||||
|
@ -31,7 +56,8 @@ class StorageService {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<List<String>> getGrantedDirectories() async {
|
@override
|
||||||
|
Future<List<String>> getGrantedDirectories() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getGrantedDirectories');
|
final result = await platform.invokeMethod('getGrantedDirectories');
|
||||||
return (result as List).cast<String>();
|
return (result as List).cast<String>();
|
||||||
|
@ -41,7 +67,8 @@ class StorageService {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<void> revokeDirectoryAccess(String path) async {
|
@override
|
||||||
|
Future<void> revokeDirectoryAccess(String path) async {
|
||||||
try {
|
try {
|
||||||
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
|
await platform.invokeMethod('revokeDirectoryAccess', <String, dynamic>{
|
||||||
'path': path,
|
'path': path,
|
||||||
|
@ -52,7 +79,8 @@ class StorageService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
@override
|
||||||
|
Future<Set<VolumeRelativeDirectory>> getInaccessibleDirectories(Iterable<String> dirPaths) async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
final result = await platform.invokeMethod('getInaccessibleDirectories', <String, dynamic>{
|
||||||
'dirPaths': dirPaths.toList(),
|
'dirPaths': dirPaths.toList(),
|
||||||
|
@ -64,7 +92,8 @@ class StorageService {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
|
@override
|
||||||
|
Future<Set<VolumeRelativeDirectory>> getRestrictedDirectories() async {
|
||||||
try {
|
try {
|
||||||
final result = await platform.invokeMethod('getRestrictedDirectories');
|
final result = await platform.invokeMethod('getRestrictedDirectories');
|
||||||
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
|
return (result as List).cast<Map>().map((map) => VolumeRelativeDirectory.fromMap(map)).toSet();
|
||||||
|
@ -75,7 +104,8 @@ class StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns whether user granted access to volume root at `volumePath`
|
// returns whether user granted access to volume root at `volumePath`
|
||||||
static Future<bool> requestVolumeAccess(String volumePath) async {
|
@override
|
||||||
|
Future<bool> requestVolumeAccess(String volumePath) async {
|
||||||
try {
|
try {
|
||||||
final completer = Completer<bool>();
|
final completer = Completer<bool>();
|
||||||
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
storageAccessChannel.receiveBroadcastStream(<String, dynamic>{
|
||||||
|
@ -96,7 +126,8 @@ class StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns number of deleted directories
|
// returns number of deleted directories
|
||||||
static Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
|
@override
|
||||||
|
Future<int> deleteEmptyDirectories(Iterable<String> dirPaths) async {
|
||||||
try {
|
try {
|
||||||
return await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
|
return await platform.invokeMethod('deleteEmptyDirectories', <String, dynamic>{
|
||||||
'dirPaths': dirPaths.toList(),
|
'dirPaths': dirPaths.toList(),
|
||||||
|
@ -108,7 +139,8 @@ class StorageService {
|
||||||
}
|
}
|
||||||
|
|
||||||
// returns media URI
|
// returns media URI
|
||||||
static Future<Uri> scanFile(String path, String mimeType) async {
|
@override
|
||||||
|
Future<Uri> scanFile(String path, String mimeType) async {
|
||||||
debugPrint('scanFile with path=$path, mimeType=$mimeType');
|
debugPrint('scanFile with path=$path, mimeType=$mimeType');
|
||||||
try {
|
try {
|
||||||
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{
|
final uriString = await platform.invokeMethod('scanFile', <String, dynamic>{
|
||||||
|
|
|
@ -1,10 +1,9 @@
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/storage_service.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/change_notifier.dart';
|
import 'package:aves/utils/change_notifier.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:path/path.dart';
|
|
||||||
|
|
||||||
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
final AndroidFileUtils androidFileUtils = AndroidFileUtils._private();
|
||||||
|
|
||||||
|
@ -21,13 +20,13 @@ class AndroidFileUtils {
|
||||||
AndroidFileUtils._private();
|
AndroidFileUtils._private();
|
||||||
|
|
||||||
Future<void> init() async {
|
Future<void> init() async {
|
||||||
storageVolumes = await StorageService.getStorageVolumes();
|
storageVolumes = await storageService.getStorageVolumes();
|
||||||
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
// path_provider getExternalStorageDirectory() gives '/storage/emulated/0/Android/data/deckers.thibault.aves/files'
|
||||||
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
primaryStorage = storageVolumes.firstWhere((volume) => volume.isPrimary).path;
|
||||||
dcimPath = join(primaryStorage, 'DCIM');
|
dcimPath = pContext.join(primaryStorage, 'DCIM');
|
||||||
downloadPath = join(primaryStorage, 'Download');
|
downloadPath = pContext.join(primaryStorage, 'Download');
|
||||||
moviesPath = join(primaryStorage, 'Movies');
|
moviesPath = pContext.join(primaryStorage, 'Movies');
|
||||||
picturesPath = join(primaryStorage, 'Pictures');
|
picturesPath = pContext.join(primaryStorage, 'Pictures');
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> initAppNames() async {
|
Future<void> initAppNames() async {
|
||||||
|
@ -60,7 +59,7 @@ class AndroidFileUtils {
|
||||||
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
if (isScreenRecordingsPath(albumPath)) return AlbumType.screenRecordings;
|
||||||
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
if (isScreenshotsPath(albumPath)) return AlbumType.screenshots;
|
||||||
|
|
||||||
final dir = albumPath.split(separator).last;
|
final dir = pContext.split(albumPath).last;
|
||||||
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
if (albumPath.startsWith(primaryStorage) && _potentialAppDirs.contains(dir)) return AlbumType.app;
|
||||||
}
|
}
|
||||||
return AlbumType.regular;
|
return AlbumType.regular;
|
||||||
|
@ -68,7 +67,7 @@ class AndroidFileUtils {
|
||||||
|
|
||||||
String getAlbumAppPackageName(String albumPath) {
|
String getAlbumAppPackageName(String albumPath) {
|
||||||
if (albumPath == null) return null;
|
if (albumPath == null) return null;
|
||||||
final dir = albumPath.split(separator).last;
|
final dir = pContext.split(albumPath).last;
|
||||||
final package = _launcherPackages.firstWhere((package) => package.potentialDirs.contains(dir), orElse: () => null);
|
final package = _launcherPackages.firstWhere((package) => package.potentialDirs.contains(dir), orElse: () => null);
|
||||||
return package?.packageName;
|
return package?.packageName;
|
||||||
}
|
}
|
||||||
|
|
|
@ -28,7 +28,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
||||||
case EntryGroupFactor.album:
|
case EntryGroupFactor.album:
|
||||||
return [
|
return [
|
||||||
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
|
DraggableThumbLabel.formatMonthThumbLabel(context, entry.bestDate),
|
||||||
if (_hasMultipleSections(context)) context.read<CollectionSource>().getUniqueAlbumName(context, entry.directory),
|
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
|
||||||
];
|
];
|
||||||
case EntryGroupFactor.month:
|
case EntryGroupFactor.month:
|
||||||
case EntryGroupFactor.none:
|
case EntryGroupFactor.none:
|
||||||
|
@ -43,7 +43,7 @@ class CollectionDraggableThumbLabel extends StatelessWidget {
|
||||||
break;
|
break;
|
||||||
case EntrySortFactor.name:
|
case EntrySortFactor.name:
|
||||||
return [
|
return [
|
||||||
if (_hasMultipleSections(context)) context.read<CollectionSource>().getUniqueAlbumName(context, entry.directory),
|
if (_hasMultipleSections(context)) context.read<CollectionSource>().getAlbumDisplayName(context, entry.directory),
|
||||||
entry.bestTitle,
|
entry.bestTitle,
|
||||||
];
|
];
|
||||||
case EntrySortFactor.size:
|
case EntrySortFactor.size:
|
||||||
|
|
|
@ -9,7 +9,6 @@ import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/android_app_service.dart';
|
import 'package:aves/services/android_app_service.dart';
|
||||||
import 'package:aves/services/image_op_events.dart';
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/services/storage_service.dart';
|
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
|
@ -69,7 +68,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
if (moveType == MoveType.move) {
|
if (moveType == MoveType.move) {
|
||||||
// check whether moving is possible given OS restrictions,
|
// check whether moving is possible given OS restrictions,
|
||||||
// before asking to pick a destination album
|
// before asking to pick a destination album
|
||||||
final restrictedDirs = await StorageService.getRestrictedDirectories();
|
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||||
for (final selectionDir in selectionDirs) {
|
for (final selectionDir in selectionDirs) {
|
||||||
final dir = VolumeRelativeDirectory.fromPath(selectionDir);
|
final dir = VolumeRelativeDirectory.fromPath(selectionDir);
|
||||||
if (restrictedDirs.contains(dir)) {
|
if (restrictedDirs.contains(dir)) {
|
||||||
|
@ -127,7 +126,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
if (moveType == MoveType.move) {
|
if (moveType == MoveType.move) {
|
||||||
await StorageService.deleteEmptyDirectories(selectionDirs);
|
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
@ -178,7 +177,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
await StorageService.deleteEmptyDirectories(selectionDirs);
|
await storageService.deleteEmptyDirectories(selectionDirs);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -46,7 +46,7 @@ class AlbumSectionHeader extends StatelessWidget {
|
||||||
return SectionHeader.getPreferredHeight(
|
return SectionHeader.getPreferredHeight(
|
||||||
context: context,
|
context: context,
|
||||||
maxWidth: maxWidth,
|
maxWidth: maxWidth,
|
||||||
title: source.getUniqueAlbumName(context, directory),
|
title: source.getAlbumDisplayName(context, directory),
|
||||||
hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular,
|
hasLeading: androidFileUtils.getAlbumType(directory) != AlbumType.regular,
|
||||||
hasTrailing: androidFileUtils.isOnRemovableStorage(directory),
|
hasTrailing: androidFileUtils.isOnRemovableStorage(directory),
|
||||||
);
|
);
|
||||||
|
|
|
@ -60,7 +60,7 @@ class CollectionSectionHeader extends StatelessWidget {
|
||||||
return AlbumSectionHeader(
|
return AlbumSectionHeader(
|
||||||
key: ValueKey(sectionKey),
|
key: ValueKey(sectionKey),
|
||||||
directory: directory,
|
directory: directory,
|
||||||
albumName: source.getUniqueAlbumName(context, directory),
|
albumName: source.getAlbumDisplayName(context, directory),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/services/storage_service.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
import 'package:aves/widgets/dialogs/aves_dialog.dart';
|
||||||
|
@ -11,9 +11,9 @@ mixin PermissionAwareMixin {
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
Future<bool> checkStoragePermissionForAlbums(BuildContext context, Set<String> albumPaths) async {
|
||||||
final restrictedDirs = await StorageService.getRestrictedDirectories();
|
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||||
while (true) {
|
while (true) {
|
||||||
final dirs = await StorageService.getInaccessibleDirectories(albumPaths);
|
final dirs = await storageService.getInaccessibleDirectories(albumPaths);
|
||||||
if (dirs == null) return false;
|
if (dirs == null) return false;
|
||||||
if (dirs.isEmpty) return true;
|
if (dirs.isEmpty) return true;
|
||||||
|
|
||||||
|
@ -49,7 +49,7 @@ mixin PermissionAwareMixin {
|
||||||
// abort if the user cancels in Flutter
|
// abort if the user cancels in Flutter
|
||||||
if (confirmed == null || !confirmed) return false;
|
if (confirmed == null || !confirmed) return false;
|
||||||
|
|
||||||
final granted = await StorageService.requestVolumeAccess(dir.volumePath);
|
final granted = await storageService.requestVolumeAccess(dir.volumePath);
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
// abort if the user denies access from the native dialog
|
// abort if the user denies access from the native dialog
|
||||||
return false;
|
return false;
|
||||||
|
|
|
@ -3,7 +3,7 @@ import 'dart:math';
|
||||||
|
|
||||||
import 'package:aves/model/actions/move_type.dart';
|
import 'package:aves/model/actions/move_type.dart';
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
import 'package:aves/services/storage_service.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
|
@ -20,7 +20,7 @@ mixin SizeAwareMixin {
|
||||||
MoveType moveType,
|
MoveType moveType,
|
||||||
) async {
|
) async {
|
||||||
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
final destinationVolume = androidFileUtils.getStorageVolume(destinationAlbum);
|
||||||
final free = await StorageService.getFreeSpace(destinationVolume);
|
final free = await storageService.getFreeSpace(destinationVolume);
|
||||||
int needed;
|
int needed;
|
||||||
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
int sumSize(sum, entry) => sum + entry.sizeBytes;
|
||||||
switch (moveType) {
|
switch (moveType) {
|
||||||
|
|
|
@ -17,7 +17,7 @@ class DraggableThumbLabel<T> extends StatelessWidget {
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final sll = context.read<SectionedListLayout<T>>();
|
final sll = context.read<SectionedListLayout<T>>();
|
||||||
final sectionLayout = sll.getSectionAt(offsetY);
|
final sectionLayout = sll.getSectionAt(offsetY);
|
||||||
if (sectionLayout == null) return null;
|
if (sectionLayout == null) return SizedBox();
|
||||||
|
|
||||||
final section = sll.sections[sectionLayout.sectionKey];
|
final section = sll.sections[sectionLayout.sectionKey];
|
||||||
final dy = offsetY - (sectionLayout.minOffset + sectionLayout.headerExtent);
|
final dy = offsetY - (sectionLayout.minOffset + sectionLayout.headerExtent);
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:aves/services/storage_service.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/utils/file_utils.dart';
|
import 'package:aves/utils/file_utils.dart';
|
||||||
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
import 'package:aves/widgets/common/identity/aves_expansion_tile.dart';
|
||||||
|
@ -17,7 +17,7 @@ class _DebugStorageSectionState extends State<DebugStorageSection> with Automati
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
androidFileUtils.storageVolumes.forEach((volume) async {
|
androidFileUtils.storageVolumes.forEach((volume) async {
|
||||||
final byteCount = await StorageService.getFreeSpace(volume);
|
final byteCount = await storageService.getFreeSpace(volume);
|
||||||
setState(() => _freeSpaceByVolume[volume.path] = byteCount);
|
setState(() => _freeSpaceByVolume[volume.path] = byteCount);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/theme/durations.dart';
|
import 'package:aves/theme/durations.dart';
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:path/path.dart';
|
|
||||||
|
|
||||||
import 'aves_dialog.dart';
|
import 'aves_dialog.dart';
|
||||||
|
|
||||||
|
@ -143,7 +143,7 @@ class _CreateAlbumDialogState extends State<CreateAlbumDialog> {
|
||||||
|
|
||||||
String _buildAlbumPath(String name) {
|
String _buildAlbumPath(String name) {
|
||||||
if (name == null || name.isEmpty) return '';
|
if (name == null || name.isEmpty) return '';
|
||||||
return join(_selectedVolume.path, 'Pictures', name);
|
return pContext.join(_selectedVolume.path, 'Pictures', name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _validate() async {
|
Future<void> _validate() async {
|
||||||
|
|
|
@ -1,8 +1,8 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart' as path;
|
|
||||||
|
|
||||||
import '../dialogs/aves_dialog.dart';
|
import '../dialogs/aves_dialog.dart';
|
||||||
|
|
||||||
|
@ -22,7 +22,7 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
||||||
|
|
||||||
String get album => widget.album;
|
String get album => widget.album;
|
||||||
|
|
||||||
String get initialValue => path.basename(album);
|
String get initialValue => pContext.basename(album);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
|
@ -75,7 +75,7 @@ class _RenameAlbumDialogState extends State<RenameAlbumDialog> {
|
||||||
|
|
||||||
String _buildAlbumPath(String name) {
|
String _buildAlbumPath(String name) {
|
||||||
if (name == null || name.isEmpty) return '';
|
if (name == null || name.isEmpty) return '';
|
||||||
return path.join(path.dirname(album), name);
|
return pContext.join(pContext.dirname(album), name);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _validate() async {
|
Future<void> _validate() async {
|
||||||
|
|
|
@ -1,9 +1,9 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:aves/model/entry.dart';
|
import 'package:aves/model/entry.dart';
|
||||||
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart' as path;
|
|
||||||
|
|
||||||
import 'aves_dialog.dart';
|
import 'aves_dialog.dart';
|
||||||
|
|
||||||
|
@ -69,7 +69,7 @@ class _RenameEntryDialogState extends State<RenameEntryDialog> {
|
||||||
|
|
||||||
String _buildEntryPath(String name) {
|
String _buildEntryPath(String name) {
|
||||||
if (name == null || name.isEmpty) return '';
|
if (name == null || name.isEmpty) return '';
|
||||||
return path.join(entry.directory, name + entry.extension);
|
return pContext.join(entry.directory, name + entry.extension);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _validate() async {
|
Future<void> _validate() async {
|
||||||
|
|
|
@ -15,13 +15,13 @@ class AlbumTile extends StatelessWidget {
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final source = context.read<CollectionSource>();
|
final source = context.read<CollectionSource>();
|
||||||
final uniqueName = source.getUniqueAlbumName(context, album);
|
final displayName = source.getAlbumDisplayName(context, album);
|
||||||
return CollectionNavTile(
|
return CollectionNavTile(
|
||||||
leading: IconUtils.getAlbumIcon(
|
leading: IconUtils.getAlbumIcon(
|
||||||
context: context,
|
context: context,
|
||||||
album: album,
|
album: album,
|
||||||
),
|
),
|
||||||
title: uniqueName,
|
title: displayName,
|
||||||
trailing: androidFileUtils.isOnRemovableStorage(album)
|
trailing: androidFileUtils.isOnRemovableStorage(album)
|
||||||
? Icon(
|
? Icon(
|
||||||
AIcons.removableStorage,
|
AIcons.removableStorage,
|
||||||
|
@ -29,7 +29,7 @@ class AlbumTile extends StatelessWidget {
|
||||||
color: Colors.grey,
|
color: Colors.grey,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
filter: AlbumFilter(album, uniqueName),
|
filter: AlbumFilter(album, displayName),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -68,7 +68,7 @@ class _AlbumPickPageState extends State<AlbumPickPage> {
|
||||||
applyQuery: (filters, query) {
|
applyQuery: (filters, query) {
|
||||||
if (query == null || query.isEmpty) return filters;
|
if (query == null || query.isEmpty) return filters;
|
||||||
query = query.toUpperCase();
|
query = query.toUpperCase();
|
||||||
return filters.where((item) => item.filter.uniqueName.toUpperCase().contains(query)).toList();
|
return filters.where((item) => item.filter.displayName.toUpperCase().contains(query)).toList();
|
||||||
},
|
},
|
||||||
emptyBuilder: () => EmptyContent(
|
emptyBuilder: () => EmptyContent(
|
||||||
icon: AIcons.album,
|
icon: AIcons.album,
|
||||||
|
|
|
@ -61,7 +61,7 @@ class AlbumListPage extends StatelessWidget {
|
||||||
// common with album selection page to move/copy entries
|
// common with album selection page to move/copy entries
|
||||||
|
|
||||||
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(BuildContext context, CollectionSource source) {
|
static Map<ChipSectionKey, List<FilterGridItem<AlbumFilter>>> getAlbumEntries(BuildContext context, CollectionSource source) {
|
||||||
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getUniqueAlbumName(context, album))).toSet();
|
final filters = source.rawAlbums.map((album) => AlbumFilter(album, source.getAlbumDisplayName(context, album))).toSet();
|
||||||
|
|
||||||
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
final sorted = FilterNavigationPage.sort(settings.albumSortFactor, source, filters);
|
||||||
return _group(context, sorted);
|
return _group(context, sorted);
|
||||||
|
|
|
@ -11,7 +11,6 @@ import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/model/source/collection_source.dart';
|
import 'package:aves/model/source/collection_source.dart';
|
||||||
import 'package:aves/services/image_op_events.dart';
|
import 'package:aves/services/image_op_events.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/services/storage_service.dart';
|
|
||||||
import 'package:aves/utils/android_file_utils.dart';
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
import 'package:aves/widgets/common/action_mixins/feedback.dart';
|
||||||
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
|
||||||
|
@ -24,7 +23,6 @@ import 'package:aves/widgets/filter_grids/albums_page.dart';
|
||||||
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
import 'package:aves/widgets/filter_grids/countries_page.dart';
|
||||||
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
import 'package:aves/widgets/filter_grids/tags_page.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:path/path.dart' as path;
|
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import 'package:tuple/tuple.dart';
|
import 'package:tuple/tuple.dart';
|
||||||
|
|
||||||
|
@ -181,7 +179,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
await StorageService.deleteEmptyDirectories({album});
|
await storageService.deleteEmptyDirectories({album});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -196,7 +194,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
||||||
|
|
||||||
// check whether renaming is possible given OS restrictions,
|
// check whether renaming is possible given OS restrictions,
|
||||||
// before asking to input a new name
|
// before asking to input a new name
|
||||||
final restrictedDirs = await StorageService.getRestrictedDirectories();
|
final restrictedDirs = await storageService.getRestrictedDirectories();
|
||||||
final dir = VolumeRelativeDirectory.fromPath(album);
|
final dir = VolumeRelativeDirectory.fromPath(album);
|
||||||
if (restrictedDirs.contains(dir)) {
|
if (restrictedDirs.contains(dir)) {
|
||||||
await showRestrictedDirectoryDialog(context, dir);
|
await showRestrictedDirectoryDialog(context, dir);
|
||||||
|
@ -211,8 +209,8 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
||||||
|
|
||||||
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
if (!await checkStoragePermissionForAlbums(context, {album})) return;
|
||||||
|
|
||||||
final destinationAlbumParent = path.dirname(album);
|
final destinationAlbumParent = pContext.dirname(album);
|
||||||
final destinationAlbum = path.join(destinationAlbumParent, newName);
|
final destinationAlbum = pContext.join(destinationAlbumParent, newName);
|
||||||
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
if (!await checkFreeSpaceForMove(context, todoEntries, destinationAlbum, MoveType.move)) return;
|
||||||
|
|
||||||
if (!(await File(destinationAlbum).exists())) {
|
if (!(await File(destinationAlbum).exists())) {
|
||||||
|
@ -239,7 +237,7 @@ class AlbumChipActionDelegate extends ChipActionDelegate with FeedbackMixin, Per
|
||||||
}
|
}
|
||||||
|
|
||||||
// cleanup
|
// cleanup
|
||||||
await StorageService.deleteEmptyDirectories({album});
|
await storageService.deleteEmptyDirectories({album});
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -117,8 +117,14 @@ class CollectionSearchDelegate {
|
||||||
StreamBuilder(
|
StreamBuilder(
|
||||||
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
stream: source.eventBus.on<AlbumsChangedEvent>(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
// filter twice: full path, and then unique name
|
final filters = source.rawAlbums
|
||||||
final filters = source.rawAlbums.where(containQuery).map((s) => AlbumFilter(s, source.getUniqueAlbumName(context, s))).where((f) => containQuery(f.uniqueName)).toList()..sort();
|
.map((album) => AlbumFilter(
|
||||||
|
album,
|
||||||
|
source.getAlbumDisplayName(context, album),
|
||||||
|
))
|
||||||
|
.where((filter) => containQuery(filter.album) || containQuery(filter.displayName))
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
return _buildFilterRow(
|
return _buildFilterRow(
|
||||||
context: context,
|
context: context,
|
||||||
title: context.l10n.searchSectionAlbums,
|
title: context.l10n.searchSectionAlbums,
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import 'package:aves/services/storage_service.dart';
|
import 'package:aves/services/services.dart';
|
||||||
import 'package:aves/theme/icons.dart';
|
import 'package:aves/theme/icons.dart';
|
||||||
import 'package:aves/widgets/common/extensions/build_context.dart';
|
import 'package:aves/widgets/common/extensions/build_context.dart';
|
||||||
import 'package:aves/widgets/common/identity/empty.dart';
|
import 'package:aves/widgets/common/identity/empty.dart';
|
||||||
|
@ -39,7 +39,7 @@ class _StorageAccessPageState extends State<StorageAccessPage> {
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _load() => _pathLoader = StorageService.getGrantedDirectories();
|
void _load() => _pathLoader = storageService.getGrantedDirectories();
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
@ -87,7 +87,7 @@ class _StorageAccessPageState extends State<StorageAccessPage> {
|
||||||
trailing: IconButton(
|
trailing: IconButton(
|
||||||
icon: Icon(AIcons.clear),
|
icon: Icon(AIcons.clear),
|
||||||
onPressed: () async {
|
onPressed: () async {
|
||||||
await StorageService.revokeDirectoryAccess(path);
|
await storageService.revokeDirectoryAccess(path);
|
||||||
_load();
|
_load();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
},
|
},
|
||||||
|
|
|
@ -82,7 +82,7 @@ class BasicSection extends StatelessWidget {
|
||||||
if (entry.isImage && entry.is360) TypeFilter.panorama,
|
if (entry.isImage && entry.is360) TypeFilter.panorama,
|
||||||
if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
|
if (entry.isVideo && entry.is360) TypeFilter.sphericalVideo,
|
||||||
if (entry.isVideo && !entry.is360) MimeFilter.video,
|
if (entry.isVideo && !entry.is360) MimeFilter.video,
|
||||||
if (album != null) AlbumFilter(album, collection?.source?.getUniqueAlbumName(context, album)),
|
if (album != null) AlbumFilter(album, collection?.source?.getAlbumDisplayName(context, album)),
|
||||||
...tags.map((tag) => TagFilter(tag)),
|
...tags.map((tag) => TagFilter(tag)),
|
||||||
};
|
};
|
||||||
return AnimatedBuilder(
|
return AnimatedBuilder(
|
||||||
|
|
28
test/fake/storage_service.dart
Normal file
28
test/fake/storage_service.dart
Normal file
|
@ -0,0 +1,28 @@
|
||||||
|
import 'package:aves/services/storage_service.dart';
|
||||||
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
|
||||||
|
class FakeStorageService extends Fake implements StorageService {
|
||||||
|
static const primaryRootAlbum = '/storage/emulated/0';
|
||||||
|
static const primaryPath = '$primaryRootAlbum/';
|
||||||
|
static const primaryDescription = 'Internal Storage';
|
||||||
|
static const removablePath = '/storage/1234-5678/';
|
||||||
|
static const removableDescription = 'SD Card';
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<Set<StorageVolume>> getStorageVolumes() => SynchronousFuture({
|
||||||
|
StorageVolume(
|
||||||
|
path: primaryPath,
|
||||||
|
description: primaryDescription,
|
||||||
|
isPrimary: true,
|
||||||
|
isRemovable: false,
|
||||||
|
),
|
||||||
|
StorageVolume(
|
||||||
|
path: removablePath,
|
||||||
|
description: removableDescription,
|
||||||
|
isPrimary: false,
|
||||||
|
isRemovable: true,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
}
|
|
@ -12,29 +12,36 @@ import 'package:aves/services/image_file_service.dart';
|
||||||
import 'package:aves/services/media_store_service.dart';
|
import 'package:aves/services/media_store_service.dart';
|
||||||
import 'package:aves/services/metadata_service.dart';
|
import 'package:aves/services/metadata_service.dart';
|
||||||
import 'package:aves/services/services.dart';
|
import 'package:aves/services/services.dart';
|
||||||
|
import 'package:aves/services/storage_service.dart';
|
||||||
import 'package:aves/services/time_service.dart';
|
import 'package:aves/services/time_service.dart';
|
||||||
|
import 'package:aves/utils/android_file_utils.dart';
|
||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import '../fake/availability.dart';
|
import '../fake/availability.dart';
|
||||||
import '../fake/image_file_service.dart';
|
import '../fake/image_file_service.dart';
|
||||||
import '../fake/media_store_service.dart';
|
import '../fake/media_store_service.dart';
|
||||||
import '../fake/metadata_db.dart';
|
import '../fake/metadata_db.dart';
|
||||||
import '../fake/metadata_service.dart';
|
import '../fake/metadata_service.dart';
|
||||||
|
import '../fake/storage_service.dart';
|
||||||
import '../fake/time_service.dart';
|
import '../fake/time_service.dart';
|
||||||
|
|
||||||
void main() {
|
void main() {
|
||||||
const volume = '/storage/emulated/0/';
|
const testAlbum = '${FakeStorageService.primaryPath}Pictures/test';
|
||||||
const testAlbum = '${volume}Pictures/test';
|
const sourceAlbum = '${FakeStorageService.primaryPath}Pictures/source';
|
||||||
const sourceAlbum = '${volume}Pictures/source';
|
const destinationAlbum = '${FakeStorageService.primaryPath}Pictures/destination';
|
||||||
const destinationAlbum = '${volume}Pictures/destination';
|
|
||||||
|
|
||||||
setUp(() async {
|
setUp(() async {
|
||||||
|
// specify Posix style path context for consistent behaviour when running tests on Windows
|
||||||
|
getIt.registerLazySingleton<p.Context>(() => p.Context(style: p.Style.posix));
|
||||||
getIt.registerLazySingleton<AvesAvailability>(() => FakeAvesAvailability());
|
getIt.registerLazySingleton<AvesAvailability>(() => FakeAvesAvailability());
|
||||||
getIt.registerLazySingleton<MetadataDb>(() => FakeMetadataDb());
|
getIt.registerLazySingleton<MetadataDb>(() => FakeMetadataDb());
|
||||||
|
|
||||||
getIt.registerLazySingleton<ImageFileService>(() => FakeImageFileService());
|
getIt.registerLazySingleton<ImageFileService>(() => FakeImageFileService());
|
||||||
getIt.registerLazySingleton<MediaStoreService>(() => FakeMediaStoreService());
|
getIt.registerLazySingleton<MediaStoreService>(() => FakeMediaStoreService());
|
||||||
getIt.registerLazySingleton<MetadataService>(() => FakeMetadataService());
|
getIt.registerLazySingleton<MetadataService>(() => FakeMetadataService());
|
||||||
|
getIt.registerLazySingleton<StorageService>(() => FakeStorageService());
|
||||||
getIt.registerLazySingleton<TimeService>(() => FakeTimeService());
|
getIt.registerLazySingleton<TimeService>(() => FakeTimeService());
|
||||||
|
|
||||||
await settings.init();
|
await settings.init();
|
||||||
|
@ -236,4 +243,35 @@ void main() {
|
||||||
expect(covers.count, 1);
|
expect(covers.count, 1);
|
||||||
expect(covers.coverContentId(albumFilter), image1.contentId);
|
expect(covers.coverContentId(albumFilter), image1.contentId);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
testWidgets('unique album names', (tester) async {
|
||||||
|
(mediaStoreService as FakeMediaStoreService).entries = {
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Elea/Zeno', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Citium/Zeno', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Cleanthes', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Chrysippus', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.removablePath}Pictures/Chrysippus', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Pictures/Seneca', '1'),
|
||||||
|
FakeMediaStoreService.newImage('${FakeStorageService.primaryPath}Seneca', '1'),
|
||||||
|
};
|
||||||
|
|
||||||
|
await androidFileUtils.init();
|
||||||
|
final source = await _initSource();
|
||||||
|
await tester.pumpWidget(
|
||||||
|
Builder(
|
||||||
|
builder: (context) {
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Elea/Zeno'), 'Elea/Zeno');
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Citium/Zeno'), 'Citium/Zeno');
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Cleanthes'), 'Cleanthes');
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Chrysippus'), 'Chrysippus');
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.removablePath}Pictures/Chrysippus'), 'Chrysippus (${FakeStorageService.removableDescription})');
|
||||||
|
expect(source.getAlbumDisplayName(context, FakeStorageService.primaryRootAlbum), FakeStorageService.primaryDescription);
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Pictures/Seneca'), 'Pictures/Seneca');
|
||||||
|
expect(source.getAlbumDisplayName(context, '${FakeStorageService.primaryPath}Seneca'), 'Seneca');
|
||||||
|
return Placeholder();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,7 +5,7 @@ import 'package:aves/model/settings/enums.dart';
|
||||||
import 'package:aves/model/settings/settings.dart';
|
import 'package:aves/model/settings/settings.dart';
|
||||||
import 'package:aves/services/storage_service.dart';
|
import 'package:aves/services/storage_service.dart';
|
||||||
import 'package:flutter_driver/driver_extension.dart';
|
import 'package:flutter_driver/driver_extension.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
import 'constants.dart';
|
import 'constants.dart';
|
||||||
|
|
||||||
|
@ -15,7 +15,7 @@ void main() {
|
||||||
// scan files copied from test assets
|
// scan files copied from test assets
|
||||||
// we do it via the app instead of broadcasting via ADB
|
// we do it via the app instead of broadcasting via ADB
|
||||||
// because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29
|
// because `MEDIA_SCANNER_SCAN_FILE` intent got deprecated in API 29
|
||||||
StorageService.scanFile(path.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg');
|
PlatformStorageService().scanFile(p.join(targetPicturesDir, 'ipse.jpg'), 'image/jpeg');
|
||||||
|
|
||||||
configureAndLaunch();
|
configureAndLaunch();
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import 'package:aves/model/source/enums.dart';
|
import 'package:aves/model/source/enums.dart';
|
||||||
import 'package:flutter_driver/flutter_driver.dart';
|
import 'package:flutter_driver/flutter_driver.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as p;
|
||||||
import 'package:pedantic/pedantic.dart';
|
import 'package:pedantic/pedantic.dart';
|
||||||
import 'package:test/test.dart';
|
import 'package:test/test.dart';
|
||||||
|
|
||||||
|
@ -141,9 +141,9 @@ void searchAlbum() {
|
||||||
await driver.waitUntilNoTransientCallbacks();
|
await driver.waitUntilNoTransientCallbacks();
|
||||||
|
|
||||||
const albumPath = targetPicturesDirEmulated;
|
const albumPath = targetPicturesDirEmulated;
|
||||||
final albumUniqueName = path.split(albumPath).last;
|
final albumDisplayName = p.split(albumPath).last;
|
||||||
await driver.tap(find.byType('TextField'));
|
await driver.tap(find.byType('TextField'));
|
||||||
await driver.enterText(albumUniqueName);
|
await driver.enterText(albumDisplayName);
|
||||||
|
|
||||||
final albumChip = find.byValueKey('album-$albumPath');
|
final albumChip = find.byValueKey('album-$albumPath');
|
||||||
await driver.waitFor(albumChip);
|
await driver.waitFor(albumChip);
|
||||||
|
|
|
@ -1,12 +1,12 @@
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as p;
|
||||||
|
|
||||||
String get adb {
|
String get adb {
|
||||||
final env = Platform.environment;
|
final env = Platform.environment;
|
||||||
// e.g. C:\Users\<username>\AppData\Local\Android\Sdk
|
// e.g. C:\Users\<username>\AppData\Local\Android\Sdk
|
||||||
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK'];
|
final sdkDir = env['ANDROID_SDK_ROOT'] ?? env['ANDROID_SDK'];
|
||||||
return path.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
|
return p.join(sdkDir, 'platform-tools', Platform.isWindows ? 'adb.exe' : 'adb');
|
||||||
}
|
}
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
Loading…
Reference in a new issue