diff --git a/lib/widgets/map/address_row.dart b/lib/widgets/map/address_row.dart new file mode 100644 index 000000000..407e24aab --- /dev/null +++ b/lib/widgets/map/address_row.dart @@ -0,0 +1,104 @@ +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/entry/extensions/location.dart'; +import 'package:aves/model/settings/enums/coordinate_format.dart'; +import 'package:aves/model/settings/settings.dart'; +import 'package:aves/services/common/services.dart'; +import 'package:aves/services/geocoding_service.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/styles.dart'; +import 'package:aves/theme/text.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +import 'info_row.dart'; + +class MapAddressRow extends StatefulWidget { + final AvesEntry? entry; + + const MapAddressRow({ + super.key, + required this.entry, + }); + + @override + State createState() => _MapAddressRowState(); +} + +class _MapAddressRowState extends State { + final ValueNotifier _addressLineNotifier = ValueNotifier(null); + + @override + void initState() { + super.initState(); + _updateAddress(); + } + + @override + void didUpdateWidget(covariant MapAddressRow oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.entry != widget.entry) { + _updateAddress(); + } + } + + @override + Widget build(BuildContext context) { + return Container( + alignment: AlignmentDirectional.centerStart, + // addresses can include non-latin scripts with inconsistent line height, + // which is especially an issue for relayout/painting of heavy Google map, + // so we give extra height to give breathing room to the text and stabilize layout + height: Theme.of(context).textTheme.bodyMedium!.fontSize! * context.select((mq) => mq.textScaleFactor) * 2, + child: ValueListenableBuilder( + valueListenable: _addressLineNotifier, + builder: (context, addressLine, child) { + final entry = widget.entry; + final location = addressLine ?? + (entry == null + ? AText.valueNotAvailable + : entry.hasAddress + ? entry.shortAddress + : settings.coordinateFormat.format(context.l10n, entry.latLng!)); + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: MapInfoRow.iconPadding), + child: Icon(AIcons.location, size: MapInfoRow.getIconSize(context)), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: location), + ], + ), + strutStyle: AStyles.overflowStrut, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + }, + ), + ); + } + + Future _updateAddress() async { + final entry = widget.entry; + final addressLine = await _getAddressLine(entry); + if (mounted && entry == widget.entry) { + _addressLineNotifier.value = addressLine; + } + } + + Future _getAddressLine(AvesEntry? entry) async { + if (entry != null && await availability.canLocatePlaces) { + final addresses = await GeocodingService.getAddress(entry.latLng!, settings.appliedLocale); + if (addresses.isNotEmpty) { + final address = addresses.first; + return address.addressLine; + } + } + return null; + } +} diff --git a/lib/widgets/map/date_row.dart b/lib/widgets/map/date_row.dart new file mode 100644 index 000000000..430a731fc --- /dev/null +++ b/lib/widgets/map/date_row.dart @@ -0,0 +1,45 @@ +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/theme/format.dart'; +import 'package:aves/theme/icons.dart'; +import 'package:aves/theme/styles.dart'; +import 'package:aves/theme/text.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/map/info_row.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class MapDateRow extends StatelessWidget { + final AvesEntry? entry; + + const MapDateRow({ + super.key, + required this.entry, + }); + + @override + Widget build(BuildContext context) { + final locale = context.l10n.localeName; + final use24hour = context.select((v) => v.alwaysUse24HourFormat); + + final date = entry?.bestDate; + final dateText = date != null ? formatDateTime(date, locale, use24hour) : AText.valueNotAvailable; + return Text.rich( + TextSpan( + children: [ + WidgetSpan( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: MapInfoRow.iconPadding), + child: Icon(AIcons.date, size: MapInfoRow.getIconSize(context)), + ), + alignment: PlaceholderAlignment.middle, + ), + TextSpan(text: dateText), + ], + ), + strutStyle: AStyles.overflowStrut, + softWrap: false, + overflow: TextOverflow.fade, + maxLines: 1, + ); + } +} diff --git a/lib/widgets/map/info_row.dart b/lib/widgets/map/info_row.dart new file mode 100644 index 000000000..fc58e1387 --- /dev/null +++ b/lib/widgets/map/info_row.dart @@ -0,0 +1,63 @@ +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/widgets/map/address_row.dart'; +import 'package:aves/widgets/map/date_row.dart'; +import 'package:aves_map/aves_map.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class MapInfoRow extends StatelessWidget { + final ValueNotifier entryNotifier; + + static const double iconPadding = 8.0; + static const double _interRowPadding = 2.0; + + const MapInfoRow({ + super.key, + required this.entryNotifier, + }); + + @override + Widget build(BuildContext context) { + final orientation = context.select((v) => v.orientation); + + return ValueListenableBuilder( + valueListenable: entryNotifier, + builder: (context, entry, child) { + final content = orientation == Orientation.portrait + ? [ + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + MapAddressRow(entry: entry), + const SizedBox(height: _interRowPadding), + MapDateRow(entry: entry), + ], + ), + ), + ] + : [ + MapDateRow(entry: entry), + Expanded( + child: MapAddressRow(entry: entry), + ), + ]; + + return Opacity( + opacity: entry != null ? 1 : 0, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: iconPadding), + const DotMarker(), + ...content, + ], + ), + ); + }, + ); + } + + static double getIconSize(BuildContext context) => 16.0 * context.select((mq) => mq.textScaleFactor); +} diff --git a/lib/widgets/map/map_info_row.dart b/lib/widgets/map/map_info_row.dart deleted file mode 100644 index a70f20501..000000000 --- a/lib/widgets/map/map_info_row.dart +++ /dev/null @@ -1,192 +0,0 @@ -import 'package:aves/model/entry/entry.dart'; -import 'package:aves/model/entry/extensions/location.dart'; -import 'package:aves/model/settings/enums/coordinate_format.dart'; -import 'package:aves/model/settings/settings.dart'; -import 'package:aves/services/common/services.dart'; -import 'package:aves/services/geocoding_service.dart'; -import 'package:aves/theme/format.dart'; -import 'package:aves/theme/icons.dart'; -import 'package:aves/theme/styles.dart'; -import 'package:aves/theme/text.dart'; -import 'package:aves/widgets/common/extensions/build_context.dart'; -import 'package:aves_map/aves_map.dart'; -import 'package:flutter/material.dart'; -import 'package:provider/provider.dart'; - -class MapInfoRow extends StatelessWidget { - final ValueNotifier entryNotifier; - - static const double iconPadding = 8.0; - static const double _interRowPadding = 2.0; - - const MapInfoRow({ - super.key, - required this.entryNotifier, - }); - - @override - Widget build(BuildContext context) { - final orientation = context.select((v) => v.orientation); - - return ValueListenableBuilder( - valueListenable: entryNotifier, - builder: (context, entry, child) { - final content = orientation == Orientation.portrait - ? [ - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _AddressRow(entry: entry), - const SizedBox(height: _interRowPadding), - _DateRow(entry: entry), - ], - ), - ), - ] - : [ - _DateRow(entry: entry), - Expanded( - child: _AddressRow(entry: entry), - ), - ]; - - return Opacity( - opacity: entry != null ? 1 : 0, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: iconPadding), - const DotMarker(), - ...content, - ], - ), - ); - }, - ); - } - - static double getIconSize(BuildContext context) => 16.0 * context.select((mq) => mq.textScaleFactor); -} - -class _AddressRow extends StatefulWidget { - final AvesEntry? entry; - - const _AddressRow({ - required this.entry, - }); - - @override - State<_AddressRow> createState() => _AddressRowState(); -} - -class _AddressRowState extends State<_AddressRow> { - final ValueNotifier _addressLineNotifier = ValueNotifier(null); - - @override - void initState() { - super.initState(); - _updateAddress(); - } - - @override - void didUpdateWidget(covariant _AddressRow oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.entry != widget.entry) { - _updateAddress(); - } - } - - @override - Widget build(BuildContext context) { - final entry = widget.entry; - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: MapInfoRow.iconPadding), - Icon(AIcons.location, size: MapInfoRow.getIconSize(context)), - const SizedBox(width: MapInfoRow.iconPadding), - Expanded( - child: Container( - alignment: AlignmentDirectional.centerStart, - // addresses can include non-latin scripts with inconsistent line height, - // which is especially an issue for relayout/painting of heavy Google map, - // so we give extra height to give breathing room to the text and stabilize layout - height: Theme.of(context).textTheme.bodyMedium!.fontSize! * context.select((mq) => mq.textScaleFactor) * 2, - child: ValueListenableBuilder( - valueListenable: _addressLineNotifier, - builder: (context, addressLine, child) { - final location = addressLine ?? - (entry == null - ? AText.valueNotAvailable - : entry.hasAddress - ? entry.shortAddress - : settings.coordinateFormat.format(context.l10n, entry.latLng!)); - return Text( - location, - strutStyle: AStyles.overflowStrut, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ); - }, - ), - ), - ), - ], - ); - } - - Future _updateAddress() async { - final entry = widget.entry; - final addressLine = await _getAddressLine(entry); - if (mounted && entry == widget.entry) { - _addressLineNotifier.value = addressLine; - } - } - - Future _getAddressLine(AvesEntry? entry) async { - if (entry != null && await availability.canLocatePlaces) { - final addresses = await GeocodingService.getAddress(entry.latLng!, settings.appliedLocale); - if (addresses.isNotEmpty) { - final address = addresses.first; - return address.addressLine; - } - } - return null; - } -} - -class _DateRow extends StatelessWidget { - final AvesEntry? entry; - - const _DateRow({ - required this.entry, - }); - - @override - Widget build(BuildContext context) { - final locale = context.l10n.localeName; - final use24hour = context.select((v) => v.alwaysUse24HourFormat); - - final date = entry?.bestDate; - final dateText = date != null ? formatDateTime(date, locale, use24hour) : AText.valueNotAvailable; - return Row( - children: [ - const SizedBox(width: MapInfoRow.iconPadding), - Icon(AIcons.date, size: MapInfoRow.getIconSize(context)), - const SizedBox(width: MapInfoRow.iconPadding), - Expanded( - child: Text( - dateText, - strutStyle: AStyles.overflowStrut, - softWrap: false, - overflow: TextOverflow.fade, - maxLines: 1, - ), - ), - ], - ); - } -} diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index d720d1ba1..d3607e23c 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -15,7 +15,6 @@ import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/tag.dart'; import 'package:aves/theme/durations.dart'; import 'package:aves/theme/icons.dart'; -import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/collection/collection_page.dart'; import 'package:aves/widgets/collection/entry_set_action_delegate.dart'; import 'package:aves/widgets/common/basic/font_size_icon_theme.dart'; @@ -25,14 +24,12 @@ import 'package:aves/widgets/common/basic/scaffold.dart'; import 'package:aves/widgets/common/behaviour/routes.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/identity/buttons/captioned_button.dart'; -import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/map/map_action_delegate.dart'; import 'package:aves/widgets/common/providers/highlight_info_provider.dart'; import 'package:aves/widgets/common/providers/map_theme_provider.dart'; -import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/filter_grids/common/action_delegates/chip.dart'; -import 'package:aves/widgets/map/map_info_row.dart'; +import 'package:aves/widgets/map/scroller.dart'; import 'package:aves/widgets/viewer/controls/notifications.dart'; import 'package:aves/widgets/viewer/entry_viewer_page.dart'; import 'package:aves_map/aves_map.dart'; @@ -101,9 +98,8 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); final ValueNotifier _regionCollectionNotifier = ValueNotifier(null); final ValueNotifier _dotLocationNotifier = ValueNotifier(null); - final ValueNotifier _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null); + final ValueNotifier _dotEntryNotifier = ValueNotifier(null); final ValueNotifier _overlayOpacityNotifier = ValueNotifier(1); - final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); final ValueNotifier _overlayVisible = ValueNotifier(true); late AnimationController _overlayAnimationController; late Animation _overlayScale, _scrollerSize; @@ -125,8 +121,6 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin }); } - _dotEntryNotifier.addListener(_onSelectedEntryChanged); - _overlayAnimationController = AnimationController( duration: context.read().viewerOverlayAnimation, vsync: this, @@ -166,7 +160,6 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin ..forEach((sub) => sub.cancel()) ..clear(); _dotEntryNotifier.value?.metadataChangeNotifier.removeListener(_onMarkerEntryMetadataChanged); - _dotEntryNotifier.removeListener(_onSelectedEntryChanged); _overlayAnimationController.dispose(); _overlayVisible.removeListener(_onOverlayVisibleChanged); _mapController.dispose(); @@ -229,8 +222,13 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin children: [ const SizedBox(height: 8), const Divider(height: 0), - _buildOverlayController(), - _buildScroller(), + _buildOverlayControls(), + MapEntryScroller( + regionCollectionNotifier: _regionCollectionNotifier, + dotEntryNotifier: _dotEntryNotifier, + selectedIndexNotifier: _selectedIndexNotifier, + onTap: (index) => _goToViewer(_getRegionEntry(index)), + ), ], ), ), @@ -300,7 +298,7 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin return child; } - Widget _buildOverlayController() { + Widget _buildOverlayControls() { if (widget.overlayEntry == null) return const SizedBox(); return Padding( @@ -326,63 +324,6 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin ); } - Widget _buildScroller() { - return Stack( - children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SafeArea( - top: false, - bottom: false, - child: MapInfoRow(entryNotifier: _infoEntryNotifier), - ), - const SizedBox(height: 8), - Selector( - selector: (context, mq) => mq.size.width, - builder: (context, mqWidth, child) => ValueListenableBuilder( - valueListenable: _regionCollectionNotifier, - builder: (context, regionCollection, child) { - return AnimatedBuilder( - // update when entries are added/removed - animation: regionCollection ?? ChangeNotifier(), - builder: (context, child) { - final regionEntries = regionCollection?.sortedEntries ?? []; - return ThumbnailScroller( - availableWidth: mqWidth, - entryCount: regionEntries.length, - entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null, - indexNotifier: _selectedIndexNotifier, - onTap: _onThumbnailTap, - heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.id]), - highlightable: true, - showLocation: false, - ); - }, - ); - }, - ), - ), - ], - ), - Positioned.fill( - child: ValueListenableBuilder( - valueListenable: _regionCollectionNotifier, - builder: (context, regionCollection, child) { - return regionCollection != null && regionCollection.isEmpty - ? EmptyContent( - text: context.l10n.mapEmptyRegion, - alignment: Alignment.center, - fontSize: 18, - ) - : const SizedBox(); - }, - ), - ), - ], - ); - } - void _onIdle(ZoomedBounds bounds) { _regionFilter = CoordinateFilter(bounds.sw, bounds.ne); _updateRegionCollection(); @@ -432,8 +373,6 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin return null; } - void _onThumbnailTap(int index) => _goToViewer(_getRegionEntry(index)); - void _onThumbnailIndexChanged() => _onEntrySelected(_getRegionEntry(_selectedIndexNotifier.value)); void _onEntrySelected(AvesEntry? selectedEntry) { @@ -447,15 +386,6 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin _dotLocationNotifier.value = _dotEntryNotifier.value?.latLng; } - void _onSelectedEntryChanged() { - final selectedEntry = _dotEntryNotifier.value; - if (_infoEntryNotifier.value == null || selectedEntry == null) { - _infoEntryNotifier.value = selectedEntry; - } else { - _infoDebouncer(() => _infoEntryNotifier.value = selectedEntry); - } - } - void _goToViewer(AvesEntry? initialEntry) { if (initialEntry == null) return; diff --git a/lib/widgets/map/scroller.dart b/lib/widgets/map/scroller.dart new file mode 100644 index 000000000..54c9a1c6b --- /dev/null +++ b/lib/widgets/map/scroller.dart @@ -0,0 +1,127 @@ +import 'package:aves/model/entry/entry.dart'; +import 'package:aves/model/source/collection_lens.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/debouncer.dart'; +import 'package:aves/widgets/common/extensions/build_context.dart'; +import 'package:aves/widgets/common/identity/empty.dart'; +import 'package:aves/widgets/common/thumbnail/scroller.dart'; +import 'package:aves/widgets/map/info_row.dart'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; + +class MapEntryScroller extends StatefulWidget { + final ValueNotifier regionCollectionNotifier; + final ValueNotifier dotEntryNotifier; + final ValueNotifier selectedIndexNotifier; + final void Function(int index) onTap; + + const MapEntryScroller({ + super.key, + required this.regionCollectionNotifier, + required this.dotEntryNotifier, + required this.selectedIndexNotifier, + required this.onTap, + }); + + @override + State createState() => _MapEntryScrollerState(); +} + +class _MapEntryScrollerState extends State { + final ValueNotifier _infoEntryNotifier = ValueNotifier(null); + final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay); + + @override + void initState() { + super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant MapEntryScroller oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(MapEntryScroller widget) { + widget.dotEntryNotifier.addListener(_onSelectedEntryChanged); + } + + void _unregisterWidget(MapEntryScroller widget) { + widget.dotEntryNotifier.removeListener(_onSelectedEntryChanged); + } + + @override + Widget build(BuildContext context) { + return Stack( + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SafeArea( + top: false, + bottom: false, + child: MapInfoRow(entryNotifier: _infoEntryNotifier), + ), + const SizedBox(height: 8), + Selector( + selector: (context, mq) => mq.size.width, + builder: (context, mqWidth, child) => ValueListenableBuilder( + valueListenable: widget.regionCollectionNotifier, + builder: (context, regionCollection, child) { + return AnimatedBuilder( + // update when entries are added/removed + animation: regionCollection ?? ChangeNotifier(), + builder: (context, child) { + final regionEntries = regionCollection?.sortedEntries ?? []; + return ThumbnailScroller( + availableWidth: mqWidth, + entryCount: regionEntries.length, + entryBuilder: (index) => index < regionEntries.length ? regionEntries[index] : null, + indexNotifier: widget.selectedIndexNotifier, + onTap: widget.onTap, + heroTagger: (entry) => Object.hashAll([regionCollection?.id, entry.id]), + highlightable: true, + showLocation: false, + ); + }, + ); + }, + ), + ), + ], + ), + Positioned.fill( + child: ValueListenableBuilder( + valueListenable: widget.regionCollectionNotifier, + builder: (context, regionCollection, child) { + return regionCollection != null && regionCollection.isEmpty + ? EmptyContent( + text: context.l10n.mapEmptyRegion, + alignment: Alignment.center, + fontSize: 18, + ) + : const SizedBox(); + }, + ), + ), + ], + ); + } + + void _onSelectedEntryChanged() { + final selectedEntry = widget.dotEntryNotifier.value; + if (_infoEntryNotifier.value == null || selectedEntry == null) { + _infoEntryNotifier.value = selectedEntry; + } else { + _infoDebouncer(() => _infoEntryNotifier.value = selectedEntry); + } + } +}