tv: fixed map

This commit is contained in:
Thibault Deckers 2023-03-28 12:24:15 +02:00
parent a70881c902
commit eda8baeda2
6 changed files with 349 additions and 272 deletions

View file

@ -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<MapAddressRow> createState() => _MapAddressRowState();
}
class _MapAddressRowState extends State<MapAddressRow> {
final ValueNotifier<String?> _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<MediaQueryData, double>((mq) => mq.textScaleFactor) * 2,
child: ValueListenableBuilder<String?>(
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<void> _updateAddress() async {
final entry = widget.entry;
final addressLine = await _getAddressLine(entry);
if (mounted && entry == widget.entry) {
_addressLineNotifier.value = addressLine;
}
}
Future<String?> _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;
}
}

View file

@ -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<MediaQueryData, bool>((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,
);
}
}

View file

@ -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<AvesEntry?> 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<MediaQueryData, Orientation>((v) => v.orientation);
return ValueListenableBuilder<AvesEntry?>(
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<MediaQueryData, double>((mq) => mq.textScaleFactor);
}

View file

@ -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<AvesEntry?> 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<MediaQueryData, Orientation>((v) => v.orientation);
return ValueListenableBuilder<AvesEntry?>(
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<MediaQueryData, double>((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<String?> _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<MediaQueryData, double>((mq) => mq.textScaleFactor) * 2,
child: ValueListenableBuilder<String?>(
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<void> _updateAddress() async {
final entry = widget.entry;
final addressLine = await _getAddressLine(entry);
if (mounted && entry == widget.entry) {
_addressLineNotifier.value = addressLine;
}
}
Future<String?> _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<MediaQueryData, bool>((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,
),
),
],
);
}
}

View file

@ -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<int?> _selectedIndexNotifier = ValueNotifier(0);
final ValueNotifier<CollectionLens?> _regionCollectionNotifier = ValueNotifier(null);
final ValueNotifier<LatLng?> _dotLocationNotifier = ValueNotifier(null);
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null), _infoEntryNotifier = ValueNotifier(null);
final ValueNotifier<AvesEntry?> _dotEntryNotifier = ValueNotifier(null);
final ValueNotifier<double> _overlayOpacityNotifier = ValueNotifier(1);
final Debouncer _infoDebouncer = Debouncer(delay: Durations.mapInfoDebounceDelay);
final ValueNotifier<bool> _overlayVisible = ValueNotifier(true);
late AnimationController _overlayAnimationController;
late Animation<double> _overlayScale, _scrollerSize;
@ -125,8 +121,6 @@ class _ContentState extends State<_Content> with SingleTickerProviderStateMixin
});
}
_dotEntryNotifier.addListener(_onSelectedEntryChanged);
_overlayAnimationController = AnimationController(
duration: context.read<DurationsData>().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<MediaQueryData, double>(
selector: (context, mq) => mq.size.width,
builder: (context, mqWidth, child) => ValueListenableBuilder<CollectionLens?>(
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<CollectionLens?>(
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;

View file

@ -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<CollectionLens?> regionCollectionNotifier;
final ValueNotifier<AvesEntry?> dotEntryNotifier;
final ValueNotifier<int?> 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<MapEntryScroller> createState() => _MapEntryScrollerState();
}
class _MapEntryScrollerState extends State<MapEntryScroller> {
final ValueNotifier<AvesEntry?> _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<MediaQueryData, double>(
selector: (context, mq) => mq.size.width,
builder: (context, mqWidth, child) => ValueListenableBuilder<CollectionLens?>(
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<CollectionLens?>(
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);
}
}
}