fix(mobile): stack row blocking gestures and not showing up (#21854)

This commit is contained in:
Mert 2025-09-18 02:16:14 -04:00 committed by GitHub
parent 28e9892ed3
commit 9ae42106cc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 121 additions and 86 deletions

View file

@ -40,13 +40,12 @@ class AssetService {
Future<List<RemoteAsset>> getStack(RemoteAsset asset) async { Future<List<RemoteAsset>> getStack(RemoteAsset asset) async {
if (asset.stackId == null) { if (asset.stackId == null) {
return []; return const [];
} }
return _remoteAssetRepository.getStackChildren(asset).then((assets) { final stack = await _remoteAssetRepository.getStackChildren(asset);
// Include the primary asset in the stack as the first item // Include the primary asset in the stack as the first item
return [asset, ...assets]; return [asset, ...stack];
});
} }
Future<ExifInfo?> getExif(BaseAsset asset) async { Future<ExifInfo?> getExif(BaseAsset asset) async {

View file

@ -62,12 +62,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
} }
Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) { Future<List<RemoteAsset>> getStackChildren(RemoteAsset asset) {
if (asset.stackId == null) { final stackId = asset.stackId;
return Future.value([]); if (stackId == null) {
return Future.value(const []);
} }
final query = _db.remoteAssetEntity.select() final query = _db.remoteAssetEntity.select()
..where((row) => row.stackId.equals(asset.stackId!) & row.id.equals(asset.id).not()) ..where((row) => row.stackId.equals(stackId) & row.id.equals(asset.id).not())
..orderBy([(row) => OrderingTerm.desc(row.createdAt)]); ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]);
return query.map((row) => row.toDto()).get(); return query.map((row) => row.toDto()).get();

View file

@ -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/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.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 @override
Future<List<RemoteAsset>> build(BaseAsset? asset) async { Future<List<RemoteAsset>> build(BaseAsset asset) {
if (asset == null || asset is! RemoteAsset || asset.stackId == null) { if (asset is! RemoteAsset || asset.stackId == null) {
return const []; return Future.value(const []);
} }
return ref.watch(assetServiceProvider).getStack(asset); return ref.watch(assetServiceProvider).getStack(asset);
@ -14,4 +14,4 @@ class StackChildrenNotifier extends AutoDisposeFamilyAsyncNotifier<List<RemoteAs
} }
final stackChildrenNotifier = AsyncNotifierProvider.autoDispose final stackChildrenNotifier = AsyncNotifierProvider.autoDispose
.family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset?>(StackChildrenNotifier.new); .family<StackChildrenNotifier, List<RemoteAsset>, BaseAsset>(StackChildrenNotifier.new);

View file

@ -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/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_stack.provider.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.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'; import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
class AssetStackRow extends ConsumerWidget { class AssetStackRow extends ConsumerWidget {
@ -11,27 +11,25 @@ class AssetStackRow extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity)); final asset = ref.watch(assetViewerProvider.select((state) => state.currentAsset));
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls)); if (asset == null) {
return const SizedBox.shrink();
if (!showControls) {
opacity = 0;
} }
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( return IgnorePointer(
ignoring: opacity < 255, ignoring: opacity < 255,
child: AnimatedOpacity( child: AnimatedOpacity(
opacity: opacity / 255, opacity: opacity / 255,
duration: Durations.short2, duration: Durations.short2,
child: ref child: _StackList(stack: stackChildren),
.watch(stackChildrenNotifier(asset))
.when(
data: (state) => SizedBox.square(dimension: 80, child: _StackList(stack: state)),
error: (_, __) => const SizedBox.shrink(),
loading: () => const SizedBox.shrink(),
),
), ),
); );
} }
@ -44,58 +42,77 @@ class _StackList extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder( return Center(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 30), child: Padding(
itemCount: stack.length, padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 20.0),
itemBuilder: (ctx, index) { child: Row(
final asset = stack[index]; mainAxisAlignment: MainAxisAlignment.center,
return Padding( spacing: 5.0,
padding: const EdgeInsets.only(right: 5), children: List.generate(stack.length, (i) {
child: GestureDetector( final asset = stack[i];
onTap: () { return _StackItem(key: ValueKey(asset.heroTag), asset: asset, index: i);
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)),
],
),
],
), ),
), ),
), ),
), );
); }
}, }
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,
),
),
); );
} }
} }

View file

@ -61,8 +61,15 @@ class AssetViewer extends ConsumerStatefulWidget {
ConsumerState createState() => _AssetViewerState(); ConsumerState createState() => _AssetViewerState();
static void setAsset(WidgetRef ref, BaseAsset asset) { static void setAsset(WidgetRef ref, BaseAsset asset) {
// Always dim the background ref.read(assetViewerProvider.notifier).reset();
ref.read(assetViewerProvider.notifier).setOpacity(255); _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 // Always holds the current asset from the timeline
ref.read(assetViewerProvider.notifier).setAsset(asset); ref.read(assetViewerProvider.notifier).setAsset(asset);
// The currentAssetNotifier actually holds the current asset that is displayed // The currentAssetNotifier actually holds the current asset that is displayed
@ -109,6 +116,8 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
ImageStream? _prevPreCacheStream; ImageStream? _prevPreCacheStream;
ImageStream? _nextPreCacheStream; ImageStream? _nextPreCacheStream;
KeepAliveLink? _stackChildrenKeepAlive;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -119,6 +128,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
WidgetsBinding.instance.addPostFrameCallback(_onAssetInit); WidgetsBinding.instance.addPostFrameCallback(_onAssetInit);
reloadSubscription = EventStream.shared.listen(_onEvent); reloadSubscription = EventStream.shared.listen(_onEvent);
heroOffset = widget.heroOffset ?? TabsRouterScope.of(context)?.controller.activeIndex ?? 0; 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 @override
@ -130,6 +143,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
_prevPreCacheStream?.removeListener(_dummyListener); _prevPreCacheStream?.removeListener(_dummyListener);
_nextPreCacheStream?.removeListener(_dummyListener); _nextPreCacheStream?.removeListener(_dummyListener);
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
_stackChildrenKeepAlive?.close();
super.dispose(); super.dispose();
} }
@ -190,9 +204,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
return; return;
} }
AssetViewer.setAsset(ref, asset); widget.changeAsset(ref, asset);
_precacheAssets(index); _precacheAssets(index);
_handleCasting(); _handleCasting();
_stackChildrenKeepAlive?.close();
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
} }
void _handleCasting() { void _handleCasting() {
@ -520,7 +536,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
BaseAsset displayAsset = asset; BaseAsset displayAsset = asset;
final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull; final stackChildren = ref.read(stackChildrenNotifier(asset)).valueOrNull;
if (stackChildren != null && stackChildren.isNotEmpty) { 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); final isPlayingMotionVideo = ref.read(isPlayingMotionVideoProvider);

View file

@ -68,12 +68,16 @@ class AssetViewerState {
stackIndex.hashCode; stackIndex.hashCode;
} }
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> { class AssetViewerStateNotifier extends Notifier<AssetViewerState> {
@override @override
AssetViewerState build() { AssetViewerState build() {
return const AssetViewerState(); return const AssetViewerState();
} }
void reset() {
state = const AssetViewerState();
}
void setAsset(BaseAsset? asset) { void setAsset(BaseAsset? asset) {
if (asset == state.currentAsset) { if (asset == state.currentAsset) {
return; return;
@ -117,6 +121,4 @@ class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
} }
} }
final assetViewerProvider = AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>( final assetViewerProvider = NotifierProvider<AssetViewerStateNotifier, AssetViewerState>(AssetViewerStateNotifier.new);
AssetViewerStateNotifier.new,
);