feat(mobile): show local assets (#905)

* introduce Asset as composition of AssetResponseDTO and AssetEntity

* filter out duplicate assets (that are both local and remote, take only remote for now)

* only allow remote images to be added to albums

* introduce ImmichImage to render Asset using local or remote data

* optimized deletion of local assets

* local video file playback

* allow multiple methods to wait on background service finished

* skip local assets when adding to album from home screen

* fix and optimize delete

* show gray box placeholder for local assets

* add comments

* fix bug: duplicate assets in state after onNewAssetUploaded
This commit is contained in:
Fynn Petersen-Frey 2022-11-08 18:00:24 +01:00 committed by GitHub
parent 99da181cfc
commit 1633af7af6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
41 changed files with 830 additions and 514 deletions

View file

@ -1,34 +1,90 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/src/types/entity.dart';
final assetServiceProvider = Provider(
(ref) => AssetService(
ref.watch(apiServiceProvider),
ref.watch(backupServiceProvider),
ref.watch(backgroundServiceProvider),
),
);
class AssetService {
final ApiService _apiService;
final BackupService _backupService;
final BackgroundService _backgroundService;
AssetService(this._apiService);
AssetService(this._apiService, this._backupService, this._backgroundService);
Future<List<AssetResponseDto>?> getAllAsset() async {
/// Returns all local, remote assets in that order
Future<List<Asset>> getAllAsset({bool urgent = false}) async {
final List<Asset> assets = [];
try {
return await _apiService.assetApi.getAllAssets();
// not using `await` here to fetch local & remote assets concurrently
final Future<List<AssetResponseDto>?> remoteTask =
_apiService.assetApi.getAllAssets();
final Iterable<AssetEntity> newLocalAssets;
final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
final Set<String> existingIds = remoteAssets
.where((e) => e.deviceId == deviceId)
.map((e) => e.deviceAssetId)
.toSet();
newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
} else {
newLocalAssets = localAssets;
}
assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
// the order (first all local, then remote assets) is important!
assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
} catch (e) {
debugPrint("Error [getAllAsset] ${e.toString()}");
return null;
}
return assets;
}
/// if [urgent] is `true`, do not block by waiting on the background service
/// to finish running. Returns an empty list instead after a timeout.
Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
try {
final Future<bool> hasAccess = urgent
? _backgroundService.hasAccess
.timeout(const Duration(milliseconds: 250))
: _backgroundService.hasAccess;
if (!await hasAccess) {
throw Exception("Error [getAllAsset] failed to gain access");
}
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
return backupAlbumInfo != null
? await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy())
: [];
} catch (e) {
debugPrint("Error [_getLocalAssets] ${e.toString()}");
return [];
}
}
Future<AssetResponseDto?> getAssetById(String assetId) async {
Future<Asset?> getAssetById(String assetId) async {
try {
return await _apiService.assetApi.getAssetById(assetId);
return Asset.remote(await _apiService.assetApi.getAssetById(assetId));
} catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}");
return null;
@ -36,12 +92,12 @@ class AssetService {
}
Future<List<DeleteAssetResponseDto>?> deleteAssets(
Set<AssetResponseDto> deleteAssets,
Iterable<AssetResponseDto> deleteAssets,
) async {
try {
List<String> payload = [];
final List<String> payload = [];
for (var asset in deleteAssets) {
for (final asset in deleteAssets) {
payload.add(asset.id);
}

View file

@ -1,27 +1,24 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/json_cache.dart';
import 'package:openapi/api.dart';
class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache");
@override
void put(List<AssetResponseDto> data) {
void put(List<Asset> data) {
putRawData(data.map((e) => e.toJson()).toList());
}
@override
Future<List<AssetResponseDto>> get() async {
Future<List<Asset>> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = mapList
.map((e) => AssetResponseDto.fromJson(e))
.whereNotNull()
.toList();
final responseData =
mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList();
return responseData;
} catch (e) {
@ -33,5 +30,5 @@ class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
}
final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
(ref) => AssetCacheService(),
);

View file

@ -1,6 +1,6 @@
import 'dart:math';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
enum RenderAssetGridElementType {
assetRow,
@ -9,7 +9,7 @@ enum RenderAssetGridElementType {
}
class RenderAssetGridRow {
final List<AssetResponseDto> assets;
final List<Asset> assets;
RenderAssetGridRow(this.assets);
}
@ -19,7 +19,7 @@ class RenderAssetGridElement {
final RenderAssetGridRow? assetRow;
final String? title;
final DateTime date;
final List<AssetResponseDto>? relatedAssetList;
final List<Asset>? relatedAssetList;
RenderAssetGridElement(
this.type, {
@ -31,13 +31,15 @@ class RenderAssetGridElement {
}
List<RenderAssetGridElement> assetsToRenderList(
List<AssetResponseDto> assets, int assetsPerRow) {
List<Asset> assets,
int assetsPerRow,
) {
List<RenderAssetGridElement> elements = [];
int cursor = 0;
while (cursor < assets.length) {
int rowElements = min(assets.length - cursor, assetsPerRow);
final date = DateTime.parse(assets[cursor].createdAt);
final date = assets[cursor].createdAt;
final rowElement = RenderAssetGridElement(
RenderAssetGridElementType.assetRow,
@ -55,7 +57,9 @@ List<RenderAssetGridElement> assetsToRenderList(
}
List<RenderAssetGridElement> assetGroupsToRenderList(
Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) {
Map<String, List<Asset>> assetGroups,
int assetsPerRow,
) {
List<RenderAssetGridElement> elements = [];
DateTime? lastDate;
@ -64,8 +68,11 @@ List<RenderAssetGridElement> assetGroupsToRenderList(
if (lastDate == null || lastDate!.month != date.month) {
elements.add(
RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
title: groupName, date: date),
RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
title: groupName,
date: date,
),
);
}

View file

@ -4,7 +4,7 @@ import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import 'asset_grid_data_structure.dart';
import 'daily_title_text.dart';
@ -13,7 +13,7 @@ import 'draggable_scrollbar_custom.dart';
typedef ImmichAssetGridSelectionListener = void Function(
bool,
Set<AssetResponseDto>,
Set<Asset>,
);
class ImmichAssetGridState extends State<ImmichAssetGrid> {
@ -24,20 +24,20 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
bool _scrolling = false;
final Set<String> _selectedAssets = HashSet();
List<AssetResponseDto> get _assets {
List<Asset> get _assets {
return widget.renderList
.map((e) {
if (e.type == RenderAssetGridElementType.assetRow) {
return e.assetRow!.assets;
} else {
return List<AssetResponseDto>.empty();
return List<Asset>.empty();
}
})
.flattened
.toList();
}
Set<AssetResponseDto> _getSelectedAssets() {
Set<Asset> _getSelectedAssets() {
return _selectedAssets
.map((e) => _assets.firstWhereOrNull((a) => a.id == e))
.whereNotNull()
@ -48,7 +48,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<AssetResponseDto> assets) {
void _selectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.add(e.id);
@ -57,7 +57,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
});
}
void _deselectAssets(List<AssetResponseDto> assets) {
void _deselectAssets(List<Asset> assets) {
setState(() {
for (var e in assets) {
_selectedAssets.remove(e.id);
@ -74,7 +74,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
_callSelectionListener(false);
}
bool _allAssetsSelected(List<AssetResponseDto> assets) {
bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive &&
assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
}
@ -85,7 +85,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
}
Widget _buildThumbnailOrPlaceholder(
AssetResponseDto asset,
Asset asset,
bool placeholder,
) {
if (placeholder) {
@ -114,7 +114,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
return Row(
key: Key("asset-row-${row.assets.first.id}"),
children: row.assets.map((AssetResponseDto asset) {
children: row.assets.map((Asset asset) {
bool last = asset == row.assets.last;
return Container(
@ -134,7 +134,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
Widget _buildTitle(
BuildContext context,
String title,
List<AssetResponseDto> assets,
List<Asset> assets,
) {
return DailyTitleText(
isoDate: title,

View file

@ -1,18 +1,15 @@
import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart';
class ThumbnailImage extends HookConsumerWidget {
final AssetResponseDto asset;
final List<AssetResponseDto> assetList;
final Asset asset;
final List<Asset> assetList;
final bool showStorageIndicator;
final bool useGrayBoxPlaceholder;
final bool isSelected;
@ -34,12 +31,9 @@ class ThumbnailImage extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
var thumbnailRequestUrl = getThumbnailUrl(asset);
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(AssetResponseDto asset) {
Widget buildSelectionIcon(Asset asset) {
if (isSelected) {
return Icon(
Icons.check_circle,
@ -87,41 +81,11 @@ class ThumbnailImage extends HookConsumerWidget {
)
: const Border(),
),
child: CachedNetworkImage(
cacheKey: 'thumbnail-image-${asset.id}',
child: ImmichImage(
asset,
width: 300,
height: 300,
memCacheHeight: 200,
maxWidthDiskCache: 200,
maxHeightDiskCache: 200,
fit: BoxFit.cover,
imageUrl: thumbnailRequestUrl,
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 250),
progressIndicatorBuilder: (context, url, downloadProgress) {
if (useGrayBoxPlaceholder) {
return const DecoratedBox(
decoration: BoxDecoration(color: Colors.grey),
);
}
return Transform.scale(
scale: 0.2,
child: CircularProgressIndicator(
value: downloadProgress.progress,
),
);
},
errorWidget: (context, url, error) {
debugPrint("Error getting thumbnail $url = $error");
CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
return Icon(
Icons.image_not_supported_outlined,
color: Theme.of(context).primaryColor,
);
},
useGrayBoxPlaceholder: useGrayBoxPlaceholder,
),
),
if (multiselectEnabled)
@ -137,14 +101,16 @@ class ThumbnailImage extends HookConsumerWidget {
right: 10,
bottom: 5,
child: Icon(
(deviceId != asset.deviceId)
? Icons.cloud_done_outlined
: Icons.photo_library_rounded,
asset.isRemote
? (deviceId == asset.deviceId
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white,
size: 18,
),
),
if (asset.type != AssetTypeEnum.IMAGE)
if (!asset.isImage)
Positioned(
top: 5,
right: 5,

View file

@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
@ -14,6 +15,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
@ -31,7 +33,7 @@ class HomePage extends HookConsumerWidget {
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false);
final selection = useState(<AssetResponseDto>{});
final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider);
final albumService = ref.watch(albumServiceProvider);
@ -60,7 +62,7 @@ class HomePage extends HookConsumerWidget {
Widget buildBody() {
void selectionListener(
bool multiselect,
Set<AssetResponseDto> selectedAssets,
Set<Asset> selectedAssets,
) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
@ -76,9 +78,27 @@ class HomePage extends HookConsumerWidget {
selectionEnabledHook.value = false;
}
Iterable<Asset> remoteOnlySelection() {
final Set<Asset> assets = selection.value;
final bool onlyRemote = assets.every((e) => e.isRemote);
if (!onlyRemote) {
ImmichToast.show(
context: context,
msg: "Can not add local assets to albums yet, skipping",
gravity: ToastGravity.BOTTOM,
);
return assets.where((a) => a.isRemote);
}
return assets;
}
void onAddToAlbum(AlbumResponseDto album) async {
final Iterable<Asset> assets = remoteOnlySelection();
if (assets.isEmpty) {
return;
}
final result = await albumService.addAdditionalAssetToAlbum(
selection.value,
assets,
album.id,
);
@ -103,6 +123,7 @@ class HomePage extends HookConsumerWidget {
"added": result.successfullyAdded.toString(),
},
),
toastType: ToastType.success,
);
}
@ -111,8 +132,11 @@ class HomePage extends HookConsumerWidget {
}
void onCreateNewAlbum() async {
final result =
await albumService.createAlbumWithGeneratedName(selection.value);
final Iterable<Asset> assets = remoteOnlySelection();
if (assets.isEmpty) {
return;
}
final result = await albumService.createAlbumWithGeneratedName(assets);
if (result != null) {
ref.watch(albumProvider.notifier).getAllAlbums();