Merge branch 'main' into fix/mobile-unawaited-futures

This commit is contained in:
Mert
2025-09-19 09:49:23 -04:00
committed by GitHub
177 changed files with 10370 additions and 1418 deletions
@@ -2,11 +2,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset?> {
class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAsset>, BaseAsset> {
@override
Future<List<RemoteAsset>> build(BaseAsset? asset) async {
if (asset == null || asset is! RemoteAsset || asset.stackId == null) {
return const [];
Future<List<RemoteAsset>> build(BaseAsset asset) {
if (asset is! RemoteAsset || asset.stackId == null) {
return Future.value(const []);
}
return ref.watch(assetServiceProvider).getStack(asset);
@@ -14,4 +14,4 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAs
}
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(StackChildrenNotifier.new);
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset>(StackChildrenNotifier.new);
@@ -3,7 +3,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
class AssetStackRow extends ConsumerWidget {
@@ -11,27 +11,25 @@ class AssetStackRow extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
if (!showControls) {
opacity = 0;
final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
if (asset == null) {
return const SizedBox.shrink();
}
final asset = ref.watch(assetViewerProvider.select((s) => s.currentAsset));
final stackChildren = ref.watch(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren == null || stackChildren.isEmpty) {
return const SizedBox.shrink();
}
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final opacity = showControls ? ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)) : 0;
return IgnorePointer(
ignoring: opacity < 255,
child: AnimatedOpacity(
opacity: opacity / 255,
duration: Durations.short2,
child: ref
.watch(stackChildrenNotifier(asset))
.when(
data: (state) => SizedBox.square(dimension: 80, child: _StackList(stack: state)),
error: (_, __) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
child: _StackList(stack: stackChildren),
),
);
}
@@ -44,58 +42,77 @@ class _StackList extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 30),
itemCount: stack.length,
itemBuilder: (ctx, index) {
final asset = stack[index];
return Padding(
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () {
ref.read(assetViewerProvider.notifier).setStackIndex(index);
ref.read(currentAssetNotifier.notifier).setAsset(asset);
},
child: Container(
height: 60,
width: 60,
decoration: index == ref.watch(assetViewerProvider.select((s) => s.stackIndex))
? const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
)
: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Stack(
fit: StackFit.expand,
children: [
Image(
fit: BoxFit.cover,
image: getThumbnailImageProvider(remoteId: asset.id, size: const Size.square(60)),
),
if (asset.isVideo)
const Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
size: 16,
shadows: [
Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0)),
],
),
],
),
),
),
return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Padding(
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
spacing: 5.0,
children: List.generate(stack.length, (i) {
final asset = stack[i];
return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
}),
),
);
},
),
),
);
}
}
class _StackItem extends ConsumerStatefulWidget {
final RemoteAsset asset;
final int index;
const _StackItem({super.key, required this.asset, required this.index});
@override
ConsumerState<_StackItem> createState() => _StackItemState();
}
class _StackItemState extends ConsumerState<_StackItem> {
void _onTap() {
ref.read(currentAssetNotifier.notifier).setAsset(widget.asset);
ref.read(assetViewerProvider.notifier).setStackIndex(widget.index);
}
@override
Widget build(BuildContext context) {
const playIcon = Center(
child: Icon(
Icons.play_circle_outline_rounded,
color: Colors.white,
size: 16,
shadows: [Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0))],
),
);
const selectedDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
borderRadius: BorderRadius.all(Radius.circular(10)),
);
const unselectedDecoration = BoxDecoration(
border: Border.fromBorderSide(BorderSide(color: Colors.grey, width: 0.5)),
borderRadius: BorderRadius.all(Radius.circular(10)),
);
Widget thumbnail = Thumbnail.fromAsset(asset: widget.asset, size: const Size(60, 40));
if (widget.asset.isVideo) {
thumbnail = Stack(children: [thumbnail, playIcon]);
}
thumbnail = ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(10)), child: thumbnail);
final isSelected = ref.watch(assetViewerProvider.select((s) => s.stackIndex == widget.index));
return SizedBox(
width: 60,
height: 40,
child: GestureDetector(
onTap: _onTap,
child: DecoratedBox(
decoration: isSelected ? selectedDecoration : unselectedDecoration,
position: DecorationPosition.foreground,
child: thumbnail,
),
),
);
}
}
@@ -12,6 +12,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_status_floating_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_stack.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
@@ -61,6 +62,15 @@ class AssetViewer extends ConsumerStatefulWidget {
ConsumerState createState() => _AssetViewerState();
static void setAsset(WidgetRef ref, BaseAsset asset) {
ref.read(assetViewerProvider.notifier).reset();
_setAsset(ref, asset);
}
void changeAsset(WidgetRef ref, BaseAsset asset) {
_setAsset(ref, asset);
}
static void _setAsset(WidgetRef ref, BaseAsset asset) {
// Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed
@@ -107,6 +117,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ImageStream? _prevPreCacheStream;
ImageStream? _nextPreCacheStream;
KeepAliveLink? _stackChildrenKeepAlive;
@override
void initState() {
super.initState();
@@ -117,6 +129,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0;
final asset = ref.read(currentAssetNotifier);
if (asset != null) {
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
}
@override
@@ -128,6 +144,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_stackChildrenKeepAlive?.close();
super.dispose();
}
@@ -188,9 +205,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return;
}
AssetViewer.setAsset(ref, asset);
widget.changeAsset(ref, asset);
_precacheAssets(index);
_handleCasting();
_stackChildrenKeepAlive?.close();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
void _handleCasting() {
@@ -518,7 +537,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) {
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider.select((s) => s.stackIndex)));
displayAsset = stackChildren.elementAt(ref.read(assetViewerProvider).stackIndex);
}
final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);
@@ -631,20 +650,25 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
appBar: const ViewerTopAppBar(),
extendBody: true,
extendBodyBehindAppBar: true,
body: PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
onPageBuild: _onPageBuild,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
floatingActionButton: const DownloadStatusFloatingButton(),
body: Stack(
children: [
PhotoViewGallery.builder(
gaplessPlayback: true,
loadingBuilder: _placeholderBuilder,
pageController: pageController,
scrollPhysics: CurrentPlatform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics(), // Use heavy physics for Android
itemCount: totalAssets,
onPageChanged: _onPageChanged,
onPageBuild: _onPageBuild,
scaleStateChangedCallback: _onScaleStateChanged,
builder: _assetBuilder,
backgroundDecoration: BoxDecoration(color: backgroundColor),
enablePanAlways: true,
),
],
),
bottomNavigationBar: showingBottomSheet
? const SizedBox.shrink()
@@ -68,12 +68,16 @@ class AssetViewerState {
stackIndex.hashCode;
}
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override
AssetViewerState build() {
return const AssetViewerState();
}
void reset() {
state = const AssetViewerState();
}
void setAsset(BaseAsset? asset) {
if (asset == state.currentAsset) {
return;
@@ -117,6 +121,4 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
}
}
final assetViewerProvider = AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>(
AssetViewerStateNotifier.new,
);
final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
@@ -56,6 +57,7 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final actions = <Widget>[
if (asset.hasRemote) const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
if (isCasting || (asset.hasRemote)) const CastActionButton(menuItem: true),
if (album != null && album.isActivityEnabled && album.isShared)
IconButton(