mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
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:
parent
99da181cfc
commit
1633af7af6
41 changed files with 830 additions and 514 deletions
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue