diff --git a/lib/widgets/common/aves_filter_chip.dart b/lib/widgets/common/aves_filter_chip.dart index b6bb5ef03..8fa097991 100644 --- a/lib/widgets/common/aves_filter_chip.dart +++ b/lib/widgets/common/aves_filter_chip.dart @@ -146,7 +146,7 @@ class _AvesFilterChipState extends State { child: Stack( fit: StackFit.passthrough, children: [ - if (widget.background != null) + if (hasBackground) ClipRRect( borderRadius: borderRadius, child: widget.background, diff --git a/lib/widgets/fullscreen/info/location_section.dart b/lib/widgets/fullscreen/info/location_section.dart index 0d25b2cff..3bfb30cf0 100644 --- a/lib/widgets/fullscreen/info/location_section.dart +++ b/lib/widgets/fullscreen/info/location_section.dart @@ -9,6 +9,7 @@ import 'package:aves/widgets/fullscreen/info/info_page.dart'; import 'package:aves/widgets/fullscreen/info/maps/common.dart'; import 'package:aves/widgets/fullscreen/info/maps/google_map.dart'; import 'package:aves/widgets/fullscreen/info/maps/leaflet_map.dart'; +import 'package:aves/widgets/fullscreen/info/maps/marker.dart'; import 'package:flutter/material.dart'; class LocationSection extends StatefulWidget { @@ -34,6 +35,9 @@ class LocationSection extends StatefulWidget { class _LocationSectionState extends State { String _loadedUri; + static const extent = 48.0; + static const pointerSize = Size(8.0, 6.0); + CollectionLens get collection => widget.collection; ImageEntry get entry => widget.entry; @@ -85,6 +89,14 @@ class _LocationSectionState extends State { if (place != null && place.isNotEmpty) filters.add(LocationFilter(LocationLevel.place, place)); } + Widget buildMarker(BuildContext context) { + return ImageMarker( + entry: entry, + extent: extent, + pointerSize: pointerSize, + ); + } + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -96,16 +108,19 @@ class _LocationSectionState extends State { }, child: settings.infoMapStyle.isGoogleMaps ? EntryGoogleMap( - markerId: entry.uri ?? entry.path, latLng: entry.latLng, geoUri: entry.geoUri, initialZoom: settings.infoMapZoom, + markerId: entry.uri ?? entry.path, + markerBuilder: buildMarker, ) : EntryLeafletMap( latLng: entry.latLng, geoUri: entry.geoUri, initialZoom: settings.infoMapZoom, style: settings.infoMapStyle, + markerSize: Size(extent, extent + pointerSize.height), + markerBuilder: buildMarker, ), ), if (entry.hasGps) diff --git a/lib/widgets/fullscreen/info/maps/google_map.dart b/lib/widgets/fullscreen/info/maps/google_map.dart index 1480e1085..2d70b77f1 100644 --- a/lib/widgets/fullscreen/info/maps/google_map.dart +++ b/lib/widgets/fullscreen/info/maps/google_map.dart @@ -1,22 +1,28 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:aves/model/settings/settings.dart'; import 'package:aves/widgets/fullscreen/info/location_section.dart'; import 'package:aves/widgets/fullscreen/info/maps/common.dart'; +import 'package:aves/widgets/fullscreen/info/maps/marker.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:tuple/tuple.dart'; class EntryGoogleMap extends StatefulWidget { - final String markerId; final LatLng latLng; final String geoUri; final double initialZoom; + final String markerId; + final WidgetBuilder markerBuilder; EntryGoogleMap({ Key key, - this.markerId, Tuple2 latLng, this.geoUri, this.initialZoom, + this.markerId, + this.markerBuilder, }) : latLng = LatLng(latLng.item1, latLng.item2), super(key: key); @@ -26,6 +32,13 @@ class EntryGoogleMap extends StatefulWidget { class EntryGoogleMapState extends State with AutomaticKeepAliveClientMixin { GoogleMapController _controller; + Completer _markerLoaderCompleter; + + @override + void initState() { + super.initState(); + _markerLoaderCompleter = Completer(); + } @override void didUpdateWidget(EntryGoogleMap oldWidget) { @@ -33,6 +46,9 @@ class EntryGoogleMapState extends State with AutomaticKeepAliveC if (widget.latLng != oldWidget.latLng && _controller != null) { _controller.moveCamera(CameraUpdate.newLatLng(widget.latLng)); } + if (widget.markerId != oldWidget.markerId) { + _markerLoaderCompleter = Completer(); + } } @override @@ -46,6 +62,11 @@ class EntryGoogleMapState extends State with AutomaticKeepAliveC super.build(context); return Stack( children: [ + MarkerGeneratorWidget( + key: Key(widget.markerId), + markers: [widget.markerBuilder(context)], + onComplete: (bitmaps) => _markerLoaderCompleter.complete(bitmaps.first), + ), MapDecorator( child: _buildMap(), ), @@ -58,34 +79,40 @@ class EntryGoogleMapState extends State with AutomaticKeepAliveC } Widget _buildMap() { - final accentHue = HSVColor.fromColor(Theme.of(context).accentColor).hue; - return GoogleMap( - // GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493 - initialCameraPosition: CameraPosition( - target: widget.latLng, - zoom: widget.initialZoom, - ), - onMapCreated: (controller) => setState(() => _controller = controller), - compassEnabled: false, - mapToolbarEnabled: false, - mapType: _toMapStyle(settings.infoMapStyle), - rotateGesturesEnabled: false, - scrollGesturesEnabled: false, - zoomControlsEnabled: false, - zoomGesturesEnabled: false, - liteModeEnabled: false, - // no camera animation in lite mode - tiltGesturesEnabled: false, - myLocationEnabled: false, - myLocationButtonEnabled: false, - markers: { - Marker( - markerId: MarkerId(widget.markerId), - icon: BitmapDescriptor.defaultMarkerWithHue(accentHue), - position: widget.latLng, - ) - }, - ); + return FutureBuilder( + future: _markerLoaderCompleter.future, + builder: (context, snapshot) { + final markers = {}; + if (!snapshot.hasError && snapshot.connectionState == ConnectionState.done) { + final markerBytes = snapshot.data; + markers.add(Marker( + markerId: MarkerId(widget.markerId), + icon: BitmapDescriptor.fromBytes(markerBytes), + position: widget.latLng, + )); + } + return GoogleMap( + // GoogleMap init perf issue: https://github.com/flutter/flutter/issues/28493 + initialCameraPosition: CameraPosition( + target: widget.latLng, + zoom: widget.initialZoom, + ), + onMapCreated: (controller) => setState(() => _controller = controller), + compassEnabled: false, + mapToolbarEnabled: false, + mapType: _toMapStyle(settings.infoMapStyle), + rotateGesturesEnabled: false, + scrollGesturesEnabled: false, + zoomControlsEnabled: false, + zoomGesturesEnabled: false, + liteModeEnabled: false, + // no camera animation in lite mode + tiltGesturesEnabled: false, + myLocationEnabled: false, + myLocationButtonEnabled: false, + markers: markers, + ); + }); } void _zoomBy(double amount) { diff --git a/lib/widgets/fullscreen/info/maps/leaflet_map.dart b/lib/widgets/fullscreen/info/maps/leaflet_map.dart index 570a71511..4b35b8d22 100644 --- a/lib/widgets/fullscreen/info/maps/leaflet_map.dart +++ b/lib/widgets/fullscreen/info/maps/leaflet_map.dart @@ -15,6 +15,8 @@ class EntryLeafletMap extends StatefulWidget { final String geoUri; final double initialZoom; final EntryMapStyle style; + final Size markerSize; + final WidgetBuilder markerBuilder; EntryLeafletMap({ Key key, @@ -22,6 +24,8 @@ class EntryLeafletMap extends StatefulWidget { this.geoUri, this.initialZoom, this.style, + this.markerBuilder, + this.markerSize, }) : latLng = LatLng(latLng.item1, latLng.item2), super(key: key); @@ -32,8 +36,6 @@ class EntryLeafletMap extends StatefulWidget { class EntryLeafletMapState extends State with AutomaticKeepAliveClientMixin, TickerProviderStateMixin { final MapController _mapController = MapController(); - static const markerSize = 40.0; - @override void didUpdateWidget(EntryLeafletMap oldWidget) { super.didUpdateWidget(oldWidget); @@ -80,16 +82,10 @@ class EntryLeafletMapState extends State with AutomaticKeepAliv options: MarkerLayerOptions( markers: [ Marker( - width: markerSize, - height: markerSize, + width: widget.markerSize.width, + height: widget.markerSize.height, point: widget.latLng, - builder: (ctx) { - return Icon( - Icons.place, - size: markerSize, - color: Theme.of(context).accentColor, - ); - }, + builder: widget.markerBuilder, anchorPos: AnchorPos.align(AnchorAlign.top), ), ], diff --git a/lib/widgets/fullscreen/info/maps/marker.dart b/lib/widgets/fullscreen/info/maps/marker.dart new file mode 100644 index 000000000..a640ae731 --- /dev/null +++ b/lib/widgets/fullscreen/info/maps/marker.dart @@ -0,0 +1,182 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:aves/model/image_entry.dart'; +import 'package:aves/widgets/collection/thumbnail/raster.dart'; +import 'package:aves/widgets/collection/thumbnail/vector.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; + +class ImageMarker extends StatelessWidget { + final ImageEntry entry; + final double extent; + final Size pointerSize; + + static const double outerBorderRadiusDim = 8; + static const double outerBorderWidth = 1.5; + static const double innerBorderWidth = 2; + static const outerBorderColor = Colors.white30; + static final innerBorderColor = Colors.grey[900]; + + const ImageMarker({ + @required this.entry, + @required this.extent, + this.pointerSize = Size.zero, + }); + + @override + Widget build(BuildContext context) { + final thumbnail = entry.isSvg + ? ThumbnailVectorImage( + entry: entry, + extent: extent, + ) + : ThumbnailRasterImage( + entry: entry, + extent: extent, + ); + + final outerBorderRadius = BorderRadius.circular(outerBorderRadiusDim); + final innerBorderRadius = BorderRadius.circular(outerBorderRadiusDim - outerBorderWidth); + + return CustomPaint( + foregroundPainter: MarkerPointerPainter( + color: innerBorderColor, + outlineColor: outerBorderColor, + outlineWidth: outerBorderWidth, + size: pointerSize, + ), + child: Padding( + padding: EdgeInsets.only(bottom: pointerSize.height), + child: Container( + decoration: BoxDecoration( + border: Border.all( + color: outerBorderColor, + width: outerBorderWidth, + ), + borderRadius: outerBorderRadius, + ), + child: DecoratedBox( + decoration: BoxDecoration( + border: Border.all( + color: innerBorderColor, + width: innerBorderWidth, + ), + borderRadius: innerBorderRadius, + ), + position: DecorationPosition.foreground, + child: ClipRRect( + borderRadius: innerBorderRadius, + child: thumbnail, + ), + ), + ), + ), + ); + } +} + +class MarkerPointerPainter extends CustomPainter { + final Color color, outlineColor; + final double outlineWidth; + final Size size; + + const MarkerPointerPainter({ + this.color, + this.outlineColor, + this.outlineWidth, + this.size, + }); + + @override + void paint(Canvas canvas, Size size) { + final pointerWidth = this.size.width; + final pointerHeight = this.size.height; + + final bottomCenter = Offset(size.width / 2, size.height); + final topLeft = bottomCenter + Offset(-pointerWidth / 2, -pointerHeight); + final topRight = bottomCenter + Offset(pointerWidth / 2, -pointerHeight); + + canvas.drawPath( + Path() + ..moveTo(bottomCenter.dx, bottomCenter.dy) + ..lineTo(topRight.dx, topRight.dy) + ..lineTo(topLeft.dx, topLeft.dy) + ..close(), + Paint()..color = outlineColor); + + canvas.translate(0, -outlineWidth.ceilToDouble()); + canvas.drawPath( + Path() + ..moveTo(bottomCenter.dx, bottomCenter.dy) + ..lineTo(topRight.dx, topRight.dy) + ..lineTo(topLeft.dx, topLeft.dy) + ..close(), + Paint()..color = color); + } + + @override + bool shouldRepaint(covariant CustomPainter oldDelegate) => false; +} + +// generate bitmap from widget, for Google Maps +class MarkerGeneratorWidget extends StatefulWidget { + final List markers; + final Duration delay; + final Function(List bitmaps) onComplete; + + const MarkerGeneratorWidget({ + Key key, + @required this.markers, + this.delay = Duration.zero, + @required this.onComplete, + }) : super(key: key); + + @override + _MarkerGeneratorWidgetState createState() => _MarkerGeneratorWidgetState(); +} + +class _MarkerGeneratorWidgetState extends State { + final _globalKeys = []; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) async { + if (widget.delay > Duration.zero) { + await Future.delayed(widget.delay); + } + widget.onComplete(await _getBitmaps(context)); + }); + } + + @override + Widget build(BuildContext context) { + return Transform.translate( + offset: Offset(MediaQuery.of(context).size.width, 0), + child: Material( + type: MaterialType.transparency, + child: Stack( + children: widget.markers.map((i) { + final key = GlobalKey(); + _globalKeys.add(key); + return RepaintBoundary( + key: key, + child: i, + ); + }).toList(), + ), + ), + ); + } + + Future> _getBitmaps(BuildContext context) async { + final pixelRatio = MediaQuery.of(context).devicePixelRatio; + return Future.wait(_globalKeys.map((key) async { + RenderRepaintBoundary boundary = key.currentContext.findRenderObject(); + final image = await boundary.toImage(pixelRatio: pixelRatio); + final byteData = await image.toByteData(format: ui.ImageByteFormat.png); + return byteData.buffer.asUint8List(); + })); + } +}