feat: full local assets / album sync
This commit is contained in:
@@ -13,7 +13,7 @@ typedef RenderListAssetProvider = FutureOr<List<Asset>> Function({
|
||||
int? limit,
|
||||
});
|
||||
|
||||
class ImmichAssetGridCubit extends Cubit<RenderList> {
|
||||
class AssetGridCubit extends Cubit<RenderList> {
|
||||
final Stream<RenderList> _renderStream;
|
||||
final RenderListAssetProvider _assetProvider;
|
||||
late final StreamSubscription _renderListSubscription;
|
||||
@@ -24,7 +24,7 @@ class ImmichAssetGridCubit extends Cubit<RenderList> {
|
||||
/// assets cache loaded from DB with offset [_bufOffset]
|
||||
List<Asset> _buf = [];
|
||||
|
||||
ImmichAssetGridCubit({
|
||||
AssetGridCubit({
|
||||
required Stream<RenderList> renderStream,
|
||||
required RenderListAssetProvider assetProvider,
|
||||
}) : _renderStream = renderStream,
|
||||
|
||||
@@ -6,14 +6,13 @@ import 'package:immich_mobile/domain/models/render_list_element.model.dart';
|
||||
import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
import 'package:immich_mobile/utils/extensions/color.extension.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
part 'immich_asset_grid_header.widget.dart';
|
||||
part 'immich_grid_asset_placeholder.widget.dart';
|
||||
|
||||
class ImAssetGrid extends StatefulWidget {
|
||||
const ImAssetGrid({super.key});
|
||||
@@ -56,8 +55,7 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<ImmichAssetGridCubit, RenderList>(
|
||||
Widget build(BuildContext context) => BlocBuilder<AssetGridCubit, RenderList>(
|
||||
builder: (_, renderList) {
|
||||
final elements = renderList.elements;
|
||||
final grid = FlutterListView(
|
||||
@@ -72,7 +70,7 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
||||
_MonthHeader(text: section.header),
|
||||
RenderListDayHeaderElement() => Text(section.header),
|
||||
RenderListAssetElement() => FutureBuilder(
|
||||
future: context.read<ImmichAssetGridCubit>().loadAssets(
|
||||
future: context.read<AssetGridCubit>().loadAssets(
|
||||
section.assetOffset,
|
||||
section.assetCount,
|
||||
),
|
||||
@@ -83,6 +81,7 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
||||
shrinkWrap: true,
|
||||
addAutomaticKeepAlives: false,
|
||||
cacheExtent: 100,
|
||||
padding: const EdgeInsets.all(0),
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
@@ -97,8 +96,8 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
||||
dimension: 200,
|
||||
// Show Placeholder when drag scrolled
|
||||
child: asset == null || _isDragScrolling
|
||||
? const _ImImagePlaceholder()
|
||||
: ImImage(asset),
|
||||
? const ImImagePlaceholder()
|
||||
: ImThumbnail(asset),
|
||||
);
|
||||
},
|
||||
itemCount: section.assetCount,
|
||||
|
||||
@@ -9,7 +9,12 @@ class _HeaderText extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0, left: 16.0, right: 24.0),
|
||||
padding: const EdgeInsets.only(
|
||||
top: 32.0,
|
||||
left: 16.0,
|
||||
right: 24.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
part of 'immich_asset_grid.widget.dart';
|
||||
|
||||
class _ImImagePlaceholder extends StatelessWidget {
|
||||
const _ImImagePlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var gradientColors = [
|
||||
context.colorScheme.surfaceContainer,
|
||||
context.colorScheme.surfaceContainer.darken(amount: .1),
|
||||
];
|
||||
|
||||
return Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: gradientColors,
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||
|
||||
/// The cache manager for thumbnail images [ImRemoteThumbnailProvider]
|
||||
class ImRemoteThumbnailCacheManager extends CacheManager {
|
||||
static final ImRemoteThumbnailCacheManager _instance =
|
||||
ImRemoteThumbnailCacheManager._();
|
||||
|
||||
factory ImRemoteThumbnailCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
ImRemoteThumbnailCacheManager._()
|
||||
: super(
|
||||
Config(
|
||||
kCacheThumbnailsKey,
|
||||
maxNrOfCacheObjects: kCacheMaxNrOfThumbnails,
|
||||
stalePeriod: const Duration(days: kCacheStalePeriod),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// The cache manager for full size images [ImRemoteImageProvider]
|
||||
class ImRemoteImageCacheManager extends CacheManager {
|
||||
static final ImRemoteImageCacheManager _instance =
|
||||
ImRemoteImageCacheManager._();
|
||||
|
||||
factory ImRemoteImageCacheManager() {
|
||||
return _instance;
|
||||
}
|
||||
|
||||
ImRemoteImageCacheManager._()
|
||||
: super(
|
||||
Config(
|
||||
kCacheFullImagesKey,
|
||||
maxNrOfCacheObjects: kCacheMaxNrOfFullImages,
|
||||
stalePeriod: const Duration(days: kCacheStalePeriod),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||
|
||||
/// An exception for the [ImageLoader] and the Immich image providers
|
||||
class ImageLoadingException implements Exception {
|
||||
final String message;
|
||||
const ImageLoadingException(this.message);
|
||||
|
||||
@override
|
||||
String toString() => 'ImageLoadingException: $message';
|
||||
}
|
||||
|
||||
/// Loads the codec from the URI and sends the events to the [chunkEvents] stream
|
||||
///
|
||||
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
|
||||
/// for this wonderful implementation of their image loader
|
||||
class ImageLoader {
|
||||
static Future<ui.Codec> loadImageFromCache(
|
||||
String uri, {
|
||||
required CacheManager cache,
|
||||
required ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent>? chunkEvents,
|
||||
}) async {
|
||||
final stream = cache.getFileStream(
|
||||
uri,
|
||||
withProgress: chunkEvents != null,
|
||||
headers: di<ImApiClient>().headers,
|
||||
);
|
||||
|
||||
await for (final result in stream) {
|
||||
if (result is DownloadProgress) {
|
||||
// We are downloading the file, so update the [chunkEvents]
|
||||
chunkEvents?.add(
|
||||
ImageChunkEvent(
|
||||
cumulativeBytesLoaded: result.downloaded,
|
||||
expectedTotalBytes: result.totalSize,
|
||||
),
|
||||
);
|
||||
} else if (result is FileInfo) {
|
||||
// We have the file
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
|
||||
final decoded = await decode(buffer);
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, the image failed to load from the cache stream
|
||||
throw const ImageLoadingException('Could not load image from stream');
|
||||
}
|
||||
}
|
||||
@@ -1,48 +1,89 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/provider/immich_local_image_provider.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/provider/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
import 'package:octo_image/octo_image.dart';
|
||||
|
||||
class ImImagePlaceholder extends StatelessWidget {
|
||||
const ImImagePlaceholder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class ImImage extends StatelessWidget {
|
||||
final Asset asset;
|
||||
final double? width;
|
||||
final double? height;
|
||||
final Widget placeholder;
|
||||
|
||||
const ImImage(this.asset, {this.width, this.height, super.key});
|
||||
const ImImage(
|
||||
this.asset, {
|
||||
this.width,
|
||||
this.height,
|
||||
this.placeholder = const ImImagePlaceholder(),
|
||||
super.key,
|
||||
});
|
||||
|
||||
// Helper function to return the image provider for the asset
|
||||
// either by using the asset ID or the asset itself
|
||||
/// [asset] is the Asset to request, or else use [assetId] to get a remote
|
||||
/// image provider
|
||||
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
|
||||
/// The size of the square thumbnail to request. Ignored if isThumbnail
|
||||
/// is not true
|
||||
static ImageProvider imageProvider({Asset? asset, String? assetId}) {
|
||||
if (asset == null && assetId == null) {
|
||||
throw Exception('Must supply either asset or assetId');
|
||||
}
|
||||
|
||||
if (asset == null) {
|
||||
return ImRemoteImageProvider(assetId: assetId!);
|
||||
}
|
||||
|
||||
// Whether to use the local asset image provider or a remote one
|
||||
final useLocal = !asset.isRemote || asset.isLocal;
|
||||
|
||||
if (useLocal) {
|
||||
return ImLocalImageProvider(asset: asset);
|
||||
}
|
||||
|
||||
return ImRemoteImageProvider(assetId: asset.remoteId!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return CachedNetworkImage(
|
||||
imageUrl: ImImageUrlHelper.getThumbnailUrl(asset),
|
||||
httpHeaders: di<ImmichApiClient>().headers,
|
||||
cacheKey: ImImageUrlHelper.getThumbnailUrl(asset),
|
||||
return OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: const Duration(milliseconds: 200),
|
||||
placeholderBuilder: (_) => placeholder,
|
||||
image: ImImage.imageProvider(asset: asset),
|
||||
width: width,
|
||||
height: height,
|
||||
// keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
|
||||
// maxHeightDiskCache = null allows to simply store the webp thumbnail
|
||||
// from the server and use it for all rendered thumbnail sizes
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 250),
|
||||
progressIndicatorBuilder: (_, url, downloadProgress) {
|
||||
// Show loading if desired
|
||||
return const SizedBox.square(
|
||||
dimension: 250,
|
||||
child: DecoratedBox(decoration: BoxDecoration(color: Colors.grey)),
|
||||
);
|
||||
},
|
||||
errorWidget: (_, url, error) {
|
||||
if (error is HttpExceptionWithStatus &&
|
||||
error.statusCode >= 400 &&
|
||||
error.statusCode < 500) {
|
||||
CachedNetworkImage.evictFromCache(url);
|
||||
errorBuilder: (_, error, stackTrace) {
|
||||
if (error is PlatformException &&
|
||||
error.code == "The asset not found!") {
|
||||
debugPrint(
|
||||
"Asset ${asset.localId ?? asset.id ?? "-"} does not exist anymore on device!",
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
"Error getting thumb for assetId=${asset.localId ?? asset.id ?? "-"}: $error",
|
||||
);
|
||||
}
|
||||
return Icon(
|
||||
Symbols.image_not_supported_rounded,
|
||||
color: Theme.of(context).primaryColor,
|
||||
Icons.image_not_supported_outlined,
|
||||
color: context.colorScheme.primary,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ class ImLogo extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class ImLogoText extends StatelessWidget {
|
||||
const ImLogoText({
|
||||
super.key,
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
IconData _getStorageIcon(Asset asset) {
|
||||
if (asset.isMerged) {
|
||||
return Symbols.cloud_done_rounded;
|
||||
} else if (asset.isRemote) {
|
||||
return Symbols.cloud_rounded;
|
||||
}
|
||||
return Symbols.cloud_off_rounded;
|
||||
}
|
||||
|
||||
class ImThumbnail extends StatelessWidget {
|
||||
final Asset asset;
|
||||
final double? width;
|
||||
final double? height;
|
||||
|
||||
const ImThumbnail(this.asset, {this.width, this.height, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: ImImage(asset, width: width, height: height)),
|
||||
_PadAlignedIcon(
|
||||
alignment: Alignment.bottomRight,
|
||||
icon: _getStorageIcon(asset),
|
||||
),
|
||||
if (!asset.isImage)
|
||||
const _PadAlignedIcon(
|
||||
alignment: Alignment.topLeft,
|
||||
icon: Symbols.play_circle_rounded,
|
||||
filled: true,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PadAlignedIcon extends StatelessWidget {
|
||||
final Alignment alignment;
|
||||
final IconData icon;
|
||||
final bool? filled;
|
||||
|
||||
const _PadAlignedIcon({
|
||||
required this.alignment,
|
||||
required this.icon,
|
||||
this.filled,
|
||||
});
|
||||
|
||||
double _calculateLeft(Alignment align) {
|
||||
return align.x == -1 ? 5 : 0;
|
||||
}
|
||||
|
||||
double _calculateTop(Alignment align) {
|
||||
return align.y == -1 ? 4 : 0;
|
||||
}
|
||||
|
||||
double _calculateRight(Alignment align) {
|
||||
return align.x == 1 ? 5 : 0;
|
||||
}
|
||||
|
||||
double _calculateBottom(Alignment align) {
|
||||
return align.y == 1 ? 4 : 0;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Positioned(
|
||||
left: _calculateLeft(alignment),
|
||||
top: _calculateTop(alignment),
|
||||
right: _calculateRight(alignment),
|
||||
bottom: _calculateBottom(alignment),
|
||||
child: Align(
|
||||
alignment: alignment,
|
||||
child: Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
fill: (filled != null && filled!) ? 1 : null,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/extensions/file.extension.dart';
|
||||
|
||||
/// The local image provider for an asset
|
||||
class ImLocalImageProvider extends ImageProvider<ImLocalImageProvider> {
|
||||
final Asset asset;
|
||||
|
||||
ImLocalImageProvider({required this.asset})
|
||||
: assert(asset.localId != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImLocalImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImLocalImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key.asset, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.name);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
Asset a,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes =
|
||||
await di<IDeviceAssetRepository>().getThumbnail(a.localId!);
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${a.name} failed");
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
/// Using 2K thumbnail for local iOS image to avoid double swiping issue
|
||||
if (Platform.isIOS) {
|
||||
final largeImageBytes = await di<IDeviceAssetRepository>()
|
||||
.getThumbnail(a.localId!, width: 3840, height: 2160);
|
||||
|
||||
if (largeImageBytes == null) {
|
||||
throw StateError("Loading thumb for local photo ${a.name} failed");
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(largeImageBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
// Use the original file for Android
|
||||
final File? file =
|
||||
await di<IDeviceAssetRepository>().getOriginalFile(a.localId!);
|
||||
if (file == null) {
|
||||
throw StateError("Opening file for asset ${a.name} failed");
|
||||
}
|
||||
try {
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(file.path);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} catch (error, stack) {
|
||||
Error.throwWithStackTrace(
|
||||
StateError("Loading asset ${a.name} failed"),
|
||||
stack,
|
||||
);
|
||||
} finally {
|
||||
await file.deleteDarwinCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImLocalImageProvider) {
|
||||
return asset == other.asset;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.hashCode;
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/device_asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/asset.model.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||
|
||||
/// The local image provider for an asset
|
||||
/// Only viable
|
||||
class ImLocalThumbnailProvider extends ImageProvider<ImLocalThumbnailProvider> {
|
||||
final Asset asset;
|
||||
final int height;
|
||||
final int width;
|
||||
|
||||
ImLocalThumbnailProvider({
|
||||
required this.asset,
|
||||
this.height = kGridThumbnailSize,
|
||||
this.width = kGridThumbnailSize,
|
||||
}) : assert(asset.localId != null, 'Only usable when asset.local is set');
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImLocalThumbnailProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImLocalThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key.asset, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
informationCollector: () sync* {
|
||||
yield ErrorDescription(asset.name);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
Asset a,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes = await di<IDeviceAssetRepository>()
|
||||
.getThumbnail(a.localId!, width: 32, height: 32, quality: 75);
|
||||
if (thumbBytes != null) {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${a.name} failed");
|
||||
}
|
||||
|
||||
final normalThumbBytes = await di<IDeviceAssetRepository>()
|
||||
.getThumbnail(a.localId!, width: width, height: height);
|
||||
if (normalThumbBytes == null) {
|
||||
throw StateError("Loading thumb for local photo ${a.name} failed");
|
||||
}
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(normalThumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
|
||||
await chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other is! ImLocalThumbnailProvider) return false;
|
||||
if (identical(this, other)) return true;
|
||||
return asset == other.asset;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => asset.hashCode;
|
||||
}
|
||||
+88
@@ -0,0 +1,88 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
|
||||
|
||||
/// The remote image provider for full size remote images
|
||||
class ImRemoteImageProvider extends ImageProvider<ImRemoteImageProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
/// The image cache manager
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
const ImRemoteImageProvider({required this.assetId, this.cacheManager});
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImRemoteImageProvider> obtainKey(ImageConfiguration configuration) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImRemoteImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ImRemoteImageCacheManager();
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode, chunkEvents),
|
||||
scale: 1.0,
|
||||
chunkEvents: chunkEvents.stream,
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
ImRemoteImageProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
StreamController<ImageChunkEvent> chunkEvents,
|
||||
) async* {
|
||||
// Load a preview to the chunk events
|
||||
final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId);
|
||||
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkEvents,
|
||||
);
|
||||
|
||||
// Load the higher resolution version of the image
|
||||
final url = ImImageUrlHelper.getThumbnailUrlForRemoteId(
|
||||
key.assetId,
|
||||
type: AssetMediaSize.preview,
|
||||
);
|
||||
final codec = await ImageLoader.loadImageFromCache(
|
||||
url,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
chunkEvents: chunkEvents,
|
||||
);
|
||||
yield codec;
|
||||
|
||||
await chunkEvents.close();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImRemoteImageProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
+80
@@ -0,0 +1,80 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/painting.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/cache/cache_manager.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/cache/image_loader.dart';
|
||||
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
|
||||
|
||||
/// The remote image provider
|
||||
class ImmichRemoteThumbnailProvider
|
||||
extends ImageProvider<ImmichRemoteThumbnailProvider> {
|
||||
/// The [Asset.remoteId] of the asset to fetch
|
||||
final String assetId;
|
||||
|
||||
final int? height;
|
||||
final int? width;
|
||||
|
||||
/// The image cache manager
|
||||
final CacheManager? cacheManager;
|
||||
|
||||
const ImmichRemoteThumbnailProvider({
|
||||
required this.assetId,
|
||||
this.height,
|
||||
this.width,
|
||||
this.cacheManager,
|
||||
});
|
||||
|
||||
/// Converts an [ImageProvider]'s settings plus an [ImageConfiguration] to a key
|
||||
/// that describes the precise image to load.
|
||||
@override
|
||||
Future<ImmichRemoteThumbnailProvider> obtainKey(
|
||||
ImageConfiguration configuration,
|
||||
) {
|
||||
return SynchronousFuture(this);
|
||||
}
|
||||
|
||||
@override
|
||||
ImageStreamCompleter loadImage(
|
||||
ImmichRemoteThumbnailProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
) {
|
||||
final cache = cacheManager ?? ImRemoteThumbnailCacheManager();
|
||||
return MultiImageStreamCompleter(
|
||||
codec: _codec(key, cache, decode),
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
// Streams in each stage of the image as we ask for it
|
||||
Stream<ui.Codec> _codec(
|
||||
ImmichRemoteThumbnailProvider key,
|
||||
CacheManager cache,
|
||||
ImageDecoderCallback decode,
|
||||
) async* {
|
||||
final preview = ImImageUrlHelper.getThumbnailUrlForRemoteId(key.assetId);
|
||||
|
||||
yield await ImageLoader.loadImageFromCache(
|
||||
preview,
|
||||
cache: cache,
|
||||
decode: decode,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
if (other is ImmichRemoteThumbnailProvider) {
|
||||
return assetId == other.assetId;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => assetId.hashCode;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImAdaptiveRouteSecondaryAppBar({super.key});
|
||||
|
||||
@@ -15,7 +15,7 @@ class HomePage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: BlocProvider(
|
||||
create: (_) => ImmichAssetGridCubit(
|
||||
create: (_) => AssetGridCubit(
|
||||
renderStream: di<IRenderListRepository>().watchAll(),
|
||||
assetProvider: di<IAssetRepository>().getAll,
|
||||
),
|
||||
|
||||
@@ -5,12 +5,14 @@ import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/store.interface.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/store.model.dart';
|
||||
import 'package:immich_mobile/domain/services/album_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/login.service.dart';
|
||||
import 'package:immich_mobile/domain/services/user.service.dart';
|
||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
|
||||
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
|
||||
import 'package:immich_mobile/presentation/states/gallery_permission.state.dart';
|
||||
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||
@@ -126,7 +128,7 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
|
||||
await di<IStoreRepository>().upsert(StoreKey.accessToken, accessToken);
|
||||
|
||||
/// Set token to interceptor
|
||||
await di<ImmichApiClient>().init(accessToken: accessToken);
|
||||
await di<ImApiClient>().init(accessToken: accessToken);
|
||||
|
||||
final user = await di<UserService>().getMyUser();
|
||||
if (user == null) {
|
||||
@@ -139,7 +141,9 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
|
||||
await di<IUserRepository>().upsert(user);
|
||||
// Remove and Sync assets in background
|
||||
await di<IAssetRepository>().deleteAll();
|
||||
unawaited(di<AssetSyncService>().performFullRemoteSyncForUser(user));
|
||||
await di<GalleryPermissionNotifier>().requestPermission();
|
||||
unawaited(di<AssetSyncService>().performFullRemoteSyncIsolate(user));
|
||||
unawaited(di<AlbumSyncService>().performFullDeviceSyncIsolate());
|
||||
|
||||
emit(state.copyWith(
|
||||
isValidationInProgress: false,
|
||||
|
||||
@@ -10,9 +10,9 @@ import 'package:immich_mobile/presentation/components/input/filled_button.widget
|
||||
import 'package:immich_mobile/presentation/components/input/password_form_field.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/input/text_button.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
|
||||
import 'package:immich_mobile/presentation/modules/common/states/server_info/server_feature_config.state.dart';
|
||||
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
|
||||
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
|
||||
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
|
||||
@@ -4,17 +4,17 @@ import 'package:immich_mobile/presentation/router/router.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
enum SettingSection {
|
||||
general(
|
||||
general._(
|
||||
icon: Symbols.interests_rounded,
|
||||
labelKey: 'settings.sections.general',
|
||||
destination: GeneralSettingsRoute(),
|
||||
),
|
||||
advance(
|
||||
advance._(
|
||||
icon: Symbols.build_rounded,
|
||||
labelKey: 'settings.sections.advance',
|
||||
destination: AdvanceSettingsRoute(),
|
||||
),
|
||||
about(
|
||||
about._(
|
||||
icon: Symbols.help_rounded,
|
||||
labelKey: 'settings.sections.about',
|
||||
destination: AboutSettingsRoute(),
|
||||
@@ -24,7 +24,7 @@ enum SettingSection {
|
||||
final String labelKey;
|
||||
final IconData icon;
|
||||
|
||||
const SettingSection({
|
||||
const SettingSection._({
|
||||
required this.labelKey,
|
||||
required this.icon,
|
||||
required this.destination,
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart';
|
||||
import 'package:immich_mobile/utils/constants/globals.dart';
|
||||
import 'package:immich_mobile/utils/constants/size_constants.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -17,9 +18,10 @@ class AboutSettingsPage extends StatelessWidget {
|
||||
title: Text(context.t.settings.about.third_party_title),
|
||||
subtitle: Text(context.t.settings.about.third_party_sub_title),
|
||||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: "Immich",
|
||||
applicationIcon: const ImLogo(width: SizeConstants.xl)),
|
||||
context: context,
|
||||
applicationName: kImmichAppName,
|
||||
applicationIcon: const ImLogo(width: SizeConstants.xl),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ class SettingsWrapperPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_colors.model
|
||||
import 'package:immich_mobile/utils/extensions/material_state.extension.dart';
|
||||
|
||||
enum AppTheme {
|
||||
blue(AppColors.blueLight, AppColors.blueDark),
|
||||
blue._(AppColors.blueLight, AppColors.blueDark),
|
||||
// Fallback color for dynamic theme for non-supported platforms
|
||||
dynamic(AppColors.blueLight, AppColors.blueDark);
|
||||
dynamic._(AppColors.blueLight, AppColors.blueDark);
|
||||
|
||||
final ColorScheme lightSchema;
|
||||
final ColorScheme darkSchema;
|
||||
|
||||
const AppTheme(this.lightSchema, this.darkSchema);
|
||||
const AppTheme._(this.lightSchema, this.darkSchema);
|
||||
|
||||
static ThemeData generateThemeData(ColorScheme color) {
|
||||
return ThemeData(
|
||||
@@ -51,6 +51,14 @@ enum AppTheme {
|
||||
borderSide: BorderSide(color: color.outlineVariant),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
borderSide: BorderSide(color: color.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
borderSide: BorderSide(color: color.error),
|
||||
),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.normal,
|
||||
|
||||
@@ -3,12 +3,13 @@ import 'dart:async';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:immich_mobile/domain/services/album_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
|
||||
import 'package:immich_mobile/domain/services/login.service.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
|
||||
import 'package:immich_mobile/presentation/modules/common/states/current_user.state.dart';
|
||||
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
|
||||
import 'package:immich_mobile/presentation/router/router.dart';
|
||||
import 'package:immich_mobile/presentation/states/current_user.state.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/mixins/log.mixin.dart';
|
||||
|
||||
@@ -53,7 +54,8 @@ class _SplashScreenState extends State<SplashScreenPage>
|
||||
Future<void> _tryLogin() async {
|
||||
if (await di<LoginService>().tryAutoLogin() && mounted) {
|
||||
unawaited(di<AssetSyncService>()
|
||||
.performFullRemoteSyncForUser(di<CurrentUserCubit>().state));
|
||||
.performFullRemoteSyncIsolate(di<CurrentUserCubit>().state));
|
||||
unawaited(di<AlbumSyncService>().performFullDeviceSyncIsolate());
|
||||
unawaited(context.replaceRoute(const TabControllerRoute()));
|
||||
} else {
|
||||
unawaited(context.replaceRoute(const LoginRoute()));
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
enum GalleryPermissionStatus {
|
||||
yetToRequest,
|
||||
granted,
|
||||
limited,
|
||||
denied,
|
||||
permanentlyDenied;
|
||||
|
||||
bool get isGranted => this == GalleryPermissionStatus.granted;
|
||||
bool get isLimited => this == GalleryPermissionStatus.limited;
|
||||
}
|
||||
|
||||
class GalleryPermissionNotifier extends ValueNotifier<GalleryPermissionStatus> {
|
||||
GalleryPermissionNotifier() : super(GalleryPermissionStatus.yetToRequest) {
|
||||
checkPermission();
|
||||
}
|
||||
|
||||
bool get hasPermission => value.isGranted || value.isLimited;
|
||||
|
||||
/// Requests the gallery permission
|
||||
Future<GalleryPermissionStatus> requestPermission() async {
|
||||
PermissionStatus result;
|
||||
// Android 32 and below uses Permission.storage
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
if (androidInfo.version.sdkInt <= 32) {
|
||||
// Android 32 and below need storage
|
||||
final permission = await Permission.storage.request();
|
||||
result = permission;
|
||||
} else {
|
||||
// Android 33 need photo & video
|
||||
final photos = await Permission.photos.request();
|
||||
if (!photos.isGranted) {
|
||||
final state = _toGalleryPermissionStatus(photos);
|
||||
// Don't ask twice for the same permission
|
||||
value = state;
|
||||
return state;
|
||||
}
|
||||
|
||||
final videos = await Permission.videos.request();
|
||||
// Return the joint result of those two permissions
|
||||
if ((photos.isGranted && videos.isGranted) ||
|
||||
(photos.isLimited && videos.isLimited)) {
|
||||
result = PermissionStatus.granted;
|
||||
} else if (photos.isDenied || videos.isDenied) {
|
||||
result = PermissionStatus.denied;
|
||||
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
|
||||
result = PermissionStatus.permanentlyDenied;
|
||||
} else {
|
||||
result = PermissionStatus.denied;
|
||||
}
|
||||
}
|
||||
if (result == PermissionStatus.granted &&
|
||||
androidInfo.version.sdkInt >= 29) {
|
||||
result = await Permission.accessMediaLocation.request();
|
||||
}
|
||||
} else {
|
||||
// iOS can use photos
|
||||
result = await Permission.photos.request();
|
||||
}
|
||||
value = _toGalleryPermissionStatus(result);
|
||||
return value;
|
||||
}
|
||||
|
||||
/// Checks the current state of the gallery permissions without
|
||||
/// requesting them again
|
||||
Future<GalleryPermissionStatus> checkPermission() async {
|
||||
PermissionStatus result;
|
||||
// Android 32 and below uses Permission.storage
|
||||
if (Platform.isAndroid) {
|
||||
final androidInfo = await DeviceInfoPlugin().androidInfo;
|
||||
if (androidInfo.version.sdkInt <= 32) {
|
||||
// Android 32 and below need storage
|
||||
final permission = await Permission.storage.status;
|
||||
result = permission;
|
||||
} else {
|
||||
// Android 33 needs photo & video
|
||||
final photos = await Permission.photos.status;
|
||||
final videos = await Permission.videos.status;
|
||||
|
||||
// Return the joint result of those two permissions
|
||||
final PermissionStatus status;
|
||||
if ((photos.isGranted && videos.isGranted) ||
|
||||
(photos.isLimited && videos.isLimited)) {
|
||||
status = PermissionStatus.granted;
|
||||
} else if (photos.isDenied || videos.isDenied) {
|
||||
status = PermissionStatus.denied;
|
||||
} else if (photos.isPermanentlyDenied || videos.isPermanentlyDenied) {
|
||||
status = PermissionStatus.permanentlyDenied;
|
||||
} else {
|
||||
status = PermissionStatus.denied;
|
||||
}
|
||||
|
||||
result = status;
|
||||
}
|
||||
if (result == PermissionStatus.granted &&
|
||||
androidInfo.version.sdkInt >= 29) {
|
||||
result = await Permission.accessMediaLocation.status;
|
||||
}
|
||||
} else {
|
||||
// iOS can use photos
|
||||
result = await Permission.photos.status;
|
||||
}
|
||||
value = _toGalleryPermissionStatus(result);
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
GalleryPermissionStatus _toGalleryPermissionStatus(PermissionStatus status) =>
|
||||
switch (status) {
|
||||
PermissionStatus.granted => GalleryPermissionStatus.granted,
|
||||
PermissionStatus.limited => GalleryPermissionStatus.limited,
|
||||
PermissionStatus.denied => GalleryPermissionStatus.denied,
|
||||
PermissionStatus.restricted ||
|
||||
PermissionStatus.permanentlyDenied ||
|
||||
PermissionStatus.provisional =>
|
||||
GalleryPermissionStatus.permanentlyDenied,
|
||||
};
|
||||
Reference in New Issue
Block a user