feat: full local assets / album sync

This commit is contained in:
shenlong-tanwen
2024-10-17 23:33:00 +05:30
parent a09710ec7b
commit c91a2878dc
87 changed files with 2417 additions and 366 deletions
@@ -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,
),
),
);
}
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -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,
};