feat(mobile): inline asset details (#25952)

The existing implementation for showing asset details uses a bottom
sheet, and is not in sync with the preview or scroll intent. Other apps
use inline details, which is much cleaner and feels better to use.
This commit is contained in:
Thomas
2026-02-17 15:24:34 +00:00
committed by GitHub
parent 06d487782e
commit 5c6433b4ca
42 changed files with 1518 additions and 1277 deletions

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class AssetMarkerIcon extends StatelessWidget {
const AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
final pinHeight = constraints.maxHeight * 0.14;
final pinWidth = constraints.maxWidth * 0.14;
return SizedOverflowBox(
size: Size(pinWidth, pinHeight),
child: Stack(
// alignment: AlignmentGeometry.center,
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(height: pinHeight, width: pinWidth),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: RemoteImageProvider(url: imageUrl),
),
),
),
],
),
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
const _PinPainter({
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush);
canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy)
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor;
}
}

View File

@@ -7,7 +7,7 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:immich_mobile/widgets/map/asset_market_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
@@ -45,21 +45,12 @@ class MapThumbnail extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
final controller = useRef<MapLibreMapController?>(null);
final styleLoaded = useState(false);
final position = useValueNotifier<Point<num>?>(null);
Future<void> onMapCreated(MapLibreMapController mapController) async {
controller.value = mapController;
styleLoaded.value = false;
if (assetMarkerRemoteId != null) {
// The iOS impl returns wrong toScreenLocation without the delay
Future.delayed(
const Duration(milliseconds: 100),
() async => position.value = await mapController.toScreenLocation(centre),
);
}
onCreated?.call(mapController);
}
@@ -90,11 +81,11 @@ class MapThumbnail extends HookConsumerWidget {
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: Stack(
alignment: Alignment.center,
alignment: AlignmentGeometry.topCenter,
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
initialCameraPosition: CameraPosition(target: offsettedCentre, zoom: zoom),
initialCameraPosition: CameraPosition(target: centre, zoom: zoom),
styleString: style,
onMapCreated: onMapCreated,
onStyleLoadedCallback: onStyleLoaded,
@@ -109,17 +100,16 @@ class MapThumbnail extends HookConsumerWidget {
attributionButtonMargins: showAttribution == false ? const Point(-100, 0) : null,
),
),
ValueListenableBuilder(
valueListenable: position,
builder: (_, value, __) => value != null && assetMarkerRemoteId != null && assetThumbhash != null
? PositionedAssetMarkerIcon(
size: height / 2,
point: value,
assetRemoteId: assetMarkerRemoteId!,
assetThumbhash: assetThumbhash!,
)
: const SizedBox.shrink(),
),
if (assetMarkerRemoteId != null && assetThumbhash != null)
Container(
width: width,
height: height / 2,
alignment: Alignment.bottomCenter,
child: SizedBox.square(
dimension: height / 2.5,
child: AssetMarkerIcon(id: assetMarkerRemoteId!, thumbhash: assetThumbhash!),
),
),
],
),
),

View File

@@ -3,8 +3,7 @@ import 'dart:math';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/map/asset_market_icon.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
@@ -36,106 +35,9 @@ class PositionedAssetMarkerIcon extends StatelessWidget {
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: _AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
),
),
);
}
}
class _AssetMarkerIcon extends StatelessWidget {
const _AssetMarkerIcon({required this.id, required this.thumbhash, super.key});
final String id;
final String thumbhash;
@override
Widget build(BuildContext context) {
final imageUrl = getThumbnailUrlForRemoteId(id);
return LayoutBuilder(
builder: (context, constraints) {
return Stack(
children: [
Positioned(
bottom: 0,
left: constraints.maxWidth * 0.5,
child: CustomPaint(
painter: _PinPainter(
primaryColor: context.colorScheme.onSurface,
secondaryColor: context.colorScheme.surface,
primaryRadius: constraints.maxHeight * 0.06,
secondaryRadius: constraints.maxHeight * 0.038,
),
child: SizedBox(height: constraints.maxHeight * 0.14, width: constraints.maxWidth * 0.14),
),
),
Positioned(
top: constraints.maxHeight * 0.07,
left: constraints.maxWidth * 0.17,
child: CircleAvatar(
radius: constraints.maxHeight * 0.40,
backgroundColor: context.colorScheme.onSurface,
child: CircleAvatar(
radius: constraints.maxHeight * 0.37,
backgroundImage: RemoteImageProvider(url: imageUrl),
),
),
),
],
);
},
);
}
}
class _PinPainter extends CustomPainter {
final Color primaryColor;
final Color secondaryColor;
final double primaryRadius;
final double secondaryRadius;
const _PinPainter({
required this.primaryColor,
required this.secondaryColor,
required this.primaryRadius,
required this.secondaryRadius,
});
@override
void paint(Canvas canvas, Size size) {
Paint primaryBrush = Paint()
..color = primaryColor
..style = PaintingStyle.fill;
Paint secondaryBrush = Paint()
..color = secondaryColor
..style = PaintingStyle.fill;
Paint lineBrush = Paint()
..color = primaryColor
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(Offset(size.width / 2, size.height), primaryRadius, primaryBrush);
canvas.drawCircle(Offset(size.width / 2, size.height), secondaryRadius, secondaryBrush);
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
// The line is to make the above triangluar path more prominent since it has a slight curve
canvas.drawLine(Offset(size.width / 2, 0), Offset(size.width / 2, size.height), lineBrush);
}
Path getTrianglePath(double x, double y) {
final firstEndPoint = Offset(x / 2, y);
final controlPoint = Offset(x / 2, y * 0.3);
final secondEndPoint = Offset(x, 0);
return Path()
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, firstEndPoint.dx, firstEndPoint.dy)
..quadraticBezierTo(controlPoint.dx, controlPoint.dy, secondEndPoint.dx, secondEndPoint.dy)
..lineTo(0, 0);
}
@override
bool shouldRepaint(_PinPainter old) {
return old.primaryColor != primaryColor || old.secondaryColor != secondaryColor;
}
}